feat(dashboard): workflow dashboard
This commit is contained in:
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user