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
+14
View File
@@ -480,6 +480,20 @@ fetched data (no DB calls inside it) is what makes the boundary testable
without a database, which is the only thing standing between an editor adding
a field to `PublicComponent` and that field being a hostname.
**An incident may only name components the page already carries.**
`services.checkAffectedOnPages` refuses an `affected_monitors` entry that no
page in the incident's `page_ids` lists, and the editor offers only the saved
page's components — labelled by their per-page display name, since that is the
name the reader sees. Naming an arbitrary monitor would publish a machine the
page deliberately does not, which is the same leak `assembleSnapshot`'s
redaction boundary exists to prevent, reached from the authoring side instead
of the read side. It is a separate pass rather than part of `validateIncident`
because it reads the database and `validateIncident` is a pure function of the
document. A component dropped from the page **after** an incident named it
makes the next edit of that incident fail, deliberately: the editor renders the
stale entry flagged and checked so it is one click from being dropped, and the
alternative is a page quietly publishing a component it no longer has.
Monitor-detected outages are **derived at read time, never copied**: each
snapshot assembly reads recent `incidents` for the page's monitors and folds
them into the timeline alongside the authored ones. There is no second
@@ -67,10 +67,66 @@ func validateIncident(inc *models.StatusIncident) error {
return nil
}
// checkAffectedOnPages refuses an incident naming a component none of its pages
// carries.
//
// An incident's affected components are the page's own components, not the
// fleet's monitors: publishing "api-gateway is degraded" on a page that never
// listed api-gateway names a machine to the public that the page deliberately
// does not, which is the same leak assembleSnapshot's redaction boundary exists
// to prevent — reached from the authoring side instead of the read side.
//
// It is a separate pass rather than part of validateIncident because it reads
// the database, and validateIncident is a pure function of the document. The
// UI only offers the page's components, but as elsewhere the API is the
// boundary and the UI is the courtesy.
//
// A monitor dropped from the page AFTER an incident named it makes the next
// edit of that incident fail, and that is intended: the fix is one unchecked
// box, and the alternative is a page quietly publishing a component it no
// longer has.
func checkAffectedOnPages(instanceID string, inc *models.StatusIncident) error {
if len(inc.AffectedMonitors) == 0 {
return nil
}
ctx, cancel := spCtx()
defer cancel()
cur, err := db.Col("status_pages").Find(ctx, bson.M{
"instance_id": instanceID,
"page_id": bson.M{"$in": inc.PageIDs},
})
if err != nil {
return err
}
var pages []models.StatusPage
if err := cur.All(ctx, &pages); err != nil {
return err
}
onPage := map[string]bool{}
for _, p := range pages {
for _, sec := range p.Sections {
for _, e := range sec.Entries {
onPage[e.MonitorID] = true
}
}
}
for _, id := range inc.AffectedMonitors {
if !onPage[id] {
return fmt.Errorf("%w: %s is not a component of this status page; add it to the page first, or leave it out of the incident",
ErrPageInvalid, id)
}
}
return nil
}
func CreateStatusIncident(instanceID string, inc *models.StatusIncident) (*models.StatusIncident, error) {
if err := validateIncident(inc); err != nil {
return nil, err
}
if err := checkAffectedOnPages(instanceID, inc); err != nil {
return nil, err
}
inc.ID = bson.ObjectID{}
inc.InstanceID = instanceID
inc.IncidentID = uuid.NewString()
@@ -172,6 +228,9 @@ func UpdateStatusIncident(instanceID, incidentID string, inc *models.StatusIncid
if err := validateIncident(inc); err != nil {
return nil, err
}
if err := checkAffectedOnPages(instanceID, inc); err != nil {
return nil, err
}
set := bson.M{
"page_ids": inc.PageIDs,
+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>
</>
)}
File diff suppressed because one or more lines are too long