diff --git a/apps/dashboard/src/app/(dashboard)/incidents/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/incidents/breadcrumb.tsx new file mode 100644 index 00000000..dfe54d30 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/incidents/breadcrumb.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { Incident } from "@openstatus/icons"; + +import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; + +export function Breadcrumb() { + return ( + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/incidents/client.tsx b/apps/dashboard/src/app/(dashboard)/incidents/client.tsx new file mode 100644 index 00000000..be78aa5d --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/incidents/client.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Button } from "@openstatus/ui/components/ui/button"; +import { useQuery } from "@tanstack/react-query"; + +import { + EmptyStateContainer, + EmptyStateDescription, + EmptyStateTitle, +} from "@/components/content/empty-state"; +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionHeaderRow, + SectionTitle, +} from "@/components/content/section"; +import { columns } from "@/components/data-table/managed-incidents/columns"; +import { DeclareIncidentButton } from "@/components/incidents/declare-incident-button"; +import { DataTable } from "@/components/ui/data-table/data-table"; +import { useFeature } from "@/hooks/use-feature"; +import { useTRPC } from "@/lib/trpc/client"; + +export function Client() { + const trpc = useTRPC(); + const enabled = useFeature("incident-management"); + const { data: incidents } = useQuery({ + ...trpc.incident.list.queryOptions(), + enabled, + }); + + if (!enabled) { + return ( + + + Incidents are not available yet + + Incident management is rolling out gradually. + + + + ); + } + + return ( + +
+ + + 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 ? null : incidents.length === 0 ? ( + + No incidents declared + + Declared incidents keep the timeline your team builds while + responding. Nothing here is public: use a status report to tell + your users. + + + ) : ( + + )} +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/incidents/layout.tsx b/apps/dashboard/src/app/(dashboard)/incidents/layout.tsx new file mode 100644 index 00000000..3b3dc63c --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/incidents/layout.tsx @@ -0,0 +1,31 @@ +import { + AppHeader, + AppHeaderActions, + AppHeaderContent, +} from "@/components/nav/app-header"; +import { AppSidebarTrigger } from "@/components/nav/app-sidebar"; +import { HydrateClient } from "@/lib/trpc/server"; + +import { Breadcrumb } from "./breadcrumb"; +import { NavActions } from "./nav-actions"; + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + + + + + + +
{children}
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/incidents/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/incidents/nav-actions.tsx new file mode 100644 index 00000000..768c5c97 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/incidents/nav-actions.tsx @@ -0,0 +1,9 @@ +import { NavFeedback } from "@/components/nav/nav-feedback"; + +export function NavActions() { + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/incidents/page.tsx b/apps/dashboard/src/app/(dashboard)/incidents/page.tsx new file mode 100644 index 00000000..e3dc7ede --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/incidents/page.tsx @@ -0,0 +1,5 @@ +import { Client } from "./client"; + +export default function Page() { + return ; +} diff --git a/apps/dashboard/src/components/data-table/managed-incidents/columns.tsx b/apps/dashboard/src/components/data-table/managed-incidents/columns.tsx new file mode 100644 index 00000000..bff0a6bd --- /dev/null +++ b/apps/dashboard/src/components/data-table/managed-incidents/columns.tsx @@ -0,0 +1,100 @@ +"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 { 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"; + +type ManagedIncident = NonNullable[number]; + +export const columns: ColumnDef[] = [ + { + accessorKey: "severity", + header: "Severity", + enableSorting: false, + cell: ({ row }) => { + const config = severityConfig[row.original.severity]; + return ( + + {config.label} + + ); + }, + }, + { + accessorKey: "title", + header: "Title", + enableSorting: false, + cell: ({ row }) => ( + + {row.original.title} + + ), + meta: { cellClassName: "max-w-[260px] truncate" }, + }, + { + 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 }) => ( + + ), + }, + { + accessorKey: "declaredAt", + header: "Declared", + cell: ({ row }) => , + }, + { + id: "duration", + header: "Duration", + cell: ({ row }) => { + const end = + row.original.status === "resolved" && row.original.resolvedAt + ? row.original.resolvedAt + : row.original.status === "canceled" + ? row.original.closedAt + : new Date(); + if (!end) return ; + const [amount, unit] = formatDistanceStrict( + row.original.startedAt, + end, + ).split(" "); + return ; + }, + }, + { + id: "statusReport", + header: "Status report", + cell: ({ row }) => ( + + ), + meta: { cellClassName: "max-w-[200px] truncate" }, + }, +]; diff --git a/apps/dashboard/src/components/forms/incident/form.tsx b/apps/dashboard/src/components/forms/incident/form.tsx new file mode 100644 index 00000000..16f625ec --- /dev/null +++ b/apps/dashboard/src/components/forms/incident/form.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { incidentSeverity } from "@openstatus/db/src/schema/incidents/constants"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@openstatus/ui/components/ui/form"; +import { Input } from "@openstatus/ui/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@openstatus/ui/components/ui/select"; +import { Textarea } from "@openstatus/ui/components/ui/textarea"; +import { cn } from "@openstatus/ui/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import { isTRPCClientError } from "@trpc/client"; +import { format } from "date-fns"; +import React, { useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +import { + FormCardContent, + FormCardSeparator, +} from "@/components/forms/form-card"; +import { useFormSheetDirty } from "@/components/forms/form-sheet"; +import { personName, severityConfig } from "@/data/managed-incidents.client"; +import { useTRPC } from "@/lib/trpc/client"; + +const NONE = "none"; + +const schema = z.object({ + title: z.string().trim().min(1, "Title is required.").max(256), + severity: z.enum(incidentSeverity), + summary: z.string().max(4000), + commanderId: z.string(), + startedAt: z.string().min(1, "Start time is required."), + statusReportId: z.string(), +}); + +export type FormValues = z.infer; + +export type DeclareIncidentValues = { + title: string; + severity: FormValues["severity"]; + summary?: string; + commanderId: number | null; + startedAt: Date; + statusReportId?: number; +}; + +export function toLocalInput(date: Date): string { + return format(date, "yyyy-MM-dd'T'HH:mm"); +} + +export function FormDeclareIncident({ + defaultValues, + onSubmit, + className, + ...props +}: Omit, "onSubmit" | "defaultValues"> & { + defaultValues?: Partial; + onSubmit: (values: DeclareIncidentValues) => Promise; +}) { + const trpc = useTRPC(); + const { data: user } = useQuery(trpc.user.get.queryOptions()); + const { data: members } = useQuery(trpc.member.list.queryOptions()); + const { data: reports } = useQuery( + trpc.statusReport.list.queryOptions({ order: "desc" }), + ); + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + title: "", + severity: "major", + summary: "", + commanderId: user ? String(user.id) : NONE, + startedAt: toLocalInput(new Date()), + statusReportId: NONE, + ...defaultValues, + }, + }); + const [isPending, startTransition] = useTransition(); + const { setIsDirty } = useFormSheetDirty(); + + const formIsDirty = form.formState.isDirty; + React.useEffect(() => { + setIsDirty(formIsDirty); + }, [formIsDirty, setIsDirty]); + + function submitAction(values: FormValues) { + if (isPending) return; + startTransition(async () => { + try { + const promise = onSubmit({ + title: values.title, + severity: values.severity, + summary: values.summary || undefined, + commanderId: + values.commanderId === NONE ? null : Number(values.commanderId), + startedAt: new Date(values.startedAt), + statusReportId: + values.statusReportId === NONE + ? undefined + : Number(values.statusReportId), + }); + toast.promise(promise, { + loading: "Declaring...", + success: () => "Incident declared", + error: (error) => + isTRPCClientError(error) ? error.message : "Failed to declare", + }); + await promise; + } catch (error) { + console.error(error); + } + }); + } + + return ( +
+ + + ( + + Title + + + + + + )} + /> + ( + + Severity + + + Critical: major outage or data loss. Major: significant + degradation. Minor: limited impact. + + + + )} + /> + ( + + Summary + +