From daa6461b54c41c66a8a07154f6e8b1823883e07f Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Ducasse Date: Mon, 28 Sep 2026 15:42:24 +0200 Subject: [PATCH] dashboard: Overview + monitor tab hooks, Downtime relabel Co-Authored-By: Claude Opus 5.5 --- .../app/(dashboard)/incidents/[id]/page.tsx | 25 ++++- .../(dashboard)/monitors/[id]/constants.ts | 2 +- .../monitors/[id]/incidents/page.tsx | 17 ++- .../src/app/(dashboard)/overview/page.tsx | 25 ++++- .../data-table/incidents/columns.tsx | 42 +++++-- .../data-table/overview-events/columns.tsx | 105 +++++++++++------- .../components/incidents/declare-from-row.tsx | 60 ++++++++++ .../src/data/overview-events.client.ts | 2 +- 8 files changed, 223 insertions(+), 55 deletions(-) create mode 100644 apps/dashboard/src/components/incidents/declare-from-row.tsx diff --git a/apps/dashboard/src/app/(dashboard)/incidents/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/incidents/[id]/page.tsx index 8c4ec5c0..5566255c 100644 --- a/apps/dashboard/src/app/(dashboard)/incidents/[id]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/incidents/[id]/page.tsx @@ -1,5 +1,7 @@ import { notFound } from "next/navigation"; +import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; + import { Client } from "./client"; export default async function Page({ @@ -10,5 +12,26 @@ export default async function Page({ const { id } = await params; const incidentId = Number(id); if (!Number.isInteger(incidentId)) return notFound(); - return ; + + const queryClient = getQueryClient(); + // Throws FORBIDDEN without the feature / NOT_FOUND for a foreign id; + // prefetchQuery swallows both and the client renders the empty state. + await Promise.all([ + queryClient.prefetchQuery( + trpc.incident.get.queryOptions({ id: incidentId }), + ), + queryClient.prefetchQuery( + trpc.incident.listEvents.queryOptions({ id: incidentId }), + ), + queryClient.prefetchQuery(trpc.member.list.queryOptions()), + queryClient.prefetchQuery( + trpc.statusReport.list.queryOptions({ order: "desc" }), + ), + ]); + + return ( + + + + ); } diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/constants.ts b/apps/dashboard/src/app/(dashboard)/monitors/[id]/constants.ts index 0cd92788..c4ed02b5 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/constants.ts +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/constants.ts @@ -8,6 +8,6 @@ export const MONITOR_TABS: { }[] = [ { value: "overview", label: "Overview", icon: Overview }, { value: "logs", label: "Logs", icon: Logs }, - { value: "incidents", label: "Incidents", icon: Incident }, + { value: "incidents", label: "Downtime", icon: Incident }, { value: "edit", label: "Settings", icon: Settings }, ]; diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/page.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/page.tsx index d623f156..adae79f4 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/incidents/page.tsx @@ -3,6 +3,7 @@ import { Info } from "@openstatus/icons"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; +import { useMemo } from "react"; import { Link } from "@/components/common/link"; import { Note } from "@/components/common/note"; @@ -18,9 +19,10 @@ import { SectionHeader, SectionTitle, } from "@/components/content/section"; -import { columns } from "@/components/data-table/incidents/columns"; +import { getColumns } from "@/components/data-table/incidents/columns"; import { DataTable } from "@/components/ui/data-table/data-table"; import { DataTablePaginationSimple } from "@/components/ui/data-table/data-table-pagination"; +import { useFeature } from "@/hooks/use-feature"; import { useTRPC } from "@/lib/trpc/client"; export default function Page() { @@ -34,6 +36,11 @@ export default function Page() { const { data: monitor } = useQuery( trpc.monitor.get.queryOptions({ id: Number.parseInt(id) }), ); + const incidentsEnabled = useFeature("incident-management"); + const columns = useMemo( + () => getColumns({ declare: incidentsEnabled }), + [incidentsEnabled], + ); if (!incidents || !monitor) return null; @@ -42,8 +49,8 @@ export default function Page() {

- Incidents are automatically created when a monitor detects downtime. - To communicate updates to your users, use Status Reports on a{" "} + Downtime is recorded automatically when a monitor fails. To tell your + users, use Status Reports on a{" "} Status Page.

@@ -62,9 +69,9 @@ export default function Page() { {incidents.length === 0 ? ( - No incidents + No downtime - No incidents found for this monitor. + No downtime recorded for this monitor. ) : ( diff --git a/apps/dashboard/src/app/(dashboard)/overview/page.tsx b/apps/dashboard/src/app/(dashboard)/overview/page.tsx index af533dc1..19bac0ee 100644 --- a/apps/dashboard/src/app/(dashboard)/overview/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/overview/page.tsx @@ -3,6 +3,7 @@ import { Agent } from "@openstatus/icons"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; +import { useMemo } from "react"; import { NoteButton } from "@/components/common/note"; import { NoteDismissible } from "@/components/common/note-dismissible"; @@ -18,7 +19,8 @@ import { SectionTitle, } from "@/components/content/section"; import { Section } from "@/components/content/section"; -import { columns } from "@/components/data-table/overview-events/columns"; +import { columns as incidentColumns } from "@/components/data-table/managed-incidents/columns"; +import { getColumns } from "@/components/data-table/overview-events/columns"; import { MetricCard, MetricCardGroup, @@ -29,6 +31,7 @@ import { } from "@/components/metric/metric-card"; import { DataTable } from "@/components/ui/data-table/data-table"; import { buildOverviewData } from "@/data/overview-events.client"; +import { useFeature } from "@/hooks/use-feature"; import { useTRPC } from "@/lib/trpc/client"; import { CreateEventButtonGroup } from "./create-event-button-group"; @@ -46,6 +49,15 @@ export default function Page() { trpc.statusReport.list.queryOptions({}), ); const { data: maintenances } = useQuery(trpc.maintenance.list.queryOptions()); + const incidentsEnabled = useFeature("incident-management"); + const { data: openIncidents } = useQuery({ + ...trpc.incident.list.queryOptions({ status: ["open", "mitigated"] }), + enabled: incidentsEnabled, + }); + const columns = useMemo( + () => getColumns({ declare: incidentsEnabled }), + [incidentsEnabled], + ); if (!monitors || !pages || !incidents || !statusReports || !maintenances) return null; @@ -107,6 +119,17 @@ export default function Page() { })} + {openIncidents?.length ? ( +
+ + Open Incidents + + Incidents your team is responding to. + + + +
+ ) : null}
Needs Attention diff --git a/apps/dashboard/src/components/data-table/incidents/columns.tsx b/apps/dashboard/src/components/data-table/incidents/columns.tsx index 0edc7f04..1bf3c22e 100644 --- a/apps/dashboard/src/components/data-table/incidents/columns.tsx +++ b/apps/dashboard/src/components/data-table/incidents/columns.tsx @@ -7,13 +7,14 @@ import { formatDistanceStrict } from "date-fns"; 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 { DeclareFromRow } from "@/components/incidents/declare-from-row"; import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; import { DataTableRowActions } from "./data-table-row-actions"; type Incident = RouterOutputs["monitorIncident"]["list"][number]; -export const columns: ColumnDef[] = [ +const baseColumns: ColumnDef[] = [ { id: "monitor", accessorFn: (row) => row.monitor.name, @@ -72,11 +73,36 @@ export const columns: ColumnDef[] = [ return ; }, }, - { - id: "actions", - cell: ({ row }) => , - meta: { - cellClassName: "w-8", - }, - }, ]; + +const declareColumn: ColumnDef = { + id: "declare", + header: () => null, + cell: ({ row }) => + row.original.resolvedAt ? null : ( + + ), + enableSorting: false, + meta: { + cellClassName: "w-[90px] text-right", + }, +}; + +const actionsColumn: ColumnDef = { + id: "actions", + cell: ({ row }) => , + meta: { + cellClassName: "w-8", + }, +}; + +/** `declare` adds the incident-management quick action; omit it when the feature is off. */ +export function getColumns({ declare }: { declare: boolean }) { + return declare + ? [...baseColumns, declareColumn, actionsColumn] + : [...baseColumns, actionsColumn]; +} diff --git a/apps/dashboard/src/components/data-table/overview-events/columns.tsx b/apps/dashboard/src/components/data-table/overview-events/columns.tsx index 82980b3d..58279932 100644 --- a/apps/dashboard/src/components/data-table/overview-events/columns.tsx +++ b/apps/dashboard/src/components/data-table/overview-events/columns.tsx @@ -17,6 +17,7 @@ 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 { FormSheetStatusReportUpdateCreate } from "@/components/forms/status-report-update/sheet-create"; +import { DeclareFromRow } from "@/components/incidents/declare-from-row"; import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; import { type OverviewEvent, @@ -70,7 +71,7 @@ function getDuration(event: OverviewEvent): string | null { return formatDistanceStrict(startedAt, resolvedAt); } -export const columns: ColumnDef[] = [ +const baseColumns: ColumnDef[] = [ { id: "type", accessorFn: (row) => row.type, @@ -159,47 +160,75 @@ export const columns: ColumnDef[] = [ }, enableSorting: false, }, - { - id: "action", - header: () => null, - cell: ({ row }) => { - const event = row.original; - switch (event.type) { - case "incident": - return ; - case "report": - if (event.report.status === "resolved") return null; - return ( +]; + +const actionColumn = (declare: boolean): ColumnDef => ({ + id: "action", + header: () => null, + cell: ({ row }) => { + const event = row.original; + switch (event.type) { + case "incident": + return ( +
+ {!declare || event.incident.resolvedAt ? null : ( + + )} + +
+ ); + case "report": + if (event.report.status === "resolved") return null; + return ( +
+ {declare ? ( + + ) : null} - ); - case "maintenance": - return null; - } - }, - enableSorting: false, - meta: { - cellClassName: "w-[110px] text-right", - }, +
+ ); + case "maintenance": + return null; + } }, - { - id: "actions", - cell: ({ row }) => { - const event = row.original; - switch (event.type) { - case "incident": - return ; - case "report": - return ; - case "maintenance": - return ; - } - }, - meta: { - cellClassName: "w-8", - }, + enableSorting: false, + meta: { + cellClassName: cn("text-right", declare ? "w-[180px]" : "w-[110px]"), }, -]; +}); + +const actionsColumn: ColumnDef = { + id: "actions", + cell: ({ row }) => { + const event = row.original; + switch (event.type) { + case "incident": + return ; + case "report": + return ; + case "maintenance": + return ; + } + }, + meta: { + cellClassName: "w-8", + }, +}; + +/** `declare` adds the incident-management quick action; omit it when the feature is off. */ +export function getColumns({ declare }: { declare: boolean }) { + return [...baseColumns, actionColumn(declare), actionsColumn]; +} diff --git a/apps/dashboard/src/components/incidents/declare-from-row.tsx b/apps/dashboard/src/components/incidents/declare-from-row.tsx new file mode 100644 index 00000000..6f83f99b --- /dev/null +++ b/apps/dashboard/src/components/incidents/declare-from-row.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Button } from "@openstatus/ui/components/ui/button"; +import { useQuery } from "@tanstack/react-query"; + +import { toLocalInput } from "@/components/forms/incident/form"; +import { useFeature } from "@/hooks/use-feature"; +import { useTRPC } from "@/lib/trpc/client"; + +import { + DeclareIncidentButton, + type DeclareSource, +} from "./declare-incident-button"; + +/** "Declare" quick action on a downtime or status-report row, prefilled from it. */ +export function DeclareFromRow({ + title, + startedAt, + source, + statusReportId, +}: { + title: string; + startedAt: Date; + source: DeclareSource; + statusReportId?: number; +}) { + const trpc = useTRPC(); + const enabled = useFeature("incident-management"); + // A report can belong to one incident; declaring another would fail on submit. + const { data: incidents } = useQuery({ + ...trpc.incident.list.queryOptions(), + enabled: enabled && statusReportId !== undefined, + }); + if (!enabled) return null; + if (incidents?.some((i) => i.statusReport?.id === statusReportId)) { + return null; + } + return ( + + + + ); +} diff --git a/apps/dashboard/src/data/overview-events.client.ts b/apps/dashboard/src/data/overview-events.client.ts index 479b3028..1f77612e 100644 --- a/apps/dashboard/src/data/overview-events.client.ts +++ b/apps/dashboard/src/data/overview-events.client.ts @@ -18,7 +18,7 @@ export type OverviewEvent = | { type: "maintenance"; maintenance: Maintenance }; export const eventTypeConfig = { - incident: { label: "Incident", icon: IncidentIcon }, + incident: { label: "Downtime", icon: IncidentIcon }, report: { label: "Status Report", icon: Report }, maintenance: { label: "Maintenance", icon: MaintenanceIcon }, } as const; -- 2.51.2