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.
)}
+
))}
@@ -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() {
save()}>
Save changes
+ setConfirmDelete(true)}>
+ Delete page
+