feat(dashboard): workflow dashboard

This commit is contained in:
2026-05-08 14:43:25 +08:00
parent 46b552ec01
commit ce0d0a962c
15 changed files with 476 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# @uncaged/workflow-dashboard
Web dashboard for the Uncaged Workflow engine. Connects to the local
`uncaged-workflow serve` API to display threads, workflows, and CAS data.
## Development
```bash
# Start the local API server (in another terminal)
uncaged-workflow serve
# Start the dashboard dev server
bun run dev
```
Opens at http://localhost:5173. Vite proxies `/api/*` to `localhost:7860`.
## Build
```bash
bun run build
```
Output goes to `dist/` — static files ready for CF Pages or any host.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workflow Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@uncaged/workflow-dashboard",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"tailwindcss": "^4.2.4",
"typescript": "^6.0.3",
"vite": "^8.0.11"
}
}
+51
View File
@@ -0,0 +1,51 @@
const BASE = "/api";
async function fetchJson<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`);
if (!res.ok) {
throw new Error(`API ${res.status}: ${path}`);
}
return res.json() as Promise<T>;
}
export type WorkflowSummary = {
name: string;
currentHash: string;
versions: number;
};
export type ThreadSummary = {
threadId: string;
workflow: string | null;
hash: string | null;
startedAt: string | null;
status: string | null;
};
export type ThreadRecord = {
type: string;
role: string | null;
content: string | null;
timestamp: number | null;
[key: string]: unknown;
};
export function listWorkflows(): Promise<{ workflows: WorkflowSummary[] }> {
return fetchJson("/workflows");
}
export function listThreads(): Promise<{ threads: ThreadSummary[] }> {
return fetchJson("/threads");
}
export function listRunningThreads(): Promise<{ threads: ThreadSummary[] }> {
return fetchJson("/threads/running");
}
export function getThread(id: string): Promise<{ records: ThreadRecord[] }> {
return fetchJson(`/threads/${id}`);
}
export function getHealth(): Promise<{ ok: boolean }> {
return fetchJson("/healthz");
}
+31
View File
@@ -0,0 +1,31 @@
import { useState } from "react";
import { Sidebar } from "./components/sidebar.tsx";
import { ThreadList } from "./components/thread-list.tsx";
import { ThreadDetail } from "./components/thread-detail.tsx";
import { WorkflowList } from "./components/workflow-list.tsx";
import { StatusBar } from "./components/status-bar.tsx";
type View = "threads" | "workflows";
export function App() {
const [view, setView] = useState<View>("threads");
const [selectedThread, setSelectedThread] = useState<string | null>(null);
return (
<div className="flex h-screen">
<Sidebar view={view} onViewChange={setView} onBack={() => setSelectedThread(null)} />
<main className="flex-1 overflow-hidden flex flex-col">
<StatusBar />
<div className="flex-1 overflow-auto p-6">
{view === "threads" && !selectedThread && (
<ThreadList onSelect={setSelectedThread} />
)}
{view === "threads" && selectedThread && (
<ThreadDetail threadId={selectedThread} onBack={() => setSelectedThread(null)} />
)}
{view === "workflows" && <WorkflowList />}
</div>
</main>
</div>
);
}
@@ -0,0 +1,38 @@
type Props = {
view: "threads" | "workflows";
onViewChange: (v: "threads" | "workflows") => void;
onBack: () => void;
};
export function Sidebar({ view, onViewChange }: Props) {
const items = [
{ key: "threads" as const, label: "Threads", icon: "⚡" },
{ key: "workflows" as const, label: "Workflows", icon: "📦" },
];
return (
<aside className="w-56 border-r flex flex-col" style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}>
<div className="p-4 border-b" style={{ borderColor: "var(--color-border)" }}>
<h1 className="text-lg font-semibold" style={{ color: "var(--color-accent)" }}>
Workflow
</h1>
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>Dashboard</p>
</div>
<nav className="flex-1 p-2 space-y-1">
{items.map((item) => (
<button
key={item.key}
onClick={() => onViewChange(item.key)}
className="w-full text-left px-3 py-2 rounded text-sm transition-colors"
style={{
background: view === item.key ? "var(--color-accent-dim)" : "transparent",
color: view === item.key ? "#fff" : "var(--color-text-muted)",
}}
>
{item.icon} {item.label}
</button>
))}
</nav>
</aside>
);
}
@@ -0,0 +1,24 @@
import { getHealth } from "../api.ts";
import { useFetch } from "../hooks.ts";
export function StatusBar() {
const health = useFetch(() => getHealth(), []);
return (
<div
className="flex items-center justify-between px-6 py-2 text-xs border-b"
style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<span style={{ color: "var(--color-text-muted)" }}>Local API: 127.0.0.1:7860</span>
<span>
{health.status === "loading" && "⏳ Connecting..."}
{health.status === "ok" && (
<span style={{ color: "var(--color-success)" }}> Connected</span>
)}
{health.status === "error" && (
<span style={{ color: "var(--color-error)" }}> Offline</span>
)}
</span>
</div>
);
}
@@ -0,0 +1,62 @@
import { getThread } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
threadId: string;
onBack: () => void;
};
export function ThreadDetail({ threadId, onBack }: Props) {
const { status, data, error } = useFetch(() => getThread(threadId), [threadId]);
return (
<div>
<button
onClick={onBack}
className="text-sm mb-4 hover:underline"
style={{ color: "var(--color-accent)" }}
>
Back to threads
</button>
<h2 className="text-xl font-semibold mb-4 font-mono">{threadId}</h2>
{status === "loading" && <p style={{ color: "var(--color-text-muted)" }}>Loading...</p>}
{status === "error" && <p style={{ color: "var(--color-error)" }}>Error: {error}</p>}
{status === "ok" && (
<div className="space-y-3">
{data.records.map((r, i) => (
<div
key={i}
className="p-3 rounded border text-sm"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 mb-1">
<span
className="text-xs px-1.5 py-0.5 rounded font-mono"
style={{ background: "var(--color-border)", color: "var(--color-accent)" }}
>
{r.type}
</span>
{r.role && (
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{r.role}
</span>
)}
{r.timestamp && (
<span className="text-xs ml-auto" style={{ color: "var(--color-text-muted)" }}>
{new Date(r.timestamp).toLocaleTimeString()}
</span>
)}
</div>
{r.content && (
<pre className="whitespace-pre-wrap text-xs mt-1" style={{ color: "var(--color-text)" }}>
{typeof r.content === "string" ? r.content : JSON.stringify(r.content, null, 2)}
</pre>
)}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,67 @@
import { listThreads } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
onSelect: (id: string) => void;
};
export function ThreadList({ onSelect }: Props) {
const { status, data, error } = useFetch(() => listThreads(), []);
if (status === "loading") return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
const threads = data.threads;
return (
<div>
<h2 className="text-xl font-semibold mb-4">Threads</h2>
{threads.length === 0 ? (
<p style={{ color: "var(--color-text-muted)" }}>No threads found.</p>
) : (
<div className="space-y-2">
{threads.map((t) => (
<button
key={t.threadId}
onClick={() => onSelect(t.threadId)}
className="w-full text-left p-4 rounded-lg border transition-colors hover:border-[var(--color-accent-dim)]"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center justify-between">
<code className="text-sm font-mono" style={{ color: "var(--color-accent)" }}>
{t.threadId}
</code>
{t.status && (
<span
className="text-xs px-2 py-0.5 rounded"
style={{
background:
t.status === "running"
? "var(--color-success)"
: t.status === "failed"
? "var(--color-error)"
: "var(--color-text-muted)",
color: "#000",
}}
>
{t.status}
</span>
)}
</div>
{t.workflow && (
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
{t.workflow}
</p>
)}
{t.startedAt && (
<p className="text-xs mt-1" style={{ color: "var(--color-text-muted)" }}>
{t.startedAt}
</p>
)}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,40 @@
import { listWorkflows } from "../api.ts";
import { useFetch } from "../hooks.ts";
export function WorkflowList() {
const { status, data, error } = useFetch(() => listWorkflows(), []);
if (status === "loading") return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
if (status === "error") return <p style={{ color: "var(--color-error)" }}>Error: {error}</p>;
const workflows = data.workflows;
return (
<div>
<h2 className="text-xl font-semibold mb-4">Workflows</h2>
{workflows.length === 0 ? (
<p style={{ color: "var(--color-text-muted)" }}>No workflows registered.</p>
) : (
<div className="space-y-2">
{workflows.map((w) => (
<div
key={w.name}
className="p-4 rounded-lg border"
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
>
<div className="flex items-center justify-between">
<span className="font-medium">{w.name}</span>
<span className="text-xs" style={{ color: "var(--color-text-muted)" }}>
{w.versions} version{w.versions !== 1 ? "s" : ""}
</span>
</div>
<code className="text-xs mt-1 block font-mono" style={{ color: "var(--color-accent)" }}>
{w.currentHash}
</code>
</div>
))}
</div>
)}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
type FetchState<T> =
| { status: "loading"; data: null; error: null }
| { status: "ok"; data: T; error: null }
| { status: "error"; data: null; error: string };
export function useFetch<T>(fetcher: () => Promise<T>, deps: unknown[] = []): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
status: "loading",
data: null,
error: null,
});
useEffect(() => {
let cancelled = false;
setState({ status: "loading", data: null, error: null });
fetcher()
.then((data) => {
if (!cancelled) setState({ status: "ok", data, error: null });
})
.catch((err: unknown) => {
if (!cancelled)
setState({
status: "error",
data: null,
error: err instanceof Error ? err.message : String(err),
});
});
return () => {
cancelled = true;
};
}, deps);
return state;
}
+21
View File
@@ -0,0 +1,21 @@
@import "tailwindcss";
:root {
--color-bg: #0a0a0f;
--color-surface: #12121a;
--color-border: #1e1e2e;
--color-text: #e4e4ef;
--color-text-muted: #6b6b8a;
--color-accent: #7c6df0;
--color-accent-dim: #5a4db8;
--color-success: #34d399;
--color-warning: #fbbf24;
--color-error: #f87171;
}
body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: "Inter", system-ui, -apple-system, sans-serif;
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { App } from "./app.tsx";
const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src"]
}
+16
View File
@@ -0,0 +1,16 @@
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:7860",
changeOrigin: true,
},
},
},
});