diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 177f1c6f46..3294d85d95 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -33363,6 +33363,12 @@ components: mode: type: string enum: [enabled, disabled, suspended] + schedule: + description: the cron expression, for a schedule only + type: string + timezone: + description: the schedule's timezone, for a schedule only + type: string TriggersCount: type: object diff --git a/backend/windmill-api/src/runnables.rs b/backend/windmill-api/src/runnables.rs index efcd9bf3ba..bd9c569d84 100644 --- a/backend/windmill-api/src/runnables.rs +++ b/backend/windmill-api/src/runnables.rs @@ -1130,6 +1130,11 @@ struct AttachedTrigger { path: String, /// `enabled` | `disabled` | `suspended` mode: String, + /// A schedule's cron expression and timezone; absent for every other kind. + #[serde(skip_serializing_if = "Option::is_none")] + schedule: Option, + #[serde(skip_serializing_if = "Option::is_none")] + timezone: Option, } #[derive(Serialize, Default)] @@ -1189,30 +1194,31 @@ async fn get_runnables_activity( ORDER BY j.created_at DESC" ); - let branch = |table: &str, kind: &str, mode: &str| { + const NO_CRON: &str = "NULL::text AS schedule, NULL::text AS timezone"; + let branch = |table: &str, kind: &str, mode: &str, cron: &str| { format!( - "SELECT '{kind}' AS kind, path, script_path, is_flow, {mode} AS mode FROM {table} \ + "SELECT '{kind}' AS kind, path, script_path, is_flow, {mode} AS mode, {cron} FROM {table} \ WHERE workspace_id = $1 AND script_path = ANY($2)" ) }; let trigger_mode = "mode::text"; let bool_mode = "CASE WHEN enabled THEN 'enabled' ELSE 'disabled' END"; let branches = [ - branch("schedule", "schedule", bool_mode), - branch("http_trigger", "http", trigger_mode), - branch("websocket_trigger", "websocket", trigger_mode), - branch("kafka_trigger", "kafka", trigger_mode), - branch("nats_trigger", "nats", trigger_mode), - branch("postgres_trigger", "postgres", trigger_mode), - branch("mqtt_trigger", "mqtt", trigger_mode), - branch("amqp_trigger", "amqp", trigger_mode), - branch("sqs_trigger", "sqs", trigger_mode), - branch("gcp_trigger", "gcp", trigger_mode), - branch("azure_trigger", "azure", trigger_mode), - branch("email_trigger", "email", trigger_mode), + branch("schedule", "schedule", bool_mode, "schedule, timezone"), + branch("http_trigger", "http", trigger_mode, NO_CRON), + branch("websocket_trigger", "websocket", trigger_mode, NO_CRON), + branch("kafka_trigger", "kafka", trigger_mode, NO_CRON), + branch("nats_trigger", "nats", trigger_mode, NO_CRON), + branch("postgres_trigger", "postgres", trigger_mode, NO_CRON), + branch("mqtt_trigger", "mqtt", trigger_mode, NO_CRON), + branch("amqp_trigger", "amqp", trigger_mode, NO_CRON), + branch("sqs_trigger", "sqs", trigger_mode, NO_CRON), + branch("gcp_trigger", "gcp", trigger_mode, NO_CRON), + branch("azure_trigger", "azure", trigger_mode, NO_CRON), + branch("email_trigger", "email", trigger_mode, NO_CRON), format!( "SELECT service_name::text AS kind, COALESCE(NULLIF(summary, ''), external_id) AS path, \ - script_path, is_flow, {bool_mode} AS mode FROM native_trigger \ + script_path, is_flow, {bool_mode} AS mode, {NO_CRON} FROM native_trigger \ WHERE workspace_id = $1 AND script_path = ANY($2)" ), ]; diff --git a/frontend/src/lib/components/DraftBadge.svelte b/frontend/src/lib/components/DraftBadge.svelte index 85b54bdda0..2b295ce9dd 100644 --- a/frontend/src/lib/components/DraftBadge.svelte +++ b/frontend/src/lib/components/DraftBadge.svelte @@ -236,7 +236,7 @@ {/if} {:else} - + {draft_only ? 'Draft only' : 'Draft'} {/if} diff --git a/frontend/src/lib/components/common/badge/Badge.svelte b/frontend/src/lib/components/common/badge/Badge.svelte index ca26a9f841..89eceafb3a 100644 --- a/frontend/src/lib/components/common/badge/Badge.svelte +++ b/frontend/src/lib/components/common/badge/Badge.svelte @@ -128,8 +128,8 @@ : 'rounded-md px-2 py-0.5', verySmall ? 'px-0.5 py-0.5' : '', 'flex flex-row gap-1 items-center justify-center', - classNames, - compact ? 'text-3xs px-1 py-0 gap-0.5' : '' + compact ? 'text-2xs px-1.5 py-0.5 gap-1' : '', + classNames ) ) const handleHide = () => (hidden = !hidden) @@ -151,7 +151,7 @@ role={clickable ? 'button' : undefined} > {#if icon?.icon && icon.position === 'left'} - + {/if} {#if compact} -
0 ? '!rounded-none' : '', - disabled ? 'opacity-25' : 'hover:bg-surface-hover', - clickToSelect || inSelectionMode ? 'cursor-pointer select-none' : '', - selected || rowSelection?.selected - ? 'bg-surface-accent-selected' - : keyboardSelected - ? 'bg-gray-200 dark:bg-gray-700' - : '' - )} - style={depth > 0 ? `padding-left: ${(depth + 1) * 16}px;` : ''} - role={clickToSelect ? 'button' : undefined} - tabindex={clickToSelect ? 0 : undefined} - onclick={handleRowClick} - onkeydown={clickToSelect ? handleRowKeydown : undefined} -> - - {#if isSelectable} -
- -
- {:else if selectDisabledReason} -
- - - {#snippet text()}{selectDisabledReason}{/snippet} - -
- {:else if alignWithSelectable} -
- {/if} - - {#if rowSelection} - -
+{#if homeTable} +
+ {#if href && !inSelectionMode} + + {/if} + +
- { - // Left unprevented on purpose: the browser's own toggle already lands - // on the value we are about to compute, except on a range re-select, - // which Checkbox re-asserts. Preventing it would revert the box AFTER - // the update and leave every clicked row visually unticked. - e.stopPropagation() - rowSelection?.onToggle(e) - }} - /> + {#if rowSelection} + { + e.stopPropagation() + rowSelection?.onToggle(e) + }} + /> + {/if}
- {/if} - - - {#if href && !inSelectionMode} - - {/if} - {@render rowContent(!rowSelection, !!href && !inSelectionMode)} - - {#if errorHandlerMuted} - - {/if} - -
- {#if kind == 'script' || kind == 'flow' || kind == 'app' || kind == 'raw_app' || kind == 'agent'} - - {/if} - - {#if kind == 'app' || kind == 'raw_app' || kind == 'script' || kind == 'flow'} -
- {#if canFavorite} - - {:else} - - - - {/if} +
0 ? `padding-left: ${depth * 16}px;` : ''} + > + {@render rowContent(false, !!href && !inSelectionMode, false)} + {#if errorHandlerMuted} + + {/if} +
+ + -
+{:else} + +
0 ? '!rounded-none' : '', + disabled ? 'opacity-25' : 'hover:bg-surface-hover', + clickToSelect || inSelectionMode ? 'cursor-pointer select-none' : '', + selected || rowSelection?.selected + ? 'bg-surface-accent-selected' + : keyboardSelected + ? 'bg-gray-200 dark:bg-gray-700' + : '' + )} + style={depth > 0 ? `padding-left: ${(depth + 1) * 16}px;` : ''} + role={clickToSelect ? 'button' : undefined} + tabindex={clickToSelect ? 0 : undefined} + onclick={handleRowClick} + onkeydown={clickToSelect ? handleRowKeydown : undefined} + > + + {#if isSelectable} +
+ +
+ {:else if selectDisabledReason} +
+ + + {#snippet text()}{selectDisabledReason}{/snippet} + +
+ {:else if alignWithSelectable} +
+ {/if} -{#snippet rowContent(withIcon: boolean, linked: boolean)} + {#if rowSelection} + +
+
+ +
+ { + // Left unprevented on purpose: the browser's own toggle already lands + // on the value we are about to compute, except on a range re-select, + // which Checkbox re-asserts. Preventing it would revert the box AFTER + // the update and leave every clicked row visually unticked. + e.stopPropagation() + rowSelection?.onToggle(e) + }} + /> +
+ {/if} + + + {#if href && !inSelectionMode} + + {/if} + {@render rowContent(!rowSelection, !!href && !inSelectionMode)} + + {#if errorHandlerMuted} + + {/if} + +
+ {#if kind == 'script' || kind == 'flow' || kind == 'app' || kind == 'raw_app' || kind == 'agent'} + + {/if} + + {@render favorite()} + +
+ {@render actions?.()} +
+
+
+{/if} + +{#snippet favorite()} + {#if kind == 'app' || kind == 'raw_app' || kind == 'script' || kind == 'flow'} +
+ {#if canFavorite} + + {:else} + + + + {/if} +
+ {:else} +
+ {/if} +{/snippet} + +{#snippet rowContent(withIcon: boolean, linked: boolean, inlineBadges = true)} {#if withIcon}
@@ -302,7 +406,8 @@
- {#if badges} + {#if !inlineBadges && draftBadge} + +
{@render draftBadge()}
+ {/if} + {#if inlineBadges && (badges || languageBadge || tagBadges || draftBadge || sharedBadge || labelBadges)} {/if}
diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 7d220969c9..5b98141e9c 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -172,10 +172,12 @@ {depth} {rowSelection} > - {#snippet badges()} + {#snippet languageBadge()} {#if script.language} {/if} + {/snippet} + {#snippet tagBadges()} {#if script.lock_error_logs} Deployment failed {/if} @@ -210,7 +212,11 @@ >{script.kind === 'failure' ? 'Error handler' : capitalize(script.kind)} {/if} + {/snippet} + {#snippet sharedBadge()} + {/snippet} + {#snippet draftBadge()} dispatch('change')} /> + {/snippet} + {#snippet labelBadges()} {#if script.labels?.length}
{#each script.labels.slice(0, 3) as label} @@ -257,10 +265,10 @@
{/if} {:else if treeView} - - {#key treeKey} - - {/key} + {#key treeKey} + + {/key} +
{:else}
+ {@render tableHeader()} {#if filter === ''} {#each [...visiblePipelineFolders].sort() as folder (folder)} Pipeline · f/{folder} @@ -2237,6 +2244,29 @@
+{#snippet tableHeader()} +
+
+ {#if homeSelection.available} + {@const state = homeSelection.renderedSelection} + homeSelection.toggleAllRendered()} + /> + {/if} +
+
Name
+ + + +
+
+{/snippet} + {#if homeSelection.active && $workspaceStore} MAX_TRIGGER_ICONS ? triggers.slice(0, MAX_TRIGGER_ICONS - 1) : triggers ) let hiddenTriggers = $derived(triggers.slice(visibleTriggers.length)) + // A runnable whose only trigger is a schedule shows its cadence instead of an icon. + let loneSchedule = $derived( + triggers.length === 1 && triggers[0].kind === 'schedule' && triggers[0].schedule + ? triggers[0] + : undefined + ) + let loneScheduleText = $derived( + loneSchedule?.schedule + ? (describeSchedule(loneSchedule.schedule, loneSchedule.timezone) ?? loneSchedule.schedule) + : undefined + ) type RunStatus = (typeof runs)[number]['status'] @@ -59,35 +71,7 @@ {#if homeActivity} - - -
{/snippet} +{#snippet triggerIconOnly(kind: string)} + {@const Icon = triggerIcon(kind)} + {#if Icon} + + {/if} +{/snippet} + {#snippet triggerLine(trigger: { kind: string; path: string; mode: string })} {@const Icon = triggerIcon(trigger.kind)}
diff --git a/frontend/src/lib/components/home/TreeView.svelte b/frontend/src/lib/components/home/TreeView.svelte index 4c6f3e8ae6..915e0d453f 100644 --- a/frontend/src/lib/components/home/TreeView.svelte +++ b/frontend/src/lib/components/home/TreeView.svelte @@ -7,6 +7,7 @@ import Item from './Item.svelte' import { countLeaves, type FolderItem, type ItemType, type UserItem } from './treeViewUtils' import { twMerge } from 'tailwind-merge' + import { isHomeTable } from './homeTable' import { pluralize } from '$lib/utils' import { base } from '$lib/base' import { Button } from '$lib/components/common' @@ -72,6 +73,9 @@ leaf }: Props = $props() + // The home table draws no separators between its rows. + const separator = isHomeTable() ? 'rounded-md hover:bg-surface-hover' : 'border-b' + let visualDepth = $derived(depth + indent) // Bounds the request burst from "expand all": however many root owners the tree @@ -264,7 +268,7 @@
@@ -353,7 +357,7 @@ {/each} {#if effectiveMax < item.items.length}
diff --git a/frontend/src/lib/components/home/TreeViewRoot.svelte b/frontend/src/lib/components/home/TreeViewRoot.svelte index 753feb6a8b..485dcfcf3c 100644 --- a/frontend/src/lib/components/home/TreeViewRoot.svelte +++ b/frontend/src/lib/components/home/TreeViewRoot.svelte @@ -277,11 +277,8 @@
No items
{:else} - -
-
+
+
{#each rows.slice(0, nbDisplayed) as row (row.kind === 'otherUsers' ? 'other_users' : 'folderName' in row.node ? `f__${row.node.folderName}` : 'username' in row.node ? `u__${row.node.username}` : `i__${row.node.type}__${row.node.path}`)} {#if row.kind === 'otherUsers'}
@@ -324,7 +321,7 @@ {/each} {#if nbOtherUsersDisplayed < otherUsers.length}
Showing {nbOtherUsersDisplayed} of {otherUsers.length} users @@ -352,7 +349,7 @@ has to read as part of the list to be noticed at all. -->
{#if nbDisplayed < rows.length} diff --git a/frontend/src/lib/components/home/homeSelection.svelte.ts b/frontend/src/lib/components/home/homeSelection.svelte.ts index 70acdfa0fd..953292ef14 100644 --- a/frontend/src/lib/components/home/homeSelection.svelte.ts +++ b/frontend/src/lib/components/home/homeSelection.svelte.ts @@ -150,6 +150,24 @@ export class HomeSelection { else this.anchor = undefined } + /** How many of the rows on screen are selected, for a select-all control. */ + get renderedSelection(): 'none' | 'some' | 'all' { + let selected = 0 + for (const key of this.registry.keys()) if (this.selected.has(key)) selected++ + if (selected === 0) return 'none' + return selected === this.registry.size ? 'all' : 'some' + } + + /** Select every row on screen, or clear the selection when all already are. */ + toggleAllRendered(): void { + if (this.renderedSelection === 'all') { + this.exit() + return + } + for (const [key, item] of this.registry) this.selected.set(key, item) + this.anchor = undefined + } + toggle(item: BulkItem, range = false): void { if (range && this.anchor != undefined && this.anchor !== item.key) { if (this.selectRange(this.anchor, item.key)) return diff --git a/frontend/src/lib/components/home/homeTable.ts b/frontend/src/lib/components/home/homeTable.ts new file mode 100644 index 0000000000..b3ae692056 --- /dev/null +++ b/frontend/src/lib/components/home/homeTable.ts @@ -0,0 +1,29 @@ +import { getContext, setContext } from 'svelte' + +/** + * Column template shared by the home table's header and every row, so the + * columns line up across separately rendered rows (the tree nests them): + * select · name · badges · runs · triggers · actions. Below `lg` the labels, + * triggers and runs cells are hidden, and the template drops them too. + */ +export const HOME_TABLE_GRID = + 'grid items-center gap-x-5 grid-cols-[1rem_minmax(0,1fr)_9rem] lg:grid-cols-[1rem_minmax(0,2fr)_minmax(0,1.5fr)_1.25rem_11rem_9rem]' + +/** Sub-columns of the badges area: labels · language · type tags · shared. */ +export const HOME_TABLE_BADGE_GRID = + 'grid-cols-[minmax(0,1fr)_1rem_5rem_1.25rem] gap-x-2 items-center min-w-0' + +/** Shows a muted dash in a table cell that renders no element. */ +export const EMPTY_CELL_DASH = + "[&:not(:has(*))]:after:content-['–'] [&:not(:has(*))]:after:text-hint [&:not(:has(*))]:after:text-2xs [&:not(:has(*))]:after:opacity-40" + +const CONTEXT_KEY = 'homeTable' + +/** Rows below the calling component render as home table rows. */ +export function setHomeTable() { + setContext(CONTEXT_KEY, true) +} + +export function isHomeTable(): boolean { + return getContext(CONTEXT_KEY) ?? false +} diff --git a/frontend/src/lib/utils/describeCron.ts b/frontend/src/lib/utils/describeCron.ts new file mode 100644 index 0000000000..96debb2589 --- /dev/null +++ b/frontend/src/lib/utils/describeCron.ts @@ -0,0 +1,128 @@ +import { formatCron } from '$lib/utils' + +const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] +const DAY_ALIASES: Record = { + SUN: 0, + MON: 1, + TUE: 2, + WED: 3, + THU: 4, + FRI: 5, + SAT: 6 +} + +const isNum = (f: string) => /^\d+$/.test(f) +const everyN = (f: string) => /^\*\/(\d+)$/.exec(f)?.[1] + +function time(hour: string, minute: string): string { + return `${Number(hour)}:${minute.padStart(2, '0')}` +} + +function ordinal(n: number): string { + const rest = n % 100 + if (rest >= 11 && rest <= 13) return `${n}th` + return `${n}${['th', 'st', 'nd', 'rd'][n % 10] ?? 'th'}` +} + +function dayNumber(token: string): number | undefined { + const t = token.toUpperCase() + if (t in DAY_ALIASES) return DAY_ALIASES[t] + if (!isNum(t)) return undefined + const n = Number(t) + return n >= 0 && n <= 7 ? n % 7 : undefined +} + +/** `1-5`, `MON-FRI`, `1,3,5`, `6` → sorted day numbers; undefined for anything else. */ +function days(field: string): number[] | undefined { + const out = new Set() + for (const part of field.split(',')) { + const [from, to] = part.split('-') + const a = dayNumber(from) + if (a === undefined) return undefined + if (to === undefined) { + out.add(a) + continue + } + const b = dayNumber(to) + if (b === undefined || b < a) return undefined + for (let d = a; d <= b; d++) out.add(d) + } + return [...out].sort() +} + +function listDays(ds: number[]): string { + if (ds.join() === '1,2,3,4,5') return 'weekday' + if (ds.join() === '0,6') return 'weekend day' + const names = ds.map((d) => DAY_NAMES[d]) + return names.length === 1 + ? names[0] + : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}` +} + +/** + * A plain-English reading of a Windmill cron expression — "Every hour", + * "Every day at 4:00", "Every Monday at 9:30" — or undefined when it is not one + * of the common shapes, so the caller can show the expression itself. + * + * Windmill crons have six fields, seconds first; a five-field entry is padded + * at the end the way the schedule editor saves it (`formatCron`). + */ +export function describeCron(cron: string): string | undefined { + const fields = formatCron(cron.trim()).split(/\s+/) + if (fields.length !== 6) return undefined + const [sec, min, hour, dom, month, dow] = fields + if (month !== '*') return undefined + + const anyDay = dom === '*' && (dow === '*' || dow === '?') + if (anyDay && min === '*' && hour === '*') { + if (sec === '*') return 'Every second' + const n = everyN(sec) + if (n) return `Every ${n} seconds` + } + if (sec !== '0') return undefined + + if (anyDay) { + if (hour === '*') { + if (min === '*') return 'Every minute' + const n = everyN(min) + if (n) return `Every ${n} minutes` + if (min === '0') return 'Every hour' + if (isNum(min)) return `Every hour at :${min.padStart(2, '0')}` + return undefined + } + const n = everyN(hour) + if (n && min === '0') return `Every ${n} hours` + if (isNum(hour) && isNum(min)) return `Every day at ${time(hour, min)}` + return undefined + } + + if (!isNum(hour) || !isNum(min)) return undefined + if (dom === '*') { + const ds = days(dow) + return ds ? `Every ${listDays(ds)} at ${time(hour, min)}` : undefined + } + if (isNum(dom) && (dow === '*' || dow === '?')) { + return `Every month on the ${ordinal(Number(dom))} at ${time(hour, min)}` + } + return undefined +} + +function shortZone(timezone: string): string { + try { + return ( + new Intl.DateTimeFormat('en-US', { timeZone: timezone, timeZoneName: 'short' }) + .formatToParts(new Date()) + .find((p) => p.type === 'timeZoneName')?.value ?? timezone + ) + } catch { + return timezone + } +} + +/** `describeCron` of a schedule, naming its zone whenever the text states a + * wall-clock time: "Every day at 12:00 UTC". */ +export function describeSchedule(cron: string, timezone?: string): string | undefined { + const text = describeCron(cron) + if (!text || !timezone || !/ at \d/.test(text)) return text + return `${text} ${shortZone(timezone)}` +}