Skip to content

Commit 4c65555

Browse files
committed
frontend: add logs view per job
1 parent 65b4bad commit 4c65555

6 files changed

Lines changed: 240 additions & 67 deletions

File tree

frontend/app/src/lib/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ export interface WorkflowJob {
221221
started_at?: string;
222222
completed_at?: string;
223223
files: WorkflowFile[];
224+
log?: string;
224225
}
225226

226227
export interface WorkflowRule {

frontend/app/src/lib/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ export function getDirectoryPath(fullPath: string | null | undefined): string {
120120
return relativePath.substring(0, lastSlashIndex + 1);
121121
}
122122

123+
export function getJobLogPath(job: { log?: string; files?: { file_type: string; path: string }[] }): string | null {
124+
if (job.log) return job.log;
125+
return job.files?.find(f => f.file_type === 'LOG')?.path ?? null;
126+
}
127+
123128
export function getTagType(tag: string | NetworkTag): TagType {
124129
if (typeof tag === 'string') return 'default';
125130
const name = tag.name?.toLowerCase() || '';

frontend/app/src/routes/runs/[id]/dag/+page.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@
173173
Close
174174
</button>
175175
</div>
176-
<RulePanel rule={selectedRule} />
176+
<RulePanel rule={selectedRule} {runId} />
177177
</div>
178178
{/if}
179179
</div>

frontend/app/src/routes/runs/components/RulePanel.svelte

Lines changed: 172 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,73 @@
11
<script lang="ts">
2+
import { untrack } from 'svelte';
23
import type { WorkflowRule } from '$lib/types.js';
4+
import { runs } from '$lib/api/client.js';
5+
import { getJobLogPath } from '$lib/utils.js';
36
import * as Table from '$lib/components/ui/table';
4-
import { ChevronRight } from 'lucide-svelte';
7+
import { ChevronRight, Terminal, ExternalLink, RotateCw } from 'lucide-svelte';
58
6-
let { rule }: { rule: WorkflowRule } = $props();
9+
let { rule, runId }: { rule: WorkflowRule; runId: string } = $props();
710
811
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+
}
971
1072
function formatWildcards(wildcards: Record<string, string> | null): string {
1173
if (!wildcards || Object.keys(wildcards).length === 0) return '';
@@ -47,6 +109,13 @@
47109
return max;
48110
});
49111
112+
const anyJobLogs = $derived.by(() => {
113+
for (const job of rule.jobs ?? []) {
114+
if (getJobLogPath(job)) return true;
115+
}
116+
return false;
117+
});
118+
50119
function shortPath(path: string): string {
51120
return path
52121
.replace(/^\/app\/\.snakedispatch\/jobs\/[^/]+\//, '')
@@ -56,63 +125,127 @@
56125
function jobHasFiles(job: { files?: { file_type: string; path: string }[] }): boolean {
57126
return (job.files?.length ?? 0) > 0;
58127
}
128+
129+
function jobIsExpandable(job: { files?: { file_type: string; path: string }[]; log?: string }): boolean {
130+
return jobHasFiles(job) || !!getJobLogPath(job);
131+
}
59132
</script>
60133

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}
85195
</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}
86202
{/if}
87203
</div>
88204
{/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; })}
89213
{:else if rule.jobs?.length > 1}
90214
<Table.Root class="text-xs">
91215
<Table.Header>
92216
<Table.Row class="hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-transparent">
93217
<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}
94221
<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>
96222
<Table.Head class="h-7 py-1 w-4 p-0"></Table.Head>
97223
</Table.Row>
98224
</Table.Header>
99225
<Table.Body>
100226
{#each rule.jobs as job, i}
101-
{@const hasFiles = jobHasFiles(job)}
227+
{@const expandable = jobIsExpandable(job)}
102228
{@const duration = jobDuration(job)}
103229
{@const status = job.status.toLowerCase()}
104230
{@const inputs = job.files?.filter(f => f.file_type === 'INPUT') ?? []}
105231
{@const outputs = job.files?.filter(f => f.file_type === 'OUTPUT') ?? []}
106232
<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; }}
109235
>
110236
<Table.Cell class="py-1 pr-3 font-mono w-0">
111237
<div class="flex items-center gap-2">
112238
<span class="inline-block h-1.5 w-1.5 rounded-full shrink-0 {statusDotColor(job.status)}"></span>
113239
<span class="truncate max-w-[20rem]">{formatWildcards(job.wildcards)}</span>
114240
</div>
115241
</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}
116249
<Table.Cell class="py-1 pr-3">
117250
{#if duration > 0}
118251
<div class="flex items-center gap-2">
@@ -128,41 +261,24 @@
128261
<span class="text-muted-foreground text-right block">&mdash;</span>
129262
{/if}
130263
</Table.Cell>
131-
<Table.Cell class="py-1 pr-3 text-right text-muted-foreground w-0">{job.threads}</Table.Cell>
132264
<Table.Cell class="py-1 pl-2 w-4">
133-
{#if hasFiles}
265+
{#if expandable}
134266
<ChevronRight
135267
class="h-3 w-3 text-muted-foreground transition-transform duration-200"
136268
style={expandedJobIndex === i ? 'transform: rotate(90deg)' : ''}
137269
/>
138270
{/if}
139271
</Table.Cell>
140272
</Table.Row>
141-
{#if hasFiles && expandedJobIndex === i}
273+
{#if expandable && expandedJobIndex === i}
142274
<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; })}
166282
</Table.Cell>
167283
</Table.Row>
168284
{/if}

0 commit comments

Comments
 (0)