mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 16:03:27 +00:00
* 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>
356 lines
11 KiB
Svelte
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>
|