Skip to content

Commit 4f3bbde

Browse files
authored
Simplify action run logs viewer and add JSON download (#1932)
2 parents 24d6754 + 1435320 commit 4f3bbde

4 files changed

Lines changed: 227 additions & 93 deletions

File tree

src/components/sequencing/actions/ActionRunDetailView.svelte

Lines changed: 47 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<script lang="ts">
44
import { Button, Tabs } from '@nasa-jpl/stellar-svelte';
55
import { capitalize } from 'lodash-es';
6-
import { ArrowLeft, Ban, RefreshCw } from 'lucide-svelte';
6+
import { ArrowLeft, Ban, Download, RefreshCw } from 'lucide-svelte';
77
import { createEventDispatcher } from 'svelte';
88
import { writable } from 'svelte/store';
99
import { Status } from '../../../enums/status';
@@ -12,25 +12,26 @@
1212
import { workspaceId } from '../../../stores/workspaces';
1313
import type { ActionDefinition, ActionDefinitionVersion, ActionRun } from '../../../types/actions';
1414
import type { User } from '../../../types/app';
15-
import type { LogMessage } from '../../../types/errors';
1615
import type { ArgumentsMap, FormParameter } from '../../../types/parameter';
1716
import {
1817
getActionDefinitionForRun,
1918
getDefaultsFromSchema,
2019
getLatestRunnableVersion,
2120
getStatusForActionRun,
21+
parseActionLogLines,
22+
type ParsedActionLog,
2223
valueSchemaRecordToParametersMap,
2324
} from '../../../utilities/actions';
2425
import effects from '../../../utilities/effects';
25-
import { ErrorTypes } from '../../../utilities/errors';
26+
import { downloadJSON } from '../../../utilities/generic';
2627
import gql from '../../../utilities/gql';
2728
import { getFormParameters } from '../../../utilities/parameters';
2829
import { permissionHandler } from '../../../utilities/permissionHandler';
2930
import { formatMS } from '../../../utilities/time';
3031
import { tooltip } from '../../../utilities/tooltip';
31-
import ConsoleLog from '../../console/views/ConsoleLog.svelte';
3232
import Parameters from '../../parameters/Parameters.svelte';
3333
import StatusBadge from '../../ui/StatusBadge.svelte';
34+
import ActionRunLogs from './ActionRunLogs.svelte';
3435
3536
const dispatch = createEventDispatcher<{
3637
back: void;
@@ -55,6 +56,7 @@
5556
let actionDefinition: ActionDefinition | null = null;
5657
let latestVersion: ActionDefinitionVersion | null = null;
5758
let isLatestVersion: boolean = false;
59+
let parsedLogs: ParsedActionLog[] = [];
5860
let status: Status | null = null;
5961
6062
$: actionRunIdStore.set(actionRunId);
@@ -71,6 +73,16 @@
7173
$: latestVersion = getLatestRunnableVersion(actionRun?.action_definition.versions ?? []);
7274
$: isLatestVersion = latestVersion != null && actionRun?.action_definition_revision === latestVersion.revision;
7375
$: status = actionRun ? getStatusForActionRun(actionRun) : null;
76+
$: parsedLogs = actionRun?.logs ? parseActionLogLines(actionRun.logs) : ([] as ParsedActionLog[]);
77+
$: errorEntry =
78+
actionRun?.error?.message != null
79+
? ({
80+
level: 'error',
81+
message: actionRun.error.message,
82+
timestamp: actionRun.requested_at,
83+
trace: actionRun.error.stack,
84+
} satisfies ParsedActionLog)
85+
: null;
7486
7587
function updateActionSettingsAndParameters(run: ActionRun) {
7688
const version =
@@ -102,64 +114,26 @@
102114
);
103115
}
104116
105-
function parseLogLines(logString: string): LogMessage[] {
106-
// Action server formats logs as: TIMESTAMP [LEVEL] message
107-
// Continuation lines (multi-line errors/stack traces) appear as: [LEVEL] text (indented, no timestamp)
108-
const serverLogPattern = /^(\S+)\s+\[(INFO|WARN|ERROR|DEBUG)]\s(.*)$/;
109-
const continuationLevelPattern = /^\s*\[(INFO|WARN|ERROR|DEBUG)]\s(.*)$/;
110-
const results: LogMessage[] = [];
111-
112-
for (const line of logString.split('\n')) {
113-
if (!line.trim()) {
114-
continue;
115-
}
116-
117-
const mainMatch = line.match(serverLogPattern);
118-
if (mainMatch) {
119-
const [, timestamp, rawLevel, message] = mainMatch;
120-
results.push({
121-
level: rawLevel.toLowerCase() as LogMessage['level'],
122-
message,
123-
timestamp,
124-
type: ErrorTypes.LOG,
125-
});
126-
continue;
127-
}
128-
129-
// Continuation line — strip [LEVEL] prefix and merge into previous entry's trace
130-
const contMatch = line.match(continuationLevelPattern);
131-
const cleanLine = contMatch ? contMatch[2] : line;
132-
133-
if (results.length > 0) {
134-
const prev = results[results.length - 1];
135-
prev.trace = prev.trace ? `${prev.trace}\n${cleanLine}` : cleanLine;
136-
} else {
137-
results.push({
138-
level: 'info',
139-
message: cleanLine,
140-
timestamp: '',
141-
type: ErrorTypes.LOG,
142-
});
143-
}
144-
}
145-
146-
// Post-process: when a message ends with '{' and trace contains the rest of
147-
// a JSON object, reassemble and parse it into `data` for clean rendering.
148-
for (const entry of results) {
149-
if (entry.message.endsWith('{') && entry.trace) {
150-
const jsonCandidate = '{\n' + entry.trace;
151-
try {
152-
const parsed = JSON.parse(jsonCandidate);
153-
entry.message = entry.message.slice(0, -1).trimEnd();
154-
entry.data = parsed;
155-
entry.trace = undefined;
156-
} catch {
157-
// Not valid JSON, leave as-is
158-
}
159-
}
117+
function onDownloadRun() {
118+
if (!actionRun) {
119+
return;
160120
}
161-
162-
return results;
121+
downloadJSON(
122+
{
123+
canceled: actionRun.canceled,
124+
duration: actionRun.duration,
125+
error: actionRun.error,
126+
id: actionRun.id,
127+
logs: parsedLogs,
128+
parameters: actionRun.parameters,
129+
requestedAt: actionRun.requested_at,
130+
requestedBy: actionRun.requested_by,
131+
results: actionRun.results,
132+
settings: actionRun.settings,
133+
status: actionRun.status,
134+
},
135+
`${actionRun.action_definition.name}-${actionRun.id}.json`,
136+
);
163137
}
164138
165139
async function onCancelRun() {
@@ -213,6 +187,10 @@
213187
</div>
214188
</div>
215189
<div class="flex items-center gap-2">
190+
<Button variant="outline" on:click={onDownloadRun}>
191+
<Download size={12} class="mr-1" />
192+
Download
193+
</Button>
216194
{#if actionDefinition && !actionDefinition.archived}
217195
<div
218196
use:permissionHandler={{
@@ -287,19 +265,13 @@
287265
<!-- Output tab (errors, results, logs) -->
288266
<Tabs.Content value="output" class="mt-0 flex-1 overflow-y-auto">
289267
<div class="mx-auto flex max-w-5xl flex-col gap-4 p-6">
290-
{#if actionRun.error?.message}
291-
{@const errorLog = {
292-
level: 'error',
293-
message: actionRun.error.message,
294-
timestamp: actionRun.requested_at,
295-
trace: actionRun.error.stack,
296-
type: ErrorTypes.CAUGHT_ERROR,
297-
}}
298-
<div class="flex flex-col gap-3 rounded border border-destructive/30 bg-destructive/5 p-4">
268+
{#if errorEntry}
269+
<div
270+
class="flex flex-col gap-3 rounded border border-destructive/30 bg-destructive/5 p-4"
271+
data-testid="action-run-error-log"
272+
>
299273
<h3 class="text-sm font-medium text-destructive">Error</h3>
300-
<div class="overflow-auto rounded bg-muted py-2 font-mono text-xs" data-testid="action-run-error-log">
301-
<ConsoleLog log={errorLog} showType={false} showLongTimestamp={false} />
302-
</div>
274+
<ActionRunLogs logs={[errorEntry]} />
303275
</div>
304276
{/if}
305277
<div class="flex flex-col gap-3 rounded border border-border p-4">
@@ -318,26 +290,8 @@
318290
</div>
319291
<div class="flex flex-col gap-3 rounded border border-border p-4">
320292
<h3 class="text-sm font-medium">Logs</h3>
321-
{#if actionRun.logs}
322-
{@const logMessages = parseLogLines(actionRun.logs)}
323-
<div class="max-h-[600px] overflow-auto rounded bg-muted py-2 font-mono text-xs">
324-
{#each logMessages as log}
325-
<ConsoleLog {log} defaultExpanded={true} showType={false} showLongTimestamp={false}>
326-
<svelte:fragment slot="message" let:message let:expandable let:open>
327-
{#if expandable && !open}
328-
<!-- "preview" text for expandable logs, ellipsize to one line -->
329-
<span class="block w-full min-w-0 overflow-hidden text-ellipsis whitespace-pre">
330-
{message}
331-
</span>
332-
{:else}
333-
<span class="block w-full min-w-0 whitespace-pre-wrap break-words">
334-
{message}
335-
</span>
336-
{/if}
337-
</svelte:fragment>
338-
</ConsoleLog>
339-
{/each}
340-
</div>
293+
{#if parsedLogs.length}
294+
<ActionRunLogs logs={parsedLogs} />
341295
{:else}
342296
<p class="text-xs italic text-muted-foreground">No logs</p>
343297
{/if}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<svelte:options immutable={true} />
2+
3+
<script lang="ts">
4+
import { cn } from '@nasa-jpl/stellar-svelte';
5+
import type { ParsedActionLog, ParsedActionLogLevel } from '../../../utilities/actions';
6+
import { safeStringify } from '../../../utilities/text';
7+
8+
export let logs: ParsedActionLog[];
9+
export let maxHeightClass: string = 'max-h-[600px]';
10+
export let className: string = '';
11+
12+
const levelTextClass: Record<ParsedActionLogLevel, string> = {
13+
debug: 'text-muted-foreground',
14+
error: 'text-destructive',
15+
info: 'text-blue-500',
16+
warn: 'text-yellow-600',
17+
};
18+
</script>
19+
20+
<div
21+
data-testid="action-run-logs"
22+
class={cn('overflow-auto rounded bg-muted py-2 font-mono text-xs leading-5', maxHeightClass, className)}
23+
>
24+
{#each logs as log}
25+
<div class="whitespace-pre-wrap break-words px-3">
26+
{#if log.timestamp}<span class="text-muted-foreground">{log.timestamp}</span>{' '}{/if}<span
27+
class={cn('uppercase', levelTextClass[log.level])}>{log.level.padEnd(5)}</span
28+
>{' '}<span>{log.message}</span>
29+
{#if log.data}
30+
<div class="whitespace-pre-wrap break-words pl-4 text-muted-foreground">{safeStringify(log.data, 2)}</div>
31+
{/if}
32+
{#if log.trace}
33+
<div class="whitespace-pre-wrap break-words pl-4 text-muted-foreground">{log.trace}</div>
34+
{/if}
35+
</div>
36+
{/each}
37+
</div>

src/utilities/actions.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
getLatestRunnableVersion,
1010
getRunnableVersions,
1111
getStatusForActionRun,
12+
parseActionLogLines,
1213
truncateRunParameters,
1314
valueSchemaRecordToParametersMap,
1415
} from './actions';
@@ -251,3 +252,70 @@ describe('truncateRunParameters', () => {
251252
expect(result).toMatch(/^primary:/);
252253
});
253254
});
255+
256+
describe('parseActionLogLines', () => {
257+
test('parses a single well-formed line', () => {
258+
const result = parseActionLogLines('2026-05-18T12:34:56Z [INFO] starting action');
259+
expect(result).toEqual([
260+
{
261+
level: 'info',
262+
message: 'starting action',
263+
timestamp: '2026-05-18T12:34:56Z',
264+
},
265+
]);
266+
});
267+
268+
test('merges continuation lines into the previous entry trace', () => {
269+
const input = [
270+
'2026-05-18T12:34:58Z [ERROR] failed to validate',
271+
' [ERROR] at validatePlan (validator.ts:12)',
272+
' [ERROR] at run (action.ts:5)',
273+
].join('\n');
274+
const result = parseActionLogLines(input);
275+
expect(result).toHaveLength(1);
276+
expect(result[0]).toEqual({
277+
level: 'error',
278+
message: 'failed to validate',
279+
timestamp: '2026-05-18T12:34:58Z',
280+
trace: ' at validatePlan (validator.ts:12)\n at run (action.ts:5)',
281+
});
282+
});
283+
284+
test('reassembles {-suffixed JSON continuation into data', () => {
285+
const input = [
286+
'2026-05-18T12:34:57Z [INFO] fetching plan {',
287+
' [INFO] "planId": 42,',
288+
' [INFO] "name": "Mars Mission"',
289+
' [INFO] }',
290+
].join('\n');
291+
const result = parseActionLogLines(input);
292+
expect(result).toHaveLength(1);
293+
expect(result[0].message).toBe('fetching plan');
294+
expect(result[0].data).toEqual({ name: 'Mars Mission', planId: 42 });
295+
expect(result[0].trace).toBeUndefined();
296+
});
297+
298+
test('keeps trace when {-suffix continuation is not valid JSON', () => {
299+
const input = ['2026-05-18T12:34:57Z [INFO] open brace {', ' [INFO] not actually json'].join('\n');
300+
const result = parseActionLogLines(input);
301+
expect(result[0].message).toBe('open brace {');
302+
expect(result[0].data).toBeUndefined();
303+
expect(result[0].trace).toBe('not actually json');
304+
});
305+
306+
test('returns [] for empty or whitespace-only input', () => {
307+
expect(parseActionLogLines('')).toEqual([]);
308+
expect(parseActionLogLines(' \n\n\t \n')).toEqual([]);
309+
});
310+
311+
test('handles orphan continuation line with no preceding main entry', () => {
312+
const result = parseActionLogLines(' [ERROR] orphan trace line');
313+
expect(result).toEqual([
314+
{
315+
level: 'info',
316+
message: 'orphan trace line',
317+
timestamp: '',
318+
},
319+
]);
320+
});
321+
});

0 commit comments

Comments
 (0)