Files
windmill/frontend/src/lib/components/scriptEditor/LogPanel.svelte
T
6186a0645d feat: edit variables, resources and triggers in their own session tab (#11206)
* feat: edit variables, resources and triggers in their own session tab

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

* fix: take a trigger's flow kind from its config and name the written trigger

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

* refactor: one operating-workspace context for editors acting on a session's workspace

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

* fix: reload page item tabs only in sessions acting on the tool's workspace

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

* fix: resolve open in workspace for every preview tab kind in one place

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

* fix: guard every component under a session editor against the navigation workspace

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

* fix: show a page item tab whose item is gone instead of mounting its editor

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

* fix: follow a saved page item in the tab's own workspace and restore in-frame rows

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

* feat: close a page item tab from its editor header

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

* fix: judge trigger permissions by the user acting in the session workspace

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

* fix: judge every session-editor permission by the user acting in that workspace

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

* fix: keep session permissions reactive to the acting role and restore tabs as last seen

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

* fix: default Path's owner controls to the user acting in its workspace

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-09-19 23:29:14 +02:00

362 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 { 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'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
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()}
{@const previewError =
previewJob && !previewJob.success
? getStringError(previewJob.result)
: undefined}
{#if lang && editor && diffEditor && args && previewError}
<ScriptFix {lang} error={previewError} jobId={previewJob?.id} />
{/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 ?? $operatingWorkspace}"
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 ?? $operatingWorkspace ?? '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 ?? $operatingWorkspace ?? '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 ?? $operatingWorkspace}/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>