feat: dashboard multi-agent support + CF Pages deploy

Phase C of #164:
- Dashboard fetches agents from gateway /endpoints
- Sidebar shows agent selector with online/offline status
- All API calls routed through gateway /api/:agent/*
- Hash routing: #agent/threads/id format
- SSE live streaming via gateway proxy
- VITE_GATEWAY_URL env var for gateway configuration
- Deployed to CF Pages: workflow-dashboard-54r.pages.dev
- Custom domain: workflow.shazhou.work (pending SSL)

Ref: #164, closes #167

小橘 🍊(NEKO Team)
This commit is contained in:
2026-05-09 10:01:27 +00:00
parent fd8943f131
commit 9e98119145
10 changed files with 221 additions and 73 deletions
+49 -22
View File
@@ -1,7 +1,15 @@
const BASE = "/api";
const GATEWAY_URL = import.meta.env.VITE_GATEWAY_URL || "";
async function postJson<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
function agentBase(agent: string): string {
if (GATEWAY_URL) {
return `${GATEWAY_URL}/api/${agent}`;
}
// Local dev: proxy via vite, no agent prefix
return "/api";
}
async function postJson<T>(base: string, path: string, body: unknown): Promise<T> {
const res = await fetch(`${base}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -13,14 +21,23 @@ async function postJson<T>(path: string, body: unknown): Promise<T> {
return res.json() as Promise<T>;
}
async function fetchJson<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`);
async function fetchJson<T>(base: string, 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>;
}
// ── Endpoint types ──────────────────────────────────────────────────
export type AgentEndpoint = {
name: string;
url: string;
status: string;
lastHeartbeat: number;
};
export type WorkflowSummary = {
name: string;
currentHash: string;
@@ -43,42 +60,52 @@ export type ThreadRecord = {
[key: string]: unknown;
};
export function listWorkflows(): Promise<{ workflows: WorkflowSummary[] }> {
return fetchJson("/workflows");
// ── Gateway endpoints ───────────────────────────────────────────────
export function listAgents(): Promise<AgentEndpoint[]> {
const url = GATEWAY_URL || "";
return fetchJson(url, "/endpoints");
}
export function listThreads(): Promise<{ threads: ThreadSummary[] }> {
return fetchJson("/threads");
// ── Agent-scoped endpoints ──────────────────────────────────────────
export function listWorkflows(agent: string): Promise<{ workflows: WorkflowSummary[] }> {
return fetchJson(agentBase(agent), "/workflows");
}
export function listRunningThreads(): Promise<{ threads: ThreadSummary[] }> {
return fetchJson("/threads/running");
export function listThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
return fetchJson(agentBase(agent), "/threads");
}
export function getThread(id: string): Promise<{ records: ThreadRecord[] }> {
return fetchJson(`/threads/${id}`);
export function listRunningThreads(agent: string): Promise<{ threads: ThreadSummary[] }> {
return fetchJson(agentBase(agent), "/threads/running");
}
export function getThread(agent: string, id: string): Promise<{ records: ThreadRecord[] }> {
return fetchJson(agentBase(agent), `/threads/${id}`);
}
export function runThread(
agent: string,
workflow: string,
prompt: string,
maxRounds: number = 10,
): Promise<{ threadId: string }> {
return postJson("/threads", { workflow, prompt, maxRounds });
return postJson(agentBase(agent), "/threads", { workflow, prompt, maxRounds });
}
export function killThread(threadId: string): Promise<{ ok: boolean }> {
return postJson(`/threads/${threadId}/kill`, {});
export function killThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
return postJson(agentBase(agent), `/threads/${threadId}/kill`, {});
}
export function pauseThread(threadId: string): Promise<{ ok: boolean }> {
return postJson(`/threads/${threadId}/pause`, {});
export function pauseThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
return postJson(agentBase(agent), `/threads/${threadId}/pause`, {});
}
export function resumeThread(threadId: string): Promise<{ ok: boolean }> {
return postJson(`/threads/${threadId}/resume`, {});
export function resumeThread(agent: string, threadId: string): Promise<{ ok: boolean }> {
return postJson(agentBase(agent), `/threads/${threadId}/resume`, {});
}
export function getHealth(): Promise<{ ok: boolean }> {
return fetchJson("/healthz");
export function getAgentHealth(agent: string): Promise<{ ok: boolean }> {
return fetchJson(agentBase(agent), "/healthz");
}
+18 -8
View File
@@ -8,24 +8,34 @@ import { WorkflowList } from "./components/workflow-list.tsx";
import { useHashRoute } from "./use-hash-route.ts";
export function App() {
const { view, threadId, setView, setThreadId } = useHashRoute();
const { view, agent, threadId, setView, setAgent, setThreadId } = useHashRoute();
const [showRun, setShowRun] = useState(false);
return (
<div className="flex h-screen">
<Sidebar view={view} onViewChange={setView} />
<Sidebar view={view} agent={agent} onViewChange={setView} onAgentChange={setAgent} />
<main className="flex-1 overflow-hidden flex flex-col">
<StatusBar onRun={() => setShowRun(true)} />
<StatusBar agent={agent} 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)} />
{!agent && (
<div className="flex items-center justify-center h-full">
<p style={{ color: "var(--color-text-muted)" }}>
Select an agent from the sidebar to get started.
</p>
</div>
)}
{view === "workflows" && <WorkflowList />}
{agent && view === "threads" && threadId === null && (
<ThreadList agent={agent} onSelect={setThreadId} />
)}
{agent && view === "threads" && threadId !== null && (
<ThreadDetail agent={agent} threadId={threadId} onBack={() => setThreadId(null)} />
)}
{agent && view === "workflows" && <WorkflowList agent={agent} />}
</div>
</main>
{showRun && (
{showRun && agent && (
<RunDialog
agent={agent}
onClose={() => setShowRun(false)}
onCreated={(id) => {
setShowRun(false);
@@ -3,12 +3,13 @@ import { listWorkflows, runThread } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
agent: string;
onClose: () => void;
onCreated: (threadId: string) => void;
};
export function RunDialog({ onClose, onCreated }: Props) {
const workflows = useFetch(() => listWorkflows(), []);
export function RunDialog({ agent, onClose, onCreated }: Props) {
const workflows = useFetch(() => listWorkflows(agent), [agent]);
const [workflow, setWorkflow] = useState("");
const [prompt, setPrompt] = useState("");
const [maxRounds, setMaxRounds] = useState(10);
@@ -21,7 +22,7 @@ export function RunDialog({ onClose, onCreated }: Props) {
setSubmitting(true);
setError(null);
try {
const result = await runThread(workflow, prompt, maxRounds);
const result = await runThread(agent, workflow, prompt, maxRounds);
onCreated(result.threadId);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -38,7 +39,7 @@ export function RunDialog({ onClose, onCreated }: Props) {
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>
<h3 className="text-lg font-semibold mb-4">Run Thread on {agent}</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
@@ -1,10 +1,21 @@
import { useState } from "react";
import type { AgentEndpoint } from "../api.ts";
import { listAgents } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
view: "threads" | "workflows";
agent: string | null;
onViewChange: (v: "threads" | "workflows") => void;
onAgentChange: (a: string | null) => void;
};
export function Sidebar({ view, onViewChange }: Props) {
const items = [
export function Sidebar({ view, agent, onViewChange, onAgentChange }: Props) {
const { status, data } = useFetch(() => listAgents(), []);
const [expanded, setExpanded] = useState(true);
const agents: AgentEndpoint[] = status === "ok" ? data : [];
const viewItems = [
{ key: "threads" as const, label: "Threads", icon: "⚡" },
{ key: "workflows" as const, label: "Workflows", icon: "📦" },
];
@@ -22,8 +33,56 @@ export function Sidebar({ view, onViewChange }: Props) {
Dashboard
</p>
</div>
{/* Agent selector */}
<div className="border-b" style={{ borderColor: "var(--color-border)" }}>
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="w-full text-left px-4 py-2 text-xs font-medium"
style={{ color: "var(--color-text-muted)" }}
>
{expanded ? "▾" : "▸"} Agents
{agent && (
<span className="ml-2 text-xs" style={{ color: "var(--color-accent)" }}>
({agent})
</span>
)}
</button>
{expanded && (
<div className="px-2 pb-2 space-y-0.5">
{agents.length === 0 && (
<p className="text-xs px-2 py-1" style={{ color: "var(--color-text-muted)" }}>
{status === "loading" ? "Loading..." : "No agents online"}
</p>
)}
{agents.map((a) => (
<button
type="button"
key={a.name}
onClick={() => onAgentChange(a.name)}
className="w-full text-left px-3 py-1.5 rounded text-xs transition-colors flex items-center gap-2"
style={{
background: agent === a.name ? "var(--color-accent-dim)" : "transparent",
color: agent === a.name ? "#fff" : "var(--color-text-muted)",
}}
>
<span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{
background: a.status === "online" ? "var(--color-success)" : "var(--color-error)",
}}
/>
{a.name}
</button>
))}
</div>
)}
</div>
{/* View navigation */}
<nav className="flex-1 p-2 space-y-1">
{items.map((item) => (
{viewItems.map((item) => (
<button
type="button"
key={item.key}
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getHealth } from "../api.ts";
import { getAgentHealth } from "../api.ts";
type HealthStatus = "connected" | "disconnected" | "reconnecting";
type Props = {
agent: string | null;
onRun: () => void;
};
@@ -17,13 +18,17 @@ function statusLabel(status: HealthStatus): { text: string; color: string } {
return { text: "● Offline", color: "var(--color-error)" };
}
export function StatusBar({ onRun }: Props) {
export function StatusBar({ agent, onRun }: Props) {
const [status, setStatus] = useState<HealthStatus>("disconnected");
const wasConnectedRef = useRef(false);
const checkHealth = useCallback(async () => {
if (!agent) {
setStatus("disconnected");
return;
}
try {
await getHealth();
await getAgentHealth(agent);
wasConnectedRef.current = true;
setStatus("connected");
} catch {
@@ -33,9 +38,11 @@ export function StatusBar({ onRun }: Props) {
setStatus("disconnected");
}
}
}, []);
}, [agent]);
useEffect(() => {
wasConnectedRef.current = false;
setStatus("disconnected");
checkHealth();
const interval = setInterval(checkHealth, 10_000);
return () => clearInterval(interval);
@@ -49,12 +56,19 @@ export function StatusBar({ onRun }: Props) {
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>
<span style={{ color: "var(--color-text-muted)" }}>
{agent ? `Agent: ${agent}` : "No agent selected"}
</span>
<button
type="button"
onClick={onRun}
disabled={!agent}
className="px-3 py-1 rounded text-xs font-medium"
style={{ background: "var(--color-accent)", color: "#fff" }}
style={{
background: agent ? "var(--color-accent)" : "var(--color-border)",
color: "#fff",
opacity: agent ? 1 : 0.5,
}}
>
Run Thread
</button>
@@ -4,13 +4,14 @@ import { useFetch } from "../hooks.ts";
import { useSSE } from "../use-sse.ts";
type Props = {
agent: string;
threadId: string;
onBack: () => void;
};
export function ThreadDetail({ threadId, onBack }: Props) {
const sse = useSSE(threadId);
const { status, data, error } = useFetch(() => getThread(threadId), [threadId]);
export function ThreadDetail({ agent, threadId, onBack }: Props) {
const sse = useSSE(agent, threadId);
const { status, data, error } = useFetch(() => getThread(agent, threadId), [agent, threadId]);
const [actionStatus, setActionStatus] = useState<string | null>(null);
const recordsEndRef = useRef<HTMLDivElement>(null);
@@ -30,7 +31,7 @@ export function ThreadDetail({ threadId, onBack }: Props) {
setActionStatus(`${action}ing...`);
try {
const fn = action === "kill" ? killThread : action === "pause" ? pauseThread : resumeThread;
await fn(threadId);
await fn(agent, threadId);
setActionStatus(`${action} sent ✓`);
} catch (e) {
setActionStatus(`${action} failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -2,11 +2,12 @@ import { listThreads } from "../api.ts";
import { useFetch } from "../hooks.ts";
type Props = {
agent: string;
onSelect: (id: string) => void;
};
export function ThreadList({ onSelect }: Props) {
const { status, data, error } = useFetch(() => listThreads(), []);
export function ThreadList({ agent, onSelect }: Props) {
const { status, data, error } = useFetch(() => listThreads(agent), [agent]);
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading threads...</p>;
@@ -1,8 +1,12 @@
import { listWorkflows } from "../api.ts";
import { useFetch } from "../hooks.ts";
export function WorkflowList() {
const { status, data, error } = useFetch(() => listWorkflows(), []);
type Props = {
agent: string;
};
export function WorkflowList({ agent }: Props) {
const { status, data, error } = useFetch(() => listWorkflows(agent), [agent]);
if (status === "loading")
return <p style={{ color: "var(--color-text-muted)" }}>Loading workflows...</p>;
@@ -4,37 +4,50 @@ type View = "threads" | "workflows";
type HashRoute = {
view: View;
agent: string | null;
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 };
}
// Format: #agent/threads/id or #agent/workflows or #threads or #workflows
const parts = raw.split("/");
// Check if first part is a known view
if (parts[0] === "threads" || parts[0] === "workflows") {
return {
view: parts[0] as View,
agent: null,
threadId: parts[0] === "threads" && parts.length > 1 ? parts.slice(1).join("/") : null,
};
}
if (raw === "workflows") {
return { view: "workflows", threadId: null };
}
return { view: "threads", threadId: null };
// First part is agent name
const agent = parts[0] || null;
const viewPart = parts[1] ?? "threads";
const view: View = viewPart === "workflows" ? "workflows" : "threads";
const threadId = view === "threads" && parts.length > 2 ? parts.slice(2).join("/") : null;
return { view, agent, threadId };
}
function buildHash(route: HashRoute): string {
const prefix = route.agent ? `${route.agent}/` : "";
if (route.view === "workflows") {
return "#workflows";
return `#${prefix}workflows`;
}
if (route.threadId !== null) {
return `#threads/${route.threadId}`;
return `#${prefix}threads/${route.threadId}`;
}
return "#threads";
return `#${prefix}threads`;
}
export function useHashRoute(): {
view: View;
agent: string | null;
threadId: string | null;
setView: (v: View) => void;
setAgent: (a: string | null) => void;
setThreadId: (id: string | null) => void;
} {
const [route, setRoute] = useState<HashRoute>(() => parseHash(window.location.hash));
@@ -53,12 +66,20 @@ export function useHashRoute(): {
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],
const setView = useCallback(
(v: View) => navigate({ view: v, agent: route.agent, threadId: null }),
[navigate, route.agent],
);
return { view: route.view, threadId: route.threadId, setView, setThreadId };
const setAgent = useCallback(
(a: string | null) => navigate({ view: route.view, agent: a, threadId: null }),
[navigate, route.view],
);
const setThreadId = useCallback(
(id: string | null) => navigate({ view: "threads", agent: route.agent, threadId: id }),
[navigate, route.agent],
);
return { view: route.view, agent: route.agent, threadId: route.threadId, setView, setAgent, setThreadId };
}
+14 -4
View File
@@ -56,7 +56,16 @@ function handleRecordEvent(ev: Event, ctx: RecordEventContext): void {
ctx.cleanupEs();
}
export function useSSE(threadId: string | null): UseSSEReturn {
function sseUrl(agent: string, threadId: string): string {
const gatewayUrl = import.meta.env.VITE_GATEWAY_URL || "";
if (gatewayUrl) {
return `${gatewayUrl}/api/${agent}/threads/${encodeURIComponent(threadId)}/live`;
}
// Local dev: use vite proxy
return `/api/threads/${encodeURIComponent(threadId)}/live`;
}
export function useSSE(agent: string | null, threadId: string | null): UseSSEReturn {
const [records, setRecords] = useState<ThreadRecord[]>([]);
const [connected, setConnected] = useState(false);
const [completed, setCompleted] = useState(false);
@@ -65,7 +74,7 @@ export function useSSE(threadId: string | null): UseSSEReturn {
const reconnectAttemptsRef = useRef(0);
useEffect(() => {
if (threadId === null) {
if (threadId === null || agent === null) {
completedRef.current = false;
reconnectAttemptsRef.current = 0;
setRecords([]);
@@ -75,6 +84,7 @@ export function useSSE(threadId: string | null): UseSSEReturn {
}
const tid = threadId;
const agentName = agent;
completedRef.current = false;
reconnectAttemptsRef.current = 0;
@@ -113,7 +123,7 @@ export function useSSE(threadId: string | null): UseSSEReturn {
}
cleanupEs();
const url = `/api/threads/${encodeURIComponent(tid)}/live`;
const url = sseUrl(agentName, tid);
es = new EventSource(url);
es.onopen = () => {
@@ -155,7 +165,7 @@ export function useSSE(threadId: string | null): UseSSEReturn {
}
cleanupEs();
};
}, [threadId]);
}, [agent, threadId]);
return { records, connected, completed };
}