mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-17 16:03:06 +00:00
refactor: mount the runs list in process in ai session previews (#11724)
* refactor: mount the runs list in process in ai session previews Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: tell runs list tabs apart by filters and reseed on re-point Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: remount a hosted runs list only when moved from outside Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3d74f6ea2a
commit
3d67d3ac6b
@@ -11,14 +11,12 @@
|
||||
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { displayDate } from '$lib/utils'
|
||||
import { userWorkspaces, superadmin, devopsRole, enterpriseLicense } from '$lib/stores'
|
||||
import { useHostedPage } from '$lib/components/hostedPage'
|
||||
import {
|
||||
userStore,
|
||||
workspaceStore,
|
||||
userWorkspaces,
|
||||
superadmin,
|
||||
devopsRole,
|
||||
enterpriseLicense
|
||||
} from '$lib/stores'
|
||||
useOperatingUser,
|
||||
useOperatingWorkspace
|
||||
} from '$lib/components/operatingWorkspace.svelte'
|
||||
import { Button, Drawer, DrawerContent, Skeleton, Tab, Tabs } from '$lib/components/common'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import RunChart from '$lib/components/RunChart.svelte'
|
||||
@@ -81,6 +79,13 @@
|
||||
|
||||
let { initialPath }: Props = $props()
|
||||
|
||||
// Under a host — an AI session's preview tab — the document URL, the page header band and
|
||||
// the navigation workspace all belong to the host: the filters live where it keeps them,
|
||||
// the filter row renders in the page, and requests go to the workspace it names.
|
||||
const hosted = useHostedPage()
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
|
||||
let paths: string[] = $state([])
|
||||
let usernames: string[] = $state([])
|
||||
let folders: string[] = $state([])
|
||||
@@ -91,7 +96,7 @@
|
||||
folders,
|
||||
jobTriggerKinds,
|
||||
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
|
||||
isAdminsWorkspace: $workspaceStore === 'admins'
|
||||
isAdminsWorkspace: $operatingWorkspace === 'admins'
|
||||
})
|
||||
)
|
||||
let perPage = useLocalStorageValue('runs_per_page', 1000, 'number')
|
||||
@@ -115,6 +120,8 @@
|
||||
// what was seeded at mount is gone. Re-apply it. Editing a filter writes with `replaceState`,
|
||||
// which never reaches `page.url`, so a filter the user clears stays cleared.
|
||||
$effect(() => {
|
||||
// A host remounts the page when it re-points it, and its document URL is not this page's.
|
||||
if (hosted) return
|
||||
page.url.href
|
||||
untrack(() => filters.seed(filterSeeds()))
|
||||
})
|
||||
@@ -171,7 +178,9 @@
|
||||
let manualTimeframe = $derived(timeframe.type === 'manual' ? timeframe : undefined)
|
||||
|
||||
let graph: 'RunChart' | 'ConcurrencyChart' = $state(
|
||||
typeOfChart(page.url.searchParams.get('graph'))
|
||||
typeOfChart(
|
||||
(hosted ? new URLSearchParams(hosted.search) : page.url.searchParams).get('graph')
|
||||
)
|
||||
)
|
||||
let innerWidth = $state(window.innerWidth)
|
||||
let jobsLoader = useJobsLoader(() => ({
|
||||
@@ -183,7 +192,7 @@
|
||||
resultError,
|
||||
perPage: perPage.val,
|
||||
lookback: graph === 'RunChart' ? 0 : lookback,
|
||||
currentWorkspace: $workspaceStore ?? ''
|
||||
currentWorkspace: $operatingWorkspace ?? ''
|
||||
}))
|
||||
let batchProgress = $derived(jobsLoader.batchProgress)
|
||||
let currentBatchSize = $derived(jobsLoader.currentBatchSize)
|
||||
@@ -211,18 +220,18 @@
|
||||
}
|
||||
|
||||
async function loadUsernames(): Promise<void> {
|
||||
usernames = await UserService.listUsernames({ workspace: $workspaceStore! })
|
||||
usernames = await UserService.listUsernames({ workspace: $operatingWorkspace! })
|
||||
}
|
||||
|
||||
async function loadFolders(): Promise<void> {
|
||||
folders = await FolderService.listFolders({
|
||||
workspace: $workspaceStore!
|
||||
workspace: $operatingWorkspace!
|
||||
}).then((x) => x.map((y) => y.name))
|
||||
}
|
||||
|
||||
async function loadPaths() {
|
||||
const npaths_scripts = await ScriptService.listScriptPaths({ workspace: $workspaceStore ?? '' })
|
||||
const npaths_flows = await FlowService.listFlowPaths({ workspace: $workspaceStore ?? '' })
|
||||
const npaths_scripts = await ScriptService.listScriptPaths({ workspace: $operatingWorkspace ?? '' })
|
||||
const npaths_flows = await FlowService.listFlowPaths({ workspace: $operatingWorkspace ?? '' })
|
||||
paths = npaths_scripts.concat(npaths_flows).sort()
|
||||
}
|
||||
|
||||
@@ -265,7 +274,7 @@
|
||||
const resultFilter = filters.val.result && JSON.parse(filters.val.result)
|
||||
const { minTs, maxTs } = timeframe.computeMinMax()
|
||||
return {
|
||||
workspace: $workspaceStore ?? '',
|
||||
workspace: $operatingWorkspace ?? '',
|
||||
startedBefore: maxTs ?? undefined,
|
||||
startedAfter: minTs ?? undefined,
|
||||
schedulePath: filters.val.schedule_path ?? undefined,
|
||||
@@ -323,7 +332,7 @@
|
||||
}
|
||||
async function cancelJobs(uuidsToCancel: string[], forceCancel: boolean = false) {
|
||||
const uuids = await JobService.cancelSelection({
|
||||
workspace: $workspaceStore ?? '',
|
||||
workspace: $operatingWorkspace ?? '',
|
||||
requestBody: uuidsToCancel,
|
||||
forceCancel: forceCancel,
|
||||
allWorkspaces: filters.val.all_workspaces || undefined
|
||||
@@ -350,7 +359,7 @@
|
||||
for (const j of jobs ?? []) if (j.workspace_id) workspaceById.set(j.id, j.workspace_id)
|
||||
const byWorkspace = new Map<string, string[]>()
|
||||
for (const id of jobIds) {
|
||||
const ws = workspaceById.get(id) ?? $workspaceStore ?? ''
|
||||
const ws = workspaceById.get(id) ?? $operatingWorkspace ?? ''
|
||||
const bucket = byWorkspace.get(ws)
|
||||
if (bucket) bucket.push(id)
|
||||
else byWorkspace.set(ws, [id])
|
||||
@@ -428,7 +437,7 @@
|
||||
}
|
||||
|
||||
async function reRunJobs(jobIdsToReRun: string[], batchReRunOptions: BatchReRunOptions) {
|
||||
if (!$workspaceStore) return
|
||||
if (!$operatingWorkspace) return
|
||||
|
||||
if (askingForConfirmation) {
|
||||
askingForConfirmation.loading = true
|
||||
@@ -442,7 +451,7 @@
|
||||
|
||||
// workaround because EventSource does not support POST requests
|
||||
// https://medium.com/@david.richards.tech/sse-server-sent-events-using-a-post-request-without-eventsource-1c0bd6f14425
|
||||
const response = await fetch(`${OpenAPI.BASE}/w/${$workspaceStore}/jobs/run/batch_rerun_jobs`, {
|
||||
const response = await fetch(`${OpenAPI.BASE}/w/${$operatingWorkspace}/jobs/run/batch_rerun_jobs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
@@ -555,7 +564,7 @@
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($workspaceStore) {
|
||||
if ($operatingWorkspace) {
|
||||
untrack(() => {
|
||||
loadUsernames()
|
||||
loadFolders()
|
||||
@@ -603,7 +612,7 @@
|
||||
openedRunId = undefined
|
||||
}
|
||||
let resolutionNote = $state('')
|
||||
const phone = $derived(pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
|
||||
const phone = $derived(!hosted && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR)
|
||||
</script>
|
||||
|
||||
{#snippet runsHint()}
|
||||
@@ -620,7 +629,7 @@
|
||||
two or three buttons, so they take a wider gap than the band's own. `flex-1 min-w-0` passes
|
||||
the band's flexibility down to the search field, the one control here that gives width
|
||||
back; the rest keep theirs or drop out of the row entirely at a width. -->
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0 {hosted ? 'flex-wrap justify-end' : ''}">
|
||||
<!-- Always the icon form: the bar has room for two counters, never for the two labelled
|
||||
blocks. pr-2 on top of the row's own gap because the counts hang outside their icons,
|
||||
so without it the badge butts against the first filter.
|
||||
@@ -816,12 +825,12 @@
|
||||
schema={runsFilterSearchbarSchema}
|
||||
presets={buildRunsFilterPresets({
|
||||
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
|
||||
isAdminsWorkspace: $workspaceStore === 'admins'
|
||||
isAdminsWorkspace: $operatingWorkspace === 'admins'
|
||||
})}
|
||||
bind:value={filters.val}
|
||||
placeholder="Filter runs..."
|
||||
size="sm"
|
||||
autofocus
|
||||
autofocus={!hosted}
|
||||
/>
|
||||
<TimeframeSelect
|
||||
unifiedSize="sm"
|
||||
@@ -833,11 +842,11 @@
|
||||
<!-- The filters are shallow-routed, so the search has to come off `window.location` at click
|
||||
time — `page.url` never sees them. Always the canonical `/runs`: only that is a recognized
|
||||
preview page, and the `/runs/<path>` route mirrors its path into `?path=` anyway. -->
|
||||
<div class={phone ? 'hidden' : 'contents'}>
|
||||
<div class={phone || hosted ? 'hidden' : 'contents'}>
|
||||
<OpenInSessionButton
|
||||
source={{
|
||||
page: () => RUNS_PATH + window.location.search,
|
||||
workspaceId: $workspaceStore ?? undefined
|
||||
workspaceId: $operatingWorkspace ?? undefined
|
||||
}}
|
||||
btnProps={{ unifiedSize: 'sm' }}
|
||||
/>
|
||||
@@ -900,13 +909,14 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<!-- The document's history is the host's: its Back is not a move on this page. -->
|
||||
<svelte:window
|
||||
onpopstate={() => {
|
||||
reset()
|
||||
if (!hosted) reset()
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.runs}
|
||||
{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.runs}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
<p>Page not available for operators</p>
|
||||
@@ -915,7 +925,13 @@
|
||||
<div class="w-full h-full flex flex-col" bind:clientWidth={innerWidth}>
|
||||
<!-- `actionsFlexible`: the filter row holds a search field that can give width back, so the
|
||||
bar lets this box shrink on a phone instead of holding its content width. -->
|
||||
<PageHeaderContent afterName={runsHint} actions={headerActions} actionsFlexible />
|
||||
{#if hosted}
|
||||
<div class="flex px-4 pt-3 pb-2">
|
||||
{@render headerActions()}
|
||||
</div>
|
||||
{:else}
|
||||
<PageHeaderContent afterName={runsHint} actions={headerActions} actionsFlexible />
|
||||
{/if}
|
||||
|
||||
<!-- Graph -->
|
||||
<div id="runs-chart" class="p-2 px-4 bg-surface-tertiary mx-4 border rounded-md">
|
||||
@@ -1106,7 +1122,7 @@
|
||||
batchRerunOptionsIsOpen = true
|
||||
}
|
||||
},
|
||||
...(!$userStore?.operator
|
||||
...(!operatingUser.current?.operator
|
||||
? [
|
||||
{
|
||||
// Operators are rejected by the endpoint, so offering it would only 403.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import PanelSection from '../apps/editor/settingsPanel/common/PanelSection.svelte'
|
||||
import { JobService, type InputTransform, type ListSelectedJobGroupsResponse } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { useOperatingWorkspace } from '../operatingWorkspace.svelte'
|
||||
import type { Schema } from '$lib/common'
|
||||
import InputTransformForm from '../InputTransformForm.svelte'
|
||||
import type { FlowPropPickerConfig, PropPickerContext } from '../prop_picker'
|
||||
@@ -43,6 +43,8 @@
|
||||
onConfirm: (options: BatchReRunOptions) => void
|
||||
} = $props()
|
||||
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
let selected: JobGroup | undefined = $state()
|
||||
let options: BatchReRunOptions = $state({ flow: {}, script: {} })
|
||||
watch(
|
||||
@@ -65,7 +67,7 @@
|
||||
|
||||
const listSelectedJobsSchemaCache = new Map<string, JobGroup>()
|
||||
async function fetchJobGroups(): Promise<JobGroup[]> {
|
||||
if (!$workspaceStore) return []
|
||||
if (!$operatingWorkspace) return []
|
||||
|
||||
const cachedSelectedIds = selectedIds.filter((id) => listSelectedJobsSchemaCache.has(id))
|
||||
const nonCachedSelectedIds = selectedIds.filter((id) => !listSelectedJobsSchemaCache.has(id))
|
||||
@@ -75,7 +77,7 @@
|
||||
)
|
||||
const newJobGroups = nonCachedSelectedIds.length
|
||||
? await JobService.listSelectedJobGroups({
|
||||
workspace: $workspaceStore,
|
||||
workspace: $operatingWorkspace,
|
||||
requestBody: nonCachedSelectedIds
|
||||
})
|
||||
: []
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { Skeleton, Tab, Tabs } from '../common'
|
||||
import HighlightCode from '../HighlightCode.svelte'
|
||||
import FlowStatusViewer from '../FlowStatusViewer.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { useOperatingWorkspace } from '../operatingWorkspace.svelte'
|
||||
import WorkflowTimeline from '../WorkflowTimeline.svelte'
|
||||
import { isFlowPreview, isScriptPreview, type StateStore } from '$lib/utils'
|
||||
import { setContext, untrack, createEventDispatcher } from 'svelte'
|
||||
@@ -26,6 +26,7 @@
|
||||
let { id, workspace, onResolutionChanged }: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
|
||||
let job: (Job & { result_stream?: string }) | undefined = $state(undefined)
|
||||
|
||||
@@ -185,7 +186,7 @@
|
||||
<div class="text-xs text-emphasis font-semibold mb-1">Inputs</div>
|
||||
<JobArgs
|
||||
id={job?.id}
|
||||
workspace={job?.workspace_id ?? $workspaceStore ?? 'no_w'}
|
||||
workspace={job?.workspace_id ?? $operatingWorkspace ?? 'no_w'}
|
||||
args={job?.args}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
import { forLater } from '$lib/forLater'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import Portal from '$lib/components/Portal.svelte'
|
||||
import { useHostedPage } from '../hostedPage'
|
||||
|
||||
import WaitTimeWarning from '../common/waitTimeWarning/WaitTimeWarning.svelte'
|
||||
import DropdownV2 from '../DropdownV2.svelte'
|
||||
@@ -29,6 +30,7 @@
|
||||
import './runs-grid.css'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const hosted = useHostedPage()
|
||||
|
||||
interface Props {
|
||||
job: Job
|
||||
@@ -60,7 +62,10 @@
|
||||
</script>
|
||||
|
||||
<Portal name="run-row">
|
||||
<ScheduleEditor onUpdate={() => goto('/schedules')} bind:this={scheduleEditor} />
|
||||
<ScheduleEditor
|
||||
onUpdate={() => (hosted ? hosted.openLink('/schedules') : goto('/schedules'))}
|
||||
bind:this={scheduleEditor}
|
||||
/>
|
||||
</Portal>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
Wrench
|
||||
} from 'lucide-svelte'
|
||||
import Popover from '../Popover.svelte'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import './runs-grid.css'
|
||||
import { useKeyPressed } from '$lib/svelte5Utils.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
@@ -21,6 +20,9 @@
|
||||
import DropdownMenu, { type Props as DropdownMenuProps } from '../DropdownMenu.svelte'
|
||||
import { clickOutside, isJobCancelable, isJobReRunnable, isJobResolvable } from '$lib/utils'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { useHostedPage } from '../hostedPage'
|
||||
import { useOperatingUser, useOperatingWorkspace } from '../operatingWorkspace.svelte'
|
||||
import { withWorkspaceParam } from '../sessions/sessionMode.svelte'
|
||||
import BarsStaggered from '../icons/BarsStaggered.svelte'
|
||||
|
||||
interface Props {
|
||||
@@ -201,6 +203,16 @@
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const hosted = useHostedPage()
|
||||
const operatingWorkspace = useOperatingWorkspace()
|
||||
const operatingUser = useOperatingUser()
|
||||
|
||||
// Under a host the link opens where it decides, in the workspace the run belongs to: the
|
||||
// document's own router would leave the host for the navigation workspace.
|
||||
function open(href: string) {
|
||||
if (!hosted) goto(href)
|
||||
else hosted.openLink(withWorkspaceParam(href, selectedWorkspace ?? $operatingWorkspace))
|
||||
}
|
||||
|
||||
let scrollToIndex = $state(0)
|
||||
|
||||
@@ -272,7 +284,7 @@
|
||||
actions.push({
|
||||
label: 'Show run details',
|
||||
icon: ExternalLinkIcon,
|
||||
onClick: () => goto(`/run/${selectedIds[0]}`)
|
||||
onClick: () => open(`/run/${selectedIds[0]}`)
|
||||
})
|
||||
const job = flatJobs?.find(
|
||||
(jobOrDate) => jobOrDate.type === 'job' && jobOrDate.job.id === selectedIds[0]
|
||||
@@ -282,14 +294,20 @@
|
||||
actions.push({
|
||||
label: 'Go to script page',
|
||||
icon: Code2Icon,
|
||||
onClick: () => goto(`/scripts/get/${job.job.script_hash}`)
|
||||
onClick: () =>
|
||||
open(
|
||||
// A host addresses an item by its path, and pins a version in the query.
|
||||
hosted
|
||||
? `/scripts/get/${job.job.script_path}?version=${job.job.script_hash}`
|
||||
: `/scripts/get/${job.job.script_hash}`
|
||||
)
|
||||
})
|
||||
}
|
||||
if (job.job.job_kind === 'flow') {
|
||||
actions.push({
|
||||
label: 'Go to flow page',
|
||||
icon: BarsStaggered,
|
||||
onClick: () => goto(`/flows/get/${job.job.script_path}`)
|
||||
onClick: () => open(`/flows/get/${job.job.script_path}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -314,7 +332,7 @@
|
||||
onHover: (hover) => (hoveredDropdownAction = hover ? 'cancel' : null)
|
||||
})
|
||||
// Operators are rejected by the endpoint, so offering the action would only 403.
|
||||
const canResolve = !$userStore?.operator
|
||||
const canResolve = !operatingUser.current?.operator
|
||||
const resolvable = canResolve ? selectedIdsPossibleActions.resolvableJobIds.length : 0
|
||||
const unresolvable = canResolve ? selectedIdsPossibleActions.unresolvableJobIds.length : 0
|
||||
if (resolvable)
|
||||
@@ -401,7 +419,7 @@
|
||||
? jobCountString(jobs.length + externalJobs.length, lastFetchWentToEnd)
|
||||
: ''}<Tooltip>{externalJobs.length} jobs obscured</Tooltip>
|
||||
</div>
|
||||
{:else if $workspaceStore !== 'admins' && omittedObscuredJobs}
|
||||
{:else if $operatingWorkspace !== 'admins' && omittedObscuredJobs}
|
||||
<div class="flex flex-row">
|
||||
{jobs ? jobCountString(jobs.length, lastFetchWentToEnd) : ''}
|
||||
<Popover>
|
||||
|
||||
@@ -3,8 +3,14 @@
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
|
||||
import { setHostedPage, type HostedPage } from '$lib/components/hostedPage'
|
||||
import { pageItemForListPath, parsePreviewItemRoute } from './previewPaths'
|
||||
import { previewLocationLabel } from './previewRouter'
|
||||
import {
|
||||
RUNS_PATH,
|
||||
barePath,
|
||||
isRunsListPath,
|
||||
pageItemForListPath,
|
||||
parsePreviewItemRoute
|
||||
} from './previewPaths'
|
||||
import { parseRunRoute, previewLocationLabel } from './previewRouter'
|
||||
import { whereIs } from './sessionPreviewTabs.svelte'
|
||||
import type { SessionPreviewTab } from './sessionState.svelte'
|
||||
import type { SessionRuntime } from './sessionRuntime.svelte'
|
||||
@@ -44,20 +50,48 @@
|
||||
? { pathname: loc, search: '' }
|
||||
: { pathname: loc.slice(0, at), search: loc.slice(at) }
|
||||
}
|
||||
// The location this page last wrote itself, and the one it was last moved to from outside.
|
||||
let ownLoc: string | undefined
|
||||
let movedTo: string | undefined
|
||||
let moves = 0
|
||||
// Bumped when the location moves other than by the page's own write. Derived rather than
|
||||
// effected: it has to flip in the same flush as the location, before the page's own
|
||||
// effects read the new query and write their defaults back over the stored preferences.
|
||||
const moved = $derived.by(() => {
|
||||
const loc = whereIs(tab)
|
||||
if (loc !== ownLoc && loc !== movedTo) {
|
||||
movedTo = loc
|
||||
moves++
|
||||
}
|
||||
return moves
|
||||
})
|
||||
const host: HostedPage = {
|
||||
get search() {
|
||||
return locationParts().search
|
||||
},
|
||||
setSearch(search) {
|
||||
runtime.previewTabs.observeLocation(tab.id, locationParts().pathname + search)
|
||||
ownLoc = locationParts().pathname + search
|
||||
movedTo = undefined
|
||||
runtime.previewTabs.observeLocation(tab.id, ownLoc)
|
||||
},
|
||||
openItem(itemPath) {
|
||||
const ref = pageItemForListPath(path, itemPath)
|
||||
if (ref) runtime.previewTabs.open({ type: 'pageitem', ref })
|
||||
},
|
||||
openLink(href) {
|
||||
const run = parseRunRoute(href)
|
||||
if (run) {
|
||||
runtime.manager.openRunInPreview?.({
|
||||
jobId: run.jobId,
|
||||
workspace: run.workspace ?? workspaceId,
|
||||
label: previewLocationLabel(`/run/${run.jobId}`)
|
||||
})
|
||||
return
|
||||
}
|
||||
const item = parsePreviewItemRoute(href)
|
||||
if (item) {
|
||||
// A link to one version stays a page tab: opening an item focuses a tab already
|
||||
// on it as it stands, where a page tab's URL is written whole, pin included.
|
||||
if (item && !new URL(href, 'http://_').searchParams.has('version')) {
|
||||
runtime.previewTabs.open({
|
||||
type: 'item',
|
||||
item: { kind: item.kind, raw_app: item.raw_app, path: item.itemPath, summary: '' },
|
||||
@@ -83,18 +117,32 @@
|
||||
if (e.defaultPrevented || e.button !== 0) return
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
|
||||
const a = (e.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null
|
||||
if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) return
|
||||
if (!a || a.hasAttribute('download')) return
|
||||
const url = new URL(a.href, window.location.href)
|
||||
if (url.origin !== window.location.origin) return
|
||||
const href = url.pathname + url.search + url.hash
|
||||
// A run's link asks for a new tab so the list keeps its place: here that tab is a
|
||||
// preview tab, not a browser one that would leave the session.
|
||||
if (a.target && a.target !== '_self' && !parseRunRoute(href)) return
|
||||
e.preventDefault()
|
||||
if (a.getAttribute('href')?.startsWith('#')) return
|
||||
host.openLink(url.pathname + url.search + url.hash)
|
||||
host.openLink(href)
|
||||
}
|
||||
el.addEventListener('click', onClick)
|
||||
return () => el.removeEventListener('click', onClick)
|
||||
})
|
||||
|
||||
const listed = $derived(pageItemForListPath(path, ''))
|
||||
// `/runs/<item path>` lists one item's runs.
|
||||
const runsOf = $derived.by(() => {
|
||||
if (!isRunsListPath(path)) return undefined
|
||||
const itemPath = barePath(path).slice(RUNS_PATH.length + 1)
|
||||
try {
|
||||
return decodeURIComponent(itemPath)
|
||||
} catch {
|
||||
return itemPath
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#snippet loading()}
|
||||
@@ -130,6 +178,15 @@
|
||||
{:then Module}
|
||||
<Module.default triggerKind={listed.triggerKind} />
|
||||
{/await}
|
||||
{:else if runsOf !== undefined}
|
||||
<!-- A move from outside remounts it, so it seeds the filters the user keeps stored. -->
|
||||
{#key moved}
|
||||
{#await import('$lib/components/RunsPage.svelte')}
|
||||
{@render loading()}
|
||||
{:then Module}
|
||||
<Module.default initialPath={runsOf} />
|
||||
{/await}
|
||||
{/key}
|
||||
{/if}
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
@@ -84,6 +84,17 @@ export function isPageItemListPath(path: string): boolean {
|
||||
return pageItemForListPath(path, '') !== undefined
|
||||
}
|
||||
|
||||
/** Whether a path is the runs list: every run, or `/runs/<item path>` for one item's. */
|
||||
export function isRunsListPath(path: string): boolean {
|
||||
const clean = barePath(path)
|
||||
return clean === RUNS_PATH || clean.startsWith(`${RUNS_PATH}/`)
|
||||
}
|
||||
|
||||
/** Whether a path is a list page a session preview mounts in process rather than frames. */
|
||||
export function isPreviewListPath(path: string): boolean {
|
||||
return isPageItemListPath(path) || isRunsListPath(path)
|
||||
}
|
||||
|
||||
/** The page item a list page's row names, or undefined for a page that lists none. */
|
||||
export function pageItemForListPath(pagePath: string, path: string): PageItemRef | undefined {
|
||||
const clean = barePath(pagePath)
|
||||
@@ -145,7 +156,13 @@ export const pageKey = (path: string) => `page:${path}`
|
||||
/** Strip the query and hash from a preview path so it can be matched against
|
||||
* `PREVIEW_PAGES` / parsed as an item route. */
|
||||
export function barePath(path: string): string {
|
||||
return path.split('?')[0].split('#')[0] || '/'
|
||||
// The router serves `/runs/` as `/runs`, and links are written both ways.
|
||||
return (
|
||||
path
|
||||
.split('?')[0]
|
||||
.split('#')[0]
|
||||
.replace(/(.)\/+$/, '$1') || '/'
|
||||
)
|
||||
}
|
||||
|
||||
/** Which side of an item a route addresses: `/edit/` builds it, `/get/` shows the
|
||||
|
||||
@@ -313,7 +313,7 @@ describe('itemDisplayName', () => {
|
||||
|
||||
describe('resolvePreviewTab', () => {
|
||||
it('routes a static page to the iframe fallback', () => {
|
||||
expect(resolvePreviewTab('/runs')).toEqual({ kind: 'iframe' })
|
||||
expect(resolvePreviewTab('/audit_logs')).toEqual({ kind: 'iframe' })
|
||||
})
|
||||
|
||||
it('mounts a run in process, in the workspace its URL names', () => {
|
||||
@@ -324,8 +324,19 @@ describe('resolvePreviewTab', () => {
|
||||
})
|
||||
expect(resolvePreviewTab('/run/01a0')).toEqual({ kind: 'run', jobId: '01a0' })
|
||||
expect(resolvePreviewTab('/run/01a0?workspace=fork&view_token=t')).toEqual({ kind: 'iframe' })
|
||||
// The runs of one item are a list page, not a run.
|
||||
expect(resolvePreviewTab('/runs/f/a/b')).toEqual({ kind: 'iframe' })
|
||||
})
|
||||
|
||||
it('mounts the runs list in process, for every run or for one item', () => {
|
||||
expect(resolvePreviewTab('/runs?status=failure')).toEqual({ kind: 'pagelist', path: '/runs' })
|
||||
expect(resolvePreviewTab('/runs/f/a/b')).toEqual({ kind: 'pagelist', path: '/runs/f/a/b' })
|
||||
expect(resolvePreviewTab('/runsheet')).toEqual({ kind: 'iframe' })
|
||||
})
|
||||
|
||||
it('tells two runs lists apart by their filters, trailing slash or not', () => {
|
||||
expect(resolvePreviewTab('/runs/?status=failure')).toEqual({ kind: 'pagelist', path: '/runs' })
|
||||
expect(showsView('/runs/?schedule_path=a', '/runs/?schedule_path=b')).toBe(false)
|
||||
expect(showsView('/runs?schedule_path=a', '/runs/?schedule_path=a')).toBe(true)
|
||||
expect(showsView('/runs/f/a/b?status=failure', '/runs/f/a/b')).toBe(false)
|
||||
})
|
||||
|
||||
it('mounts the list page of a page item kind in process, whatever its filters', () => {
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
AUDIT_LOGS_PATH,
|
||||
FOLDERS_PATH,
|
||||
GROUPS_PATH,
|
||||
isPageItemListPath,
|
||||
isPreviewListPath,
|
||||
isRunsListPath,
|
||||
pageItemForListPath,
|
||||
pageItemListPath,
|
||||
pageItemPageHref,
|
||||
@@ -247,9 +248,14 @@ function pageRequestParamTable(): Record<string, readonly string[]> {
|
||||
})
|
||||
}
|
||||
|
||||
// The page whose filters a path takes: one item's runs filter as every run does.
|
||||
function filterPageOf(path: string): string {
|
||||
return isRunsListPath(path) ? RUNS_PATH : path
|
||||
}
|
||||
|
||||
/** The query params a request can set on `path` — empty for a page that takes none. */
|
||||
export function pageRequestParams(path: string): readonly string[] {
|
||||
return pageRequestParamTable()[barePath(path)] ?? []
|
||||
return pageRequestParamTable()[filterPageOf(barePath(path))] ?? []
|
||||
}
|
||||
|
||||
/** What a preview location means, read against the page it points at. */
|
||||
@@ -286,7 +292,7 @@ export function describeLocation(loc: string): PreviewLocation {
|
||||
const query = bare.includes('?') ? bare.slice(bare.indexOf('?') + 1) : ''
|
||||
return {
|
||||
identity: path,
|
||||
view: requestedParams(query, pageRequestParamTable()[path]),
|
||||
view: requestedParams(query, pageRequestParamTable()[filterPageOf(path)]),
|
||||
anchor: drawerAnchorFor(canonical) ?? ''
|
||||
}
|
||||
}
|
||||
@@ -319,7 +325,7 @@ export function showsView(observed: string, commanded: string): boolean {
|
||||
const y = describeLocation(commanded)
|
||||
if (x.identity !== y.identity || x.anchor !== y.anchor) return false
|
||||
if (x.view === y.view) return true
|
||||
const seeded = PAGE_SEEDED_PARAMS[x.identity]
|
||||
const seeded = PAGE_SEEDED_PARAMS[filterPageOf(x.identity)]
|
||||
if (!seeded?.length) return false
|
||||
// Both views come out of `requestedParams` already sorted and encoded, so they compare
|
||||
// as strings once the seeded params are dropped. One direction only: the page may add
|
||||
@@ -594,7 +600,8 @@ export const isArtifactKey = (key: string) => key.startsWith('artifact:')
|
||||
// flow, raw app) mounts its per-(kind,path) cell editor on `/edit/` and its viewer
|
||||
// on `/get/`; a `/pipeline/<folder>` route mounts the data-pipeline graph editor of
|
||||
// that folder (`path` is the folder); the list page of a page item kind mounts that
|
||||
// list; a `/run/<id>` route without a share token mounts that run's page; everything
|
||||
// list, and so does the runs list; a `/run/<id>` route without a share token mounts that
|
||||
// run's page; everything
|
||||
// else (static pages, regular drag-and-drop apps, any other route) stays an iframe.
|
||||
export type PreviewSlot =
|
||||
| { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string }
|
||||
@@ -614,7 +621,7 @@ export function resolvePreviewTab(url: string): PreviewSlot {
|
||||
const pageItem = parsePageItemRoute(url)
|
||||
if (pageItem) return { kind: 'pageitem', ref: pageItem }
|
||||
const route = barePath(url)
|
||||
if (isPageItemListPath(route)) return { kind: 'pagelist', path: route }
|
||||
if (isPreviewListPath(route)) return { kind: 'pagelist', path: route }
|
||||
const run = parseRunRoute(url)
|
||||
// A share link's token authorizes every request of the window that installs it, so a run
|
||||
// opened through one keeps a frame of its own rather than lending the token to the session.
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
import { isGlobalAiEnabled, sessionsAllowedFor } from '$lib/components/copilot/chat/global/gate'
|
||||
import { copilotInfo } from '$lib/aiStore'
|
||||
import {
|
||||
isPageItemListPath,
|
||||
isPreviewListPath,
|
||||
parsePreviewItemRoute,
|
||||
barePath
|
||||
} from '$lib/components/sessions/previewPaths'
|
||||
@@ -467,7 +467,7 @@
|
||||
}
|
||||
// A list page mounts in process too, where its rows open as tabs of their own.
|
||||
const listUrl = navigation.to?.url
|
||||
if (listUrl && isPageItemListPath(barePath(listUrl.pathname))) {
|
||||
if (listUrl && isPreviewListPath(barePath(listUrl.pathname))) {
|
||||
navigation.cancel()
|
||||
const u = new URL(listUrl.href)
|
||||
u.searchParams.delete('nomenubar')
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
tabsToReload,
|
||||
viewerTabsToReload
|
||||
} from '$lib/components/sessions/previewReload'
|
||||
import { isPageItemListPath, barePath } from '$lib/components/sessions/previewPaths'
|
||||
import { isPreviewListPath, barePath } from '$lib/components/sessions/previewPaths'
|
||||
import {
|
||||
leafKeyFor,
|
||||
loadKind,
|
||||
@@ -928,7 +928,7 @@
|
||||
// A preview frame navigating to a list page: re-point the active tab to the list
|
||||
// mounted in process, whose rows open as tabs of their own.
|
||||
if (d.type === 'wm.session.openList') {
|
||||
if (typeof d.href !== 'string' || !isPageItemListPath(barePath(d.href))) return
|
||||
if (typeof d.href !== 'string' || !isPreviewListPath(barePath(d.href))) return
|
||||
owner?.navigate({ type: 'page', href: d.href, label: previewLocationLabel(d.href) })
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user