feat: Updated affected components on status page incidents
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Canceled after 6m59s

This commit is contained in:
2026-08-25 14:56:50 +00:00
parent 3e4865884c
commit c440b59b93
4 changed files with 139 additions and 14 deletions
+65 -13
View File
@@ -96,6 +96,40 @@ function unnamedComponent(draft: Draft): { section: number; entry: number } | nu
return null;
}
/*
* The components an incident may name, in page order.
*
* An incident's affected components are the PAGE's components, not the fleet's
* monitors: naming a monitor the page never listed publishes a machine the page
* deliberately does not, which is the leak assembleSnapshot exists to prevent,
* reached from the authoring side. services.checkAffectedOnPages refuses it —
* this is what stops an operator getting that far.
*
* It reads the SAVED page rather than the draft. A component added in the
* editor and not yet saved is not on the page, and offering it would produce an
* incident the server refuses.
*
* The label is the per-page display name, which is the name the reader will see
* — the monitor's own name is internal and may differ.
*/
interface PageComponent {
monitorId: string;
label: string;
}
function pageComponents(page: StatusPage | undefined): PageComponent[] {
const seen = new Set<string>();
const out: PageComponent[] = [];
for (const section of page?.sections ?? []) {
for (const entry of section.entries) {
if (seen.has(entry.monitor_id)) continue;
seen.add(entry.monitor_id);
out.push({ monitorId: entry.monitor_id, label: (entry.display_name ?? "").trim() || entry.monitor_id });
}
}
return out;
}
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";
@@ -365,13 +399,13 @@ function fromLocalInput(s: string): string | undefined {
function IncidentFormModal({
pageId,
kind,
monitors,
components,
initial,
onClose,
}: {
pageId: string;
kind: "incident" | "maintenance";
monitors: Monitor[];
components: PageComponent[];
initial?: StatusIncident;
onClose: () => void;
}) {
@@ -383,6 +417,21 @@ function IncidentFormModal({
const [impact, setImpact] = useState(initial?.impact ?? "minor");
const [status, setStatus] = useState(initial?.status ?? statuses[0]);
const [affected, setAffected] = useState<string[]>(initial?.affected_monitors ?? []);
/*
* The page's components, plus any this incident already names that have
* since been removed from the page. The server refuses to save one of those,
* so hiding it would leave an incident that could not be edited at all and
* no way to see why. Shown, flagged, and one click from being dropped.
*/
const selectable = useMemo(() => {
const rows = components.map((c) => ({ ...c, stale: false }));
const known = new Set(components.map((c) => c.monitorId));
for (const id of initial?.affected_monitors ?? []) {
if (!known.has(id)) rows.push({ monitorId: id, label: id, stale: true });
}
return rows;
}, [components, initial]);
const [scheduledStart, setScheduledStart] = useState(toLocalInput(initial?.scheduled_start));
const [scheduledEnd, setScheduledEnd] = useState(toLocalInput(initial?.scheduled_end));
@@ -497,16 +546,17 @@ function IncidentFormModal({
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Affected components</label>
<div className="max-h-40 space-y-1 overflow-auto rounded border border-border p-2">
{monitors.length === 0 && <p className="px-1 py-1 text-xs text-text-tertiary">No monitors yet.</p>}
{monitors.map((m) => (
<label key={m.monitor_id} className="flex items-center gap-2 rounded px-1 py-1 hover:bg-surface-2">
{selectable.length === 0 && <p className="px-1 py-1 text-xs text-text-tertiary">This page has no components yet. Add one above, save the page, then open an incident.</p>}
{selectable.map((c) => (
<label key={c.monitorId} className="flex items-center gap-2 rounded px-1 py-1 hover:bg-surface-2">
<input
type="checkbox"
checked={affected.includes(m.monitor_id)}
onChange={() => toggleMonitor(m.monitor_id)}
checked={affected.includes(c.monitorId)}
onChange={() => toggleMonitor(c.monitorId)}
className="h-4 w-4 accent-accent"
/>
<span className="text-sm text-text-primary">{m.name}</span>
<span className="text-sm text-text-primary">{c.label}</span>
{c.stale && <span className="text-xs text-warning">No longer on this page uncheck to save</span>}
</label>
))}
</div>
@@ -654,7 +704,7 @@ function DeleteIncidentButton({ pageId, incident }: { pageId: string; incident:
);
}
function IncidentsPanel({ pageId, monitors }: { pageId: string; monitors: Monitor[] }) {
function IncidentsPanel({ pageId, components }: { pageId: string; components: PageComponent[] }) {
const {
data: incidents,
isLoading,
@@ -669,10 +719,12 @@ function IncidentsPanel({ pageId, monitors }: { pageId: string; monitors: Monito
const [editing, setEditing] = useState<StatusIncident | null>(null);
const [posting, setPosting] = useState<StatusIncident | null>(null);
// Named as the page names them, so the list here reads as the public page
// reads. An id that survives the lookup is a component since removed.
const monitorName = useMemo(() => {
const m = new Map(monitors.map((mon) => [mon.monitor_id, mon.name]));
const m = new Map(components.map((c) => [c.monitorId, c.label]));
return (id: string) => m.get(id) ?? id;
}, [monitors]);
}, [components]);
return (
<Card>
@@ -680,7 +732,7 @@ function IncidentsPanel({ pageId, monitors }: { pageId: string; monitors: Monito
<IncidentFormModal
pageId={pageId}
kind={editing?.kind ?? openForm ?? "incident"}
monitors={monitors}
components={components}
initial={editing ?? undefined}
onClose={() => {
setOpenForm(null);
@@ -900,7 +952,7 @@ export default function StatusPageEditorPage() {
<div className="space-y-5">
<DetailsPanel draft={draft} setDraft={setDraft} pageId={pageId} />
<ComponentsPanel draft={draft} setDraft={setDraft} monitors={monitors ?? []} />
<IncidentsPanel pageId={pageId} monitors={monitors ?? []} />
<IncidentsPanel pageId={pageId} components={pageComponents(page)} />
</div>
</>
)}