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.

}