diff --git a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx new file mode 100644 index 00000000..bd2022e8 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx @@ -0,0 +1,137 @@ +"use client"; + +import type { IncidentStatus } from "@openstatus/db/src/schema/incidents/constants"; +import { Badge } from "@openstatus/ui/components/ui/badge"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@openstatus/ui/components/ui/tabs"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { + EmptyStateContainer, + EmptyStateTitle, +} from "@/components/content/empty-state"; +import { + Section, + SectionDescription, + SectionGroup, + SectionHeader, + SectionTitle, +} from "@/components/content/section"; +import { IncidentComposer } from "@/components/incidents/incident-composer"; +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 { useFeature } from "@/hooks/use-feature"; +import { useTRPC } from "@/lib/trpc/client"; +import { cn } from "@/lib/utils"; + +export function Client({ id }: { id: number }) { + const trpc = useTRPC(); + const enabled = useFeature("incident-management"); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const { data: incident, isError } = useQuery({ + ...trpc.incident.get.queryOptions({ id }), + enabled, + retry: false, + }); + const { data: events } = useQuery({ + ...trpc.incident.listEvents.queryOptions({ id }), + enabled, + }); + const [followUp, setFollowUp] = useState<{ note: string } | null>(null); + + if (isError) { + return ( + + + Incident not found + + + ); + } + if (!incident) return null; + + const canNotify = workspace?.limits["status-subscribers"] === true; + const report = incident.statusReport; + const onStatusChanged = (status: IncidentStatus, note: string) => { + if ( + (status === "resolved" || status === "canceled") && + report && + report.status !== "resolved" + ) { + setFollowUp({ + note: + note || + (status === "resolved" + ? "This incident has been resolved." + : "This was a false alarm. Everything is operating normally."), + }); + } + }; + + return ( + +
+ + + {incident.title} + + {severityConfig[incident.severity].label} + + + {incident.summary ? ( + {incident.summary} + ) : null} + +
+ + + Timeline + + + {incident.closedAt === null ? ( + + ) : null} + + + + +
+
+ {report ? ( + { + if (!open) setFollowUp(null); + }} + /> + ) : null} +
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/incidents/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/incidents/[id]/page.tsx new file mode 100644 index 00000000..8c4ec5c0 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/incidents/[id]/page.tsx @@ -0,0 +1,14 @@ +import { notFound } from "next/navigation"; + +import { Client } from "./client"; + +export default async function Page({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const incidentId = Number(id); + if (!Number.isInteger(incidentId)) return notFound(); + return ; +} diff --git a/apps/dashboard/src/app/(dashboard)/incidents/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/incidents/breadcrumb.tsx index dfe54d30..9043b39b 100644 --- a/apps/dashboard/src/app/(dashboard)/incidents/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/incidents/breadcrumb.tsx @@ -1,13 +1,40 @@ "use client"; import { Incident } from "@openstatus/icons"; +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; +import { useTRPC } from "@/lib/trpc/client"; export function Breadcrumb() { + const trpc = useTRPC(); + const params = useParams<{ id?: string }>(); + const id = params.id ? Number(params.id) : null; + const { data: incident } = useQuery({ + ...trpc.incident.get.queryOptions({ id: id ?? 0 }), + enabled: id !== null, + retry: false, + }); + + if (id === null) { + return ( + + ); + } return ( ); } diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx index d5ac7ada..697a294c 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/page.tsx @@ -6,6 +6,7 @@ import { Button } from "@openstatus/ui/components/ui/button"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useParams } from "next/navigation"; +import { Link } from "@/components/common/link"; import { EmptyStateContainer, EmptyStateDescription, @@ -32,6 +33,7 @@ import { impactsEqual, toCreateStatusReportUpdateInput, } from "@/data/status-report-updates.client"; +import { useFeature } from "@/hooks/use-feature"; import { useTRPC } from "@/lib/trpc/client"; export default function Page() { @@ -43,6 +45,14 @@ export default function Page() { trpc.statusReport.get.queryOptions({ id: Number.parseInt(reportId) }), ); + const incidentsEnabled = useFeature("incident-management"); + const { data: linkedIncident } = useQuery({ + ...trpc.incident.forStatusReport.queryOptions({ + statusReportId: Number.parseInt(reportId), + }), + enabled: incidentsEnabled, + }); + const sendStatusReportUpdateMutation = useMutation( trpc.subscriberNotification.statusReport.mutationOptions(), ); @@ -97,6 +107,16 @@ export default function Page() { {affected ? affected : "zero"} {" "} component(s). + {linkedIncident ? ( + <> + {" "} + Communicates incident{" "} + + {linkedIncident.title} + + . + + ) : null} diff --git a/apps/dashboard/src/components/incidents/incident-composer.tsx b/apps/dashboard/src/components/incidents/incident-composer.tsx new file mode 100644 index 00000000..fde76f1e --- /dev/null +++ b/apps/dashboard/src/components/incidents/incident-composer.tsx @@ -0,0 +1,146 @@ +"use client"; + +import type { IncidentStatus } from "@openstatus/db/src/schema/incidents/constants"; +import { Button } from "@openstatus/ui/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + 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 { 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 { useTRPC } from "@/lib/trpc/client"; + +const UNCHANGED = "unchanged"; + +/** + * Posting with the status unchanged adds a note; picking a new status moves + * the incident and carries the text on the same timeline event. + */ +export function IncidentComposer({ + incident, + onStatusChanged, +}: { + incident: { + id: number; + status: IncidentStatus; + allowedTransitions: ReadonlyArray; + }; + onStatusChanged: (status: IncidentStatus, note: string) => void; +}) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [status, setStatus] = useState(UNCHANGED); + 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 addNote = useMutation( + trpc.incident.addNote.mutationOptions({ onSuccess: invalidate }), + ); + const setIncidentStatus = useMutation( + trpc.incident.setStatus.mutationOptions({ onSuccess: invalidate }), + ); + const pending = addNote.isPending || setIncidentStatus.isPending; + const next = incident.allowedTransitions.find((s) => s === status); + + async function submit() { + const note = message.trim(); + const promise: Promise = next + ? setIncidentStatus + .mutateAsync({ + id: incident.id, + status: next, + note: note || undefined, + }) + .then(() => undefined) + : addNote + .mutateAsync({ id: incident.id, message: note }) + .then(() => undefined); + toast.promise(promise, { + loading: "Posting...", + success: "Posted", + error: (error) => + isTRPCClientError(error) ? error.message : "Failed to post", + }); + await promise; + setMessage(""); + setStatus(UNCHANGED); + if (next) onStatusChanged(next, note); + } + + const disabled = pending || (!next && !message.trim()); + + return ( +
+ +
+ + Write + Preview + + +
+ +