feat: Updated affected components on status page incidents
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user