Skip to content

Commit 12e3288

Browse files
committed
feat(flow): add Flow Viewer and Flow Test Runner components with automation integration
1 parent 3a2a353 commit 12e3288

8 files changed

Lines changed: 859 additions & 3 deletions

File tree

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { useEffect, useState, useCallback } from 'react';
4+
import { useParams } from '@tanstack/react-router';
5+
import { useClient } from '@objectstack/client-react';
6+
import { useScopedClient } from '@/hooks/useObjectStackClient';
7+
import { Button } from '@/components/ui/button';
8+
import { Badge } from '@/components/ui/badge';
9+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
10+
import { Skeleton } from '@/components/ui/skeleton';
11+
import {
12+
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
13+
} from '@/components/ui/table';
14+
import {
15+
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription,
16+
} from '@/components/ui/sheet';
17+
import { ScrollArea } from '@/components/ui/scroll-area';
18+
import { RefreshCw, Loader2, CheckCircle2, XCircle, Clock, AlertCircle } from 'lucide-react';
19+
import { JsonTree } from './MetadataInspector';
20+
21+
interface FlowRunsPanelProps {
22+
flowName: string;
23+
refreshKey?: number;
24+
}
25+
26+
interface RunSummary {
27+
id: string;
28+
status: string;
29+
startedAt: string;
30+
completedAt?: string;
31+
durationMs?: number;
32+
flowVersion?: number;
33+
trigger?: { type?: string };
34+
}
35+
36+
function StatusBadge({ status }: { status: string }) {
37+
const map: Record<string, { variant: 'default' | 'secondary' | 'destructive' | 'outline'; icon: any; cls: string }> = {
38+
success: { variant: 'outline', icon: CheckCircle2, cls: 'text-emerald-600 border-emerald-300' },
39+
completed: { variant: 'outline', icon: CheckCircle2, cls: 'text-emerald-600 border-emerald-300' },
40+
failed: { variant: 'outline', icon: XCircle, cls: 'text-red-600 border-red-300' },
41+
error: { variant: 'outline', icon: XCircle, cls: 'text-red-600 border-red-300' },
42+
running: { variant: 'outline', icon: Loader2, cls: 'text-blue-600 border-blue-300 animate-pulse' },
43+
pending: { variant: 'outline', icon: Clock, cls: 'text-amber-600 border-amber-300' },
44+
skipped: { variant: 'outline', icon: AlertCircle, cls: 'text-muted-foreground' },
45+
};
46+
const cfg = map[status] ?? { variant: 'outline' as const, icon: AlertCircle, cls: 'text-muted-foreground' };
47+
const Icon = cfg.icon;
48+
return (
49+
<Badge variant={cfg.variant} className={`text-[10px] font-mono inline-flex items-center gap-1 ${cfg.cls}`}>
50+
<Icon className="h-3 w-3" />
51+
{status}
52+
</Badge>
53+
);
54+
}
55+
56+
function fmtDate(s?: string) {
57+
if (!s) return '—';
58+
try { return new Date(s).toLocaleString(); } catch { return s; }
59+
}
60+
61+
export function FlowRunsPanel({ flowName, refreshKey }: FlowRunsPanelProps) {
62+
const params = useParams({ strict: false }) as { projectId?: string };
63+
const unscoped = useClient();
64+
const scoped = useScopedClient(params.projectId);
65+
const client: any = scoped ?? unscoped;
66+
67+
const [runs, setRuns] = useState<RunSummary[]>([]);
68+
const [loading, setLoading] = useState(true);
69+
const [error, setError] = useState<string | null>(null);
70+
71+
const [openRunId, setOpenRunId] = useState<string | null>(null);
72+
const [runDetail, setRunDetail] = useState<any>(null);
73+
const [detailLoading, setDetailLoading] = useState(false);
74+
const [detailError, setDetailError] = useState<string | null>(null);
75+
76+
const loadRuns = useCallback(async () => {
77+
if (!client?.automation?.listRuns) {
78+
setError('automation.listRuns is not available on this client');
79+
setLoading(false);
80+
return;
81+
}
82+
setLoading(true);
83+
setError(null);
84+
try {
85+
const res: any = await client.automation.listRuns(flowName, { limit: 20 });
86+
const items: RunSummary[] = Array.isArray(res) ? res : (res?.items ?? res?.runs ?? []);
87+
setRuns(items);
88+
} catch (e: any) {
89+
setError(e?.message ?? String(e));
90+
} finally {
91+
setLoading(false);
92+
}
93+
}, [client, flowName]);
94+
95+
useEffect(() => { loadRuns(); }, [loadRuns, refreshKey]);
96+
97+
const openRun = async (runId: string) => {
98+
setOpenRunId(runId);
99+
setRunDetail(null);
100+
setDetailError(null);
101+
setDetailLoading(true);
102+
try {
103+
const res: any = await client.automation.getRun(flowName, runId);
104+
setRunDetail(res?.run ?? res);
105+
} catch (e: any) {
106+
setDetailError(e?.message ?? String(e));
107+
} finally {
108+
setDetailLoading(false);
109+
}
110+
};
111+
112+
return (
113+
<>
114+
<Card>
115+
<CardHeader className="pb-3">
116+
<div className="flex items-center justify-between">
117+
<CardTitle className="text-base">Recent Runs</CardTitle>
118+
<Button variant="outline" size="sm" onClick={loadRuns} disabled={loading}>
119+
{loading ? <Loader2 className="h-3.5 w-3.5 mr-2 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5 mr-2" />}
120+
Refresh
121+
</Button>
122+
</div>
123+
</CardHeader>
124+
<CardContent>
125+
{loading && (
126+
<div className="space-y-2">
127+
<Skeleton className="h-8 w-full" />
128+
<Skeleton className="h-8 w-full" />
129+
<Skeleton className="h-8 w-full" />
130+
</div>
131+
)}
132+
{!loading && error && (
133+
<p className="text-sm text-red-500 font-mono break-all">{error}</p>
134+
)}
135+
{!loading && !error && runs.length === 0 && (
136+
<p className="text-sm text-muted-foreground py-6 text-center">
137+
No runs yet. Use the Test Run tab to invoke this flow.
138+
</p>
139+
)}
140+
{!loading && !error && runs.length > 0 && (
141+
<Table>
142+
<TableHeader>
143+
<TableRow>
144+
<TableHead>Run</TableHead>
145+
<TableHead>Status</TableHead>
146+
<TableHead>Started</TableHead>
147+
<TableHead className="text-right">Duration</TableHead>
148+
</TableRow>
149+
</TableHeader>
150+
<TableBody>
151+
{runs.map(r => (
152+
<TableRow
153+
key={r.id}
154+
className="cursor-pointer"
155+
onClick={() => openRun(r.id)}
156+
>
157+
<TableCell className="font-mono text-xs">{r.id.slice(0, 12)}</TableCell>
158+
<TableCell><StatusBadge status={r.status} /></TableCell>
159+
<TableCell className="text-xs text-muted-foreground">{fmtDate(r.startedAt)}</TableCell>
160+
<TableCell className="text-right font-mono text-xs">
161+
{typeof r.durationMs === 'number' ? `${r.durationMs} ms` : '—'}
162+
</TableCell>
163+
</TableRow>
164+
))}
165+
</TableBody>
166+
</Table>
167+
)}
168+
</CardContent>
169+
</Card>
170+
171+
<Sheet open={!!openRunId} onOpenChange={(o) => { if (!o) { setOpenRunId(null); setRunDetail(null); } }}>
172+
<SheetContent side="right" className="w-full sm:max-w-2xl p-0 flex flex-col">
173+
<SheetHeader className="p-4 border-b">
174+
<SheetTitle className="font-mono text-sm break-all">Run {openRunId}</SheetTitle>
175+
<SheetDescription>
176+
{runDetail && (
177+
<span className="inline-flex items-center gap-2">
178+
<StatusBadge status={runDetail.status} />
179+
{typeof runDetail.durationMs === 'number' && (
180+
<Badge variant="secondary" className="text-[10px] font-mono">{runDetail.durationMs} ms</Badge>
181+
)}
182+
<span className="text-xs">{fmtDate(runDetail.startedAt)}</span>
183+
</span>
184+
)}
185+
</SheetDescription>
186+
</SheetHeader>
187+
<ScrollArea className="flex-1 p-4">
188+
{detailLoading && (
189+
<div className="space-y-2">
190+
<Skeleton className="h-16 w-full" />
191+
<Skeleton className="h-16 w-full" />
192+
<Skeleton className="h-16 w-full" />
193+
</div>
194+
)}
195+
{detailError && (
196+
<p className="text-sm text-red-500 font-mono break-all">{detailError}</p>
197+
)}
198+
{!detailLoading && runDetail && (
199+
<div className="space-y-3">
200+
{Array.isArray(runDetail.steps) && runDetail.steps.length > 0 ? (
201+
runDetail.steps.map((step: any, idx: number) => (
202+
<div key={`${step.nodeId}-${idx}`} className="rounded-md border p-3 space-y-2">
203+
<div className="flex items-center gap-2 flex-wrap">
204+
<span className="text-muted-foreground font-mono text-xs">#{idx + 1}</span>
205+
<span className="font-mono text-sm font-medium">{step.nodeLabel || step.nodeId}</span>
206+
{step.nodeType && (
207+
<Badge variant="outline" className="text-[10px] font-mono">{step.nodeType}</Badge>
208+
)}
209+
<StatusBadge status={step.status} />
210+
{typeof step.durationMs === 'number' && (
211+
<Badge variant="secondary" className="text-[10px] font-mono ml-auto">
212+
{step.durationMs} ms
213+
</Badge>
214+
)}
215+
</div>
216+
{step.input !== undefined && (
217+
<div>
218+
<div className="text-[10px] uppercase text-muted-foreground mb-1">Input</div>
219+
<JsonTree data={step.input} />
220+
</div>
221+
)}
222+
{step.output !== undefined && (
223+
<div>
224+
<div className="text-[10px] uppercase text-muted-foreground mb-1">Output</div>
225+
<JsonTree data={step.output} />
226+
</div>
227+
)}
228+
{step.error && (
229+
<div>
230+
<div className="text-[10px] uppercase text-red-500 mb-1">Error</div>
231+
<JsonTree data={step.error} />
232+
</div>
233+
)}
234+
</div>
235+
))
236+
) : (
237+
<div>
238+
<div className="text-[10px] uppercase text-muted-foreground mb-1">Run</div>
239+
<JsonTree data={runDetail} />
240+
</div>
241+
)}
242+
</div>
243+
)}
244+
</ScrollArea>
245+
</SheetContent>
246+
</Sheet>
247+
</>
248+
);
249+
}

0 commit comments

Comments
 (0)