Add dedicated Run Center with queue, detail, and log viewer
Two-pane RunsPage: left pane shows searchable, filterable run queue (by status: queued/running/completed/failed). Right pane shows full run detail: agent, provider, trigger, timing, duration, execution context, error messages for failures, and provider request response text as a scrollable log viewer.
This commit is contained in:
1 parent
ed9b3a3dd1
commit
dfc45f5fde
3 files changed
+215
-1
No files matched your search
@@ -11,6 +11,7 @@ import { ApprovalsPage } from "../pages/ApprovalsPage";
|
||||
import { SocialPage } from "../pages/SocialPage";
|
||||
import { DocumentsPage } from "../pages/DocumentsPage";
|
||||
import { KnowledgePage } from "../pages/KnowledgePage";
|
||||
import { RunsPage } from "../pages/RunsPage";
|
||||
import { AutomationPage } from "../pages/AutomationPage";
|
||||
import { MeetingsPage } from "../pages/MeetingsPage";
|
||||
import { SprintsPage } from "../pages/SprintsPage";
|
||||
@@ -44,6 +45,7 @@ export function App() {
|
||||
files: <FilesPage {...pageProps} />,
|
||||
docs: <DocumentsPage {...pageProps} />,
|
||||
knowledge: <KnowledgePage {...pageProps} />,
|
||||
runs: <RunsPage {...pageProps} />,
|
||||
automation: <AutomationPage {...pageProps} />,
|
||||
meetings: <MeetingsPage {...pageProps} />,
|
||||
sprints: <SprintsPage {...pageProps} />,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Avatar, Button, Icon, IconButton, Pill, Segmented } from "../shared/ui";
|
||||
import { TopbarActions } from "../shared/TopbarActions";
|
||||
import type { AgentRecord, ProviderRecord, TaskRecord, TaskRunRecord } from "../../../shared/domain";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, formatRelative } from "./types";
|
||||
|
||||
type StatusFilter = "all" | "queued" | "running" | "completed" | "failed";
|
||||
const FILTERS: Array<{ id: StatusFilter; label: string }> = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "queued", label: "Queued" },
|
||||
{ id: "running", label: "Running" },
|
||||
{ id: "completed", label: "Done" },
|
||||
{ id: "failed", label: "Failed" },
|
||||
];
|
||||
|
||||
export function RunsPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const taskRuns = useMemo(() => snapshot.taskRuns.filter((r) => r.orgId === orgId), [snapshot.taskRuns, orgId]);
|
||||
const tasks = useMemo(() => snapshot.tasks.filter((t) => t.orgId === orgId), [snapshot.tasks, orgId]);
|
||||
const agents = useMemo(() => snapshot.agents.filter((a) => a.orgId === orgId), [snapshot.agents, orgId]);
|
||||
const providers = useMemo(() => snapshot.providers.filter((p) => p.orgId === orgId), [snapshot.providers, orgId]);
|
||||
const providerRequests = useMemo(() => snapshot.providerRequests.filter((r) => r.orgId === orgId), [snapshot.providerRequests, orgId]);
|
||||
const [filter, setFilter] = useState<StatusFilter>("all");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = filter === "all" ? taskRuns : taskRuns.filter((r) => r.state === filter);
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
list = list.filter((r) => {
|
||||
const task = tasks.find((t) => t.id === r.taskId);
|
||||
const agent = agents.find((a) => a.id === r.agentId);
|
||||
return task?.title.toLowerCase().includes(q) || agent?.name.toLowerCase().includes(q) || r.id.includes(q);
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}, [taskRuns, filter, search, tasks, agents]);
|
||||
|
||||
const selected = selectedId ? taskRuns.find((r) => r.id === selectedId) ?? null : null;
|
||||
const queued = taskRuns.filter((r) => r.state === "queued").length;
|
||||
const running = taskRuns.filter((r) => r.state === "running").length;
|
||||
const failed = taskRuns.filter((r) => r.state === "failed").length;
|
||||
|
||||
return (
|
||||
<div className="split-main">
|
||||
<aside className="secondary-pane" style={{ width: 340 }}>
|
||||
<div style={{ padding: "12px 12px 8px" }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Runs</div>
|
||||
<div className="row" style={{ gap: 6, marginBottom: 8, fontSize: 11, color: "var(--text-3)" }}>
|
||||
<span>{queued} queued</span><span>·</span><span>{running} running</span><span>·</span><span>{failed} failed</span>
|
||||
</div>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Search runs"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Segmented value={filter} onChange={setFilter} options={FILTERS} />
|
||||
</div>
|
||||
<div className="pane-scroll">
|
||||
{filtered.map((run) => {
|
||||
const task = tasks.find((t) => t.id === run.taskId);
|
||||
const agent = agents.find((a) => a.id === run.agentId);
|
||||
const active = run.id === selectedId;
|
||||
return (
|
||||
<button
|
||||
key={run.id}
|
||||
type="button"
|
||||
className={`nav-row ${active ? "active" : ""}`}
|
||||
onClick={() => setSelectedId(run.id)}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12.5, fontWeight: 480, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{task?.title ?? run.taskId}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-3)" }}>
|
||||
{agent?.name ?? "Agent"} · {run.trigger}
|
||||
</div>
|
||||
</div>
|
||||
<Pill tone={runTone(run.state)}>{runLabel(run.state)}</Pill>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && <div style={{ padding: 16, fontSize: 13, color: "var(--text-3)" }}>No runs</div>}
|
||||
</div>
|
||||
</aside>
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: "auto", padding: 24 }}>
|
||||
{selected ? (
|
||||
<RunDetail run={selected} tasks={tasks} agents={agents} providers={providers} providerRequests={providerRequests} />
|
||||
) : (
|
||||
<div style={{ display: "grid", placeItems: "center", height: "100%", color: "var(--text-3)", fontSize: 13 }}>
|
||||
Select a run
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunDetail({ run, tasks, agents, providers, providerRequests }: {
|
||||
run: TaskRunRecord;
|
||||
tasks: TaskRecord[];
|
||||
agents: AgentRecord[];
|
||||
providers: ProviderRecord[];
|
||||
providerRequests: Array<{ id: string; orgId: string; providerId: string; model: string; state: string; responseText: string | null; inputCharCount: number; outputCharCount: number; createdAt: string }>;
|
||||
}) {
|
||||
const task = tasks.find((t) => t.id === run.taskId);
|
||||
const agent = agents.find((a) => a.id === run.agentId);
|
||||
const provider = providers.find((p) => p.id === run.providerId);
|
||||
const request = run.providerRequestId ? providerRequests.find((r) => r.id === run.providerRequestId) : null;
|
||||
|
||||
const started = run.startedAt ? new Date(run.startedAt) : null;
|
||||
const completed = run.completedAt ? new Date(run.completedAt) : null;
|
||||
const durationMs = started && completed ? completed.getTime() - started.getTime() : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="row" style={{ gap: 10, marginBottom: 16 }}>
|
||||
{agent && <Avatar name={agent.name} size="sm" bot />}
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>{task?.title ?? run.taskId}</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)" }}>{run.id}</div>
|
||||
</div>
|
||||
<Pill tone={runTone(run.state)}>{runLabel(run.state)}</Pill>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "120px 1fr", gap: "8px 14px", fontSize: 12.5, marginBottom: 20 }}>
|
||||
<MetaLabel>Agent</MetaLabel><div>{agent?.name ?? "—"} ({agent?.role ?? "—"})</div>
|
||||
<MetaLabel>Provider</MetaLabel><div>{provider?.label ?? "—"} · {provider?.model ?? "—"}</div>
|
||||
<MetaLabel>Trigger</MetaLabel><div>{run.trigger}</div>
|
||||
<MetaLabel>Created</MetaLabel><div>{formatRelative(run.createdAt)}</div>
|
||||
{run.startedAt && (<><MetaLabel>Started</MetaLabel><div>{formatRelative(run.startedAt)}</div></>)}
|
||||
{run.completedAt && (<><MetaLabel>Completed</MetaLabel><div>{formatRelative(run.completedAt)}</div></>)}
|
||||
{durationMs !== null && (<><MetaLabel>Duration</MetaLabel><div>{formatDuration(durationMs)}</div></>)}
|
||||
<MetaLabel>Context</MetaLabel><div className="mono" style={{ fontSize: 11 }}>{run.executionContextId}</div>
|
||||
</div>
|
||||
|
||||
{run.state === "failed" && run.errorMessage && (
|
||||
<div className="card" style={{ padding: 14, marginBottom: 16, borderColor: "var(--red)" }}>
|
||||
<div className="section-label" style={{ color: "var(--red)", marginBottom: 6 }}>Error</div>
|
||||
<div style={{ fontSize: 12.5, color: "var(--text-1)", whiteSpace: "pre-wrap", lineHeight: 1.5 }}>{run.errorMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{request && (
|
||||
<div className="card" style={{ padding: 14, marginBottom: 16 }}>
|
||||
<div className="section-label" style={{ marginBottom: 8 }}>Provider request</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "120px 1fr", gap: "6px 14px", fontSize: 12 }}>
|
||||
<MetaLabel>Request ID</MetaLabel><div className="mono" style={{ fontSize: 11 }}>{request.id}</div>
|
||||
<MetaLabel>Model</MetaLabel><div>{request.model}</div>
|
||||
<MetaLabel>State</MetaLabel><div>{request.state}</div>
|
||||
<MetaLabel>Input chars</MetaLabel><div className="num">{request.inputCharCount.toLocaleString()}</div>
|
||||
<MetaLabel>Output chars</MetaLabel><div className="num">{request.outputCharCount.toLocaleString()}</div>
|
||||
</div>
|
||||
{request.responseText && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div className="section-label" style={{ marginBottom: 6 }}>Response</div>
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-2)",
|
||||
whiteSpace: "pre-wrap",
|
||||
lineHeight: 1.5,
|
||||
maxHeight: 400,
|
||||
overflow: "auto",
|
||||
padding: 10,
|
||||
background: "var(--panel-3)",
|
||||
borderRadius: 8,
|
||||
fontFamily: "var(--font-mono)",
|
||||
}}>
|
||||
{request.responseText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaLabel({ children }: { children: string }) {
|
||||
return <div style={{ color: "var(--text-3)" }}>{children}</div>;
|
||||
}
|
||||
|
||||
function runTone(state: string): "red" | "amber" | "violet" | "green" | undefined {
|
||||
if (state === "failed") return "red";
|
||||
if (state === "running") return "violet";
|
||||
if (state === "queued") return "amber";
|
||||
if (state === "completed") return "green";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function runLabel(state: string): string {
|
||||
if (state === "queued") return "Queued";
|
||||
if (state === "running") return "Running";
|
||||
if (state === "completed") return "Done";
|
||||
if (state === "failed") return "Failed";
|
||||
return state;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.round(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = seconds % 60;
|
||||
return `${minutes}m ${remainder}s`;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export type RouteId =
|
||||
| "docs"
|
||||
| "knowledge"
|
||||
| "automation"
|
||||
| "runs"
|
||||
| "meetings"
|
||||
| "sprints"
|
||||
| "approvals"
|
||||
@@ -29,7 +30,7 @@ interface NavSpec {
|
||||
|
||||
/** Routes whose pages own a full-height, multi-pane layout with their own
|
||||
* internal scroll regions, so the content area must not scroll as one block. */
|
||||
const FLUSH_ROUTES = new Set<RouteId>(["chat", "settings", "files", "social"]);
|
||||
const FLUSH_ROUTES = new Set<RouteId>(["chat", "settings", "files", "social", "runs"]);
|
||||
|
||||
const primaryNav: NavSpec[] = [
|
||||
{ id: "home", label: "Home", icon: "home" },
|
||||
@@ -39,6 +40,7 @@ const primaryNav: NavSpec[] = [
|
||||
{ id: "files", label: "Files", icon: "docs" },
|
||||
{ id: "docs", label: "Documents", icon: "file" },
|
||||
{ id: "knowledge", label: "Knowledge", icon: "sparkle" },
|
||||
{ id: "runs", label: "Runs", icon: "send" },
|
||||
{ id: "automation", label: "Automation", icon: "bot" },
|
||||
{ id: "meetings", label: "Meetings", icon: "team" },
|
||||
{ id: "sprints", label: "Sprints", icon: "work" },
|
||||
|
||||
Reference in new issue
Block a user