Files
windmill/frontend/src/lib/components/PersistentScriptDrawer.svelte
T
GuilhemandClaude Opus 5 b10f83c763 feat(sessions): show an item's deployed page in the preview panel (#11458)
* feat(sessions): show an item's deployed page in the preview panel

The preview panel showed an item's editor and nothing else. It now shows the
deployed page too, as a tab of its own.

**Two sides, two tabs.** The editor and the deployed page are different things
to look at, so `isItemTabFor` keys on the side rather than the item: asking for
a side already open focuses it, asking for the other opens it alongside. Two
editors for one item still cannot coexist — they would race its single
(kind, path) cell — but an editor beside a viewer is safe, since the viewer
reads the deployed version over the API and holds no cell. The tab breadcrumb
keeps re-pointing in place; `Exit & see details` and the detail page's `Edit`
open the other side instead of consuming the one you are on. A tab's label
carries `(edit)` when it is the editor side.

**The detail pages moved out of their routes.** `/scripts/get` and `/flows/get`
are now thin wrappers over `ScriptDetail` and `FlowDetail`, which the panel
renders too. Everything they reach — drawers, triggers, saved inputs — is scoped
to the viewer's workspace through `setOperatingWorkspace`, and every navigation
they attempt is caught by `interceptNav` and turned into a move inside the
panel, so nothing takes the browser out of the session. A plain click is
intercepted; ⌘-click and middle-click still open a real tab, which is why the
pages keep their `href`s.

**The picker opens what exists.** A row opens the deployed page, or the editor
when nothing is deployed there, and carries a Draft / Draft only badge in the
review dock's words. `WorkspaceItem` gained `draftOnly` and `hasDraft` from the
listers, which already returned both; deploys now invalidate the picker cache
through `itemDeployed`, so a just-deployed item stops reading as a draft. Rows
also carry a hover Edit action, reachable from the keyboard with the pick
modifier.

`open_preview` gained a `mode`, narrowed out of the advertised schema for a
session that cannot write drafts and re-checked per path in the handler, where
a refusal reports "couldn't check" apart from "denied". The mode is resolved
before those checks, so a call that omits it is gated as the editor it will
become.

Alongside, the workspace a detail page acts on is now the one it is showing
rather than the one the browser is navigated to: `MoveDrawer`,
`toggleWorkspaceErrorHandler` and the pages' own permission gates read the
operating workspace and its acting user, and `RunForm` mints a password
argument's ephemeral variable there too — in a fork session all of these
previously answered for the parent. `InWorkspaceAppViewer` hands the app's
`ctx` user down as a prop instead of writing the global `userStore`, which from
a session tab was re-pointing every permission check on the page.

Fixes found on the way: `DetailPageLayout` claimed `h-screen` inside a panel
that is not the viewport; Edit on a historical script version dropped the
version from the intercepted click; the Run button stayed spinning after a run
that left the page mounted; and the window-level run shortcut fired from a
collapsed panel and from keys another handler had already claimed.

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

* fix(sessions): historical edits, stale not-found, shared edit rights, tab labels

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

* fix(sessions): keep deployed-page links and workspace reads in the session

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

* fix(sessions): scope detail hrefs to the session, re-point the viewer's own tab

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

* fix(sessions): keep a previewed raw app's route out of the session URL

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

* fix(sessions): resolve edit-in-fork against the session workspace

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

* fix(sessions): read advanced run tags from the session workspace

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

* fix(sessions): fork from the session workspace, star only navigation items

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

* fix(sessions): mount only the side a preview tab shows

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

* fix(sessions): address CI review round 1 findings

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

* fix(sessions): route links in a deployed page's drawers through the panel

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

* fix(sessions): tell the chat which deployed page the panel shows

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

* fix(sessions): only a 404 reads as an undeployed raw app

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

* fix(sessions): scope the unparseable-JSON run gate to the form

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

* fix(sessions): a flow deleted from the panel stays in its tab

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

* fix(sessions): the picker's edit shortcut works on the current row

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

* fix(sessions): load-error toasts say what failed

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

* fix(sessions): keep an oversized invalid JSON editor in its form's run gate

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

* revert: keep main's draft_only description in openapi.yaml

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:43:52 +02:00

192 lines
4.9 KiB
Svelte

