fix: status page editor error handling, maintenance validation and UTC display

This commit is contained in:
2026-08-25 08:40:05 +00:00
parent 1452928b75
commit fa67d839cd
+82 -47
View File
@@ -59,12 +59,18 @@ function draftToInput(draft: Draft): Partial<StatusPage> {
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({
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Ends</label>
<input type="datetime-local" value={scheduledEnd} onChange={(e) => setScheduledEnd(e.target.value)} className={inputClass} />
</div>
{scheduleError && <p className="col-span-2 text-xs text-danger">{scheduleError}</p>}
</div>
)}
@@ -467,7 +490,7 @@ function IncidentFormModal({
<Button variant="secondary" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button variant="primary" loading={isPending} disabled={!title.trim()} onClick={() => save()}>
<Button variant="primary" loading={isPending} disabled={!title.trim() || !!scheduleError} onClick={() => save()}>
{initial ? "Save" : kind === "incident" ? "Open incident" : "Schedule maintenance"}
</Button>
</div>
@@ -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 (
<div className="p-4 sm:p-6 lg:p-8">
<CenteredSpinner />
</div>
);
}
if (error || !page) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<p className="text-sm text-danger">{(error as Error)?.message ?? "Status page not found."}</p>
</div>
);
}
return (
<div className="p-4 sm:p-6 lg:p-8">
<Link href="/status-pages" className="mb-2.5 inline-block text-sm text-text-secondary hover:text-text-primary">
All status pages
</Link>
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-text-primary">{page.title}</h1>
<PublicUrlLine pageId={pageId} />
</div>
<div className="flex gap-2">
<Button href={`/status/${pageId}`} variant="secondary" target="_blank" rel="noreferrer">
View page
</Button>
<Button variant="primary" loading={isSaving} onClick={() => save()}>
Save changes
</Button>
</div>
</div>
{/* 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. */}
<AsyncBoundary isLoading={isLoading} error={error} onRetry={() => refetch()}>
{!page || !draft ? (
<CenteredSpinner />
) : (
<>
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-text-primary">{page.title}</h1>
<PublicUrlLine pageId={pageId} />
</div>
<div className="flex gap-2">
<Button href={`/status/${pageId}`} variant="secondary" target="_blank" rel="noreferrer">
View page
</Button>
<Button variant="primary" loading={isSaving} disabled={!!sectionError} onClick={() => save()}>
Save changes
</Button>
</div>
</div>
{saveError && (
<div className="mb-5 rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger" role="alert">
{(saveError as Error).message}
</div>
)}
{sectionError && (
<div className="mb-5 rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger" role="alert">
{sectionError}
</div>
)}
<div className="space-y-5">
<DetailsPanel draft={draft} setDraft={setDraft} pageId={pageId} />
<ComponentsPanel draft={draft} setDraft={setDraft} monitors={monitors ?? []} />
<IncidentsPanel pageId={pageId} monitors={monitors ?? []} />
</div>
{saveError && (
<div className="mb-5 rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger" role="alert">
{(saveError as Error).message}
</div>
)}
<div className="space-y-5">
<DetailsPanel draft={draft} setDraft={setDraft} pageId={pageId} />
<ComponentsPanel draft={draft} setDraft={setDraft} monitors={monitors ?? []} />
<IncidentsPanel pageId={pageId} monitors={monitors ?? []} />
</div>
</>
)}
</AsyncBoundary>
</div>
);
}