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"}
+
+
+
+
+
+ {formatDistanceToNow(incident.declaredAt, { addSuffix: true })}
+
+
+
+ {incident.closedAt ? (
+
+ Closed
+
+
+ {formatDistanceToNow(incident.closedAt, { addSuffix: true })}
+
+
+
+ ) : (
+
+ {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 }) {
/>
-
-
-
-
Status report
-
-
-
-
-
+
+
+
+ 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.
-
-
-
- Declare incident
-
-
+
+ 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 ? (
+
+ Declare incident
+
+ ) : 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 (
+
+ );
+}
diff --git a/apps/dashboard/src/components/common/user-avatar.tsx b/apps/dashboard/src/components/common/user-avatar.tsx
new file mode 100644
index 00000000..9ef10e0c
--- /dev/null
+++ b/apps/dashboard/src/components/common/user-avatar.tsx
@@ -0,0 +1,25 @@
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@openstatus/ui/components/ui/avatar";
+import { cn } from "@openstatus/ui/lib/utils";
+
+export function UserAvatar({
+ name,
+ src,
+ className,
+ ...props
+}: Omit, "children"> & {
+ name: string | null;
+ src?: string | null;
+}) {
+ return (
+
+ {src ? : null}
+
+ {Array.from(name ?? "")[0] ?? "?"}
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/content/composer.tsx b/apps/dashboard/src/components/content/composer.tsx
new file mode 100644
index 00000000..7f2754a7
--- /dev/null
+++ b/apps/dashboard/src/components/content/composer.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupTextarea,
+} from "@openstatus/ui/components/ui/input-group";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger,
+} from "@openstatus/ui/components/ui/tabs";
+import { cn } from "@openstatus/ui/lib/utils";
+
+import { ProcessMessage } from "@/components/content/process-message";
+
+/** Markdown composer: write/preview tabs around a textarea, with a footer for actions. */
+export function Composer({
+ children,
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export function ComposerHeader({
+ children,
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+const tabsTriggerClassName =
+ "text-muted-foreground hover:text-foreground data-[state=active]:text-foreground dark:data-[state=active]:bg-transparent h-7 flex-none data-[state=active]:bg-transparent data-[state=active]:shadow-none dark:data-[state=active]:border-transparent";
+
+export function ComposerTabs({
+ className,
+ ...props
+}: Omit, "children">) {
+ return (
+
+
+ Write
+
+
+ Preview
+
+
+ );
+}
+
+export function ComposerHint({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function ComposerTextarea({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export function ComposerPreview({
+ value,
+ className,
+ ...props
+}: Omit, "children"> & { value: string }) {
+ return (
+
+
+ {value.trim() ? (
+
+ ) : (
+
Nothing to preview.
+ )}
+
+
+ );
+}
+
+export function ComposerFooter({
+ children,
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/dashboard/src/components/content/detail.tsx b/apps/dashboard/src/components/content/detail.tsx
new file mode 100644
index 00000000..aa74c7fc
--- /dev/null
+++ b/apps/dashboard/src/components/content/detail.tsx
@@ -0,0 +1,328 @@
+"use client";
+
+import { Textarea } from "@openstatus/ui/components/ui/textarea";
+import { cn } from "@openstatus/ui/lib/utils";
+import { type VariantProps, cva } from "class-variance-authority";
+import { useState } from "react";
+
+export function DetailHeader({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"header">) {
+ return (
+
+ );
+}
+
+export function DetailEyebrow({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function DetailActions({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** Title on the left, `DetailActions` on the right, on one line. */
+export function DetailTitleRow({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function DetailTitle({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"h1">) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function DetailDescription({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"p">) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** Edit-in-place field for `DetailTitle` / `DetailDescription`; commits on blur. */
+export function DetailInput({
+ value,
+ onCommit,
+ multiline = false,
+ className,
+ ...props
+}: Omit<
+ React.ComponentProps,
+ "value" | "defaultValue" | "onBlur" | "onKeyDown" | "onInput"
+> & {
+ value: string;
+ onCommit: (value: string) => void;
+ multiline?: boolean;
+}) {
+ // Keyed by the server value: a new one resets the draft as it remounts.
+ const [draft, setDraft] = useState({ base: value, text: value });
+ const text = draft.base === value ? draft.text : value;
+
+ return (
+
+ {/* Invisible mirror in the same cell sizes the textarea in every browser. */}
+
+ {text}{" "}
+
+
+ );
+}
+
+export function DetailMeta({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function DetailMetaItem({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function DetailContent({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function DetailMain({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function DetailAside({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"aside">) {
+ return (
+
+ );
+}
+
+export function DetailSection({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"section">) {
+ return (
+
+ );
+}
+
+export function DetailSectionHeader({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ {children}
+
+ );
+}
+
+const detailSectionTitleVariants = cva("", {
+ variants: {
+ variant: {
+ label:
+ "text-muted-foreground text-xs font-medium tracking-wide uppercase",
+ heading: "text-base font-medium",
+ },
+ },
+ defaultVariants: {
+ variant: "label",
+ },
+});
+
+export function DetailSectionTitle({
+ children,
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"h2"> &
+ VariantProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/dashboard/src/components/content/property-list.tsx b/apps/dashboard/src/components/content/property-list.tsx
new file mode 100644
index 00000000..966177f2
--- /dev/null
+++ b/apps/dashboard/src/components/content/property-list.tsx
@@ -0,0 +1,114 @@
+import { Input } from "@openstatus/ui/components/ui/input";
+import { SelectTrigger } from "@openstatus/ui/components/ui/select";
+import { cn } from "@openstatus/ui/lib/utils";
+
+export function PropertyList({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"dl">) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function Property({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function PropertyLabel({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"dt">) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function PropertyValue({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"dd">) {
+ return (
+
+ {children}
+
+ );
+}
+
+// Borderless until hovered; -ml-2 keeps the control's text on the value column's edge.
+const propertyControlClassName =
+ "hover:bg-accent dark:hover:bg-accent/50 -ml-2 h-8 w-[calc(100%+0.5rem)] min-w-0 border-transparent bg-transparent px-2 shadow-none dark:bg-transparent";
+
+export function PropertySelectTrigger({
+ children,
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+// The native date picker icon is pinned right to line up with select chevrons.
+export function PropertyInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/components/content/timeline.tsx b/apps/dashboard/src/components/content/timeline.tsx
new file mode 100644
index 00000000..31f7130f
--- /dev/null
+++ b/apps/dashboard/src/components/content/timeline.tsx
@@ -0,0 +1,190 @@
+import { cn } from "@openstatus/ui/lib/utils";
+import { type VariantProps, cva } from "class-variance-authority";
+import { formatDistanceToNowStrict } from "date-fns";
+
+import { HoverCardTimestamp } from "@/components/common/hover-card-timestamp";
+
+export function Timeline({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"ol">) {
+ return (
+
+ {children}
+
+ );
+}
+
+// The rail is drawn per item, from below its indicator down to the next one.
+export function TimelineItem({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+
+ {children}
+
+ );
+}
+
+const timelineIndicatorVariants = cva(
+ "flex size-8 shrink-0 items-center justify-center rounded-full border [&>svg]:size-3.5",
+ {
+ variants: {
+ variant: {
+ default: "border-border bg-background text-muted-foreground",
+ 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",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+export function TimelineIndicator({
+ children,
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> &
+ VariantProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+// pt-1.5 centers the first text line on the indicator.
+export function TimelineContent({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ {children}
+
+ );
+}
+
+// The time stays top-right; the title group wraps underneath itself.
+export function TimelineHeader({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function TimelineTitle({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function TimelineMeta({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function TimelineTime({
+ date,
+ children,
+ className,
+ ...props
+}: Omit, "dateTime"> & { date: Date }) {
+ return (
+
+
+ {children ?? formatDistanceToNowStrict(date, { addSuffix: true })}
+
+
+ );
+}
+
+export function TimelineBody({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/dashboard/src/components/data-table/managed-incidents/columns.tsx b/apps/dashboard/src/components/data-table/managed-incidents/columns.tsx
index 138712dc..b5f4c34e 100644
--- a/apps/dashboard/src/components/data-table/managed-incidents/columns.tsx
+++ b/apps/dashboard/src/components/data-table/managed-incidents/columns.tsx
@@ -1,21 +1,17 @@
"use client";
import type { RouterOutputs } from "@openstatus/api";
-import { Badge } from "@openstatus/ui/components/ui/badge";
import type { ColumnDef } from "@tanstack/react-table";
import { formatDistanceStrict } from "date-fns";
-import { Link } from "@/components/common/link";
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 {
- personName,
- severityConfig,
- statusConfig,
-} from "@/data/managed-incidents.client";
-import { cn } from "@/lib/utils";
+ IncidentSeverityBadge,
+ IncidentStatusBadge,
+} from "@/components/incidents/incident-badge";
type ManagedIncident = NonNullable[number];
@@ -24,23 +20,19 @@ export const columns: ColumnDef[] = [
accessorKey: "severity",
header: "Severity",
enableSorting: false,
- cell: ({ row }) => {
- const config = severityConfig[row.original.severity];
- return (
-
- {config.label}
-
- );
- },
+ cell: ({ row }) => (
+
+ ),
},
{
accessorKey: "title",
header: "Title",
enableSorting: false,
cell: ({ row }) => (
-
- {row.original.title}
-
+
),
meta: { cellClassName: "max-w-[260px] truncate" },
},
@@ -48,23 +40,11 @@ export const columns: ColumnDef[] = [
accessorKey: "status",
header: "Status",
enableSorting: false,
- cell: ({ row }) => {
- const config = statusConfig[row.original.status];
- return (
-
- {config.label}
- {row.original.closedAt && row.original.status !== "canceled"
- ? " · closed"
- : null}
-
- );
- },
- },
- {
- id: "commander",
- header: "Commander",
cell: ({ row }) => (
-
+
),
},
{
@@ -90,26 +70,4 @@ export const columns: ColumnDef[] = [
return ;
},
},
- {
- id: "statusReport",
- header: "Status report",
- cell: ({ row }) => (
-
- ),
- meta: { cellClassName: "max-w-[200px] truncate" },
- },
- {
- id: "slack",
- header: "Slack",
- cell: ({ row }) => {
- const { slackTeamId, slackChannelId } = row.original;
- const bound = slackTeamId && slackChannelId;
- return (
-
- );
- },
- },
];
diff --git a/apps/dashboard/src/components/dropdowns/quick-actions.tsx b/apps/dashboard/src/components/dropdowns/quick-actions.tsx
index 74da943b..27ae9596 100644
--- a/apps/dashboard/src/components/dropdowns/quick-actions.tsx
+++ b/apps/dashboard/src/components/dropdowns/quick-actions.tsx
@@ -107,6 +107,7 @@ export function QuickActions({
{...props}
>
+ Actions
)}
diff --git a/apps/dashboard/src/components/forms/incident/sheet.tsx b/apps/dashboard/src/components/forms/incident/sheet.tsx
index 972dce17..a1fede2e 100644
--- a/apps/dashboard/src/components/forms/incident/sheet.tsx
+++ b/apps/dashboard/src/components/forms/incident/sheet.tsx
@@ -22,22 +22,30 @@ import {
export function FormSheetDeclareIncident({
children,
+ open: controlledOpen,
+ onOpenChange,
defaultValues,
onSubmit,
footer,
slack,
}: {
- children: React.ReactNode;
+ children?: React.ReactNode;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
defaultValues?: Partial;
onSubmit: (values: DeclareIncidentValues) => Promise;
footer?: React.ReactNode;
slack: "ready" | "reconnect" | "disconnected";
}) {
- const [open, setOpen] = useState(false);
+ const [internalOpen, setInternalOpen] = useState(false);
+ const open = controlledOpen ?? internalOpen;
+ const setOpen = onOpenChange ?? setInternalOpen;
return (
- {children}
+ {children ? (
+ {children}
+ ) : null}
Declare incident
diff --git a/apps/dashboard/src/components/incidents/declare-incident-button.tsx b/apps/dashboard/src/components/incidents/declare-incident-button.tsx
index 3f591232..ccd91394 100644
--- a/apps/dashboard/src/components/incidents/declare-incident-button.tsx
+++ b/apps/dashboard/src/components/incidents/declare-incident-button.tsx
@@ -15,11 +15,15 @@ export type DeclareSource = {
/** Opens the declare sheet and lands on the new incident once declared. */
export function DeclareIncidentButton({
children,
+ open,
+ onOpenChange,
defaultValues,
source,
footer,
}: {
- children: React.ReactNode;
+ children?: React.ReactNode;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
defaultValues?: Partial;
source?: DeclareSource;
footer?: React.ReactNode;
@@ -51,6 +55,8 @@ export function DeclareIncidentButton({
return (
;
+
+/** Whether `IncidentActions` renders anything; lets the page skip the row. */
+export function hasIncidentActions(incident: Incident): boolean {
+ return (
+ (incident.status === "resolved" && incident.closedAt === null) ||
+ incident.deletable
+ );
+}
+
+export function IncidentActions({ incident }: { incident: Incident }) {
+ const trpc = useTRPC();
+ const router = useRouter();
+ const queryClient = useQueryClient();
+ const invalidate = useInvalidateIncident(incident.id);
+ const { data: postmortem } = useQuery(
+ trpc.incident.getPostmortem.queryOptions({ id: incident.id }),
+ );
+ const approved = postmortem?.status === "approved";
+
+ const close = useMutation(
+ trpc.incident.close.mutationOptions({
+ onSuccess: invalidate,
+ onError: (error) => {
+ toast.error(
+ isTRPCClientError(error) ? error.message : "Failed to close",
+ );
+ },
+ }),
+ );
+ const remove = useMutation(
+ trpc.incident.delete.mutationOptions({
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({
+ queryKey: trpc.incident.list.queryKey(),
+ });
+ router.push("/incidents");
+ },
+ }),
+ );
+
+ if (!hasIncidentActions(incident)) return null;
+ const closable = incident.status === "resolved" && incident.closedAt === null;
+
+ return (
+ <>
+ {closable ? (
+
+ close.mutate({
+ id: incident.id,
+ skipPostmortem: approved ? undefined : true,
+ })
+ }
+ >
+ {approved ? "Close incident" : "Close (skip postmortem)"}
+
+ ) : null}
+ {incident.deletable ? (
+ {
+ await remove.mutateAsync({ id: incident.id });
+ },
+ }}
+ />
+ ) : null}
+ >
+ );
+}
diff --git a/apps/dashboard/src/components/incidents/incident-badge.tsx b/apps/dashboard/src/components/incidents/incident-badge.tsx
new file mode 100644
index 00000000..8042ebe1
--- /dev/null
+++ b/apps/dashboard/src/components/incidents/incident-badge.tsx
@@ -0,0 +1,38 @@
+import type {
+ IncidentSeverity,
+ IncidentStatus,
+} from "@openstatus/db/src/schema/incidents/constants";
+
+import { StatusBadge } from "@/components/common/status-badge";
+import { severityConfig, statusConfig } from "@/data/managed-incidents.client";
+
+type StatusBadgeProps = Omit<
+ React.ComponentProps,
+ "children" | "variant" | "dot"
+>;
+
+export function IncidentStatusBadge({
+ status,
+ closed = false,
+ ...props
+}: StatusBadgeProps & { status: IncidentStatus; closed?: boolean }) {
+ const config = statusConfig[status];
+ return (
+
+ {config.label}
+ {closed && status !== "canceled" ? " · closed" : null}
+
+ );
+}
+
+export function IncidentSeverityBadge({
+ severity,
+ ...props
+}: StatusBadgeProps & { severity: IncidentSeverity }) {
+ const config = severityConfig[severity];
+ return (
+
+ {config.label}
+
+ );
+}
diff --git a/apps/dashboard/src/components/incidents/incident-composer.tsx b/apps/dashboard/src/components/incidents/incident-composer.tsx
index fde76f1e..c8994b90 100644
--- a/apps/dashboard/src/components/incidents/incident-composer.tsx
+++ b/apps/dashboard/src/components/incidents/incident-composer.tsx
@@ -9,23 +9,26 @@ import {
SelectTrigger,
SelectValue,
} from "@openstatus/ui/components/ui/select";
-import {
- Tabs,
- TabsContent,
- TabsList,
- TabsTrigger,
-} from "@openstatus/ui/components/ui/tabs";
-import { Textarea } from "@openstatus/ui/components/ui/textarea";
-import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useMutation, useQuery } from "@tanstack/react-query";
import { isTRPCClientError } from "@trpc/client";
import { useState } from "react";
import { toast } from "sonner";
-import { ProcessMessage } from "@/components/content/process-message";
-import { statusConfig } from "@/data/managed-incidents.client";
+import { StatusDot } from "@/components/common/status-dot";
+import { UserAvatar } from "@/components/common/user-avatar";
+import {
+ Composer,
+ ComposerFooter,
+ ComposerHeader,
+ ComposerHint,
+ ComposerPreview,
+ ComposerTabs,
+ ComposerTextarea,
+} from "@/components/content/composer";
+import { personName, statusConfig } from "@/data/managed-incidents.client";
import { useTRPC } from "@/lib/trpc/client";
-const UNCHANGED = "unchanged";
+import { useInvalidateIncident } from "./use-invalidate-incident";
/**
* Posting with the status unchanged adds a note; picking a new status moves
@@ -43,23 +46,11 @@ export function IncidentComposer({
onStatusChanged: (status: IncidentStatus, note: string) => void;
}) {
const trpc = useTRPC();
- const queryClient = useQueryClient();
- const [status, setStatus] = useState(UNCHANGED);
+ const { data: user } = useQuery(trpc.user.get.queryOptions());
+ const [selected, setSelected] = useState(null);
const [message, setMessage] = useState("");
- const invalidate = () =>
- Promise.all([
- queryClient.invalidateQueries({
- queryKey: trpc.incident.get.queryKey({ id: incident.id }),
- }),
- queryClient.invalidateQueries({
- queryKey: trpc.incident.listEvents.queryKey({ id: incident.id }),
- }),
- queryClient.invalidateQueries({
- queryKey: trpc.incident.list.queryKey(),
- }),
- ]);
-
+ const invalidate = useInvalidateIncident(incident.id);
const addNote = useMutation(
trpc.incident.addNote.mutationOptions({ onSuccess: invalidate }),
);
@@ -67,9 +58,11 @@ export function IncidentComposer({
trpc.incident.setStatus.mutationOptions({ onSuccess: invalidate }),
);
const pending = addNote.isPending || setIncidentStatus.isPending;
- const next = incident.allowedTransitions.find((s) => s === status);
+ const next = incident.allowedTransitions.find((s) => s === selected);
+ const disabled = pending || (!next && !message.trim());
async function submit() {
+ if (disabled) return;
const note = message.trim();
const promise: Promise = next
? setIncidentStatus
@@ -90,57 +83,76 @@ export function IncidentComposer({
});
await promise;
setMessage("");
- setStatus(UNCHANGED);
+ setSelected(null);
if (next) onStatusChanged(next, note);
}
- const disabled = pending || (!next && !message.trim());
-
return (
-
-
-
-
- Write
- Preview
-
-
-
-
-
-
-
- {statusConfig[incident.status].label} (unchanged)
-
- {incident.allowedTransitions.map((s) => (
-
- Mark {statusConfig[s].label.toLowerCase()}
-
- ))}
-
-
-
-
-
-
-
-
+
+
+
+
+
+ Markdown
+
+ setMessage(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ submit().catch(console.error);
+ }
+ }}
+ />
+
+
+
+ Set status to
+
+ setSelected(value === incident.status ? null : value)
+ }
+ >
+
+
+
+
+ {[incident.status, ...incident.allowedTransitions].map((s) => (
+
+
+ {statusConfig[s].label}
+
+ ))}
+
+
+
+
+
+ Only your team sees notes
+
+ submit().catch(console.error)}
+ >
+ {next
+ ? `Post and mark ${statusConfig[next].label.toLowerCase()}`
+ : "Post note"}
+
-
-
-
- submit()}>
- {next
- ? `Post and mark ${statusConfig[next].label.toLowerCase()}`
- : "Post note"}
-
-
+
+
);
}
diff --git a/apps/dashboard/src/components/incidents/incident-heading.tsx b/apps/dashboard/src/components/incidents/incident-heading.tsx
new file mode 100644
index 00000000..d311f2d0
--- /dev/null
+++ b/apps/dashboard/src/components/incidents/incident-heading.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import type { RouterOutputs } from "@openstatus/api";
+import { useMutation } from "@tanstack/react-query";
+import { isTRPCClientError } from "@trpc/client";
+import { toast } from "sonner";
+
+import {
+ DetailActions,
+ DetailDescription,
+ DetailInput,
+ DetailTitle,
+ DetailTitleRow,
+} from "@/components/content/detail";
+import { useTRPC } from "@/lib/trpc/client";
+
+import { useInvalidateIncident } from "./use-invalidate-incident";
+
+type Incident = NonNullable
;
+
+export function IncidentHeading({
+ incident,
+ actions,
+}: {
+ incident: Incident;
+ actions?: React.ReactNode;
+}) {
+ const trpc = useTRPC();
+ const invalidate = useInvalidateIncident(incident.id);
+ const update = useMutation(
+ trpc.incident.update.mutationOptions({
+ onSuccess: invalidate,
+ onError: (error) => {
+ toast.error(
+ isTRPCClientError(error) ? error.message : "Failed to save",
+ );
+ },
+ }),
+ );
+
+ if (incident.closedAt !== null) {
+ return (
+ <>
+
+ {incident.title}
+ {actions ? {actions} : null}
+
+ {incident.summary ? (
+
+ {incident.summary}
+
+ ) : null}
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+ update.mutate({ id: incident.id, title })}
+ />
+
+ {actions ? {actions} : null}
+
+
+
+ update.mutate({ id: incident.id, summary: summary || null })
+ }
+ />
+
+ >
+ );
+}
diff --git a/apps/dashboard/src/components/incidents/incident-postmortem.tsx b/apps/dashboard/src/components/incidents/incident-postmortem.tsx
index 1000e2df..fccfb639 100644
--- a/apps/dashboard/src/components/incidents/incident-postmortem.tsx
+++ b/apps/dashboard/src/components/incidents/incident-postmortem.tsx
@@ -1,23 +1,33 @@
"use client";
import type { RouterOutputs } from "@openstatus/api";
-import { Badge } from "@openstatus/ui/components/ui/badge";
+import { AI } from "@openstatus/icons";
import { Button } from "@openstatus/ui/components/ui/button";
-import {
- Tabs,
- TabsContent,
- TabsList,
- TabsTrigger,
-} from "@openstatus/ui/components/ui/tabs";
-import { Textarea } from "@openstatus/ui/components/ui/textarea";
+import { InputGroupButton } from "@openstatus/ui/components/ui/input-group";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isTRPCClientError } from "@trpc/client";
import { useState } from "react";
import { toast } from "sonner";
-import { ProcessMessage } from "@/components/content/process-message";
+import { StatusBadge } from "@/components/common/status-badge";
+import {
+ Composer,
+ ComposerFooter,
+ ComposerHeader,
+ ComposerHint,
+ ComposerPreview,
+ ComposerTabs,
+ ComposerTextarea,
+} from "@/components/content/composer";
+import {
+ EmptyStateContainer,
+ EmptyStateDescription,
+ EmptyStateTitle,
+} from "@/components/content/empty-state";
import { useTRPC } from "@/lib/trpc/client";
+import { useInvalidateIncident } from "./use-invalidate-incident";
+
type Incident = NonNullable;
const TEMPLATE = `## Summary
@@ -49,6 +59,7 @@ export function IncidentPostmortem({
}) {
const trpc = useTRPC();
const queryClient = useQueryClient();
+ const invalidate = useInvalidateIncident(incident.id);
const { data: postmortem } = useQuery(
trpc.incident.getPostmortem.queryOptions({ id: incident.id }),
);
@@ -63,15 +74,7 @@ export function IncidentPostmortem({
queryClient.invalidateQueries({
queryKey: trpc.incident.getPostmortem.queryKey({ id: incident.id }),
}),
- queryClient.invalidateQueries({
- queryKey: trpc.incident.get.queryKey({ id: incident.id }),
- }),
- queryClient.invalidateQueries({
- queryKey: trpc.incident.listEvents.queryKey({ id: incident.id }),
- }),
- queryClient.invalidateQueries({
- queryKey: trpc.incident.list.queryKey(),
- }),
+ invalidate(),
]);
const onError = (error: { message: string }) => {
toast.error(errorText(error));
@@ -107,97 +110,108 @@ export function IncidentPostmortem({
if (incident.status !== "resolved") {
return (
-
- The postmortem opens once the incident is resolved.
-
+
+ No postmortem yet
+
+ The postmortem opens once the incident is resolved.
+
+
);
}
+ // The default tab depends on the server copy; mounting earlier would pin it.
+ if (postmortem === undefined) return null;
const approved = postmortem?.status === "approved";
const dirty = draft !== null && draft !== server;
const busy = save.isPending || draftWithAgent.isPending || approve.isPending;
+ const canSave = !busy && dirty && content.trim().length > 0;
+ const canApprove = postmortem !== null && !approved;
+
+ const hint = dirty
+ ? "Unsaved changes"
+ : approved && incident.closedAt === null
+ ? "Close the incident from the header"
+ : postmortem?.draftedBy === "agent"
+ ? "Drafted by the agent"
+ : postmortem
+ ? null
+ : "No postmortem yet";
return (
-
-
-
- {postmortem ? (
-
- {postmortem.status}
-
- ) : (
- No postmortem yet
- )}
- {postmortem?.draftedBy === "agent" ? (
- Drafted by the agent
- ) : null}
-
+
+
+
{agentAllowed && !approved ? (
- draftWithAgent.mutate({ id: incident.id })}
>
+
{draftWithAgent.isPending ? "Drafting..." : "Draft with agent"}
-
- ) : null}
-
-
-
- Write
- Preview
-
-
-
-
-
-
-
-
-
- save.mutate({ id: incident.id, content, draftedBy: "user" })
+
+ ) : (
+ Markdown
+ )}
+
+ setDraft(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && canSave) {
+ e.preventDefault();
+ save.mutate({ id: incident.id, content, draftedBy: "user" });
}
- >
- Save
-
- {postmortem && !approved ? (
- <>
-
approve.mutate({ id: incident.id })}
- >
- Approve
-
-
approve.mutate({ id: incident.id, close: true })}
+ }}
+ />
+
+
+
+ {postmortem ? (
+
- Approve & close
-
- >
- ) : null}
-
- {approved && incident.closedAt === null ? (
-
- Approved. Close the incident from the panel on the right.
-
- ) : null}
-
+ {approved ? "Approved" : "Draft"}
+
+ ) : null}
+ {hint ?
{hint} : null}
+
+
+
+ save.mutate({ id: incident.id, content, draftedBy: "user" })
+ }
+ >
+ Save
+
+ {canApprove ? (
+ <>
+ approve.mutate({ id: incident.id })}
+ >
+ Approve
+
+ approve.mutate({ id: incident.id, close: true })}
+ >
+ Approve & close
+
+ >
+ ) : null}
+
+
+
);
}
diff --git a/apps/dashboard/src/components/incidents/incident-properties.tsx b/apps/dashboard/src/components/incidents/incident-properties.tsx
index 335acaae..b08ec731 100644
--- a/apps/dashboard/src/components/incidents/incident-properties.tsx
+++ b/apps/dashboard/src/components/incidents/incident-properties.tsx
@@ -1,245 +1,286 @@
"use client";
import type { RouterOutputs } from "@openstatus/api";
-import { incidentSeverity } from "@openstatus/db/src/schema/incidents/constants";
+import {
+ type IncidentStatus,
+ incidentSeverity,
+} from "@openstatus/db/src/schema/incidents/constants";
import { Button } from "@openstatus/ui/components/ui/button";
-import { Input } from "@openstatus/ui/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
- SelectTrigger,
SelectValue,
} from "@openstatus/ui/components/ui/select";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useMutation, useQuery } from "@tanstack/react-query";
import { isTRPCClientError } from "@trpc/client";
-import { formatDistanceStrict } from "date-fns";
-import { useRouter } from "next/navigation";
+import {
+ format,
+ formatDistanceStrict,
+ formatDistanceToNowStrict,
+} from "date-fns";
import { useState } from "react";
import { toast } from "sonner";
-import { Link } from "@/components/common/link";
-import { FormAlertDialog } from "@/components/forms/form-alert-dialog";
+import { HoverCardTimestamp } from "@/components/common/hover-card-timestamp";
+import { StatusDot } from "@/components/common/status-dot";
+import { UserAvatar } from "@/components/common/user-avatar";
+import {
+ Property,
+ PropertyInput,
+ PropertyLabel,
+ PropertyList,
+ PropertySelectTrigger,
+ PropertyValue,
+} from "@/components/content/property-list";
import { toLocalInput } from "@/components/forms/incident/form";
import {
+ incidentEndedAt,
personName,
severityConfig,
statusConfig,
} from "@/data/managed-incidents.client";
import { useTRPC } from "@/lib/trpc/client";
+import { useInvalidateIncident } from "./use-invalidate-incident";
+
type Incident = NonNullable;
const NONE = "none";
-function Row({
- label,
- children,
+export function IncidentProperties({
+ incident,
+ lastUpdateAt,
+ onStatusChanged,
}: {
- label: string;
- children: React.ReactNode;
+ incident: Incident;
+ lastUpdateAt?: Date;
+ onStatusChanged: (status: IncidentStatus, note: string) => void;
}) {
- return (
-
- );
-}
-
-export function slackChannelUrl(teamId: string, channelId: string): string {
- return `https://slack.com/app_redirect?team=${teamId}&channel=${channelId}`;
-}
-
-export function IncidentProperties({ incident }: { incident: Incident }) {
const trpc = useTRPC();
- const router = useRouter();
- const queryClient = useQueryClient();
const { data: members } = useQuery(trpc.member.list.queryOptions());
- const { data: postmortem } = useQuery(
- trpc.incident.getPostmortem.queryOptions({ id: incident.id }),
- );
const [startedAt, setStartedAt] = useState(toLocalInput(incident.startedAt));
const closed = incident.closedAt !== null;
- const refresh = () =>
- Promise.all([
- queryClient.invalidateQueries({
- queryKey: trpc.incident.get.queryKey({ id: incident.id }),
- }),
- queryClient.invalidateQueries({
- queryKey: trpc.incident.listEvents.queryKey({ id: incident.id }),
- }),
- queryClient.invalidateQueries({
- queryKey: trpc.incident.list.queryKey(),
- }),
- ]);
+ const invalidate = useInvalidateIncident(incident.id);
const onError = (error: { message: string }) => {
toast.error(isTRPCClientError(error) ? error.message : "Failed to save");
};
-
const update = useMutation(
- trpc.incident.update.mutationOptions({ onSuccess: refresh, onError }),
- );
- const close = useMutation(
- trpc.incident.close.mutationOptions({ onSuccess: refresh, onError }),
+ trpc.incident.update.mutationOptions({ onSuccess: invalidate, onError }),
);
- const remove = useMutation(
- trpc.incident.delete.mutationOptions({
- onSuccess: async () => {
- await queryClient.invalidateQueries({
- queryKey: trpc.incident.list.queryKey(),
- });
- router.push("/incidents");
+ const setStatus = useMutation(
+ trpc.incident.setStatus.mutationOptions({
+ onSuccess: async (row) => {
+ await invalidate();
+ if (row) onStatusChanged(row.status, "");
},
+ onError,
}),
);
- const end =
- incident.status === "resolved" && incident.resolvedAt
- ? incident.resolvedAt
- : (incident.closedAt ?? new Date());
+ const severity = severityConfig[incident.severity];
+ const status = statusConfig[incident.status];
+ const commander = personName(incident.commander);
+ const endedAt = incidentEndedAt(incident);
return (
-
-
- {
- const severity = incidentSeverity.find((s) => s === value);
- if (severity) update.mutate({ id: incident.id, severity });
- }}
- >
-
-
-
-
- {incidentSeverity.map((severity) => (
-
- {severityConfig[severity].label}
-
- ))}
-
-
-
-
-
- {statusConfig[incident.status].label}
- {closed && incident.status !== "canceled" ? " · closed" : null}
-
-
-
-
- update.mutate({
- id: incident.id,
- commanderId: value === NONE ? null : Number(value),
- })
- }
- >
-
-
-
-
- No commander
- {(members ?? []).map((member) => (
-
- {personName(member.user) ?? `User ${member.user.id}`}
-
- ))}
-
-
-
-
-
-
setStartedAt(e.target.value)}
- />
- {startedAt !== toLocalInput(incident.startedAt) ? (
-
+
+
+ Severity
+
+ {closed ? (
+ <>
+
+ {severity.label}
+ >
+ ) : (
+ {
+ const next = incidentSeverity.find((s) => s === value);
+ if (next) update.mutate({ id: incident.id, severity: next });
+ }}
+ >
+
+
+
+
+ {incidentSeverity.map((s) => (
+
+
+ {severityConfig[s].label}
+
+ ))}
+
+
+ )}
+
+
+
+ Status
+
+ {incident.allowedTransitions.length === 0 ? (
+ <>
+
+ {status.label}
+ {closed && incident.status !== "canceled" ? " · closed" : null}
+ >
+ ) : (
+ {
+ const next = incident.allowedTransitions.find(
+ (s) => s === value,
+ );
+ if (next) setStatus.mutate({ id: incident.id, status: next });
+ }}
+ >
+
+
+
+
+ {[incident.status, ...incident.allowedTransitions].map((s) => (
+
+
+ {statusConfig[s].label}
+
+ ))}
+
+
+ )}
+
+
+
+ Commander
+
+ {closed ? (
+ commander ? (
+ <>
+
+ {commander}
+ >
+ ) : (
+ No commander
+ )
+ ) : (
+
update.mutate({
id: incident.id,
- startedAt: new Date(startedAt),
+ commanderId: value === NONE ? null : Number(value),
})
}
>
- Save
-
- ) : null}
-
-
-
-
- {formatDistanceStrict(incident.startedAt, end)}
-
-
-
- {personName(incident.declaredByUser) ?? "System"}
-
-
- {incident.slackTeamId && incident.slackChannelId ? (
-
- Open channel
-
- ) : (
- No channel
- )}
-
-
- {incident.status === "resolved" && !closed ? (
- postmortem?.status === "approved" ? (
-
close.mutate({ id: incident.id })}
- >
- Close incident
-
+
+
+
+
+
+ No commander
+
+ {(members ?? []).map((member) => {
+ const name =
+ personName(member.user) ?? `User ${member.user.id}`;
+ return (
+
+
+ {name}
+
+ );
+ })}
+
+
+ )}
+
+
+
+ Started at
+
+ {closed ? (
+
+ {format(incident.startedAt, "LLL dd, y HH:mm")}
+
) : (
-
- close.mutate({ id: incident.id, skipPostmortem: true })
- }
- >
- Close (skip postmortem)
-
- )
- ) : null}
- {incident.deletable ? (
- {
- await remove.mutateAsync({ id: incident.id });
- }}
- >
-
- Delete
-
-
- ) : null}
-
-
+ <>
+ setStartedAt(e.target.value)}
+ />
+ {startedAt !== toLocalInput(incident.startedAt) ? (
+
+
+ update.mutate({
+ id: incident.id,
+ startedAt: new Date(startedAt),
+ })
+ }
+ >
+ Save
+
+
+ setStartedAt(toLocalInput(incident.startedAt))
+ }
+ >
+ Reset
+
+
+ ) : null}
+ >
+ )}
+
+
+
+ Duration
+
+ {formatDistanceStrict(incident.startedAt, endedAt ?? new Date())}
+ {endedAt ? null : (
+ <>
+
+ ongoing
+ >
+ )}
+
+
+ {lastUpdateAt ? (
+
+ Last update
+
+
+
+ {formatDistanceToNowStrict(lastUpdateAt, { addSuffix: true })}
+
+
+
+
+ ) : null}
+
+ Declared by
+
+
+ {personName(incident.declaredByUser) ?? "System"}
+
+
+
+
);
}
diff --git a/apps/dashboard/src/components/incidents/incident-status-report.tsx b/apps/dashboard/src/components/incidents/incident-status-report.tsx
index 661ebc06..737a13eb 100644
--- a/apps/dashboard/src/components/incidents/incident-status-report.tsx
+++ b/apps/dashboard/src/components/incidents/incident-status-report.tsx
@@ -20,6 +20,16 @@ import { useState } from "react";
import { toast } from "sonner";
import { Link } from "@/components/common/link";
+import { StatusDot } from "@/components/common/status-dot";
+import {
+ ActionCard,
+ ActionCardContent,
+ ActionCardDescription,
+ ActionCardFooter,
+ ActionCardHeader,
+ ActionCardTitle,
+} from "@/components/content/action-card";
+import { statusVariants } from "@/data/status-report-updates.client";
import { useTRPC } from "@/lib/trpc/client";
import { usePublicUpdate } from "./use-public-update";
@@ -49,6 +59,27 @@ export function IncidentStatusReport({
return ;
}
+function NotifySubscribers({
+ id,
+ checked,
+ onCheckedChange,
+}: {
+ id: string;
+ checked: boolean;
+ onCheckedChange: (checked: boolean) => void;
+}) {
+ return (
+
+ onCheckedChange(value === true)}
+ />
+ Notify subscribers
+
+ );
+}
+
function LinkedReport({
incident,
report,
@@ -60,6 +91,7 @@ function LinkedReport({
}) {
const trpc = useTRPC();
const queryClient = useQueryClient();
+ const [composing, setComposing] = useState(false);
const [status, setStatus] = useState(
report.status === "resolved" ? "monitoring" : report.status,
);
@@ -92,78 +124,99 @@ function LinkedReport({
});
await promise;
setMessage("");
+ setComposing(false);
}
return (
-
-
-
+
+
+
+
+ Published
+
+ {report.status}
+
+
+
{report.pageId ? (
{report.title}
) : (
- {report.title}
- )}
-
- {report.status}
-
-
- {closed ? null : (
-
unlink.mutate({ id: incident.id })}
- >
- Unlink
-
- )}
-
-
-
Post public update
-
-
-
-
-
- {statusReportStatus.map((s) => (
-
- {s}
-
- ))}
-
-
-
-
+ ) : null}
+
+ ) : null}
+
+ {composing ? (
+ <>
+ post().catch(console.error)}
+ >
+ Post public update
+
+ setComposing(false)}
+ >
+ Cancel
+
+ >
+ ) : (
+ <>
+ setComposing(true)}>
+ Post public update
+
+ {closed ? null : (
+ unlink.mutate({ id: incident.id })}
+ >
+ Unlink
+
+ )}
+ >
+ )}
+
+
);
}
@@ -180,7 +233,7 @@ function UnlinkedReport({
trpc.statusReport.list.queryOptions({ order: "desc" }),
);
const { data: pages } = useQuery(trpc.page.list.queryOptions());
- const [mode, setMode] = useState<"link" | "create" | null>(null);
+ const [draftMode, setMode] = useState<"link" | "create" | null>(null);
const [reportId, setReportId] = useState("");
const [pageId, setPageId] = useState("");
const [title, setTitle] = useState(incident.title);
@@ -205,12 +258,9 @@ function UnlinkedReport({
const notify = useMutation(
trpc.subscriberNotification.statusReport.mutationOptions(),
);
-
- if (incident.closedAt !== null) {
- return (
- No status report linked.
- );
- }
+ const closed = incident.closedAt !== null;
+ // Closing mid-edit would otherwise strand a form whose footer is gone.
+ const mode = closed ? null : draftMode;
async function createReport() {
const promise = (async () => {
@@ -240,30 +290,25 @@ function UnlinkedReport({
const openReports = (reports ?? []).filter((r) => r.status !== "resolved");
return (
-
-
- No status report yet. Your users only see what you publish there.
-
-
- setMode("create")}
- >
- Create status report
-
- setMode("link")}
- >
- Link existing
-
-
+
+
+
+
+ Not published
+
+
+ {closed
+ ? "This incident was never linked to a status report."
+ : "Nothing here is public. Customers only see what you publish on your status page."}
+
+
{mode === "link" ? (
-
+
-
+
@@ -274,31 +319,12 @@ function UnlinkedReport({
))}
-
- toast.promise(
- link.mutateAsync({
- id: incident.id,
- statusReportId: Number(reportId),
- }),
- {
- loading: "Linking...",
- success: "Status report linked",
- error: toastError,
- },
- )
- }
- >
- Link
-
-
+
) : null}
{mode === "create" ? (
-
+
) : null}
-
+ {closed ? null : (
+
+ {mode === null ? (
+ <>
+ setMode("create")}>
+ Create status report
+
+ setMode("link")}
+ >
+ Link existing
+
+ >
+ ) : (
+ <>
+ {mode === "create" ? (
+ createReport().catch(console.error)}
+ >
+ Create and link
+
+ ) : (
+
+ toast.promise(
+ link.mutateAsync({
+ id: incident.id,
+ statusReportId: Number(reportId),
+ }),
+ {
+ loading: "Linking...",
+ success: "Status report linked",
+ error: toastError,
+ },
+ )
+ }
+ >
+ Link
+
+ )}
+ setMode(null)}>
+ Cancel
+
+ >
+ )}
+
+ )}
+
);
}
diff --git a/apps/dashboard/src/components/incidents/incident-timeline.tsx b/apps/dashboard/src/components/incidents/incident-timeline.tsx
index d25cb86b..c1e3c7c1 100644
--- a/apps/dashboard/src/components/incidents/incident-timeline.tsx
+++ b/apps/dashboard/src/components/incidents/incident-timeline.tsx
@@ -1,61 +1,159 @@
"use client";
import type { RouterOutputs } from "@openstatus/api";
-import type { IncidentEventType } from "@openstatus/db/src/schema/incidents/constants";
-import { formatDistanceToNow } from "date-fns";
+import {
+ type IncidentEventType,
+ type IncidentSeverity,
+ type IncidentStatus,
+ incidentSeverity,
+ incidentStatus,
+} from "@openstatus/db/src/schema/incidents/constants";
+import {
+ Account,
+ Chat,
+ Check,
+ Clock,
+ Close,
+ Compare,
+ FileText,
+ type IconType,
+ Impact,
+ Linked,
+ Lock,
+ Plug,
+ Unlinked,
+ Warning,
+} from "@openstatus/icons";
+import type { StatusVariant } from "@/components/common/status-dot";
import { ProcessMessage } from "@/components/content/process-message";
-import { personName } from "@/data/managed-incidents.client";
+import {
+ Timeline,
+ TimelineBody,
+ TimelineContent,
+ TimelineHeader,
+ TimelineIndicator,
+ TimelineItem,
+ TimelineMeta,
+ TimelineTime,
+ TimelineTitle,
+} from "@/components/content/timeline";
+import {
+ personName,
+ severityConfig,
+ statusConfig,
+} from "@/data/managed-incidents.client";
+
+import { IncidentSeverityBadge, IncidentStatusBadge } from "./incident-badge";
type Event = NonNullable[number];
-const labels: Record = {
- declared: "Declared",
- severity_changed: "Severity changed",
- status_changed: "Status changed",
- commander_changed: "Commander changed",
- started_at_changed: "Start time changed",
- note: "Note",
- status_report_linked: "Status report linked",
- status_report_unlinked: "Status report unlinked",
- slack_channel_bound: "Slack channel bound",
- slack_channel_unbound: "Slack channel unbound",
- mitigated: "Mitigated",
- resolved: "Resolved",
- canceled: "Canceled",
- postmortem_drafted: "Postmortem drafted",
- postmortem_updated: "Postmortem updated",
- postmortem_approved: "Postmortem approved",
- closed: "Closed",
+const eventConfig: Record<
+ IncidentEventType,
+ { label: string; icon: IconType; variant?: StatusVariant }
+> = {
+ declared: { label: "Declared", icon: Warning, variant: "warning" },
+ severity_changed: { label: "Severity changed", icon: Impact },
+ status_changed: { label: "Status changed", icon: Compare },
+ commander_changed: { label: "Commander changed", icon: Account },
+ started_at_changed: { label: "Start time changed", icon: Clock },
+ note: { label: "Note", icon: Chat },
+ status_report_linked: { label: "Status report linked", icon: Linked },
+ status_report_unlinked: { label: "Status report unlinked", icon: Unlinked },
+ slack_channel_bound: { label: "Slack channel bound", icon: Plug },
+ slack_channel_unbound: { label: "Slack channel unbound", icon: Plug },
+ mitigated: { label: "Mitigated", icon: Check, variant: "warning" },
+ resolved: { label: "Resolved", icon: Check, variant: "success" },
+ canceled: { label: "Canceled", icon: Close },
+ postmortem_drafted: { label: "Postmortem drafted", icon: FileText },
+ postmortem_updated: { label: "Postmortem updated", icon: FileText },
+ postmortem_approved: {
+ label: "Postmortem approved",
+ icon: FileText,
+ variant: "success",
+ },
+ closed: { label: "Closed", icon: Lock },
};
+const TRANSITION = /^\w+ changed from \w+ to (\w+)(?:\n\n([\s\S]+))?$/;
+const DECLARED = /^Declared as (\w+): /;
+
+// Events carry no structured payload, so the target of a transition is read
+// from the service's own message. An unknown shape falls back to the raw text.
+function parseEvent(event: Event): {
+ status?: IncidentStatus;
+ severity?: IncidentSeverity;
+ message: string | null;
+} {
+ const message = event.message;
+ if (!message) return { message: null };
+
+ // The message only repeats the label around a raw Slack channel id.
+ if (
+ event.type === "slack_channel_bound" ||
+ event.type === "slack_channel_unbound"
+ ) {
+ return { message: null };
+ }
+ if (event.type === "status_changed") {
+ const match = TRANSITION.exec(message);
+ const status = incidentStatus.find((s) => s === match?.[1]);
+ if (status) return { status, message: match?.[2] ?? null };
+ }
+ if (event.type === "severity_changed") {
+ const match = TRANSITION.exec(message);
+ const severity = incidentSeverity.find((s) => s === match?.[1]);
+ if (severity) return { severity, message: match?.[2] ?? null };
+ }
+ if (event.type === "declared") {
+ const severity = incidentSeverity.find(
+ (s) => s === DECLARED.exec(message)?.[1],
+ );
+ if (severity) return { severity, message: null };
+ }
+ return { message };
+}
+
export function IncidentTimeline({ events }: { events: Event[] }) {
if (events.length === 0) return null;
return (
-
- {events.map((event) => (
-
-
-
- {labels[event.type]}
-
- ·
- {personName(event.createdByUser) ?? "System"}
- ·
-
- {formatDistanceToNow(event.createdAt, { addSuffix: true })}
-
-
- {event.message ? (
-
- ) : null}
-
- ))}
-
+
+ {events.map((event) => {
+ const config = eventConfig[event.type];
+ const { status, severity, message } = parseEvent(event);
+ const variant = status
+ ? statusConfig[status].variant
+ : event.type === "declared" && severity
+ ? severityConfig[severity].variant
+ : config.variant;
+ return (
+
+
+
+
+
+
+
+ {config.label}
+ {status ? : null}
+ {severity ? (
+
+ ) : null}
+
+ {personName(event.createdByUser) ?? "System"}
+
+
+
+
+ {message ? (
+
+
+
+ ) : null}
+
+
+ );
+ })}
+
);
}
diff --git a/apps/dashboard/src/components/incidents/use-invalidate-incident.ts b/apps/dashboard/src/components/incidents/use-invalidate-incident.ts
new file mode 100644
index 00000000..e5fdb4c4
--- /dev/null
+++ b/apps/dashboard/src/components/incidents/use-invalidate-incident.ts
@@ -0,0 +1,24 @@
+"use client";
+
+import { useQueryClient } from "@tanstack/react-query";
+
+import { useTRPC } from "@/lib/trpc/client";
+
+/** Refetches the incident, its timeline and the incident list. */
+export function useInvalidateIncident(id: number) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+
+ return () =>
+ Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: trpc.incident.get.queryKey({ id }),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: trpc.incident.listEvents.queryKey({ id }),
+ }),
+ queryClient.invalidateQueries({
+ queryKey: trpc.incident.list.queryKey(),
+ }),
+ ]);
+}
diff --git a/apps/dashboard/src/components/nav/app-sidebar.tsx b/apps/dashboard/src/components/nav/app-sidebar.tsx
index b330dc7d..afe87d16 100644
--- a/apps/dashboard/src/components/nav/app-sidebar.tsx
+++ b/apps/dashboard/src/components/nav/app-sidebar.tsx
@@ -62,13 +62,14 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
const incidents = useFeature("incident-management");
const overview = incidents
? [
- data.overview[0],
+ ...data.overview.slice(0, 3),
{
name: NAV.incidents.label,
url: NAV.incidents.href,
icon: NAV.incidents.icon,
+ badge: "New",
},
- ...data.overview.slice(1),
+ ...data.overview.slice(3),
]
: data.overview;
diff --git a/apps/dashboard/src/components/nav/nav-overview.tsx b/apps/dashboard/src/components/nav/nav-overview.tsx
index c2c98047..147fc9ca 100644
--- a/apps/dashboard/src/components/nav/nav-overview.tsx
+++ b/apps/dashboard/src/components/nav/nav-overview.tsx
@@ -5,6 +5,7 @@ import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
+ SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
@@ -25,6 +26,7 @@ export function NavOverview({
name: string;
url: string;
icon: IconType;
+ badge?: string;
}[];
}) {
const pathname = usePathname();
@@ -53,6 +55,11 @@ export function NavOverview({
{item.name}
+ {item.badge ? (
+
+ {item.badge}
+
+ ) : null}
))}
diff --git a/apps/dashboard/src/data/managed-incidents.client.ts b/apps/dashboard/src/data/managed-incidents.client.ts
index e29a1e38..3302b0d5 100644
--- a/apps/dashboard/src/data/managed-incidents.client.ts
+++ b/apps/dashboard/src/data/managed-incidents.client.ts
@@ -3,28 +3,39 @@ import type {
IncidentStatus,
} from "@openstatus/db/src/schema/incidents/constants";
+import type { StatusVariant } from "@/components/common/status-dot";
+
export const severityConfig = {
- critical: {
- label: "Critical",
- className: "text-destructive border-destructive/40",
- },
- major: { label: "Major", className: "text-warning border-warning/40" },
- minor: { label: "Minor", className: "text-info border-info/40" },
+ critical: { label: "Critical", variant: "destructive" },
+ major: { label: "Major", variant: "warning" },
+ minor: { label: "Minor", variant: "info" },
} as const satisfies Record<
IncidentSeverity,
- { label: string; className: string }
+ { label: string; variant: StatusVariant }
>;
export const statusConfig = {
- open: { label: "Open", className: "text-destructive" },
- mitigated: { label: "Mitigated", className: "text-warning" },
- resolved: { label: "Resolved", className: "text-success" },
- canceled: { label: "Canceled", className: "text-muted-foreground" },
+ open: { label: "Open", variant: "destructive" },
+ mitigated: { label: "Mitigated", variant: "warning" },
+ resolved: { label: "Resolved", variant: "success" },
+ canceled: { label: "Canceled", variant: "default" },
} as const satisfies Record<
IncidentStatus,
- { label: string; className: string }
+ { label: string; variant: StatusVariant }
>;
+/** When the incident stopped being ongoing; `null` while it still is. */
+export function incidentEndedAt(incident: {
+ status: IncidentStatus;
+ resolvedAt: Date | null;
+ closedAt: Date | null;
+}): Date | null {
+ if (incident.status === "resolved" && incident.resolvedAt) {
+ return incident.resolvedAt;
+ }
+ return incident.closedAt;
+}
+
export function personName(
person: {
name: string | null;
diff --git a/apps/dashboard/src/data/status-report-updates.client.ts b/apps/dashboard/src/data/status-report-updates.client.ts
index a19f259c..1cde9fcd 100644
--- a/apps/dashboard/src/data/status-report-updates.client.ts
+++ b/apps/dashboard/src/data/status-report-updates.client.ts
@@ -2,6 +2,7 @@ import type { StatusReportStatus } from "@openstatus/db/src/schema";
import type { 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";
export const impactConfig = {
@@ -88,6 +89,13 @@ export const colors = {
"text-warning/80 data-[state=selected]:bg-warning/10 data-[state=selected]:text-warning",
} as const satisfies Record;
+export const statusVariants = {
+ resolved: "success",
+ investigating: "destructive",
+ monitoring: "info",
+ identified: "warning",
+} as const satisfies Record;
+
/**
* Get the next status in the progression:
* investigating → identified → monitoring → resolved