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.
Showing the changes to plugins (1 of 1 file). Show all
Unified · Split
| @@ -1,3 +1,4 @@ | |||
| 1 | import { useState, useMemo, useCallback } from "react"; | ||
| 1 | import type { | 2 | import 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"; |
| 9 | import { | 10 | import { |
| 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 | ||
| 21 | const formatTimestamp = (value: number | null): string => { | 29 | // ── Helpers ────────────────────────────────────────────────────────────── |
| 22 | if (value === null) { | 30 | |
| 23 | return "—"; | 31 | type ExecutionStatus = Execution["status"]; |
| 24 | } | ||
| 25 | 32 | ||
| 26 | return new Intl.DateTimeFormat(undefined, { | 33 | const 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 | ||
| 32 | const formatDuration = (execution: Execution): string | null => { | 44 | const 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 | ||
| 49 | const summarizeCode = (code: string): string => | 52 | const truncateCode = (code: string, max = 80): string => |
| 50 | code | 53 | code.trim().replace(/\s+/g, " ").slice(0, max); |
| 51 | .trim() | ||
| 52 | .replace(/\s+/g, " ") | ||
| 53 | .slice(0, 220); | ||
| 54 | 54 | ||
| 55 | const formatJsonDocument = (value: string | null): string | null => { | 55 | const 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 | ||
| 67 | const statusVariant = ( | 64 | const 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 | |||
| 81 | const 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 | |||
| 91 | const 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 | |||
| 97 | const 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 | ||
| 83 | const MetadataItem = (props: { | 112 | // ── Execution Row ──────────────────────────────────────────────────────── |
| 84 | label: string; | 113 | |
| 85 | value: string; | 114 | const 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 | ||
| 95 | const ExecutionCard = (props: { | 121 | const 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 | <Button | 130 | <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">"{truncateCode(execution.code, 120)}…"</span> | ||
| 158 | </span> | ||
| 159 | </button> | ||
| 134 | ); | 160 | ); |
| 135 | }; | 161 | }; |
| 136 | 162 | ||
| 137 | export function ExecutionHistoryPage() { | 163 | // ── Detail Drawer ──────────────────────────────────────────────────────── |
| 138 | const executions = useExecutions(); | ||
| 139 | const navigation = useExecutorPluginNavigation(); | ||
| 140 | 164 | ||
| 141 | return ( | 165 | type 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..."> | 167 | const 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 | <EmptyState | 171 | }) => ( |
| 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 | <ExecutionCard | 179 | : "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 | ||
| 182 | const ExecutionDetailSections = (props: { | 186 | const 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 | <MetadataItem | 195 | </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 | <DocumentPanel | 220 | <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 | <DocumentPanel | 228 | 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 | <DocumentPanel | 235 | <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 interaction | 247 | <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 | <DocumentPanel | 252 | <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 | <DocumentPanel | 258 | <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 | ||
| 271 | const 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 | |||
| 322 | const 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 | |||
| 413 | type ExecutionHistorySearch = { | ||
| 414 | executionId?: string; | ||
| 415 | }; | ||
| 416 | |||
| 417 | export 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 | |||
| 554 | const 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 | |||
| 270 | export function ExecutionHistoryDetailPage() { | 580 | export 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 | <Button | 592 | 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 history | 600 | </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> |