mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
feat: add Gateway page and Runs page with real-time updates
- Implemented a new Gateway page that provides a uniform ingress for deployed agents. - Created a Runs page that lists recent pipeline executions with auto-refresh and error handling. - Added a RunsListener component to handle real-time notifications for new runs via SSE. - Updated the AppSidebar to include links to the new Gateway and Runs pages. - Enhanced FlowNode component to improve status display and styling. - Introduced EmptySection component for consistent empty state presentation. - Updated API utility to include a new endpoint for the runs stream.
This commit is contained in:
@@ -88,6 +88,12 @@ function AgentDetail() {
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// Reload recent runs whenever any run is dispatched (manual / agent /
|
||||
// GitHub push). Cheap — `load()` is one round-trip.
|
||||
const es = new EventSource(api.runsStreamURL());
|
||||
es.onmessage = () => load();
|
||||
es.onerror = () => {};
|
||||
return () => es.close();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id]);
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { EmptySection } from "@/components/empty-section";
|
||||
|
||||
export default function ApprovalsPage() {
|
||||
return (
|
||||
<EmptySection
|
||||
title="Approvals"
|
||||
description="An inbox for pending Approval-node decisions across runs. The wiring exists (Restate awakeables + the Resume panel on each run page); this page will surface them in one place."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { EmptySection } from "@/components/empty-section";
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<EmptySection
|
||||
title="Dashboard"
|
||||
description="A summary of agents, pipelines, recent runs and approvals will land here. For now, the Pipelines and Agents pages have everything you need."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { EmptySection } from "@/components/empty-section";
|
||||
|
||||
export default function EnvironmentsPage() {
|
||||
return (
|
||||
<EmptySection
|
||||
title="Environments"
|
||||
description="Per-environment config (dev / staging / prod, runtime targets, secrets bindings) for agent deployments. Hooks into the Promote node."
|
||||
/>
|
||||
);
|
||||
}
|
||||
+507
-291
@@ -3,7 +3,7 @@
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { ArrowLeft, RefreshCw, Send } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, RefreshCw, Send } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -18,25 +18,26 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { PipelineCanvas } from "@/components/canvas/pipeline-canvas";
|
||||
import { api, type ExecutionStatus, type Run } from "@/lib/api";
|
||||
import type { PipelineDefinition } from "@/lib/pipeline-graph";
|
||||
import type { PipelineDefinition, PipelineNode } from "@/lib/pipeline-graph";
|
||||
import { lookup as lookupNode } from "@/lib/node-catalog";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
type NodeStatus = "pending" | "running" | "success" | "failed" | "paused";
|
||||
type NodeStatuses = Record<string, NodeStatus>;
|
||||
type NodeLogs = Record<string, string[]>;
|
||||
type NodeDurations = Record<string, number>;
|
||||
|
||||
interface ExecutionEvent {
|
||||
type: string;
|
||||
node?: string;
|
||||
node_type?: string;
|
||||
status?: string;
|
||||
content?: string; // for node_log events
|
||||
content?: string;
|
||||
outputs?: Record<string, unknown>;
|
||||
error?: string;
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
type NodeLogLine = { ts: number; line: string };
|
||||
type NodeLogs = Record<string, NodeLogLine[]>;
|
||||
|
||||
export default function ExecutionPage() {
|
||||
return (
|
||||
<Suspense
|
||||
@@ -74,21 +75,17 @@ function ExecutionView() {
|
||||
const [run, setRun] = useState<Run | null>(null);
|
||||
const [pipelineDef, setPipelineDef] = useState<PipelineDefinition | null>(null);
|
||||
const [nodeStatuses, setNodeStatuses] = useState<NodeStatuses>({});
|
||||
const [events, setEvents] = useState<ExecutionEvent[]>([]);
|
||||
const [nodeLogs, setNodeLogs] = useState<NodeLogs>({});
|
||||
const [activeLogNode, setActiveLogNode] = useState<string | null>(null);
|
||||
const [nodeDurations, setNodeDurations] = useState<NodeDurations>({});
|
||||
const [streamConnected, setStreamConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<"canvas" | "json">("canvas");
|
||||
|
||||
// Resume form
|
||||
const [awakeable, setAwakeable] = useState("");
|
||||
const [data, setData] = useState(`{"approved": true}`);
|
||||
const [resuming, setResuming] = useState(false);
|
||||
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
// Initial load: status + run record + pipeline def.
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
(async () => {
|
||||
@@ -105,7 +102,7 @@ function ExecutionView() {
|
||||
const f = await api.getFlow(r.pipelineId);
|
||||
setPipelineDef((f.definition as PipelineDefinition | null) ?? null);
|
||||
} catch {
|
||||
// Pipeline may have been deleted; render JSON view only.
|
||||
/* pipeline may have been deleted */
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -114,41 +111,24 @@ function ExecutionView() {
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
// SSE subscription. On open, mark all known nodes as pending. Each
|
||||
// node_started/completed/error event flips that node's status.
|
||||
// SSE
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
const url = api.executionStreamURL(id);
|
||||
const es = new EventSource(url);
|
||||
eventSourceRef.current = es;
|
||||
const es = new EventSource(api.executionStreamURL(id));
|
||||
es.onopen = () => setStreamConnected(true);
|
||||
es.onerror = () => {
|
||||
setStreamConnected(false);
|
||||
// EventSource auto-reconnects on transient errors; only close on
|
||||
// permanent ones. We log but don't bail.
|
||||
};
|
||||
es.onerror = () => setStreamConnected(false);
|
||||
es.onmessage = (msg) => {
|
||||
try {
|
||||
const ev: ExecutionEvent = JSON.parse(msg.data);
|
||||
|
||||
// Log lines are high-frequency — keep them out of the generic
|
||||
// events array (used for the JSON debug view) and route into
|
||||
// their own state instead.
|
||||
if (ev.type === "node_log" && ev.node && typeof ev.content === "string") {
|
||||
const node = ev.node;
|
||||
setNodeLogs((prev) => {
|
||||
const cur = prev[node] ?? [];
|
||||
const next = cur.length >= 2000 ? cur.slice(-1999) : cur;
|
||||
return {
|
||||
...prev,
|
||||
[node]: [...next, { ts: Date.now(), line: ev.content! }],
|
||||
};
|
||||
return { ...prev, [node]: [...next, ev.content!] };
|
||||
});
|
||||
setActiveLogNode((prev) => prev ?? node);
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev.slice(-99), ev]);
|
||||
if (ev.node) {
|
||||
setNodeStatuses((prev) => {
|
||||
const next: NodeStatus =
|
||||
@@ -161,8 +141,13 @@ function ExecutionView() {
|
||||
: (prev[ev.node!] ?? "pending");
|
||||
return { ...prev, [ev.node!]: next };
|
||||
});
|
||||
if (ev.type === "node_started") {
|
||||
setActiveLogNode(ev.node);
|
||||
if (ev.type === "node_completed" || ev.type === "node_error") {
|
||||
if (typeof ev.duration_ms === "number") {
|
||||
setNodeDurations((prev) => ({
|
||||
...prev,
|
||||
[ev.node!]: ev.duration_ms!,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ev.type === "done") {
|
||||
@@ -171,26 +156,44 @@ function ExecutionView() {
|
||||
setStreamConnected(false);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages.
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
setStreamConnected(false);
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
// Fallback polling for terminal status — useful if SSE failed to connect.
|
||||
// Fallback polling when SSE drops
|
||||
useEffect(() => {
|
||||
if (!id || streamConnected) return;
|
||||
const t = setInterval(() => {
|
||||
api.getExecution(id).then(setStatus).catch(() => {});
|
||||
}, 3000);
|
||||
}, 4000);
|
||||
return () => clearInterval(t);
|
||||
}, [id, streamConnected]);
|
||||
|
||||
// Hydrate canvas node statuses from the terminal payload whenever we
|
||||
// have status + pipeline def. This handles the "joined after the run
|
||||
// finished" case — without it, the canvas stays at PENDING forever
|
||||
// because SSE has nothing left to replay. SSE-derived statuses are not
|
||||
// overwritten so an in-flight run is still authoritative.
|
||||
useEffect(() => {
|
||||
if (!status || !pipelineDef) return;
|
||||
const overall = String(status.status || "").toLowerCase();
|
||||
const isTerm = ["success", "completed", "failed", "error", "partial_error"].includes(overall);
|
||||
if (!isTerm) return;
|
||||
setNodeStatuses((prev) => {
|
||||
const next: NodeStatuses = { ...prev };
|
||||
for (const n of pipelineDef.nodes ?? []) {
|
||||
if (next[n.name]) continue; // SSE wins
|
||||
next[n.name] = inferStatus(status, n.name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [status, pipelineDef]);
|
||||
|
||||
async function onResume() {
|
||||
setResuming(true);
|
||||
setError(null);
|
||||
@@ -207,9 +210,14 @@ function ExecutionView() {
|
||||
}
|
||||
}
|
||||
|
||||
const overallStatus = useMemo(() => {
|
||||
return (status?.status as string) || run?.status || "running";
|
||||
}, [status, run]);
|
||||
const overallStatus = useMemo(
|
||||
() => (status?.status as string) || run?.status || "running",
|
||||
[status, run]
|
||||
);
|
||||
const isTerminal = ["success", "completed", "failed", "error", "partial_error"]
|
||||
.includes(overallStatus.toLowerCase());
|
||||
|
||||
const nodes: PipelineNode[] = pipelineDef?.nodes ?? [];
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
@@ -220,55 +228,147 @@ function ExecutionView() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b bg-background/80 px-3 backdrop-blur">
|
||||
<Button variant="ghost" size="icon" asChild aria-label="Back">
|
||||
<Link href="/">
|
||||
<ArrowLeft className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Execution
|
||||
<div className="space-y-6 p-6 pb-24">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Run</h1>
|
||||
<div className="mt-1 font-mono text-xs text-muted-foreground">{id}</div>
|
||||
{run?.pipelineId && (
|
||||
<Link
|
||||
href={`/flows/view/?id=${encodeURIComponent(run.pipelineId)}`}
|
||||
className="mt-1 inline-block text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
pipeline {run.pipelineId.slice(0, 8)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-mono text-xs">{id}</span>
|
||||
<Badge variant={statusVariant(overallStatus)}>{overallStatus}</Badge>
|
||||
{streamConnected ? (
|
||||
<Badge variant="success" className="text-[10px]">
|
||||
● live
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
polling
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<div className="inline-flex rounded-md border bg-muted/30 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("canvas")}
|
||||
<Badge variant={statusVariant(overallStatus)} className="gap-1">
|
||||
<span
|
||||
className={
|
||||
"rounded-sm px-3 py-1 text-xs " +
|
||||
(tab === "canvas"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground")
|
||||
"size-1.5 rounded-full " +
|
||||
(overallStatus === "running"
|
||||
? "bg-sky-500 animate-pulse"
|
||||
: overallStatus === "success" || overallStatus === "completed"
|
||||
? "bg-emerald-500"
|
||||
: overallStatus === "failed" || overallStatus === "error"
|
||||
? "bg-rose-500"
|
||||
: "bg-muted-foreground")
|
||||
}
|
||||
>
|
||||
Canvas
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("json")}
|
||||
className={
|
||||
"rounded-sm px-3 py-1 text-xs " +
|
||||
(tab === "json"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground")
|
||||
}
|
||||
>
|
||||
JSON
|
||||
</button>
|
||||
</div>
|
||||
/>
|
||||
{overallStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive/40">
|
||||
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pipeline canvas card */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>Pipeline</CardTitle>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{nodes.length} {nodes.length === 1 ? "node" : "nodes"}
|
||||
</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative h-[440px] overflow-hidden rounded-lg border bg-background">
|
||||
{pipelineDef ? (
|
||||
<PipelineCanvas
|
||||
initialValue={pipelineDef}
|
||||
pipelineId={`exec-${id}`}
|
||||
readOnly
|
||||
fullBleed
|
||||
nodeStatuses={nodeStatuses}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{run?.pipelineId
|
||||
? "Loading pipeline canvas…"
|
||||
: "No pipeline definition for this run."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Nodes panel */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Nodes</CardTitle>
|
||||
<CardDescription>
|
||||
Per-node detail. Build logs stream live and archive to S3 when the
|
||||
node finishes.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{nodes.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No nodes loaded.</p>
|
||||
) : (
|
||||
nodes.map((n) => (
|
||||
<NodeRow
|
||||
key={n.id}
|
||||
node={n}
|
||||
status={nodeStatuses[n.name] ?? (isTerminal ? inferStatus(status, n.name) : "pending")}
|
||||
durationMs={nodeDurations[n.name]}
|
||||
liveLog={nodeLogs[n.name]}
|
||||
streaming={streamConnected && nodeStatuses[n.name] === "running"}
|
||||
isTerminal={isTerminal}
|
||||
executionId={id}
|
||||
statusOutputs={status?.node_outputs as Record<string, unknown> | undefined}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Resume overlay if paused */}
|
||||
{(overallStatus === "paused" || overallStatus === "waiting") && (
|
||||
<Card className="fixed bottom-6 right-6 z-30 w-80 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Resume</CardTitle>
|
||||
<CardDescription>
|
||||
Resolve a Restate awakeable to continue.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="awakeable">Awakeable ID</Label>
|
||||
<Input
|
||||
id="awakeable"
|
||||
value={awakeable}
|
||||
onChange={(e) => setAwakeable(e.target.value)}
|
||||
placeholder="awk_…"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="data">Resolution data (JSON)</Label>
|
||||
<Textarea
|
||||
id="data"
|
||||
rows={4}
|
||||
value={data}
|
||||
onChange={(e) => setData(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={onResume}
|
||||
disabled={resuming || !awakeable}
|
||||
>
|
||||
<Send />
|
||||
{resuming ? "Sending…" : "Resume"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -278,222 +378,338 @@ function ExecutionView() {
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{error && (
|
||||
<div className="border-b border-destructive/40 bg-destructive/5 px-4 py-2 text-xs text-destructive">
|
||||
{error}
|
||||
// inferStatus reads the orchestrator's terminal payload to decide whether a
|
||||
// node ran. Used after `done` when the SSE map may be incomplete (e.g. user
|
||||
// landed on the page after the run finished).
|
||||
function inferStatus(
|
||||
status: ExecutionStatus | null,
|
||||
nodeName: string
|
||||
): NodeStatus {
|
||||
if (!status) return "pending";
|
||||
const outs = status.node_outputs as Record<string, unknown> | undefined;
|
||||
if (outs && nodeName in outs) return "success";
|
||||
const errs = (status.errors as string[] | undefined) ?? [];
|
||||
for (const e of errs) {
|
||||
if (typeof e === "string" && e.includes(`node "${nodeName}"`)) return "failed";
|
||||
}
|
||||
return "pending";
|
||||
}
|
||||
|
||||
function NodeRow(props: {
|
||||
node: PipelineNode;
|
||||
status: NodeStatus;
|
||||
durationMs?: number;
|
||||
liveLog?: string[];
|
||||
streaming: boolean;
|
||||
isTerminal: boolean;
|
||||
executionId: string;
|
||||
statusOutputs?: Record<string, unknown>;
|
||||
}) {
|
||||
const { node, status, durationMs, liveLog, streaming, isTerminal, executionId, statusOutputs } = props;
|
||||
const entry = lookupNode(node.type);
|
||||
const typeLabel = entry?.label?.toLowerCase() ?? node.type.replace("flow-nodes-base.", "");
|
||||
|
||||
const buildSummary = extractBuildSummary(node.name, statusOutputs);
|
||||
const triggerSummary = extractTriggerSummary(node.name, statusOutputs);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/10">
|
||||
<div className="flex items-center justify-between gap-2 p-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-medium">{node.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{typeLabel}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{typeof durationMs === "number" && (
|
||||
<span>took {(durationMs / 1000).toFixed(1)}s</span>
|
||||
)}
|
||||
<NodeStatusDot status={status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build node — image / digest / log path */}
|
||||
{buildSummary && (
|
||||
<div className="border-t px-4 py-3 font-mono text-[11px] leading-6">
|
||||
{buildSummary.image && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">image: </span>
|
||||
<span>{buildSummary.image}</span>
|
||||
</div>
|
||||
)}
|
||||
{buildSummary.digest && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">digest: </span>
|
||||
<span className="break-all">{buildSummary.digest}</span>
|
||||
</div>
|
||||
)}
|
||||
{buildSummary.commit && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">sha: </span>
|
||||
<span>{buildSummary.commit}</span>
|
||||
</div>
|
||||
)}
|
||||
{buildSummary.command && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">command: </span>
|
||||
<span>{buildSummary.command}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Always render the log path so the field appears even when archived. */}
|
||||
<div>
|
||||
<span className="text-muted-foreground">log: </span>
|
||||
<span>s3://flow-logs/runs/{executionId}/{node.name}.log</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{tab === "canvas" ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{pipelineDef ? (
|
||||
<PipelineCanvas
|
||||
initialValue={pipelineDef}
|
||||
pipelineId={`exec-${id}`}
|
||||
readOnly
|
||||
fullBleed
|
||||
nodeStatuses={nodeStatuses}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{run?.pipelineId
|
||||
? "Loading pipeline canvas…"
|
||||
: "No pipeline definition available for this run."}
|
||||
</div>
|
||||
)}
|
||||
{triggerSummary && (
|
||||
<div className="border-t px-4 py-3 font-mono text-[11px] leading-6">
|
||||
<div>
|
||||
<span className="text-muted-foreground">source: </span>
|
||||
<span>{triggerSummary.source ?? "manual"}</span>
|
||||
</div>
|
||||
{triggerSummary.repoUrl && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">repo: </span>
|
||||
<span>{triggerSummary.repoUrl}</span>
|
||||
</div>
|
||||
<LogPanel
|
||||
nodeLogs={nodeLogs}
|
||||
activeNode={activeLogNode}
|
||||
onActiveNodeChange={setActiveLogNode}
|
||||
nodeStatuses={nodeStatuses}
|
||||
streaming={streamConnected}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-full grid-cols-1 gap-4 overflow-auto p-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status</CardTitle>
|
||||
<CardDescription>Latest snapshot from the orchestrator</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="max-h-[60vh] overflow-auto rounded-md border bg-muted/30 p-3 text-xs">
|
||||
{status ? JSON.stringify(status, null, 2) : "Loading…"}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Event log</CardTitle>
|
||||
<CardDescription>Streamed via SSE</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="max-h-[60vh] overflow-auto rounded-md border bg-muted/30 p-3 text-[11px]">
|
||||
{events.length === 0
|
||||
? "(no events yet)"
|
||||
: events
|
||||
.map((e) => JSON.stringify(e))
|
||||
.join("\n")}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{triggerSummary.ref && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">ref: </span>
|
||||
<span>{triggerSummary.ref}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resume panel — overlaid only when paused */}
|
||||
{(overallStatus === "paused" || overallStatus === "waiting") && (
|
||||
<Card className="absolute right-4 top-4 z-20 w-80 shadow-xl">
|
||||
<CardHeader>
|
||||
<CardTitle>Resume</CardTitle>
|
||||
<CardDescription>
|
||||
Resolve the Restate awakeable to continue.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="awakeable">Awakeable ID</Label>
|
||||
<Input
|
||||
id="awakeable"
|
||||
value={awakeable}
|
||||
onChange={(e) => setAwakeable(e.target.value)}
|
||||
placeholder="awk_…"
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="data">Resolution data (JSON)</Label>
|
||||
<Textarea
|
||||
id="data"
|
||||
rows={4}
|
||||
value={data}
|
||||
onChange={(e) => setData(e.target.value)}
|
||||
spellCheck={false}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={onResume}
|
||||
disabled={resuming || !awakeable}
|
||||
>
|
||||
<Send />
|
||||
{resuming ? "Sending…" : "Resume"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
{/* Build log disclosure — visible for all node types that emitted lines */}
|
||||
{(liveLog?.length || isTerminal) && (
|
||||
<LogDisclosure
|
||||
executionId={executionId}
|
||||
node={node}
|
||||
liveLog={liveLog}
|
||||
streaming={streaming}
|
||||
isTerminal={isTerminal}
|
||||
status={status}
|
||||
fallbackLog={buildSummary?.logTail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// LogPanel renders the per-node live log output below the canvas. Tabs at
|
||||
// the top let the user pick which node's stream they're watching; the
|
||||
// active tab follows the most-recently-started node by default.
|
||||
function LogPanel(props: {
|
||||
nodeLogs: NodeLogs;
|
||||
activeNode: string | null;
|
||||
onActiveNodeChange: (node: string | null) => void;
|
||||
nodeStatuses: NodeStatuses;
|
||||
streaming: boolean;
|
||||
}) {
|
||||
const { nodeLogs, activeNode, onActiveNodeChange, nodeStatuses, streaming } = props;
|
||||
const nodes = Object.keys(nodeLogs);
|
||||
const lines = activeNode ? (nodeLogs[activeNode] ?? []) : [];
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom on new lines unless the user has scrolled up.
|
||||
const stickRef = useRef(true);
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (stickRef.current) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [lines.length, activeNode]);
|
||||
|
||||
function onScroll() {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
||||
stickRef.current = atBottom;
|
||||
function NodeStatusDot({ status }: { status: NodeStatus }) {
|
||||
if (status === "running") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-500">
|
||||
<span className="size-1.5 rounded-full bg-sky-500 animate-pulse" />
|
||||
running
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "success") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-emerald-600 dark:text-emerald-500">
|
||||
<span className="size-1.5 rounded-full bg-emerald-500" />
|
||||
succeeded
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "failed") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-rose-600 dark:text-rose-500">
|
||||
<span className="size-1.5 rounded-full bg-rose-500" />
|
||||
failed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "paused") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-amber-600 dark:text-amber-500">
|
||||
<span className="size-1.5 rounded-full bg-amber-500" />
|
||||
paused
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<span className="size-1.5 rounded-full bg-muted-foreground/60" />
|
||||
pending
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LogDisclosure(props: {
|
||||
executionId: string;
|
||||
node: PipelineNode;
|
||||
liveLog?: string[];
|
||||
streaming: boolean;
|
||||
isTerminal: boolean;
|
||||
status: NodeStatus;
|
||||
/** Fallback log content from the orchestrator's terminal payload
|
||||
* (e.g. `__build.log_tail`). Used when the archive isn't configured
|
||||
* and we joined the page after the run finished. */
|
||||
fallbackLog?: string;
|
||||
}) {
|
||||
const { executionId, node, liveLog, streaming, isTerminal, status, fallbackLog } = props;
|
||||
const [open, setOpen] = useState(false);
|
||||
const [archived, setArchived] = useState<string | null>(null);
|
||||
const [archiveErr, setArchiveErr] = useState<string | null>(null);
|
||||
const fetched = useRef(false);
|
||||
|
||||
const live = liveLog ?? [];
|
||||
// Show archived log when: run is terminal, no live lines this session,
|
||||
// and the archive endpoint returns 200.
|
||||
const tryArchive = isTerminal && live.length === 0 && (status === "success" || status === "failed");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !tryArchive || fetched.current) return;
|
||||
fetched.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/executions/${executionId}/logs/${encodeURIComponent(node.name)}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
setArchiveErr(`archive ${res.status}`);
|
||||
return;
|
||||
}
|
||||
setArchived(await res.text());
|
||||
} catch (e) {
|
||||
setArchiveErr(e instanceof Error ? e.message : "fetch failed");
|
||||
}
|
||||
})();
|
||||
}, [open, tryArchive, executionId, node.name]);
|
||||
|
||||
// Pick the best content source available, in priority order:
|
||||
// 1. live SSE buffer (this session)
|
||||
// 2. archived object (MinIO, if configured)
|
||||
// 3. fallbackLog (last 80 lines from log_tail in node_outputs)
|
||||
const usingArchive = archived !== null;
|
||||
const usingFallback = !usingArchive && live.length === 0 && Boolean(fallbackLog);
|
||||
|
||||
const labelStr = streaming
|
||||
? "live"
|
||||
: live.length > 0
|
||||
? "live (cached)"
|
||||
: usingArchive
|
||||
? "archived"
|
||||
: usingFallback
|
||||
? "tail"
|
||||
: tryArchive && archiveErr
|
||||
? `unarchived (${archiveErr})`
|
||||
: "no output yet";
|
||||
|
||||
return (
|
||||
<div className="flex h-64 shrink-0 flex-col border-t bg-background">
|
||||
<div className="flex items-center gap-1 overflow-x-auto border-b px-2 py-1">
|
||||
<span className="px-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Logs
|
||||
</span>
|
||||
{nodes.length === 0 ? (
|
||||
<span className="px-2 text-xs text-muted-foreground">
|
||||
{streaming ? "waiting for output…" : "no logs yet"}
|
||||
</span>
|
||||
) : (
|
||||
nodes.map((n) => {
|
||||
const status = nodeStatuses[n];
|
||||
const isActive = activeNode === n;
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => onActiveNodeChange(n)}
|
||||
className={
|
||||
"flex items-center gap-1.5 rounded-md px-2 py-1 text-xs transition-colors " +
|
||||
(isActive
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/60")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
"size-1.5 rounded-full " +
|
||||
(status === "running"
|
||||
? "bg-sky-500 animate-pulse"
|
||||
: status === "success"
|
||||
? "bg-emerald-500"
|
||||
: status === "failed"
|
||||
? "bg-rose-500"
|
||||
: "bg-muted-foreground/60")
|
||||
}
|
||||
/>
|
||||
<span className="truncate max-w-[160px]">{n}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{nodeLogs[n].length}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{streaming && (
|
||||
<span className="px-2 text-[10px] text-emerald-600 dark:text-emerald-500">
|
||||
● live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
className="flex-1 overflow-auto bg-zinc-950 px-3 py-2 font-mono text-[11px] leading-[1.4] text-zinc-200"
|
||||
<div className="border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center justify-between px-4 py-2 text-left text-xs font-medium hover:bg-muted/30"
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<div className="text-zinc-500">
|
||||
{activeNode
|
||||
? "waiting for output…"
|
||||
: "select a node tab above"}
|
||||
</div>
|
||||
) : (
|
||||
lines.map((l, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-all">
|
||||
{l.line}
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
{open ? (
|
||||
<ChevronDown className="size-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5" />
|
||||
)}
|
||||
build log
|
||||
</span>
|
||||
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{labelStr}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-t bg-zinc-950 px-3 py-2 font-mono text-[11px] leading-[1.45] text-zinc-200">
|
||||
{live.length > 0 ? (
|
||||
<div className="max-h-80 overflow-auto">
|
||||
{live.map((l, i) => (
|
||||
<div key={i} className="whitespace-pre-wrap break-all">
|
||||
{l}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : usingArchive ? (
|
||||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-all text-zinc-200">
|
||||
{archived}
|
||||
</pre>
|
||||
) : tryArchive && archived === null && !archiveErr ? (
|
||||
<div className="text-zinc-500">loading archive…</div>
|
||||
) : usingFallback ? (
|
||||
<>
|
||||
<div className="mb-2 text-[10px] uppercase tracking-wider text-amber-400">
|
||||
Showing the last lines from the orchestrator (log archive
|
||||
{archiveErr ? ` ${archiveErr}` : " not configured"}). Set
|
||||
MINIO_ENDPOINT on the flow service to enable full archive.
|
||||
</div>
|
||||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-all text-zinc-200">
|
||||
{fallbackLog}
|
||||
</pre>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-zinc-500">waiting for output…</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Pull image / digest / commit / command from a Build node's output items
|
||||
// in the orchestrator's terminal node_outputs payload.
|
||||
function extractBuildSummary(
|
||||
nodeName: string,
|
||||
outputs?: Record<string, unknown>
|
||||
): { image?: string; digest?: string; commit?: string; command?: string; logTail?: string } | null {
|
||||
if (!outputs) return null;
|
||||
const slot = outputs[nodeName];
|
||||
if (!slot || typeof slot !== "object") return null;
|
||||
// node_outputs[name] = {0: [items]}
|
||||
const portMap = slot as Record<string, unknown>;
|
||||
const port = portMap["0"];
|
||||
if (!Array.isArray(port) || port.length === 0) return null;
|
||||
const item = port[0] as Record<string, unknown>;
|
||||
const build = item["__build"] as Record<string, unknown> | undefined;
|
||||
if (!build) return null;
|
||||
const out: {
|
||||
image?: string;
|
||||
digest?: string;
|
||||
commit?: string;
|
||||
command?: string;
|
||||
logTail?: string;
|
||||
} = {};
|
||||
if (typeof build.image === "string") out.image = build.image;
|
||||
if (typeof build.commit === "string") out.commit = build.commit;
|
||||
if (typeof build.command === "string") out.command = build.command;
|
||||
if (typeof build.log_tail === "string") {
|
||||
out.logTail = build.log_tail;
|
||||
const m = build.log_tail.match(/sha256:[0-9a-f]{64}/);
|
||||
if (m) out.digest = m[0];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractTriggerSummary(
|
||||
nodeName: string,
|
||||
outputs?: Record<string, unknown>
|
||||
): { source?: string; repoUrl?: string; ref?: string } | null {
|
||||
if (!outputs) return null;
|
||||
const slot = outputs[nodeName];
|
||||
if (!slot || typeof slot !== "object") return null;
|
||||
const portMap = slot as Record<string, unknown>;
|
||||
const port = portMap["0"];
|
||||
if (!Array.isArray(port) || port.length === 0) return null;
|
||||
const item = port[0] as Record<string, unknown>;
|
||||
// Heuristic: trigger items carry source/agentId/repoUrl/ref.
|
||||
if (!item.source && !item.repoUrl) return null;
|
||||
return {
|
||||
source: typeof item.source === "string" ? item.source : undefined,
|
||||
repoUrl: typeof item.repoUrl === "string" ? item.repoUrl : undefined,
|
||||
ref: typeof item.ref === "string" ? item.ref : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { EmptySection } from "@/components/empty-section";
|
||||
|
||||
export default function GatewayPage() {
|
||||
return (
|
||||
<EmptySection
|
||||
title="Gateway"
|
||||
description="Public ingress for deployed agents — routes, auth, rate limits, OpenTelemetry export. Replaces the per-runtime gateway (Bedrock AgentCore endpoints, Vertex Reasoning Engine routes, K8s Ingress) with a uniform layer."
|
||||
/>
|
||||
);
|
||||
}
|
||||
+4
-2
@@ -3,10 +3,11 @@ import "./globals.css";
|
||||
|
||||
import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { RunsListener } from "@/components/runs-listener";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "flow",
|
||||
description: "Durable n8n-compatible workflow engine",
|
||||
title: "Langship",
|
||||
description: "Durable agent-pipeline runtime",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -25,6 +26,7 @@ export default function RootLayout({
|
||||
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<RunsListener />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Play, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { api, type Run } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
|
||||
function statusVariant(s?: string) {
|
||||
switch ((s || "").toLowerCase()) {
|
||||
case "success":
|
||||
case "completed":
|
||||
return "success" as const;
|
||||
case "running":
|
||||
case "pending":
|
||||
return "secondary" as const;
|
||||
case "failed":
|
||||
case "error":
|
||||
return "destructive" as const;
|
||||
case "waiting":
|
||||
case "paused":
|
||||
return "warning" as const;
|
||||
default:
|
||||
return "outline" as const;
|
||||
}
|
||||
}
|
||||
|
||||
export default function RunsListPage() {
|
||||
const [runs, setRuns] = useState<Run[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const list = await api.listRuns({ limit: 50 });
|
||||
setRuns(list);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "load failed");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, 2000);
|
||||
// Push notifications: reload immediately when any run is created.
|
||||
const es = new EventSource(api.runsStreamURL());
|
||||
es.onmessage = () => load();
|
||||
es.onerror = () => {
|
||||
/* polling fallback covers it */
|
||||
};
|
||||
return () => {
|
||||
clearInterval(t);
|
||||
es.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">Runs</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Recent pipeline executions across all agents.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={load}>
|
||||
<RefreshCw />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-destructive/40">
|
||||
<CardContent className="pt-6 text-sm text-destructive">{error}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{runs === null ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : runs.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<Play className="size-5 text-muted-foreground" />
|
||||
<div className="text-sm font-medium">No runs yet</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Trigger a run from an agent or pipeline.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{runs.map((r) => (
|
||||
<Card key={r.id} className="transition-colors hover:bg-muted/30">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{r.pipelineName || r.pipelineId}
|
||||
</span>
|
||||
<Badge variant={statusVariant(r.status)}>{r.status}</Badge>
|
||||
</div>
|
||||
<CardDescription className="mt-0.5 truncate font-mono text-[10px]">
|
||||
{r.id}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{formatDate(r.startedAt)}</span>
|
||||
<Button size="sm" variant="ghost" asChild>
|
||||
<Link href={`/executions/view/?id=${encodeURIComponent(r.id)}`}>
|
||||
Open
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Imports below are referenced via JSX above; ensure CardTitle isn't unused.
|
||||
void CardTitle;
|
||||
+145
-86
@@ -4,14 +4,18 @@ import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
Workflow,
|
||||
LayoutGrid,
|
||||
PlusCircle,
|
||||
Activity,
|
||||
BookOpen,
|
||||
ExternalLink,
|
||||
Github,
|
||||
LayoutDashboard,
|
||||
Bot,
|
||||
GitBranch,
|
||||
Play,
|
||||
CheckSquare,
|
||||
Layers,
|
||||
Radio,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
Sun,
|
||||
MoonStar,
|
||||
Monitor,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -19,18 +23,12 @@ import {
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarSeparator,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { PanelLeftClose, PanelLeft } from "lucide-react";
|
||||
|
||||
type NavItem = {
|
||||
title: string;
|
||||
@@ -40,6 +38,12 @@ type NavItem = {
|
||||
};
|
||||
|
||||
const primary: NavItem[] = [
|
||||
{
|
||||
title: "Dashboard",
|
||||
href: "/dashboard",
|
||||
icon: LayoutDashboard,
|
||||
match: (p) => p === "/dashboard",
|
||||
},
|
||||
{
|
||||
title: "Agents",
|
||||
href: "/agents",
|
||||
@@ -49,38 +53,32 @@ const primary: NavItem[] = [
|
||||
{
|
||||
title: "Pipelines",
|
||||
href: "/",
|
||||
icon: LayoutGrid,
|
||||
match: (p) => p === "/" || p.startsWith("/flows/view"),
|
||||
icon: GitBranch,
|
||||
match: (p) => p === "/" || p.startsWith("/flows"),
|
||||
},
|
||||
{
|
||||
title: "New pipeline",
|
||||
href: "/flows/new",
|
||||
icon: PlusCircle,
|
||||
match: (p) => p.startsWith("/flows/new"),
|
||||
title: "Runs",
|
||||
href: "/runs",
|
||||
icon: Play,
|
||||
match: (p) => p === "/runs" || p.startsWith("/executions"),
|
||||
},
|
||||
{
|
||||
title: "Executions",
|
||||
href: "/executions/view",
|
||||
icon: Activity,
|
||||
match: (p) => p.startsWith("/executions"),
|
||||
},
|
||||
];
|
||||
|
||||
const docs = [
|
||||
{
|
||||
title: "n8n compatibility",
|
||||
href: "https://github.com/lyzrai/flow#n8n-compatible",
|
||||
external: true,
|
||||
title: "Approvals",
|
||||
href: "/approvals",
|
||||
icon: CheckSquare,
|
||||
match: (p) => p.startsWith("/approvals"),
|
||||
},
|
||||
{
|
||||
title: "Durability model",
|
||||
href: "https://github.com/lyzrai/flow#durability",
|
||||
external: true,
|
||||
title: "Environments",
|
||||
href: "/environments",
|
||||
icon: Layers,
|
||||
match: (p) => p.startsWith("/environments"),
|
||||
},
|
||||
{
|
||||
title: "Embed as Go library",
|
||||
href: "https://github.com/lyzrai/flow#embedding",
|
||||
external: true,
|
||||
title: "Gateway",
|
||||
href: "/gateway",
|
||||
icon: Radio,
|
||||
match: (p) => p.startsWith("/gateway"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -88,20 +86,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const pathname = usePathname() || "/";
|
||||
|
||||
return (
|
||||
<Sidebar variant="floating" {...props}>
|
||||
<Sidebar variant="floating" collapsible="icon" {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link href="/">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<Workflow className="size-4" />
|
||||
<LangshipMark />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span className="font-semibold">flow</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
pre-v0.1 · durable pipelines
|
||||
</span>
|
||||
<span className="font-semibold">Langship</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
@@ -111,7 +106,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
|
||||
<SidebarMenu className="gap-1">
|
||||
{primary.map((item) => {
|
||||
const Icon = item.icon;
|
||||
@@ -131,55 +125,15 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>
|
||||
<BookOpen className="mr-1 size-3.5" />
|
||||
Reference
|
||||
</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="font-medium" disabled>
|
||||
Documentation
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuSub className="ml-0 border-l-0 px-1.5">
|
||||
{docs.map((d) => (
|
||||
<SidebarMenuSubItem key={d.href}>
|
||||
<SidebarMenuSubButton asChild>
|
||||
<a href={d.href} target="_blank" rel="noreferrer">
|
||||
<span className="truncate">{d.title}</span>
|
||||
{d.external && (
|
||||
<ExternalLink className="ml-auto size-3 opacity-60" />
|
||||
)}
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<a
|
||||
href="https://github.com/lyzrai/flow"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Github className="size-4" />
|
||||
<span>GitHub</span>
|
||||
<ExternalLink className="ml-auto size-3 opacity-60" />
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
<CollapseToggle />
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<CollapseToggle />
|
||||
<ThemeToggle />
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
@@ -187,6 +141,25 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
);
|
||||
}
|
||||
|
||||
function LangshipMark() {
|
||||
// Tiny anchor/route mark — placeholder for the real Langship logo.
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="size-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="6" cy="6" r="2" />
|
||||
<circle cx="18" cy="18" r="2" />
|
||||
<path d="M6 8v6a4 4 0 0 0 4 4h6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapseToggle() {
|
||||
const { toggleSidebar, state } = useSidebar();
|
||||
const collapsed = state === "collapsed";
|
||||
@@ -198,3 +171,89 @@ function CollapseToggle() {
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
// ThemeToggle is a 3-way segmented control (Light / System / Dark) that
|
||||
// writes the theme to <html class>. Persists in localStorage. When the
|
||||
// sidebar is collapsed to icons, it renders a single button that cycles
|
||||
// through the three themes instead.
|
||||
function ThemeToggle() {
|
||||
const { state } = useSidebar();
|
||||
const collapsed = state === "collapsed";
|
||||
const [theme, setTheme] = React.useState<"light" | "dark" | "system">("system");
|
||||
|
||||
React.useEffect(() => {
|
||||
const saved =
|
||||
(typeof window !== "undefined" &&
|
||||
(localStorage.getItem("flow-theme") as
|
||||
| "light"
|
||||
| "dark"
|
||||
| "system"
|
||||
| null)) ||
|
||||
"system";
|
||||
setTheme(saved);
|
||||
apply(saved);
|
||||
}, []);
|
||||
|
||||
function apply(t: "light" | "dark" | "system") {
|
||||
const root = document.documentElement;
|
||||
const prefersDark =
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
const dark = t === "dark" || (t === "system" && prefersDark);
|
||||
root.classList.toggle("dark", dark);
|
||||
localStorage.setItem("flow-theme", t);
|
||||
}
|
||||
|
||||
function pick(next: "light" | "dark" | "system") {
|
||||
setTheme(next);
|
||||
apply(next);
|
||||
}
|
||||
|
||||
const items: { id: "light" | "system" | "dark"; icon: typeof Sun; label: string }[] = [
|
||||
{ id: "light", icon: Sun, label: "Light" },
|
||||
{ id: "system", icon: Monitor, label: "System" },
|
||||
{ id: "dark", icon: MoonStar, label: "Dark" },
|
||||
];
|
||||
|
||||
if (collapsed) {
|
||||
const current = items.find((i) => i.id === theme) ?? items[1];
|
||||
const Icon = current.icon;
|
||||
const next = items[(items.findIndex((i) => i.id === theme) + 1) % items.length].id;
|
||||
return (
|
||||
<SidebarMenuButton
|
||||
onClick={() => pick(next)}
|
||||
className="text-muted-foreground"
|
||||
title={`Theme: ${current.label} (click to cycle)`}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{current.label}</span>
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-2 mb-1 flex rounded-md border bg-muted/40 p-0.5">
|
||||
{items.map((it) => {
|
||||
const Icon = it.icon;
|
||||
const active = theme === it.id;
|
||||
return (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
onClick={() => pick(it.id)}
|
||||
aria-pressed={active}
|
||||
className={
|
||||
"flex flex-1 items-center justify-center gap-1 rounded-sm px-2 py-1 text-[11px] transition-colors " +
|
||||
(active
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground")
|
||||
}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
<span>{it.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,33 +28,34 @@ export function FlowNode({ data, selected }: NodeProps) {
|
||||
const outputs = entry?.outputs ?? 1;
|
||||
const isTrigger = pn.type === "flow-nodes-base.trigger";
|
||||
|
||||
// Per-type accent colour for the TYPE label. Keeps the existing
|
||||
// catalog `color` (full bg) but extracts the hue for header text.
|
||||
const typeAccent = typeAccentClass(entry?.color);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-[200px] rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow",
|
||||
"min-w-[260px] rounded-xl border bg-card px-4 py-3 text-card-foreground shadow-sm transition-shadow",
|
||||
selected ? "ring-2 ring-primary shadow-md" : "hover:shadow-md",
|
||||
!entry && "border-destructive/60",
|
||||
status === "running" && "ring-2 ring-sky-500 animate-pulse",
|
||||
status === "success" && "ring-2 ring-emerald-500",
|
||||
status === "running" && "ring-2 ring-sky-500/60",
|
||||
status === "success" && "ring-1 ring-emerald-500/40",
|
||||
status === "failed" && "ring-2 ring-rose-500",
|
||||
status === "paused" && "ring-2 ring-amber-500"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-t-lg px-3 py-2 text-xs font-medium text-white",
|
||||
entry?.color ?? "bg-destructive"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
<span className="truncate">{entry?.label ?? "Unsupported"}</span>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium">{pn.name}</span>
|
||||
<StatusIcon status={status} />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className={cn("flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-widest", typeAccent)}>
|
||||
<Icon className="size-3.5" />
|
||||
<span>{(entry?.label ?? "Unsupported")}</span>
|
||||
</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{pn.type}</div>
|
||||
<StatusPill status={status} />
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<span className="truncate text-xl font-semibold leading-tight">
|
||||
{pn.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Input handle: triggers have no inputs */}
|
||||
@@ -85,15 +86,75 @@ export function FlowNode({ data, selected }: NodeProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status?: NodeRunStatus }) {
|
||||
if (!status || status === "pending") return null;
|
||||
if (status === "running")
|
||||
return <Loader2 className="size-3.5 animate-spin text-sky-500" />;
|
||||
if (status === "success")
|
||||
return <CheckCircle2 className="size-3.5 text-emerald-500" />;
|
||||
if (status === "failed")
|
||||
return <XCircle className="size-3.5 text-rose-500" />;
|
||||
if (status === "paused")
|
||||
return <PauseCircle className="size-3.5 text-amber-500" />;
|
||||
function StatusPill({ status }: { status?: NodeRunStatus }) {
|
||||
if (!status || status === "pending") {
|
||||
return (
|
||||
<span className="rounded-full border bg-muted/40 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
pending
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "running") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-sky-500/40 bg-sky-500/15 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-sky-700 dark:text-sky-400">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
running
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "success") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-500/40 bg-emerald-500/15 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-emerald-700 dark:text-emerald-400">
|
||||
<CheckCircle2 className="size-3" />
|
||||
succeeded
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "failed") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-rose-500/40 bg-rose-500/15 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-rose-700 dark:text-rose-400">
|
||||
<XCircle className="size-3" />
|
||||
failed
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === "paused") {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/15 px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-widest text-amber-700 dark:text-amber-400">
|
||||
<PauseCircle className="size-3" />
|
||||
paused
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// typeAccentClass picks a tailwind text-color class that pairs with the
|
||||
// node's catalog `color` (bg-X-500) so the TYPE row reads as a distinct
|
||||
// accent against the card background.
|
||||
function typeAccentClass(bg?: string): string {
|
||||
switch (bg) {
|
||||
case "bg-emerald-500":
|
||||
return "text-emerald-500";
|
||||
case "bg-amber-500":
|
||||
return "text-amber-500";
|
||||
case "bg-sky-500":
|
||||
return "text-sky-500";
|
||||
case "bg-violet-500":
|
||||
return "text-violet-500";
|
||||
case "bg-indigo-500":
|
||||
return "text-indigo-500";
|
||||
case "bg-fuchsia-500":
|
||||
return "text-fuchsia-500";
|
||||
case "bg-rose-500":
|
||||
case "bg-red-600":
|
||||
return "text-rose-500";
|
||||
case "bg-orange-500":
|
||||
return "text-orange-500";
|
||||
case "bg-slate-500":
|
||||
case "bg-slate-400":
|
||||
return "text-slate-500";
|
||||
default:
|
||||
return "text-primary";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function EmptySection(props: {
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
status?: "soon" | "alpha";
|
||||
}) {
|
||||
const { title, description, status = "soon" } = props;
|
||||
return (
|
||||
<div className="flex h-full flex-col items-start gap-6 p-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">{title}</h1>
|
||||
<span className="rounded-md border bg-muted/30 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{status === "soon" ? "coming soon" : "alpha"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="max-w-lg text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Play, X } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface RunCreated {
|
||||
type: string;
|
||||
executionId: string;
|
||||
pipelineId?: string;
|
||||
pipelineName?: string;
|
||||
agentId?: string;
|
||||
source?: string;
|
||||
startedAt?: string;
|
||||
}
|
||||
|
||||
type Toast = RunCreated & { receivedAt: number };
|
||||
|
||||
// RunsListener mounts once at the app root, opens an SSE stream to
|
||||
// /api/runs/stream, and renders a stack of "new run" toasts in the
|
||||
// bottom-right corner. Each toast links to the live run view.
|
||||
//
|
||||
// Toasts auto-dismiss after 12s. Clicking the stream's link navigates to
|
||||
// the live execution page (which has its own per-execution SSE feed).
|
||||
export function RunsListener() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let es: EventSource | null = null;
|
||||
let backoff = 1000; // ms — grows on consecutive errors, capped at 30s
|
||||
|
||||
function connect() {
|
||||
es = new EventSource(api.runsStreamURL());
|
||||
es.onopen = () => {
|
||||
backoff = 1000;
|
||||
};
|
||||
es.onerror = () => {
|
||||
es?.close();
|
||||
es = null;
|
||||
// EventSource doesn't reconnect cleanly when nginx / cloudflared
|
||||
// drops the connection — fall back to manual reconnect with
|
||||
// bounded backoff so we keep getting events after a server restart.
|
||||
const wait = Math.min(backoff, 30_000);
|
||||
backoff = Math.min(backoff * 2, 30_000);
|
||||
setTimeout(connect, wait);
|
||||
};
|
||||
es.onmessage = (msg) => {
|
||||
try {
|
||||
const ev: RunCreated = JSON.parse(msg.data);
|
||||
if (ev.type !== "run_created" || !ev.executionId) return;
|
||||
setToasts((prev) => {
|
||||
const next = [...prev, { ...ev, receivedAt: Date.now() }];
|
||||
// Cap visible stack to 4
|
||||
return next.slice(-4);
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}
|
||||
connect();
|
||||
return () => {
|
||||
es?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-dismiss
|
||||
useEffect(() => {
|
||||
if (toasts.length === 0) return;
|
||||
const t = setInterval(() => {
|
||||
const cutoff = Date.now() - 12_000;
|
||||
setToasts((prev) => prev.filter((x) => x.receivedAt > cutoff));
|
||||
}, 1000);
|
||||
return () => clearInterval(t);
|
||||
}, [toasts.length]);
|
||||
|
||||
function dismiss(id: string) {
|
||||
setToasts((prev) => prev.filter((t) => t.executionId !== id));
|
||||
}
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-6 right-6 z-40 flex flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
<ToastCard key={t.executionId} toast={t} onDismiss={dismiss} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastCard({
|
||||
toast,
|
||||
onDismiss,
|
||||
}: {
|
||||
toast: Toast;
|
||||
onDismiss: (id: string) => void;
|
||||
}) {
|
||||
const sourceLabel =
|
||||
toast.source === "github_push"
|
||||
? "GitHub push"
|
||||
: toast.source === "manual"
|
||||
? "Manual trigger"
|
||||
: (toast.source ?? "New run");
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto flex w-80 items-start gap-3 rounded-lg border bg-background p-3 shadow-xl">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-md bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
|
||||
<Play className="size-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{sourceLabel}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{toast.pipelineName || toast.pipelineId || "pipeline"}
|
||||
</div>
|
||||
<Link
|
||||
href={`/executions/view/?id=${encodeURIComponent(toast.executionId)}`}
|
||||
className="mt-1 inline-block text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
Open live view →
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDismiss(toast.executionId)}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -171,6 +171,9 @@ export const api = {
|
||||
/** Returns the EventSource URL for SSE streaming of an execution. */
|
||||
executionStreamURL: (id: string) => `${base}/api/executions/${id}/stream`,
|
||||
|
||||
/** Global runs feed — fires once per dispatched run. */
|
||||
runsStreamURL: () => `${base}/api/runs/stream`,
|
||||
|
||||
listRuns: (params?: { pipelineId?: string; limit?: number }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.pipelineId) qs.set("pipeline_id", params.pipelineId);
|
||||
|
||||
Reference in New Issue
Block a user