mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 08:02:47 +00:00
feat: name a schedule node's cadence, like "Every day at 12:00"
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
69cea7f3d9
commit
d3e5100bf4
@@ -717,6 +717,7 @@
|
||||
ref: string
|
||||
missing: boolean
|
||||
draft: boolean
|
||||
schedule?: string
|
||||
// First target script (drives the per-script create/edit flows).
|
||||
runnable_path?: string
|
||||
// Every target script: a single (kind, ref) — e.g. one schedule —
|
||||
@@ -733,7 +734,8 @@
|
||||
unsaved: boolean,
|
||||
missing: boolean,
|
||||
draft: boolean,
|
||||
runnable_path?: string
|
||||
runnable_path?: string,
|
||||
schedule?: string
|
||||
) {
|
||||
const prev = triggerSourceNodes.get(id)
|
||||
if (!prev) {
|
||||
@@ -743,6 +745,7 @@
|
||||
ref,
|
||||
missing,
|
||||
draft,
|
||||
schedule,
|
||||
runnable_path,
|
||||
runnable_paths: runnable_path ? [runnable_path] : []
|
||||
})
|
||||
@@ -798,7 +801,8 @@
|
||||
// lock. Previously only set for missing, which silently
|
||||
// disabled `canEdit` (and the resulting click affordance)
|
||||
// on every attached native trigger.
|
||||
t.runnable_path
|
||||
t.runnable_path,
|
||||
t.schedule
|
||||
)
|
||||
edges.push({
|
||||
id: `trig-${t.trigger_kind}:${sourceId}->${runnableId}`,
|
||||
@@ -827,6 +831,7 @@
|
||||
unsaved: info.allUnsaved,
|
||||
missing: info.missing,
|
||||
draft: info.draft,
|
||||
schedule: info.schedule,
|
||||
runnable_path: info.runnable_path,
|
||||
// data_upload nodes go green once a file is staged for their
|
||||
// target script (see readyDataUploadPaths / page dataUploadArgs).
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
<script lang="ts">
|
||||
import { Handle, Position } from '@xyflow/svelte'
|
||||
import PipelineNodeCard from './PipelineNodeCard.svelte'
|
||||
import { describeCron } from '$lib/utils/describeCron'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { AlertTriangle, CheckCircle2, EllipsisVertical, Target, Trash2 } from 'lucide-svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
@@ -72,6 +73,8 @@
|
||||
// Edit/delete go through the same callbacks, which the page routes
|
||||
// to the draft instead of a trigger row.
|
||||
draft?: boolean
|
||||
// The cron, for a schedule: the node names its cadence instead of "Schedule".
|
||||
schedule?: string
|
||||
runnable_path?: string
|
||||
// Page-supplied dispatcher that opens the matching native
|
||||
// trigger drawer with `script_path` pre-filled. When absent
|
||||
@@ -201,6 +204,14 @@
|
||||
: [])
|
||||
])
|
||||
|
||||
// A schedule names its cadence ("Every day at 4:00", or the cron itself
|
||||
// when it has no plain reading); every other kind names its kind.
|
||||
let kindName = $derived(
|
||||
data.kind === 'schedule' && data.schedule
|
||||
? (describeCron(data.schedule) ?? `Schedule (${data.schedule})`)
|
||||
: style.label
|
||||
)
|
||||
|
||||
// One card for every state: which label, title and click the node gets.
|
||||
let card = $derived.by(
|
||||
(): {
|
||||
@@ -223,7 +234,7 @@
|
||||
}
|
||||
if (canEdit)
|
||||
return {
|
||||
kindLabel: `${style.label}${data.draft ? ' · draft' : unsaved ? ' · unsaved' : ''}`,
|
||||
kindLabel: `${kindName}${data.draft ? ' · draft' : unsaved ? ' · unsaved' : ''}`,
|
||||
title: data.ref,
|
||||
tooltip: data.draft
|
||||
? `Draft ${style.label}: ${data.ref} — created when you save the pipeline. Click to edit.`
|
||||
@@ -251,7 +262,7 @@
|
||||
onclick: handleDataUploadClick
|
||||
}
|
||||
return {
|
||||
kindLabel: `${style.label}${displayMissing ? ' · missing' : unsaved ? ' · unsaved' : ''}`,
|
||||
kindLabel: `${displayMissing ? style.label : kindName}${displayMissing ? ' · missing' : unsaved ? ' · unsaved' : ''}`,
|
||||
title: displayMissing ? 'no trigger row' : data.ref,
|
||||
tooltip:
|
||||
missingTitle ??
|
||||
|
||||
@@ -308,7 +308,8 @@ function withTriggerDrafts(
|
||||
runnable_kind: 'script' as const,
|
||||
runnable_path: d.config.script_path,
|
||||
unsaved: true,
|
||||
draft: true
|
||||
draft: true,
|
||||
...(d.kind === 'schedule' ? { schedule: d.config.schedule as string } : {})
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
||||
@@ -214,6 +214,8 @@ export type AssetGraphTrigger =
|
||||
// A pipeline-local `PipelineTriggerDraft`: `path` is the trigger it
|
||||
// will be deployed as, and no row exists yet.
|
||||
draft?: boolean
|
||||
// A schedule's cron, filled in client-side, for the node's label.
|
||||
schedule?: string
|
||||
}
|
||||
|
||||
// Macro-library → consumer edge: the consumer calls `macro_names` of
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describeCron } from './describeCron'
|
||||
|
||||
describe('describeCron', () => {
|
||||
it.each([
|
||||
['*/30 * * * * *', 'Every 30 seconds'],
|
||||
['0 * * * * *', 'Every minute'],
|
||||
['0 */15 * * * *', 'Every 15 minutes'],
|
||||
['0 0 * * * *', 'Every hour'],
|
||||
['0 30 * * * *', 'Every hour at :30'],
|
||||
['0 0 */6 * * *', 'Every 6 hours'],
|
||||
['0 0 4 * * *', 'Every day at 4:00'],
|
||||
// five fields are padded at the end, like the schedule editor saves them
|
||||
['0 0 12 * *', 'Every day at 12:00'],
|
||||
['0 30 9 * * 1-5', 'Every weekday at 9:30'],
|
||||
['0 0 9 * * MON', 'Every Monday at 9:00'],
|
||||
['0 0 18 * * 1,3,5', 'Every Monday, Wednesday and Friday at 18:00'],
|
||||
['0 0 8 1 * *', 'Every month on the 1st at 8:00'],
|
||||
['0 0 8 22 * *', 'Every month on the 22nd at 8:00']
|
||||
])('%s → %s', (cron, text) => {
|
||||
expect(describeCron(cron)).toBe(text)
|
||||
})
|
||||
|
||||
it.each(['0 0 8 1 1 *', '15 0 4 * * *', '0 0 4-6 * * *', 'not a cron'])(
|
||||
'leaves %s to be shown as is',
|
||||
(cron) => {
|
||||
expect(describeCron(cron)).toBeUndefined()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import { formatCron } from '$lib/utils'
|
||||
|
||||
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
const DAY_ALIASES: Record<string, number> = {
|
||||
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<number>()
|
||||
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
|
||||
}
|
||||
@@ -1363,7 +1363,7 @@
|
||||
withSummaries(mode === 'edit' || includeDrafts ? graphWithDraft : deployedGraph)
|
||||
)
|
||||
// Adds what the canvas shows but the resolved graph does not carry: script
|
||||
// summaries and languages, and which assets are misconfigured.
|
||||
// summaries and languages, schedule crons, and which nodes are misconfigured.
|
||||
function withSummaries(g: AssetGraphResponse): AssetGraphResponse {
|
||||
const showDrafts = mode === 'edit' || includeDrafts
|
||||
const explicitOnByPath = new Map<string, Array<{ kind: AssetKind; path: string }>>()
|
||||
@@ -1381,8 +1381,16 @@
|
||||
if (refs.length > 0) explicitOnByPath.set(r.path, refs)
|
||||
}
|
||||
const errors = pipelineNodeErrors(g, explicitOnByPath)
|
||||
const crons = scheduleCrons.current
|
||||
return {
|
||||
...g,
|
||||
triggers: crons?.size
|
||||
? g.triggers.map((t) =>
|
||||
t.trigger_kind === 'schedule' && t.path && !t.schedule && crons.has(t.path)
|
||||
? { ...t, schedule: crons.get(t.path) }
|
||||
: t
|
||||
)
|
||||
: g.triggers,
|
||||
assets:
|
||||
errors.assets.size === 0
|
||||
? g.assets
|
||||
@@ -2459,6 +2467,31 @@
|
||||
}
|
||||
)
|
||||
|
||||
// Crons of the deployed schedules on the graph, for their nodes' labels.
|
||||
// Re-read with every graph load, so an edit in the schedule drawer shows.
|
||||
let scheduleCrons = resource(
|
||||
[() => $workspaceStore, () => graphRes.current],
|
||||
async ([ws, g]) => {
|
||||
const crons = new Map<string, string>()
|
||||
if (!ws || !g) return crons
|
||||
const paths = new Set<string>()
|
||||
for (const t of g.triggers) {
|
||||
if (t.trigger_kind === 'schedule' && t.path && !t.missing) paths.add(t.path)
|
||||
}
|
||||
await Promise.all(
|
||||
[...paths].map(async (path) => {
|
||||
try {
|
||||
const s = await ScheduleService.getSchedule({ workspace: ws, path })
|
||||
crons.set(path, s.schedule)
|
||||
} catch {
|
||||
// Unreadable schedule: its node keeps the plain "Schedule" label.
|
||||
}
|
||||
})
|
||||
)
|
||||
return crons
|
||||
}
|
||||
)
|
||||
|
||||
// Folder whose graph is actually rendered. `graphRes.current` is stale-
|
||||
// while-revalidate on an in-place folder switch, so keying the canvas's
|
||||
// one-shot initial fit on the route param would fire the new folder's fit
|
||||
|
||||
Reference in New Issue
Block a user