History

feat(execution-history): redesign UI to list-with-drawer pattern

Replace card list with table-row layout featuring inline status dots. Add status filter dropdown and text search for code/errors. Implement slide-in drawer (Dialog) for execution details. Split detail view into Properties and Logs tabs. Add Copy JSON action with copied feedback. Highlight durations >5000ms in red for visibility.

Saatvik Arya committed 7 months ago

Commit 29345981 parent e8209cb1 file · +501 −191Browse files

Showing the changes to plugins (1 of 1 file). Show all

Unified · Split

plugins/execution-history/react/components.tsx+501−191
@@ -1,3 +1,4 @@
1import { useState, useMemo, useCallback } from "react";
1import type {2import type {
2 Execution,3 Execution,
3 ExecutionEnvelope,4 ExecutionEnvelope,
@@ -7,56 +8,52 @@ import { · 3 unmodified lines
7 useExecutions,8 useExecutions,
8} from "@executor/react";9} from "@executor/react";
9import {10import {
10 Alert,
11 Badge,11 Badge,
12 Button,12 Button,
13 Card,13 cn,
14 Dialog,
15 DialogOverlay,
16 DialogPopup,
17 DialogPortal,
14 DocumentPanel,18 DocumentPanel,
15 EmptyState,19 EmptyState,
20 IconClose,
21 Input,
16 LoadableBlock,22 LoadableBlock,
23 Select,
17 useExecutorPluginNavigation,24 useExecutorPluginNavigation,
18 useExecutorPluginRouteParams,25 useExecutorPluginRouteParams,
26 useExecutorPluginSearch,
19} from "@executor/react/plugins";27} from "@executor/react/plugins";
20 28
21const formatTimestamp = (value: number | null): string => {29// ── Helpers ──────────────────────────────────────────────────────────────
22 if (value === null) {30
23 return "—";31type ExecutionStatus = Execution["status"];
24 }
25 32
26 return new Intl.DateTimeFormat(undefined, {33const formatTimestamp = (value: number | null): string => {
27 dateStyle: "medium",34 if (value === null) return "—";
28 timeStyle: "short",35 const d = new Date(value);
29 }).format(value);36 const month = d.toLocaleString(undefined, { month: "short" });
37 const day = d.getDate();
38 const h = d.getHours().toString().padStart(2, "0");
39 const m = d.getMinutes().toString().padStart(2, "0");
40 const s = d.getSeconds().toString().padStart(2, "0");
41 return `${month} ${day}, ${h}:${m}:${s}`;
30};42};
31 43
32const formatDuration = (execution: Execution): string | null => {44const formatDuration = (execution: Execution): string | null => {
33 if (execution.startedAt === null || execution.completedAt === null) {45 if (execution.startedAt === null || execution.completedAt === null) return null;
34 return null;46 const ms = Math.max(0, execution.completedAt - execution.startedAt);
35 }47 if (ms < 1_000) return `${ms}ms`;
36 48 if (ms < 60_000) return `${(ms / 1_000).toFixed(1)}s`;
37 const durationMs = Math.max(0, execution.completedAt - execution.startedAt);49 return `${(ms / 60_000).toFixed(1)}m`;
38 if (durationMs < 1_000) {
39 return `${durationMs} ms`;
40 }
41
42 if (durationMs < 60_000) {
43 return `${(durationMs / 1_000).toFixed(1)} s`;
44 }
45
46 return `${(durationMs / 60_000).toFixed(1)} min`;
47};50};
48 51
49const summarizeCode = (code: string): string =>52const truncateCode = (code: string, max = 80): string =>
50 code53 code.trim().replace(/\s+/g, " ").slice(0, max);
51 .trim()
52 .replace(/\s+/g, " ")
53 .slice(0, 220);
54 54
55const formatJsonDocument = (value: string | null): string | null => {55const formatJsonDocument = (value: string | null): string | null => {
56 if (value === null) {56 if (value === null) return null;
57 return null;
58 }
59
60 try {57 try {
61 return JSON.stringify(JSON.parse(value), null, 2);58 return JSON.stringify(JSON.parse(value), null, 2);
62 } catch {59 } catch {
@@ -64,136 +61,160 @@ const formatJsonDocument = (value: string | null): string | null => { · 1 unmodified line
64 }61 }
65};62};
66 63
67const statusVariant = (64const statusDotColor = (status: ExecutionStatus): string => {
68 status: Execution["status"],65 switch (status) {
69): "default" | "muted" | "outline" | "destructive" => {66 case "completed":
67 return "bg-emerald-400";
68 case "failed":
69 return "bg-red-400";
70 case "running":
71 return "bg-blue-400 animate-pulse";
72 case "waiting_for_interaction":
73 return "bg-amber-400 animate-pulse";
74 case "pending":
75 return "bg-zinc-500";
76 case "cancelled":
77 return "bg-zinc-500";
78 }
79};
80
81const STATUS_OPTIONS: Array<{ value: string; label: string }> = [
82 { value: "all", label: "All" },
83 { value: "completed", label: "Completed" },
84 { value: "failed", label: "Failed" },
85 { value: "running", label: "Running" },
86 { value: "waiting_for_interaction", label: "Waiting" },
87 { value: "pending", label: "Pending" },
88 { value: "cancelled", label: "Cancelled" },
89];
90
91const formatDurationMs = (execution: Execution): string | null => {
92 if (execution.startedAt === null || execution.completedAt === null) return null;
93 const ms = Math.max(0, execution.completedAt - execution.startedAt);
94 return ms.toLocaleString();
95};
96
97const statusColor = (status: ExecutionStatus): string => {
70 switch (status) {98 switch (status) {
71 case "completed":99 case "completed":
72 return "default";100 return "text-emerald-400";
73 case "failed":101 case "failed":
74 return "destructive";102 return "text-red-400";
75 case "running":103 case "running":
104 return "text-blue-400";
76 case "waiting_for_interaction":105 case "waiting_for_interaction":
77 return "outline";106 return "text-amber-400";
78 default:107 default:
79 return "muted";108 return "text-zinc-400";
80 }109 }
81};110};
82 111
83const MetadataItem = (props: {112// ── Execution Row ────────────────────────────────────────────────────────
84 label: string;113
85 value: string;114const KeyValue = (props: { k: string; v: string; vClass?: string }) => (
86}) => (115 <>
87 <Card className="bg-card/60 px-3 py-2">116 <span className="text-muted-foreground/60">{props.k}:</span>{" "}
88 <div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">117 <span className={props.vClass ?? "text-emerald-400"}>{props.v}</span>{" "}
89 {props.label}118 </>
90 </div>
91 <div className="mt-1 text-sm text-foreground">{props.value}</div>
92 </Card>
93);119);
94 120
95const ExecutionCard = (props: {121const ExecutionRow = (props: {
96 execution: Execution;122 execution: Execution;
97 onOpen: () => void;123 isSelected: boolean;
124 onSelect: () => void;
98}) => {125}) => {
99 const { execution } = props;126 const { execution, isSelected } = props;
127 const durationMs = formatDurationMs(execution);
100 128
101 return (129 return (
102 <Button130 <button
103 variant="ghost"
104 type="button"131 type="button"
105 onClick={props.onOpen}132 onClick={props.onSelect}
106 className="h-auto w-full rounded-2xl border border-border bg-card px-5 py-4 text-left transition-colors hover:border-primary/25 hover:bg-card/90"133 className={cn(
107 >134 "group flex w-full items-start gap-3 border-b border-border/50 px-4 py-2 text-left font-mono text-xs transition-colors hover:bg-white/[0.04]",
108 <div className="flex items-start justify-between gap-3">135 isSelected && "bg-white/[0.06]",
109 <div className="min-w-0 flex-1">
110 <div className="flex items-center gap-2">
111 <div className="truncate text-sm font-semibold text-foreground">
112 {execution.id}
113 </div>
114 <Badge variant={statusVariant(execution.status)}>
115 {execution.status.replaceAll("_", " ")}
116 </Badge>
117 </div>
118 <div className="mt-1 text-xs text-muted-foreground">
119 Created {formatTimestamp(execution.createdAt)}
120 {formatDuration(execution) ? ` • ${formatDuration(execution)}` : ""}
121 </div>
122 <p className="mt-3 line-clamp-3 text-sm leading-6 text-muted-foreground">
123 {summarizeCode(execution.code) || "No code captured."}
124 </p>
125 </div>
126 </div>
127
128 {execution.errorText && (
129 <Alert variant="destructive" className="mt-3 text-xs">
130 {execution.errorText}
131 </Alert>
132 )}136 )}
133 </Button>137 >
138 <span
139 className={cn(
140 "mt-1.5 size-2 shrink-0 rounded-full",
141 statusDotColor(execution.status),
142 )}
143 />
144 <span className="w-[120px] shrink-0 tabular-nums text-muted-foreground">
145 {formatTimestamp(execution.createdAt)}
146 </span>
147 <span className="inline-flex w-[130px] shrink-0">
148 <span className="text-muted-foreground/60">status:</span>
149 <span className={statusColor(execution.status)}>{execution.status.replaceAll("_", " ")}</span>
150 </span>
151 <span className="inline-flex w-[110px] shrink-0">
152 <span className="text-muted-foreground/60">duration_ms:</span>{" "}
153 <span className={durationMs && Number(durationMs.replace(/,/g, "")) > 5000 ? "text-red-400" : "text-emerald-400"}>{durationMs ?? "—"}</span>
154 </span>
155 <span className="min-w-0 flex-1 truncate">
156 <span className="text-muted-foreground/60">code:</span>{" "}
157 <span className="text-muted-foreground">&quot;{truncateCode(execution.code, 120)}…&quot;</span>
158 </span>
159 </button>
134 );160 );
135};161};
136 162
137export function ExecutionHistoryPage() {163// ── Detail Drawer ────────────────────────────────────────────────────────
138 const executions = useExecutions();
139 const navigation = useExecutorPluginNavigation();
140 164
141 return (165type DetailTab = "properties" | "logs";
142 <div className="h-full overflow-y-auto">
143 <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14">
144 <div className="mb-8">
145 <h1 className="font-display text-3xl tracking-tight text-foreground lg:text-4xl">
146 Execution history
147 </h1>
148 <p className="mt-1.5 text-[14px] text-muted-foreground">
149 Every execution recorded for this workspace, newest first.
150 </p>
151 </div>
152 166
153 <LoadableBlock loadable={executions} loading="Loading executions...">167const DetailTabButton = (props: {
154 {(items) =>168 label: string;
155 items.length === 0 ? (169 active: boolean;
156 <div className="rounded-2xl border border-dashed border-border py-20">170 onClick: () => void;
157 <EmptyState171}) => (
158 title="No executions yet"172 <button
159 description="Run something and it will show up here."173 type="button"
160 />174 onClick={props.onClick}
161 </div>175 className={cn(
162 ) : (176 "px-3 py-1.5 text-sm font-medium transition-colors",
163 <div className="grid gap-3">177 props.active
164 {items.map((execution) => (178 ? "border-b-2 border-primary text-foreground"
165 <ExecutionCard179 : "text-muted-foreground hover:text-foreground",
166 key={execution.id}180 )}
167 execution={execution}181 >
168 onOpen={() => {182 {props.label}
169 void navigation.route(execution.id);183 </button>
170 }}184);
171 />
172 ))}
173 </div>
174 )
175 }
176 </LoadableBlock>
177 </div>
178 </div>
179 );
180}
181 185
182const ExecutionDetailSections = (props: {186const PropertiesTab = (props: { envelope: ExecutionEnvelope }) => {
183 envelope: ExecutionEnvelope;
184}) => {
185 const { execution, pendingInteraction } = props.envelope;187 const { execution, pendingInteraction } = props.envelope;
186 188
187 return (189 return (
188 <div className="space-y-6">190 <div className="space-y-4">
189 <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">191 <div className="grid grid-cols-2 gap-3">
190 <MetadataItem label="Status" value={execution.status.replaceAll("_", " ")} />192 <div className="rounded-lg border border-border/50 px-3 py-2">
191 <MetadataItem label="Created" value={formatTimestamp(execution.createdAt)} />193 <div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">
192 <MetadataItem label="Started" value={formatTimestamp(execution.startedAt)} />194 Status
193 <MetadataItem195 </div>
194 label="Completed"196 <div className="mt-1 flex items-center gap-2">
195 value={formatTimestamp(execution.completedAt)}197 <span className={cn("size-2 rounded-full", statusDotColor(execution.status))} />
196 />198 <span className="text-sm">{execution.status.replaceAll("_", " ")}</span>
199 </div>
200 </div>
201 <div className="rounded-lg border border-border/50 px-3 py-2">
202 <div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">
203 Duration
204 </div>
205 <div className="mt-1 text-sm">{formatDuration(execution) ?? "—"}</div>
206 </div>
207 </div>
208
209 <div className="grid grid-cols-2 gap-3 text-xs text-muted-foreground">
210 <div>
211 <span className="text-muted-foreground/60">Created </span>
212 {formatTimestamp(execution.createdAt)}
213 </div>
214 <div>
215 <span className="text-muted-foreground/60">Started </span>
216 {formatTimestamp(execution.startedAt)}
217 </div>
197 </div>218 </div>
198 219
199 <DocumentPanel220 <DocumentPanel
@@ -203,20 +224,12 @@ const ExecutionDetailSections = (props: { · 3 unmodified lines
203 empty="No code captured."224 empty="No code captured."
204 />225 />
205 226
206 <div className="grid gap-4 xl:grid-cols-2">227 <DocumentPanel
207 <DocumentPanel228 title="Result"
208 title="Result"229 body={formatJsonDocument(execution.resultJson)}
209 body={formatJsonDocument(execution.resultJson)}230 lang="json"
210 lang="json"231 empty="No result recorded."
211 empty="No result recorded."232 />
212 />
213 <DocumentPanel
214 title="Logs"
215 body={formatJsonDocument(execution.logsJson)}
216 lang="json"
217 empty="No logs recorded."
218 />
219 </div>
220 233
221 {execution.errorText && (234 {execution.errorText && (
222 <DocumentPanel235 <DocumentPanel
@@ -228,37 +241,25 @@ const ExecutionDetailSections = (props: { · 5 unmodified lines
228 )}241 )}
229 242
230 {pendingInteraction && (243 {pendingInteraction && (
231 <div className="space-y-4">244 <div className="space-y-3">
232 <div>245 <div className="text-sm font-medium text-foreground">
233 <h2 className="text-sm font-semibold text-foreground">246 Pending interaction
234 Pending interaction247 <span className="ml-2 text-xs text-muted-foreground">
235 </h2>248 {pendingInteraction.kind} — {pendingInteraction.purpose}
236 <p className="mt-1 text-sm text-muted-foreground">249 </span>
237 {pendingInteraction.kind} • {pendingInteraction.purpose}
238 </p>
239 </div>250 </div>
240 251 <div className="grid gap-3 xl:grid-cols-2">
241 <div className="grid gap-3 sm:grid-cols-3">
242 <MetadataItem label="Kind" value={pendingInteraction.kind} />
243 <MetadataItem label="Purpose" value={pendingInteraction.purpose} />
244 <MetadataItem
245 label="Status"
246 value={pendingInteraction.status.replaceAll("_", " ")}
247 />
248 </div>
249
250 <div className="grid gap-4 xl:grid-cols-2">
251 <DocumentPanel252 <DocumentPanel
252 title="Request"253 title="Request"
253 body={formatJsonDocument(pendingInteraction.payloadJson)}254 body={formatJsonDocument(pendingInteraction.payloadJson)}
254 lang="json"255 lang="json"
255 empty="No interaction request recorded."256 empty="No request payload."
256 />257 />
257 <DocumentPanel258 <DocumentPanel
258 title="Response"259 title="Response"
259 body={formatJsonDocument(pendingInteraction.responseJson)}260 body={formatJsonDocument(pendingInteraction.responseJson)}
260 lang="json"261 lang="json"
261 empty="No interaction response recorded."262 empty="No response yet."
262 />263 />
263 </div>264 </div>
264 </div>265 </div>
@@ -267,6 +268,315 @@ const ExecutionDetailSections = (props: { · 2 unmodified lines
267 );268 );
268};269};
269 270
271const LogsTab = (props: { logsJson: string | null }) => {
272 const parsed = useMemo(() => {
273 if (!props.logsJson) return null;
274 try {
275 const arr = JSON.parse(props.logsJson);
276 if (!Array.isArray(arr)) return null;
277 return arr as string[];
278 } catch {
279 return null;
280 }
281 }, [props.logsJson]);
282
283 if (!parsed) {
284 return (
285 <DocumentPanel
286 title="Logs"
287 body={formatJsonDocument(props.logsJson)}
288 lang="json"
289 empty="No logs recorded."
290 />
291 );
292 }
293
294 return (
295 <div className="rounded-lg border border-border/50 bg-card/40 p-3">
296 <div className="mb-2 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">
297 Logs
298 </div>
299 <div className="space-y-0.5 font-mono text-xs">
300 {parsed.map((line, i) => {
301 const isError = typeof line === "string" && /\[error]/i.test(line);
302 const isWarn = typeof line === "string" && /\[warn]/i.test(line);
303 return (
304 <div
305 key={i}
306 className={cn(
307 "whitespace-pre-wrap break-all",
308 isError && "text-red-400",
309 isWarn && "text-amber-400",
310 !isError && !isWarn && "text-foreground/80",
311 )}
312 >
313 {typeof line === "string" ? line : JSON.stringify(line)}
314 </div>
315 );
316 })}
317 </div>
318 </div>
319 );
320};
321
322const ExecutionDetailDrawer = (props: {
323 executionId: string;
324 onClose: () => void;
325}) => {
326 const [tab, setTab] = useState<DetailTab>("properties");
327 const execution = useExecution(props.executionId);
328 const [copied, setCopied] = useState(false);
329
330 const handleCopy = useCallback(
331 (envelope: ExecutionEnvelope) => {
332 void navigator.clipboard
333 .writeText(JSON.stringify(envelope, null, 2))
334 .then(() => {
335 setCopied(true);
336 setTimeout(() => setCopied(false), 1500);
337 });
338 },
339 [],
340 );
341
342 return (
343 <Dialog open onOpenChange={(open) => !open && props.onClose()}>
344 <DialogPortal>
345 <DialogOverlay />
346 <DialogPopup
347 className="fixed inset-y-0 right-0 z-50 flex w-full max-w-3xl flex-col bg-popover text-popover-foreground ring-1 ring-foreground/10 outline-none duration-150 data-open:animate-in data-open:slide-in-from-right data-closed:animate-out data-closed:slide-out-to-right"
348 >
349 {/* Header */}
350 <div className="flex items-center justify-between border-b border-border/50 px-5 py-3">
351 <div className="min-w-0">
352 <div className="truncate text-sm font-medium text-foreground">
353 {props.executionId}
354 </div>
355 </div>
356 <div className="flex items-center gap-2">
357 <LoadableBlock loadable={execution} loading="">
358 {(envelope) => (
359 <Button
360 variant="ghost"
361 size="sm"
362 onClick={() => handleCopy(envelope)}
363 className="text-xs text-muted-foreground"
364 >
365 {copied ? "Copied" : "Copy JSON"}
366 </Button>
367 )}
368 </LoadableBlock>
369 <Button
370 variant="ghost"
371 size="icon-sm"
372 onClick={props.onClose}
373 >
374 <IconClose className="size-3.5" />
375 </Button>
376 </div>
377 </div>
378
379 {/* Tabs */}
380 <div className="flex gap-1 border-b border-border/50 px-5">
381 <DetailTabButton
382 label="Properties"
383 active={tab === "properties"}
384 onClick={() => setTab("properties")}
385 />
386 <DetailTabButton
387 label="Logs"
388 active={tab === "logs"}
389 onClick={() => setTab("logs")}
390 />
391 </div>
392
393 {/* Content */}
394 <div className="flex-1 overflow-y-auto px-5 py-4">
395 <LoadableBlock loadable={execution} loading="Loading execution...">
396 {(envelope) =>
397 tab === "properties" ? (
398 <PropertiesTab envelope={envelope} />
399 ) : (
400 <LogsTab logsJson={envelope.execution.logsJson} />
401 )
402 }
403 </LoadableBlock>
404 </div>
405 </DialogPopup>
406 </DialogPortal>
407 </Dialog>
408 );
409};
410
411// ── List Page ────────────────────────────────────────────────────────────
412
413type ExecutionHistorySearch = {
414 executionId?: string;
415};
416
417export function ExecutionHistoryPage() {
418 const executions = useExecutions();
419 const navigation = useExecutorPluginNavigation();
420 const searchState = useExecutorPluginSearch<ExecutionHistorySearch>();
421 const [statusFilter, setStatusFilter] = useState<string>("all");
422 const [search, setSearch] = useState("");
423 const selectedId =
424 typeof searchState.executionId === "string"
425 ? searchState.executionId
426 : null;
427
428 const setSelectedId = useCallback(
429 (executionId: string | null) => {
430 if (executionId === null) {
431 const { executionId: _, ...nextSearch } = searchState;
432 void navigation.updateSearch(nextSearch);
433 return;
434 }
435
436 void navigation.updateSearch({
437 ...searchState,
438 executionId,
439 });
440 },
441 [navigation, searchState],
442 );
443
444 const filtered = useMemo(() => {
445 if (executions.status !== "ready") return [];
446 let items = [...executions.data];
447 if (statusFilter !== "all") {
448 items = items.filter((e) => e.status === statusFilter);
449 }
450 if (search.trim()) {
451 const q = search.toLowerCase();
452 items = items.filter(
453 (e) =>
454 e.code.toLowerCase().includes(q) ||
455 (e.errorText && e.errorText.toLowerCase().includes(q)),
456 );
457 }
458 return items;
459 }, [executions, statusFilter, search]);
460
461 return (
462 <div className="flex h-full flex-col">
463 {/* Header */}
464 <div className="shrink-0 border-b border-border/50 px-6 py-5">
465 <h1 className="font-display text-2xl tracking-tight text-foreground">
466 Execution history
467 </h1>
468 <p className="mt-1 text-sm text-muted-foreground">
469 Every execution recorded for this workspace, newest first.
470 </p>
471
472 {/* Filter bar */}
473 <div className="mt-4 flex items-center gap-3">
474 <Select
475 value={statusFilter}
476 onChange={(e) => setStatusFilter(e.target.value)}
477 className="w-[140px]"
478 >
479 {STATUS_OPTIONS.map((opt) => (
480 <option key={opt.value} value={opt.value}>
481 {opt.label}
482 </option>
483 ))}
484 </Select>
485 <Input
486 type="text"
487 placeholder="Search code or errors..."
488 value={search}
489 onChange={(e) => setSearch((e.target as HTMLInputElement).value)}
490 className="flex-1"
491 />
492 </div>
493 </div>
494
495 {/* Column headers */}
496 <div className="flex items-center gap-3 border-b border-border/50 px-4 py-1.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60">
497 <span className="size-2 shrink-0" />
498 <span className="w-[120px] shrink-0">_time</span>
499 <span>Raw Data</span>
500 </div>
501
502 {/* List */}
503 <div className="flex-1 overflow-y-auto">
504 <LoadableBlock loadable={executions} loading="Loading executions...">
505 {() =>
506 filtered.length === 0 ? (
507 <div className="py-20">
508 <EmptyState
509 title={
510 statusFilter !== "all" || search.trim()
511 ? "No matching executions"
512 : "No executions yet"
513 }
514 description={
515 statusFilter !== "all" || search.trim()
516 ? "Try adjusting your filters."
517 : "Run something and it will show up here."
518 }
519 />
520 </div>
521 ) : (
522 <div>
523 {filtered.map((execution) => (
524 <ExecutionRow
525 key={execution.id}
526 execution={execution}
527 isSelected={selectedId === execution.id}
528 onSelect={() =>
529 setSelectedId(
530 selectedId === execution.id ? null : execution.id,
531 )
532 }
533 />
534 ))}
535 </div>
536 )
537 }
538 </LoadableBlock>
539 </div>
540
541 {/* Drawer */}
542 {selectedId && (
543 <ExecutionDetailDrawer
544 executionId={selectedId}
545 onClose={() => setSelectedId(null)}
546 />
547 )}
548 </div>
549 );
550}
551
552// ── Detail Page (deep-link) ──────────────────────────────────────────────
553
554const ExecutionDetailInline = (props: { envelope: ExecutionEnvelope }) => {
555 const [tab, setTab] = useState<DetailTab>("properties");
556
557 return (
558 <div>
559 <div className="mb-4 flex gap-1 border-b border-border/50">
560 <DetailTabButton
561 label="Properties"
562 active={tab === "properties"}
563 onClick={() => setTab("properties")}
564 />
565 <DetailTabButton
566 label="Logs"
567 active={tab === "logs"}
568 onClick={() => setTab("logs")}
569 />
570 </div>
571 {tab === "properties" ? (
572 <PropertiesTab envelope={props.envelope} />
573 ) : (
574 <LogsTab logsJson={props.envelope.execution.logsJson} />
575 )}
576 </div>
577 );
578};
579
270export function ExecutionHistoryDetailPage() {580export function ExecutionHistoryDetailPage() {
271 const { executionId } = useExecutorPluginRouteParams<{581 const { executionId } = useExecutorPluginRouteParams<{
272 executionId: string;582 executionId: string;
@@ -276,29 +586,29 @@ export function ExecutionHistoryDetailPage() { · 3 unmodified lines
276 586
277 return (587 return (
278 <div className="h-full overflow-y-auto">588 <div className="h-full overflow-y-auto">
279 <div className="mx-auto max-w-6xl px-6 py-10 lg:px-10 lg:py-14">589 <div className="mx-auto max-w-5xl px-6 py-10 lg:px-10 lg:py-14">
280 <div className="mb-8 flex flex-wrap items-center justify-between gap-4">590 <div className="mb-8">
281 <div>591 <Button
282 <Button592 variant="ghost"
283 variant="ghost"593 type="button"
284 type="button"594 onClick={() => {
285 onClick={() => {595 void navigation.route();
286 void navigation.route();596 }}
287 }}597 className="mb-3 h-auto p-0 text-xs font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
288 className="mb-3 h-auto p-0 text-xs font-medium uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"598 >
289 >599 Back to history
290 Back to history600 </Button>
291 </Button>601 <div className="flex items-center gap-3">
292 <div className="flex items-center gap-3">602 <h1 className="font-display text-2xl tracking-tight text-foreground lg:text-3xl">
293 <h1 className="font-display text-2xl tracking-tight text-foreground lg:text-3xl">603 {executionId}
294 {executionId}604 </h1>
295 </h1>
296 </div>
297 </div>605 </div>
298 </div>606 </div>
299 607
300 <LoadableBlock loadable={execution} loading="Loading execution...">608 <LoadableBlock loadable={execution} loading="Loading execution...">
301 {(envelope) => <ExecutionDetailSections envelope={envelope} />}609 {(envelope) => (
610 <ExecutionDetailInline envelope={envelope} />
611 )}
302 </LoadableBlock>612 </LoadableBlock>
303 </div>613 </div>
304 </div>614 </div>