diff --git a/web/src/components/alerts/steps/QueryConfig.spec.ts b/web/src/components/alerts/steps/QueryConfig.spec.ts index 175d06c4d8..0536cb19a3 100644 --- a/web/src/components/alerts/steps/QueryConfig.spec.ts +++ b/web/src/components/alerts/steps/QueryConfig.spec.ts @@ -175,6 +175,13 @@ vi.mock("@/utils/zincutils", () => ({ }), isAboveMinRefreshInterval: vi.fn(() => true), describeCron: vi.fn((cron: string) => `Every ${cron}`), + resolveBrowserTimezone: vi.fn((tz: string) => { + if (typeof tz === "string" && tz.toLowerCase().startsWith("browser time")) { + const inner = tz.match(/\(([^)]+)\)/)?.[1]?.trim(); + return inner || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } + return tz; + }), })); describe("QueryConfig.vue", () => { @@ -318,6 +325,33 @@ describe("QueryConfig.vue", () => { } }); + describe("Timezone Options", () => { + it("lists 'Browser Time' and 'UTC' shortcuts at the top of the options", () => { + const options = wrapper.vm.filteredTimezones; + expect(options[0].startsWith("Browser Time (")).toBe(true); + expect(options[1]).toBe("UTC"); + expect(options.length).toBeGreaterThan(2); + }); + + it("onCronTimezoneChange stores 'UTC' unchanged", async () => { + wrapper.vm.onCronTimezoneChange("UTC"); + await nextTick(); + expect(wrapper.vm.cronTimezone).toBe("UTC"); + }); + + it("onCronTimezoneChange passes a raw IANA zone through unchanged", async () => { + wrapper.vm.onCronTimezoneChange("Asia/Kolkata"); + await nextTick(); + expect(wrapper.vm.cronTimezone).toBe("Asia/Kolkata"); + }); + + it("onCronTimezoneChange resolves a 'Browser Time ()' pick to the inner IANA zone", async () => { + wrapper.vm.onCronTimezoneChange("Browser Time (America/Los_Angeles)"); + await nextTick(); + expect(wrapper.vm.cronTimezone).toBe("America/Los_Angeles"); + }); + }); + describe("Initialization", () => { it("should mount component successfully", () => { expect(wrapper.exists()).toBe(true); diff --git a/web/src/components/alerts/steps/QueryConfig.vue b/web/src/components/alerts/steps/QueryConfig.vue index 7cc50f1422..798880f41d 100644 --- a/web/src/components/alerts/steps/QueryConfig.vue +++ b/web/src/components/alerts/steps/QueryConfig.vue @@ -1620,6 +1620,7 @@ import { isAboveMinRefreshInterval, describeCron, getImageURL, + resolveBrowserTimezone, } from "@/utils/zincutils"; import hljs from "highlight.js/lib/core"; import sql from "highlight.js/lib/languages/sql"; @@ -2559,13 +2560,16 @@ export default defineComponent({ // already seeds `timezone: "UTC"`, so the control is never blank anyway. const initTimezones = () => { try { + const browserTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; // @ts-ignore - if (typeof Intl !== "undefined" && typeof Intl.supportedValuesOf === "function") { - // @ts-ignore - filteredTimezones.value = Intl.supportedValuesOf("timeZone"); - } else { - filteredTimezones.value = [cronTimezone.value || "UTC"]; - } + const zones: string[] = + typeof Intl !== "undefined" && typeof Intl.supportedValuesOf === "function" + ? // @ts-ignore + Intl.supportedValuesOf("timeZone") + : [cronTimezone.value || "UTC"]; + // Convenience shortcuts first (matching the reports picker), then every + // IANA zone. This only populates OPTIONS — it must not seed cronTimezone. + filteredTimezones.value = [`Browser Time (${browserTz})`, "UTC", ...zones]; } catch { filteredTimezones.value = ["UTC"]; } @@ -2673,7 +2677,10 @@ export default defineComponent({ const onCronTimezoneChange = (value: any) => { isUserTriggerChange.value = true; - cronTimezone.value = value; + // "Browser Time ()" is a display-only shortcut; persist the resolved + // IANA zone so trigger_condition.timezone stays a value the backend can + // parse (storing the raw label produces a NaN tz_offset). + cronTimezone.value = resolveBrowserTimezone(value); validateCron(); emitTriggerUpdate(); }; diff --git a/web/src/composables/useAlertForm.ts b/web/src/composables/useAlertForm.ts index 7249df61a1..e94b6496a3 100644 --- a/web/src/composables/useAlertForm.ts +++ b/web/src/composables/useAlertForm.ts @@ -46,6 +46,7 @@ import { smartDecodeVrlFunction, isValidResourceName, getTimezonesByOffset, + resolveBrowserTimezone, } from "@/utils/zincutils"; import { convertDateToTimestamp } from "@/utils/date"; import { generateSqlQuery } from "@/utils/alerts/alertQueryBuilder"; @@ -2107,11 +2108,16 @@ export function useAlertForm(props: AlertFormProps, emit: AlertFormEmit) { const minutes = String(now.getMinutes()).padStart(2, "0"); const time = `${hours}:${minutes}`; - const convertedDateTime = convertDateToTimestamp( - date, - time, - formData.value.trigger_condition.timezone, + // Resolve any lingering "Browser Time ()" label (e.g. an existing + // alert opened from an older release) to a plain IANA zone before deriving + // the offset — otherwise convertDateToTimestamp returns NaN and tz_offset + // serializes to null in the saved payload. + const resolvedTimezone = resolveBrowserTimezone( + formData.value?.trigger_condition?.timezone ?? "", ); + setF("trigger_condition.timezone", resolvedTimezone); + + const convertedDateTime = convertDateToTimestamp(date, time, resolvedTimezone); setF("tz_offset", convertedDateTime.offset); } @@ -2341,6 +2347,11 @@ export function useAlertForm(props: AlertFormProps, emit: AlertFormEmit) { // Resolved async AFTER the form.reset below → setF in the .then. pendingTimezoneOffset = data.tz_offset; } + } else { + // Heal legacy alerts (e.g. created on older releases) that persisted a + // "Browser Time ()" label — resolve it to a plain IANA zone so the + // picker shows a valid value and the save path computes a real offset. + data.trigger_condition.timezone = resolveBrowserTimezone(data.trigger_condition.timezone); } if (data.query_condition.vrl_function) { diff --git a/web/src/utils/timezone.spec.ts b/web/src/utils/timezone.spec.ts index 76de5de11f..12772c211d 100644 --- a/web/src/utils/timezone.spec.ts +++ b/web/src/utils/timezone.spec.ts @@ -40,6 +40,7 @@ import { localTimeSelectedTimezoneUTCTime, getLocalTime, convertDateToTimestamp, + resolveBrowserTimezone, getTimezoneOffset, convertTimeFromMicroToMilli, convertTimeFromNsToMs, @@ -186,6 +187,60 @@ describe("convertDateToTimestamp", () => { }); }); +// --------------------------------------------------------------------------- +// resolveBrowserTimezone +// --------------------------------------------------------------------------- + +describe("resolveBrowserTimezone", () => { + it("returns a raw IANA zone unchanged", () => { + expect(resolveBrowserTimezone("America/Los_Angeles")).toBe("America/Los_Angeles"); + }); + + it("returns 'UTC' unchanged", () => { + expect(resolveBrowserTimezone("UTC")).toBe("UTC"); + }); + + it("resolves a 'Browser Time ()' label to the inner IANA zone", () => { + expect(resolveBrowserTimezone("Browser Time (America/Los_Angeles)")).toBe( + "America/Los_Angeles", + ); + }); + + it("resolves a legacy label saved on another machine (rc9 update case)", () => { + expect(resolveBrowserTimezone("Browser Time (Asia/Kolkata)")).toBe("Asia/Kolkata"); + }); + + it("matches the label prefix regardless of case", () => { + expect(resolveBrowserTimezone("browser time (Asia/Kolkata)")).toBe("Asia/Kolkata"); + expect(resolveBrowserTimezone("BROWSER TIME (Asia/Kolkata)")).toBe("Asia/Kolkata"); + }); + + it("trims whitespace inside the label parentheses", () => { + expect(resolveBrowserTimezone("Browser Time ( Asia/Kolkata )")).toBe("Asia/Kolkata"); + }); + + it("leaves a value that only contains (not starts with) the label untouched", () => { + expect(resolveBrowserTimezone("Not Browser Time (Asia/Kolkata)")).toBe( + "Not Browser Time (Asia/Kolkata)", + ); + }); + + it("never returns a 'Browser Time' label for a malformed value", () => { + const result = resolveBrowserTimezone("Browser Time"); + expect(result.toLowerCase().startsWith("browser time")).toBe(false); + expect(result).toBeTruthy(); + }); + + it("returns an empty string unchanged", () => { + expect(resolveBrowserTimezone("")).toBe(""); + }); + + it("passes a null/undefined value through without throwing", () => { + expect(resolveBrowserTimezone(null as any)).toBeNull(); + expect(resolveBrowserTimezone(undefined as any)).toBeUndefined(); + }); +}); + // --------------------------------------------------------------------------- // getTimezoneOffset // --------------------------------------------------------------------------- diff --git a/web/src/utils/timezone.ts b/web/src/utils/timezone.ts index fefef729f2..14adbc36cb 100644 --- a/web/src/utils/timezone.ts +++ b/web/src/utils/timezone.ts @@ -89,6 +89,21 @@ export const getLocalTime = (datetime: string) => { } }; +/** + * "Browser Time ()" is a display-only label that some schedule pickers + * historically persisted (e.g. alerts created on older releases). It is NOT a + * valid IANA zone, so passing it to Luxon/the backend yields a NaN tz_offset. + * Resolve it to the plain IANA zone named inside the parentheses. Any other + * value (a real IANA zone, "UTC", empty) is returned unchanged. + */ +export const resolveBrowserTimezone = (timezone: string): string => { + if (typeof timezone === "string" && timezone.toLowerCase().startsWith("browser time")) { + const inner = timezone.match(/\(([^)]+)\)/)?.[1]?.trim(); + return inner || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + } + return timezone; +}; + export const convertDateToTimestamp = (date: string, time: string, timezone: string) => { const browserTime = "Browser Time (" + Intl.DateTimeFormat().resolvedOptions().timeZone + ")";