|
1 | 1 | <script lang="ts"> |
| 2 | + import { untrack } from 'svelte'; |
2 | 3 | import type { WorkflowRule } from '$lib/types.js'; |
| 4 | + import { runs } from '$lib/api/client.js'; |
| 5 | + import { getJobLogPath } from '$lib/utils.js'; |
3 | 6 | import * as Table from '$lib/components/ui/table'; |
4 | | - import { ChevronRight } from 'lucide-svelte'; |
| 7 | + import { ChevronRight, Terminal, ExternalLink, RotateCw } from 'lucide-svelte'; |
5 | 8 |
|
6 | | - let { rule }: { rule: WorkflowRule } = $props(); |
| 9 | + let { rule, runId }: { rule: WorkflowRule; runId: string } = $props(); |
7 | 10 |
|
8 | 11 | let expandedJobIndex = $state<number | null>(null); |
| 12 | + let logCache = $state<Record<number, string>>({}); |
| 13 | + let logLoading = $state<Record<number, boolean>>({}); |
| 14 | + let activeTabOverride = $state<'files' | 'logs' | null>(null); |
| 15 | + let activeJobTab = $state<Record<number, 'files' | 'logs'>>({}); |
| 16 | +
|
| 17 | + // Reset state when rule changes (e.g. clicking different rule in DAG). |
| 18 | + // Track by name to avoid resetting on every poll cycle (which recreates rule objects). |
| 19 | + // Writes are wrapped in untrack() so only rule.name is a dependency. |
| 20 | + let prevRuleName = $state<string | null>(null); |
| 21 | + $effect(() => { |
| 22 | + const name = rule.name; |
| 23 | + untrack(() => { |
| 24 | + if (name !== prevRuleName) { |
| 25 | + prevRuleName = name; |
| 26 | + logCache = {}; |
| 27 | + logLoading = {}; |
| 28 | + activeTabOverride = null; |
| 29 | + activeJobTab = {}; |
| 30 | + expandedJobIndex = null; |
| 31 | + } |
| 32 | + }); |
| 33 | + }); |
| 34 | +
|
| 35 | + // For single-job rules: default to 'files' if files exist, else 'logs' |
| 36 | + const activeTab = $derived.by(() => { |
| 37 | + if (activeTabOverride) return activeTabOverride; |
| 38 | + if (rule.jobs?.length !== 1) return 'files'; |
| 39 | + const job = rule.jobs[0]; |
| 40 | + const hasFiles = (job.files?.some(f => f.file_type === 'INPUT') ?? false) || |
| 41 | + (job.files?.some(f => f.file_type === 'OUTPUT') ?? false); |
| 42 | + return hasFiles ? 'files' : 'logs'; |
| 43 | + }); |
| 44 | +
|
| 45 | + // Auto-load log when single-job defaults to logs tab |
| 46 | + $effect(() => { |
| 47 | + if (activeTab === 'logs' && rule.jobs?.length === 1) { |
| 48 | + const logPath = getJobLogPath(rule.jobs[0]); |
| 49 | + if (logPath && logCache[0] === undefined && !logLoading[0]) loadLog(0, logPath); |
| 50 | + } |
| 51 | + }); |
| 52 | +
|
| 53 | + async function refreshLog(jobIndex: number, logPath: string) { |
| 54 | + delete logCache[jobIndex]; |
| 55 | + logLoading[jobIndex] = false; |
| 56 | + await loadLog(jobIndex, logPath); |
| 57 | + } |
| 58 | +
|
| 59 | + async function loadLog(jobIndex: number, logPath: string) { |
| 60 | + if (logCache[jobIndex] !== undefined || logLoading[jobIndex]) return; |
| 61 | + logLoading[jobIndex] = true; |
| 62 | + try { |
| 63 | + const url = `${runs.outputDownloadUrl(runId, logPath)}?format=text`; |
| 64 | + const resp = await fetch(url, { credentials: 'include' }); |
| 65 | + logCache[jobIndex] = resp.ok ? await resp.text() : `Failed to fetch log (${resp.status})`; |
| 66 | + } catch { |
| 67 | + logCache[jobIndex] = 'Failed to fetch log'; |
| 68 | + } |
| 69 | + logLoading[jobIndex] = false; |
| 70 | + } |
9 | 71 |
|
10 | 72 | function formatWildcards(wildcards: Record<string, string> | null): string { |
11 | 73 | if (!wildcards || Object.keys(wildcards).length === 0) return '—'; |
|
47 | 109 | return max; |
48 | 110 | }); |
49 | 111 |
|
| 112 | + const anyJobLogs = $derived.by(() => { |
| 113 | + for (const job of rule.jobs ?? []) { |
| 114 | + if (getJobLogPath(job)) return true; |
| 115 | + } |
| 116 | + return false; |
| 117 | + }); |
| 118 | +
|
50 | 119 | function shortPath(path: string): string { |
51 | 120 | return path |
52 | 121 | .replace(/^\/app\/\.snakedispatch\/jobs\/[^/]+\//, '') |
|
56 | 125 | function jobHasFiles(job: { files?: { file_type: string; path: string }[] }): boolean { |
57 | 126 | return (job.files?.length ?? 0) > 0; |
58 | 127 | } |
| 128 | +
|
| 129 | + function jobIsExpandable(job: { files?: { file_type: string; path: string }[]; log?: string }): boolean { |
| 130 | + return jobHasFiles(job) || !!getJobLogPath(job); |
| 131 | + } |
59 | 132 | </script> |
60 | 133 |
|
61 | | -{#if rule.jobs?.length === 1} |
62 | | - {@const job = rule.jobs[0]} |
63 | | - {@const inputs = job.files?.filter(f => f.file_type === 'INPUT') ?? []} |
64 | | - {@const outputs = job.files?.filter(f => f.file_type === 'OUTPUT') ?? []} |
65 | | - {#if inputs.length > 0 || outputs.length > 0} |
66 | | - <div class="flex gap-6 text-[10px] text-muted-foreground"> |
67 | | - {#if inputs.length > 0} |
68 | | - <div class="flex-1 min-w-0"> |
69 | | - <span class="font-medium">Inputs</span> |
70 | | - <ul class="mt-0.5 space-y-px"> |
71 | | - {#each inputs as f} |
72 | | - <li class="font-mono truncate" title={f.path}>{shortPath(f.path)}</li> |
73 | | - {/each} |
74 | | - </ul> |
75 | | - </div> |
76 | | - {/if} |
77 | | - {#if outputs.length > 0} |
78 | | - <div class="flex-1 min-w-0"> |
79 | | - <span class="font-medium">Outputs</span> |
80 | | - <ul class="mt-0.5 space-y-px"> |
81 | | - {#each outputs as f} |
82 | | - <li class="font-mono truncate" title={f.path}>{shortPath(f.path)}</li> |
83 | | - {/each} |
84 | | - </ul> |
| 134 | +{#snippet jobDetail(jobIndex: number, inputs: {file_type: string; path: string}[], outputs: {file_type: string; path: string}[], logPath: string | null, currentTab: 'files' | 'logs', setTab: (tab: 'files' | 'logs') => void)} |
| 135 | + {@const hasFiles = inputs.length > 0 || outputs.length > 0} |
| 136 | + {@const hasLog = !!logPath} |
| 137 | + {#if hasFiles || hasLog} |
| 138 | + <div class="flex flex-col gap-1.5"> |
| 139 | + <div class="flex items-center gap-3"> |
| 140 | + {#if hasFiles} |
| 141 | + <button |
| 142 | + class="text-xs pb-0.5 border-b-2 transition-colors {currentTab === 'files' ? 'border-foreground text-foreground font-medium' : 'border-transparent text-muted-foreground hover:text-foreground'}" |
| 143 | + onclick={(e) => { e.stopPropagation(); setTab('files'); }} |
| 144 | + >Files</button> |
| 145 | + {/if} |
| 146 | + {#if hasLog} |
| 147 | + <button |
| 148 | + class="text-xs pb-0.5 border-b-2 transition-colors {currentTab === 'logs' ? 'border-foreground text-foreground font-medium' : 'border-transparent text-muted-foreground hover:text-foreground'}" |
| 149 | + onclick={(e) => { e.stopPropagation(); setTab('logs'); if (logPath) loadLog(jobIndex, logPath); }} |
| 150 | + >Logs</button> |
| 151 | + {/if} |
| 152 | + {#if currentTab === 'logs' && hasLog && logPath} |
| 153 | + <a |
| 154 | + href="{runs.outputDownloadUrl(runId, logPath)}?format=text" |
| 155 | + target="_blank" |
| 156 | + rel="noopener noreferrer" |
| 157 | + class="ml-auto inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors" |
| 158 | + onclick={(e) => e.stopPropagation()} |
| 159 | + > |
| 160 | + <ExternalLink class="h-3.5 w-3.5" /> |
| 161 | + Raw logs |
| 162 | + </a> |
| 163 | + <button |
| 164 | + class="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors" |
| 165 | + onclick={(e) => { e.stopPropagation(); if (logPath) refreshLog(jobIndex, logPath); }} |
| 166 | + disabled={logLoading[jobIndex]} |
| 167 | + title="Refresh log" |
| 168 | + > |
| 169 | + <RotateCw class="h-3 w-3 {logLoading[jobIndex] ? 'animate-spin' : ''}" /> |
| 170 | + </button> |
| 171 | + {/if} |
| 172 | + </div> |
| 173 | + {#if currentTab === 'files' && hasFiles} |
| 174 | + <div class="flex gap-6 text-[10px] text-muted-foreground"> |
| 175 | + {#if inputs.length > 0} |
| 176 | + <div class="flex-1 min-w-0"> |
| 177 | + <span class="font-medium">Inputs</span> |
| 178 | + <ul class="mt-0.5 space-y-px"> |
| 179 | + {#each inputs as f} |
| 180 | + <li class="font-mono truncate" title={f.path}>{shortPath(f.path)}</li> |
| 181 | + {/each} |
| 182 | + </ul> |
| 183 | + </div> |
| 184 | + {/if} |
| 185 | + {#if outputs.length > 0} |
| 186 | + <div class="flex-1 min-w-0"> |
| 187 | + <span class="font-medium">Outputs</span> |
| 188 | + <ul class="mt-0.5 space-y-px"> |
| 189 | + {#each outputs as f} |
| 190 | + <li class="font-mono truncate" title={f.path}>{shortPath(f.path)}</li> |
| 191 | + {/each} |
| 192 | + </ul> |
| 193 | + </div> |
| 194 | + {/if} |
85 | 195 | </div> |
| 196 | + {:else if currentTab === 'logs' && hasLog && logPath} |
| 197 | + {#if logLoading[jobIndex]} |
| 198 | + <span class="text-xs text-muted-foreground">Loading…</span> |
| 199 | + {:else if logCache[jobIndex] !== undefined} |
| 200 | + <pre class="max-h-[300px] overflow-auto rounded bg-zinc-950 p-2 font-mono text-xs leading-5 text-zinc-200 whitespace-pre-wrap">{logCache[jobIndex]}</pre> |
| 201 | + {/if} |
86 | 202 | {/if} |
87 | 203 | </div> |
88 | 204 | {/if} |
| 205 | +{/snippet} |
| 206 | + |
| 207 | +{#if rule.jobs?.length === 1} |
| 208 | + {@const job = rule.jobs[0]} |
| 209 | + {@const inputs = job.files?.filter(f => f.file_type === 'INPUT') ?? []} |
| 210 | + {@const outputs = job.files?.filter(f => f.file_type === 'OUTPUT') ?? []} |
| 211 | + {@const logPath = getJobLogPath(job)} |
| 212 | + {@render jobDetail(0, inputs, outputs, logPath, activeTab, (tab) => { activeTabOverride = tab; })} |
89 | 213 | {:else if rule.jobs?.length > 1} |
90 | 214 | <Table.Root class="text-xs"> |
91 | 215 | <Table.Header> |
92 | 216 | <Table.Row class="hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-transparent"> |
93 | 217 | <Table.Head class="h-7 py-1 pr-3">Wildcards</Table.Head> |
| 218 | + {#if anyJobLogs} |
| 219 | + <Table.Head class="h-7 py-1 w-4 p-0"></Table.Head> |
| 220 | + {/if} |
94 | 221 | <Table.Head class="h-7 py-1 pr-3 text-right">Duration</Table.Head> |
95 | | - <Table.Head class="h-7 py-1 pr-3 text-right w-0">Threads</Table.Head> |
96 | 222 | <Table.Head class="h-7 py-1 w-4 p-0"></Table.Head> |
97 | 223 | </Table.Row> |
98 | 224 | </Table.Header> |
99 | 225 | <Table.Body> |
100 | 226 | {#each rule.jobs as job, i} |
101 | | - {@const hasFiles = jobHasFiles(job)} |
| 227 | + {@const expandable = jobIsExpandable(job)} |
102 | 228 | {@const duration = jobDuration(job)} |
103 | 229 | {@const status = job.status.toLowerCase()} |
104 | 230 | {@const inputs = job.files?.filter(f => f.file_type === 'INPUT') ?? []} |
105 | 231 | {@const outputs = job.files?.filter(f => f.file_type === 'OUTPUT') ?? []} |
106 | 232 | <Table.Row |
107 | | - class="{hasFiles ? 'cursor-pointer' : 'hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-transparent'} {hasFiles && expandedJobIndex === i ? 'border-0' : ''}" |
108 | | - onclick={() => { if (hasFiles) expandedJobIndex = expandedJobIndex === i ? null : i; }} |
| 233 | + class="{expandable ? 'cursor-pointer' : 'hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-transparent'} {expandable && expandedJobIndex === i ? 'border-0' : ''}" |
| 234 | + onclick={() => { if (expandable) expandedJobIndex = expandedJobIndex === i ? null : i; }} |
109 | 235 | > |
110 | 236 | <Table.Cell class="py-1 pr-3 font-mono w-0"> |
111 | 237 | <div class="flex items-center gap-2"> |
112 | 238 | <span class="inline-block h-1.5 w-1.5 rounded-full shrink-0 {statusDotColor(job.status)}"></span> |
113 | 239 | <span class="truncate max-w-[20rem]">{formatWildcards(job.wildcards)}</span> |
114 | 240 | </div> |
115 | 241 | </Table.Cell> |
| 242 | + {#if anyJobLogs} |
| 243 | + <Table.Cell class="py-1 w-4 text-center"> |
| 244 | + {#if getJobLogPath(job)} |
| 245 | + <Terminal class="h-3 w-3 text-muted-foreground inline-block" /> |
| 246 | + {/if} |
| 247 | + </Table.Cell> |
| 248 | + {/if} |
116 | 249 | <Table.Cell class="py-1 pr-3"> |
117 | 250 | {#if duration > 0} |
118 | 251 | <div class="flex items-center gap-2"> |
|
128 | 261 | <span class="text-muted-foreground text-right block">—</span> |
129 | 262 | {/if} |
130 | 263 | </Table.Cell> |
131 | | - <Table.Cell class="py-1 pr-3 text-right text-muted-foreground w-0">{job.threads}</Table.Cell> |
132 | 264 | <Table.Cell class="py-1 pl-2 w-4"> |
133 | | - {#if hasFiles} |
| 265 | + {#if expandable} |
134 | 266 | <ChevronRight |
135 | 267 | class="h-3 w-3 text-muted-foreground transition-transform duration-200" |
136 | 268 | style={expandedJobIndex === i ? 'transform: rotate(90deg)' : ''} |
137 | 269 | /> |
138 | 270 | {/if} |
139 | 271 | </Table.Cell> |
140 | 272 | </Table.Row> |
141 | | - {#if hasFiles && expandedJobIndex === i} |
| 273 | + {#if expandable && expandedJobIndex === i} |
142 | 274 | <Table.Row class="hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-transparent"> |
143 | | - <Table.Cell colspan={4} class="py-1 pl-2"> |
144 | | - <div class="flex gap-6 text-[10px] text-muted-foreground"> |
145 | | - {#if inputs.length > 0} |
146 | | - <div class="flex-1 min-w-0"> |
147 | | - <span class="font-medium">Inputs</span> |
148 | | - <ul class="mt-0.5 space-y-px"> |
149 | | - {#each inputs as f} |
150 | | - <li class="font-mono truncate" title={f.path}>{shortPath(f.path)}</li> |
151 | | - {/each} |
152 | | - </ul> |
153 | | - </div> |
154 | | - {/if} |
155 | | - {#if outputs.length > 0} |
156 | | - <div class="flex-1 min-w-0"> |
157 | | - <span class="font-medium">Outputs</span> |
158 | | - <ul class="mt-0.5 space-y-px"> |
159 | | - {#each outputs as f} |
160 | | - <li class="font-mono truncate" title={f.path}>{shortPath(f.path)}</li> |
161 | | - {/each} |
162 | | - </ul> |
163 | | - </div> |
164 | | - {/if} |
165 | | - </div> |
| 275 | + <!-- colspan: Wildcards + Duration + Chevron = 3, plus optional log column --> |
| 276 | + <Table.Cell colspan={3 + (anyJobLogs ? 1 : 0)} class="py-1 pl-2"> |
| 277 | + {@const logPath = getJobLogPath(job)} |
| 278 | + {@const hasFiles = inputs.length > 0 || outputs.length > 0} |
| 279 | + {@const defaultJobTab = hasFiles ? 'files' : 'logs'} |
| 280 | + {@const currentTab = activeJobTab[i] ?? defaultJobTab} |
| 281 | + {@render jobDetail(i, inputs, outputs, logPath, currentTab, (tab) => { activeJobTab[i] = tab; })} |
166 | 282 | </Table.Cell> |
167 | 283 | </Table.Row> |
168 | 284 | {/if} |
|
0 commit comments