diff --git a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx index a9f77fd4..e800b6b7 100644 --- a/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/incidents/[id]/client.tsx @@ -1,17 +1,10 @@ "use client"; import type { IncidentStatus } from "@openstatus/db/src/schema/incidents/constants"; -import { - Tabs, - TabsContent, - TabsList, - 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, @@ -20,6 +13,7 @@ import { DetailMain, DetailMeta, DetailMetaItem, + DetailMetaTime, DetailSection, DetailSectionTitle, } from "@/components/content/detail"; @@ -100,8 +94,42 @@ export function Client({ id }: { id: number }) { } }; + const resolved = incident.status === "resolved"; + const timeline = ( + + + Timeline + {events ? ( + + {events.length} + + ) : null} + + + {closed ? null : ( + + )} + {events?.map((event) => ( + + ))} + + + ); + const postmortem = ( + + Postmortem + + + ); + return ( - + - - - + + {formatDistanceToNow(incident.declaredAt, { addSuffix: true })} + {incident.closedAt ? ( Closed - - - + + {formatDistanceToNow(incident.closedAt, { addSuffix: true })} + ) : ( @@ -149,38 +173,9 @@ export function Client({ id }: { id: number }) { - - - - Timeline - {events ? ( - - {events.length} - - ) : null} - - Postmortem - - - - {closed ? null : ( - - )} - {events?.map((event) => ( - - ))} - - - - - - + {/* Resolved incidents lead with the postmortem; open ones with the timeline. */} + {resolved ? postmortem : timeline} + {resolved ? timeline : postmortem} @@ -218,7 +213,6 @@ export function Client({ id }: { id: number }) { {report ? ( + queryClient.getQueryData( + trpc.incident.get.queryKey({ id: incidentId }), + ), + ) + // the postmortem query is only enabled once resolved + .then((incident) => + incident?.status === "resolved" + ? queryClient.prefetchQuery( + trpc.incident.getPostmortem.queryOptions({ id: incidentId }), + ) + : undefined, + ), queryClient.prefetchQuery( trpc.incident.listEvents.queryOptions({ id: incidentId }), ), queryClient.prefetchQuery(trpc.member.list.queryOptions()), - queryClient.prefetchQuery( - trpc.statusReport.list.queryOptions({ order: "desc" }), - ), ]); return ( diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx index b45bbec6..8808a5fb 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx @@ -74,7 +74,7 @@ export function Client({ if (!workspace || !monitor) return null; - // No `SectionGroup` here: it centres content in a `max-w-4xl` column, and the + // No `SectionGroup` here: it centres content in a `max-w-6xl` column, and the // table renders its own filter sidebar + toolbar shell that needs the full // width of the content area. The box is pinned to the space left below the // app header and the monitor tabs so the document never scrolls: the filter diff --git a/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx b/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx index 9194c419..90e63c7b 100644 --- a/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx +++ b/apps/dashboard/src/app/(dashboard)/overview/create-event-button-group.tsx @@ -13,9 +13,13 @@ import { useState } from "react"; import { FormSheetMaintenanceCreate } from "@/components/forms/maintenance/sheet-create"; import { FormSheetStatusReportCreate } from "@/components/forms/status-report/sheet-create"; +import { DeclareIncidentButton } from "@/components/incidents/declare-incident-button"; +import { useFeature } from "@/hooks/use-feature"; export function CreateEventButtonGroup() { const [maintenanceOpen, setMaintenanceOpen] = useState(false); + const [incidentOpen, setIncidentOpen] = useState(false); + const incidentsEnabled = useFeature("incident-management"); return (
@@ -42,6 +46,12 @@ export function CreateEventButtonGroup() { Create Maintenance + {incidentsEnabled ? ( + setIncidentOpen(true)}> + + Declare Incident + + ) : null} @@ -49,6 +59,10 @@ export function CreateEventButtonGroup() { open={maintenanceOpen} onOpenChange={setMaintenanceOpen} /> + {/* Mounted on demand: the sheet fetches integrations as soon as it renders. */} + {incidentsEnabled && incidentOpen ? ( + + ) : null}
); } diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/breadcrumb.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/breadcrumb.tsx index 6ae97cfa..f9deb367 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/breadcrumb.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/breadcrumb.tsx @@ -5,17 +5,33 @@ import { useQuery } from "@tanstack/react-query"; import { useParams, usePathname } from "next/navigation"; import { NavBreadcrumb } from "@/components/nav/nav-breadcrumb"; +import { useHydrated } from "@/hooks/use-hydrated"; import { useTRPC } from "@/lib/trpc/client"; import { STATUS_PAGE_TABS } from "./constants"; export function Breadcrumb() { - const { id } = useParams<{ id: string }>(); + // The entity crumb depends on data the page hydrates after this layout + // streamed, so it is only rendered on the client. + const hydrated = useHydrated(); + const { id, reportId, maintenanceId } = useParams<{ + id: string; + reportId?: string; + maintenanceId?: string; + }>(); const pathname = usePathname(); const trpc = useTRPC(); const { data: statusPage } = useQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); + const { data: report } = useQuery({ + ...trpc.statusReport.get.queryOptions({ id: Number(reportId) }), + enabled: reportId !== undefined, + }); + const { data: maintenance } = useQuery({ + ...trpc.maintenance.get.queryOptions({ id: Number(maintenanceId) }), + enabled: maintenanceId !== undefined, + }); if (!statusPage) return null; @@ -23,6 +39,8 @@ export function Breadcrumb() { const currentTab = STATUS_PAGE_TABS.find((tab) => segments.includes(tab.value), ); + const onDetail = reportId !== undefined || maintenanceId !== undefined; + const entity = hydrated ? (report?.title ?? maintenance?.title) : undefined; return ( diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/layout.tsx similarity index 96% rename from apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/layout.tsx rename to apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/layout.tsx index 143aec60..bbbb8f89 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/layout.tsx @@ -7,7 +7,7 @@ import { } from "@/lib/sidebar-cookie"; import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; -import { Sidebar } from "../sidebar"; +import { Sidebar } from "../../sidebar"; export default async function Layout({ children, diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx similarity index 100% rename from apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx rename to apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/client.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/client.tsx new file mode 100644 index 00000000..126e7514 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/client.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { formatDistanceStrict } from "date-fns"; + +import { + DetailActions, + DetailAside, + DetailContent, + DetailHeader, + DetailInput, + DetailMain, + DetailMeta, + DetailMetaItem, + DetailMetaTime, + DetailSection, + DetailSectionTitle, + DetailTitle, + DetailTitleRow, +} from "@/components/content/detail"; +import { SectionGroup } from "@/components/content/section"; +import { MaintenanceActions } from "@/components/maintenances/maintenance-actions"; +import { MaintenanceComponents } from "@/components/maintenances/maintenance-components"; +import { MaintenanceComposer } from "@/components/maintenances/maintenance-composer"; +import { MaintenanceProperties } from "@/components/maintenances/maintenance-properties"; +import { useMaintenanceStatus } from "@/components/maintenances/use-maintenance-status"; +import { useUpdateMaintenance } from "@/components/maintenances/use-update-maintenance"; +import { Notifications } from "@/components/status-pages/notifications"; +import { getPageUrl } from "@/data/status-pages.client"; +import { useTRPC } from "@/lib/trpc/client"; + +export function Client({ id, pageId }: { id: number; pageId: number }) { + const trpc = useTRPC(); + const { data: maintenance } = useQuery( + trpc.maintenance.get.queryOptions({ id }), + ); + const { data: page } = useQuery(trpc.page.get.queryOptions({ id: pageId })); + const rename = useUpdateMaintenance(id); + const status = useMaintenanceStatus( + maintenance ?? { from: new Date(0), to: new Date(0) }, + ); + + if (!maintenance || !page) return null; + + const publicUrl = `${getPageUrl(page)}/events/maintenance/${maintenance.id}`; + + return ( + + + + + rename.update({ title })} + /> + + + + + + + + {status === "scheduled" ? "Starts" : "Started"}{" "} + + + + {status === "completed" ? "Lasted" : "Lasts"}{" "} + + {formatDistanceStrict(maintenance.from, maintenance.to)} + + + + + + + + Message + + + + + + Properties + + + + Affected components + + + + Notifications + + + + + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/page.tsx new file mode 100644 index 00000000..c4a052c0 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/page.tsx @@ -0,0 +1,39 @@ +import { notFound } from "next/navigation"; + +import { + HydrateClient, + fetchQueryOrNotFound, + getQueryClient, + trpc, +} from "@/lib/trpc/server"; + +import { Client } from "./client"; + +export default async function Page({ + params, +}: { + params: Promise<{ id: string; maintenanceId: string }>; +}) { + const { id, maintenanceId } = await params; + const pageId = Number(id); + const maintenanceIdNumber = Number(maintenanceId); + if (!Number.isInteger(pageId) || !Number.isInteger(maintenanceIdNumber)) { + notFound(); + } + + const subscribers = getQueryClient().prefetchQuery( + trpc.pageSubscriber.list.queryOptions({ pageId }), + ); + const maintenance = await fetchQueryOrNotFound( + trpc.maintenance.get.queryOptions({ id: maintenanceIdNumber }), + ); + // the URL's page must own the maintenance + if (maintenance.pageId !== pageId) notFound(); + await subscribers; + + return ( + + + + ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/nav-actions.tsx index 3979fdd3..c1a6167e 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/nav-actions.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/nav-actions.tsx @@ -14,7 +14,7 @@ import { toast } from "sonner"; import { QuickActions } from "@/components/dropdowns/quick-actions"; import { NavFeedback } from "@/components/nav/nav-feedback"; -import { getActions } from "@/data/status-pages.client"; +import { getActions, getPageUrl } from "@/data/status-pages.client"; import { useTRPC } from "@/lib/trpc/client"; export function NavActions() { @@ -58,13 +58,7 @@ export function NavActions() { diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/sidebar.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/sidebar.tsx index 2979cbfa..a3c6635e 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/sidebar.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/sidebar.tsx @@ -14,6 +14,7 @@ import { useParams } from "next/navigation"; import { Link } from "@/components/common/link"; import { TableCellLink } from "@/components/data-table/table-cell-link"; import { SidebarRight } from "@/components/nav/sidebar-right"; +import { getPageUrl } from "@/data/status-pages.client"; import { useTRPC } from "@/lib/trpc/client"; export function Sidebar() { @@ -26,9 +27,7 @@ export function Sidebar() { if (!statusPage) return null; - const BADGE_URL = `https://${ - statusPage.customDomain || `${statusPage.slug}.openstatus.dev` - }/badge/v2`; + const BADGE_URL = `${getPageUrl(statusPage)}/badge/v2`; return ( + {statusPage.slug} ), @@ -138,12 +131,7 @@ export function Sidebar() { footerButton={{ onClick: () => typeof window !== "undefined" && - window.open( - `https://${ - statusPage.customDomain || `${statusPage.slug}.openstatus.dev` - }`, - "_blank", - ), + window.open(getPageUrl(statusPage), "_blank"), children: ( <> diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/layout.tsx similarity index 96% rename from apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/layout.tsx rename to apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/layout.tsx index df7a4b6e..e6750ded 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/layout.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/layout.tsx @@ -7,7 +7,7 @@ import { } from "@/lib/sidebar-cookie"; import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server"; -import { Sidebar } from "../sidebar"; +import { Sidebar } from "../../sidebar"; export default async function Layout({ children, diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/page.tsx similarity index 88% rename from apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx rename to apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/page.tsx index 4aea477e..5ef268c1 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/(list)/page.tsx @@ -17,7 +17,6 @@ import { SectionHeaderRow, SectionTitle, } from "@/components/content/section"; -import { DataTable as UpdatesDataTable } from "@/components/data-table/status-report-updates/data-table"; import { columns } from "@/components/data-table/status-reports/columns"; import { FormSheetStatusReport } from "@/components/forms/status-report/sheet"; import { toCheckboxTreeItems } from "@/components/ui/checkbox-tree"; @@ -136,23 +135,7 @@ export default function Page() { - - row.getCanExpand() ? row.toggleExpanded() : undefined - } - rowComponent={({ row }) => ( - ({ - id: c.id, - name: c.name, - }))} - /> - )} - /> +
); diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/client.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/client.tsx new file mode 100644 index 00000000..e40058b3 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/client.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { currentImpactsFromUpdates } from "@openstatus/db/src/schema/page_components/constants"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { + ComponentImpact, + ComponentList, + ComponentListActions, + ComponentListEmpty, + ComponentListItem, + ComponentListName, +} from "@/components/content/component-list"; +import { + DetailActions, + DetailAside, + DetailContent, + DetailHeader, + DetailInput, + DetailMain, + DetailMeta, + DetailMetaItem, + DetailMetaTime, + DetailSection, + DetailSectionTitle, + DetailTitle, + DetailTitleRow, +} from "@/components/content/detail"; +import { SectionGroup } from "@/components/content/section"; +import { Timeline } from "@/components/content/timeline"; +import { Notifications } from "@/components/status-pages/notifications"; +import { StatusReportActions } from "@/components/status-reports/status-report-actions"; +import { StatusReportComposer } from "@/components/status-reports/status-report-composer"; +import { StatusReportProperties } from "@/components/status-reports/status-report-properties"; +import { StatusReportTimelineItem } from "@/components/status-reports/status-report-timeline"; +import { useInvalidateStatusReport } from "@/components/status-reports/use-invalidate-status-report"; +import { toGroupNameLookup } from "@/data/page-components.client"; +import { getPageUrl } from "@/data/status-pages.client"; +import { reportStartedAt } from "@/data/status-reports.client"; +import { useTRPC } from "@/lib/trpc/client"; +import { errorMessage } from "@/lib/trpc/error"; + +export function Client({ id, pageId }: { id: number; pageId: number }) { + const trpc = useTRPC(); + const { data: report } = useQuery(trpc.statusReport.get.queryOptions({ id })); + const { data: page } = useQuery(trpc.page.get.queryOptions({ id: pageId })); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + + const invalidate = useInvalidateStatusReport(id); + const update = useMutation( + trpc.statusReport.updateStatus.mutationOptions({ + onSuccess: invalidate, + onError: (error) => { + toast.error(errorMessage(error, "Failed to save")); + }, + }), + ); + + if (!report) return null; + + const publicUrl = `${getPageUrl(report.page)}/events/report/${report.id}`; + const canNotify = workspace?.limits["status-subscribers"] === true; + const updates = [...report.updates].sort( + (a, b) => b.date.getTime() - a.date.getTime() || b.id - a.id, + ); + const latest = updates[0]; + const currentImpacts = currentImpactsFromUpdates(report.updates); + const groupOf = toGroupNameLookup( + page?.pageComponents ?? [], + page?.pageComponentGroups ?? [], + ); + + return ( + + + + + update.mutate({ id: report.id, title })} + /> + + + + + + + + Opened + + + {updates.length} {updates.length === 1 ? "update" : "updates"} + + {latest ? ( + + Last update + + ) : null} + + + + + + + {updates.map((update, i) => ( + + ))} + + + + + Properties + + + + Affected components + {report.pageComponents.length ? ( + + {report.pageComponents.map((component) => ( + + + {component.name} + + + + + + ))} + + ) : ( + + )} +

+ Change impact per component in the composer. +

+
+ + Notifications + + +
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/layout.tsx deleted file mode 100644 index 557874e9..00000000 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/status-reports/[reportId]/layout.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { notFound } from "next/navigation"; - -import { HydrateClient, fetchQueryOrNotFound, trpc } from "@/lib/trpc/server"; - -export default async function Layout({ - children, - params, -}: { - children: React.ReactNode; - params: Promise<{ id: string; reportId: string }>; -}) { - const { reportId } = await params; - const statusReportId = Number.parseInt(reportId); - if (Number.isNaN(statusReportId)) notFound(); - await fetchQueryOrNotFound( - trpc.statusReport.get.queryOptions({ id: statusReportId }), - ); - return {children}; -} 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 697a294c..d0ec20ad 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 @@ -1,200 +1,51 @@ -"use client"; +import { notFound } from "next/navigation"; -import { currentImpactsFromUpdates } from "@openstatus/db/src/schema/page_components/constants"; -import { Add } from "@openstatus/icons"; -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, -} from "@/components/content/empty-state"; -import { - Section, - SectionDescription, - SectionGroup, - SectionHeader, - SectionHeaderRow, - SectionTitle, -} from "@/components/content/section"; -import { FormCardGroup } from "@/components/forms/form-card"; -import { FormSheetWithDirtyProtection } from "@/components/forms/form-sheet"; -import type { FormValues } from "@/components/forms/status-report-update/form"; import { - FormStatusReportUpdateCard, - type FormValues as UpdateCardFormValues, -} from "@/components/forms/status-report-update/form-status-report"; -import { FormSheetStatusReportUpdate } from "@/components/forms/status-report-update/sheet"; -import { - defaultComponentImpacts, - getNextStatus, - impactsEqual, - toCreateStatusReportUpdateInput, -} from "@/data/status-report-updates.client"; -import { useFeature } from "@/hooks/use-feature"; -import { useTRPC } from "@/lib/trpc/client"; - -export default function Page() { - const { reportId } = useParams<{ id: string; reportId: string }>(); - const trpc = useTRPC(); - const queryClient = useQueryClient(); - - const { data: statusReport, refetch } = useQuery( - 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(), - ); - - const createStatusReportUpdateMutation = useMutation( - trpc.statusReport.createStatusReportUpdate.mutationOptions({ - onSuccess: (update) => { - if (update?.notifySubscribers) { - sendStatusReportUpdateMutation.mutate({ id: update.id }); - } - refetch(); - queryClient.invalidateQueries({ - queryKey: trpc.page.list.queryKey(), - }); - }, - }), - ); - - const updateStatusReportUpdateMutation = useMutation( - trpc.statusReport.updateStatusReportUpdate.mutationOptions({ - onSuccess: () => { - refetch(); - }, - }), - ); - - if (!statusReport) return null; - - const updates = [...statusReport.updates].sort( - (a, b) => b.date.getTime() - a.date.getTime(), - ); - - const affected = statusReport.pageComponents - .map((component) => component.name) - .join(", "); - - const reportHasImpacts = statusReport.updates.some( - (u) => u.componentImpacts.length > 0, - ); - const currentImpacts = currentImpactsFromUpdates(statusReport.updates); - const nextStatus = getNextStatus(statusReport.status); + HydrateClient, + fetchQueryOrNotFound, + getQueryClient, + trpc, +} from "@/lib/trpc/server"; + +import { Client } from "./client"; + +export default async function Page({ + params, +}: { + params: Promise<{ id: string; reportId: string }>; +}) { + const { id, reportId } = await params; + const pageId = Number(id); + const statusReportId = Number(reportId); + if (!Number.isInteger(pageId) || !Number.isInteger(statusReportId)) { + notFound(); + } + + const queryClient = getQueryClient(); + const [report] = await Promise.all([ + fetchQueryOrNotFound( + trpc.statusReport.get.queryOptions({ id: statusReportId }), + ), + queryClient.prefetchQuery( + trpc.pageSubscriber.list.queryOptions({ pageId }), + ), + // forStatusReport throws FORBIDDEN without the feature; skip it then. + queryClient + .fetchQuery(trpc.workspace.get.queryOptions()) + .then((workspace) => + workspace.features.includes("incident-management") + ? queryClient.prefetchQuery( + trpc.incident.forStatusReport.queryOptions({ statusReportId }), + ) + : undefined, + ), + ]); + // the URL's page must own the report + if (report.pageId !== pageId) notFound(); return ( - -
- - - {statusReport.title} - - Manage updates for this status report. Affects{" "} - - {affected ? affected : "zero"} - {" "} - component(s). - {linkedIncident ? ( - <> - {" "} - Communicates incident{" "} - - {linkedIncident.title} - - . - - ) : null} - - - - - - Status Page Report - ({ - id: c.id, - name: c.name, - }))} - onSubmit={async (values: FormValues) => { - await createStatusReportUpdateMutation.mutateAsync( - toCreateStatusReportUpdateInput({ - statusReportId: statusReport.id, - values, - reportHasImpacts, - }), - ); - }} - > - - - - - - {updates.map((update, index) => ( - - ({ - id: c.id, - name: c.name, - }))} - defaultValues={{ - status: update.status, - message: update.message, - date: update.date, - componentImpacts: update.componentImpacts.map((ci) => ({ - pageComponentId: ci.pageComponentId, - impact: ci.impact, - })), - }} - onSubmit={async (values: UpdateCardFormValues) => { - await updateStatusReportUpdateMutation.mutateAsync({ - id: update.id, - statusReportId: statusReport.id, - message: values.message, - status: values.status, - // replace-set semantics: only send when actually edited, - // so untouched (incl. legacy) updates keep their rows - componentImpacts: impactsEqual( - values.componentImpacts ?? [], - update.componentImpacts, - ) - ? undefined - : values.componentImpacts, - date: values.date, - }); - }} - /> - - ))} - -
-
+ + + ); } diff --git a/apps/dashboard/src/components/content/component-list.tsx b/apps/dashboard/src/components/content/component-list.tsx new file mode 100644 index 00000000..366a87ea --- /dev/null +++ b/apps/dashboard/src/components/content/component-list.tsx @@ -0,0 +1,219 @@ +import type { PageComponentImpact } from "@openstatus/db/src/schema/page_components/constants"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectSeparator, + SelectTrigger, + SelectValue, +} from "@openstatus/ui/components/ui/select"; +import { cn } from "@openstatus/ui/lib/utils"; +import { Fragment } from "react"; + +import { StatusDot } from "@/components/common/status-dot"; +import { toComponentSections } from "@/data/page-components.client"; +import { impactDisplay } from "@/data/status-report-updates.client"; + +type Component = { + id: number; + name: string; + groupId?: number | null; + order?: number | null; + groupOrder?: number | null; +}; + +export function ComponentList({ + children, + className, + ...props +}: React.ComponentProps<"ul">) { + return ( + + ); +} + +export function ComponentListItem({ + children, + className, + ...props +}: React.ComponentProps<"li">) { + return ( +
  • + {children} +
  • + ); +} + +/** `group` renders muted after the name for components in a group. */ +export function ComponentListName({ + children, + group, + className, + ...props +}: React.ComponentProps<"span"> & { group?: string | null }) { + return ( + + {children} + {group ? ( + {group} + ) : null} + + ); +} + +/** Dot + label for an impact; nullish reads as untriaged. */ +export function ComponentImpact({ + impact, + className, + ...props +}: React.ComponentProps<"span"> & { + impact: PageComponentImpact | null | undefined; +}) { + const display = impactDisplay(impact); + return ( + + + {display.label} + + ); +} + +/** Right-aligned controls on a row, like `DetailActions`. */ +export function ComponentListActions({ + children, + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
    + {children} +
    + ); +} + +export function ComponentListEmpty({ + children = "No components affected.", + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

    + {children} +

    + ); +} + +// Borderless until hovered, like PropertySelectTrigger. +export function ComponentListSelectTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +/** + * Picker for `components` not yet on the list, in page order: a group's + * members sit under its label, runs of ungrouped components in between, + * separated. + */ +export function ComponentListAdd({ + components, + groups = [], + onAdd, + disabled = false, + placeholder, + className, +}: { + components: Component[]; + groups?: { id: number; name: string }[]; + onAdd: (id: number) => void; + disabled?: boolean; + /** Replaces the "Add component" / "All components added" copy. */ + placeholder?: string; + className?: string; +}) { + const sections = toComponentSections(components, groups); + const option = (c: Component) => ( + + {c.name} + + ); + + return ( + + ); +} diff --git a/apps/dashboard/src/components/content/composer.tsx b/apps/dashboard/src/components/content/composer.tsx index 7f2754a7..288c5534 100644 --- a/apps/dashboard/src/components/content/composer.tsx +++ b/apps/dashboard/src/components/content/composer.tsx @@ -15,15 +15,20 @@ 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. */ +/** + * Markdown composer: write/preview tabs around a textarea, with a footer for + * actions. `size="lg"` is for long-form documents (message, postmortem). + */ export function Composer({ children, className, + size = "default", ...props -}: React.ComponentProps) { +}: React.ComponentProps & { size?: "default" | "lg" }) { return ( @@ -34,6 +39,7 @@ export function Composer({ ); } +/** Write/Preview tabs on the left; `children` replaces the "Markdown" hint on the right. */ export function ComposerHeader({ children, className, @@ -48,7 +54,8 @@ export function ComposerHeader({ )} {...props} > - {children} + + {children ?? Markdown} ); } @@ -56,7 +63,7 @@ export function ComposerHeader({ 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({ +function ComposerTabs({ className, ...props }: Omit, "children">) { @@ -94,13 +101,29 @@ export function ComposerHint({ ); } +/** `onSubmit` fires on Cmd/Ctrl+Enter. */ export function ComposerTextarea({ className, + onSubmit, + onKeyDown, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + onSubmit?: () => void; +}) { return ( - + { + onKeyDown?.(e); + if (e.defaultPrevented || e.nativeEvent.isComposing) return; + if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && onSubmit) { + e.preventDefault(); + onSubmit(); + } + }} + {...props} + /> ); } @@ -115,7 +138,7 @@ export function ComposerPreview({
    ) { + return ( + + {children} + + ); +} + export function ComposerFooter({ children, className, diff --git a/apps/dashboard/src/components/content/detail.tsx b/apps/dashboard/src/components/content/detail.tsx index 81511a8a..374eead3 100644 --- a/apps/dashboard/src/components/content/detail.tsx +++ b/apps/dashboard/src/components/content/detail.tsx @@ -3,8 +3,12 @@ import { Textarea } from "@openstatus/ui/components/ui/textarea"; import { cn } from "@openstatus/ui/lib/utils"; import { type VariantProps, cva } from "class-variance-authority"; +import { format } from "date-fns"; import { useState } from "react"; +import { HoverCardTimestamp } from "@/components/common/hover-card-timestamp"; +import { useHydrated } from "@/hooks/use-hydrated"; + export function DetailHeader({ children, className, @@ -213,6 +217,34 @@ export function DetailMetaItem({ ); } +/** Absolute by default; pass children for a relative or custom label. */ +export function DetailMetaTime({ + date, + children, + className, + ...props +}: Omit, "dateTime"> & { date: Date }) { + // local-time text differs between server and browser + const hydrated = useHydrated(); + return ( + + + + ); +} + export function DetailContent({ children, className, diff --git a/apps/dashboard/src/components/content/property-list.tsx b/apps/dashboard/src/components/content/property-list.tsx index 966177f2..7cc2ebdf 100644 --- a/apps/dashboard/src/components/content/property-list.tsx +++ b/apps/dashboard/src/components/content/property-list.tsx @@ -1,7 +1,10 @@ +import { ArrowUpRight } from "@openstatus/icons"; import { Input } from "@openstatus/ui/components/ui/input"; import { SelectTrigger } from "@openstatus/ui/components/ui/select"; import { cn } from "@openstatus/ui/lib/utils"; +import { Link } from "@/components/common/link"; + export function PropertyList({ children, className, @@ -72,6 +75,27 @@ export function PropertyValue({ ); } +// pr-2 lines the arrow up with select chevrons on sibling rows. +export function PropertyLink({ + children, + className, + ...props +}: React.ComponentProps) { + 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"; diff --git a/apps/dashboard/src/components/content/section.tsx b/apps/dashboard/src/components/content/section.tsx index 96530c59..bbf12c6b 100644 --- a/apps/dashboard/src/components/content/section.tsx +++ b/apps/dashboard/src/components/content/section.tsx @@ -79,7 +79,7 @@ export function SectionGroup({ }: React.ComponentProps<"div">) { return (
    {children} diff --git a/apps/dashboard/src/components/content/timeline.tsx b/apps/dashboard/src/components/content/timeline.tsx index 31f7130f..2cc0b321 100644 --- a/apps/dashboard/src/components/content/timeline.tsx +++ b/apps/dashboard/src/components/content/timeline.tsx @@ -1,8 +1,17 @@ +"use client"; + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@openstatus/ui/components/ui/tooltip"; 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"; +import { UserAvatar } from "@/components/common/user-avatar"; +import { useHydrated } from "@/hooks/use-hydrated"; export function Timeline({ children, @@ -78,6 +87,105 @@ export function TimelineIndicator({ ); } +// Same size as the indicator so the rail starts below it; children render +// outside the avatar's overflow clip (see `TimelineAvatarBadge`). +export function TimelineAvatar({ + children, + className, + ...props +}: React.ComponentProps & { children?: React.ReactNode }) { + return ( + + + {children} + + ); +} + +// Event icon pinned to the avatar's corner; the ring lifts it off the photo. +export function TimelineAvatarBadge({ + children, + className, + variant, + ...props +}: React.ComponentProps<"span"> & + VariantProps) { + return ( + + ); +} + +type Actor = { + name: string | null; + email: string | null; + photoUrl?: string | null; +}; + +// Name and email on hover; the avatar is the only place the actor is shown. +export function TimelineActorTooltip({ + actor, + children, +}: { + actor: Actor; + children: React.ReactNode; +}) { + if (!actor.name && !actor.email) return children; + return ( + + + {children} + + + {actor.name && actor.name !== actor.email ? ( +
    {actor.name}
    + ) : null} + {actor.email ?
    {actor.email}
    : null} +
    +
    + ); +} + +// Inline actor avatar for rows whose rail shows the event indicator. +export function TimelineActor({ + actor, + className, + ...props +}: Omit, "name" | "src"> & { + actor: Actor; +}) { + return ( + + + + ); +} + // pt-1.5 centers the first text line on the indicator. export function TimelineContent({ children, @@ -156,18 +264,24 @@ export function TimelineTime({ className, ...props }: Omit, "dateTime"> & { date: Date }) { + // local-time text differs between server and browser + const hydrated = useHydrated(); return ( ); diff --git a/apps/dashboard/src/components/data-table/maintenances/columns.tsx b/apps/dashboard/src/components/data-table/maintenances/columns.tsx index 2de5d98d..4b9a98e6 100644 --- a/apps/dashboard/src/components/data-table/maintenances/columns.tsx +++ b/apps/dashboard/src/components/data-table/maintenances/columns.tsx @@ -4,8 +4,10 @@ import type { RouterOutputs } from "@openstatus/api"; import type { ColumnDef } from "@tanstack/react-table"; import { formatDistanceStrict } from "date-fns"; -import { ProcessMessage } from "@/components/content/process-message"; +import { Link } from "@/components/common/link"; +import { TableCellBadge } from "@/components/data-table/table-cell-badge"; 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 { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; @@ -17,6 +19,15 @@ export const columns: ColumnDef[] = [ { accessorKey: "title", header: "Title", + cell: ({ row }) => { + const { id, pageId } = row.original; + return ( + + ); + }, enableSorting: false, enableHiding: false, meta: { @@ -24,15 +35,26 @@ export const columns: ColumnDef[] = [ }, }, { - accessorKey: "message", - header: "Message", + id: "pageComponents", + accessorFn: (row) => row.pageComponents, + header: "Affected", enableSorting: false, - enableHiding: false, cell: ({ row }) => { - const value = String(row.getValue("message")); + const value = row.original.pageComponents; + if (value.length === 0) { + return
    -
    ; + } return ( -
    - +
    + {value.map((c) => + c.monitorId ? ( + + + + ) : ( + + ), + )}
    ); }, 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 b5f4c34e..95e4a6d2 100644 --- a/apps/dashboard/src/components/data-table/managed-incidents/columns.tsx +++ b/apps/dashboard/src/components/data-table/managed-incidents/columns.tsx @@ -16,14 +16,6 @@ import { type ManagedIncident = NonNullable[number]; export const columns: ColumnDef[] = [ - { - accessorKey: "severity", - header: "Severity", - enableSorting: false, - cell: ({ row }) => ( - - ), - }, { accessorKey: "title", header: "Title", @@ -36,6 +28,14 @@ export const columns: ColumnDef[] = [ ), meta: { cellClassName: "max-w-[260px] truncate" }, }, + { + accessorKey: "severity", + header: "Severity", + enableSorting: false, + cell: ({ row }) => ( + + ), + }, { accessorKey: "status", header: "Status", diff --git a/apps/dashboard/src/components/data-table/overview-events/columns.tsx b/apps/dashboard/src/components/data-table/overview-events/columns.tsx index 58279932..f7f5ba14 100644 --- a/apps/dashboard/src/components/data-table/overview-events/columns.tsx +++ b/apps/dashboard/src/components/data-table/overview-events/columns.tsx @@ -59,7 +59,7 @@ function getTitle(event: OverviewEvent): { title: string; href: string } { case "maintenance": return { title: event.maintenance.title, - href: `/status-pages/${event.maintenance.pageId}/maintenances`, + href: `/status-pages/${event.maintenance.pageId}/maintenances/${event.maintenance.id}`, }; } } diff --git a/apps/dashboard/src/components/data-table/status-report-updates/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/status-report-updates/data-table-row-actions.tsx deleted file mode 100644 index 5d1033b8..00000000 --- a/apps/dashboard/src/components/data-table/status-report-updates/data-table-row-actions.tsx +++ /dev/null @@ -1,117 +0,0 @@ -"use client"; - -import type { RouterOutputs } from "@openstatus/api"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useParams } from "next/navigation"; -import { useRef } from "react"; - -import { QuickActions } from "@/components/dropdowns/quick-actions"; -import { FormSheetStatusReportUpdate } from "@/components/forms/status-report-update/sheet"; -import { getActions, impactsEqual } from "@/data/status-report-updates.client"; -import { useTRPC } from "@/lib/trpc/client"; - -type StatusReportUpdate = - RouterOutputs["statusReport"]["list"][number]["updates"][number]; - -interface DataTableRowActionsProps { - row: StatusReportUpdate; - components?: { id: number; name: string }[]; -} - -export function DataTableRowActions({ - row, - components, -}: DataTableRowActionsProps) { - const buttonRef = useRef(null); - const { id } = useParams<{ id: string }>(); - const trpc = useTRPC(); - const queryClient = useQueryClient(); - const updateStatusReportUpdateMutation = useMutation( - trpc.statusReport.updateStatusReportUpdate.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.statusReport.list.queryKey({ - pageId: Number.parseInt(id), - }), - }); - queryClient.invalidateQueries({ - queryKey: trpc.page.list.queryKey(), - }); - queryClient.invalidateQueries({ - queryKey: trpc.statusReport.list.queryKey({ - period: "7d", - }), - }); - }, - }), - ); - const deleteStatusReportUpdateMutation = useMutation( - trpc.statusReport.deleteUpdate.mutationOptions({ - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: trpc.statusReport.list.queryKey({ - pageId: Number.parseInt(id), - }), - }); - queryClient.invalidateQueries({ - queryKey: trpc.statusReport.list.queryKey({ - period: "7d", - }), - }); - }, - }), - ); - const actions = getActions({ - edit: () => buttonRef.current?.click(), - }); - - return ( - <> - { - await deleteStatusReportUpdateMutation.mutateAsync({ - id: row.id, - }); - }, - }} - /> - ({ - pageComponentId: ci.pageComponentId, - impact: ci.impact, - })), - }} - components={components} - allowUnsetImpacts - onSubmit={async (values) => { - await updateStatusReportUpdateMutation.mutateAsync({ - id: row.id, - statusReportId: row.statusReportId, - message: values.message, - status: values.status, - // replace-set semantics: only send when actually edited, so - // untouched (incl. legacy) updates keep their rows - componentImpacts: impactsEqual( - values.componentImpacts ?? [], - row.componentImpacts, - ) - ? undefined - : values.componentImpacts, - date: values.date, - }); - }} - > - - - - ); -} diff --git a/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx b/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx deleted file mode 100644 index 583b929b..00000000 --- a/apps/dashboard/src/components/data-table/status-report-updates/data-table.tsx +++ /dev/null @@ -1,159 +0,0 @@ -"use client"; - -import type { RouterOutputs } from "@openstatus/api"; -import { currentImpactsFromUpdates } from "@openstatus/db/src/schema/page_components/constants"; -import { Add } from "@openstatus/icons"; -import { Button } from "@openstatus/ui/components/ui/button"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@openstatus/ui/components/ui/table"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@openstatus/ui/components/ui/tooltip"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useParams } from "next/navigation"; - -import { ProcessMessage } from "@/components/content/process-message"; -import { TableCellDate } from "@/components/data-table/table-cell-date"; -import { FormSheetStatusReportUpdate } from "@/components/forms/status-report-update/sheet"; -import { icons } from "@/data/icons"; -import { - colors, - defaultComponentImpacts, - getNextStatus, - toCreateStatusReportUpdateInput, -} from "@/data/status-report-updates.client"; -import { useTRPC } from "@/lib/trpc/client"; -import { cn } from "@/lib/utils"; - -import { DataTableRowActions } from "./data-table-row-actions"; - -type StatusReportUpdates = - RouterOutputs["statusReport"]["list"][number]["updates"]; - -export function DataTable({ - updates, - reportId, - components = [], -}: { - updates: StatusReportUpdates; - reportId: number; - components?: { id: number; name: string }[]; -}) { - const reportHasImpacts = updates.some((u) => u.componentImpacts.length > 0); - const currentImpacts = currentImpactsFromUpdates(updates); - const nextStatus = getNextStatus( - updates[updates.length - 1]?.status ?? "investigating", - ); - const trpc = useTRPC(); - const { id } = useParams<{ id: string }>(); - const queryClient = useQueryClient(); - const sendStatusReportUpdateMutation = useMutation( - trpc.subscriberNotification.statusReport.mutationOptions(), - ); - const createStatusReportUpdateMutation = useMutation( - trpc.statusReport.createStatusReportUpdate.mutationOptions({ - onSuccess: (update) => { - if (update?.notifySubscribers) { - sendStatusReportUpdateMutation.mutate({ id: update.id }); - } - queryClient.invalidateQueries({ - queryKey: trpc.statusReport.list.queryKey({ - pageId: Number.parseInt(id), - }), - }); - queryClient.invalidateQueries({ - queryKey: trpc.page.list.queryKey(), - }); - queryClient.invalidateQueries({ - queryKey: trpc.statusReport.list.queryKey({ - period: "7d", - }), - }); - }, - }), - ); - - return ( - - - - - Status - - Message - Date - - - - { - await createStatusReportUpdateMutation.mutateAsync( - toCreateStatusReportUpdateInput({ - statusReportId: reportId, - values, - reportHasImpacts, - }), - ); - }} - > - - - - - - Create Report Update - - - - - - - - {updates.map((update) => { - const Icon = icons.status[update.status]; - return ( - - -
    - -
    -
    - -
    - -
    -
    - - - - - - -
    - ); - })} -
    -
    - ); -} diff --git a/apps/dashboard/src/components/data-table/status-reports/columns.tsx b/apps/dashboard/src/components/data-table/status-reports/columns.tsx index c23fe6b6..386c2e0e 100644 --- a/apps/dashboard/src/components/data-table/status-reports/columns.tsx +++ b/apps/dashboard/src/components/data-table/status-reports/columns.tsx @@ -5,9 +5,6 @@ import { type PageComponentImpact, worstImpact, } from "@openstatus/db/src/schema/page_components/constants"; -import { ChevronDown, ChevronUp } from "@openstatus/icons"; -import { Button } from "@openstatus/ui/components/ui/button"; -import { cn } from "@openstatus/ui/lib/utils"; import type { ColumnDef } from "@tanstack/react-table"; import Link from "next/link"; @@ -15,12 +12,12 @@ import { TableCellBadge } from "@/components/data-table/table-cell-badge"; 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 { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; import { - colors, - impactConfig, - untriagedImpact, -} from "@/data/status-report-updates.client"; + StatusReportImpactBadge, + StatusReportStatusBadge, +} from "@/components/status-reports/status-report-badge"; +import { DataTableColumnHeader } from "@/components/ui/data-table/data-table-column-header"; +import { reportStartedAt } from "@/data/status-reports.client"; import { DataTableRowActions } from "./data-table-row-actions"; @@ -38,103 +35,45 @@ function worstReportImpact(report: StatusReport) { } export const columns: ColumnDef[] = [ - { - id: "expander", - header: () => null, - cell: ({ row }) => { - return row.getCanExpand() ? ( - - ) : undefined; - }, - meta: { - headerClassName: "w-7", - }, - }, { accessorKey: "title", header: "Title", + enableSorting: false, + enableHiding: false, cell: ({ row }) => { const { id, pageId } = row.original; - return ( { - // avoid expanding the row - e.stopPropagation(); - }} value={row.getValue("title")} /> ); }, - enableSorting: false, - enableHiding: false, - meta: { - cellClassName: "max-w-[200px] truncate", - }, - }, - { - accessorKey: "status", - header: "Current Status", - cell: ({ row }) => { - const value = String(row.getValue("status")); - return ( -
    - {value} -
    - ); - }, - enableSorting: false, - enableHiding: false, + meta: { cellClassName: "max-w-[200px] truncate" }, }, { id: "impact", accessorFn: (row) => worstReportImpact(row), header: "Impact", - cell: ({ row }) => { - const impact = row.getValue("impact"); - const config = impact ? impactConfig[impact] : untriagedImpact; - return ( -
    - {config.label} -
    - ); - }, enableSorting: false, + cell: ({ row }) => ( + ("impact")} + /> + ), + }, + { + accessorKey: "status", + header: "Status", + enableSorting: false, + enableHiding: false, + cell: ({ row }) => , }, { id: "updates", accessorFn: (row) => row.updates.length, header: "Updates", - cell: ({ row }) => { - const value = row.getValue("updates"); - return ; - }, + cell: ({ row }) => , }, { id: "pageComponents", @@ -162,23 +101,17 @@ export const columns: ColumnDef[] = [ }, { id: "startedAt", - accessorFn: (row) => - row.updates.sort((a, b) => a.date.getTime() - b.date.getTime())[0] - ?.date ?? row.createdAt, + accessorFn: (row) => reportStartedAt(row), header: ({ column }) => ( ), cell: ({ row }) => , enableHiding: false, - meta: { - cellClassName: "w-[170px]", - }, + meta: { cellClassName: "whitespace-nowrap" }, }, { id: "actions", cell: ({ row }) => , - meta: { - cellClassName: "w-8", - }, + meta: { cellClassName: "w-8" }, }, ]; diff --git a/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx index 1a6c9a5e..ebdcff3f 100644 --- a/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx +++ b/apps/dashboard/src/components/data-table/status-reports/data-table-row-actions.tsx @@ -1,14 +1,11 @@ "use client"; import type { RouterOutputs } from "@openstatus/api"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { Row } from "@tanstack/react-table"; -import { useRef } from "react"; import { QuickActions } from "@/components/dropdowns/quick-actions"; -import { FormSheetStatusReportUpdateCreate } from "@/components/forms/status-report-update/sheet-create"; -import { FormSheetStatusReport } from "@/components/forms/status-report/sheet"; -import { toCheckboxTreeItems } from "@/components/ui/checkbox-tree"; +import { getPageUrl } from "@/data/status-pages.client"; import { getActions } from "@/data/status-reports.client"; import { useTRPC } from "@/lib/trpc/client"; @@ -22,96 +19,41 @@ export function DataTableRowActions({ row }: { row: Row }) { } export function StatusReportRowActions({ report }: { report: StatusReport }) { - const buttonCreateRef = useRef(null); - const buttonUpdateRef = useRef(null); + const trpc = useTRPC(); + const queryClient = useQueryClient(); const actions = getActions({ - "create-update": () => buttonCreateRef.current?.click(), - edit: () => buttonUpdateRef.current?.click(), "view-report": () => { - if (typeof window !== "undefined") { - window.open( - `https://${ - report.page.customDomain || `${report.page.slug}.openstatus.dev` - }/events/report/${report.id}`, - "_blank", - ); - } + window.open( + `${getPageUrl(report.page)}/events/report/${report.id}`, + "_blank", + ); }, }); - const trpc = useTRPC(); - const queryClient = useQueryClient(); - const { data: page } = useQuery( - trpc.page.get.queryOptions( - { id: report.pageId ?? 0 }, - { enabled: report.pageId !== null }, - ), - ); - // no-input prefix key — matches every statusReport.list query (overview, page detail) - const invalidateLists = () => { - queryClient.invalidateQueries({ - queryKey: trpc.statusReport.list.queryKey(), - }); - queryClient.invalidateQueries({ - queryKey: trpc.page.list.queryKey(), - }); - }; - const updateStatusReportMutation = useMutation( - trpc.statusReport.updateStatus.mutationOptions({ - onSuccess: invalidateLists, - }), - ); const deleteStatusReportMutation = useMutation( trpc.statusReport.delete.mutationOptions({ - onSuccess: invalidateLists, + // no-input prefix key — matches every statusReport.list query (overview, page detail) + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.statusReport.list.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.page.list.queryKey(), + }); + }, }), ); if (!report.pageId) return null; return ( - <> - { - await deleteStatusReportMutation.mutateAsync({ - id: report.id, - }); - }, - }} - /> - c.id) ?? [], - }} - onSubmit={async (values) => { - const hasComponents = - (page?.pageComponents?.length ?? 0) > 0 || - (page?.pageComponentGroups?.length ?? 0) > 0; - await updateStatusReportMutation.mutateAsync({ - id: report.id, - pageComponents: hasComponents ? values.pageComponents : undefined, - title: values.title, - status: values.status, - }); - }} - > - - - - - - + { + await deleteStatusReportMutation.mutateAsync({ id: report.id }); + }, + }} + /> ); } diff --git a/apps/dashboard/src/components/forms/incident/form.tsx b/apps/dashboard/src/components/forms/incident/form.tsx index 30893163..9e01685b 100644 --- a/apps/dashboard/src/components/forms/incident/form.tsx +++ b/apps/dashboard/src/components/forms/incident/form.tsx @@ -23,8 +23,6 @@ import { 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"; @@ -37,7 +35,9 @@ import { } from "@/components/forms/form-card"; import { useFormSheetDirty } from "@/components/forms/form-sheet"; import { personName, severityConfig } from "@/data/managed-incidents.client"; +import { formatDateForInput } from "@/lib/formatter"; import { useTRPC } from "@/lib/trpc/client"; +import { errorMessage } from "@/lib/trpc/error"; const NONE = "none"; @@ -69,10 +69,6 @@ export type DeclareIncidentValues = { openSlackChannel: boolean; }; -export function toLocalInput(date: Date): string { - return format(date, "yyyy-MM-dd'T'HH:mm"); -} - export function FormDeclareIncident({ defaultValues, onSubmit, @@ -100,7 +96,7 @@ export function FormDeclareIncident({ // user.get is prefetched in the dashboard layout, so it is hydrated // before the sheet can mount and the default is never NONE for members. commanderId: user ? String(user.id) : NONE, - startedAt: toLocalInput(new Date()), + startedAt: formatDateForInput(new Date()), statusReportId: NONE, openSlackChannel: slack === "ready", ...defaultValues, @@ -134,8 +130,7 @@ export function FormDeclareIncident({ toast.promise(promise, { loading: "Declaring...", success: () => "Incident declared", - error: (error) => - isTRPCClientError(error) ? error.message : "Failed to declare", + error: (error) => errorMessage(error, "Failed to declare"), }); await promise; } catch (error) { diff --git a/apps/dashboard/src/components/forms/status-report-update/form-status-report.tsx b/apps/dashboard/src/components/forms/status-report-update/form-status-report.tsx deleted file mode 100644 index 7b57e3af..00000000 --- a/apps/dashboard/src/components/forms/status-report-update/form-status-report.tsx +++ /dev/null @@ -1,459 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import type { StatusReportUpdate } from "@openstatus/db/src/schema"; -import { pageComponentImpact } from "@openstatus/db/src/schema/page_components/constants"; -import { statusReportStatus } from "@openstatus/db/src/schema/status_reports/constants"; -import { Calendar as CalendarIcon, Clock } from "@openstatus/icons"; -import { Button } from "@openstatus/ui/components/ui/button"; -import { Calendar } from "@openstatus/ui/components/ui/calendar"; -import { Checkbox } from "@openstatus/ui/components/ui/checkbox"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@openstatus/ui/components/ui/form"; -import { Input } from "@openstatus/ui/components/ui/input"; -import { Label } from "@openstatus/ui/components/ui/label"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@openstatus/ui/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@openstatus/ui/components/ui/select"; -import { TabsContent } from "@openstatus/ui/components/ui/tabs"; -import { TabsList, TabsTrigger } from "@openstatus/ui/components/ui/tabs"; -import { Tabs } from "@openstatus/ui/components/ui/tabs"; -import { Textarea } from "@openstatus/ui/components/ui/textarea"; -import { useIsMobile } from "@openstatus/ui/hooks/use-mobile"; -import { cn } from "@openstatus/ui/lib/utils"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { isTRPCClientError } from "@trpc/client"; -import { format } from "date-fns"; -import { useParams } from "next/navigation"; -import React, { useTransition } from "react"; -import { useForm } from "react-hook-form"; -import { toast } from "sonner"; -import { z } from "zod"; - -import { ProcessMessage } from "@/components/content/process-message"; -import { FormAlertDialog } from "@/components/forms/form-alert-dialog"; -import { - FormCardContent, - FormCardSeparator, -} from "@/components/forms/form-card"; -import { - FormCard, - FormCardFooter, - FormCardHeader, - FormCardTitle, -} from "@/components/forms/form-card"; -import { useFormSheetDirty } from "@/components/forms/form-sheet"; -import { ComponentImpactList } from "@/components/forms/status-report/component-impact-field"; -import { colors } from "@/data/status-report-updates.client"; -import { useTRPC } from "@/lib/trpc/client"; - -const schema = z.object({ - status: z.enum(statusReportStatus), - message: z.string(), - date: z.date(), - componentImpacts: z - .array( - z.object({ - pageComponentId: z.number(), - impact: z.enum(pageComponentImpact), - }), - ) - .optional(), - notifySubscribers: z.boolean().optional(), -}); - -export type FormValues = z.infer; - -export function FormStatusReportUpdateCard({ - defaultValues, - onSubmit, - className, - index, - update, - components, - ...props -}: Omit, "onSubmit"> & { - defaultValues?: FormValues; - onSubmit: (values: FormValues) => Promise; - index: number; - update: StatusReportUpdate; - /** The report's affected components; renders the per-component impact picker. */ - components?: { id: number; name: string }[]; -}) { - const { reportId } = useParams<{ id: string; reportId: string }>(); - const trpc = useTRPC(); - const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); - const mobile = useIsMobile(); - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const form = useForm({ - resolver: zodResolver(schema), - defaultValues: defaultValues ?? { - status: "identified", - message: "", - date: new Date(), - componentImpacts: undefined, - notifySubscribers: !!workspace?.limits["status-subscribers"], - }, - }); - - const watchMessage = form.watch("message"); - const [isPending, startTransition] = useTransition(); - const { setIsDirty } = useFormSheetDirty(); - - const formIsDirty = form.formState.isDirty; - React.useEffect(() => { - setIsDirty(formIsDirty); - }, [formIsDirty, setIsDirty]); - - const { data: statusReport, refetch } = useQuery( - trpc.statusReport.get.queryOptions({ id: Number.parseInt(reportId) }), - ); - - function submitAction(values: FormValues) { - if (isPending) return; - - startTransition(async () => { - try { - const promise = onSubmit(values); - toast.promise(promise, { - loading: "Saving...", - success: () => "Saved", - error: (error) => { - if (isTRPCClientError(error)) { - return error.message; - } - return "Failed to save"; - }, - }); - await promise; - } catch (error) { - console.error(error); - } - }); - } - - const deleteStatusReportUpdateMutation = useMutation( - trpc.statusReport.deleteUpdate.mutationOptions({ - onSuccess: () => { - refetch(); - }, - }), - ); - - const updates = [...(statusReport?.updates ?? [])].sort( - (a, b) => b.date.getTime() - a.date.getTime(), - ); - - return ( - - - - Status Report Update #{updates.length - index} - - - -
    - - - ( - - Status - - - - - - )} - /> - ( - - Date - - - - - - - - { - if (!selectedDate) return; - const newDate = new Date(selectedDate); - newDate.setHours( - field.value.getHours(), - field.value.getMinutes(), - field.value.getSeconds(), - field.value.getMilliseconds(), - ); - field.onChange(newDate); - }} - disabled={(date) => - date > new Date() || date < new Date("1900-01-01") - } - initialFocus - /> -
    -
    - -
    - { - try { - const timeValue = e.target.value; - if (!timeValue || !field.value) return; - const [hours, minutes, seconds] = timeValue - .split(":") - .map(Number); - const newDate = new Date(field.value); - newDate.setHours( - hours, - minutes, - seconds || 0, - 0, - ); - field.onChange(newDate); - } catch (error) { - console.error(error); - } - }} - /> -
    -
    -
    -
    -
    -
    -
    - -
    - )} - /> -
    - - - When the status report was created. Shown in your timezone ( - - {timezone} - - ) and saved as Unix time ( - UTC - ). - - - {components && components.length > 0 ? ( - <> - - - ( - - Component Impact - - The impact this update set per component. Components - marked "No change" keep their prior impact. - - - - - - - )} - /> - - - ) : null} - - - - - Writing - Preview - - - ( - - Message - -