diff --git a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx index c4741278..a766184c 100644 --- a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx @@ -170,9 +170,11 @@ export function Client({ id }: { id: number }) { - {/* Resolved incidents lead with the postmortem; open ones with the timeline. */} + {/* Resolved incidents lead with the postmortem; open ones with the + timeline. A canceled one is a false alarm: no postmortem. */} {resolved ? postmortem : timeline} - {resolved ? timeline : postmortem} + {resolved ? timeline : null} + {resolved || incident.status === "canceled" ? null : postmortem} diff --git a/apps/dashboard/src/components/incidents/incident-postmortem.tsx b/apps/dashboard/src/components/incidents/incident-postmortem.tsx index cf1f3063..baffc5fc 100644 --- a/apps/dashboard/src/components/incidents/incident-postmortem.tsx +++ b/apps/dashboard/src/components/incidents/incident-postmortem.tsx @@ -1,9 +1,10 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; -import { AI } from "@openstatus/icons"; +import { AI, Check, Copy } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; import { InputGroupButton } from "@openstatus/ui/components/ui/input-group"; +import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; @@ -63,6 +64,7 @@ export function IncidentPostmortem({ // refetch never clobbers unsaved edits. const [draft, setDraft] = useState(null); const [confirmClose, setConfirmClose] = useState(false); + const { copy, isCopied } = useCopyToClipboard(); const server = postmortem?.content ?? TEMPLATE; const content = draft ?? server; @@ -125,6 +127,9 @@ export function IncidentPostmortem({ const canSave = !busy && dirty && content.trim().length > 0; const canApprove = postmortem !== null && !approved; + const showAgent = agentAllowed && !approved; + const hasActions = postmortem !== null || showAgent; + const hint = dirty ? "Unsaved changes" : approved && incident.closedAt === null @@ -136,17 +141,33 @@ export function IncidentPostmortem({ : "No postmortem yet"; return ( - + - {agentAllowed && !approved ? ( - draftWithAgent.mutate({ id: incident.id })} - > - - {draftWithAgent.isPending ? "Drafting..." : "Draft with agent"} - + {/* Rendered only with an action, so the header's "Markdown" hint shows otherwise. */} + {hasActions ? ( +
+ {postmortem ? ( + + copy(content, { withToast: "Postmortem copied" }) + } + > + {isCopied ? : } + Copy + + ) : null} + {showAgent ? ( + draftWithAgent.mutate({ id: incident.id })} + > + + {draftWithAgent.isPending ? "Drafting..." : "Draft with agent"} + + ) : null} +
) : null}
( report.status === "resolved" ? "monitoring" : report.status, ); @@ -95,10 +121,18 @@ function LinkedReport({ const update = usePublishUpdate(report.id); const unlink = useMutation( trpc.incident.unlinkStatusReport.mutationOptions({ - onSuccess: () => - queryClient.invalidateQueries({ - queryKey: trpc.incident.get.queryKey({ id: incident.id }), - }), + onSuccess: () => { + setConfirmUnlink(false); + return Promise.all([ + queryClient.invalidateQueries({ + queryKey: trpc.incident.get.queryKey({ id: incident.id }), + }), + queryClient.invalidateQueries({ + queryKey: trpc.incident.linkedStatusReportIds.queryKey(), + }), + ]); + }, + onError: (error) => toast.error(errorMessage(error)), }), ); const closed = incident.closedAt !== null; @@ -203,8 +237,7 @@ function LinkedReport({ @@ -212,6 +245,30 @@ function LinkedReport({ )} + + + + Unlink this status report? + + {report.title} stays on your status page as it is, but updates + from this incident no longer reach it. You can link it again + later. + + + + Keep linked + { + e.preventDefault(); + unlink.mutate({ id: incident.id }); + }} + > + Unlink + + + + ); } @@ -221,7 +278,8 @@ function UnlinkedReport({ incident }: { incident: Incident }) { const queryClient = useQueryClient(); const [createOpen, setCreateOpen] = useState(false); const [linking, setLinking] = useState(false); - const [reportId, setReportId] = useState(""); + const [pickerOpen, setPickerOpen] = useState(false); + const [reportId, setReportId] = useState(null); const closed = incident.closedAt !== null; const refresh = () => @@ -232,6 +290,9 @@ function UnlinkedReport({ incident }: { incident: Incident }) { queryClient.invalidateQueries({ queryKey: trpc.incident.listEvents.queryKey({ id: incident.id }), }), + queryClient.invalidateQueries({ + queryKey: trpc.incident.linkedStatusReportIds.queryKey(), + }), ]); const link = useMutation( trpc.incident.linkStatusReport.mutationOptions({ onSuccess: refresh }), @@ -240,9 +301,17 @@ function UnlinkedReport({ incident }: { incident: Incident }) { ...trpc.statusReport.list.queryOptions({ order: "desc" }), enabled: !closed, }); - const openReports = (reports ?? []).filter((r) => r.status !== "resolved"); + const { data: linkedIds } = useQuery({ + ...trpc.incident.linkedStatusReportIds.queryOptions(), + enabled: !closed, + }); + // Until both load, the list could offer reports another incident holds. + const linked = linkedIds ? new Set(linkedIds) : null; + const linkable = + reports && linked ? reports.filter((r) => !linked.has(r.id)) : []; + const selected = linkable.find((r) => r.id === reportId); // Closing mid-edit would otherwise strand a form whose footer is gone. - const showLink = !closed && linking && openReports.length > 0; + const showLink = !closed && linking && linkable.length > 0; return ( @@ -262,19 +331,63 @@ function UnlinkedReport({ incident }: { incident: Incident }) { {showLink ? ( - + + + + + + + + + No status report found. + + {linkable.map((r) => ( + { + setReportId(r.id); + setPickerOpen(false); + }} + > + + {r.title} + + {r.page.title} + + + + ))} + + + + + ) : null} {closed ? null : ( @@ -283,12 +396,13 @@ function UnlinkedReport({ incident }: { incident: Incident }) { <> - {openReports.length > 0 ? ( + {linkable.length > 0 ? (