diff --git a/Cargo.lock b/Cargo.lock index 7fa021c1..a0b1d13a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -723,6 +723,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -987,7 +997,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.13.1", "smallvec", ] @@ -2592,6 +2602,15 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + [[package]] name = "phf" version = "0.13.1" @@ -2599,7 +2618,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.13.1", "serde", ] @@ -2610,7 +2629,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -2620,7 +2639,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -2630,12 +2649,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -3014,6 +3042,7 @@ dependencies = [ "base64", "brotli", "chrono", + "chrono-tz", "croner", "dashmap", "flate2", @@ -3084,6 +3113,7 @@ name = "ruscker-config" version = "0.2.50" dependencies = [ "chrono", + "chrono-tz", "ordered-float", "pretty_assertions", "regex", @@ -3294,7 +3324,7 @@ dependencies = [ "derive_more", "log", "new_debug_unreachable", - "phf", + "phf 0.13.1", "phf_codegen", "precomputed-hash", "rustc-hash", diff --git a/Cargo.toml b/Cargo.toml index 266bd7ef..352f23bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,10 @@ regex = "1.12" url = "2.5.8" uuid = { version = "1.23", features = ["v4", "serde"] } chrono = { version = "0.4.44", features = ["serde"] } +# IANA zone database, for evaluating cron schedules in the operator's +# local time instead of UTC (#1042). Pure data + arithmetic — no I/O, so +# it's admissible in the pure-domain `ruscker-config` crate. +chrono-tz = "0.10" dashmap = "6.2" ordered-float = { version = "5.3", features = ["serde"] } diff --git a/book/src/admin.md b/book/src/admin.md index 23daab25..2781dbb3 100644 --- a/book/src/admin.md +++ b/book/src/admin.md @@ -352,7 +352,13 @@ nothing to run. Semantics worth knowing: - **No run on creation.** A new schedule waits for its next cron - occurrence (times are UTC). + occurrence. +- **Which clock.** Cron expressions are evaluated in `server.timezone` + (an IANA name like `America/Recife` in `ruscker.yml`), and the + next/last run columns are shown in that same zone, labelled with it. + Unset ⇒ **UTC**, the historical behaviour — so `0 8 * * *` fires at + 08:00 UTC until you configure a zone. Setting one shifts existing + schedules by your offset; review them afterwards. - **Downtime collapses.** If the server was down across several occurrences, the schedule fires **once** on the next tick — ETL semantics, not a message queue. @@ -450,9 +456,9 @@ too; rows with a change diff expand to show it. Timestamps are stored in UTC and rendered in **your browser's timezone**, so two operators in different zones each read the local wall clock of the same -event. Hovering a timestamp shows the full date with the zone name. (The -scheduler's own times — next occurrence and last run on the Schedules page — -are still labelled UTC.) +event. Hovering a timestamp shows the full date with the zone name. (The Schedules page is +deliberately different: those times are when the *server* will fire a job, +so they follow `server.timezone` — see the Schedules section.) ### System diff --git a/crates/ruscker-admin/Cargo.toml b/crates/ruscker-admin/Cargo.toml index dfd633c0..54027ef9 100644 --- a/crates/ruscker-admin/Cargo.toml +++ b/crates/ruscker-admin/Cargo.toml @@ -84,6 +84,7 @@ serde = { workspace = true } serde_json = { workspace = true } serde_yaml_ng = { workspace = true } chrono = { workspace = true } +chrono-tz = { workspace = true } regex = { workspace = true } url = { workspace = true } uuid = { workspace = true } diff --git a/crates/ruscker-admin/assets/i18n/en/landing.ftl b/crates/ruscker-admin/assets/i18n/en/landing.ftl index ff03f2dd..f863feec 100644 --- a/crates/ruscker-admin/assets/i18n/en/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/en/landing.ftl @@ -993,7 +993,7 @@ admin-schedules-subtitle = Run an app's image to completion on a cron schedule ( admin-schedules-create = New schedule admin-schedules-spec = App admin-schedules-cron = Cron -admin-schedules-cron-help = Standard 5-field cron, UTC. Examples: "0 3 * * *" = every day at 03:00; "*/15 * * * *" = every 15 minutes. +admin-schedules-cron-help = Standard 5-field cron, in the server timezone. Examples: "0 3 * * *" = every day at 03:00; "*/15 * * * *" = every 15 minutes. admin-schedules-cmd = Command admin-schedules-cmd-help = One line per argument (argv). Empty = the app's own command (its container-cmd, else the image's CMD). admin-schedules-timeout = Timeout (minutes) diff --git a/crates/ruscker-admin/assets/i18n/es/landing.ftl b/crates/ruscker-admin/assets/i18n/es/landing.ftl index 443a03a2..6c5a5e4e 100644 --- a/crates/ruscker-admin/assets/i18n/es/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/es/landing.ftl @@ -993,7 +993,7 @@ admin-schedules-subtitle = Ejecuta la imagen de una app hasta terminar según un admin-schedules-create = Nueva programación admin-schedules-spec = App admin-schedules-cron = Cron -admin-schedules-cron-help = Cron estándar de 5 campos, en UTC. Ejemplos: "0 3 * * *" = cada día a las 03:00; "*/15 * * * *" = cada 15 minutos. +admin-schedules-cron-help = Cron estándar de 5 campos, en el huso horario del servidor. Ejemplos: "0 3 * * *" = cada día a las 03:00; "*/15 * * * *" = cada 15 minutos. admin-schedules-cmd = Comando admin-schedules-cmd-help = Un argumento por línea (argv). Vacío = el comando de la propia app (su container-cmd, si no el CMD de la imagen). admin-schedules-timeout = Timeout (minutos) diff --git a/crates/ruscker-admin/assets/i18n/fr/landing.ftl b/crates/ruscker-admin/assets/i18n/fr/landing.ftl index 5c6d3da3..d8980833 100644 --- a/crates/ruscker-admin/assets/i18n/fr/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/fr/landing.ftl @@ -993,7 +993,7 @@ admin-schedules-subtitle = Exécute l'image d'une app jusqu'au bout selon un hor admin-schedules-create = Nouvelle planification admin-schedules-spec = App admin-schedules-cron = Cron -admin-schedules-cron-help = Cron standard à 5 champs, en UTC. Exemples : "0 3 * * *" = chaque jour à 03:00 ; "*/15 * * * *" = toutes les 15 minutes. +admin-schedules-cron-help = Cron standard à 5 champs, dans le fuseau horaire du serveur. Exemples : "0 3 * * *" = chaque jour à 03:00 ; "*/15 * * * *" = toutes les 15 minutes. admin-schedules-cmd = Commande admin-schedules-cmd-help = Un argument par ligne (argv). Vide = la commande de l'app elle-même (son container-cmd, sinon le CMD de l'image). admin-schedules-timeout = Timeout (minutes) diff --git a/crates/ruscker-admin/assets/i18n/pt/landing.ftl b/crates/ruscker-admin/assets/i18n/pt/landing.ftl index e55fec0d..d93dfb2e 100644 --- a/crates/ruscker-admin/assets/i18n/pt/landing.ftl +++ b/crates/ruscker-admin/assets/i18n/pt/landing.ftl @@ -997,7 +997,7 @@ admin-schedules-subtitle = Executa a imagem de um app até o fim em um horário admin-schedules-create = Novo agendamento admin-schedules-spec = App admin-schedules-cron = Cron -admin-schedules-cron-help = Cron padrão de 5 campos, em UTC. Exemplos: "0 3 * * *" = todo dia às 03:00; "*/15 * * * *" = a cada 15 minutos. +admin-schedules-cron-help = Cron padrão de 5 campos, no fuso horário do servidor. Exemplos: "0 3 * * *" = todo dia às 03:00; "*/15 * * * *" = a cada 15 minutos. admin-schedules-cmd = Comando admin-schedules-cmd-help = Um argumento por linha (argv). Vazio = o comando do próprio app (o container-cmd, senão o CMD da imagem). admin-schedules-timeout = Timeout (minutos) diff --git a/crates/ruscker-admin/src/jobs.rs b/crates/ruscker-admin/src/jobs.rs index db2034a9..f5fca251 100644 --- a/crates/ruscker-admin/src/jobs.rs +++ b/crates/ruscker-admin/src/jobs.rs @@ -28,18 +28,43 @@ pub const TICK: Duration = Duration::from_secs(30); /// The next occurrence of `cron` strictly after `after`, or `None` /// for an unparseable expression (surfaced by the caller once — a bad /// expression must not wedge the tick). -fn next_occurrence(cron: &str, after: DateTime) -> Option> { +/// +/// The cron fields are matched against the **wall clock in `tz`** +/// (`server.timezone`, UTC when unset — #1042): an operator writing +/// `0 8 * * *` means eight in the morning where they live. Croner does +/// the matching on `start_time.naive_local()`, so handing it a +/// `DateTime` is all it takes; the result comes back in the same +/// zone and is converted to UTC for storage and comparison, which are +/// zone-free throughout. +/// +/// DST is croner's to resolve, and it does so sanely — verified, not +/// assumed, in `dst_transitions_neither_skip_nor_duplicate_a_run`: a +/// spring-forward gap fires at the transition instant (the run is not +/// lost) and a fall-back repeat fires only on the first of the two +/// (no duplicate run). +/// +/// Public so the Schedules page computes "next run" through the very +/// same function the scheduler fires on — a UI that disagreed with the +/// runner about when a job fires would be worse than no UI. +pub fn next_occurrence( + cron: &str, + after: DateTime, + tz: chrono_tz::Tz, +) -> Option> { let parsed: croner::Cron = cron.parse().ok()?; - parsed.find_next_occurrence(&after, false).ok() + parsed + .find_next_occurrence(&after.with_timezone(&tz), false) + .ok() + .map(|dt| dt.with_timezone(&Utc)) } /// Whether `schedule` is due at `now`: its cron has an occurrence in /// `(anchor, now]`, where the anchor is the last fire marker (or the /// schedule's creation, so a brand-new "every day at 03:00" waits for /// 03:00 instead of firing on creation). -fn is_due(schedule: &Schedule, now: DateTime) -> bool { +fn is_due(schedule: &Schedule, now: DateTime, tz: chrono_tz::Tz) -> bool { let anchor = schedule.last_run_at.unwrap_or(schedule.created_at); - match next_occurrence(&schedule.cron, anchor) { + match next_occurrence(&schedule.cron, anchor, tz) { Some(next) => next <= now, None => false, } @@ -116,8 +141,11 @@ async fn tick(state: &AppState) { } }; let now = Utc::now(); + // One zone for the whole pass, so two schedules in the same tick + // can't disagree about what "08:00" means. + let tz = state.config.server.effective_timezone(); for schedule in schedules.into_iter().filter(|s| s.enabled) { - if next_occurrence(&schedule.cron, now).is_none() { + if next_occurrence(&schedule.cron, now, tz).is_none() { tracing::warn!( schedule = schedule.id, spec = %schedule.spec_id, @@ -126,7 +154,7 @@ async fn tick(state: &AppState) { ); continue; } - if !is_due(&schedule, now) { + if !is_due(&schedule, now, tz) { continue; } // Claim BEFORE running — a crash mid-job must not double-fire, @@ -275,23 +303,126 @@ mod tests { fn due_when_an_occurrence_passed_since_the_anchor() { let now: DateTime = "2026-07-13T03:05:00Z".parse().unwrap(); // Daily at 03:00, last fired yesterday 03:00 → due. - assert!(is_due(&sched("0 3 * * *", Some("2026-07-12T03:00:30Z"), "2026-07-01T00:00:00Z"), now)); + assert!(is_due(&sched("0 3 * * *", Some("2026-07-12T03:00:30Z"), "2026-07-01T00:00:00Z"), now, chrono_tz::UTC)); // Already fired today at 03:00 → not due again. - assert!(!is_due(&sched("0 3 * * *", Some("2026-07-13T03:00:30Z"), "2026-07-01T00:00:00Z"), now)); + assert!(!is_due(&sched("0 3 * * *", Some("2026-07-13T03:00:30Z"), "2026-07-01T00:00:00Z"), now, chrono_tz::UTC)); // Brand new schedule created at 02:00 today → 03:00 passed → due. - assert!(is_due(&sched("0 3 * * *", None, "2026-07-13T02:00:00Z"), now)); + assert!(is_due(&sched("0 3 * * *", None, "2026-07-13T02:00:00Z"), now, chrono_tz::UTC)); // Brand new created at 04:00 → next is tomorrow → NOT due (no // fire-on-create). let later: DateTime = "2026-07-13T04:10:00Z".parse().unwrap(); - assert!(!is_due(&sched("0 3 * * *", None, "2026-07-13T04:00:00Z"), later)); + assert!(!is_due(&sched("0 3 * * *", None, "2026-07-13T04:00:00Z"), later, chrono_tz::UTC)); // Downtime over several occurrences collapses to one firing. - assert!(is_due(&sched("0 3 * * *", Some("2026-07-09T03:00:00Z"), "2026-07-01T00:00:00Z"), now)); + assert!(is_due(&sched("0 3 * * *", Some("2026-07-09T03:00:00Z"), "2026-07-01T00:00:00Z"), now, chrono_tz::UTC)); + } + + /// The whole point of #1042: `0 8 * * *` under `America/Recife` + /// means 08:00 THERE, i.e. 11:00 UTC. Firing at 08:00 UTC (05:00 + /// local) is the bug this fixes. + #[test] + fn cron_is_evaluated_in_the_configured_zone() { + let recife = chrono_tz::America::Recife; + let anchor: DateTime = "2026-07-12T12:00:00Z".parse().unwrap(); + assert_eq!( + next_occurrence("0 8 * * *", anchor, recife).unwrap().to_rfc3339(), + "2026-07-13T11:00:00+00:00" + ); + // Same expression, no zone configured → unchanged behaviour. + assert_eq!( + next_occurrence("0 8 * * *", anchor, chrono_tz::UTC).unwrap().to_rfc3339(), + "2026-07-13T08:00:00+00:00" + ); + + // And the due check follows: at 08:05 UTC the Recife job has NOT + // fired yet (it is 05:05 there); at 11:05 UTC it has. + let sched = sched("0 8 * * *", Some("2026-07-12T11:00:30Z"), "2026-07-01T00:00:00Z"); + let morning_utc: DateTime = "2026-07-13T08:05:00Z".parse().unwrap(); + let morning_local: DateTime = "2026-07-13T11:05:00Z".parse().unwrap(); + assert!(!is_due(&sched, morning_utc, recife)); + assert!(is_due(&sched, morning_local, recife)); + // Under UTC the same schedule is due at 08:05 — the old behaviour, + // which an upgrade must preserve for anyone who sets nothing. + assert!(is_due(&sched, morning_utc, chrono_tz::UTC)); + } + + /// A zone whose offset changes across the year must be read from the + /// zone database, not frozen: São Paulo is −03 in July and was −02 + /// under DST in January. (Brazil dropped DST in 2019, so the January + /// case is historical — which is exactly why a hardcoded offset + /// would be wrong and a zone name is right.) + #[test] + fn zone_offset_follows_the_date() { + let sp = chrono_tz::America::Sao_Paulo; + let winter: DateTime = "2026-07-01T00:00:00Z".parse().unwrap(); + assert_eq!( + next_occurrence("0 8 * * *", winter, sp).unwrap().to_rfc3339(), + "2026-07-01T11:00:00+00:00" + ); + let dst_era: DateTime = "2018-01-01T00:00:00Z".parse().unwrap(); + assert_eq!( + next_occurrence("0 8 * * *", dst_era, sp).unwrap().to_rfc3339(), + "2018-01-01T10:00:00+00:00" + ); + } + + + /// What croner ACTUALLY does across a DST transition, pinned here + /// because both plausible guesses are wrong and the answer decides + /// whether a nightly ETL silently skips or double-runs. Lisbon, + /// which still observes EU DST (Brazil dropped it in 2019). + #[test] + fn dst_transitions_neither_skip_nor_duplicate_a_run() { + let lisbon = chrono_tz::Europe::Lisbon; + + // Spring forward, 2026-03-29: 01:00 WET → 02:00 WEST, so a + // 01:30 local never happens that day. The run is NOT skipped — + // croner lands on the transition instant itself (01:00 UTC), + // i.e. the job fires as the clock jumps. + let before: DateTime = "2026-03-28T12:00:00Z".parse().unwrap(); + assert_eq!( + next_occurrence("30 1 * * *", before, lisbon).unwrap().to_rfc3339(), + "2026-03-29T01:00:00+00:00" + ); + + // Fall back, 2026-10-25: 02:00 WEST → 01:00 WET, so 01:30 local + // happens twice. Croner takes the FIRST (00:30 UTC) and then + // moves to the next day — the repeat does not fire a second run. + let fb: DateTime = "2026-10-24T12:00:00Z".parse().unwrap(); + assert_eq!( + next_occurrence("30 1 * * *", fb, lisbon).unwrap().to_rfc3339(), + "2026-10-25T00:30:00+00:00" + ); + let after_first: DateTime = "2026-10-25T00:45:00Z".parse().unwrap(); + assert_eq!( + next_occurrence("30 1 * * *", after_first, lisbon).unwrap().to_rfc3339(), + "2026-10-26T01:30:00+00:00", + "the second 01:30 must not fire a duplicate run" + ); + } + + /// Adopting a zone on an install that already has schedules can fire + /// one EXTRA run on the switch-over day: the fire marker is a UTC + /// instant, and re-reading the same expression on a westward clock + /// puts today's occurrence later than the marker. Pinned so the + /// behaviour is a documented consequence rather than a surprise — + /// it's why the docs tell operators to review their schedules after + /// setting `server.timezone`. + #[test] + fn adopting_a_zone_can_refire_on_the_switchover_day() { + // Fired today at 08:00 UTC under the old (UTC) behaviour… + let s = sched("0 8 * * *", Some("2026-07-29T08:00:00Z"), "2026-07-01T00:00:00Z"); + let noon: DateTime = "2026-07-29T12:00:00Z".parse().unwrap(); + // …under UTC it is done for the day… + assert!(!is_due(&s, noon, chrono_tz::UTC)); + // …but under Recife today's 08:00 local (11:00 UTC) is still + // ahead of the marker, so it runs once more. + assert!(is_due(&s, noon, chrono_tz::America::Recife)); } #[test] fn unparseable_cron_is_never_due() { let now = Utc::now(); - assert!(!is_due(&sched("not a cron", None, "2026-07-01T00:00:00Z"), now)); - assert!(next_occurrence("61 99 * * *", now).is_none()); + assert!(!is_due(&sched("not a cron", None, "2026-07-01T00:00:00Z"), now, chrono_tz::UTC)); + assert!(next_occurrence("61 99 * * *", now, chrono_tz::UTC).is_none()); } } diff --git a/crates/ruscker-admin/src/lib.rs b/crates/ruscker-admin/src/lib.rs index 64dd43e4..d91caeda 100644 --- a/crates/ruscker-admin/src/lib.rs +++ b/crates/ruscker-admin/src/lib.rs @@ -637,6 +637,11 @@ impl AdminServer { db = db_kind, specs = self.state.config.proxy.specs.len(), images_dir = ?self.images_dir, + // The scheduler's clock (#1042). Worth a line in the banner: + // an operator who set `timezone` in the wrong file or section + // gets a silent UTC fallback otherwise, and would only find + // out when a nightly job fires hours off. + timezone = self.state.config.server.effective_timezone().name(), "ruscker started — listening" ); // `into_make_service_with_connect_info` exposes the TCP peer diff --git a/crates/ruscker-admin/src/routes/admin/schedules_ui.rs b/crates/ruscker-admin/src/routes/admin/schedules_ui.rs index 0d0b123f..9fcf5c7b 100644 --- a/crates/ruscker-admin/src/routes/admin/schedules_ui.rs +++ b/crates/ruscker-admin/src/routes/admin/schedules_ui.rs @@ -44,10 +44,10 @@ struct ScheduleRow { /// First line of the argv override, or an em dash for "the spec's /// own command". cmd_summary: String, - /// `YYYY-MM-DD HH:MM UTC`, or an em dash when the schedule is - /// disabled or its cron no longer parses. + /// `YYYY-MM-DD HH:MM ` in the scheduler's timezone, or an em + /// dash when the schedule is disabled or its cron no longer parses. next_run: String, - /// `YYYY-MM-DD HH:MM UTC`, or an em dash before the first fire. + /// `YYYY-MM-DD HH:MM `, or an em dash before the first fire. last_run: String, enabled: bool, } @@ -82,6 +82,11 @@ struct SchedulesPage<'a> { kpi_jobs: i64, kpi_active: i64, kpi_recent_failures: i64, + /// The scheduler's zone, spelled out for the cron field's help text + /// (`America/Recife`, or `UTC` when unconfigured) — an operator + /// writing an expression shouldn't have to open `ruscker.yml` to + /// learn which clock it will be read in (#1042). + tz_label: String, flash: Option<&'static str>, flash_error: bool, } @@ -110,23 +115,35 @@ impl SchedulesPage<'_> { /// `2026-07-13T03:00:12Z` → `2026-07-13 03:00 UTC` — minute precision /// is all a cron UI needs. -fn fmt_utc(dt: &DateTime) -> String { - dt.format("%Y-%m-%d %H:%M UTC").to_string() +/// +/// Rendered in the SCHEDULER's zone (`server.timezone`, UTC when unset +/// — #1042), not the viewer's: these times are when the server will +/// fire a job, and the operator writing the cron expression needs to +/// read them in the same clock they wrote it in. That's the opposite +/// call from the admin tables (#1041), which are records of past +/// events and follow each viewer's browser. `%Z` labels the zone, so +/// an unconfigured install still reads exactly `… UTC` as before. +fn fmt_dt(dt: &DateTime, tz: chrono_tz::Tz) -> String { + dt.with_timezone(&tz).format("%Y-%m-%d %H:%M %Z").to_string() } /// The schedule's next occurrence, from the same anchor the scheduler /// uses ([`crate::jobs`]): the last fire marker, or creation for a /// schedule that never fired (no fire-on-create). `None` when the /// stored cron no longer parses. -fn next_run(schedule: &Schedule) -> Option> { +/// +/// Delegates to [`crate::jobs::next_occurrence`] instead of re-deriving +/// it, so the page can't drift from the runner — it used to hold its +/// own copy of the croner call, which would now have silently kept +/// computing in UTC while the scheduler moved to the configured zone. +fn next_run(schedule: &Schedule, tz: chrono_tz::Tz) -> Option> { let anchor = schedule.last_run_at.unwrap_or(schedule.created_at); - let parsed: croner::Cron = schedule.cron.parse().ok()?; - parsed.find_next_occurrence(&anchor, false).ok() + crate::jobs::next_occurrence(&schedule.cron, anchor, tz) } -fn schedule_row(s: Schedule) -> ScheduleRow { +fn schedule_row(s: Schedule, tz: chrono_tz::Tz) -> ScheduleRow { let next = if s.enabled { - next_run(&s).map(|dt| fmt_utc(&dt)) + next_run(&s, tz).map(|dt| fmt_dt(&dt, tz)) } else { None // a disabled schedule has no next fire }; @@ -136,7 +153,10 @@ fn schedule_row(s: Schedule) -> ScheduleRow { .and_then(|argv| argv.first().cloned()) .unwrap_or_else(|| "—".to_string()), next_run: next.unwrap_or_else(|| "—".to_string()), - last_run: s.last_run_at.map(|dt| fmt_utc(&dt)).unwrap_or_else(|| "—".to_string()), + last_run: s + .last_run_at + .map(|dt| fmt_dt(&dt, tz)) + .unwrap_or_else(|| "—".to_string()), id: s.id, spec_id: s.spec_id, cron: s.cron, @@ -153,10 +173,10 @@ fn fmt_duration(ms: Option) -> String { } } -fn run_view(r: RunRow) -> RunView { +fn run_view(r: RunRow, tz: chrono_tz::Tz) -> RunView { RunView { spec_id: r.spec_id, - started: fmt_utc(&r.started_at), + started: fmt_dt(&r.started_at, tz), status: r.status, exit_code: r.exit_code.map(|c| c.to_string()).unwrap_or_else(|| "—".to_string()), duration: fmt_duration(r.duration_ms), @@ -220,6 +240,10 @@ async fn index( let kpi_active = schedules.iter().filter(|schedule| schedule.enabled).count() as i64; let kpi_recent_failures = runs.iter().filter(|run| run.status != "ok").count() as i64; + // The scheduler's zone — every time on this page is a server firing + // time, so they all render in it (#1042). + let tz = state.config.server.effective_timezone(); + super::render(&SchedulesPage { locale: loc, theme, @@ -229,11 +253,12 @@ async fn index( nav_section: "schedules", role: admin.role, specs, - schedules: schedules.into_iter().map(schedule_row).collect(), - runs: runs.into_iter().map(run_view).collect(), + schedules: schedules.into_iter().map(|s| schedule_row(s, tz)).collect(), + runs: runs.into_iter().map(|r| run_view(r, tz)).collect(), kpi_jobs, kpi_active, kpi_recent_failures, + tz_label: tz.name().to_string(), flash, flash_error, }) @@ -421,21 +446,58 @@ mod tests { #[test] fn schedule_row_formats_next_and_last_run() { // Fired 2026-07-12 03:00 → next daily 03:00 is the 13th. - let row = schedule_row(sched("0 3 * * *", true, Some("2026-07-12T03:00:10Z"))); + let row = schedule_row( + sched("0 3 * * *", true, Some("2026-07-12T03:00:10Z")), + chrono_tz::UTC, + ); assert_eq!(row.next_run, "2026-07-13 03:00 UTC"); assert_eq!(row.last_run, "2026-07-12 03:00 UTC"); assert_eq!(row.cmd_summary, "Rscript"); // Disabled → no next fire; never fired → em-dash last run. - let row = schedule_row(sched("0 3 * * *", false, None)); + let row = schedule_row(sched("0 3 * * *", false, None), chrono_tz::UTC); assert_eq!(row.next_run, "—"); assert_eq!(row.last_run, "—"); // A stored cron that no longer parses can't show a next run. - let row = schedule_row(sched("not a cron", true, None)); + let row = schedule_row(sched("not a cron", true, None), chrono_tz::UTC); assert_eq!(row.next_run, "—"); } + /// With a zone configured, the page reports the firing time on the + /// operator's clock — and the STORED instant is unchanged, so this + /// is purely how it reads (#1042). `0 3 * * *` in Recife fires at + /// 06:00 UTC; the row must say 03:00, not 06:00, or the operator + /// can't tell whether their cron means what they wrote. + #[test] + fn schedule_row_renders_in_the_configured_zone() { + let row = schedule_row( + sched("0 3 * * *", true, Some("2026-07-12T06:00:10Z")), + chrono_tz::America::Recife, + ); + assert_eq!(row.next_run, "2026-07-13 03:00 -03"); + assert_eq!(row.last_run, "2026-07-12 03:00 -03"); + } + + /// The page's "next run" must be the very instant the scheduler + /// will fire on. Both go through `jobs::next_occurrence`, and this + /// pins them together: the same schedule, the same zone, one answer. + #[test] + fn page_next_run_matches_the_scheduler() { + let s = sched("0 8 * * *", true, Some("2026-07-12T11:00:00Z")); + let tz = chrono_tz::America::Recife; + let anchor = s.last_run_at.unwrap(); + assert_eq!( + next_run(&s, tz), + crate::jobs::next_occurrence("0 8 * * *", anchor, tz) + ); + // …and that instant is 08:00 local = 11:00 UTC, not 08:00 UTC. + assert_eq!( + next_run(&s, tz).unwrap().to_rfc3339(), + "2026-07-13T11:00:00+00:00" + ); + } + #[test] fn durations_format_human_readable() { assert_eq!(fmt_duration(None), "—"); diff --git a/crates/ruscker-admin/templates/admin/schedules.html b/crates/ruscker-admin/templates/admin/schedules.html index 80780d74..4b7cfbdc 100644 --- a/crates/ruscker-admin/templates/admin/schedules.html +++ b/crates/ruscker-admin/templates/admin/schedules.html @@ -49,7 +49,10 @@

{{ self.t("admin-schedules-title" - {{ self.t("admin-schedules-cron-help") }} + {# The zone itself is data, not a translatable string — appended + so the operator sees which clock their expression will be read + in without having to open ruscker.yml (#1042). #} + {{ self.t("admin-schedules-cron-help") }} ({{ tz_label }})