From 0f65df24d2cabd9d8daa71c103969e6392e37b4f Mon Sep 17 00:00:00 2001 From: Guilhem Date: Sat, 3 Oct 2026 20:18:44 +0200 Subject: [PATCH] feat: redesign the service logs page (#11506) * feat: redesign the service logs page Co-Authored-By: Claude Opus 5.5 (1M context) * fix: drop a stale jump after a cached host switch and remove unused max_lines Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep focus on url sync and expose host selection and new logs to assistive tech Co-Authored-By: Claude Opus 5.5 (1M context) * fix: reset the tail on dropdown host picks, resume refresh after search, key log cache by host Co-Authored-By: Claude Opus 5.5 (1M context) * fix: clear the pending jump when a host switch needs no fetch Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../lib/components/LogSnippetViewer.svelte | 13 +- .../lib/components/ServiceLogsInner.svelte | 1036 +++++++++++------ .../(root)/(logged)/service_logs/+page.svelte | 76 +- 3 files changed, 680 insertions(+), 445 deletions(-) diff --git a/frontend/src/lib/components/LogSnippetViewer.svelte b/frontend/src/lib/components/LogSnippetViewer.svelte index 8aa1ad9dec..ce913cb6c2 100644 --- a/frontend/src/lib/components/LogSnippetViewer.svelte +++ b/frontend/src/lib/components/LogSnippetViewer.svelte @@ -36,9 +36,10 @@ let html = highlightSnippet(untrack(() => content)) - + diff --git a/frontend/src/lib/components/ServiceLogsInner.svelte b/frontend/src/lib/components/ServiceLogsInner.svelte index 16bdd7d2ad..85c6cf7fb9 100644 --- a/frontend/src/lib/components/ServiceLogsInner.svelte +++ b/frontend/src/lib/components/ServiceLogsInner.svelte @@ -1,7 +1,4 @@ - + {#snippet actions()} {/snippet} -
-
...
{#each content.split('\n') as line, index}
{@html line}
{/each}...
-
+
{#each content.split('\n') as line, index}
{@html line}
{/each}
- - {#snippet left_pane()} -
-
- { - minTs = undefined - maxTs = undefined - allLogs = undefined - const ts = timeframe.computeMinMax() - getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined) - }} - /> -
-
0' }} - on:change={() => { - allLogs = undefined - getAllLogs(minTs, maxTs) - }} - /> - { - if (e.detail) { - getAllLogs(maxTs, undefined) - } else { - timeout && clearTimeout(timeout) - } - }} - options={{ right: 'auto-refresh' }} - />
- {#if searchError} -
- - {searchError} - +
+
+ {@render title?.()} +
+ + + {#if searchTerm !== '' && queryParseErrors && queryParseErrors.length > 0} +
+ + + {#snippet text()} +
+ Some search terms were ignored because they could not be parsed: +
    + {#each queryParseErrors ?? [] as msg} +
  • {msg}
  • + {/each} +
+
+ {/snippet} +
{/if} - {#if allLogs == undefined} -
- {:else if Object.keys(allLogs).length == 0} -
- No logs - Search only covers a recent time window, configurable in instance settings under - Indexer. -
- {:else if minTs && maxTs} - {@const minTsN = new Date(minTs).getTime()} - {@const maxTsN = new Date(maxTs).getTime()} - {@const diff = maxTsN - minTsN} - {#if searchTerm === ''} -
-
+
+ { + minTs = undefined + maxTs = undefined + allLogs = undefined + const ts = timeframe.computeMinMax() + getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined) + }} + /> +
+ { + allLogs = undefined + getAllLogs(minTs, maxTs) + }} + /> + { + if (e.detail) { + getAllLogs(maxTs, undefined) + } else { + timeout && clearTimeout(timeout) + } + }} + options={{ + right: 'Auto-refresh', + rightTooltip: 'Fetch new log files every 5 seconds. Paused while searching.' + }} + /> +
+
-
{new Date(minTs).toLocaleTimeString([], { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit' - })}
{new Date(maxTs).toLocaleTimeString([], { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit' - })}
-
- {/if} -
- selected, + (v) => (v ? selectHost(v.mode, v.workerGroup, v.hostname) : (selected = v)) + } + items={getSelectItems(allLogs, countsPerHost)} + onClear={() => { + selected = undefined + }} + size="sm" + placeholder="Find a host" + /> +
+ {@const minTsN = minTs ? new Date(minTs).getTime() : 0} + {@const diff = maxTs && minTs ? new Date(maxTs).getTime() - minTsN || 1 : 1} +
+ {#if searchTerm === '' && minTs && maxTs} + +
+ +
+ {formatAxisTime(minTs, maxTs)} + {formatAxisTime(maxTs, minTs)} +
+
+ {/if} + {#each sortedEntries(allLogsOrQueryResults(allLogs, countsPerHost)) as [mode, o1]} +
+

{modeLabel(mode, true)}

+ {#each sortedEntries(o1) as [wg, o2]} + {@const hosts = visibleHosts(mode, wg, o2)} + {#if hosts.length > 0} +
+ {#if wg} + {wg} + {/if} + {#each hosts as [hn, files]} + {@const hostKey = `${mode},${wg},${hn}`} + {@const hostMax = Math.max( + 1, + ...files.map((f) => f.ok_lines + f.err_lines) + )} + + {/each} +
+ {/if} + {/each} +
+ {/each} + {#if !loadingLogCounts && sumOtherDocCount != 0} + + {sumOtherDocCount} more matches are not attributed to any of these hosts. + + {/if} +
+ {/if}
- {#each Object.entries(allLogsOrQueryResults(allLogs, countsPerHost)) as [mode, o1]} -
-

{mode}s

- {#each Object.entries(o1) as [wg, o2]} -
- {#if wg && wg != ''} -

{wg}

+ {/snippet} + {#snippet right_pane()} +
+ {#if selected} +
+
+
+

+ {modeLabel(selected.mode, false)} +

+ {selected.hostname} +
+ {#if selected.workerGroup} + + Worker group {selected.workerGroup} + {/if} -
- {#each Object.entries(o2).filter(([hn, files]) => { - if (selected && selected.mode === mode && selected.workerGroup === wg && selected.hostname === hn) { - return true - } - const hostKey = `${mode},${wg},${hn}` - if (countsPerHost && (countsPerHost[hostKey] == undefined || countsPerHost[hostKey].doc_count === 0)) { - return false - } - return true - }) as [hn, files]} - {@const hostKey = `${mode},${wg},${hn}`} - - -
{ - selected = { mode, workerGroup: wg, hostname: hn } - upToIsLatest = true - upTo = getLatestUpTo(selected) - scrollToBottom() +
+ {#if searchTerm == ''} +
+ Last 5 log files up to + +
+ {:else if logs != undefined && !loadingLogs} + + {(logs.hits ?? []).length} matches on this host + + {/if} +
+ {#if searchTerm == ''} + + Logs appear about a minute after they are written, once their file is compacted. + + {/if} +
+
+ {#if loadingLogs} +
+ +
+ {:else if logs != undefined} +
+ + {#each logs.hits ?? [] as hit, i (`${i}:${hit.file_path}:${hit.line_no}`)} + seeLogContext(hit.line_no, hit.file_path, hit.host, true)} + /> + {/each} + {#if (logs.hits ?? []).length === 0} +
+ +
+ {/if} + {#if (logs.hits ?? []).length === 1000} + + Only the 1000 most recent matches are shown. Narrow the search or the time + range to see older ones. + + {/if} +
+ {:else} + + {#each getLogs(selected, upTo) as file (file.file_path)} + {@const entry = logsContent[fileKey(selected.hostname, file.file_path)]} +
{truncateRev(hn, countsPerHost || loadingLogs ? 40 : 8)}
- {#if loadingLogCounts} - - {:else if countsPerHost} -
- {countsPerHost[hostKey]?.doc_count ?? 0} matches + {formatTime(file.ts)} +
+ {#if entry == undefined} +
+ {:else if entry.error} +
+ {#if entry.error?.startsWith('Not Found')} + + Servers and workers need a shared log volume, or the EE object storage + for logs set in the instance settings, for their log files to be + readable from here. + + {:else} + + {entry.error} + + {/if}
+ {:else if entry.content} + {:else} -
- {#each files as file} - {@const okHeight = 100.0 * ((file.ok_lines * 1.0) / (max_lines ?? 1))} - {@const errHeight = 100.0 * ((file.err_lines * 1.0) / (max_lines ?? 1))} -
-
- {/each} -
+

This log file is empty.

{/if}
{/each} -
-
- {/each} -
- {/each} - {#if !loadingLogCounts && sumOtherDocCount != 0} -
- Note: {sumOtherDocCount} additional matches weren't grouped into any of the above hosts. -
- {/if} - {/if} -
- {/snippet} - {#snippet right_pane()} -
- {#if selected} - {#if !loadingLogs && logs == undefined} -
- 1 min delay: logs are compacted before being available -
- {/if} -
- {#if loadingLogs} -
-
- -
-
- {:else if logs != undefined} -
- - {#each logs.hits ?? [] as hit, i (`${i}:${hit.file_path}:${hit.line_no}`)} - seeLogContext(hit.line_no, hit.file_path, hit.host, true)} - /> - {/each} - {#if (logs.hits ?? []).length === 0} -
No logs
- {/if} - {#if (logs.hits ?? []).length === 1000} -
- Older matches were truncated from this search, try refining your filters to get - more precise results. -
- {/if} -
-
- {:else} - {#each getLogs(selected, upTo) as file} -
-
{new Date(file.ts).toLocaleTimeString([], { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit' - })}
- {#if logsContent[file.file_path] == undefined} -
- {:else if logsContent[file.file_path]} - {#if logsContent[file.file_path].error} - {#if logsContent[file.file_path].error?.startsWith('Not Found')} -
Log file is missing. Log files require a shared log volume to be mounted - across servers and workers or to use the EE S3/object storage integration - for logs. To avoid mounting a shared volume, set the EE object store logs in - the instance settings
- {:else} -
{logsContent[file.file_path].error}
- {/if} - {:else if logsContent[file.file_path].content} - - -
- {:else} -
No logs
- {/if} {/if}
- {/each} + {#if showScrollToEnd && logs == undefined} +
+ + {#if hasNewLogs} + + + + + {/if} +
+ {/if} + + + {hasNewLogs ? 'New logs available below' : ''} + +
+ {:else} +
+ +
{/if}
- {#if searchTerm == ''} -
-
Last 5 log files up to:
-
- {#if upTo} - - {:else} -
- {/if} + {/snippet} + +
+
-
-
- {#if upTo} - - {:else} -
- {/if} -
-
- -
-
- {/if} - {:else} -
Select a host to see its logs
- {/if}
- {/snippet} - + diff --git a/frontend/src/routes/(root)/(logged)/service_logs/+page.svelte b/frontend/src/routes/(root)/(logged)/service_logs/+page.svelte index cab2a14790..5b8e56b5a2 100644 --- a/frontend/src/routes/(root)/(logged)/service_logs/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/service_logs/+page.svelte @@ -1,72 +1,34 @@ -
-
-
-
-

Service logs

- Explore and search Windmill service logs from within Windmill! -
-
+{#snippet title()} +
+

Service logs

+ + Logs written by the servers, workers and indexers of this instance, grouped by host. +
+{/snippet} +
{#if $devopsRole == false} - - Service logs are only available to superadmins (or devops) - - {:else} -
- - - - {#if searchTerm !== '' && queryParseErrors && queryParseErrors.length > 0} - - - {#snippet text()} - Some of your search terms have been ignored because one or more parse errors:

- {#if queryParseErrors} -
    - {#each queryParseErrors as msg} -
  • - {msg}
  • - {/each} -
- {/if} - {/snippet} -
- {/if} +
+ {@render title()} + + Ask one of them to look at the logs, or to grant you the devops role. +
- - + {:else} + {/if}
- -