chore: rename packages/dashboard → packages/workflow-dashboard
Align folder name with package name @uncaged/workflow-dashboard, consistent with all other packages in the monorepo.
This commit is contained in:
@@ -1,24 +0,0 @@
|
||||
# @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.
|
||||
@@ -1,12 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
const BASE = "/api";
|
||||
|
||||
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({ error: res.statusText }))) as { error: string };
|
||||
throw new Error(err.error || `API ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
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 runThread(
|
||||
workflow: string,
|
||||
prompt: string,
|
||||
maxRounds: number = 10,
|
||||
): Promise<{ threadId: string }> {
|
||||
return postJson("/threads", { workflow, prompt, maxRounds });
|
||||
}
|
||||
|
||||
export function killThread(threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(`/threads/${threadId}/kill`, {});
|
||||
}
|
||||
|
||||
export function pauseThread(threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(`/threads/${threadId}/pause`, {});
|
||||
}
|
||||
|
||||
export function resumeThread(threadId: string): Promise<{ ok: boolean }> {
|
||||
return postJson(`/threads/${threadId}/resume`, {});
|
||||
}
|
||||
|
||||
export function getHealth(): Promise<{ ok: boolean }> {
|
||||
return fetchJson("/healthz");
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { RunDialog } from "./components/run-dialog.tsx";
|
||||
import { Sidebar } from "./components/sidebar.tsx";
|
||||
import { StatusBar } from "./components/status-bar.tsx";
|
||||
import { ThreadDetail } from "./components/thread-detail.tsx";
|
||||
import { ThreadList } from "./components/thread-list.tsx";
|
||||
import { WorkflowList } from "./components/workflow-list.tsx";
|
||||
import { useHashRoute } from "./use-hash-route.ts";
|
||||
|
||||
export function App() {
|
||||
const { view, threadId, setView, setThreadId } = useHashRoute();
|
||||
const [showRun, setShowRun] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar view={view} onViewChange={setView} />
|
||||
<main className="flex-1 overflow-hidden flex flex-col">
|
||||
<StatusBar onRun={() => setShowRun(true)} />
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{view === "threads" && threadId === null && <ThreadList onSelect={setThreadId} />}
|
||||
{view === "threads" && threadId !== null && (
|
||||
<ThreadDetail threadId={threadId} onBack={() => setThreadId(null)} />
|
||||
)}
|
||||
{view === "workflows" && <WorkflowList />}
|
||||
</div>
|
||||
</main>
|
||||
{showRun && (
|
||||
<RunDialog
|
||||
onClose={() => setShowRun(false)}
|
||||
onCreated={(id) => {
|
||||
setShowRun(false);
|
||||
setThreadId(id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { listWorkflows, runThread } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
|
||||
type Props = {
|
||||
onClose: () => void;
|
||||
onCreated: (threadId: string) => void;
|
||||
};
|
||||
|
||||
export function RunDialog({ onClose, onCreated }: Props) {
|
||||
const workflows = useFetch(() => listWorkflows(), []);
|
||||
const [workflow, setWorkflow] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [maxRounds, setMaxRounds] = useState(10);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!workflow || !prompt) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await runThread(workflow, prompt, maxRounds);
|
||||
onCreated(result.threadId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center z-50"
|
||||
style={{ background: "rgba(0,0,0,0.6)" }}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg p-6 rounded-lg border"
|
||||
style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-4">Run Thread</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="run-workflow"
|
||||
className="text-sm block mb-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Workflow
|
||||
</label>
|
||||
<select
|
||||
id="run-workflow"
|
||||
value={workflow}
|
||||
onChange={(e) => setWorkflow(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded border text-sm"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
>
|
||||
<option value="">Select a workflow...</option>
|
||||
{workflows.status === "ok" &&
|
||||
workflows.data.workflows.map((w) => (
|
||||
<option key={w.name} value={w.name}>
|
||||
{w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="run-prompt"
|
||||
className="text-sm block mb-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Prompt
|
||||
</label>
|
||||
<textarea
|
||||
id="run-prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 rounded border text-sm"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
placeholder="Enter the task prompt..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="run-max-rounds"
|
||||
className="text-sm block mb-1"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Max Rounds
|
||||
</label>
|
||||
<input
|
||||
id="run-max-rounds"
|
||||
type="number"
|
||||
value={maxRounds}
|
||||
onChange={(e) => setMaxRounds(Number(e.target.value))}
|
||||
min={1}
|
||||
max={100}
|
||||
className="w-24 px-3 py-2 rounded border text-sm"
|
||||
style={{
|
||||
background: "var(--color-bg)",
|
||||
borderColor: "var(--color-border)",
|
||||
color: "var(--color-text)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm" style={{ color: "var(--color-error)" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded border"
|
||||
style={{ borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !workflow || !prompt}
|
||||
className="px-4 py-2 text-sm rounded"
|
||||
style={{
|
||||
background: submitting ? "var(--color-accent-dim)" : "var(--color-accent)",
|
||||
color: "#fff",
|
||||
opacity: !workflow || !prompt ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{submitting ? "Starting..." : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
type Props = {
|
||||
view: "threads" | "workflows";
|
||||
onViewChange: (v: "threads" | "workflows") => 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
|
||||
type="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>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { getHealth } from "../api.ts";
|
||||
|
||||
type HealthStatus = "connected" | "disconnected" | "reconnecting";
|
||||
|
||||
type Props = {
|
||||
onRun: () => void;
|
||||
};
|
||||
|
||||
function statusLabel(status: HealthStatus): { text: string; color: string } {
|
||||
if (status === "connected") {
|
||||
return { text: "● Connected", color: "var(--color-success)" };
|
||||
}
|
||||
if (status === "reconnecting") {
|
||||
return { text: "● Reconnecting...", color: "var(--color-warning, #f59e0b)" };
|
||||
}
|
||||
return { text: "● Offline", color: "var(--color-error)" };
|
||||
}
|
||||
|
||||
export function StatusBar({ onRun }: Props) {
|
||||
const [status, setStatus] = useState<HealthStatus>("disconnected");
|
||||
const wasConnectedRef = useRef(false);
|
||||
|
||||
const checkHealth = useCallback(async () => {
|
||||
try {
|
||||
await getHealth();
|
||||
wasConnectedRef.current = true;
|
||||
setStatus("connected");
|
||||
} catch {
|
||||
if (wasConnectedRef.current) {
|
||||
setStatus("reconnecting");
|
||||
} else {
|
||||
setStatus("disconnected");
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
checkHealth();
|
||||
const interval = setInterval(checkHealth, 10_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [checkHealth]);
|
||||
|
||||
const label = statusLabel(status);
|
||||
|
||||
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)" }}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span style={{ color: "var(--color-text-muted)" }}>Local API: 127.0.0.1:7860</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRun}
|
||||
className="px-3 py-1 rounded text-xs font-medium"
|
||||
style={{ background: "var(--color-accent)", color: "#fff" }}
|
||||
>
|
||||
▶ Run Thread
|
||||
</button>
|
||||
</div>
|
||||
<span style={{ color: label.color }}>{label.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getThread, killThread, pauseThread, resumeThread } from "../api.ts";
|
||||
import { useFetch } from "../hooks.ts";
|
||||
import { useSSE } from "../use-sse.ts";
|
||||
|
||||
type Props = {
|
||||
threadId: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export function ThreadDetail({ threadId, onBack }: Props) {
|
||||
const sse = useSSE(threadId);
|
||||
const { status, data, error } = useFetch(() => getThread(threadId), [threadId]);
|
||||
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||
const recordsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const liveActive = sse.connected && !sse.completed;
|
||||
const records = liveActive
|
||||
? sse.records
|
||||
: status === "ok"
|
||||
? data.records
|
||||
: ([] as typeof sse.records);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the rendered record list grows
|
||||
useEffect(() => {
|
||||
recordsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [records.length]);
|
||||
|
||||
async function handleAction(action: "kill" | "pause" | "resume") {
|
||||
setActionStatus(`${action}ing...`);
|
||||
try {
|
||||
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
|
||||
await fn(threadId);
|
||||
setActionStatus(`${action} sent ✓`);
|
||||
} catch (e) {
|
||||
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-sm hover:underline"
|
||||
style={{ color: "var(--color-accent)" }}
|
||||
>
|
||||
← Back to threads
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction("pause")}
|
||||
className="px-3 py-1 text-xs rounded border"
|
||||
style={{ borderColor: "var(--color-warning)", color: "var(--color-warning)" }}
|
||||
>
|
||||
⏸ Pause
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction("resume")}
|
||||
className="px-3 py-1 text-xs rounded border"
|
||||
style={{ borderColor: "var(--color-success)", color: "var(--color-success)" }}
|
||||
>
|
||||
▶ Resume
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction("kill")}
|
||||
className="px-3 py-1 text-xs rounded border"
|
||||
style={{ borderColor: "var(--color-error)", color: "var(--color-error)" }}
|
||||
>
|
||||
✕ Kill
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-2 font-mono flex items-center gap-2 flex-wrap">
|
||||
<span>{threadId}</span>
|
||||
{sse.connected && (
|
||||
<span
|
||||
className="text-xs font-medium px-2 py-0.5 rounded"
|
||||
style={{ background: "var(--color-success)", color: "var(--color-bg)" }}
|
||||
>
|
||||
Live
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{actionStatus && (
|
||||
<p className="text-xs mb-4" style={{ color: "var(--color-text-muted)" }}>
|
||||
{actionStatus}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === "loading" && !liveActive && records.length === 0 && (
|
||||
<p style={{ color: "var(--color-text-muted)" }}>Loading...</p>
|
||||
)}
|
||||
{status === "error" && !liveActive && (
|
||||
<p style={{ color: "var(--color-error)" }}>Error: {error}</p>
|
||||
)}
|
||||
{(status === "ok" || liveActive || records.length > 0) && (
|
||||
<div className="space-y-3">
|
||||
{records.map((r) => (
|
||||
<div
|
||||
key={`${threadId}-${r.type}-${String(r.timestamp)}-${r.role ?? ""}-${r.content ?? ""}`}
|
||||
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 !== null && (
|
||||
<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 ref={recordsEndRef} aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
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
|
||||
type="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>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
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;
|
||||
};
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: this helper intentionally accepts caller-provided dependency arrays
|
||||
}, deps);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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>,
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type View = "threads" | "workflows";
|
||||
|
||||
type HashRoute = {
|
||||
view: View;
|
||||
threadId: string | null;
|
||||
};
|
||||
|
||||
function parseHash(hash: string): HashRoute {
|
||||
const raw = hash.replace(/^#\/?/, "");
|
||||
if (raw.startsWith("threads/")) {
|
||||
const id = raw.slice("threads/".length);
|
||||
if (id.length > 0) {
|
||||
return { view: "threads", threadId: id };
|
||||
}
|
||||
}
|
||||
if (raw === "workflows") {
|
||||
return { view: "workflows", threadId: null };
|
||||
}
|
||||
return { view: "threads", threadId: null };
|
||||
}
|
||||
|
||||
function buildHash(route: HashRoute): string {
|
||||
if (route.view === "workflows") {
|
||||
return "#workflows";
|
||||
}
|
||||
if (route.threadId !== null) {
|
||||
return `#threads/${route.threadId}`;
|
||||
}
|
||||
return "#threads";
|
||||
}
|
||||
|
||||
export function useHashRoute(): {
|
||||
view: View;
|
||||
threadId: string | null;
|
||||
setView: (v: View) => void;
|
||||
setThreadId: (id: string | null) => void;
|
||||
} {
|
||||
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
|
||||
|
||||
useEffect(() => {
|
||||
function onHashChange(): void {
|
||||
setRoute(parseHash(window.location.hash));
|
||||
}
|
||||
window.addEventListener("hashchange", onHashChange);
|
||||
return () => window.removeEventListener("hashchange", onHashChange);
|
||||
}, []);
|
||||
|
||||
const navigate = useCallback((next: HashRoute) => {
|
||||
const hash = buildHash(next);
|
||||
window.location.hash = hash;
|
||||
setRoute(next);
|
||||
}, []);
|
||||
|
||||
const setView = useCallback((v: View) => navigate({ view: v, threadId: null }), [navigate]);
|
||||
|
||||
const setThreadId = useCallback(
|
||||
(id: string | null) => navigate({ view: "threads", threadId: id }),
|
||||
[navigate],
|
||||
);
|
||||
|
||||
return { view: route.view, threadId: route.threadId, setView, setThreadId };
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import {
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type SetStateAction,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import type { ThreadRecord } from "./api.ts";
|
||||
|
||||
export type UseSSEReturn = {
|
||||
records: ThreadRecord[];
|
||||
connected: boolean;
|
||||
completed: boolean;
|
||||
};
|
||||
|
||||
function isWorkflowResult(record: ThreadRecord): boolean {
|
||||
return record.type === "workflow-result";
|
||||
}
|
||||
|
||||
function parseRecord(data: string): ThreadRecord | null {
|
||||
try {
|
||||
return JSON.parse(data) as ThreadRecord;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type RecordEventContext = {
|
||||
cancelled: boolean;
|
||||
completedRef: MutableRefObject<boolean>;
|
||||
setRecords: Dispatch<SetStateAction<ThreadRecord[]>>;
|
||||
setCompleted: (value: boolean) => void;
|
||||
setConnected: (value: boolean) => void;
|
||||
cleanupEs: () => void;
|
||||
};
|
||||
|
||||
function handleRecordEvent(ev: Event, ctx: RecordEventContext): void {
|
||||
if (ctx.cancelled) {
|
||||
return;
|
||||
}
|
||||
const msg = ev as MessageEvent;
|
||||
const raw = typeof msg.data === "string" ? msg.data : "";
|
||||
const parsed = parseRecord(raw);
|
||||
if (parsed === null) {
|
||||
return;
|
||||
}
|
||||
ctx.setRecords((prev) => [...prev, parsed]);
|
||||
if (!isWorkflowResult(parsed)) {
|
||||
return;
|
||||
}
|
||||
ctx.completedRef.current = true;
|
||||
ctx.setCompleted(true);
|
||||
ctx.setConnected(false);
|
||||
ctx.cleanupEs();
|
||||
}
|
||||
|
||||
export function useSSE(threadId: string | null): UseSSEReturn {
|
||||
const [records, setRecords] = useState<ThreadRecord[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
const completedRef = useRef(false);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (threadId === null) {
|
||||
completedRef.current = false;
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setRecords([]);
|
||||
setConnected(false);
|
||||
setCompleted(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const tid = threadId;
|
||||
|
||||
completedRef.current = false;
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setRecords([]);
|
||||
setConnected(false);
|
||||
setCompleted(false);
|
||||
|
||||
let es: EventSource | null = null;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
function cleanupEs(): void {
|
||||
if (es !== null) {
|
||||
es.close();
|
||||
es = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (cancelled || completedRef.current) {
|
||||
return;
|
||||
}
|
||||
const delayMs = Math.min(1000 * 2 ** reconnectAttemptsRef.current, 8000);
|
||||
reconnectAttemptsRef.current += 1;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
if (!cancelled && !completedRef.current) {
|
||||
connect();
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
if (cancelled || completedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupEs();
|
||||
const url = `/api/threads/${encodeURIComponent(tid)}/live`;
|
||||
es = new EventSource(url);
|
||||
|
||||
es.onopen = () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setConnected(true);
|
||||
setRecords([]);
|
||||
};
|
||||
|
||||
es.addEventListener("record", (ev: Event) =>
|
||||
handleRecordEvent(ev, {
|
||||
cancelled,
|
||||
completedRef,
|
||||
setRecords,
|
||||
setCompleted,
|
||||
setConnected,
|
||||
cleanupEs,
|
||||
}),
|
||||
);
|
||||
|
||||
es.onerror = () => {
|
||||
if (cancelled || completedRef.current) {
|
||||
return;
|
||||
}
|
||||
setConnected(false);
|
||||
cleanupEs();
|
||||
scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (reconnectTimer !== null) {
|
||||
clearTimeout(reconnectTimer);
|
||||
}
|
||||
cleanupEs();
|
||||
};
|
||||
}, [threadId]);
|
||||
|
||||
return { records, connected, completed };
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// biome-ignore lint/style/noDefaultExport: Vite loads config from default export.
|
||||
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