Files
windmill/frontend/src/lib/components/scriptEditor/LogPanel.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

356 lines
11 KiB
Svelte

<script lang="ts">
import { preventDefault } from 'svelte/legacy'
import {
type CompletedJob,
type Job,
JobService,
OpenAPI,
type Preview,
type WorkflowStatus
} from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { base } from '$lib/base'
import { displayDate } from '$lib/utils'
import Tabs from '../common/tabs/Tabs.svelte'
import Tab from '../common/tabs/Tab.svelte'
import DisplayResult from '../DisplayResult.svelte'
import Drawer from '../common/drawer/Drawer.svelte'
import DrawerContent from '../common/drawer/DrawerContent.svelte'
import HighlightCode from '../HighlightCode.svelte'
import LogViewer from '../LogViewer.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import SplitPanesWrapper from '../splitPanes/SplitPanesWrapper.svelte'
import { CheckCircle2, Loader2, XCircle } from 'lucide-svelte'
import type Editor from '../Editor.svelte'
import type DiffEditor from '../DiffEditor.svelte'
import ScriptFix from '../copilot/ScriptFix.svelte'
import JobOtelTraces from '../JobOtelTraces.svelte'
import Cell from '../table/Cell.svelte'
import DataTable from '../table/DataTable.svelte'
import Head from '../table/Head.svelte'
import WorkflowTimeline from '../WorkflowTimeline.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import type { PreviewPanelUi } from '../custom_ui'
import { getStringError } from '../copilot/chat/utils'
interface Props {
lang: Preview['language'] | undefined
previewIsLoading?: boolean
previewJob: (Job & { result_stream?: string; result?: any; success?: boolean }) | undefined
pastPreviews?: CompletedJob[]
editor?: Editor | undefined
diffEditor?: DiffEditor | undefined
args?: Record<string, any> | undefined
workspace?: string | undefined
showCaptures?: boolean
customUi?: PreviewPanelUi | undefined
children?: import('svelte').Snippet
capturesTab?: import('svelte').Snippet
customResultPanel?: import('svelte').Snippet
showCustomResultPanel?: boolean
onTabChange?: (tab: string) => void
}
let {
lang,
previewIsLoading = false,
previewJob,
pastPreviews = [],
editor = undefined,
diffEditor = undefined,
args = undefined,
workspace = undefined,
showCaptures = false,
customUi = undefined,
children,
capturesTab,
customResultPanel,
showCustomResultPanel = false,
onTabChange
}: Props = $props()
type DContent = {
mode: 'json' | Preview['language'] | 'plain'
title: string
content: any
}
let selectedTab = $state('logs')
let drawerOpen: boolean = $state(false)
let drawerContent: DContent | undefined = $state(undefined)
$effect(() => {
onTabChange?.(selectedTab)
})
export function setFocusToLogs() {
selectedTab = 'logs'
}
function openDrawer(newContent: DContent) {
drawerContent = newContent
drawerOpen = true
}
function closeDrawer() {
drawerOpen = false
}
function asWorkflowStatus(x: any): Record<string, WorkflowStatus> {
if (!x || typeof x !== 'object') return {}
const result: Record<string, WorkflowStatus> = {}
for (const [k, v] of Object.entries(x)) {
if (!k.startsWith('_') || k.startsWith('_step/')) result[k] = v as WorkflowStatus
}
return result
}
function getStepResults(x: any): Record<string, any> {
return x?._checkpoint?.completed_steps ?? {}
}
let forceJson = $state(false)
let isWac = $derived(!!previewJob?.workflow_as_code_status)
// Hide the tab strip when only the "Logs & Result" tab would render —
// avoids a single-item bar in embedded contexts (e.g. asset graph pane).
let visibleTabCount = $derived(
1 +
(customUi?.disableHistory !== true ? 1 : 0) +
(showCaptures && customUi?.disableTriggerCaptures !== true ? 1 : 0) +
(customUi?.disableTracing !== true ? 1 : 0)
)
let wacDone = $derived(
previewJob?.type == 'CompletedJob' ||
(previewJob != undefined && !previewIsLoading && !!previewJob.workflow_as_code_status)
)
</script>
<Drawer bind:open={drawerOpen} size="800px">
<DrawerContent title={drawerContent?.title} on:close={() => closeDrawer()}>
{#if drawerContent?.mode === 'json'}
<DisplayResult
workspaceId={previewJob?.workspace_id}
jobId={previewJob?.id}
result={drawerContent.content}
customUi={customUi?.displayResult}
language={lang}
result_stream={previewJob?.result_stream}
/>
{:else if drawerContent?.mode === 'plain'}
<pre
class="overflow-x-auto break-words relative h-full m-2 text-xs bg-surface shadow-inner p-2"
>{drawerContent?.content}
</pre>
{:else if drawerContent?.mode}
<HighlightCode language={drawerContent?.mode} code={drawerContent?.content} />
{/if}
</DrawerContent>
</Drawer>
<div class="h-full flex flex-col">
<Tabs
bind:selected={selectedTab}
class="pt-1"
wrapperClass={visibleTabCount > 1 ? 'flex-none' : 'hidden'}
>
<Tab value="logs" label="Logs & Result" />
{#if customUi?.disableHistory !== true}
<Tab value="history" label="History" />
{/if}
{#if showCaptures && customUi?.disableTriggerCaptures !== true}
<Tab value="captures" label="Trigger captures" />
{/if}
{#if customUi?.disableTracing !== true}
<Tab value="tracing" label="Tracing" />
{/if}
{#snippet content()}
<div class="grow min-h-0">
{#if selectedTab === 'logs'}
{#if isWac}
<div class="h-full overflow-auto">
<WorkflowTimeline
flow_status={asWorkflowStatus(previewJob?.workflow_as_code_status)}
flowDone={wacDone}
stepResults={getStepResults(previewJob?.workflow_as_code_status)}
result={previewJob?.result}
success={previewJob?.success !== false}
autoExpandResult
jobId={previewJob?.id}
/>
</div>
{:else}
<SplitPanesWrapper>
<Splitpanes horizontal={customUi?.logsResultSideBySide !== true}>
<Pane class="relative">
<LogViewer
jobId={previewJob?.id}
duration={previewJob?.['duration_ms']}
mem={previewJob?.['mem_peak']}
content={previewJob?.logs}
isLoading={previewJob?.['running'] == false && previewIsLoading}
tag={previewJob?.tag}
download={customUi?.disableDownload !== true}
tagLabel={customUi?.tagLabel}
/>
</Pane>
<Pane>
{@render children?.()}
{#if showCustomResultPanel && customResultPanel}
<div class="h-full">
{@render customResultPanel()}
</div>
{:else if previewJob != undefined && (previewJob.result_stream || previewJob.result)}
<div class="relative w-full h-full p-2">
<div class="relative h-full">
<DisplayResult
bind:forceJson
workspaceId={previewJob?.workspace_id}
jobId={previewJob?.id}
result={previewJob.result}
customUi={customUi?.displayResult}
language={lang}
result_stream={previewJob?.result_stream}
fixTableSizingToParent
>
{#snippet copilot_fix()}
{#if lang && editor && diffEditor && args && previewJob && !previewJob.success && getStringError(previewJob.result)}
<ScriptFix {lang} />
{/if}
{/snippet}
</DisplayResult>
</div>
</div>
{:else}
<div class="text-sm text-primary p-2 flex justify-between items-center">
<!-- min-h pins this to the text-sm line box, and tracks it across root font
sizes, so swapping the text for the spinner does not resize the row -->
<span class="flex items-center min-h-5">
{#if previewIsLoading}
<Loader2 size={14} class="animate-spin" />
{:else}
Test to see the result here
{/if}
</span>
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/rich_display_rendering"
>
The result renderer in Windmill supports rich display rendering, allowing
you to customize the display format of your results.
</Tooltip>
</div>
{/if}
</Pane>
</Splitpanes>
</SplitPanesWrapper>
{/if}
{/if}
{#if selectedTab === 'history'}
<div>
<DataTable size="xs" noBorder>
<Head>
<tr>
<Cell first>Id</Cell>
<Cell>Created at</Cell>
<Cell>Success</Cell>
<Cell>Result</Cell>
<Cell>Code</Cell>
<Cell last>Logs</Cell>
</tr>
</Head>
<tbody class="divide-y">
{#each pastPreviews as { id, created_at, success }}
<tr>
<Cell first>
<a
class="pr-3"
href="{base}/run/{id}?workspace={workspace ?? $workspaceStore}"
target="_blank">{id.substring(30)}</a
>
</Cell>
<Cell>{displayDate(created_at)}</Cell>
<Cell>
{#if success}
<CheckCircle2 size={10} class="text-green-600" />
{:else}
<XCircle size={10} class="text-red-700" />
{/if}
</Cell>
<Cell>
<button
class="text-xs"
onclick={preventDefault(() => {
openDrawer({ mode: 'json', content: undefined, title: 'Result' })
JobService.getCompletedJobResult({
workspace: workspace ?? $workspaceStore ?? 'NO_W',
id
}).then((res) => {
drawerContent && (drawerContent.content = res)
})
})}
>
See Result
</button>
</Cell>
<Cell>
<button
class="text-xs"
onclick={preventDefault(async () => {
const code = (
await JobService.getCompletedJob({
workspace: workspace ?? $workspaceStore ?? 'NO_W',
id
})
).raw_code
openDrawer({
mode: lang ?? 'plain',
content: String(code),
title: `Code ${lang}`
})
})}
>
View code
</button>
</Cell>
<Cell last>
<button
class="text-xs"
onclick={preventDefault(async () => {
const logs = await (
await fetch(
OpenAPI.BASE +
`/w/${workspace ?? $workspaceStore}/jobs_u/get_logs/${id}`
)
).text()
console.log(logs)
openDrawer({
mode: 'plain',
content: String(logs),
title: `Logs for ${id}`
})
})}
>
View logs
</button>
</Cell>
</tr>
{/each}
</tbody>
</DataTable>
</div>
{/if}
{#if selectedTab === 'captures'}
{@render capturesTab?.()}
{/if}
{#if selectedTab === 'tracing'}
{#if previewJob?.id}
<JobOtelTraces jobId={previewJob.id} />
{:else}
<div class="p-4 text-secondary"> Run a preview to see HTTP request traces </div>
{/if}
{/if}
</div>
{/snippet}
</Tabs>
</div>