<script lang="ts">
import { JobService, type Script } from '$lib/gen'
import { Badge, Button, Drawer } from './common'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import { createEventDispatcher, onDestroy } from 'svelte'
import { base } from '$lib/base'
import { displayDate, sleep, sendUserToast } from '$lib/utils'
import TableCustom from './TableCustom.svelte'
import { Hourglass, Loader2, Play, RefreshCw } from 'lucide-svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
let dispatch = createEventDispatcher()
let drawer: Drawer | undefined = $state()
let script: Script | undefined = $state()
let loadQueuedJobs = $state(true)
let queuedJobsLoading = $state(false)
let queuedJobs: {
status: 'running' | 'queued'
jobId: string
scheduledFor: string
scriptHash: string
}[] = $state([])
let cancellingInProgress = $state(false)
async function continuouslyLoadQueuedJobs() {
while (loadQueuedJobs) {
loadQueuedJobsOnce()
await sleep(3 * 1000)
}
}
async function loadQueuedJobsOnce() {
if (queuedJobsLoading) {
return
}
const timeStart = new Date().getTime()
queuedJobsLoading = true
let qjs = await JobService.listQueue({
workspace: $operatingWorkspace ?? '',
orderDesc: false,
scriptPathExact: script?.path
})
let loadingQueuedJobs: {
status: 'running' | 'queued'
jobId: string
scheduledFor: string
scriptHash: string
}[] = []
for (const qj of qjs) {
loadingQueuedJobs.push({
status: qj.started_at ? 'running' : 'queued',
jobId: qj.id,
scriptHash: qj.script_hash ?? '',
scheduledFor: displayDate(qj.scheduled_for, true)
})
}
queuedJobs = loadingQueuedJobs
const endStart = new Date().getTime()
// toggle queuedJobsLoading to false in 1 secs to let some time for the animation to play
setTimeout(
() => {
queuedJobsLoading = false
},
3000 - (endStart - timeStart)
)
}
async function scaleToZero() {
cancellingInProgress = true
await JobService.cancelPersistentQueuedJobs({
workspace: $operatingWorkspace ?? '',
path: script?.path ?? '',
requestBody: {
reason: undefined
}
})
sendUserToast(`All jobs cancelled for ${script?.path}`)
cancellingInProgress = false
}
export async function open(persistentScript: Script | undefined) {
if (persistentScript === undefined) {
console.log('Unable to open persistent script drawer without a proper script definition')
return
}
script = persistentScript!
loadQueuedJobs = true
continuouslyLoadQueuedJobs()
drawer?.openDrawer?.()
}
async function exit() {
loadQueuedJobs = false
drawer?.closeDrawer?.()
}
onDestroy(() => {
loadQueuedJobs = false
})
</script>
<Drawer
bind:this={drawer}
on:close={() => {
loadQueuedJobs = false
dispatch('close')
}}
size="800px"
>
<DrawerContent
title="Persistent script"
overflow_y={false}
on:close={exit}
tooltip="Manage runs of persistent scripts. Scaling a persistent script to zero will cancel all current runs of this script based on the script path."
>
<div class="flex gap-2 items-center justify-between">
<h2>
Queued jobs for {script?.path}
</h2>
<Button size="md" btnClasses="w-full h-8" variant="default" on:click={loadQueuedJobsOnce}>
<RefreshCw class={queuedJobsLoading ? 'animate-spin' : ''} size={14} />
</Button>
</div>
<TableCustom>
{#snippet headerRow()}
<tr>
<th class="text-xs">Script Hash</th>
<th class="text-xs">Job ID</th>
<th class="text-xs">Status</th>
<th class="text-xs">Scheduled For</th>
</tr>
{/snippet}
{#snippet body()}
<tbody>
{#each queuedJobs as { jobId, status, scriptHash, scheduledFor }}
<tr class="">
<td class="text-xs">
<a
class="pr-3"
href="{base}/scripts/get/{scriptHash}?workspace={$operatingWorkspace}"
target="_blank"
>
{scriptHash}
</a>
</td>
<td class="text-xs">
<a
class="pr-3"
href="{base}/run/{jobId}?workspace={$operatingWorkspace}"
target="_blank">{jobId.substring(24)}</a
>
</td>
<td class="text-xs">
{#if status === 'running'}
<Badge color="yellow" baseClass="!px-1.5">
<Play size={14} />
</Badge>
{:else}
<Badge baseClass="!px-1.5">
<Hourglass size={14} />
</Badge>
{/if}
</td>
<td class="text-xs">{scheduledFor}</td>
</tr>
{/each}
</tbody>
{/snippet}
</TableCustom>
{#snippet actions()}
<div class="flex gap-1">
<Button
color="red"
disabled={cancellingInProgress === true || queuedJobs.length === 0}
on:click={scaleToZero}
>
{#if cancellingInProgress}
<Loader2 class="animate-spin" /> Stopping jobs
{:else}
Scale down to 0
{/if}
</Button>
</div>
{/snippet}
</DrawerContent>
</Drawer>