|
3 | 3 | <script lang="ts"> |
4 | 4 | import { Button, Tabs } from '@nasa-jpl/stellar-svelte'; |
5 | 5 | import { capitalize } from 'lodash-es'; |
6 | | - import { ArrowLeft, Ban, RefreshCw } from 'lucide-svelte'; |
| 6 | + import { ArrowLeft, Ban, Download, RefreshCw } from 'lucide-svelte'; |
7 | 7 | import { createEventDispatcher } from 'svelte'; |
8 | 8 | import { writable } from 'svelte/store'; |
9 | 9 | import { Status } from '../../../enums/status'; |
|
12 | 12 | import { workspaceId } from '../../../stores/workspaces'; |
13 | 13 | import type { ActionDefinition, ActionDefinitionVersion, ActionRun } from '../../../types/actions'; |
14 | 14 | import type { User } from '../../../types/app'; |
15 | | - import type { LogMessage } from '../../../types/errors'; |
16 | 15 | import type { ArgumentsMap, FormParameter } from '../../../types/parameter'; |
17 | 16 | import { |
18 | 17 | getActionDefinitionForRun, |
19 | 18 | getDefaultsFromSchema, |
20 | 19 | getLatestRunnableVersion, |
21 | 20 | getStatusForActionRun, |
| 21 | + parseActionLogLines, |
| 22 | + type ParsedActionLog, |
22 | 23 | valueSchemaRecordToParametersMap, |
23 | 24 | } from '../../../utilities/actions'; |
24 | 25 | import effects from '../../../utilities/effects'; |
25 | | - import { ErrorTypes } from '../../../utilities/errors'; |
| 26 | + import { downloadJSON } from '../../../utilities/generic'; |
26 | 27 | import gql from '../../../utilities/gql'; |
27 | 28 | import { getFormParameters } from '../../../utilities/parameters'; |
28 | 29 | import { permissionHandler } from '../../../utilities/permissionHandler'; |
29 | 30 | import { formatMS } from '../../../utilities/time'; |
30 | 31 | import { tooltip } from '../../../utilities/tooltip'; |
31 | | - import ConsoleLog from '../../console/views/ConsoleLog.svelte'; |
32 | 32 | import Parameters from '../../parameters/Parameters.svelte'; |
33 | 33 | import StatusBadge from '../../ui/StatusBadge.svelte'; |
| 34 | + import ActionRunLogs from './ActionRunLogs.svelte'; |
34 | 35 |
|
35 | 36 | const dispatch = createEventDispatcher<{ |
36 | 37 | back: void; |
|
55 | 56 | let actionDefinition: ActionDefinition | null = null; |
56 | 57 | let latestVersion: ActionDefinitionVersion | null = null; |
57 | 58 | let isLatestVersion: boolean = false; |
| 59 | + let parsedLogs: ParsedActionLog[] = []; |
58 | 60 | let status: Status | null = null; |
59 | 61 |
|
60 | 62 | $: actionRunIdStore.set(actionRunId); |
|
71 | 73 | $: latestVersion = getLatestRunnableVersion(actionRun?.action_definition.versions ?? []); |
72 | 74 | $: isLatestVersion = latestVersion != null && actionRun?.action_definition_revision === latestVersion.revision; |
73 | 75 | $: 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; |
74 | 86 |
|
75 | 87 | function updateActionSettingsAndParameters(run: ActionRun) { |
76 | 88 | const version = |
|
102 | 114 | ); |
103 | 115 | } |
104 | 116 |
|
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; |
160 | 120 | } |
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 | + ); |
163 | 137 | } |
164 | 138 |
|
165 | 139 | async function onCancelRun() { |
|
213 | 187 | </div> |
214 | 188 | </div> |
215 | 189 | <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> |
216 | 194 | {#if actionDefinition && !actionDefinition.archived} |
217 | 195 | <div |
218 | 196 | use:permissionHandler={{ |
|
287 | 265 | <!-- Output tab (errors, results, logs) --> |
288 | 266 | <Tabs.Content value="output" class="mt-0 flex-1 overflow-y-auto"> |
289 | 267 | <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 | + > |
299 | 273 | <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]} /> |
303 | 275 | </div> |
304 | 276 | {/if} |
305 | 277 | <div class="flex flex-col gap-3 rounded border border-border p-4"> |
|
318 | 290 | </div> |
319 | 291 | <div class="flex flex-col gap-3 rounded border border-border p-4"> |
320 | 292 | <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} /> |
341 | 295 | {:else} |
342 | 296 | <p class="text-xs italic text-muted-foreground">No logs</p> |
343 | 297 | {/if} |
|
0 commit comments