diff --git a/apps/dashboard/AGENTS.md b/apps/dashboard/AGENTS.md index 8d55adcae..c3c6b78ff 100644 --- a/apps/dashboard/AGENTS.md +++ b/apps/dashboard/AGENTS.md @@ -41,6 +41,14 @@ part into a sibling file. `import type` from the barrel is fine; types erase. Reuse the dashboard's own primitives — `TableCell*`, `ResultTable`, `ChangesTable`. Do not build bespoke chrome for a tool result. +Mapped entity ids render as `EntityRef` (hover card per entity); unmapped ids +such as `commanderId` stay text. `ChangesTable` resolves them +by field name and the audit log by `entityType`, both through `entityKinds` in +`src/data/entity-refs.client.ts`. A new entity is one entry there plus one card +in `src/components/common/entity-ref/cards/`; the `cards` map fails typecheck +without it. In read-tool tables, swap the foreign-key cell for `EntityRef` +yourself and build links with `entityKinds..href`. + Persisted chat messages are validated on write, which is what makes the `asUIMessages` cast in `apps/dashboard/src/components/chat/use-chat-session.ts` safe. Keep casts of that kind in one named helper. diff --git a/apps/dashboard/src/components/chat/chat-tool-part.tsx b/apps/dashboard/src/components/chat/chat-tool-part.tsx index 74ba4173b..10505333f 100644 --- a/apps/dashboard/src/components/chat/chat-tool-part.tsx +++ b/apps/dashboard/src/components/chat/chat-tool-part.tsx @@ -73,6 +73,8 @@ function ApprovalCard({ }, []); const onKeyDown = (e: KeyboardEvent) => { + // An open popover (e.g. an entity hover card) already handled this key. + if (e.defaultPrevented) return; if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); confirmTool(approvalId); diff --git a/apps/dashboard/src/components/chat/tool-renderers/add-status-report-update.tsx b/apps/dashboard/src/components/chat/tool-renderers/add-status-report-update.tsx index 586fe8808..273de07ee 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/add-status-report-update.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/add-status-report-update.tsx @@ -1,5 +1,4 @@ import type { AgentToolInput } from "@openstatus/services/agent-tools"; -import { formatComponentImpacts } from "@openstatus/services/status-report/utils"; import type { ChangeRow } from "@/components/common/changes-table"; @@ -26,7 +25,7 @@ export function addStatusReportUpdateChanges( if (input.componentImpacts?.length) { changes.push({ field: "componentImpacts", - after: formatComponentImpacts(input.componentImpacts), + after: input.componentImpacts, }); } if (input.date) { diff --git a/apps/dashboard/src/components/chat/tool-renderers/create-status-report.tsx b/apps/dashboard/src/components/chat/tool-renderers/create-status-report.tsx index 6cc118d4b..13521c422 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/create-status-report.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/create-status-report.tsx @@ -1,5 +1,4 @@ import type { AgentToolInput } from "@openstatus/services/agent-tools"; -import { formatComponentImpacts } from "@openstatus/services/status-report/utils"; import type { ChangeRow } from "@/components/common/changes-table"; @@ -40,7 +39,7 @@ export function createStatusReportChanges( if (input.componentImpacts?.length) { changes.push({ field: "componentImpacts", - after: formatComponentImpacts(input.componentImpacts), + after: input.componentImpacts, }); } diff --git a/apps/dashboard/src/components/chat/tool-renderers/details.test.tsx b/apps/dashboard/src/components/chat/tool-renderers/details.test.tsx index de58fc7bc..0605463ff 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/details.test.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/details.test.tsx @@ -48,7 +48,7 @@ describe("getMonitorDetails", () => { followRedirects: true, public: false, tags: [{ name: "prod" }, { name: "api" }], - notifications: [{ name: "Ops", provider: "slack" }], + notifications: [{ id: 3, name: "Ops", provider: "slack" }], privateLocationIds: [7, 8], }; const input = asFixture({ id: 1 }); @@ -65,8 +65,19 @@ describe("getMonitorDetails", () => { expect(value(data, "Regions")).toBe("ams, iad"); expect(value(data, "Degraded after")).toBe("3000ms"); expect(value(data, "Tags")).toBe("prod, api"); - expect(value(data, "Notifications")).toBe("Ops (slack)"); - expect(value(data, "IDs")).toBe("7, 8"); + expect(value(data, "Notifications")).toBe("Ops"); + const notifications = data.sections + .flatMap((s) => s.rows) + .find((r) => r.label === "Notifications"); + expect(renderToStaticMarkup(<>{notifications?.value})).toContain( + 'data-slot="entity-ref"', + ); + const ids = data.sections + .flatMap((s) => s.rows) + .find((r) => r.label === "IDs"); + const markup = renderToStaticMarkup(<>{ids?.value}); + expect(markup).toContain('aria-label="Private location 7"'); + expect(markup).toContain('aria-label="Private location 8"'); }); it("drops optional rows and sections when absent", () => { diff --git a/apps/dashboard/src/components/chat/tool-renderers/get-monitor-summary.tsx b/apps/dashboard/src/components/chat/tool-renderers/get-monitor-summary.tsx index 597bbe6c2..1e1fbebcf 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/get-monitor-summary.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/get-monitor-summary.tsx @@ -3,6 +3,7 @@ import type { AgentToolOutput, } from "@openstatus/services/agent-tools"; +import { EntityRef } from "@/components/common/entity-ref/entity-ref"; import { TableCellDate } from "@/components/data-table/table-cell-date"; import { TableCellNumber } from "@/components/data-table/table-cell-number"; import { TableCellText } from "@/components/data-table/table-cell-text"; @@ -24,7 +25,7 @@ export function getMonitorSummaryDetails( rows: [ { label: "ID", - value: , + value: , }, { label: "Window", value: }, { diff --git a/apps/dashboard/src/components/chat/tool-renderers/get-monitor.tsx b/apps/dashboard/src/components/chat/tool-renderers/get-monitor.tsx index 4e5846154..9abbc8c84 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/get-monitor.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/get-monitor.tsx @@ -3,6 +3,7 @@ import type { AgentToolOutput, } from "@openstatus/services/agent-tools"; +import { EntityRefList } from "@/components/common/entity-ref/entity-ref"; import { TableCellBoolean } from "@/components/data-table/table-cell-boolean"; import { TableCellNumber } from "@/components/data-table/table-cell-number"; import { TableCellText } from "@/components/data-table/table-cell-text"; @@ -78,12 +79,13 @@ export function getMonitorDetails( }, { label: "Notifications", - value: ( - `${n.name} (${n.provider})`) - .join(", ")} + value: output.notifications.length ? ( + ({ id: n.id, label: n.name }))} /> + ) : ( + ), }, ]; @@ -99,7 +101,12 @@ export function getMonitorDetails( rows: [ { label: "IDs", - value: , + value: ( + + ), }, ], }); diff --git a/apps/dashboard/src/components/chat/tool-renderers/get-response-log.tsx b/apps/dashboard/src/components/chat/tool-renderers/get-response-log.tsx index 687bc56e7..a8c049b52 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/get-response-log.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/get-response-log.tsx @@ -3,11 +3,13 @@ import type { AgentToolOutput, } from "@openstatus/services/agent-tools"; +import { EntityRef } from "@/components/common/entity-ref/entity-ref"; import { TableCellBoolean } from "@/components/data-table/table-cell-boolean"; import { TableCellDate } from "@/components/data-table/table-cell-date"; import { TableCellNumber } from "@/components/data-table/table-cell-number"; import { TableCellRegion } from "@/components/data-table/table-cell-region"; import { TableCellText } from "@/components/data-table/table-cell-text"; +import { toEntityId } from "@/data/entity-refs.client"; import type { DetailsRow, @@ -19,13 +21,25 @@ type Input = AgentToolInput<"get_response_log">; type Output = AgentToolOutput<"get_response_log">; type Timing = NonNullable; +function MonitorRef({ value }: { value: string }) { + const id = toEntityId(value); + return id ? ( + + ) : ( + + ); +} + export function getResponseLogDetails( _input: Input, output: Output, ): DetailsTableData { const request: DetailsRow[] = [ { label: "Log ID", value: }, - { label: "Monitor", value: }, + { + label: "Monitor", + value: , + }, { label: "Region", value: }, { label: "URL", value: }, { diff --git a/apps/dashboard/src/components/chat/tool-renderers/get-status-report.tsx b/apps/dashboard/src/components/chat/tool-renderers/get-status-report.tsx index d674af124..60c0e53ee 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/get-status-report.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/get-status-report.tsx @@ -1,10 +1,12 @@ import type { AgentToolOutput } from "@openstatus/services/agent-tools"; import { cn } from "@openstatus/ui/lib/utils"; +import { EntityRef } from "@/components/common/entity-ref/entity-ref"; import { TableCellDate } from "@/components/data-table/table-cell-date"; import { TableCellLink } from "@/components/data-table/table-cell-link"; import { TableCellNumber } from "@/components/data-table/table-cell-number"; import { TableCellText } from "@/components/data-table/table-cell-text"; +import { entityKinds } from "@/data/entity-refs.client"; import { colors } from "@/data/status-report-updates.client"; import type { DetailsTableData } from "./details-table"; @@ -35,7 +37,10 @@ export function getStatusReportDetails( value: output.pageId !== null ? ( ) : ( @@ -47,7 +52,7 @@ export function getStatusReportDetails( label: "Page", value: output.pageId !== null ? ( - + ) : ( ), diff --git a/apps/dashboard/src/components/chat/tool-renderers/incidents.tsx b/apps/dashboard/src/components/chat/tool-renderers/incidents.tsx index 4d577bfaf..ab72a9660 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/incidents.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/incidents.tsx @@ -4,6 +4,7 @@ import type { } from "@openstatus/services/agent-tools"; import type { ChangeRow } from "@/components/common/changes-table"; +import { EntityRef } from "@/components/common/entity-ref/entity-ref"; import { TableCellDate } from "@/components/data-table/table-cell-date"; import { TableCellNumber } from "@/components/data-table/table-cell-number"; import { TableCellText } from "@/components/data-table/table-cell-text"; @@ -74,7 +75,13 @@ export function getIncidentDetails( }, { label: "Status report", - value: , + value: output.statusReport ? ( + + {output.statusReport.title} + + ) : ( + + ), }, ], }, diff --git a/apps/dashboard/src/components/chat/tool-renderers/list-audit-logs.tsx b/apps/dashboard/src/components/chat/tool-renderers/list-audit-logs.tsx index ea3fad4d8..a09465aca 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/list-audit-logs.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/list-audit-logs.tsx @@ -1,14 +1,31 @@ import type { AgentToolOutput } from "@openstatus/services/agent-tools"; import { Badge } from "@openstatus/ui/components/ui/badge"; +import { EntityRef } from "@/components/common/entity-ref/entity-ref"; import { TableCellDate } from "@/components/data-table/table-cell-date"; import { TableCellText } from "@/components/data-table/table-cell-text"; +import { auditEntityKind, toEntityId } from "@/data/entity-refs.client"; import { cn } from "@/lib/utils"; import type { ResultTableData } from "./result-table"; type Output = AgentToolOutput<"list_audit_logs">; +function AuditLogEntity({ type, id }: { type: string; id: string }) { + const kind = auditEntityKind(type); + const entityId = toEntityId(id); + return ( + + {type}# + {kind && entityId ? ( + + ) : ( + id + )} + + ); +} + function getActionBadgeColor(action: string) { if (action.endsWith(".create")) return "bg-success/10 text-success border-success/20"; @@ -44,12 +61,7 @@ export function listAuditLogsTable( {item.action} ), - entity: ( - - ), + entity: , actor: ( , page: m.pageId !== null ? ( - + ) : ( ), diff --git a/apps/dashboard/src/components/chat/tool-renderers/list-monitors.tsx b/apps/dashboard/src/components/chat/tool-renderers/list-monitors.tsx index 41e42e3d0..10d1f7517 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/list-monitors.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/list-monitors.tsx @@ -4,6 +4,7 @@ import { TableCellBoolean } from "@/components/data-table/table-cell-boolean"; import { TableCellLink } from "@/components/data-table/table-cell-link"; import { TableCellNumber } from "@/components/data-table/table-cell-number"; import { TableCellText } from "@/components/data-table/table-cell-text"; +import { entityKinds } from "@/data/entity-refs.client"; import type { ResultTableData } from "./result-table"; @@ -29,7 +30,7 @@ export function listMonitorsTable( id: m.id, cells: { name: ( - + ), jobType: ( ({ id: n.id, cells: { - name: , - provider: ( - ), - monitors: ( + provider: ( ), + monitors: + n.monitorIds.length === 0 ? ( + + ) : ( + + ), id: , }, })), diff --git a/apps/dashboard/src/components/chat/tool-renderers/list-page-components.tsx b/apps/dashboard/src/components/chat/tool-renderers/list-page-components.tsx index 155879408..586fbca28 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/list-page-components.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/list-page-components.tsx @@ -1,8 +1,10 @@ import type { AgentToolOutput } from "@openstatus/services/agent-tools"; +import { EntityRef } from "@/components/common/entity-ref/entity-ref"; import { TableCellLink } from "@/components/data-table/table-cell-link"; import { TableCellNumber } from "@/components/data-table/table-cell-number"; import { TableCellText } from "@/components/data-table/table-cell-text"; +import { entityKinds } from "@/data/entity-refs.client"; import type { ResultTableData } from "./result-table"; @@ -26,7 +28,7 @@ export function listPageComponentsTable( cells: { name: ( ), @@ -36,10 +38,10 @@ export function listPageComponentsTable( className="text-muted-foreground font-mono" /> ), - page: , + page: , monitor: c.monitorId !== null ? ( - + ) : ( ), diff --git a/apps/dashboard/src/components/chat/tool-renderers/list-private-locations.tsx b/apps/dashboard/src/components/chat/tool-renderers/list-private-locations.tsx index 4be9d5608..7c23af396 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/list-private-locations.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/list-private-locations.tsx @@ -1,11 +1,13 @@ import type { AgentToolOutput } from "@openstatus/services/agent-tools"; +import { EntityRefList } from "@/components/common/entity-ref/entity-ref"; import { TableCellDate } from "@/components/data-table/table-cell-date"; import { TableCellEmpty } from "@/components/data-table/table-cell-empty"; import { TableCellLink } from "@/components/data-table/table-cell-link"; import { TableCellMetadata } from "@/components/data-table/table-cell-metadata"; import { TableCellNumber } from "@/components/data-table/table-cell-number"; import { TableCellText } from "@/components/data-table/table-cell-text"; +import { entityKinds } from "@/data/entity-refs.client"; import type { ResultTableData } from "./result-table"; @@ -29,20 +31,16 @@ export function listPrivateLocationsTable( cells: { name: ( ), - monitors: ( - - ), + monitors: + location.monitorIds.length === 0 ? ( + + ) : ( + + ), metadata: , lastSeenAt: location.lastSeenAt ? ( diff --git a/apps/dashboard/src/components/chat/tool-renderers/list-status-pages.tsx b/apps/dashboard/src/components/chat/tool-renderers/list-status-pages.tsx index b573c70ed..7ba2de015 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/list-status-pages.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/list-status-pages.tsx @@ -3,6 +3,7 @@ import type { AgentToolOutput } from "@openstatus/services/agent-tools"; import { TableCellLink } from "@/components/data-table/table-cell-link"; import { TableCellNumber } from "@/components/data-table/table-cell-number"; import { TableCellText } from "@/components/data-table/table-cell-text"; +import { entityKinds } from "@/data/entity-refs.client"; import type { ResultTableData } from "./result-table"; @@ -23,10 +24,7 @@ export function listStatusPagesTable( id: p.id, cells: { title: ( - + ), slug: ( ) : ( @@ -46,7 +51,7 @@ export function listStatusReportsTable( ), page: r.pageId !== null ? ( - + ) : ( ), diff --git a/apps/dashboard/src/components/chat/tool-renderers/list-tables.test.tsx b/apps/dashboard/src/components/chat/tool-renderers/list-tables.test.tsx index 2fb81e690..2d8a97e17 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/list-tables.test.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/list-tables.test.tsx @@ -25,6 +25,12 @@ function text(node: ReactNode) { return html(node).replace(/<[^>]+>/g, ""); } +function refsOf(node: ReactNode) { + return [ + ...html(node).matchAll(/data-slot="entity-ref"[^>]*aria-label="([^"]+)"/g), + ].map((m) => m[1]); +} + function hrefOf(node: ReactNode) { return html(node).match(/]*href="([^"]+)"/)?.[1]; } @@ -71,13 +77,17 @@ describe("listNotificationsTable", () => { }), ); - it("links to the notification", () => { - expect(hrefOf(table.rows[0].cells.name)).toBe("/notifications/1"); + it("links to the notifications list", () => { + // Notifiers have no detail page; they open in a sheet on the list. + expect(hrefOf(table.rows[0].cells.name)).toBe("/notifications"); }); - it("summarises attached monitors", () => { - expect(text(table.rows[0].cells.monitors)).toBe("2 (4, 5)"); - expect(text(table.rows[1].cells.monitors)).toBe("none"); + it("references attached monitors", () => { + expect(refsOf(table.rows[0].cells.monitors)).toEqual([ + "Monitor 4", + "Monitor 5", + ]); + expect(text(table.rows[1].cells.monitors)).toBe("None"); }); }); @@ -95,8 +105,9 @@ describe("listPageComponentsTable", () => { expect(hrefOf(table.rows[0].cells.name)).toBe("/status-pages/2/components"); }); - it("shows the monitor id, or a placeholder for static components", () => { - expect(text(table.rows[0].cells.monitor)).toContain("4"); + it("references the page and monitor, or a placeholder for static components", () => { + expect(refsOf(table.rows[0].cells.page)).toEqual(["Status page 2"]); + expect(refsOf(table.rows[0].cells.monitor)).toEqual(["Monitor 4"]); expect(text(table.rows[1].cells.monitor).trim()).toBe("No monitor"); }); }); @@ -124,7 +135,7 @@ describe("listPrivateLocationsTable", () => { it("shows 'Never' for a location that never reported", () => { expect(text(table.rows[0].cells.lastSeenAt)).toBe("Never"); - expect(text(table.rows[0].cells.monitors)).toBe("none"); + expect(text(table.rows[0].cells.monitors)).toBe("None"); }); it("renders metadata as pills", () => { diff --git a/apps/dashboard/src/components/chat/tool-renderers/status-report-changes.test.ts b/apps/dashboard/src/components/chat/tool-renderers/status-report-changes.test.ts index 1e7417799..bf3d1f6cf 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/status-report-changes.test.ts +++ b/apps/dashboard/src/components/chat/tool-renderers/status-report-changes.test.ts @@ -48,7 +48,10 @@ describe("createStatusReportChanges", () => { ); expect(rows.slice(5)).toEqual([ { field: "pageComponentIds", after: [1, 2] }, - { field: "componentImpacts", after: ["1 → major_outage"] }, + { + field: "componentImpacts", + after: [{ pageComponentId: 1, impact: "major_outage" }], + }, { field: "date", after: "2024-01-01T00:00:00Z" }, ]); }); @@ -110,7 +113,7 @@ describe("addStatusReportUpdateChanges", () => { ]); }); - it("formats impacts, keeps the date and uses the applied notify flag", () => { + it("keeps impacts structured, keeps the date and uses the applied notify flag", () => { const rows = addStatusReportUpdateChanges( asInput({ ...base, @@ -121,7 +124,10 @@ describe("addStatusReportUpdateChanges", () => { ); expect(rows.find((r) => r.field === "notify")?.after).toBe(true); expect(rows.slice(4)).toEqual([ - { field: "componentImpacts", after: ["4 → operational"] }, + { + field: "componentImpacts", + after: [{ pageComponentId: 4, impact: "operational" }], + }, { field: "date", after: "2024-01-01T00:00:00Z" }, ]); }); diff --git a/apps/dashboard/src/components/chat/tool-renderers/status-report-update.tsx b/apps/dashboard/src/components/chat/tool-renderers/status-report-update.tsx index c7be2c6c5..717166dcd 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/status-report-update.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/status-report-update.tsx @@ -1,5 +1,4 @@ import type { AgentToolInput } from "@openstatus/services/agent-tools"; -import { formatComponentImpacts } from "@openstatus/services/status-report/utils"; import type { ChangeRow } from "@/components/common/changes-table"; @@ -15,9 +14,7 @@ export function updateStatusReportUpdateChanges( ? [ { field: "componentImpacts", - after: input.componentImpacts.length - ? formatComponentImpacts(input.componentImpacts) - : [], + after: input.componentImpacts, }, ] : []), diff --git a/apps/dashboard/src/components/common/changes-table.test.tsx b/apps/dashboard/src/components/common/changes-table.test.tsx index f3aff5032..036df330a 100644 --- a/apps/dashboard/src/components/common/changes-table.test.tsx +++ b/apps/dashboard/src/components/common/changes-table.test.tsx @@ -107,3 +107,66 @@ describe("ChangesTable", () => { expect(html).not.toContain('data-kind="added"'); }); }); + +describe("ChangesTable entity refs", () => { + function refs(changes: Parameters[0]["changes"]) { + const html = renderToStaticMarkup(); + return [ + ...html.matchAll(/data-slot="entity-ref"[^>]*aria-label="([^"]+)"/g), + ].map((m) => m[1]); + } + + it("renders a known id field as a ref", () => { + expect(refs([{ field: "pageId", after: 1 }])).toEqual(["Status page 1"]); + }); + + it("renders every id of an id array", () => { + expect(refs([{ field: "pageComponentIds", after: [3, 4] }])).toEqual([ + "Component 3", + "Component 4", + ]); + }); + + it("renders refs on removed lines too", () => { + expect(refs([{ field: "monitorId", before: 2, after: 5 }])).toEqual([ + "Monitor 2", + "Monitor 5", + ]); + }); + + it("renders component impacts as ref and impact label", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('aria-label="Component 7"'); + expect(html).toContain("Major outage"); + }); + + it("falls back to text for unknown fields and non-id values", () => { + expect( + refs([ + { field: "retry", after: 3 }, + { field: "pageId", after: null }, + { field: "monitorIds", after: [] }, + { field: "pageComponentIds", after: ["a"] }, + { field: "componentImpacts", after: ["1 → major_outage"] }, + ]), + ).toEqual([]); + }); + + it("treats inherited object keys as plain fields", () => { + expect( + refs([ + { field: "toString", after: 1 }, + { field: "constructor", after: 2 }, + ]), + ).toEqual([]); + }); +}); diff --git a/apps/dashboard/src/components/common/changes-table.tsx b/apps/dashboard/src/components/common/changes-table.tsx index 25f8bd806..1315957fe 100644 --- a/apps/dashboard/src/components/common/changes-table.tsx +++ b/apps/dashboard/src/components/common/changes-table.tsx @@ -1,5 +1,18 @@ +import { + fieldRefKind, + toEntityId, + toEntityIds, +} from "@/data/entity-refs.client"; +import { + type ComponentImpact, + impactDisplay, + isComponentImpactList, +} from "@/data/status-report-updates.client"; import { cn } from "@/lib/utils"; +import { EntityRef, EntityRefList } from "./entity-ref/entity-ref"; +import { StatusBadge } from "./status-badge"; + /** * Field-level diff row used by the audit-log "CHANGES" view and the * chat tool draft/result cards. @@ -33,12 +46,20 @@ export function ChangesTable({ changes }: { changes: ChangeRow[] }) {
{change.field}
-
+
{"before" in change ? ( - + ) : null} {"after" in change ? ( - + ) : null}
@@ -49,9 +70,11 @@ export function ChangesTable({ changes }: { changes: ChangeRow[] }) { function DiffLine({ kind, + field, value, }: { kind: "added" | "removed"; + field: string; value: unknown; }) { const sign = kind === "added" ? "+" : "-"; @@ -63,13 +86,49 @@ function DiffLine({ {sign} - - {formatValue(value)} + {/* Refs and badges are inline boxes; the removed strikethrough has to be set on them. */} + + ); } +function DiffValue({ field, value }: { field: string; value: unknown }) { + const kind = fieldRefKind(field); + if (kind === "componentImpacts") { + if (isComponentImpactList(value) && value.length > 0) { + return ; + } + } else if (kind) { + // Strings stay text: a diff value of "4" is not a reference. + const id = typeof value === "number" ? toEntityId(value) : undefined; + if (id) return ; + const ids = toEntityIds(value); + if (ids) return ; + } + return formatValue(value); +} + +function ImpactList({ impacts }: { impacts: ComponentImpact[] }) { + return ( + + {impacts.map(({ pageComponentId, impact }) => { + const display = impactDisplay(impact); + return ( + + + → + + {display.label} + + + ); + })} + + ); +} + const HIDDEN_FIELDS = new Set([ "workspaceId", "createdAt", diff --git a/apps/dashboard/src/components/common/entity-card.tsx b/apps/dashboard/src/components/common/entity-card.tsx new file mode 100644 index 000000000..baf022d6a --- /dev/null +++ b/apps/dashboard/src/components/common/entity-card.tsx @@ -0,0 +1,96 @@ +import { cn } from "@/lib/utils"; + +export function EntityCard({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ {children} +
+ ); +} + +export function EntityCardHeader({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:text-muted-foreground flex min-w-0 items-start gap-2 px-3 py-2.5 [&>[data-slot=badge]]:shrink-0 [&>svg]:mt-0.5 [&>svg]:size-4 [&>svg]:shrink-0", + className, + )} + {...props} + > + {children} +
+ ); +} + +export function EntityCardTitle({ + children, + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + {children} + + ); +} + +export function EntityCardContent({ + children, + className, + ...props +}: React.ComponentProps<"dl">) { + return ( +
+ {children} +
+ ); +} + +export function EntityCardRow({ + label, + children, + className, + ...props +}: React.ComponentProps<"dd"> & { label: string }) { + return ( + <> +
+ {label} +
+
+ {children} +
+ + ); +} diff --git a/apps/dashboard/src/components/common/entity-ref/cards/incident.tsx b/apps/dashboard/src/components/common/entity-ref/cards/incident.tsx new file mode 100644 index 000000000..6aff09514 --- /dev/null +++ b/apps/dashboard/src/components/common/entity-ref/cards/incident.tsx @@ -0,0 +1,58 @@ +import { useQuery } from "@tanstack/react-query"; + +import { + EntityCard, + EntityCardContent, + EntityCardRow, +} from "@/components/common/entity-card"; +import { entityKinds } from "@/data/entity-refs.client"; +import { severityConfig, statusConfig } from "@/data/managed-incidents.client"; +import { useTRPC } from "@/lib/trpc/client"; + +import { + EntityRefFallback, + EntityRefHeader, + RelativeTime, + lookupOptions, +} from "../parts"; + +export function IncidentCard({ id }: { id: number }) { + const trpc = useTRPC(); + const incident = useQuery( + trpc.incident.get.queryOptions({ id }, lookupOptions), + ); + + if (!incident.data) { + return ; + } + const { data } = incident; + + return ( + + + {data.title} + + + + {statusConfig[data.status].label} + + + {severityConfig[data.severity].label} + + + + + + {data.commander + ? (data.commander.name ?? data.commander.email) + : "Unassigned"} + + {data.statusReport ? ( + + {data.statusReport.title} + + ) : null} + + + ); +} diff --git a/apps/dashboard/src/components/common/entity-ref/cards/maintenance.tsx b/apps/dashboard/src/components/common/entity-ref/cards/maintenance.tsx new file mode 100644 index 000000000..fb810c9cb --- /dev/null +++ b/apps/dashboard/src/components/common/entity-ref/cards/maintenance.tsx @@ -0,0 +1,59 @@ +import { useQuery } from "@tanstack/react-query"; + +import { + EntityCard, + EntityCardContent, + EntityCardRow, +} from "@/components/common/entity-card"; +import { entityKinds } from "@/data/entity-refs.client"; +import { + getMaintenanceStatus, + maintenanceStatusConfig, +} from "@/data/overview-events.client"; +import { formatDateRange } from "@/lib/formatter"; +import { useTRPC } from "@/lib/trpc/client"; + +import { EntityRefFallback, EntityRefHeader, lookupOptions } from "../parts"; + +export function MaintenanceCard({ id }: { id: number }) { + const trpc = useTRPC(); + const maintenance = useQuery( + trpc.maintenance.get.queryOptions({ id }, lookupOptions), + ); + + if (!maintenance.data) { + return ; + } + const { data } = maintenance; + const components = data.pageComponents.map((c) => c.name).join(", "); + + return ( + + + {data.title} + + + + {maintenanceStatusConfig[getMaintenanceStatus(data)].label} + + + + + {components ? ( + + {components} + + ) : null} + + + ); +} diff --git a/apps/dashboard/src/components/common/entity-ref/cards/monitor.tsx b/apps/dashboard/src/components/common/entity-ref/cards/monitor.tsx new file mode 100644 index 000000000..576f7fe16 --- /dev/null +++ b/apps/dashboard/src/components/common/entity-ref/cards/monitor.tsx @@ -0,0 +1,43 @@ +import { useQuery } from "@tanstack/react-query"; + +import { + EntityCard, + EntityCardContent, + EntityCardRow, +} from "@/components/common/entity-card"; +import { entityKinds, findById } from "@/data/entity-refs.client"; +import { getMonitorHealth, monitorHealthConfig } from "@/data/monitors.client"; +import { useTRPC } from "@/lib/trpc/client"; + +import { EntityRefFallback, EntityRefHeader } from "../parts"; + +export function MonitorCard({ id }: { id: number }) { + const trpc = useTRPC(); + const monitor = useQuery({ + ...trpc.monitor.list.queryOptions(), + select: findById(id), + }); + + if (!monitor.data) { + return ; + } + const { data } = monitor; + + return ( + + + {data.name} + + + + {monitorHealthConfig[getMonitorHealth(data)].label} + + + {data.url} + + {data.periodicity} + {data.regions.length} + + + ); +} diff --git a/apps/dashboard/src/components/common/entity-ref/cards/notification.tsx b/apps/dashboard/src/components/common/entity-ref/cards/notification.tsx new file mode 100644 index 000000000..f3be64727 --- /dev/null +++ b/apps/dashboard/src/components/common/entity-ref/cards/notification.tsx @@ -0,0 +1,45 @@ +import { useQuery } from "@tanstack/react-query"; + +import { + EntityCard, + EntityCardContent, + EntityCardRow, +} from "@/components/common/entity-card"; +import { entityKinds, findById } from "@/data/entity-refs.client"; +import { useTRPC } from "@/lib/trpc/client"; + +import { EntityRefFallback, EntityRefHeader } from "../parts"; + +export function NotificationCard({ id }: { id: number }) { + const trpc = useTRPC(); + const notification = useQuery({ + ...trpc.notification.list.queryOptions(), + select: findById(id), + }); + + if (!notification.data) { + return ( + + ); + } + const { data } = notification; + const monitors = data.monitors.map((m) => m.name).join(", "); + + return ( + + + {data.name} + + + {/* Raw provider id: the provider config would pull every notifier form into this bundle. */} + {data.provider} + + {monitors || "None"} + + + + ); +} diff --git a/apps/dashboard/src/components/common/entity-ref/cards/page-component.tsx b/apps/dashboard/src/components/common/entity-ref/cards/page-component.tsx new file mode 100644 index 000000000..dcfc40d00 --- /dev/null +++ b/apps/dashboard/src/components/common/entity-ref/cards/page-component.tsx @@ -0,0 +1,54 @@ +import { useQuery } from "@tanstack/react-query"; + +import { + EntityCard, + EntityCardContent, + EntityCardRow, +} from "@/components/common/entity-card"; +import { entityKinds, findById } from "@/data/entity-refs.client"; +import { useTRPC } from "@/lib/trpc/client"; + +import { EntityRefFallback, EntityRefHeader } from "../parts"; + +export function PageComponentCard({ id }: { id: number }) { + const trpc = useTRPC(); + const component = useQuery({ + ...trpc.pageComponent.list.queryOptions(), + select: findById(id), + }); + const page = useQuery({ + ...trpc.page.list.queryOptions(), + select: findById(component.data?.pageId ?? 0), + enabled: component.data !== undefined, + }); + + if (!component.data) { + return ; + } + const { data } = component; + + return ( + + + {data.name} + + + + {data.type === "monitor" ? "Monitor" : "Static"} + + {page.data ? ( + {page.data.title} + ) : null} + {data.group ? ( + {data.group.name} + ) : null} + {data.monitor ? ( + {data.monitor.name} + ) : null} + + + ); +} diff --git a/apps/dashboard/src/components/common/entity-ref/cards/page.tsx b/apps/dashboard/src/components/common/entity-ref/cards/page.tsx new file mode 100644 index 000000000..326729073 --- /dev/null +++ b/apps/dashboard/src/components/common/entity-ref/cards/page.tsx @@ -0,0 +1,48 @@ +import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; +import { useQuery } from "@tanstack/react-query"; + +import { + EntityCard, + EntityCardContent, + EntityCardRow, +} from "@/components/common/entity-card"; +import { entityKinds, findById } from "@/data/entity-refs.client"; +import { useTRPC } from "@/lib/trpc/client"; + +import { EntityRefFallback, EntityRefHeader } from "../parts"; + +export function PageCard({ id }: { id: number }) { + const trpc = useTRPC(); + const page = useQuery({ + ...trpc.page.list.queryOptions(), + select: findById(id), + }); + const components = useQuery({ + ...trpc.pageComponent.list.queryOptions({ pageId: id }), + select: (rows) => rows?.length, + }); + + if (!page.data) { + return ; + } + const { data } = page; + + return ( + + + {data.title} + + + {data.slug} + {data.customDomain ? ( + {data.customDomain} + ) : null} + + {components.isError + ? "—" + : (components.data ?? )} + + + + ); +} diff --git a/apps/dashboard/src/components/common/entity-ref/cards/private-location.tsx b/apps/dashboard/src/components/common/entity-ref/cards/private-location.tsx new file mode 100644 index 000000000..dc685e976 --- /dev/null +++ b/apps/dashboard/src/components/common/entity-ref/cards/private-location.tsx @@ -0,0 +1,48 @@ +import { useQuery } from "@tanstack/react-query"; + +import { + EntityCard, + EntityCardContent, + EntityCardRow, +} from "@/components/common/entity-card"; +import { entityKinds, findById } from "@/data/entity-refs.client"; +import { useTRPC } from "@/lib/trpc/client"; + +import { EntityRefFallback, EntityRefHeader, RelativeTime } from "../parts"; + +export function PrivateLocationCard({ id }: { id: number }) { + const trpc = useTRPC(); + const location = useQuery({ + // Schema max; there is no `get`, so locations past the newest 100 show + // "not found". Accepted: the settings page itself only lists 50. + ...trpc.privateLocation.list.queryOptions({ limit: 100 }), + select: findById(id), + }); + + if (!location.data) { + return ( + + ); + } + const { data } = location; + + return ( + + + {data.name} + + + + {data.status === "active" ? "Online" : "Offline"} + + {data.monitors.length} + + {data.lastSeenAt ? : "Never"} + + + + ); +} diff --git a/apps/dashboard/src/components/common/entity-ref/cards/status-report.tsx b/apps/dashboard/src/components/common/entity-ref/cards/status-report.tsx new file mode 100644 index 000000000..5c04dc70d --- /dev/null +++ b/apps/dashboard/src/components/common/entity-ref/cards/status-report.tsx @@ -0,0 +1,61 @@ +import { useQuery } from "@tanstack/react-query"; + +import { + EntityCard, + EntityCardContent, + EntityCardRow, +} from "@/components/common/entity-card"; +import { entityKinds } from "@/data/entity-refs.client"; +import { useTRPC } from "@/lib/trpc/client"; + +import { + EntityRefFallback, + EntityRefHeader, + RelativeTime, + lookupOptions, +} from "../parts"; + +export function StatusReportCard({ id }: { id: number }) { + const trpc = useTRPC(); + const report = useQuery( + trpc.statusReport.get.queryOptions({ id }, lookupOptions), + ); + + if (!report.data) { + return ; + } + const { data } = report; + const latest = data.updates[0]; + const components = data.pageComponents.map((c) => c.name).join(", "); + + return ( + + + {data.title} + + + + {data.status} + + {data.page.title} + {components ? ( + + {components} + + ) : null} + {latest ? ( + + + + ) : null} + + + ); +} diff --git a/apps/dashboard/src/components/common/entity-ref/entity-ref.tsx b/apps/dashboard/src/components/common/entity-ref/entity-ref.tsx new file mode 100644 index 000000000..fb00ce33d --- /dev/null +++ b/apps/dashboard/src/components/common/entity-ref/entity-ref.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@openstatus/ui/components/ui/hover-card"; +import { useState } from "react"; + +import { type EntityKind, entityKinds } from "@/data/entity-refs.client"; +import { cn } from "@/lib/utils"; + +import { IncidentCard } from "./cards/incident"; +import { MaintenanceCard } from "./cards/maintenance"; +import { MonitorCard } from "./cards/monitor"; +import { NotificationCard } from "./cards/notification"; +import { PageCard } from "./cards/page"; +import { PageComponentCard } from "./cards/page-component"; +import { PrivateLocationCard } from "./cards/private-location"; +import { StatusReportCard } from "./cards/status-report"; + +const cards = { + page: PageCard, + pageComponent: PageComponentCard, + monitor: MonitorCard, + statusReport: StatusReportCard, + maintenance: MaintenanceCard, + incident: IncidentCard, + privateLocation: PrivateLocationCard, + notification: NotificationCard, +} satisfies Record>; + +/** An entity id that opens a card with the entity on hover, focus or tap. */ +export function EntityRef({ + kind, + id, + children, + className, + onClick, + ...props +}: Omit, "id"> & { + kind: EntityKind; + id: number; +}) { + const [open, setOpen] = useState(false); + const Card = cards[kind]; + return ( + + + + + + + + + ); +} + +/** A bare id, or an id shown by its label (e.g. a name the payload already has). */ +export type EntityRefItem = number | { id: number; label: React.ReactNode }; + +export function EntityRefList({ + kind, + refs, + className, + ...props +}: React.ComponentProps<"span"> & { + kind: EntityKind; + refs: readonly EntityRefItem[]; +}) { + return ( + + {refs.map((ref, i) => { + const { id, label } = + typeof ref === "number" ? { id: ref, label: undefined } : ref; + return ( + + + {label} + + {i < refs.length - 1 ? ( + , + ) : null} + + ); + })} + + ); +} diff --git a/apps/dashboard/src/components/common/entity-ref/parts.tsx b/apps/dashboard/src/components/common/entity-ref/parts.tsx new file mode 100644 index 000000000..2bd9d93c7 --- /dev/null +++ b/apps/dashboard/src/components/common/entity-ref/parts.tsx @@ -0,0 +1,117 @@ +import { ArrowUpRight } from "@openstatus/icons"; +import { Skeleton } from "@openstatus/ui/components/ui/skeleton"; +import type { UseQueryResult } from "@tanstack/react-query"; +import NextLink from "next/link"; + +import { + EntityCard, + EntityCardContent, + EntityCardHeader, + EntityCardRow, + EntityCardTitle, +} from "@/components/common/entity-card"; +import { type EntityKind, entityKinds } from "@/data/entity-refs.client"; +import { formatDateTime, formatDistanceToNowShort } from "@/lib/formatter"; +import { isNotFound } from "@/lib/trpc/error"; + +/** Options for a `get` lookup: a 404 is the not-found card, not a bug. */ +export const lookupOptions = { + retry: false, + meta: { expectNotFound: true }, + trpc: { context: { expectNotFound: true } }, +} as const; + +/** Kind icon and title; with `href` the title opens the entity in a new tab. */ +export function EntityRefHeader({ + kind, + href, + children, +}: { + kind: EntityKind; + href?: string; + children: React.ReactNode; +}) { + const Icon = entityKinds[kind].icon; + return ( + + + {href ? ( + + {children} + + + ) : ( + {children} + )} + + ); +} + +/** Loading, not-found or failed state for a card whose query has no data. */ +export function EntityRefFallback({ + kind, + id, + query, +}: { + kind: EntityKind; + id: number; + query: Pick< + UseQueryResult, + "isPending" | "isError" | "error" + >; +}) { + const Icon = entityKinds[kind].icon; + if (query.isPending) { + return ( + + + + + + + + + + + + + ); + } + // Any 404 here means the entity is gone; `lookupOptions` only decides Sentry. + const failed = query.isError && !isNotFound(query.error); + const { label } = entityKinds[kind]; + return ( + + + + + {failed + ? `Couldn't load ${label.toLowerCase()}` + : `${label} not found`} + + + + {id} + + {failed ? "Try again" : "Deleted or no access"} + + + + ); +} + +export function RelativeTime({ date }: { date: Date }) { + return ( + + ); +} diff --git a/apps/dashboard/src/components/common/monitor-status-dot.tsx b/apps/dashboard/src/components/common/monitor-status-dot.tsx index 9f8c03099..6089588b1 100644 --- a/apps/dashboard/src/components/common/monitor-status-dot.tsx +++ b/apps/dashboard/src/components/common/monitor-status-dot.tsx @@ -5,43 +5,46 @@ import { } from "@openstatus/ui/components/ui/tooltip"; import { cn } from "@openstatus/ui/lib/utils"; +import { + type MonitorHealth, + getMonitorHealth, + monitorHealthConfig, +} from "@/data/monitors.client"; + +const dotColor = { + inactive: "bg-muted-foreground/70", + active: "bg-success", + degraded: "bg-warning", + error: "bg-destructive", +} as const satisfies Record; + export function MonitorStatusDot({ monitor, label, className, }: { - monitor: { active: boolean | null; status: string }; + monitor: Parameters[0]; /** Overrides the default tooltip text */ label?: React.ReactNode; className?: string; }) { - const text = - label ?? - (!monitor.active - ? "Inactive" - : monitor.status === "active" - ? "Normal" - : monitor.status === "error" - ? "Failing" - : "Degraded"); + const health = getMonitorHealth(monitor); + const text = label ?? monitorHealthConfig[health].label; return ( {/* biome-ignore lint/a11y/noNoninteractiveTabindex: focusable so keyboard users can open the tooltip */} - {!monitor.active ? ( - - ) : monitor.status === "active" ? ( - <> - - - - ) : monitor.status === "error" ? ( - - ) : ( - - )} + {health === "active" ? ( + + ) : null} + {text} diff --git a/apps/dashboard/src/data/entity-refs.client.test.ts b/apps/dashboard/src/data/entity-refs.client.test.ts new file mode 100644 index 000000000..1238723cf --- /dev/null +++ b/apps/dashboard/src/data/entity-refs.client.test.ts @@ -0,0 +1,67 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { + auditEntityKind, + fieldRefKind, + toEntityId, + toEntityIds, +} from "./entity-refs.client"; + +describe("toEntityId", () => { + for (const [value, expected] of [ + [4, 4], + ["42", 42], + [0, undefined], + [-1, undefined], + [1.5, undefined], + ["", undefined], + ["4a", undefined], + [" 4", undefined], + [null, undefined], + [Number.MAX_SAFE_INTEGER + 1, undefined], + ] as const) { + it(`parses ${JSON.stringify(value)}`, () => { + expect(toEntityId(value)).toBe(expected); + }); + } +}); + +describe("toEntityIds", () => { + it("returns every id of a numeric list", () => { + expect(toEntityIds([3, 4])).toEqual([3, 4]); + }); + + it("rejects empty lists, strings and lists with one bad entry", () => { + expect(toEntityIds([])).toBeUndefined(); + expect(toEntityIds(["3"])).toBeUndefined(); + expect(toEntityIds([3, 0])).toBeUndefined(); + expect(toEntityIds("3,4")).toBeUndefined(); + }); +}); + +describe("fieldRefKind", () => { + it("maps known id fields", () => { + expect(fieldRefKind("pageId")).toBe("page"); + expect(fieldRefKind("monitorIds")).toBe("monitor"); + expect(fieldRefKind("componentImpacts")).toBe("componentImpacts"); + }); + + it("ignores unknown and inherited keys", () => { + expect(fieldRefKind("retry")).toBeUndefined(); + expect(fieldRefKind("constructor")).toBeUndefined(); + expect(fieldRefKind("toString")).toBeUndefined(); + }); +}); + +describe("auditEntityKind", () => { + it("maps audit entity types with a card", () => { + expect(auditEntityKind("status_report")).toBe("statusReport"); + expect(auditEntityKind("private_location")).toBe("privateLocation"); + }); + + it("returns undefined for types without a card", () => { + expect(auditEntityKind("api_key")).toBeUndefined(); + expect(auditEntityKind("__proto__")).toBeUndefined(); + }); +}); diff --git a/apps/dashboard/src/data/entity-refs.client.ts b/apps/dashboard/src/data/entity-refs.client.ts new file mode 100644 index 000000000..d4aabfaab --- /dev/null +++ b/apps/dashboard/src/data/entity-refs.client.ts @@ -0,0 +1,145 @@ +import type { AuditEntityType } from "@openstatus/db/src/schema/audit_logs/validation"; +import { + Components, + Incident, + type IconType, + Maintenance, + Monitor, + Notification, + PrivateLocation, + Report, + StatusPage, +} from "@openstatus/icons"; + +type EntityKindConfig = { + label: string; + icon: IconType; + /** Change-row and tool-schema fields that hold this kind's id(s). */ + fields: readonly string[]; + /** The audit-log `entityType` for this kind. */ + auditType: AuditEntityType; + /** Dashboard page for the entity. */ + href: (entity: never) => string; +}; + +/** One entry per referenceable entity; add a card in `entity-ref/cards` too. */ +export const entityKinds = { + page: { + label: "Status page", + icon: StatusPage, + fields: ["pageId"], + auditType: "page", + href: (page: { id: number }) => `/status-pages/${page.id}/status-reports`, + }, + pageComponent: { + label: "Component", + icon: Components, + fields: ["pageComponentId", "pageComponentIds"], + auditType: "page_component", + href: (component: { pageId: number }) => + `/status-pages/${component.pageId}/components`, + }, + monitor: { + label: "Monitor", + icon: Monitor, + fields: ["monitorId", "monitorIds"], + auditType: "monitor", + href: (monitor: { id: number }) => `/monitors/${monitor.id}/overview`, + }, + statusReport: { + label: "Status report", + icon: Report, + fields: ["statusReportId"], + auditType: "status_report", + href: (report: { id: number; pageId: number }) => + `/status-pages/${report.pageId}/status-reports/${report.id}`, + }, + maintenance: { + label: "Maintenance", + icon: Maintenance, + fields: ["maintenanceId"], + auditType: "maintenance", + href: (maintenance: { id: number; pageId: number }) => + `/status-pages/${maintenance.pageId}/maintenances/${maintenance.id}`, + }, + incident: { + label: "Incident", + icon: Incident, + fields: ["incidentId"], + auditType: "incident", + href: (incident: { id: number }) => `/incidents/${incident.id}`, + }, + privateLocation: { + label: "Private location", + icon: PrivateLocation, + fields: ["privateLocationId", "privateLocationIds"], + auditType: "private_location", + href: () => "/settings/private-locations", + }, + notification: { + label: "Notification", + icon: Notification, + fields: ["notificationId", "notificationIds"], + auditType: "notification", + // Notifiers are edited in a sheet on the list; there is no detail page. + href: () => "/notifications", + }, +} as const satisfies Record; + +export type EntityKind = keyof typeof entityKinds; + +// Safe because the keys of a `satisfies`-checked literal are exactly `EntityKind`. +export const entityKindList = Object.keys(entityKinds) as EntityKind[]; + +export type FieldRefKind = EntityKind | "componentImpacts"; + +// Maps, not object lookups, so inherited keys (`constructor`) never match. +const fieldKinds = new Map([ + ...entityKindList.flatMap((kind) => + entityKinds[kind].fields.map((field) => [field, kind] as const), + ), + ["componentImpacts", "componentImpacts"], +]); + +const auditKinds = new Map( + entityKindList.map((kind) => [entityKinds[kind].auditType, kind]), +); + +/** What a change-row field references, by its name. */ +export function fieldRefKind(field: string): FieldRefKind | undefined { + return fieldKinds.get(field); +} + +/** The ref kind for an audit-log `entityType`; `undefined` when it has no card. */ +export function auditEntityKind(type: string): EntityKind | undefined { + return auditKinds.get(type); +} + +/** A positive integer id from a number or digit string, else `undefined`. */ +export function toEntityId(value: unknown): number | undefined { + const id = + typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value; + return typeof id === "number" && Number.isSafeInteger(id) && id > 0 + ? id + : undefined; +} + +/** A non-empty list of ids, else `undefined` (one bad entry fails the list). */ +export function toEntityIds(value: unknown): number[] | undefined { + if (!Array.isArray(value) || value.length === 0) return undefined; + const ids: number[] = []; + for (const item of value) { + // Strings stay text here: a diff value of "4" is not a reference. + if (typeof item !== "number") return undefined; + const id = toEntityId(item); + if (id === undefined) return undefined; + ids.push(id); + } + return ids; +} + +/** `select` for list queries: the row with `id`, or `undefined`. */ +export function findById(id: number) { + return (rows: readonly T[] | undefined) => + rows?.find((row) => row.id === id); +} diff --git a/apps/dashboard/src/data/entity-refs.coverage.test.ts b/apps/dashboard/src/data/entity-refs.coverage.test.ts new file mode 100644 index 000000000..3c9a91b5d --- /dev/null +++ b/apps/dashboard/src/data/entity-refs.coverage.test.ts @@ -0,0 +1,98 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { + entityKindList, + entityKinds, + fieldRefKind, +} from "./entity-refs.client"; + +// The tool registry validates env and opens a db client on import; mirror the +// services test script. Nothing here queries, so the URL is never dialled. +// Next types NODE_ENV as read-only; widen to assign the test defaults. +const env: Record = process.env; +for (const [key, value] of [ + ["NODE_ENV", "test"], + ["RESEND_API_KEY", "test-key"], + ["DATABASE_URL", "http://127.0.0.1:0"], +] as const) { + env[key] ||= value; +} +const { agentTools } = await import("@openstatus/services/agent-tools"); +const { z } = await import("zod"); + +/** Id fields that are not entity refs, with the reason they stay text. */ +const notEntityRefs: Record = { + actorId: "user; no member lookup yet", + commanderId: "user; no member lookup yet", + entityId: "polymorphic; rendered through auditEntityKind", + eventId: "incident timeline event, lives inside the incident", + initialUpdateId: "status report update, lives inside the report", + statusReportUpdateId: "status report update, lives inside the report", + logId: "response log in Tinybird, no card", + slackChannelId: "external Slack id", +}; + +function propertyKeys(node: unknown, keys = new Set()): Set { + if (Array.isArray(node)) { + for (const item of node) propertyKeys(item, keys); + } else if (node && typeof node === "object") { + if ( + "properties" in node && + node.properties && + typeof node.properties === "object" + ) { + for (const key of Object.keys(node.properties)) keys.add(key); + } + for (const value of Object.values(node)) propertyKeys(value, keys); + } + return keys; +} + +function toolIdFields(): string[] { + const keys = new Set(); + for (const tool of Object.values(agentTools)) { + for (const [schema, io] of [ + [tool.inputSchema, "input"], + [tool.outputSchema, "output"], + ] as const) { + propertyKeys( + z.toJSONSchema(schema, { unrepresentable: "any", io }), + keys, + ); + } + } + return [...keys].filter((key) => /.Ids?$/.test(key)).sort(); +} + +describe("entity ref coverage", () => { + it("maps every agent-tool id field to a kind or lists why it stays text", () => { + const unmapped = toolIdFields().filter( + (field) => !fieldRefKind(field) && !(field in notEntityRefs), + ); + expect(unmapped).toEqual([]); + }); + + it("keeps the ignore list honest", () => { + const fields = new Set(toolIdFields()); + for (const field of Object.keys(notEntityRefs)) { + expect({ field, mapped: fieldRefKind(field) }).toEqual({ + field, + mapped: undefined, + }); + expect({ field, inSchemas: fields.has(field) }).toEqual({ + field, + inSchemas: true, + }); + } + }); + + it("claims each field and audit type for one kind only", () => { + const fields = entityKindList.flatMap((kind) => entityKinds[kind].fields); + expect(new Set(fields).size).toBe(fields.length); + const auditTypes = entityKindList.map( + (kind) => entityKinds[kind].auditType, + ); + expect(new Set(auditTypes).size).toBe(auditTypes.length); + }); +}); diff --git a/apps/dashboard/src/data/monitors.client.test.ts b/apps/dashboard/src/data/monitors.client.test.ts new file mode 100644 index 000000000..7b1865c39 --- /dev/null +++ b/apps/dashboard/src/data/monitors.client.test.ts @@ -0,0 +1,23 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { getMonitorHealth } from "./monitors.client"; + +describe("getMonitorHealth", () => { + it("follows the last check while active", () => { + expect(getMonitorHealth({ active: true, status: "active" })).toBe("active"); + expect(getMonitorHealth({ active: true, status: "error" })).toBe("error"); + expect(getMonitorHealth({ active: true, status: "degraded" })).toBe( + "degraded", + ); + }); + + it("reads as inactive when paused, whatever the last check said", () => { + expect(getMonitorHealth({ active: false, status: "error" })).toBe( + "inactive", + ); + expect(getMonitorHealth({ active: null, status: "active" })).toBe( + "inactive", + ); + }); +}); diff --git a/apps/dashboard/src/data/monitors.client.ts b/apps/dashboard/src/data/monitors.client.ts index 970fcf033..7ebcc4595 100644 --- a/apps/dashboard/src/data/monitors.client.ts +++ b/apps/dashboard/src/data/monitors.client.ts @@ -1,3 +1,4 @@ +import type { monitorStatus } from "@openstatus/db/src/schema/monitors/constants"; import { Api, Settings, @@ -88,3 +89,22 @@ export const getActions = ( onClick: props[action.id as keyof typeof props], })); }; + +type MonitorStatus = (typeof monitorStatus)[number]; + +export const monitorHealthConfig = { + inactive: { label: "Inactive" }, + active: { label: "Normal" }, + degraded: { label: "Degraded" }, + error: { label: "Failing" }, +} as const satisfies Record; + +export type MonitorHealth = keyof typeof monitorHealthConfig; + +/** A paused monitor reads as inactive whatever its last check said. */ +export function getMonitorHealth(monitor: { + active: boolean | null; + status: MonitorStatus; +}): MonitorHealth { + return monitor.active ? monitor.status : "inactive"; +} diff --git a/apps/dashboard/src/data/overview-events.client.ts b/apps/dashboard/src/data/overview-events.client.ts index 7974b57cf..285c0643e 100644 --- a/apps/dashboard/src/data/overview-events.client.ts +++ b/apps/dashboard/src/data/overview-events.client.ts @@ -47,7 +47,7 @@ export type IncidentStatus = keyof typeof incidentStatusConfig; export const maintenanceStatusConfig = { scheduled: { label: "Scheduled", color: "text-info/80", variant: "info" }, "in-progress": { - label: "In Progress", + label: "In progress", color: "text-info/80", variant: "warning", }, diff --git a/apps/dashboard/src/data/status-report-updates.client.test.ts b/apps/dashboard/src/data/status-report-updates.client.test.ts new file mode 100644 index 000000000..b9aa9b4a3 --- /dev/null +++ b/apps/dashboard/src/data/status-report-updates.client.test.ts @@ -0,0 +1,34 @@ +import { expect } from "@std/expect"; +import { describe, it } from "@std/testing/bdd"; + +import { + isComponentImpactList, + isPageComponentImpact, +} from "./status-report-updates.client"; + +describe("isPageComponentImpact", () => { + it("accepts known impacts only", () => { + expect(isPageComponentImpact("major_outage")).toBe(true); + expect(isPageComponentImpact("down")).toBe(false); + expect(isPageComponentImpact(1)).toBe(false); + }); +}); + +describe("isComponentImpactList", () => { + it("accepts structured impacts, including an empty list", () => { + expect( + isComponentImpactList([{ pageComponentId: 1, impact: "operational" }]), + ).toBe(true); + expect(isComponentImpactList([])).toBe(true); + }); + + it("rejects formatted strings, bad ids and unknown impacts", () => { + expect(isComponentImpactList(["1 → major_outage"])).toBe(false); + expect( + isComponentImpactList([{ pageComponentId: 0, impact: "operational" }]), + ).toBe(false); + expect( + isComponentImpactList([{ pageComponentId: 1, impact: "down" }]), + ).toBe(false); + }); +}); diff --git a/apps/dashboard/src/data/status-report-updates.client.ts b/apps/dashboard/src/data/status-report-updates.client.ts index 9d21ea24d..dfcb10edd 100644 --- a/apps/dashboard/src/data/status-report-updates.client.ts +++ b/apps/dashboard/src/data/status-report-updates.client.ts @@ -1,9 +1,13 @@ import type { StatusReportStatus } from "@openstatus/db/src/schema"; -import type { PageComponentImpact } from "@openstatus/db/src/schema/page_components/constants"; +import { + type PageComponentImpact, + pageComponentImpact, +} from "@openstatus/db/src/schema/page_components/constants"; import { Settings, Delete } from "@openstatus/icons"; import type { StatusVariant } from "@/components/common/status-dot"; import type { FormValues as StatusReportUpdateFormValues } from "@/components/forms/status-report-update/form"; +import { toEntityId } from "@/data/entity-refs.client"; export const impactConfig = { operational: { @@ -27,6 +31,38 @@ export const impactConfig = { { label: string; variant: StatusVariant } >; +export type ComponentImpact = { + pageComponentId: number; + impact: PageComponentImpact; +}; + +export function isPageComponentImpact( + value: unknown, +): value is PageComponentImpact { + return ( + typeof value === "string" && + (pageComponentImpact as readonly string[]).includes(value) + ); +} + +export function isComponentImpactList( + value: unknown, +): value is ComponentImpact[] { + return ( + Array.isArray(value) && + value.every( + (item: unknown) => + typeof item === "object" && + item !== null && + "pageComponentId" in item && + "impact" in item && + typeof item.pageComponentId === "number" && + toEntityId(item.pageComponentId) !== undefined && + isPageComponentImpact(item.impact), + ) + ); +} + /** Set equality regardless of order — used to skip no-op impact writes. */ export function impactsEqual( a: { pageComponentId: number; impact: string }[], diff --git a/apps/dashboard/src/lib/trpc/error.ts b/apps/dashboard/src/lib/trpc/error.ts index 3074b978c..22199bdb4 100644 --- a/apps/dashboard/src/lib/trpc/error.ts +++ b/apps/dashboard/src/lib/trpc/error.ts @@ -1,4 +1,5 @@ import { isTRPCClientError } from "@trpc/client"; +import { TRPCError } from "@trpc/server"; /** What to show for a failed call: the server's message, else `fallback`. */ export function errorMessage( @@ -7,3 +8,14 @@ export function errorMessage( ): string { return isTRPCClientError(error) ? error.message : fallback; } + +/** A NOT_FOUND from the browser client (`TRPCClientError`) or a server prefetch (`TRPCError`). */ +export function isNotFound(error: unknown) { + if (error instanceof TRPCError) return error.code === "NOT_FOUND"; + return isTRPCClientError(error) && error.data?.code === "NOT_FOUND"; +} + +/** A 404 the caller opted into (`expectNotFound`): a reference to a deleted entity, not a bug. */ +export function isExpectedNotFound(error: unknown, expected: unknown) { + return expected === true && isNotFound(error); +} diff --git a/apps/dashboard/src/lib/trpc/query-client.ts b/apps/dashboard/src/lib/trpc/query-client.ts index 2eb918378..7758e46ff 100644 --- a/apps/dashboard/src/lib/trpc/query-client.ts +++ b/apps/dashboard/src/lib/trpc/query-client.ts @@ -5,15 +5,19 @@ import { defaultShouldDehydrateQuery, } from "@tanstack/react-query"; +import { isExpectedNotFound } from "./error"; + export function makeQueryClient() { return new QueryClient({ // `prefetchQuery` swallows rejections, so the cache is the only place left // to observe a failed server-side prefetch. queryCache: new QueryCache({ - onError: (error, query) => + onError: (error, query) => { + if (isExpectedNotFound(error, query.meta?.expectNotFound)) return; Sentry.captureException(error, { extra: { queryHash: query.queryHash }, - }), + }); + }, }), defaultOptions: { queries: { diff --git a/apps/dashboard/src/lib/trpc/shared.ts b/apps/dashboard/src/lib/trpc/shared.ts index 501d3afa1..8da1b5847 100644 --- a/apps/dashboard/src/lib/trpc/shared.ts +++ b/apps/dashboard/src/lib/trpc/shared.ts @@ -4,6 +4,8 @@ import type { HTTPHeaders, TRPCLink } from "@trpc/client"; import { httpBatchStreamLink, loggerLink } from "@trpc/client"; import superjson from "superjson"; +import { isExpectedNotFound } from "./error"; + /** * tRPC logger link that reports failed queries to Sentry directly instead of * letting captureConsoleIntegration scrape tRPC's styled console.error format @@ -19,6 +21,9 @@ export const sentryLoggerLink = (): TRPCLink => (opts.direction === "down" && opts.result instanceof Error), logger: (opts) => { if (opts.direction === "down" && opts.result instanceof Error) { + if (isExpectedNotFound(opts.result, opts.context.expectNotFound)) { + return; + } Sentry.captureException(opts.result, { extra: { path: opts.path }, }); diff --git a/packages/services/AGENTS.md b/packages/services/AGENTS.md index 8822dd6cb..75bc4c810 100644 --- a/packages/services/AGENTS.md +++ b/packages/services/AGENTS.md @@ -39,6 +39,11 @@ Fail-closed: a failed audit insert rolls the mutation back. - **Strip secrets from snapshots** — credentials, bot tokens, raw API keys. - Action names are `{entity}.{verb}`; new variants go in the discriminated union at `packages/db/src/schema/audit_logs/validation.ts`. +- An entity type the dashboard can show also gets an `entityKinds` entry in + `apps/dashboard/src/data/entity-refs.client.ts`. Its `auditType` resolves the + audit row's entity cell; its `fields` resolve diff fields by name. These are + separate: list every `*Id`/`*Ids` field, or diffs show raw ids. A new `*Id`/`*Ids` field in an agent-tool schema fails + `entity-refs.coverage.test.ts` until it is mapped there or listed as not a ref. A missing `emitAudit` or `requireScope` is a defect, not a style nit. Both are lint-enforced; if a verb genuinely needs neither, the inline disable must carry diff --git a/packages/services/package.json b/packages/services/package.json index acbc1917b..5a53925f7 100644 --- a/packages/services/package.json +++ b/packages/services/package.json @@ -13,10 +13,6 @@ "import": "./src/status-report/index.ts", "types": "./src/status-report/index.ts" }, - "./status-report/utils": { - "import": "./src/status-report/utils.ts", - "types": "./src/status-report/utils.ts" - }, "./maintenance": { "import": "./src/maintenance/index.ts", "types": "./src/maintenance/index.ts" diff --git a/packages/services/src/status-report/utils.ts b/packages/services/src/status-report/utils.ts index c0035d6a7..b7e8d3484 100644 --- a/packages/services/src/status-report/utils.ts +++ b/packages/services/src/status-report/utils.ts @@ -1,4 +1,3 @@ -// dependency-free: imported by client bundles via the package subpath export. export function formatComponentImpacts( impacts: readonly { pageComponentId: number; impact: string }[], ): string[] {