From fa67d839cdfa034de88b75d7538753caf7638023 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 25 Aug 2026 08:40:05 +0000 Subject: [PATCH] fix: status page editor error handling, maintenance validation and UTC display --- web/app/(app)/status-pages/[pageId]/page.tsx | 129 ++++++++++++------- 1 file changed, 82 insertions(+), 47 deletions(-) diff --git a/web/app/(app)/status-pages/[pageId]/page.tsx b/web/app/(app)/status-pages/[pageId]/page.tsx index 1f97260..96e297c 100644 --- a/web/app/(app)/status-pages/[pageId]/page.tsx +++ b/web/app/(app)/status-pages/[pageId]/page.tsx @@ -59,12 +59,18 @@ function draftToInput(draft: Draft): Partial { level: draft.banner.text.trim() ? draft.banner.level || "info" : undefined, text: draft.banner.text.trim() || undefined, }, - sections: draft.sections - .filter((s) => s.name.trim().length > 0) - .map((s) => ({ name: s.name.trim(), entries: s.entries })), + sections: draft.sections.map((s) => ({ name: s.name.trim(), entries: s.entries })), }; } +// A blank-named section used to be dropped silently on save -- filtered out +// here and the reseed from the server then made it vanish with no message. +// Finding it instead lets the caller block the save and name which section +// needs a name, rather than discarding an operator's work. +function unnamedSectionIndex(draft: Draft): number { + return draft.sections.findIndex((s) => s.name.trim().length === 0); +} + const inputClass = "w-full rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"; @@ -354,6 +360,22 @@ function IncidentFormModal({ const [scheduledStart, setScheduledStart] = useState(toLocalInput(initial?.scheduled_start)); const [scheduledEnd, setScheduledEnd] = useState(toLocalInput(initial?.scheduled_end)); + // Mirrors services.validateIncident's maintenance rule (server side is not + // reachable client-side, so this is a duplicate that must stay in sync with + // it): a maintenance window needs both timestamps, and the end must be + // strictly after the start. Named per-rule so the message says which one + // failed rather than a generic "invalid". + const scheduleError = + kind === "maintenance" + ? !scheduledStart + ? "A start time is required." + : !scheduledEnd + ? "An end time is required." + : new Date(scheduledEnd).getTime() <= new Date(scheduledStart).getTime() + ? "The end time must be after the start time." + : null + : null; + const { mutate: save, isPending, @@ -442,6 +464,7 @@ function IncidentFormModal({ setScheduledEnd(e.target.value)} className={inputClass} /> + {scheduleError &&

{scheduleError}

} )} @@ -467,7 +490,7 @@ function IncidentFormModal({ - @@ -540,9 +563,14 @@ function incidentMeta(inc: StatusIncident): string { if (inc.kind === "maintenance" && inc.scheduled_start) { const start = new Date(inc.scheduled_start); const end = inc.scheduled_end ? new Date(inc.scheduled_end) : null; - const date = start.toLocaleDateString(undefined, { day: "numeric", month: "short" }); - const startTime = start.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); - const endTime = end ? end.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) : null; + // Explicitly UTC, matching the label: toLocale*(undefined, ...) renders + // in the viewer's own zone, which made the hardcoded "UTC" suffix wrong + // for anyone not on it (a London summer viewer read 01:00-03:00 UTC as + // "02:00-04:00 UTC"). timeZone: "UTC" keeps the numbers honest instead + // of dropping the label. + const date = start.toLocaleDateString(undefined, { day: "numeric", month: "short", timeZone: "UTC" }); + const startTime = start.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", timeZone: "UTC" }); + const endTime = end ? end.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", timeZone: "UTC" }) : null; return `${date}, ${startTime}${endTime ? `–${endTime}` : ""} UTC`; } if (inc.status === "resolved" && inc.resolved_at) { @@ -654,6 +682,7 @@ export default function StatusPageEditorPage() { data: page, isLoading, error, + refetch, } = useQuery({ queryKey: ["status-pages", pageId], queryFn: () => api.getStatusPage(pageId), @@ -673,6 +702,9 @@ export default function StatusPageEditorPage() { if (page && !draft) setDraft(draftFromPage(page)); }, [page, draft]); + const unnamedIndex = draft ? unnamedSectionIndex(draft) : -1; + const sectionError = unnamedIndex >= 0 ? `Section ${unnamedIndex + 1} needs a name before this can be saved.` : null; + const { mutate: save, isPending: isSaving, @@ -680,6 +712,7 @@ export default function StatusPageEditorPage() { } = useMutation({ mutationFn: () => { if (!draft) throw new Error("nothing to save"); + if (unnamedSectionIndex(draft) >= 0) throw new Error(sectionError ?? "A section needs a name."); return api.updateStatusPage(pageId, draftToInput(draft)); }, onSuccess: (updated) => { @@ -690,54 +723,56 @@ export default function StatusPageEditorPage() { }, }); - if (isLoading || !draft) { - return ( -
- -
- ); - } - - if (error || !page) { - return ( -
-

{(error as Error)?.message ?? "Status page not found."}

-
- ); - } - return (
← All status pages -
-
-

{page.title}

- -
-
- - -
-
+ {/* error is checked before the draft-seeding gap below, matching the + AsyncBoundary pattern the list page and IncidentsPanel already use -- + a 404, a role/licence refusal or a network failure gets a message and + a retry instead of an endless spinner. */} + refetch()}> + {!page || !draft ? ( + + ) : ( + <> +
+
+

{page.title}

+ +
+
+ + +
+
- {saveError && ( -
- {(saveError as Error).message} -
- )} + {sectionError && ( +
+ {sectionError} +
+ )} -
- - - -
+ {saveError && ( +
+ {(saveError as Error).message} +
+ )} + +
+ + + +
+ + )} +
); }