mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-17 16:03:06 +00:00
* feat: shrink the page header in stages and let the bar fold page actions Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: fold the runs header through the bar and wrap its filter to a second row Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: truncate the run id and fold run actions into one menu on a narrow bar Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: keep an item's summary and drop its path on a narrow page header Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: show the full summary and path read-only when they cannot be edited Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: keep secondary detail actions in menus and the star beside the summary Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * feat: show secondary detail actions as buttons while the bar has room for them Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: fold embedded detail headers, refresh stale fold widths, keep the path beside a summary placeholder Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: drop the duplicate path on read-only detail pages and re-measure folded actions when they change Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: re-measure the runs header when its hidden counters change width Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * revert: drop the runs header's counter version, the badges do not change its width Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * fix: stop the header re-measuring its actions every frame while idle Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1340 lines
45 KiB
Svelte
1340 lines
45 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
JobService,
|
|
UserService,
|
|
FolderService,
|
|
ScriptService,
|
|
FlowService,
|
|
type ExtendedJobs,
|
|
OpenAPI
|
|
} from '$lib/gen'
|
|
|
|
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 {
|
|
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'
|
|
|
|
import Tooltip from '$lib/components/Tooltip.svelte'
|
|
|
|
import RunsTable from '$lib/components/runs/RunsTable.svelte'
|
|
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
|
import {
|
|
allowWildcards,
|
|
buildRunsFilterSearchbarSchema,
|
|
buildRunsFilterPresets
|
|
} from '$lib/components/runs/runsFilter'
|
|
import Toggle from '$lib/components/Toggle.svelte'
|
|
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
|
import RunsQueue from '$lib/components/runs/RunsQueue.svelte'
|
|
import OpenInSessionButton from '$lib/components/sessions/OpenInSessionButton.svelte'
|
|
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
|
import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte'
|
|
import { RUNS_PATH } from '$lib/components/sessions/previewPaths'
|
|
import { computeJobKinds, useJobsLoader } from '$lib/components/runs/useJobsLoader.svelte'
|
|
import ConcurrentJobsChart from '$lib/components/ConcurrentJobsChart.svelte'
|
|
import BatchLoadProgress from '$lib/components/BatchLoadProgress.svelte'
|
|
import { pluralize, MAX_RESOLUTION_BATCH, MAX_RESOLUTION_NOTE_LEN } from '$lib/utils'
|
|
import type { BatchReRunOptions } from '$lib/components/runs/BatchReRunOptionsPane.svelte'
|
|
import { untrack, type Snippet } from 'svelte'
|
|
import { page } from '$app/state'
|
|
import Select from '$lib/components/select/Select.svelte'
|
|
import AnimatedPane from '$lib/components/splitPanes/AnimatedPane.svelte'
|
|
import PagedContent from '$lib/components/common/modal/PagedContent.svelte'
|
|
import { StaleWhileLoading, useLocalStorageValue } from '$lib/svelte5Utils.svelte'
|
|
import {
|
|
Calendar,
|
|
CircleAlert,
|
|
CircleCheck,
|
|
CirclePlay,
|
|
Clock,
|
|
Hourglass,
|
|
TriangleAlertIcon,
|
|
ArrowLeft,
|
|
Loader2
|
|
} from 'lucide-svelte'
|
|
import DropdownV2 from './DropdownV2.svelte'
|
|
import TimeframeSelect, {
|
|
buildManualTimeframe,
|
|
runsTimeframes,
|
|
useSyncedTimeframe
|
|
} from './runs/TimeframeSelect.svelte'
|
|
import FilterSearchbar, { useUrlSyncedFilterInstance } from './FilterSearchbar.svelte'
|
|
import { jobTriggerKinds } from './triggers/utils'
|
|
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
|
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
|
import ToggleButtonMore from './common/toggleButton-v2/ToggleButtonMore.svelte'
|
|
|
|
interface Props {
|
|
/** Initial path from route params (e.g., /runs/u/user/script) */
|
|
initialPath?: string
|
|
}
|
|
|
|
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([])
|
|
let runsFilterSearchbarSchema = $derived(
|
|
buildRunsFilterSearchbarSchema({
|
|
paths,
|
|
usernames,
|
|
folders,
|
|
jobTriggerKinds,
|
|
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
|
|
isAdminsWorkspace: $operatingWorkspace === 'admins'
|
|
})
|
|
)
|
|
let perPage = useLocalStorageValue('runs_per_page', 1000, 'number')
|
|
let showSchedulesStorage = useLocalStorageValue('runs_show_schedules', true, 'boolean')
|
|
let showFutureJobsStorage = useLocalStorageValue('runs_show_future_jobs', true, 'boolean')
|
|
function filterSeeds() {
|
|
return {
|
|
path: initialPath || undefined,
|
|
job_trigger_kind: showSchedulesStorage.val === false ? ('!schedule' as const) : undefined,
|
|
show_future_jobs: showFutureJobsStorage.val === false ? false : undefined
|
|
}
|
|
}
|
|
|
|
let filters = useUrlSyncedFilterInstance(
|
|
untrack(() => runsFilterSearchbarSchema),
|
|
untrack(filterSeeds)
|
|
)
|
|
|
|
// `runs/[...path]` is a single route, so a navigation between its URLs — the sidebar's own
|
|
// "Runs" entry, `/runs/<path>` → `/runs`, Back — rewrites the query without remounting, and
|
|
// 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()))
|
|
})
|
|
|
|
let batchRerunOptionsIsOpen = $state(false)
|
|
|
|
// Sync toggle state back to local storage when filters change
|
|
$effect(() => {
|
|
if (!filters.val.job_trigger_kind || filters.val.job_trigger_kind === '!schedule') {
|
|
showSchedulesStorage.val = filters.val.job_trigger_kind !== '!schedule'
|
|
}
|
|
})
|
|
$effect(() => {
|
|
showFutureJobsStorage.val = filters.val.show_future_jobs !== false
|
|
})
|
|
|
|
let selectedIds: string[] = $state([])
|
|
let selectedWorkspace: string | undefined = $state(undefined)
|
|
|
|
let jobKinds: string | undefined = $derived(computeJobKinds(filters.val.job_kinds ?? null))
|
|
let argError = $state('')
|
|
let resultError = $state('')
|
|
let autoRefresh = useLocalStorageValue('auto_refresh_in_runs', true, 'boolean')
|
|
let runDrawer: Drawer | undefined = $state(undefined)
|
|
let lookback: number = $state(1)
|
|
let askingForConfirmation:
|
|
| undefined
|
|
| {
|
|
title: string
|
|
confirmBtnText: string
|
|
loading?: boolean
|
|
preContent?: string
|
|
onConfirm?: (forceCancel: boolean) => void
|
|
type?: ConfirmationModal['$$prop_def']['type']
|
|
} = $state(undefined)
|
|
|
|
let _timeframe = useSyncedTimeframe(
|
|
runsTimeframes,
|
|
() => ({
|
|
maxTs: filters.val.max_ts?.toISOString(),
|
|
minTs: filters.val.min_ts?.toISOString(),
|
|
timeframe: filters.val.timeframe
|
|
}),
|
|
(v) => {
|
|
v.maxTs ? (filters.val.max_ts = new Date(v.maxTs)) : delete filters.val.max_ts
|
|
v.minTs ? (filters.val.min_ts = new Date(v.minTs)) : delete filters.val.min_ts
|
|
v.timeframe && v.timeframe !== 'Latest runs'
|
|
? (filters.val.timeframe = v.timeframe)
|
|
: delete filters.val.timeframe
|
|
}
|
|
)
|
|
let timeframe = $derived(_timeframe.val)
|
|
|
|
let manualTimeframe = $derived(timeframe.type === 'manual' ? timeframe : undefined)
|
|
|
|
let graph: 'RunChart' | 'ConcurrencyChart' = $state(
|
|
typeOfChart(
|
|
(hosted ? new URLSearchParams(hosted.search) : page.url.searchParams).get('graph')
|
|
)
|
|
)
|
|
let innerWidth = $state(window.innerWidth)
|
|
let jobsLoader = useJobsLoader(() => ({
|
|
filters: filters.val,
|
|
timeframe,
|
|
jobKinds,
|
|
autoRefresh: autoRefresh.val,
|
|
argError,
|
|
resultError,
|
|
perPage: perPage.val,
|
|
lookback: graph === 'RunChart' ? 0 : lookback,
|
|
currentWorkspace: $operatingWorkspace ?? ''
|
|
}))
|
|
let batchProgress = $derived(jobsLoader.batchProgress)
|
|
let currentBatchSize = $derived(jobsLoader.currentBatchSize)
|
|
let scanProgress = $derived(jobsLoader.scanProgress)
|
|
let lastFetchWentToEnd = $derived(jobsLoader.lastFetchWentToEnd)
|
|
let queue_count = $derived(jobsLoader.queue_count)
|
|
let suspended_count = $derived(jobsLoader.suspended_count)
|
|
let externalJobs = $derived(jobsLoader.externalJobs)
|
|
let extendedJobs = $derived(jobsLoader.extendedJobs)
|
|
// Avoid flicker, but still show empty if loading takes too long
|
|
let debouncedCompletedJobs = new StaleWhileLoading(() => jobsLoader.completedJobs)
|
|
let debouncedJobs = new StaleWhileLoading(() => jobsLoader.jobs)
|
|
let completedJobs = $derived(jobsLoader.completedJobs ?? debouncedCompletedJobs.current)
|
|
let jobs = $derived(jobsLoader.jobs ?? debouncedJobs.current)
|
|
|
|
let runsTable: RunsTable | undefined = $state(undefined)
|
|
|
|
function reset() {
|
|
_timeframe.val = { ...runsTimeframes[0] }
|
|
selectedIds = []
|
|
openedRunId = undefined
|
|
delete filters.val.schedule_path
|
|
selectedWorkspace = undefined
|
|
jobsLoader?.loadJobs(true)
|
|
}
|
|
|
|
async function loadUsernames(): Promise<void> {
|
|
usernames = await UserService.listUsernames({ workspace: $operatingWorkspace! })
|
|
}
|
|
|
|
async function loadFolders(): Promise<void> {
|
|
folders = await FolderService.listFolders({
|
|
workspace: $operatingWorkspace!
|
|
}).then((x) => x.map((y) => y.name))
|
|
}
|
|
|
|
async function loadPaths() {
|
|
const npaths_scripts = await ScriptService.listScriptPaths({ workspace: $operatingWorkspace ?? '' })
|
|
const npaths_flows = await FlowService.listFlowPaths({ workspace: $operatingWorkspace ?? '' })
|
|
paths = npaths_scripts.concat(npaths_flows).sort()
|
|
}
|
|
|
|
function resetAndFilterBy(setter: (string) => void) {
|
|
return (e: CustomEvent<string>) => {
|
|
delete filters.val.path
|
|
delete filters.val.user
|
|
delete filters.val.folder
|
|
delete filters.val.label
|
|
delete filters.val.concurrency_key
|
|
delete filters.val.tag
|
|
delete filters.val.worker
|
|
delete filters.val.schedule_path
|
|
setter(e.detail)
|
|
}
|
|
}
|
|
|
|
const filterByPath = resetAndFilterBy((s) => (filters.val.path = s))
|
|
const filterByUser = resetAndFilterBy((s) => (filters.val.user = s))
|
|
const filterByFolder = resetAndFilterBy((s) => (filters.val.folder = s))
|
|
const filterByLabel = resetAndFilterBy((s) => (filters.val.label = s))
|
|
const filterByConcurrencyKey = resetAndFilterBy((s) => (filters.val.concurrency_key = s))
|
|
const filterByTag = resetAndFilterBy((s) => (filters.val.tag = s))
|
|
const filterBySchedule = resetAndFilterBy((s) => (filters.val.schedule_path = s))
|
|
const filterByWorker = resetAndFilterBy((s) => (filters.val.worker = s))
|
|
|
|
function typeOfChart(s: string | null): 'RunChart' | 'ConcurrencyChart' {
|
|
switch (s) {
|
|
case 'RunChart':
|
|
return 'RunChart'
|
|
case 'ConcurrencyChart':
|
|
return 'ConcurrencyChart'
|
|
default:
|
|
return 'RunChart'
|
|
}
|
|
}
|
|
|
|
function getSelectedFilters() {
|
|
const argFilter = filters.val.arg && JSON.parse(filters.val.arg)
|
|
const resultFilter = filters.val.result && JSON.parse(filters.val.result)
|
|
const { minTs, maxTs } = timeframe.computeMinMax()
|
|
return {
|
|
workspace: $operatingWorkspace ?? '',
|
|
startedBefore: maxTs ?? undefined,
|
|
startedAfter: minTs ?? undefined,
|
|
schedulePath: filters.val.schedule_path ?? undefined,
|
|
scriptPathExact:
|
|
filters.val.path === null || filters.val.path === '' ? undefined : filters.val.path,
|
|
createdBy: filters.val.user || undefined,
|
|
scriptPathStart: filters.val.folder ? `f/${filters.val.folder}/` : undefined,
|
|
jobKinds: jobKinds == '' ? undefined : jobKinds,
|
|
success: filters.val.status == 'success' ? true : undefined,
|
|
status:
|
|
filters.val.status == 'failure' || filters.val.status == 'canceled'
|
|
? filters.val.status
|
|
: undefined,
|
|
running:
|
|
filters.val.status == 'running' || filters.val.status == 'suspended'
|
|
? true
|
|
: filters.val.status == 'waiting'
|
|
? false
|
|
: undefined,
|
|
isSkipped: filters.val.show_skipped ? undefined : false,
|
|
resolved:
|
|
filters.val.resolved === 'resolved'
|
|
? true
|
|
: filters.val.resolved === 'unresolved'
|
|
? false
|
|
: undefined,
|
|
// isFlowStep: jobKindsCat != 'all' ? false : undefined,
|
|
hasNullParent:
|
|
filters.val.path != undefined ||
|
|
filters.val.path != undefined ||
|
|
filters.val.job_kinds != 'all'
|
|
? true
|
|
: undefined,
|
|
label: filters.val.label || undefined,
|
|
tag: filters.val.tag || undefined,
|
|
suspended:
|
|
filters.val.status == 'waiting'
|
|
? false
|
|
: filters.val.status == 'suspended'
|
|
? true
|
|
: undefined,
|
|
scheduledForBeforeNow:
|
|
filters.val.show_future_jobs == false ||
|
|
filters.val.status == 'waiting' ||
|
|
filters.val.status == 'suspended'
|
|
? true
|
|
: undefined,
|
|
args: argFilter && Object.keys(argFilter).length && !argError ? argFilter : undefined,
|
|
result:
|
|
resultFilter && Object.keys(resultFilter).length && !resultError ? resultFilter : undefined,
|
|
jobTriggerKind: filters.val.job_trigger_kind || undefined,
|
|
allWorkspaces: filters.val.all_workspaces || undefined,
|
|
allowWildcards: allowWildcards(filters.val) || undefined
|
|
}
|
|
}
|
|
async function cancelJobs(uuidsToCancel: string[], forceCancel: boolean = false) {
|
|
const uuids = await JobService.cancelSelection({
|
|
workspace: $operatingWorkspace ?? '',
|
|
requestBody: uuidsToCancel,
|
|
forceCancel: forceCancel,
|
|
allWorkspaces: filters.val.all_workspaces || undefined
|
|
})
|
|
selectedIds = []
|
|
jobsLoader?.loadJobs(true, true)
|
|
sendUserToast(`Canceled ${uuids.length} jobs`)
|
|
}
|
|
|
|
async function setJobsResolution(jobIds: string[], resolved: boolean, note?: string) {
|
|
// Spread-count code points so this matches the server's `chars().count()` exactly;
|
|
// `.length` counts UTF-16 units and would reject valid astral-plane notes.
|
|
if (note !== undefined && [...note].length > MAX_RESOLUTION_NOTE_LEN) {
|
|
sendUserToast(`Note cannot exceed ${MAX_RESOLUTION_NOTE_LEN} characters`, true)
|
|
return
|
|
}
|
|
// The endpoint scopes rows to the path workspace, so in the admins all-workspaces
|
|
// view a selection spanning workspaces has to be dispatched per workspace or the
|
|
// out-of-workspace ids are silently skipped. Same reason cancel_selection groups.
|
|
// Index once and append in place: a scan per id plus a bucket copy per id is ~100M
|
|
// operations at the 10k selection the table allows, which blocks the page before the
|
|
// first request goes out.
|
|
const workspaceById = new Map<string, string>()
|
|
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) ?? $operatingWorkspace ?? ''
|
|
const bucket = byWorkspace.get(ws)
|
|
if (bucket) bucket.push(id)
|
|
else byWorkspace.set(ws, [id])
|
|
}
|
|
// The table selects up to 10k rows but the endpoint caps a call at MAX_RESOLUTION_BATCH,
|
|
// so chunk rather than let an oversized selection reject the whole action.
|
|
const requests: { workspace: string; ids: string[] }[] = []
|
|
for (const [workspace, ids] of byWorkspace) {
|
|
for (let i = 0; i < ids.length; i += MAX_RESOLUTION_BATCH) {
|
|
requests.push({ workspace, ids: ids.slice(i, i + MAX_RESOLUTION_BATCH) })
|
|
}
|
|
}
|
|
const affected = (
|
|
await Promise.all(
|
|
requests.map(({ workspace, ids }) =>
|
|
resolved
|
|
? JobService.resolveCompletedJobs({
|
|
workspace,
|
|
requestBody: { job_ids: ids, note: note || undefined }
|
|
})
|
|
: JobService.unresolveCompletedJobs({ workspace, requestBody: { job_ids: ids } })
|
|
)
|
|
)
|
|
).flat()
|
|
selectedIds = []
|
|
manualSelectionMode = undefined
|
|
resolutionNote = ''
|
|
jobsLoader?.loadJobs(true, true)
|
|
sendUserToast(
|
|
`${resolved ? 'Resolved' : 'Unresolved'} ${affected.length} ${affected.length === 1 ? 'job' : 'jobs'}`
|
|
)
|
|
}
|
|
|
|
async function onCancelAllJobsMatchingFilters() {
|
|
forceCancelInPopup = false
|
|
askingForConfirmation = {
|
|
title: 'Confirm cancelling all jobs corresponding to the selected filters',
|
|
confirmBtnText: 'Loading...',
|
|
loading: true
|
|
}
|
|
|
|
const selectedFilters = getSelectedFilters()
|
|
// Cancellation targets come from the queue, but resolution only exists on completed
|
|
// jobs: "Resolved only" therefore matches nothing cancellable. The queue endpoint
|
|
// takes ListQueueQuery and has no `resolved` param at all, so without this the
|
|
// lookup would silently return the queued jobs the table is currently hiding and
|
|
// offer to cancel them.
|
|
if (selectedFilters.resolved === true) {
|
|
askingForConfirmation = undefined
|
|
sendUserToast('No queued jobs match "Resolved only" — resolution applies to completed runs')
|
|
return
|
|
}
|
|
const selectedFiltersString = JSON.stringify(selectedFilters, null, 4)
|
|
const jobIdsToCancel = await JobService.listFilteredQueueUuids(selectedFilters)
|
|
|
|
askingForConfirmation = {
|
|
title: `Confirm cancelling all jobs corresponding to the selected filters (${jobIdsToCancel.length} jobs)`,
|
|
confirmBtnText: `Cancel ${jobIdsToCancel.length} jobs that matched the filters`,
|
|
preContent: selectedFiltersString,
|
|
onConfirm: (forceCancel) => {
|
|
cancelJobs(jobIdsToCancel, forceCancel)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function onCancelSelectedJobs(jobIdsToCancel: string[]) {
|
|
forceCancelInPopup = true
|
|
askingForConfirmation = {
|
|
confirmBtnText: `Cancel ${jobIdsToCancel.length} jobs`,
|
|
title: `Confirm cancelling ${jobIdsToCancel.length} jobs`,
|
|
onConfirm: (forceCancel) => {
|
|
cancelJobs(jobIdsToCancel, forceCancel)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function reRunJobs(jobIdsToReRun: string[], batchReRunOptions: BatchReRunOptions) {
|
|
if (!$operatingWorkspace) return
|
|
|
|
if (askingForConfirmation) {
|
|
askingForConfirmation.loading = true
|
|
}
|
|
|
|
const body: Parameters<typeof JobService.batchReRunJobs>[0]['requestBody'] = {
|
|
job_ids: jobIdsToReRun,
|
|
script_options_by_path: batchReRunOptions.script ?? {},
|
|
flow_options_by_path: batchReRunOptions.flow ?? {}
|
|
}
|
|
|
|
// 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/${$operatingWorkspace}/jobs/run/batch_rerun_jobs`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body)
|
|
})
|
|
await new Promise(async (resolve) => {
|
|
const reader = response?.body?.pipeThrough(new TextDecoderStream()).getReader()
|
|
let reRanUuids: string[] = []
|
|
if (reader) {
|
|
while (true) {
|
|
const { value, done } = await reader.read()
|
|
if (value) {
|
|
// It is possible get multiple values at once in case of buffering
|
|
const receivedUuids: string[] = []
|
|
for (const line of value.split('\n')) {
|
|
if (!line) continue
|
|
else if (line.startsWith('Error:')) {
|
|
console.error(line)
|
|
} else {
|
|
receivedUuids.push(line)
|
|
}
|
|
}
|
|
if (receivedUuids.length) {
|
|
reRanUuids.push(...receivedUuids)
|
|
if (askingForConfirmation) {
|
|
askingForConfirmation.confirmBtnText = `${reRanUuids.length}/${jobIdsToReRun.length}`
|
|
}
|
|
}
|
|
}
|
|
|
|
if (done || !value) {
|
|
if (reRanUuids.length) {
|
|
sendUserToast(`Re-ran ${reRanUuids.length}/${jobIdsToReRun.length} jobs`)
|
|
}
|
|
if (reRanUuids.length !== jobIdsToReRun.length) {
|
|
sendUserToast(
|
|
`Failed to re-run ${jobIdsToReRun.length - reRanUuids.length} jobs. Check console for details`,
|
|
true
|
|
)
|
|
// We do not get explicit error from backend if the job script don't exist
|
|
for (const jobId of jobIdsToReRun) {
|
|
if (reRanUuids.includes(jobId)) continue
|
|
console.error('Could not re-run job ' + jobId)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
resolve(undefined)
|
|
})
|
|
|
|
selectedIds = []
|
|
// Opened from the table's "Run again", the options are done; left open, the next selection
|
|
// of one run would show them again. The re-run selection mode keeps them for its next batch.
|
|
if (!manualSelectionMode) batchRerunOptionsIsOpen = false
|
|
jobsLoader?.loadJobs(true, true)
|
|
}
|
|
|
|
async function onRerunAllJobsMatchingFilters() {
|
|
const selectedFilters = getSelectedFilters()
|
|
selectedIds = []
|
|
|
|
const loadingToast = sendUserToast('Loading job ids', 'info')
|
|
|
|
if (filters.val.job_kinds !== 'runs') {
|
|
sendUserToast('Batch re-run is only supported for scripts and flows', true)
|
|
loadingToast.destroy()
|
|
return
|
|
}
|
|
// started_at is unindexed on v2_job_completed, so windowing by it alone seq-scans the
|
|
// workspace. started_at >= minTs implies completed_at >= minTs, so completedAfter adds a
|
|
// lossless indexed lower bound ((workspace_id, completed_at DESC)); started_at stays the
|
|
// exact recheck. (completedBefore is omitted: it would drop jobs that finish after maxTs.)
|
|
selectedIds = await JobService.listFilteredJobsUuids({
|
|
...selectedFilters,
|
|
completedAfter: selectedFilters.startedAfter,
|
|
jobKinds: 'script,flow'
|
|
})
|
|
loadingToast.destroy()
|
|
batchRerunOptionsIsOpen = true
|
|
}
|
|
|
|
async function onReRunSelectedJobs(batchReRunOptions: BatchReRunOptions) {
|
|
const jobIdsToReRun = selectedIds
|
|
askingForConfirmation = {
|
|
title: `Confirm re-running the selected jobs`,
|
|
confirmBtnText: `Re-run ${jobIdsToReRun.length} jobs`,
|
|
type: 'reload',
|
|
onConfirm: async () => {
|
|
await reRunJobs(jobIdsToReRun, batchReRunOptions)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function loadExtra() {
|
|
await jobsLoader?.loadExtraJobs()
|
|
}
|
|
|
|
function jobsFilter(f: 'waiting' | 'suspended') {
|
|
delete filters.val.path
|
|
delete filters.val.user
|
|
delete filters.val.folder
|
|
delete filters.val.label
|
|
delete filters.val.concurrency_key
|
|
delete filters.val.tag
|
|
delete filters.val.worker
|
|
delete filters.val.schedule_path
|
|
filters.val.status = filters.val.status == f ? undefined : f
|
|
filters.val.job_kinds = 'all'
|
|
}
|
|
|
|
$effect(() => {
|
|
if ($operatingWorkspace) {
|
|
untrack(() => {
|
|
loadUsernames()
|
|
loadFolders()
|
|
loadPaths()
|
|
})
|
|
}
|
|
})
|
|
let warnJobLimit = $derived.by(() => {
|
|
let extended = extendedJobs as ExtendedJobs | undefined
|
|
return (
|
|
graph === 'ConcurrencyChart' &&
|
|
extended !== undefined &&
|
|
extended.jobs.length + extended.obscured_jobs.length >= 1000
|
|
)
|
|
})
|
|
|
|
const smallScreenWidth = 1920
|
|
|
|
let forceCancelInPopup = $state(false)
|
|
|
|
const warnJobLimitMsg = $derived(
|
|
`The exact number of concurrent jobs at the beginning of the time range may be incorrect as only the last ${perPage.val} jobs are taken into account: a job that was started earlier than this limit will not be taken into account`
|
|
)
|
|
|
|
let manualSelectionMode: undefined | 'cancel' | 'rerun' | 'resolve' = $state()
|
|
|
|
// Below 1024px (Tailwind `lg`) the side pane leaves the table too little room, so what it would
|
|
// show becomes a page pushed over the list. Selection modes keep the split: their rows are
|
|
// picked in the list while the pane holds the action.
|
|
const pagedLayout = $derived(innerWidth < 1024 && !manualSelectionMode)
|
|
// Set only by a plain row click, a chart point or the arrow keys, never by a modifier click:
|
|
// a Ctrl/Cmd/Shift-click starting a multi-selection selects one run first, and opening it would
|
|
// hide the list before the second click.
|
|
let openedRunId: string | undefined = $state()
|
|
const runPageOpen = $derived(
|
|
// A confirmed re-run clears the selection but leaves the options flag set.
|
|
(batchRerunOptionsIsOpen && selectedIds.length > 0) ||
|
|
(selectedIds.length === 1 && selectedIds[0] === openedRunId)
|
|
)
|
|
|
|
// Clears the selection too: a re-run selection of one job would otherwise land on that job.
|
|
function backToList() {
|
|
batchRerunOptionsIsOpen = false
|
|
selectedIds = []
|
|
openedRunId = undefined
|
|
}
|
|
let resolutionNote = $state('')
|
|
// The bar folds this row (`actionFolds` below) once its breadcrumb has given all it can: first
|
|
// the counters, which are a glance and repeat the chart below; then the session button; then
|
|
// the filter field moves to a row of its own rather than shrinking past usable. Hosted, the row
|
|
// is the page's own and wraps instead.
|
|
const fold = $derived(hosted ? 0 : pageHeader.actionFold)
|
|
const filterBelow = $derived(fold >= 3)
|
|
</script>
|
|
|
|
{#snippet runsHint()}
|
|
<Tooltip
|
|
documentationLink="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs"
|
|
>
|
|
All past and schedule executions of scripts and flows, including previews. You only see your own
|
|
runs or runs of groups you belong to unless you are an admin.
|
|
</Tooltip>
|
|
{/snippet}
|
|
|
|
{#snippet runsFilter(cls: string)}
|
|
<FilterSearchbar
|
|
class={cls}
|
|
schema={runsFilterSearchbarSchema}
|
|
presets={buildRunsFilterPresets({
|
|
isSuperAdminOrDevops: !!$superadmin || !!$devopsRole,
|
|
isAdminsWorkspace: $operatingWorkspace === 'admins'
|
|
})}
|
|
bind:value={filters.val}
|
|
placeholder="Filter runs..."
|
|
size="sm"
|
|
autofocus={!hosted}
|
|
/>
|
|
{/snippet}
|
|
|
|
{#snippet filterRow()}
|
|
{@render runsFilter('relative w-full min-w-0')}
|
|
{/snippet}
|
|
|
|
{#snippet headerActions()}
|
|
<!-- The page's own row inside the band's actions box: these controls are denser than a page's
|
|
two or three buttons, so they take a wider gap than the band's own. Every control keeps its
|
|
width: the bar measures the row and folds it rather than squeezing it. -->
|
|
<div class="flex items-center gap-3 {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. -->
|
|
<div class="flex items-center pr-2 {fold >= 1 ? 'hidden' : ''}">
|
|
<RunsQueue
|
|
success={filters.val.status ?? null}
|
|
{queue_count}
|
|
{suspended_count}
|
|
onJobsWaiting={() => {
|
|
jobsFilter('waiting')
|
|
}}
|
|
onJobsSuspended={() => {
|
|
jobsFilter('suspended')
|
|
}}
|
|
small
|
|
/>
|
|
</div>
|
|
<!-- The two groups are the widest thing in the bar: they appear only once the bar is wide
|
|
enough to hold them next to the search field, below which the same filtering is reachable
|
|
from the search field's own `kind` and `status` chips. -->
|
|
<div class="hidden min-[1500px]:flex gap-2">
|
|
<ToggleButtonGroup
|
|
tabListClass="hidden min-[1500px]:flex"
|
|
bind:selected={
|
|
() => filters.val.job_kinds ?? 'runs',
|
|
(v) => (v === 'runs' ? delete filters.val.job_kinds : (filters.val.job_kinds = v))
|
|
}
|
|
>
|
|
{#snippet children({ item })}
|
|
<ToggleButton size="sm" value="all" label="All" {item} />
|
|
<ToggleButton
|
|
size="sm"
|
|
value="runs"
|
|
label="Runs"
|
|
tooltip="Runs are jobs that have no parent jobs (flows are jobs that are parent of the jobs they start), they have been triggered through the UI, a schedule or webhook"
|
|
{item}
|
|
/>
|
|
<ToggleButton
|
|
size="sm"
|
|
value="dependencies"
|
|
label="Deps"
|
|
tooltip="Deploying a script, flow or an app launch a dependency job that create and then attach the lockfile to the deployed item. This mechanism ensure that logic is always executed with the exact same direct and indirect dependencies."
|
|
{item}
|
|
/>
|
|
<ToggleButtonMore
|
|
small
|
|
hideSelectedOption={innerWidth < smallScreenWidth}
|
|
togglableItems={[
|
|
{
|
|
label: 'Previews',
|
|
value: 'previews',
|
|
tooltip: "Previews are jobs that have been started in the editor as 'Tests'"
|
|
},
|
|
{
|
|
label: 'Sync',
|
|
value: 'deploymentcallbacks',
|
|
tooltip:
|
|
'Sync jobs that are triggered on every script deployment to sync the workspace with the Git repository configured in the the workspace settings'
|
|
}
|
|
]}
|
|
{item}
|
|
bind:selected={filters.val.job_kinds}
|
|
/>
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
<ToggleButtonGroup
|
|
bind:selected={
|
|
() => filters.val.status ?? 'all',
|
|
(v) => (v === 'all' ? delete filters.val.status : (filters.val.status = v))
|
|
}
|
|
id="status"
|
|
>
|
|
{#snippet children({ item })}
|
|
<ToggleButton size="sm" value={'all'} label="All" {item} />
|
|
<ToggleButton
|
|
size="sm"
|
|
value={'running'}
|
|
tooltip="Running"
|
|
class="whitespace-nowrap"
|
|
icon={CirclePlay}
|
|
iconProps={{
|
|
class:
|
|
'group-data-[state=on]:text-yellow-600 dark:group-data-[state=on]:text-yellow-400'
|
|
}}
|
|
{item}
|
|
/>
|
|
<ToggleButton
|
|
size="sm"
|
|
value={'success'}
|
|
tooltip="Success"
|
|
class="whitespace-nowrap"
|
|
icon={CircleCheck}
|
|
iconProps={{
|
|
class:
|
|
'group-data-[state=on]:text-green-500 dark:group-data-[state=on]:text-green-300'
|
|
}}
|
|
{item}
|
|
/>
|
|
<ToggleButton
|
|
size="sm"
|
|
value={'failure'}
|
|
tooltip="Failure"
|
|
class="whitespace-nowrap"
|
|
icon={CircleAlert}
|
|
iconProps={{
|
|
class: 'group-data-[state=on]:text-red-500 dark:group-data-[state=on]:text-red-300'
|
|
}}
|
|
{item}
|
|
/>
|
|
<ToggleButton
|
|
size="sm"
|
|
value={'canceled'}
|
|
tooltip="Canceled"
|
|
class="whitespace-nowrap"
|
|
icon={Hourglass}
|
|
selectedColor="gray"
|
|
{item}
|
|
/>
|
|
{#if filters.val.status == 'waiting'}
|
|
<ToggleButton
|
|
size="sm"
|
|
value={'waiting'}
|
|
tooltip="Waiting"
|
|
class="whitespace-nowrap"
|
|
icon={Hourglass}
|
|
selectedColor="blue"
|
|
{item}
|
|
/>
|
|
{:else if filters.val.status == 'suspended'}
|
|
<ToggleButton
|
|
size="sm"
|
|
value={'suspended'}
|
|
tooltip="Suspended"
|
|
class="whitespace-nowrap"
|
|
icon={Hourglass}
|
|
selectedColor="purple"
|
|
{item}
|
|
/>
|
|
{/if}
|
|
{/snippet}
|
|
</ToggleButtonGroup>
|
|
</div>
|
|
|
|
<div class="hidden xl:flex gap-2 items-center">
|
|
{#if !filters.val.job_trigger_kind || filters.val.job_trigger_kind === '!schedule'}
|
|
<div class="flex items-center gap-1" title="Show schedules">
|
|
<Toggle
|
|
size="xs"
|
|
id="show-schedules"
|
|
bind:checked={
|
|
() => filters.val.job_trigger_kind !== '!schedule',
|
|
(v) =>
|
|
v
|
|
? delete filters.val.job_trigger_kind
|
|
: (filters.val.job_trigger_kind = '!schedule')
|
|
}
|
|
/>
|
|
<Calendar size={14} />
|
|
</div>
|
|
{/if}
|
|
<div class="flex items-center gap-1" title="Show future jobs">
|
|
<Toggle
|
|
size="xs"
|
|
id="show-future-jobs"
|
|
bind:checked={
|
|
() => filters.val.show_future_jobs !== false,
|
|
(v) =>
|
|
v ? delete filters.val.show_future_jobs : (filters.val.show_future_jobs = false)
|
|
}
|
|
/>
|
|
<Clock size={14} />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- A width of its own, never what the row has left over: as `flex-1` a chip appearing or a
|
|
filter committing resized it under the cursor. `shrink-0` keeps it at that width, which
|
|
is the row's width the bar measures and folds. -->
|
|
{#if !filterBelow}{@render runsFilter('relative w-[18rem] shrink-0')}{/if}
|
|
<TimeframeSelect
|
|
unifiedSize="sm"
|
|
onClick={() => jobsLoader?.loadJobs(true)}
|
|
loading={jobsLoader?.loading}
|
|
items={runsTimeframes}
|
|
bind:value={_timeframe.val}
|
|
/>
|
|
<!-- 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={fold >= 2 || hosted ? 'hidden' : 'contents'}>
|
|
<OpenInSessionButton
|
|
source={{
|
|
page: () => RUNS_PATH + window.location.search,
|
|
workspaceId: $operatingWorkspace ?? undefined
|
|
}}
|
|
btnProps={{ unifiedSize: 'sm' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
|
|
<ConfirmationModal
|
|
title={askingForConfirmation?.title ?? ''}
|
|
confirmationText={askingForConfirmation?.confirmBtnText ?? ''}
|
|
open={!!askingForConfirmation}
|
|
on:confirmed={async () => {
|
|
const func = askingForConfirmation?.onConfirm
|
|
await func?.(forceCancelInPopup)
|
|
askingForConfirmation = undefined
|
|
}}
|
|
type={askingForConfirmation?.type}
|
|
loading={askingForConfirmation?.loading}
|
|
on:canceled={() => {
|
|
askingForConfirmation = undefined
|
|
}}
|
|
>
|
|
{#if askingForConfirmation?.preContent}
|
|
<pre>{askingForConfirmation.preContent}</pre>
|
|
<Toggle
|
|
size="xs"
|
|
class="mt-4"
|
|
color="red"
|
|
bind:checked={forceCancelInPopup}
|
|
options={{
|
|
right: 'Force cancel',
|
|
rightTooltip:
|
|
'Only use this for jobs that refuse to gracefully cancel. This is dangerous, only do this if you have no alternatives!'
|
|
}}
|
|
></Toggle>
|
|
{#if forceCancelInPopup}
|
|
<div class="mt-4 text-red-500 p-2 text-sm">
|
|
<p>
|
|
Force cancel is enabled. This is dangerous, only do this if you have no alternatives.
|
|
Instead of being gracefully cancelled, all jobs will be immediately sent to the completed
|
|
job table regardless of them being processed or not or part of running flows. You may end
|
|
up in an inconsistent state.
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</ConfirmationModal>
|
|
|
|
<Drawer bind:this={runDrawer}>
|
|
<DrawerContent title="Run details" on:close={runDrawer.closeDrawer}>
|
|
{#if selectedIds.length === 1}
|
|
{#if selectedIds[0] === '-'}
|
|
<div class="p-4">There is no information available for this job</div>
|
|
{:else}
|
|
{#await import('$lib/components/runs/JobRunsPreview.svelte') then JobRunsPreview}
|
|
<JobRunsPreview.default id={selectedIds[0]} workspace={selectedWorkspace} />
|
|
{/await}
|
|
{/if}
|
|
{/if}
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<!-- The document's history is the host's: its Back is not a move on this page. -->
|
|
<svelte:window
|
|
onpopstate={() => {
|
|
if (!hosted) reset()
|
|
}}
|
|
/>
|
|
|
|
{#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>
|
|
</div>
|
|
{:else}
|
|
<div class="w-full h-full flex flex-col" bind:clientWidth={innerWidth}>
|
|
{#if hosted}
|
|
<div class="flex px-4 pt-3 pb-2">
|
|
{@render headerActions()}
|
|
</div>
|
|
{:else}
|
|
<PageHeaderContent
|
|
afterName={runsHint}
|
|
actions={headerActions}
|
|
actionFolds={3}
|
|
belowBar={filterRow}
|
|
belowBarFold={3}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- Graph -->
|
|
<div id="runs-chart" class="p-2 px-4 bg-surface-tertiary mx-4 border rounded-md">
|
|
<div class="relative z-10 mb-2 flex gap-2">
|
|
<Tabs bind:selected={graph}>
|
|
<Tab value="RunChart" label="Duration" id="runs-chart-duration-tab" />
|
|
<Tab value="ConcurrencyChart" label="Concurrency" id="runs-chart-concurrency-tab">
|
|
{#snippet extra()}
|
|
{#if warnJobLimit}
|
|
<Tooltip Icon={TriangleAlertIcon}>{warnJobLimitMsg}</Tooltip>
|
|
{/if}
|
|
{/snippet}
|
|
</Tab>
|
|
</Tabs>
|
|
|
|
{#if graph !== 'RunChart'}
|
|
<Select
|
|
class="ml-2"
|
|
bind:value={lookback}
|
|
items={[
|
|
{ label: 'None', value: 0 },
|
|
{ label: '1 day', value: 1 },
|
|
{ label: '3 days', value: 3 },
|
|
{ label: '7 days', value: 7 }
|
|
]}
|
|
transformInputSelectedText={(_, v) => `${pluralize(v, 'day')} lookback`}
|
|
tooltip={'How far behind the min datetime to start considering jobs for the concurrency graph. Change this value to include jobs started before the set time window for the computation of the graph'}
|
|
/>
|
|
{:else if !lastFetchWentToEnd && (jobs?.length ?? 0) >= (perPage.val ?? 1000)}
|
|
<Button
|
|
wrapperClasses="ml-2"
|
|
unifiedSize="md"
|
|
loading={jobsLoader.loadingExtra}
|
|
onClick={() => jobsLoader.loadExtraJobs()}
|
|
>
|
|
Load more
|
|
<Tooltip>There are more jobs to load</Tooltip>
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
{#if graph === 'RunChart'}
|
|
{@const manualTimeframe = timeframe.type === 'manual' ? timeframe : undefined}
|
|
<RunChart
|
|
bind:selectedIds
|
|
canSelect
|
|
minTimeSet={manualTimeframe?.minTs}
|
|
maxTimeSet={manualTimeframe?.maxTs}
|
|
maxIsNow={manualTimeframe?.maxTs == undefined}
|
|
jobs={completedJobs}
|
|
onZoom={(zoom) => {
|
|
_timeframe.val = buildManualTimeframe(zoom.min.toISOString(), zoom.max.toISOString())
|
|
}}
|
|
onPointClicked={(ids) => {
|
|
openedRunId = ids.length === 1 ? ids[0] : undefined
|
|
runsTable?.scrollToRun(ids)
|
|
}}
|
|
/>
|
|
{:else if graph === 'ConcurrencyChart'}
|
|
<ConcurrentJobsChart
|
|
minTimeSet={manualTimeframe?.minTs}
|
|
maxTimeSet={manualTimeframe?.maxTs}
|
|
maxIsNow={manualTimeframe?.maxTs == undefined}
|
|
{extendedJobs}
|
|
onZoom={(zoom) => {
|
|
_timeframe.val = buildManualTimeframe(zoom.min.toISOString(), zoom.max.toISOString())
|
|
}}
|
|
/>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="grow min-h-0 bottom-splitpane-wrapper">
|
|
{#if pagedLayout}
|
|
<!-- No `onNavigate`: its arrow keys are read at `window`, where they would take
|
|
ArrowRight from the table's horizontal scroll and ArrowLeft from a modal or drawer
|
|
opened over the run. -->
|
|
<PagedContent
|
|
class="h-full"
|
|
current={runPageOpen ? 'run' : 'list'}
|
|
pages={[
|
|
{ key: 'list', content: runsList },
|
|
{ key: 'run', content: runPage, placeholder: runPagePlaceholder }
|
|
]}
|
|
/>
|
|
{:else}
|
|
<Splitpanes>
|
|
<Pane minSize={40}>
|
|
{@render runsList()}
|
|
</Pane>
|
|
<AnimatedPane
|
|
size={40}
|
|
minSize={15}
|
|
class="flex flex-col"
|
|
opened={selectedIds.length > 0 || !!manualSelectionMode}
|
|
>
|
|
{@render sidePanel()}
|
|
</AnimatedPane>
|
|
</Splitpanes>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#snippet runsList()}
|
|
<div class="h-full flex">
|
|
<div class="flex flex-col flex-1 min-w-0 m-4 mt-2 mr-2">
|
|
{#if scanProgress}
|
|
<div class="px-1 pb-2 flex items-center gap-2">
|
|
<Loader2 size={14} class="animate-spin shrink-0 text-accent" />
|
|
<div class="flex-1 min-w-0">
|
|
<BatchLoadProgress
|
|
loaded={Math.round(scanProgress.fraction * 100)}
|
|
total={100}
|
|
itemsLabel="runs"
|
|
label={`Slow search: runs since ${displayDate(scanProgress.scannedTo)} searched, ${jobs?.length ?? 0} found`}
|
|
onStop={() => jobsLoader.stopBatchLoading()}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
{#if batchProgress}
|
|
<div class="px-1 pb-2">
|
|
<BatchLoadProgress
|
|
loaded={batchProgress.loaded}
|
|
total={batchProgress.total}
|
|
itemsLabel="jobs"
|
|
batchSize={currentBatchSize}
|
|
batchSizeCap={1000}
|
|
onBatchSizeChange={(v) => jobsLoader.restreamWithBatchSize(v)}
|
|
onStop={() => jobsLoader.stopBatchLoading()}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
<!-- Runs table. Add overflow-hidden because scroll is handled inside the runs table based on this wrapper height -->
|
|
<div class="grow min-h-0 overflow-y-hidden overflow-x-auto">
|
|
{#if jobs}
|
|
<RunsTable
|
|
{jobs}
|
|
externalJobs={externalJobs ?? []}
|
|
omittedObscuredJobs={extendedJobs?.omitted_obscured_jobs ?? false}
|
|
showExternalJobs={graph !== 'RunChart'}
|
|
activeLabel={filters.val.label}
|
|
{lastFetchWentToEnd}
|
|
loadingExtra={jobsLoader.loadingExtra}
|
|
bind:selectedIds
|
|
bind:selectedWorkspace
|
|
on:select={() => (openedRunId = selectedIds.length === 1 ? selectedIds[0] : undefined)}
|
|
on:loadExtra={loadExtra}
|
|
on:filterByPath={filterByPath}
|
|
on:filterByUser={filterByUser}
|
|
on:filterByFolder={filterByFolder}
|
|
on:filterByLabel={filterByLabel}
|
|
on:filterByConcurrencyKey={filterByConcurrencyKey}
|
|
on:filterByTag={filterByTag}
|
|
on:filterBySchedule={filterBySchedule}
|
|
on:filterByWorker={filterByWorker}
|
|
bind:this={runsTable}
|
|
perPage={perPage.val}
|
|
bind:batchRerunOptionsIsOpen
|
|
onCancelJobs={onCancelSelectedJobs}
|
|
onSetJobsResolution={setJobsResolution}
|
|
{manualSelectionMode}
|
|
></RunsTable>
|
|
{:else}
|
|
<div class="gap-1 flex flex-col">
|
|
{#each new Array(8) as _}
|
|
<Skeleton layout={[[3]]} />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
<div
|
|
class="bg-surface-tertiary border rounded-b-md flex text-xs px-2 py-1 items-center gap-4"
|
|
>
|
|
{#if !manualSelectionMode}
|
|
<DropdownV2
|
|
btnText="Batch actions"
|
|
size="xs"
|
|
items={[
|
|
{
|
|
displayName: 'Cancel jobs',
|
|
action: () => ((manualSelectionMode = 'cancel'), (selectedIds = []))
|
|
},
|
|
{
|
|
displayName: 'Re-run jobs',
|
|
action: () => {
|
|
manualSelectionMode = 'rerun'
|
|
selectedIds = []
|
|
batchRerunOptionsIsOpen = true
|
|
}
|
|
},
|
|
...(!operatingUser.current?.operator
|
|
? [
|
|
{
|
|
// Operators are rejected by the endpoint, so offering it would only 403.
|
|
displayName: 'Resolve failed jobs',
|
|
action: () => (
|
|
(manualSelectionMode = 'resolve'),
|
|
(selectedIds = []),
|
|
(resolutionNote = '')
|
|
)
|
|
}
|
|
]
|
|
: []),
|
|
{
|
|
displayName: 'Cancel all jobs matching filters',
|
|
action: () => onCancelAllJobsMatchingFilters()
|
|
},
|
|
{
|
|
displayName: 'Re-run all jobs matching filters',
|
|
action: () => onRerunAllJobsMatchingFilters()
|
|
}
|
|
]}
|
|
/>
|
|
{:else}
|
|
<Button
|
|
size="xs"
|
|
destructive
|
|
onClick={() => {
|
|
manualSelectionMode = undefined
|
|
batchRerunOptionsIsOpen = false
|
|
}}
|
|
>
|
|
Exit selection mode
|
|
</Button>
|
|
{/if}
|
|
<div class="flex-1"></div>
|
|
<Toggle
|
|
size="xs"
|
|
color="nord"
|
|
bind:checked={autoRefresh.val}
|
|
options={{ right: 'Auto-refresh' }}
|
|
textClass="whitespace-nowrap"
|
|
/>
|
|
<Select
|
|
class="w-24"
|
|
bind:value={
|
|
() => perPage.val,
|
|
(newPerPage) => {
|
|
perPage.val = newPerPage
|
|
if (newPerPage > (jobs?.length ?? 1000)) loadExtra()
|
|
}
|
|
}
|
|
onCreateItem={(v) => (perPage.val = parseInt(v))}
|
|
items={[
|
|
{ value: 25, label: '25' },
|
|
{ value: 100, label: '100' },
|
|
{ value: 1000, label: '1000' },
|
|
{ value: 10000, label: '10000' }
|
|
]}
|
|
transformInputSelectedText={(_, v) => `${v} / page`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet sidePanel()}
|
|
<div class="mt-12 overflow-y-auto pr-4 ml-2 relative flex-1">
|
|
{#if manualSelectionMode === 'cancel'}
|
|
<div
|
|
class="rounded-md bg-surface-tertiary border absolute inset-0 mb-4 flex flex-col items-center justify-center"
|
|
>
|
|
<Button
|
|
destructive
|
|
variant="accent"
|
|
disabled={!selectedIds.length}
|
|
onClick={() => onCancelSelectedJobs(selectedIds)}
|
|
>
|
|
Cancel {selectedIds.length} jobs
|
|
</Button>
|
|
</div>
|
|
{:else if manualSelectionMode === 'resolve'}
|
|
<div
|
|
class="rounded-md bg-surface-tertiary border absolute inset-0 mb-4 flex flex-col items-center justify-center gap-3 p-4"
|
|
>
|
|
<p class="text-xs text-secondary text-center max-w-xs">
|
|
Resolving keeps the run a failure but stops it showing as one in the runs list.
|
|
</p>
|
|
<TextInput
|
|
bind:value={resolutionNote}
|
|
inputProps={{
|
|
placeholder: $enterpriseLicense
|
|
? 'Why is this handled? (optional)'
|
|
: 'Notes and attribution require ee',
|
|
disabled: !$enterpriseLicense
|
|
}}
|
|
size="sm"
|
|
/>
|
|
<div class="flex flex-row gap-2">
|
|
<Button
|
|
variant="accent"
|
|
disabled={!selectedIds.length}
|
|
onClick={() => setJobsResolution(selectedIds, true, resolutionNote)}
|
|
>
|
|
Mark {selectedIds.length} resolved
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
disabled={!selectedIds.length}
|
|
onClick={() => setJobsResolution(selectedIds, false)}
|
|
>
|
|
Unresolve {selectedIds.length}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{:else if batchRerunOptionsIsOpen}
|
|
{@render batchRerunOptions()}
|
|
{:else if selectedIds.length === 1}
|
|
{#if selectedIds[0] === '-'}
|
|
<div class="p-4">There is no information available for this job</div>
|
|
{:else}
|
|
{@render jobPreview()}
|
|
{/if}
|
|
{:else if selectedIds.length > 1}
|
|
<div
|
|
class="rounded-md bg-surface-tertiary border absolute inset-0 mb-4 flex items-center justify-center"
|
|
>
|
|
<div class="text-xs m-4"> {selectedIds.length} jobs selected</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet runPage()}
|
|
{@render runPageFrame(runPageBody)}
|
|
{/snippet}
|
|
|
|
{#snippet runPagePlaceholder()}
|
|
{@render runPageFrame(runPageSkeleton)}
|
|
{/snippet}
|
|
|
|
{#snippet runPageFrame(body: Snippet)}
|
|
<!-- Margins and header row match the runs table's column header, so the back row sits where
|
|
the column labels were. -->
|
|
<div class="h-full flex flex-col min-h-0 p-4 pt-2 pr-2">
|
|
<div class="flex min-h-6 my-2 items-end">
|
|
<Button
|
|
unifiedSize="xs"
|
|
variant="subtle"
|
|
startIcon={{ icon: ArrowLeft }}
|
|
btnClasses="-mb-1 gap-1 text-xs font-semibold leading-3"
|
|
onclick={backToList}
|
|
>
|
|
Runs list
|
|
</Button>
|
|
</div>
|
|
<div class="grow min-h-0 overflow-y-auto">
|
|
{@render body()}
|
|
</div>
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet runPageBody()}
|
|
{#if batchRerunOptionsIsOpen}
|
|
{@render batchRerunOptions()}
|
|
{:else if selectedIds[0] === '-'}
|
|
<div class="p-4">There is no information available for this job</div>
|
|
{:else if selectedIds.length === 1}
|
|
{@render jobPreview(runPageSkeleton)}
|
|
{/if}
|
|
{/snippet}
|
|
|
|
<!-- The shape of a run: its header card, then inputs and result. `mounted` skips the skeleton's
|
|
fade-in, which outlasts the slide it is drawn for. -->
|
|
{#snippet runPageSkeleton()}
|
|
<Skeleton mounted layout={[[4.5], 1.5, [1], 0.5, [3], 1.5, [1], 0.5, [8]]} />
|
|
{/snippet}
|
|
|
|
{#snippet batchRerunOptions()}
|
|
{#await import('$lib/components/runs/BatchReRunOptionsPane.svelte') then BatchReRunOptionsPane}
|
|
<BatchReRunOptionsPane.default
|
|
{selectedIds}
|
|
onCancel={() => ((batchRerunOptionsIsOpen = false), (manualSelectionMode = undefined))}
|
|
onConfirm={async (options) => {
|
|
await onReRunSelectedJobs(options)
|
|
}}
|
|
/>
|
|
{/await}
|
|
{/snippet}
|
|
|
|
{#snippet jobPreview(pending?: Snippet)}
|
|
<!-- Dynamic, like the batch re-run pane: both only show once jobs are selected
|
|
and each statically reaches hundreds of modules (flow graph, monaco). -->
|
|
{#await import('$lib/components/runs/JobRunsPreview.svelte')}
|
|
{@render pending?.()}
|
|
{:then JobRunsPreview}
|
|
<JobRunsPreview.default
|
|
id={selectedIds[0]}
|
|
workspace={selectedWorkspace}
|
|
on:filterByConcurrencyKey={filterByConcurrencyKey}
|
|
on:filterByWorker={filterByWorker}
|
|
onResolutionChanged={() => jobsLoader?.loadJobs(true, true)}
|
|
/>
|
|
{/await}
|
|
{/snippet}
|
|
|
|
<style>
|
|
:global(.bottom-splitpane-wrapper .splitpanes__splitter) {
|
|
background-color: transparent !important;
|
|
border: none !important;
|
|
/* opacity: 0 !important; */
|
|
}
|
|
</style>
|