fix: guard v1 weekday crons, refresh home activity on reload, stable trigger keys

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-10-07 18:23:20 +02:00
co-authored by Claude Opus 5.5
parent f38ff60f54
commit d2b92f1b39
5 changed files with 79 additions and 8 deletions
+5
View File
@@ -33369,6 +33369,11 @@ components:
timezone:
description: the schedule's timezone, for a schedule only
type: string
cron_version:
description: >-
the schedule's cron version, for a schedule only; v1 numbers
weekdays from 1 = Sunday
type: string
TriggersCount:
type: object
+12 -3
View File
@@ -1130,11 +1130,14 @@ struct AttachedTrigger {
path: String,
/// `enabled` | `disabled` | `suspended`
mode: String,
/// A schedule's cron expression and timezone; absent for every other kind.
/// A schedule's cron expression, timezone and cron version (`v1` numbers
/// weekdays from 1 = Sunday); absent for every other kind.
#[serde(skip_serializing_if = "Option::is_none")]
schedule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
timezone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
cron_version: Option<String>,
}
#[derive(Serialize, Default)]
@@ -1194,7 +1197,8 @@ async fn get_runnables_activity(
ORDER BY j.created_at DESC"
);
const NO_CRON: &str = "NULL::text AS schedule, NULL::text AS timezone";
const NO_CRON: &str =
"NULL::text AS schedule, NULL::text AS timezone, NULL::text AS cron_version";
let branch = |table: &str, kind: &str, mode: &str, cron: &str| {
format!(
"SELECT '{kind}' AS kind, path, script_path, is_flow, {mode} AS mode, {cron} FROM {table} \
@@ -1204,7 +1208,12 @@ async fn get_runnables_activity(
let trigger_mode = "mode::text";
let bool_mode = "CASE WHEN enabled THEN 'enabled' ELSE 'disabled' END";
let branches = [
branch("schedule", "schedule", bool_mode, "schedule, timezone"),
branch(
"schedule",
"schedule",
bool_mode,
"schedule, timezone, cron_version::text",
),
branch("http_trigger", "http", trigger_mode, NO_CRON),
branch("websocket_trigger", "websocket", trigger_mode, NO_CRON),
branch("kafka_trigger", "kafka", trigger_mode, NO_CRON),
@@ -837,6 +837,8 @@
// was on screen so the selection can drop what this reload removes instead of
// keeping a dead path. `tick` lets the reloaded rows re-register first.
const renderedBefore = homeSelection.renderedKeys
// A row action can add, toggle or remove a trigger, or start a run.
homeActivity.reset()
void ownerCountsRes.refetch()
await reloadItems()
await tick()
@@ -2,7 +2,7 @@
import { base } from '$lib/base'
import { Tooltip } from '$lib/components/meltComponents'
import { triggerDisplayNamesMap, triggerIconMapMono } from '$lib/components/triggers/utils'
import { displayDate } from '$lib/utils'
import { displayDate, formatCron } from '$lib/utils'
import { describeSchedule } from '$lib/utils/describeCron'
import { twMerge } from 'tailwind-merge'
import { getHomeActivity, type ActivityKind } from './homeActivity.svelte'
@@ -41,10 +41,26 @@
)
let loneScheduleText = $derived(
loneSchedule?.schedule
? (describeSchedule(loneSchedule.schedule, loneSchedule.timezone) ?? loneSchedule.schedule)
? (describeLoneSchedule(
loneSchedule.schedule,
loneSchedule.timezone,
loneSchedule.cron_version
) ?? loneSchedule.schedule)
: undefined
)
// `describeCron` reads weekdays the v2 way (0 = Sunday); a v1 cron numbers them
// from 1 = Sunday, so one that names weekdays is shown as the raw expression.
function describeLoneSchedule(
cron: string,
timezone: string | undefined,
cronVersion: string | undefined
): string | undefined {
const dow = formatCron(cron.trim()).split(/\s+/)[5]
if (cronVersion === 'v1' && dow !== undefined && dow !== '*' && dow !== '?') return undefined
return describeSchedule(cron, timezone)
}
type RunStatus = (typeof runs)[number]['status']
const statusClass: Record<RunStatus, string> = {
@@ -111,7 +127,9 @@
{/if}
</div>
<div class="relative z-[1] hidden lg:flex items-center justify-start gap-0.5 w-44 min-w-0 shrink-0">
<div
class="relative z-[1] hidden lg:flex items-center justify-start gap-0.5 w-44 min-w-0 shrink-0"
>
{#if loneSchedule}
<Tooltip class="min-w-0">
<div
@@ -128,7 +146,7 @@
{/snippet}
</Tooltip>
{:else}
{#each visibleTriggers as trigger (trigger.kind + trigger.path)}
{#each visibleTriggers as trigger, i (i)}
<Tooltip>
{@render triggerChip(trigger.kind, trigger.mode)}
{#snippet text()}
@@ -146,7 +164,7 @@
{#snippet text()}
<div class="flex flex-col gap-1">
<span class="font-semibold">{triggers.length} triggers</span>
{#each triggers as trigger (trigger.kind + trigger.path)}
{#each triggers as trigger, i (i)}
{@render triggerLine(trigger)}
{/each}
</div>
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { describeCron, describeSchedule } 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()
}
)
})
describe('describeSchedule', () => {
it('names the zone of a wall-clock time only', () => {
expect(describeSchedule('0 0 12 * * *', 'UTC')).toBe('Every day at 12:00 UTC')
expect(describeSchedule('0 0 * * * *', 'UTC')).toBe('Every hour')
})
})