diff --git a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx index 31791cca..3aa26f6e 100644 --- a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx @@ -1,7 +1,6 @@ "use client"; import type { IncidentStatus } from "@openstatus/db/src/schema/incidents/constants"; -import { Badge } from "@openstatus/ui/components/ui/badge"; import { Tabs, TabsContent, @@ -9,29 +8,50 @@ import { TabsTrigger, } from "@openstatus/ui/components/ui/tabs"; import { useQuery } from "@tanstack/react-query"; +import { formatDistanceStrict, formatDistanceToNow } from "date-fns"; import { useState } from "react"; +import { HoverCardTimestamp } from "@/components/common/hover-card-timestamp"; +import { Link } from "@/components/common/link"; +import { + DetailAside, + DetailContent, + DetailHeader, + DetailMain, + DetailMeta, + DetailMetaItem, + DetailSection, + DetailSectionTitle, +} from "@/components/content/detail"; import { EmptyStateContainer, EmptyStateTitle, } from "@/components/content/empty-state"; import { - Section, - SectionDescription, - SectionGroup, - SectionHeader, - SectionTitle, -} from "@/components/content/section"; + Property, + PropertyLabel, + PropertyList, + PropertyValue, +} from "@/components/content/property-list"; +import { SectionGroup } from "@/components/content/section"; +import { + IncidentActions, + hasIncidentActions, +} from "@/components/incidents/incident-actions"; import { IncidentComposer } from "@/components/incidents/incident-composer"; +import { IncidentHeading } from "@/components/incidents/incident-heading"; import { IncidentPostmortem } from "@/components/incidents/incident-postmortem"; import { IncidentProperties } from "@/components/incidents/incident-properties"; import { IncidentStatusReport } from "@/components/incidents/incident-status-report"; import { IncidentTimeline } from "@/components/incidents/incident-timeline"; import { ResolveReportDialog } from "@/components/incidents/resolve-report-dialog"; -import { severityConfig } from "@/data/managed-incidents.client"; +import { incidentEndedAt, personName } from "@/data/managed-incidents.client"; import { useFeature } from "@/hooks/use-feature"; import { useTRPC } from "@/lib/trpc/client"; -import { cn } from "@/lib/utils"; + +function slackChannelUrl(teamId: string, channelId: string): string { + return `https://slack.com/app_redirect?team=${teamId}&channel=${channelId}`; +} export function Client({ id }: { id: number }) { const trpc = useTRPC(); @@ -61,6 +81,8 @@ export function Client({ id }: { id: number }) { const canNotify = workspace?.limits["status-subscribers"] === true; const report = incident.statusReport; + const closed = incident.closedAt !== null; + const endedAt = incidentEndedAt(incident); const onStatusChanged = (status: IncidentStatus, note: string) => { if ( (status === "resolved" || status === "canceled") && @@ -78,38 +100,73 @@ export function Client({ id }: { id: number }) { }; return ( - -
- - - {incident.title} - - {severityConfig[incident.severity].label} - - - {incident.summary ? ( - {incident.summary} - ) : null} - -
- + + + + ) : null + } + /> + + + Declared by + + {personName(incident.declaredByUser) ?? "System"} + + + + + + + + {incident.closedAt ? ( + + Closed + + + + + ) : ( + + {endedAt ? "Lasted" : "Ongoing for"} + + {formatDistanceStrict( + incident.startedAt, + endedAt ?? new Date(), + )} + + + )} + + + + + - Timeline + + Timeline + {events ? ( + + {events.length} + + ) : null} + Postmortem - - {incident.closedAt === null ? ( + + {closed ? null : ( - ) : null} + )} @@ -119,15 +176,41 @@ export function Client({ id }: { id: number }) { /> - -
-
+ + + + Properties + + + + Communication + + + + Slack + + {incident.slackTeamId && incident.slackChannelId ? ( + + Open channel + + ) : ( + No channel + )} + + + + + + {report ? (
- - - Incidents - - Declare an incident when the team is responding to an outage: a - severity, a commander and a timeline, resolved and closed with a - postmortem. Monitor downtime is detected automatically and lives - on each monitor. - - - - - - + + Incidents + + Declare and track the incidents your team is responding to. + + {!incidents ? null : incidents.length === 0 ? ( No incidents declared @@ -71,7 +61,11 @@ export function Client() { ) : ( // incident.list already sorts open first, then newest declared. - + )}
diff --git a/apps/dashboard/src/app/(dashboard)/incidents/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/incidents/nav-actions.tsx index 768c5c97..24b6954b 100644 --- a/apps/dashboard/src/app/(dashboard)/incidents/nav-actions.tsx +++ b/apps/dashboard/src/app/(dashboard)/incidents/nav-actions.tsx @@ -1,9 +1,26 @@ +"use client"; + +import { Button } from "@openstatus/ui/components/ui/button"; +import { useParams } from "next/navigation"; + +import { DeclareIncidentButton } from "@/components/incidents/declare-incident-button"; import { NavFeedback } from "@/components/nav/nav-feedback"; +import { useFeature } from "@/hooks/use-feature"; export function NavActions() { + const params = useParams<{ id?: string }>(); + const enabled = useFeature("incident-management"); + // The layout also wraps /incidents/[id]; the detail page has its own actions. + const isList = !params.id; + return (
+ {enabled && isList ? ( + + + + ) : null}
); } diff --git a/apps/dashboard/src/app/(dashboard)/overview/layout.tsx b/apps/dashboard/src/app/(dashboard)/overview/layout.tsx index afea1916..ee1ac818 100644 --- a/apps/dashboard/src/app/(dashboard)/overview/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/overview/layout.tsx @@ -20,6 +20,10 @@ export default async function Layout({ queryClient.prefetchQuery(trpc.monitorIncident.list.queryOptions()), queryClient.prefetchQuery(trpc.statusReport.list.queryOptions({})), queryClient.prefetchQuery(trpc.maintenance.list.queryOptions()), + // Throws FORBIDDEN without the feature; prefetchQuery swallows it. + queryClient.prefetchQuery( + trpc.incident.list.queryOptions({ status: ["open", "mitigated"] }), + ), ]); return ( diff --git a/apps/dashboard/src/components/command-menu/command-menu.tsx b/apps/dashboard/src/components/command-menu/command-menu.tsx index bd544cf7..d717ac27 100644 --- a/apps/dashboard/src/components/command-menu/command-menu.tsx +++ b/apps/dashboard/src/components/command-menu/command-menu.tsx @@ -21,11 +21,13 @@ import { FormSheetMaintenanceCreate } from "@/components/forms/maintenance/sheet import { FormSheetStatusReportUpdateCreate } from "@/components/forms/status-report-update/sheet-create"; import { FormSheetStatusReportCreate } from "@/components/forms/status-report/sheet-create"; import { FormDialogSupportContact } from "@/components/forms/support-contact/dialog"; +import { DeclareIncidentButton } from "@/components/incidents/declare-incident-button"; import { scrollToHash, useScrollToHash } from "@/hooks/use-scroll-to-hash"; import { switchWorkspace } from "@/lib/workspace-cookie"; import { GroupView } from "./group-view"; import { + incidentsGroup, maintenancesGroup, monitorsGroup, statusPagesGroup, @@ -146,12 +148,13 @@ export function CommandMenu() { maintenances: data.sortedMaintenances, }) : [ + incidentsGroup(data.incidents), monitorsGroup(data.monitors), statusPagesGroup(data.statusPages), statusReportsGroup(data.sortedStatusReports), maintenancesGroup(data.sortedMaintenances, data.pageTitleById), - navigationGroup(), - createGroup(), + navigationGroup({ incidents: data.incidentsEnabled }), + createGroup({ incidents: data.incidentsEnabled }), settingsGroup(), workspacesGroup(data.otherWorkspaces, switchWorkspace), helpGroup(), @@ -255,6 +258,15 @@ export function CommandMenu() { open={activeSheet?.sheet === "support"} onOpenChange={(o) => setActiveSheet(o ? { sheet: "support" } : null)} /> + {/* Mounted on demand: the sheet fetches integrations as soon as it renders. */} + {data.incidentsEnabled && activeSheet?.sheet === "declare-incident" ? ( + { + if (!o) setActiveSheet(null); + }} + /> + ) : null} {reportForUpdate ? ( ({ + value: `incident-${i.id}`, + label: i.title, + description: `${i.status} · ${i.severity}`, + keywords: [i.title, i.status, i.severity, i.commander?.name].filter( + Boolean, + ) as string[], + action: { type: "navigate", href: `/incidents/${i.id}` }, + })), + }; +} export function monitorsGroup( monitors: Monitor[] | undefined, diff --git a/apps/dashboard/src/components/command-menu/groups/static.ts b/apps/dashboard/src/components/command-menu/groups/static.ts index a8c8e36c..66fcc069 100644 --- a/apps/dashboard/src/components/command-menu/groups/static.ts +++ b/apps/dashboard/src/components/command-menu/groups/static.ts @@ -2,15 +2,24 @@ import { Dark, Light } from "@openstatus/icons"; import { AtSign, KeyRound, Palette, Pencil, Plus, Users } from "lucide-react"; import { HELP_LINKS, HELP_SUPPORT } from "@/config/help"; -import { NAV_MENU_ITEMS } from "@/config/nav"; +import { NAV, NAV_MENU_ITEMS } from "@/config/nav"; import { SETTINGS_TABS } from "@/config/settings"; import type { CommandMenuGroup, CommandMenuItem } from "../types"; -export function navigationGroup(): CommandMenuGroup { +// Incident entries are feature-flagged. Unlike the sidebar, Incidents sits +// right below Overview: the palette is for reaching the urgent thing fast. +export function navigationGroup({ + incidents, +}: { + incidents: boolean; +}): CommandMenuGroup { + const items = incidents + ? [...NAV_MENU_ITEMS.slice(0, 1), NAV.incidents, ...NAV_MENU_ITEMS.slice(1)] + : NAV_MENU_ITEMS; return { heading: "Navigation", - items: NAV_MENU_ITEMS.map((item) => ({ + items: items.map((item) => ({ value: item.label, label: item.label, icon: item.icon, @@ -20,6 +29,16 @@ export function navigationGroup(): CommandMenuGroup { }; } +export function declareIncidentItem(): CommandMenuItem { + return { + value: "Declare Incident", + label: "Declare Incident", + icon: Plus, + keywords: ["new incident", "outage", "commander", "severity"], + action: { type: "sheet", sheet: { sheet: "declare-incident" } }, + }; +} + export function createStatusReportItem(pageId?: number): CommandMenuItem { return { value: pageId ? "create-status-report" : "Create Status Report", @@ -40,10 +59,15 @@ export function createMaintenanceItem(pageId?: number): CommandMenuItem { }; } -export function createGroup(): CommandMenuGroup { +export function createGroup({ + incidents, +}: { + incidents: boolean; +}): CommandMenuGroup { return { heading: "Create", items: [ + ...(incidents ? [declareIncidentItem()] : []), { value: "Create Monitor", label: "Create Monitor", diff --git a/apps/dashboard/src/components/command-menu/types.ts b/apps/dashboard/src/components/command-menu/types.ts index df779a49..859f8277 100644 --- a/apps/dashboard/src/components/command-menu/types.ts +++ b/apps/dashboard/src/components/command-menu/types.ts @@ -10,6 +10,7 @@ type CommandSheetContext = { maintenance: { pageId?: number }; support: Record; "status-report-update": { reportId: number }; + "declare-incident": Record; }; export type CommandSheet = { diff --git a/apps/dashboard/src/components/command-menu/use-command-menu-data.ts b/apps/dashboard/src/components/command-menu/use-command-menu-data.ts index 7296f31e..dea8196d 100644 --- a/apps/dashboard/src/components/command-menu/use-command-menu-data.ts +++ b/apps/dashboard/src/components/command-menu/use-command-menu-data.ts @@ -2,10 +2,12 @@ import { useQuery } from "@tanstack/react-query"; +import { useFeature } from "@/hooks/use-feature"; import { useTRPC } from "@/lib/trpc/client"; export function useCommandMenuData({ open }: { open: boolean }) { const trpc = useTRPC(); + const incidentsEnabled = useFeature("incident-management"); // `enabled: open` keeps the always-mounted palette from fetching on every // dashboard load; data stays cached once fetched. @@ -29,6 +31,13 @@ export function useCommandMenuData({ open }: { open: boolean }) { const { data: maintenances } = useQuery( trpc.maintenance.list.queryOptions(undefined, { enabled: open }), ); + // Server rejects the query without the feature — never fire it unflagged. + // Server order is already open → mitigated → newest declared. + const { data: incidents } = useQuery( + trpc.incident.list.queryOptions(undefined, { + enabled: open && incidentsEnabled, + }), + ); const otherWorkspaces = workspaces?.filter((w) => w.slug !== workspace?.slug) ?? []; @@ -55,6 +64,8 @@ export function useCommandMenuData({ open }: { open: boolean }) { const pageTitleById = new Map(statusPages?.map((p) => [p.id, p.title]) ?? []); return { + incidentsEnabled, + incidents: incidentsEnabled ? incidents : undefined, monitors, statusPages, statusReports, diff --git a/apps/dashboard/src/components/common/status-badge.tsx b/apps/dashboard/src/components/common/status-badge.tsx new file mode 100644 index 00000000..b215ee5e --- /dev/null +++ b/apps/dashboard/src/components/common/status-badge.tsx @@ -0,0 +1,45 @@ +import { Badge } from "@openstatus/ui/components/ui/badge"; +import { cn } from "@openstatus/ui/lib/utils"; +import { type VariantProps, cva } from "class-variance-authority"; + +import { StatusDot, type StatusVariant } from "./status-dot"; + +const statusBadgeVariants = cva("", { + variants: { + variant: { + default: "bg-muted/50", + success: "border-success/20 bg-success/10 text-success", + warning: "border-warning/20 bg-warning/10 text-warning", + destructive: "border-destructive/20 bg-destructive/10 text-destructive", + info: "border-info/20 bg-info/10 text-info", + } satisfies Record, + }, + defaultVariants: { + variant: "default", + }, +}); + +/** Tinted by `variant`; with `dot` it stays neutral and the dot carries the color. */ +export function StatusBadge({ + children, + className, + variant, + dot = false, + ...props +}: Omit, "variant"> & + VariantProps & { dot?: boolean }) { + return ( + + {dot ? : null} + {children} + + ); +} diff --git a/apps/dashboard/src/components/common/status-dot.tsx b/apps/dashboard/src/components/common/status-dot.tsx new file mode 100644 index 00000000..c7c173cd --- /dev/null +++ b/apps/dashboard/src/components/common/status-dot.tsx @@ -0,0 +1,36 @@ +import { cn } from "@openstatus/ui/lib/utils"; +import { type VariantProps, cva } from "class-variance-authority"; + +const statusDotVariants = cva("inline-block size-2 shrink-0 rounded-full", { + variants: { + variant: { + default: "bg-muted-foreground", + success: "bg-success", + warning: "bg-warning", + destructive: "bg-destructive", + info: "bg-info", + }, + }, + defaultVariants: { + variant: "default", + }, +}); + +export type StatusVariant = NonNullable< + VariantProps["variant"] +>; + +export function StatusDot({ + className, + variant, + ...props +}: React.ComponentProps<"span"> & VariantProps) { + return ( +