diff --git a/packages/dashboard/README.md b/packages/dashboard/README.md new file mode 100644 index 0000000..a95b3fd --- /dev/null +++ b/packages/dashboard/README.md @@ -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. diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html new file mode 100644 index 0000000..6621a30 --- /dev/null +++ b/packages/dashboard/index.html @@ -0,0 +1,12 @@ + + + + + + Workflow Dashboard + + +
+ + + diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json new file mode 100644 index 0000000..d0bdfc7 --- /dev/null +++ b/packages/dashboard/package.json @@ -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" + } +} diff --git a/packages/dashboard/src/api.ts b/packages/dashboard/src/api.ts new file mode 100644 index 0000000..c7ef011 --- /dev/null +++ b/packages/dashboard/src/api.ts @@ -0,0 +1,51 @@ +const BASE = "/api"; + +async function fetchJson(path: string): Promise { + const res = await fetch(`${BASE}${path}`); + if (!res.ok) { + throw new Error(`API ${res.status}: ${path}`); + } + return res.json() as Promise; +} + +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"); +} diff --git a/packages/dashboard/src/app.tsx b/packages/dashboard/src/app.tsx new file mode 100644 index 0000000..7173761 --- /dev/null +++ b/packages/dashboard/src/app.tsx @@ -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("threads"); + const [selectedThread, setSelectedThread] = useState(null); + + return ( +
+ setSelectedThread(null)} /> +
+ +
+ {view === "threads" && !selectedThread && ( + + )} + {view === "threads" && selectedThread && ( + setSelectedThread(null)} /> + )} + {view === "workflows" && } +
+
+
+ ); +} diff --git a/packages/dashboard/src/components/sidebar.tsx b/packages/dashboard/src/components/sidebar.tsx new file mode 100644 index 0000000..ceb8e90 --- /dev/null +++ b/packages/dashboard/src/components/sidebar.tsx @@ -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 ( + + ); +} diff --git a/packages/dashboard/src/components/status-bar.tsx b/packages/dashboard/src/components/status-bar.tsx new file mode 100644 index 0000000..9ae3cc6 --- /dev/null +++ b/packages/dashboard/src/components/status-bar.tsx @@ -0,0 +1,24 @@ +import { getHealth } from "../api.ts"; +import { useFetch } from "../hooks.ts"; + +export function StatusBar() { + const health = useFetch(() => getHealth(), []); + + return ( +
+ Local API: 127.0.0.1:7860 + + {health.status === "loading" && "⏳ Connecting..."} + {health.status === "ok" && ( + ● Connected + )} + {health.status === "error" && ( + ● Offline + )} + +
+ ); +} diff --git a/packages/dashboard/src/components/thread-detail.tsx b/packages/dashboard/src/components/thread-detail.tsx new file mode 100644 index 0000000..c9c76d9 --- /dev/null +++ b/packages/dashboard/src/components/thread-detail.tsx @@ -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 ( +
+ +

{threadId}

+ + {status === "loading" &&

Loading...

} + {status === "error" &&

Error: {error}

} + {status === "ok" && ( +
+ {data.records.map((r, i) => ( +
+
+ + {r.type} + + {r.role && ( + + {r.role} + + )} + {r.timestamp && ( + + {new Date(r.timestamp).toLocaleTimeString()} + + )} +
+ {r.content && ( +
+                  {typeof r.content === "string" ? r.content : JSON.stringify(r.content, null, 2)}
+                
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/packages/dashboard/src/components/thread-list.tsx b/packages/dashboard/src/components/thread-list.tsx new file mode 100644 index 0000000..1e71cb7 --- /dev/null +++ b/packages/dashboard/src/components/thread-list.tsx @@ -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

Loading threads...

; + if (status === "error") return

Error: {error}

; + + const threads = data.threads; + + return ( +
+

Threads

+ {threads.length === 0 ? ( +

No threads found.

+ ) : ( +
+ {threads.map((t) => ( + + ))} +
+ )} +
+ ); +} diff --git a/packages/dashboard/src/components/workflow-list.tsx b/packages/dashboard/src/components/workflow-list.tsx new file mode 100644 index 0000000..8023db7 --- /dev/null +++ b/packages/dashboard/src/components/workflow-list.tsx @@ -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

Loading workflows...

; + if (status === "error") return

Error: {error}

; + + const workflows = data.workflows; + + return ( +
+

Workflows

+ {workflows.length === 0 ? ( +

No workflows registered.

+ ) : ( +
+ {workflows.map((w) => ( +
+
+ {w.name} + + {w.versions} version{w.versions !== 1 ? "s" : ""} + +
+ + {w.currentHash} + +
+ ))} +
+ )} +
+ ); +} diff --git a/packages/dashboard/src/hooks.ts b/packages/dashboard/src/hooks.ts new file mode 100644 index 0000000..ebddd2a --- /dev/null +++ b/packages/dashboard/src/hooks.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from "react"; + +type FetchState = + | { status: "loading"; data: null; error: null } + | { status: "ok"; data: T; error: null } + | { status: "error"; data: null; error: string }; + +export function useFetch(fetcher: () => Promise, deps: unknown[] = []): FetchState { + const [state, setState] = useState>({ + 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; +} diff --git a/packages/dashboard/src/index.css b/packages/dashboard/src/index.css new file mode 100644 index 0000000..537e9ea --- /dev/null +++ b/packages/dashboard/src/index.css @@ -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; +} diff --git a/packages/dashboard/src/main.tsx b/packages/dashboard/src/main.tsx new file mode 100644 index 0000000..aeff74c --- /dev/null +++ b/packages/dashboard/src/main.tsx @@ -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( + + + , + ); +} diff --git a/packages/dashboard/tsconfig.json b/packages/dashboard/tsconfig.json new file mode 100644 index 0000000..6790d00 --- /dev/null +++ b/packages/dashboard/tsconfig.json @@ -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"] +} diff --git a/packages/dashboard/vite.config.ts b/packages/dashboard/vite.config.ts new file mode 100644 index 0000000..68ca093 --- /dev/null +++ b/packages/dashboard/vite.config.ts @@ -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, + }, + }, + }, +});