feat: Updated affected components on status page incidents
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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