fix: status page editor error handling, maintenance validation and UTC display
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user