feat(execution-history): redesign UI to list-with-drawer pattern
Showing the changes to plugins (1 of 5 files). 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,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> |