fix: wire status page and incident delete, stop promising a name we do not publish

- The display-name placeholder showed the monitor's own name, reading as "leave
  this blank and we will use it". The server deliberately does the opposite: a
  blank `display_name` publishes the raw monitor id, because publishing an
  internal name has to be a decision. The placeholder now says "Public name
  (required)" and Save is refused until every component has one, so nobody adds
  five monitors and discovers five UUIDs on their public page. The server
  fallback is unchanged.
- `deleteStatusPage` and `deleteStatusIncident` existed in the api client and
  were wired to nothing, and the page address is immutable — delete was the
  only correction for a typo and there was no way to reach it. The editor
  header gains a typed-confirmation Delete page, and each incident row a
  confirmed delete, both on the existing ConfirmDialog.
- The create modal's address hint had lost its em dash and read as a broken
  sentence.
This commit is contained in:
2026-08-25 09:05:06 +00:00
parent 7e1d67dba4
commit 32e7420d89
2 changed files with 138 additions and 6 deletions
+137 -5
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import {
api,
@@ -11,7 +11,17 @@ import {
StatusPage,
StatusPageSection,
} from "@/lib/api";
import { AsyncBoundary, Badge, Button, Card, CenteredSpinner, Modal, useToast } from "@/components/ui";
import {
AsyncBoundary,
Badge,
Button,
Card,
CenteredSpinner,
ConfirmDialog,
friendlyMessage,
Modal,
useToast,
} from "@/components/ui";
import { relativeTime } from "@/components/monitors/MonitorVisuals";
/*
@@ -71,6 +81,21 @@ function unnamedSectionIndex(draft: Draft): number {
return draft.sections.findIndex((s) => s.name.trim().length === 0);
}
/*
* A blank display_name does NOT fall back to the monitor's name on the server —
* it publishes the raw monitor UUID, deliberately, because publishing an
* internal name has to be a decision rather than a default. So the editor
* refuses to save one instead of letting an operator add five monitors and
* discover five UUIDs on their public page.
*/
function unnamedComponent(draft: Draft): { section: number; entry: number } | null {
for (let si = 0; si < draft.sections.length; si++) {
const ei = draft.sections[si].entries.findIndex((e) => (e.display_name ?? "").trim().length === 0);
if (ei >= 0) return { section: si, entry: ei };
}
return null;
}
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";
@@ -213,7 +238,7 @@ function SectionEditor({
entries[i] = { ...entry, display_name: e.target.value };
onChange({ ...section, entries });
}}
placeholder={monitor?.name ?? entry.monitor_id}
placeholder="Public name (required)"
aria-label={`Public name for ${monitor?.name ?? entry.monitor_id}`}
className={inputClass}
/>
@@ -267,6 +292,7 @@ function ComponentsPanel({
<h2 className="text-base font-bold tracking-[-0.02em] text-text-primary">Components</h2>
<p className="mt-0.5 text-sm text-text-secondary">
Monitors grouped for the public page. Grouping here is separate from the groups on Monitors.
Each component needs a public name monitor names are never published for you.
</p>
</div>
<Button
@@ -579,6 +605,55 @@ function incidentMeta(inc: StatusIncident): string {
return `Opened ${relativeTime(inc.started_at)} · ${inc.updates?.length ?? 0} update${inc.updates?.length === 1 ? "" : "s"}`;
}
function DeleteIncidentButton({ pageId, incident }: { pageId: string; incident: StatusIncident }) {
const queryClient = useQueryClient();
const toast = useToast();
const [open, setOpen] = useState(false);
const { mutate, isPending, error } = useMutation({
mutationFn: () => api.deleteStatusIncident(pageId, incident.incident_id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["status-pages", pageId, "incidents"] });
setOpen(false);
toast.success("Incident deleted.");
},
// The dialog stays open and shows the failure, as the monitor delete does.
});
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="font-mono text-xs text-text-tertiary hover:text-danger"
>
delete
</button>
<ConfirmDialog
open={open}
title="Delete incident"
confirmLabel="Delete"
loading={isPending}
error={error ? friendlyMessage(error) : null}
onClose={() => setOpen(false)}
onConfirm={() => mutate()}
body={
<>
<p>
<span className="font-mono text-text-primary">{incident.title}</span> is removed from every
page it was published to, along with its updates.
</p>
<p>
To leave the record standing but close it out, mark it resolved instead the public page
files a resolved incident under its history.
</p>
</>
}
/>
</>
);
}
function IncidentsPanel({ pageId, monitors }: { pageId: string; monitors: Monitor[] }) {
const {
data: incidents,
@@ -661,6 +736,7 @@ function IncidentsPanel({ pageId, monitors }: { pageId: string; monitors: Monito
Edit
</Button>
)}
<DeleteIncidentButton pageId={pageId} incident={inc} />
</div>
</div>
))}
@@ -676,7 +752,9 @@ export default function StatusPageEditorPage() {
const params = useParams();
const pageId = params.pageId as string;
const queryClient = useQueryClient();
const router = useRouter();
const toast = useToast();
const [confirmDelete, setConfirmDelete] = useState(false);
const {
data: page,
@@ -703,7 +781,13 @@ export default function StatusPageEditorPage() {
}, [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 unnamed = draft ? unnamedComponent(draft) : null;
const sectionError =
unnamedIndex >= 0
? `Section ${unnamedIndex + 1} needs a name before this can be saved.`
: unnamed
? `Component ${unnamed.entry + 1} in section ${unnamed.section + 1} needs a public name before this can be saved. A blank name publishes the monitor's id, not its name.`
: null;
const {
mutate: save,
@@ -712,7 +796,9 @@ 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.");
if (unnamedSectionIndex(draft) >= 0 || unnamedComponent(draft)) {
throw new Error(sectionError ?? "A section and every component needs a name.");
}
return api.updateStatusPage(pageId, draftToInput(draft));
},
onSuccess: (updated) => {
@@ -723,6 +809,26 @@ export default function StatusPageEditorPage() {
},
});
/*
* Delete is the only correction for a typo'd page address: the address is
* immutable by design, because it is a URL handed to customers. Typed, like
* the monitor and secret-group deletes, and for the same reason — the page,
* its components and its authored incidents go together and there is
* nothing to restore them from.
*/
const {
mutate: deletePage,
isPending: isDeleting,
error: deleteError,
} = useMutation({
mutationFn: () => api.deleteStatusPage(pageId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["status-pages"] });
toast.success(`Deleted ${page?.title ?? "the status page"}.`);
router.push("/status-pages");
},
});
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">
@@ -750,9 +856,35 @@ export default function StatusPageEditorPage() {
<Button variant="primary" loading={isSaving} disabled={!!sectionError} onClick={() => save()}>
Save changes
</Button>
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
Delete page
</Button>
</div>
</div>
<ConfirmDialog
open={confirmDelete}
title="Delete status page"
requireTyped={pageId}
loading={isDeleting}
error={deleteError ? friendlyMessage(deleteError) : null}
onClose={() => setConfirmDelete(false)}
onConfirm={() => deletePage()}
body={
<>
<p>
<span className="font-mono text-text-primary">{publicUrl(pageId)}</span> stops
resolving, and the page&apos;s sections and authored incidents go with it.
Monitors and their history are untouched.
</p>
<p>
To take it off the internet without losing the work, un-publish it in Details
instead.
</p>
</>
}
/>
{sectionError && (
<div className="mb-5 rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger" role="alert">
{sectionError}
+1 -1
View File
@@ -107,7 +107,7 @@ function CreateStatusPageModal({ onClose }: { onClose: () => void }) {
className="w-full rounded border border-border bg-surface-2 px-3 py-2 font-mono text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
<p className="mt-1 text-xs text-text-tertiary">
{`Becomes ${publicUrl(pageId || "<address>")}. Fixed once created lowercase letters, numbers and hyphens, 3-40 characters.`}
{`Becomes ${publicUrl(pageId || "<address>")}. Fixed once created lowercase letters, numbers and hyphens, 3-40 characters.`}
</p>
{touched && pageId.length > 0 && !idValid && <p className="mt-1 text-xs text-danger">Not a valid page address.</p>}
</div>