From 32e7420d89dfb68e32ecdb4ae492a0e6ceb4f8b8 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 25 Aug 2026 09:05:06 +0000 Subject: [PATCH] fix: wire status page and incident delete, stop promising a name we do not publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- web/app/(app)/status-pages/[pageId]/page.tsx | 142 ++++++++++++++++++- web/app/(app)/status-pages/page.tsx | 2 +- 2 files changed, 138 insertions(+), 6 deletions(-) diff --git a/web/app/(app)/status-pages/[pageId]/page.tsx b/web/app/(app)/status-pages/[pageId]/page.tsx index 96e297c..a26fa2f 100644 --- a/web/app/(app)/status-pages/[pageId]/page.tsx +++ b/web/app/(app)/status-pages/[pageId]/page.tsx @@ -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({

Components

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.

+ setOpen(false)} + onConfirm={() => mutate()} + body={ + <> +

+ {incident.title} is removed from every + page it was published to, along with its updates. +

+

+ To leave the record standing but close it out, mark it resolved instead — the public page + files a resolved incident under its history. +

+ + } + /> + + ); +} + function IncidentsPanel({ pageId, monitors }: { pageId: string; monitors: Monitor[] }) { const { data: incidents, @@ -661,6 +736,7 @@ function IncidentsPanel({ pageId, monitors }: { pageId: string; monitors: Monito Edit )} + ))} @@ -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 (
@@ -750,9 +856,35 @@ export default function StatusPageEditorPage() { +
+ setConfirmDelete(false)} + onConfirm={() => deletePage()} + body={ + <> +

+ {publicUrl(pageId)} stops + resolving, and the page's sections and authored incidents go with it. + Monitors and their history are untouched. +

+

+ To take it off the internet without losing the work, un-publish it in Details + instead. +

+ + } + /> + {sectionError && (
{sectionError} diff --git a/web/app/(app)/status-pages/page.tsx b/web/app/(app)/status-pages/page.tsx index 1c3849c..7770a32 100644 --- a/web/app/(app)/status-pages/page.tsx +++ b/web/app/(app)/status-pages/page.tsx @@ -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" />

- {`Becomes ${publicUrl(pageId || "

")}. Fixed once created lowercase letters, numbers and hyphens, 3-40 characters.`} + {`Becomes ${publicUrl(pageId || "
")}. Fixed once created — lowercase letters, numbers and hyphens, 3-40 characters.`}

{touched && pageId.length > 0 && !idValid &&

Not a valid page address.

}