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