From cecc7036a7448ad5d7aec1163a09f7ede37ccde6 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Fri, 7 Aug 2026 16:38:17 +0200 Subject: [PATCH] feat: add maintenance updates for status page communications Allow posting chronological updates on maintenances across dashboard, API, RPC/MCP, feeds, and subscriber notifications. --- .../maintenances/[maintenanceId]/layout.tsx | 22 + .../maintenances/[maintenanceId]/page.tsx | 140 + .../status-pages/[id]/maintenances/page.tsx | 19 +- .../components/chat/tool-renderers/index.tsx | 31 + .../tool-renderers/maintenance-update.tsx | 32 + .../maintenances/data-table-row-actions.tsx | 4 +- .../forms/maintenance-update/card.tsx | 61 + .../forms/maintenance-update/form.tsx | 267 + .../forms/maintenance-update/sheet.tsx | 63 + .../forms/maintenance/sheet-create.tsx | 4 +- apps/server/src/routes/mcp/handler.test.ts | 3 + .../maintenance/__tests__/maintenance.test.ts | 87 + .../rpc/handlers/maintenance/converters.ts | 26 + .../routes/rpc/handlers/maintenance/index.ts | 78 +- .../src/routes/rpc/interceptors/tracking.ts | 9 + apps/server/src/routes/v1/index.ts | 7 + .../routes/v1/maintenanceUpdates/delete.ts | 46 + .../src/routes/v1/maintenanceUpdates/get.ts | 39 + .../src/routes/v1/maintenanceUpdates/index.ts | 20 + .../maintenance-updates.test.ts | 48 + .../src/routes/v1/maintenanceUpdates/post.ts | 62 + .../src/routes/v1/maintenanceUpdates/put.ts | 56 + .../routes/v1/maintenanceUpdates/schema.ts | 45 + .../src/routes/v1/maintenanceUpdates/utils.ts | 44 + .../src/routes/v1/maintenances/post.test.ts | 13 +- .../server/src/routes/v1/maintenances/post.ts | 23 +- apps/server/static/openapi.yaml | 209 + .../[locale]/(public)/feed/[type]/route.ts | 19 +- .../[locale]/(public)/feed/json/route.ts | 43 +- .../src/content/markdown/generators.test.ts | 22 + .../src/content/markdown/generators.ts | 65 +- .../src/content/status-json.test.ts | 12 + apps/status-page/src/content/status-json.ts | 6 + packages/analytics/src/events.ts | 12 + packages/api/src/router/maintenance.test.ts | 62 + packages/api/src/router/maintenance.ts | 88 +- packages/api/src/router/statusPage.ts | 12 + .../api/src/router/subscriber-notification.ts | 2 +- packages/db/drizzle/0084_mean_thunderbird.sql | 15 + packages/db/drizzle/meta/0084_snapshot.json | 5037 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../db/src/schema/audit_logs/validation.ts | 13 + .../db/src/schema/maintenances/maintenance.ts | 32 + .../db/src/schema/maintenances/validation.ts | 16 +- packages/db/src/schema/shared.ts | 6 +- .../maintenance/v1/maintenance.proto | 25 + .../openstatus/maintenance/v1/service.proto | 60 + packages/proto/gen/openapi.yaml | 209 + .../maintenance/v1/maintenance_pb.ts | 69 +- .../openstatus/maintenance/v1/service_pb.ts | 195 +- .../__tests__/maintenance-updates.test.ts | 38 + packages/services/src/agent-tools/index.ts | 19 +- .../services/src/agent-tools/maintenance.ts | 187 +- packages/services/src/agent-tools/prompt.ts | 5 +- .../maintenance/__tests__/maintenance.test.ts | 236 +- .../services/src/maintenance/add-update.ts | 64 + packages/services/src/maintenance/create.ts | 31 +- .../services/src/maintenance/delete-update.ts | 50 + .../services/src/maintenance/get-update.ts | 19 + packages/services/src/maintenance/index.ts | 13 +- packages/services/src/maintenance/internal.ts | 54 +- packages/services/src/maintenance/list.ts | 18 +- packages/services/src/maintenance/notify.ts | 27 +- packages/services/src/maintenance/schemas.ts | 34 +- .../services/src/maintenance/update-update.ts | 59 + packages/services/src/maintenance/update.ts | 16 +- packages/services/src/page/get-content.ts | 23 +- packages/services/src/types.ts | 5 +- .../subscriptions/src/channels/email.test.ts | 2 +- packages/subscriptions/src/channels/email.ts | 4 +- packages/subscriptions/src/dispatcher.test.ts | 63 +- packages/subscriptions/src/dispatcher.ts | 46 +- packages/subscriptions/src/types.ts | 2 +- .../src/components/blocks/status-events.tsx | 102 +- .../ui/src/components/blocks/status-feed.tsx | 1 + .../ui/src/components/blocks/status.types.ts | 4 + 76 files changed, 8438 insertions(+), 169 deletions(-) create mode 100644 apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/layout.tsx create mode 100644 apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/page.tsx create mode 100644 apps/dashboard/src/components/chat/tool-renderers/maintenance-update.tsx create mode 100644 apps/dashboard/src/components/forms/maintenance-update/card.tsx create mode 100644 apps/dashboard/src/components/forms/maintenance-update/form.tsx create mode 100644 apps/dashboard/src/components/forms/maintenance-update/sheet.tsx create mode 100644 apps/server/src/routes/v1/maintenanceUpdates/delete.ts create mode 100644 apps/server/src/routes/v1/maintenanceUpdates/get.ts create mode 100644 apps/server/src/routes/v1/maintenanceUpdates/index.ts create mode 100644 apps/server/src/routes/v1/maintenanceUpdates/maintenance-updates.test.ts create mode 100644 apps/server/src/routes/v1/maintenanceUpdates/post.ts create mode 100644 apps/server/src/routes/v1/maintenanceUpdates/put.ts create mode 100644 apps/server/src/routes/v1/maintenanceUpdates/schema.ts create mode 100644 apps/server/src/routes/v1/maintenanceUpdates/utils.ts create mode 100644 packages/db/drizzle/0084_mean_thunderbird.sql create mode 100644 packages/db/drizzle/meta/0084_snapshot.json create mode 100644 packages/services/src/agent-tools/__tests__/maintenance-updates.test.ts create mode 100644 packages/services/src/maintenance/add-update.ts create mode 100644 packages/services/src/maintenance/delete-update.ts create mode 100644 packages/services/src/maintenance/get-update.ts create mode 100644 packages/services/src/maintenance/update-update.ts diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/layout.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/layout.tsx new file mode 100644 index 000000000..83162b737 --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/layout.tsx @@ -0,0 +1,22 @@ +import { HydrateClient, fetchQueryOrNotFound, trpc } from "@/lib/trpc/server"; + +export default async function Layout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ id: string; maintenanceId: string }>; +}) { + const { id, maintenanceId } = await params; + await Promise.all([ + fetchQueryOrNotFound( + trpc.maintenance.get.queryOptions({ + id: Number.parseInt(maintenanceId), + }), + ), + fetchQueryOrNotFound( + trpc.page.get.queryOptions({ id: Number.parseInt(id) }), + ), + ]); + return {children}; +} 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 000000000..7c56a19aa --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/[maintenanceId]/page.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { Add } from "@openstatus/icons"; +import { Button } from "@openstatus/ui/components/ui/button"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { useParams } from "next/navigation"; + +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 { FormMaintenanceUpdateCard } from "@/components/forms/maintenance-update/card"; +import { FormSheetMaintenanceUpdate } from "@/components/forms/maintenance-update/sheet"; +import { useTRPC } from "@/lib/trpc/client"; + +export default function Page() { + const { maintenanceId } = useParams<{ + id: string; + maintenanceId: string; + }>(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const input = { id: Number.parseInt(maintenanceId) }; + const { data: maintenance, refetch } = useQuery( + trpc.maintenance.get.queryOptions(input), + ); + + const invalidateMaintenanceQueries = () => { + refetch(); + queryClient.invalidateQueries({ + queryKey: trpc.maintenance.list.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: trpc.page.list.queryKey(), + }); + }; + + const notifyMutation = useMutation( + trpc.subscriberNotification.maintenance.mutationOptions(), + ); + const createMutation = useMutation( + trpc.maintenance.createUpdate.mutationOptions({ + onSuccess: (update) => { + if (update?.notifySubscribers) { + notifyMutation.mutate({ id: update.id }); + } + invalidateMaintenanceQueries(); + }, + }), + ); + const updateMutation = useMutation( + trpc.maintenance.updateUpdate.mutationOptions({ + onSuccess: invalidateMaintenanceQueries, + }), + ); + const deleteMutation = useMutation( + trpc.maintenance.deleteUpdate.mutationOptions({ + onSuccess: invalidateMaintenanceQueries, + }), + ); + + if (!maintenance) return null; + + const updates = [...maintenance.updates].sort( + (a, b) => b.date.getTime() - a.date.getTime() || b.id - a.id, + ); + const affected = maintenance.pageComponents + .map((component) => component.name) + .join(", "); + + return ( + +
+ + + {maintenance.title} + + {format(maintenance.from, "PPP 'at' p")} –{" "} + {format(maintenance.to, "PPP 'at' p")}. Affects{" "} + {affected || "zero"}{" "} + component(s). + + + + + + Maintenance Updates + { + await createMutation.mutateAsync({ + maintenanceId: maintenance.id, + message: values.message, + date: values.date, + notifySubscribers: values.notifySubscribers, + }); + }} + > + + + + + + {updates.map((update, index) => ( + + { + await updateMutation.mutateAsync({ + id: update.id, + message: values.message, + date: values.date, + }); + }} + onDelete={async () => { + await deleteMutation.mutateAsync({ id: update.id }); + }} + /> + + ))} + +
+
+ ); +} diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx index 65930a078..84a2e136f 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/page.tsx @@ -2,8 +2,8 @@ import { Add } from "@openstatus/icons"; import { Button } from "@openstatus/ui/components/ui/button"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { useParams } from "next/navigation"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useParams, useRouter } from "next/navigation"; import { Link } from "@/components/common/link"; import { @@ -22,7 +22,9 @@ import { useTRPC } from "@/lib/trpc/client"; export default function Page() { const { id } = useParams<{ id: string }>(); + const router = useRouter(); const trpc = useTRPC(); + const queryClient = useQueryClient(); const { data: statusPage } = useQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); @@ -38,9 +40,12 @@ export default function Page() { trpc.maintenance.new.mutationOptions({ onSuccess: (maintenance) => { refetch(); + queryClient.invalidateQueries({ + queryKey: trpc.page.list.queryKey(), + }); if (maintenance.notifySubscribers) { sendMaintenanceUpdateMutation.mutate({ - id: maintenance.id, + id: maintenance.initialUpdateId, }); } }, @@ -88,7 +93,13 @@ export default function Page() { - + + router.push(`/status-pages/${id}/maintenances/${row.original.id}`) + } + /> ); diff --git a/apps/dashboard/src/components/chat/tool-renderers/index.tsx b/apps/dashboard/src/components/chat/tool-renderers/index.tsx index 5c584622b..0cfa97140 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/index.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/index.tsx @@ -28,6 +28,11 @@ import { listPrivateLocationsTable } from "./list-private-locations"; import { listResponseLogsTable } from "./list-response-logs"; import { listStatusPagesTable } from "./list-status-pages"; import { listStatusReportsTable } from "./list-status-reports"; +import { + addMaintenanceUpdateChanges, + deleteMaintenanceUpdateChanges, + updateMaintenanceUpdateChanges, +} from "./maintenance-update"; import { resolveStatusReportChanges } from "./resolve-status-report"; import { ResultTable } from "./result-table"; import { searchDocsTable } from "./search-docs"; @@ -140,6 +145,32 @@ export const toolRenderers: ToolRendererRegistry = { ), summary: (o) => `ID ${o.id}`, }, + add_maintenance_update: { + renderDraft: (input) => addMaintenanceUpdateChanges(input), + renderResult: ({ input, output }) => ( + + ), + summary: (o) => `update #${o.id}`, + }, + update_maintenance_update: { + renderDraft: (input) => updateMaintenanceUpdateChanges(input), + renderResult: ({ input }) => ( + + ), + summary: (o) => `update #${o.id}`, + }, + delete_maintenance_update: { + renderDraft: (input) => deleteMaintenanceUpdateChanges(input), + renderResult: ({ input }) => ( + + ), + summary: (o) => `deleted #${o.id}`, + }, list_monitors: { renderResult: ({ output }) => ( diff --git a/apps/dashboard/src/components/chat/tool-renderers/maintenance-update.tsx b/apps/dashboard/src/components/chat/tool-renderers/maintenance-update.tsx new file mode 100644 index 000000000..76edf647b --- /dev/null +++ b/apps/dashboard/src/components/chat/tool-renderers/maintenance-update.tsx @@ -0,0 +1,32 @@ +import type { AgentToolInput } from "@openstatus/services/agent-tools"; + +import type { ChangeRow } from "@/components/common/changes-table"; + +export function addMaintenanceUpdateChanges( + input: AgentToolInput<"add_maintenance_update">, + applied?: { id: number; notified: boolean }, +): ChangeRow[] { + return [ + ...(applied ? [{ field: "id", after: applied.id }] : []), + { field: "maintenanceId", after: input.maintenanceId }, + { field: "message", after: input.message }, + { field: "date", after: input.date }, + { field: "notify", after: applied?.notified ?? input.notify }, + ]; +} + +export function updateMaintenanceUpdateChanges( + input: AgentToolInput<"update_maintenance_update">, +): ChangeRow[] { + return [ + { field: "id", after: input.id }, + ...(input.message ? [{ field: "message", after: input.message }] : []), + ...(input.date ? [{ field: "date", after: input.date }] : []), + ]; +} + +export function deleteMaintenanceUpdateChanges( + input: AgentToolInput<"delete_maintenance_update">, +): ChangeRow[] { + return [{ field: "id", before: input.id, after: undefined }]; +} diff --git a/apps/dashboard/src/components/data-table/maintenances/data-table-row-actions.tsx b/apps/dashboard/src/components/data-table/maintenances/data-table-row-actions.tsx index 051c54bf2..c6ebdffaa 100644 --- a/apps/dashboard/src/components/data-table/maintenances/data-table-row-actions.tsx +++ b/apps/dashboard/src/components/data-table/maintenances/data-table-row-actions.tsx @@ -50,7 +50,7 @@ export function MaintenanceRowActions({ ); return ( - <> +
event.stopPropagation()}> - +
); } diff --git a/apps/dashboard/src/components/forms/maintenance-update/card.tsx b/apps/dashboard/src/components/forms/maintenance-update/card.tsx new file mode 100644 index 000000000..93f0edeeb --- /dev/null +++ b/apps/dashboard/src/components/forms/maintenance-update/card.tsx @@ -0,0 +1,61 @@ +"use client"; + +import type { RouterOutputs } from "@openstatus/api"; +import { Button } from "@openstatus/ui/components/ui/button"; + +import { FormAlertDialog } from "@/components/forms/form-alert-dialog"; +import { + FormCard, + FormCardFooter, + FormCardHeader, + FormCardTitle, +} from "@/components/forms/form-card"; +import { + FormMaintenanceUpdate, + type FormValues, +} from "@/components/forms/maintenance-update/form"; + +type MaintenanceUpdate = RouterOutputs["maintenance"]["get"]["updates"][number]; + +export function FormMaintenanceUpdateCard({ + index, + total, + update, + onSubmit, + onDelete, +}: { + index: number; + total: number; + update: MaintenanceUpdate; + onSubmit: (values: FormValues) => Promise; + onDelete: () => Promise; +}) { + const formId = `maintenance-update-${update.id}`; + const title = `Maintenance Update #${total - index}`; + + return ( + + + {title} + + + + + + + + + + ); +} diff --git a/apps/dashboard/src/components/forms/maintenance-update/form.tsx b/apps/dashboard/src/components/forms/maintenance-update/form.tsx new file mode 100644 index 000000000..482e3cd48 --- /dev/null +++ b/apps/dashboard/src/components/forms/maintenance-update/form.tsx @@ -0,0 +1,267 @@ +"use client"; + +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 { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} 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 { 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 { ProcessMessage } from "@/components/content/process-message"; +import { + FormCardContent, + FormCardSeparator, +} from "@/components/forms/form-card"; +import { useFormSheetDirty } from "@/components/forms/form-sheet"; +import { useTRPC } from "@/lib/trpc/client"; + +export type FormValues = { + message: string; + date: Date; + notifySubscribers?: boolean; +}; + +export function FormMaintenanceUpdate({ + defaultValues, + onSubmit, + showNotifySubscribers = false, + className, + id, + ...props +}: Omit, "onSubmit"> & { + defaultValues?: Partial; + onSubmit: (values: FormValues) => Promise; + showNotifySubscribers?: boolean; +}) { + const trpc = useTRPC(); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); + const mobile = useIsMobile(); + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const form = useForm({ + defaultValues: { + message: defaultValues?.message ?? "", + date: defaultValues?.date ?? new Date(), + notifySubscribers: + defaultValues?.notifySubscribers ?? + !!workspace?.limits["status-subscribers"], + }, + }); + const message = form.watch("message"); + const [isPending, startTransition] = useTransition(); + const { setIsDirty } = useFormSheetDirty(); + + React.useEffect(() => { + setIsDirty(form.formState.isDirty); + }, [form.formState.isDirty, setIsDirty]); + + function submitAction(values: FormValues) { + if (isPending) return; + startTransition(async () => { + const promise = onSubmit(values); + toast.promise(promise, { + loading: "Saving...", + success: "Saved", + error: (error) => + isTRPCClientError(error) ? error.message : "Failed to save", + }); + try { + await promise; + } catch (error) { + console.error(error); + } + }); + } + + const timeInputId = `${id ?? "maintenance-update"}-time`; + + return ( +
+ + + ( + + Date + + + + + + + + { + if (!date) return; + date.setHours( + field.value.getHours(), + field.value.getMinutes(), + field.value.getSeconds(), + field.value.getMilliseconds(), + ); + field.onChange(date); + }} + disabled={(date) => + date > new Date() || date < new Date("1900-01-01") + } + initialFocus + /> +
+
+ +
+ { + const [hours, minutes, seconds] = + event.target.value.split(":").map(Number); + if (hours === undefined || minutes === undefined) + return; + const date = new Date(field.value); + date.setHours(hours, minutes, seconds ?? 0, 0); + field.onChange(date); + }} + /> +
+
+
+
+
+
+
+ + Shown in your timezone ( + + {timezone} + + ) and saved as UTC. + + +
+ )} + /> +
+ + + + + Writing + Preview + + + ( + + Message + +