Files
windmill/frontend/src/lib/components/LogViewer.svelte
T
Ruben FiszelandClaude Opus 5 2808150ae4 fix: avoid content shift on home page load and in the script editor logs pane (#10654)
* fix: avoid content shift on home page load and in the script editor logs pane

The tutorial banner rendered by default and was removed once an API round-trip
resolved that it should not show, jumping everything below it up by 58px on
every home page load. It now caches the last resolved state in localStorage and
paints that first, so the first frame already matches what the sync concludes; a
device with nothing cached stays hidden until the sync answers.

The logs header spinner was an unsized lucide icon (24px) where the settled
state renders a 12px Timer, so the row grew 7px while a job was queued and
shrank back when it started, shoving the log body down and up again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the tutorial banner hidden when dismissed mid-sync

The banner is interactive while the initial tutorial-progress request is still
in flight, so a dismiss or a skip can land before the sync resolves. The
continuation then overwrote the user's choice and brought the banner back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: pin the result placeholder row height across the spinner swap

Sizing the spinner to the font size still left it 6px short of the text-sm line
box it replaces, so the row contracted instead of growing. Pin the height on the
container so it holds in both states and tracks the root font size.

Also assign state before persisting it, and collapse the duplicated rationale
above the banner cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop the test panel splitpanes resting one header too tall

The panes carried `!max-h-[calc(100%-{...}px)]`, but the arbitrary value is
built by string interpolation so Tailwind never emitted a rule for it: the
class was inert and the computed max-height was `none`. The panes then took
their 100% height, ignoring the header row above them, and overflowed the
column by exactly the header. Flex only applied the shrink transiently, so a
reflow during a run snapped the whole logs & result region up ~12px and back.

min-h-0 lets flex size the panes to the space that is actually left, which is
what the clamp was reaching for and is correct for the debug and bottom layouts
too, without their hardcoded 83/43/0 pixel guesses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 09:33:22 +02:00

675 lines
19 KiB
Svelte

<script lang="ts" module>
import { untrack } from 'svelte'
const s3LogPrefixes = [
'[windmill] Previous logs have been saved to object storage at logs/',
'[windmill] Previous logs have been saved to disk at logs/',
'[windmill] No object storage set in instance settings. Previous logs have been saved to disk at logs/'
]
// Only search within first N characters to avoid expensive full-content scans
const S3_LOG_SEARCH_LIMIT = 2000
</script>
<script lang="ts">
import { ClipboardCopy, Download, Expand, Loader2, Timer, Cpu } from 'lucide-svelte'
import { Button, Drawer, DrawerContent } from './common'
import { copyToClipboard } from '$lib/utils'
import { base } from '$lib/base'
import { withExternalDomain } from '$lib/externalDomain'
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
import { appendViewToken } from '$lib/viewToken'
import { workspaceStore } from '$lib/stores'
import { AnsiUp } from 'ansi_up'
import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte'
import { JobService } from '$lib/gen'
import { WM_LOGS_SKIPPED } from '$lib/consts'
import { isReplaying } from './recording/offlineReplay.svelte'
import Tooltip from './Tooltip.svelte'
import { twMerge } from 'tailwind-merge'
import QueuePosition from './QueuePosition.svelte'
interface Props {
content: string | undefined
isLoading: boolean
duration?: number | undefined
mem?: number | undefined
wrapperClass?: string
jobId?: string | undefined
tag: string | undefined
small?: boolean
drawerOpen?: boolean
noMaxH?: boolean
noAutoScroll?: boolean
download?: boolean
customEmptyMessage?: string
tagLabel?: string
noPadding?: boolean
navigationId?: string
/** Called once after we resolve a WM_LOGS_SKIPPED sentinel by fetching the
* full job. Use this to write the real logs back into the source of truth
* (e.g. flowStateStore) so subsequent mounts don't refetch. */
onLogsResolved?: (logs: string) => void
}
let {
content,
isLoading,
duration = undefined,
mem = undefined,
wrapperClass = '',
jobId = undefined,
tag,
small = false,
drawerOpen = $bindable(false),
noMaxH = false,
noAutoScroll = false,
download = true,
customEmptyMessage = 'No logs are available yet',
tagLabel = undefined,
noPadding = false,
navigationId = undefined,
onLogsResolved
}: Props = $props()
// @ts-ignore
const ansi_up = $state(new AnsiUp())
ansi_up.use_classes = true
let scroll = $state(true)
let preEl: HTMLElement | null = $state(null)
// let downloadStartUrl: string | undefined = undefined
let LOG_INC = 10000
let LOG_LIMIT = $state(LOG_INC)
let lastJobId = $state(untrack(() => jobId))
let loadedFromObjectStore = $state('')
// A replayed job only exists inside the recording: its logs are whatever was
// captured, so every path that would go ask the backend for more has to stay
// shut. `jobId` still comes through for the reset-on-change bookkeeping.
let replaying = $derived(isReplaying())
// `content` is the WM_LOGS_SKIPPED sentinel when the job was fetched with
// no_logs=true. If an older in-memory value accidentally has real bytes
// concatenated after the sentinel, treat that as skipped too and refetch.
let isLogsSkipped = $derived((content ?? '').startsWith(WM_LOGS_SKIPPED))
let resolvedSkippedLogs: string | undefined = $state(undefined)
let fetchedSkippedJobId: string | undefined = $state(undefined)
let effectiveContent = $derived(isLogsSkipped ? resolvedSkippedLogs : content)
let resolvingSkippedLogs = $derived(
isLogsSkipped && !!jobId && !replaying && resolvedSkippedLogs === undefined
)
$effect(() => {
if (!isLogsSkipped || !jobId || replaying || fetchedSkippedJobId === jobId) {
return
}
const id = jobId
fetchedSkippedJobId = id
untrack(() => {
JobService.getJob({ workspace: $workspaceStore ?? '', id })
.then((j) => {
if (fetchedSkippedJobId === id) {
const logs = (j as { logs?: string })['logs'] ?? ''
resolvedSkippedLogs = logs
onLogsResolved?.(logs)
}
})
.catch((e) => console.error('Failed to resolve skipped logs', e))
})
})
function findPrefixInfo(
truncateContent: string
): { prefixIndex: number; position: number } | undefined {
// Quick check - [windmill] is rare, so bail early in the common case
// This is much faster than doing 3 long string indexOf searches
const windmillPos = truncateContent.indexOf('[windmill]')
if (windmillPos === -1 || windmillPos >= S3_LOG_SEARCH_LIMIT) {
return undefined
}
// Found [windmill] marker, now determine which specific prefix matches
for (let i = 0; i < s3LogPrefixes.length; i++) {
const prefix = s3LogPrefixes[i]
if (
truncateContent.length >= windmillPos + prefix.length &&
truncateContent.startsWith(prefix, windmillPos)
) {
return { prefixIndex: i, position: windmillPos }
}
}
return undefined
}
function findStartUrl(
truncateContent: string,
prefixInfo: { prefixIndex: number; position: number } | undefined
) {
if (!prefixInfo) {
return undefined
}
const { prefixIndex, position } = prefixInfo
const prefix = s3LogPrefixes[prefixIndex]
const startOfPath = position + prefix.length
const endOfLine = truncateContent.indexOf('\n', startOfPath)
return truncateContent.substring(startOfPath, endOfLine === -1 ? undefined : endOfLine)
}
function splitAtWindmillLine(
content: string,
prefixInfo: { prefixIndex: number; position: number }
): { before: string; after: string } {
const { prefixIndex, position } = prefixInfo
const prefix = s3LogPrefixes[prefixIndex]
// Find line boundaries
const lineStart = position > 0 && content[position - 1] === '\n' ? position - 1 : position
const pathStart = position + prefix.length
const lineEnd = content.indexOf('\n', pathStart)
if (lineEnd === -1) {
// [windmill] line is at the end
return { before: content.substring(0, lineStart), after: '' }
}
return {
before: content.substring(0, lineStart),
after: content.substring(lineEnd)
}
}
function tooltipText(prefixInfo: { prefixIndex: number; position: number } | undefined) {
if (prefixInfo == undefined) {
return 'No path/file detected to download from'
} else if (prefixInfo.prefixIndex == 0) {
return 'Download the previous logs from the instance configured object store'
} else if (prefixInfo.prefixIndex == 1) {
return 'Attempt to download the logs from disk. Assume there is a shared disk between the workers and the server at /tmp/windmill/logs. Upgrade to EE to use an object store such as S3 instead of a shared volume.'
} else if (prefixInfo.prefixIndex == 2) {
return 'Attempt to download the logs from disk. Assume there is a shared disk between the workers and the server at /tmp/windmill/logs. Since you are on EE, you can alternatively use an object store such as S3 configured in the instance settings instead of a shared volume..'
}
}
function truncateContent(
jobContent: string | undefined,
loadedFromObjectStore: string,
limit: number
) {
let content = loadedFromObjectStore + (jobContent ?? '')
if (content.length > limit) {
return content.substring(content.length - limit)
}
return content
}
export function scrollToBottom() {
scroll && setTimeout(() => preEl?.scroll({ top: preEl?.scrollHeight, behavior: 'smooth' }), 100)
}
let logViewer: Drawer | undefined = $state()
async function getStoreLogs() {
if (downloadStartUrl) {
scroll = false
let res = (await JobService.getLogFileFromStore({
workspace: $workspaceStore ?? '',
path: downloadStartUrl
})) as string
LOG_LIMIT += Math.min(LOG_INC, res.length)
loadedFromObjectStore = res + loadedFromObjectStore
let newC = truncateContent(effectiveContent, loadedFromObjectStore, LOG_LIMIT)
LOG_LIMIT -= newC.indexOf('\n') + 1
} else {
console.error('No file detected to download from')
}
}
function showMoreTruncate(len: number) {
scroll = false
LOG_LIMIT += LOG_INC
let newC = truncateContent(effectiveContent, loadedFromObjectStore, LOG_LIMIT)
let newlineIndex = newC.indexOf('\n') + 1
if (newlineIndex < LOG_INC / 2) {
LOG_LIMIT -= newlineIndex
}
}
$effect.pre(() => {
if (jobId !== lastJobId) {
lastJobId = jobId
loadedFromObjectStore = ''
LOG_LIMIT = LOG_INC
scroll = true
resolvedSkippedLogs = undefined
fetchedSkippedJobId = undefined
}
})
let logsApiPath = $derived(appendViewToken(`/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`))
let downloadHref = $derived(withExternalDomain(`${base}/api${logsApiPath}`))
let downloadName = $derived(`windmill_logs_${jobId}.txt`)
let truncatedContent = $derived(
truncateContent(effectiveContent, loadedFromObjectStore, LOG_LIMIT)
)
// The "Show more..." button behind this reads the rest of the logs out of the
// instance's object store, which a replay has no access to; leaving `prefixInfo`
// unset renders the `[windmill]` line as the plain log line it is.
let prefixInfo = $derived(replaying ? undefined : findPrefixInfo(truncatedContent))
let downloadStartUrl = $derived(findStartUrl(truncatedContent, prefixInfo))
$effect.pre(() => {
truncatedContent && scrollToBottom()
})
// When [windmill] line is NOT at start, split into before/after to render button inline
let splitHtml = $derived.by(() => {
if (prefixInfo == undefined || prefixInfo.position === 0) {
return undefined
}
const { before, after } = splitAtWindmillLine(truncatedContent, prefixInfo)
return {
before: ansi_up.ansi_to_html(before),
after: ansi_up.ansi_to_html(after)
}
})
// Only compute html when splitHtml won't be used (avoids wasteful ansi_to_html call)
let html = $derived.by(() => {
if (splitHtml) {
// splitHtml is active - skip expensive computation
return ''
}
if (prefixInfo == undefined) {
// No [windmill] line - return full content
return ansi_up.ansi_to_html(truncatedContent)
}
// [windmill] at start - strip the line and return the rest
const { after } = splitAtWindmillLine(truncatedContent, prefixInfo)
return ansi_up.ansi_to_html(after)
})
</script>
<Drawer bind:this={logViewer} bind:open={drawerOpen} size="900px">
<DrawerContent title="Expanded Logs" on:close={logViewer.closeDrawer}>
{#snippet actions()}
{#if jobId && download}
{#if shouldDownloadViaClient()}
<Button
on:click={() => downloadViaClient(logsApiPath, downloadName)}
color="light"
size="xs"
startIcon={{
icon: Download
}}
>
Download
</Button>
{:else}
<Button
href={downloadHref}
download={downloadName}
color="light"
size="xs"
startIcon={{
icon: Download
}}
>
Download
</Button>
{/if}
{/if}
<Button
on:click={() => copyToClipboard(effectiveContent)}
color="light"
size="xs"
startIcon={{
icon: ClipboardCopy
}}
>
Copy to clipboard
</Button>
{/snippet}
<div>
<pre
class="bg-surface-secondary text-primary text-xs w-full p-2 whitespace-pre-wrap border rounded-md"
>{#if resolvingSkippedLogs}<Loader2
class="animate-spin"
size={14}
/>{:else if effectiveContent}{@const len =
(effectiveContent?.length ?? 0) +
(loadedFromObjectStore?.length ?? 0)}{#if splitHtml}{@html splitHtml.before}<button
onclick={getStoreLogs}
>Show more... <Tooltip>{tooltipText(prefixInfo)}</Tooltip></button
>{@html splitHtml.after}{:else if downloadStartUrl}<button onclick={getStoreLogs}
>Show more... <Tooltip>{tooltipText(prefixInfo)}</Tooltip></button
><br
/>{@html html}{:else if len > LOG_LIMIT}(truncated to the last {LOG_LIMIT} characters)...<br
/><button onclick={() => showMoreTruncate(len)}>Show more..</button><br
/>{@html html}{:else}{@html html}{/if}{:else if isLoading}Waiting for job to start...{:else}No logs are available yet{/if}</pre
>
</div>
</DrawerContent>
</Drawer>
<div class="w-full h-full {wrapperClass}">
<div class="w-full h-full relative">
<div
class="w-full h-full bg-surface-secondary flex flex-col {noMaxH ? '' : 'max-h-screen'}"
data-nav-id={navigationId}
>
<div
class="flex gap-2 ml-2 {small ? 'py-1' : 'py-2'} border-b overflow-x-auto overflow-y-hidden"
>
{#if isLoading}
<div class="flex gap-2 items-center">
<!-- Same size as the Timer icon this swaps out for: an unsized (24px) spinner makes
this header taller than the settled state and shoves the logs down while queued -->
<Loader2 size={small ? 10 : 12} class="animate-spin" />
{#if tag}
<div class="flex flex-row items-center gap-1">
<div class="text-secondary text-2xs">{tagLabel ?? 'tag'}: {tag}</div>
<NoWorkerWithTagWarning {tagLabel} {tag} />
</div>
{/if}
{#if jobId && !replaying}
<QueuePosition {jobId} />
{/if}
</div>
{:else if duration}
<span
class={twMerge(
'flex items-center gap-1 text-secondary dark:text-gray-400',
small ? '!text-2xs' : '!text-xs'
)}
title="Duration"
>
<Timer size={small ? 10 : 12} />
{duration}ms
</span>
{/if}
{#if mem}
<span
class={twMerge(
'flex items-center gap-1 text-secondary dark:text-gray-400',
small ? '!text-2xs' : '!text-xs'
)}
title="Memory peak"
>
<Cpu size={small ? 10 : 12} />
{(mem / 1024).toPrecision(4)}MB
</span>
{/if}
<div class="flex gap-2 justify-end flex-1">
{#if jobId && download}
<div class="flex items-center">
{#if shouldDownloadViaClient()}
<button
class="text-primary pb-0.5"
onclick={() => downloadViaClient(logsApiPath, downloadName)}
><Download size="14" />
</button>
{:else}
<a
class="text-primary pb-0.5"
target="_blank"
href={downloadHref}
download={downloadName}
><Download size="14" />
</a>
{/if}
</div>
{/if}
<button onclick={logViewer.openDrawer}><Expand size="12" /></button>
{#if !noAutoScroll}
<label
class="pr-2 text-2xs flex gap-2 font-normal text-primary items-center whitespace-nowrap"
>
auto-scroll
<input class="windmillapp" type="checkbox" bind:checked={scroll} />
</label>
{/if}
</div>
</div>
<pre
bind:this={preEl}
class={twMerge(
'whitespace-pre break-words w-full flex-1 overflow-auto',
small ? '!text-2xs' : '!text-xs',
noPadding ? '' : 'p-2'
)}
>{#if resolvingSkippedLogs}<span class="flex p-2"
><Loader2 class="animate-spin" size={14} /></span
>{:else if effectiveContent}{@const len =
(effectiveContent?.length ?? 0) +
(loadedFromObjectStore?.length ?? 0)}{#if splitHtml}<span>{@html splitHtml.before}</span
><button onclick={getStoreLogs}
>Show more... &nbsp;<Tooltip>{tooltipText(prefixInfo)}</Tooltip></button
><span>{@html splitHtml.after}</span>{:else if downloadStartUrl}<button
onclick={getStoreLogs}
>Show more... &nbsp;<Tooltip>{tooltipText(prefixInfo)}</Tooltip></button
><br /><span>{@html html}</span>{:else if len > LOG_LIMIT}<button
onclick={() => showMoreTruncate(len)}>Show more..</button
>&nbsp;({LOG_LIMIT}/{len} chars)<br /><span>{@html html}</span>{:else}<span
>{@html html}</span
>{/if}{:else if !isLoading}<span>{customEmptyMessage}</span>{/if}</pre
>
</div>
</div>
</div>
<style global>
/* Foreground colors */
.ansi-black-fg {
color: rgb(0, 0, 0);
}
.ansi-red-fg {
color: rgb(187, 0, 0);
}
.ansi-green-fg {
color: rgb(0, 187, 0);
}
.ansi-yellow-fg {
color: rgb(187, 187, 0);
}
.ansi-blue-fg {
color: rgb(0, 0, 187);
}
.ansi-magenta-fg {
color: rgb(187, 0, 187);
}
.ansi-cyan-fg {
color: rgb(0, 187, 187);
}
.ansi-white-fg {
color: rgb(255, 255, 255);
}
.ansi-bright-black-fg {
color: rgb(85, 85, 85);
}
.ansi-bright-red-fg {
color: rgb(255, 85, 85);
}
.ansi-bright-green-fg {
color: rgb(0, 255, 0);
}
.ansi-bright-yellow-fg {
color: rgb(255, 255, 85);
}
.ansi-bright-blue-fg {
color: rgb(85, 85, 255);
}
.ansi-bright-magenta-fg {
color: rgb(255, 85, 255);
}
.ansi-bright-cyan-fg {
color: rgb(85, 255, 255);
}
.ansi-bright-white-fg {
color: rgb(255, 255, 255);
}
/* Background colors */
.ansi-black-bg {
background-color: rgb(0, 0, 0);
}
.ansi-red-bg {
background-color: rgb(187, 0, 0);
}
.ansi-green-bg {
background-color: rgb(0, 187, 0);
}
.ansi-yellow-bg {
background-color: rgb(187, 187, 0);
}
.ansi-blue-bg {
background-color: rgb(0, 0, 187);
}
.ansi-magenta-bg {
background-color: rgb(187, 0, 187);
}
.ansi-cyan-bg {
background-color: rgb(0, 187, 187);
}
.ansi-white-bg {
background-color: rgb(255, 255, 255);
}
.ansi-bright-black-bg {
background-color: rgb(85, 85, 85);
}
.ansi-bright-red-bg {
background-color: rgb(255, 85, 85);
}
.ansi-bright-green-bg {
background-color: rgb(0, 255, 0);
}
.ansi-bright-yellow-bg {
background-color: rgb(255, 255, 85);
}
.ansi-bright-blue-bg {
background-color: rgb(85, 85, 255);
}
.ansi-bright-magenta-bg {
background-color: rgb(255, 85, 255);
}
.ansi-bright-cyan-bg {
background-color: rgb(85, 255, 255);
}
.ansi-bright-white-bg {
background-color: rgb(255, 255, 255);
}
/* Foreground colors for dark mode (Nord theme) */
.dark .ansi-black-fg {
color: rgb(46, 52, 64);
}
.dark .ansi-red-fg {
color: rgb(191, 97, 106);
}
.dark .ansi-green-fg {
color: rgb(163, 190, 140);
}
.dark .ansi-yellow-fg {
color: rgb(235, 203, 139);
}
.dark .ansi-blue-fg {
color: rgb(94, 129, 172);
}
.dark .ansi-magenta-fg {
color: rgb(180, 142, 173);
}
.dark .ansi-cyan-fg {
color: rgb(136, 192, 208);
}
.dark .ansi-white-fg {
color: rgb(216, 222, 233);
}
.dark .ansi-bright-black-fg {
color: rgb(67, 76, 94);
}
.dark .ansi-bright-red-fg {
color: rgb(191, 97, 106);
}
.dark .ansi-bright-green-fg {
color: rgb(163, 190, 140);
}
.dark .ansi-bright-yellow-fg {
color: rgb(235, 203, 139);
}
.dark .ansi-bright-blue-fg {
color: rgb(94, 129, 172);
}
.dark .ansi-bright-magenta-fg {
color: rgb(180, 142, 173);
}
.dark .ansi-bright-cyan-fg {
color: rgb(136, 192, 208);
}
.dark .ansi-bright-white-fg {
color: rgb(229, 233, 240);
}
/* Background colors for dark mode (Nord theme) */
.dark .ansi-black-bg {
background-color: rgb(46, 52, 64);
}
.dark .ansi-red-bg {
background-color: rgb(191, 97, 106);
}
.dark .ansi-green-bg {
background-color: rgb(163, 190, 140);
}
.dark .ansi-yellow-bg {
background-color: rgb(235, 203, 139);
}
.dark .ansi-blue-bg {
background-color: rgb(94, 129, 172);
}
.dark .ansi-magenta-bg {
background-color: rgb(180, 142, 173);
}
.dark .ansi-cyan-bg {
background-color: rgb(136, 192, 208);
}
.dark .ansi-white-bg {
background-color: rgb(216, 222, 233);
}
.dark .ansi-bright-black-bg {
background-color: rgb(67, 76, 94);
}
.dark .ansi-bright-red-bg {
background-color: rgb(191, 97, 106);
}
.dark .ansi-bright-green-bg {
background-color: rgb(163, 190, 140);
}
.dark .ansi-bright-yellow-bg {
background-color: rgb(235, 203, 139);
}
.dark .ansi-bright-blue-bg {
background-color: rgb(94, 129, 172);
}
.dark .ansi-bright-magenta-bg {
background-color: rgb(180, 142, 173);
}
.dark .ansi-bright-cyan-bg {
background-color: rgb(136, 192, 208);
}
.dark .ansi-bright-white-bg {
background-color: rgb(229, 233, 240);
}
[data-nav-id]:focus-visible {
outline: none;
outline-offset: 0;
}
</style>