From c33687cbffb0cbb79b61cea33b648cee6ab87795 Mon Sep 17 00:00:00 2001 From: Bryan FRIMIN Date: Fri, 9 Oct 2026 10:49:29 +0200 Subject: [PATCH] feat: add maintenance updates for status page communications (#2561) * feat: add maintenance updates for status page communications Allow posting chronological updates on maintenances across dashboard, API, RPC/MCP, feeds, and subscriber notifications. * fix: complete maintenance update integrations * ci: apply automated fixes * refactor(maintenance): keep message as the announcement, updates as an independent timeline The branch mirrored the newest update into `maintenance.message`, which kept reads compatible but let any legacy writer (v1 PUT, RPC UpdateMaintenance, Terraform, the dashboard composer) overwrite the latest progress note. - `message` is the announcement again; `maintenance_update` rows are an optional 0..n timeline with `created_by`/`updated_by`. No sync helper, no initial update on create, no "at least one update" rule, no backfill. - Notifications split: `notifyMaintenance` (announcement, by maintenance id) and `notifyMaintenanceUpdate` (note, by update id); tRPC exposes both. Maintenance-update emails get their own idempotency key prefix. - Status JSON, feeds, Markdown and the UI block render the announcement followed by its notes. RPC/REST/CLI/Terraform shapes keep their meaning. - Migration 0092 regenerated; docs for the maintenance reference and MCP tools updated; tests rewritten for the new model. * fix(maintenance): reject non-decimal page component ids in RPC, surface notify failures - The maintenance RPC accepted any string that `Number()` turned into a safe integer, so "1e3" targeted component 1000 and "0x10" component 16. Use the same digits-only check as the status-report handler. - The dashboard fired subscriber notifications without an error handler after creating a maintenance or posting an update, so a failed dispatch was invisible. Toast the error on all three call sites. * feat(dashboard): maintenance updates as a timeline with an inline composer Replace the "Add update" button and update cards with the same layout the status-report detail uses: a composer at the top of the timeline (message, date, notify subscribers, "Publish update") and one timeline item per update with author, relative time, index and an actions menu for edit/delete. The header meta shows the update count and the last update time. Maintenance updates now carry `createdByUser`/`updatedByUser` so the timeline can attribute them. * refactor(maintenance): move the message into the update timeline A maintenance now works like a status report: creating one posts the message as the first `maintenance_update`, `message` on read is the newest update, `message` on update rewrites the newest one, and the last update cannot be deleted. The `maintenance.message` column stays as a create-only mirror until a follow-up drops it; migration 0092 backfills one update per existing row. - services: create returns `{ maintenance, initialUpdate }`, list/get derive `message`, `latestMaintenanceUpdate` helper - RPC v2 / v1 REST / agent tools keep their fields with the new meaning; v1 get/list/put derive and rewrite via `v1/maintenances/updates.ts` - ui blocks render updates oldest-first, `message` optional as fallback - status page feeds and markdown list updates, dispatcher uses the newest - importers date the first update at the window start - dashboard: single timeline composer, message only in the create sheet - docs, seed, OpenAPI v1 document, tests * chore(proto): regenerate maintenance comments * feat(subscriptions): thread maintenance updates in Slack like status reports Maintenance now has a timeline of updates, so each notified update posted a fresh root message in the subscriber's channel. Route maintenance through the same threaded delivery as status reports: the announcement opens the root, later updates backfill the first message, reply in thread and re-render the root. - Namespace anchor and delivery keys by event kind so a report and a maintenance sharing an id keep separate threads. Report keys are unchanged. - Send the first update's id on the announcement dispatch; it anchors the thread and gives the email idempotency key its per-update form. - Pin the root's "Scheduled" line to the maintenance start so a re-render does not pick up the update's timestamp. * fix(maintenance): address review findings on maintenance updates - Reject null dates: a bare z.coerce.date() turns JSON null into 1970-01-01. Service inputs reject null before coercing, REST update schemas take an ISO 8601 date-time, tRPC takes a Date. - Backfill updated_by from the legacy column before falling back to created_by so edits by another user keep their editor. - Maintenance emails carry the from - to window instead of a bare start. - Audit the first update written by the importer. - Dashboard: lock the composer while publishing and keep isPending through the refetch; nested links in a table row keep their own click; the update sheet forwards trigger props. - Drop the unused maintenance update load from getUptime. - Keep multi-line update messages inside their Markdown list item. - Docs: tool counts, add_maintenance_update in the notify contract, notified:false on plans without subscribers. - Tests: REST GET asserts the body; dispatcher asserts the window. * ci: apply automated fixes * fix(maintenance): anchor announcements on the first update, hash the window into the email key - dispatchMaintenance ordered updates descending, so a re-announcement after a later notified update reused that update's id (Slack deduped it away, email carried the wrong message) - the email idempotency fingerprint ignored the maintenance window the body renders, so a changed window on the same update hit Resend's 409 - drop the maintenance list row click and its stopPropagation wrapper; the title cell link already navigates * fix(maintenance): reject future-dated updates, type update id as integer in OpenAPI * fix(maintenance): address Copilot review on updates ordering and RPC ids - break date ties by update id in status page queries, the UI block, feeds, status JSON and markdown generators (dates have second precision) - reject non-decimal maintenance, update and page ids in the RPC handler - require a non-empty message on the v1 maintenance schema - regenerate the v1 OpenAPI spec and the RPC OpenAPI json/yaml module * refactor(maintenance): drop v1 update routes and notify by update id Maintenance updates are a ConnectRPC-only surface: remove the duplicate v1 /maintenance_update REST routes and regenerate the OpenAPI spec. Collapse notifyMaintenance/notifyMaintenanceUpdate into one notifyMaintenance keyed by update id, mirroring notifyStatusReport. The announcement is the first update, so maintenance.new returns initialUpdateId for the dashboard to notify. Drop the NotifyMaintenanceUpdate event and tRPC procedure. Remove dispatchMaintenance from the subscriptions dispatcher; the v1 create route dispatches its first update instead. Drop the redundant client-side sort on the maintenance detail page (the service already orders newest first) and mark the feed JSON `message` field deprecated in favour of maintenanceUpdates. * fix(status-page): show only the newest maintenance update in the banner * test(maintenance): verify parent touch per actor and malformed RPC page ids * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Maximilian Kaske --- .../[id]/maintenances/(list)/page.tsx | 18 +- .../maintenances/[maintenanceId]/client.tsx | 37 +- .../components/chat/tool-renderers/index.tsx | 31 + .../tool-renderers/maintenance-update.tsx | 32 + .../maintenances/data-table-row-actions.tsx | 2 - .../components/forms/form-alert-dialog.tsx | 23 +- .../forms/maintenance-update/form.tsx | 194 + .../forms/maintenance-update/sheet.tsx | 80 + .../src/components/forms/maintenance/form.tsx | 76 +- .../forms/maintenance/sheet-create.tsx | 14 +- .../maintenances/maintenance-composer.tsx | 79 - .../maintenance-update-composer.tsx | 133 + .../maintenance-update-timeline.tsx | 101 + .../use-publish-maintenance-update.ts | 54 + .../maintenances/use-update-maintenance.ts | 1 - .../components/ui/data-table/data-table.tsx | 6 +- .../dashboard/src/data/maintenances.client.ts | 1 - apps/server/src/routes/mcp/handler.test.ts | 3 + .../src/routes/mcp/tools/maintenance.ts | 6 + .../server/src/routes/mcp/tools/tools.test.ts | 3 + .../maintenance/__tests__/maintenance.test.ts | 191 +- .../rpc/handlers/maintenance/converters.ts | 26 + .../routes/rpc/handlers/maintenance/errors.ts | 12 + .../routes/rpc/handlers/maintenance/index.ts | 125 +- .../src/routes/rpc/interceptors/tracking.ts | 9 + apps/server/src/routes/slack/system-prompt.ts | 3 +- apps/server/src/routes/v1/maintenances/get.ts | 4 +- .../src/routes/v1/maintenances/get_all.ts | 4 +- .../src/routes/v1/maintenances/post.test.ts | 25 +- .../server/src/routes/v1/maintenances/post.ts | 22 +- apps/server/src/routes/v1/maintenances/put.ts | 12 +- .../src/routes/v1/maintenances/schema.ts | 5 +- .../src/routes/v1/maintenances/updates.ts | 54 + apps/server/static/openapi-v1.json | 17 +- apps/server/static/openapi-yaml.ts | 2 +- apps/server/static/openapi.json | 293 +- apps/server/static/openapi.yaml | 225 +- .../[domain]/[locale]/(public)/client.tsx | 15 +- .../[locale]/(public)/feed/[type]/route.ts | 10 +- .../[locale]/(public)/feed/json/route.ts | 44 +- .../src/content/markdown/generators.test.ts | 23 + .../src/content/markdown/generators.ts | 48 +- .../src/content/status-json.test.ts | 12 + apps/status-page/src/content/status-json.ts | 6 + .../pages/docs/reference/maintenance.mdx | 12 +- .../pages/docs/reference/mcp-server.mdx | 10 +- deno.lock | 258 - packages/analytics/src/events.ts | 12 + packages/api/src/router/maintenance.test.ts | 68 +- packages/api/src/router/maintenance.ts | 79 +- packages/api/src/router/statusPage.ts | 18 + .../api/src/router/subscriber-notification.ts | 2 +- packages/db/drizzle/0092_yielding_banshee.sql | 20 + packages/db/drizzle/meta/0092_snapshot.json | 6892 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../db/src/schema/audit_logs/validation.ts | 13 + .../db/src/schema/maintenances/maintenance.ts | 46 + .../db/src/schema/maintenances/validation.ts | 16 +- packages/db/src/schema/shared.ts | 8 +- packages/db/src/seed.mts | 29 + .../maintenance/v1/maintenance.proto | 29 +- .../openstatus/maintenance/v1/service.proto | 65 +- packages/proto/gen/openapi.yaml | 225 +- .../maintenance/v1/maintenance_pb.ts | 73 +- .../openstatus/maintenance/v1/service_pb.ts | 200 +- .../__tests__/maintenance-updates.test.ts | 52 + packages/services/src/agent-tools/index.ts | 19 +- .../services/src/agent-tools/maintenance.ts | 207 +- packages/services/src/agent-tools/prompt.ts | 5 +- packages/services/src/import/phase-writers.ts | 26 + .../maintenance/__tests__/attribution.test.ts | 3 +- .../maintenance/__tests__/maintenance.test.ts | 391 +- .../services/src/maintenance/add-update.ts | 67 + packages/services/src/maintenance/create.ts | 34 +- .../services/src/maintenance/delete-update.ts | 57 + .../services/src/maintenance/get-update.ts | 17 + packages/services/src/maintenance/index.ts | 12 +- packages/services/src/maintenance/internal.ts | 22 + packages/services/src/maintenance/list.ts | 57 +- packages/services/src/maintenance/notify.ts | 33 +- packages/services/src/maintenance/schemas.ts | 62 +- .../services/src/maintenance/update-update.ts | 65 + packages/services/src/maintenance/update.ts | 77 +- packages/services/src/maintenance/utils.ts | 18 + packages/services/src/page/get-content.ts | 23 +- packages/services/src/types.ts | 2 +- .../subscriptions/src/channels/email.test.ts | 24 +- packages/subscriptions/src/channels/email.ts | 24 +- .../src/channels/slack-blocks.test.ts | 15 + .../src/channels/slack-blocks.ts | 12 +- .../subscriptions/src/channels/slack-store.ts | 67 +- .../subscriptions/src/channels/slack.test.ts | 64 +- packages/subscriptions/src/channels/slack.ts | 57 +- packages/subscriptions/src/dispatcher.test.ts | 69 +- packages/subscriptions/src/dispatcher.ts | 48 +- packages/subscriptions/src/types.ts | 2 +- .../src/components/blocks/status-events.tsx | 100 +- .../ui/src/components/blocks/status-feed.tsx | 1 + .../ui/src/components/blocks/status.types.ts | 8 +- 99 files changed, 11178 insertions(+), 725 deletions(-) create mode 100644 apps/dashboard/src/components/chat/tool-renderers/maintenance-update.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 delete mode 100644 apps/dashboard/src/components/maintenances/maintenance-composer.tsx create mode 100644 apps/dashboard/src/components/maintenances/maintenance-update-composer.tsx create mode 100644 apps/dashboard/src/components/maintenances/maintenance-update-timeline.tsx create mode 100644 apps/dashboard/src/components/maintenances/use-publish-maintenance-update.ts create mode 100644 apps/server/src/routes/v1/maintenances/updates.ts create mode 100644 packages/db/drizzle/0092_yielding_banshee.sql create mode 100644 packages/db/drizzle/meta/0092_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 create mode 100644 packages/services/src/maintenance/utils.ts diff --git a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx index 88dd8ab03..f9983a7f2 100644 --- a/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/status-pages/[id]/maintenances/(list)/page.tsx @@ -1,8 +1,9 @@ "use client"; import { Button } from "@openstatus/ui/components/ui/button"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useParams } from "next/navigation"; +import { toast } from "sonner"; import { Link } from "@/components/common/link"; import { @@ -18,10 +19,12 @@ import { FormSheetMaintenance } from "@/components/forms/maintenance/sheet"; import { toCheckboxTreeItems } from "@/components/ui/checkbox-tree"; import { DataTable } from "@/components/ui/data-table/data-table"; import { useTRPC } from "@/lib/trpc/client"; +import { errorMessage } from "@/lib/trpc/error"; export default function Page() { const { id } = useParams<{ id: string }>(); const trpc = useTRPC(); + const queryClient = useQueryClient(); const { data: statusPage } = useQuery( trpc.page.get.queryOptions({ id: Number.parseInt(id) }), ); @@ -31,15 +34,22 @@ export default function Page() { }), ); const sendMaintenanceUpdateMutation = useMutation( - trpc.subscriberNotification.maintenance.mutationOptions(), + trpc.subscriberNotification.maintenance.mutationOptions({ + onError: (error) => { + toast.error(errorMessage(error, "Failed to notify subscribers")); + }, + }), ); const createMaintenanceMutation = useMutation( 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, }); } }, @@ -72,7 +82,7 @@ export default function Page() { await createMaintenanceMutation.mutateAsync({ pageId: Number.parseInt(id), title: values.title, - message: values.message, + message: values.message ?? "", startDate: values.startDate, endDate: values.endDate, pageComponents: values.pageComponents, 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 index 126e75141..7a2e3243e 100644 --- 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 @@ -19,10 +19,12 @@ import { DetailTitleRow, } from "@/components/content/detail"; import { SectionGroup } from "@/components/content/section"; +import { Timeline } from "@/components/content/timeline"; 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 { MaintenanceUpdateComposer } from "@/components/maintenances/maintenance-update-composer"; +import { MaintenanceUpdateTimelineItem } from "@/components/maintenances/maintenance-update-timeline"; import { useMaintenanceStatus } from "@/components/maintenances/use-maintenance-status"; import { useUpdateMaintenance } from "@/components/maintenances/use-update-maintenance"; import { Notifications } from "@/components/status-pages/notifications"; @@ -35,6 +37,7 @@ export function Client({ id, pageId }: { id: number; pageId: number }) { trpc.maintenance.get.queryOptions({ id }), ); const { data: page } = useQuery(trpc.page.get.queryOptions({ id: pageId })); + const { data: workspace } = useQuery(trpc.workspace.get.queryOptions()); const rename = useUpdateMaintenance(id); const status = useMaintenanceStatus( maintenance ?? { from: new Date(0), to: new Date(0) }, @@ -43,6 +46,10 @@ export function Client({ id, pageId }: { id: number; pageId: number }) { if (!maintenance || !page) return null; const publicUrl = `${getPageUrl(page)}/events/maintenance/${maintenance.id}`; + const canNotify = workspace?.limits["status-subscribers"] === true; + // service returns updates newest first + const updates = maintenance.updates; + const latest = updates[0]; return ( @@ -76,14 +83,34 @@ export function Client({ id, pageId }: { id: number; pageId: number }) { {formatDistanceStrict(maintenance.from, maintenance.to)} + {updates.length ? ( + + {updates.length} {updates.length === 1 ? "update" : "updates"} + + ) : null} + {latest ? ( + + Last update + + ) : null} - - Message - - + + + {updates.map((update, i) => ( + + ))} + diff --git a/apps/dashboard/src/components/chat/tool-renderers/index.tsx b/apps/dashboard/src/components/chat/tool-renderers/index.tsx index 193f3872b..9989b2da5 100644 --- a/apps/dashboard/src/components/chat/tool-renderers/index.tsx +++ b/apps/dashboard/src/components/chat/tool-renderers/index.tsx @@ -35,6 +35,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 { searchContentTable } from "./search-content"; @@ -148,6 +153,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_incidents: { 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..c31fdbff0 --- /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 }, + ...(input.date ? [{ 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 }]; +} 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..1637b344f 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 @@ -69,7 +69,6 @@ export function MaintenanceRowActions({ )} defaultValues={{ title: maintenance.title, - message: maintenance.message, startDate: maintenance.from, endDate: maintenance.to, pageComponents: maintenance.pageComponents?.map((c) => c.id) ?? [], @@ -78,7 +77,6 @@ export function MaintenanceRowActions({ await updateMaintenanceMutation.mutateAsync({ id: maintenance.id, title: values.title, - message: values.message, startDate: values.startDate, endDate: values.endDate, pageComponents: values.pageComponents, diff --git a/apps/dashboard/src/components/forms/form-alert-dialog.tsx b/apps/dashboard/src/components/forms/form-alert-dialog.tsx index 1138b47b1..8d92a5b0e 100644 --- a/apps/dashboard/src/components/forms/form-alert-dialog.tsx +++ b/apps/dashboard/src/components/forms/form-alert-dialog.tsx @@ -35,9 +35,14 @@ export function FormAlertDialog({ const { copy, isCopied } = useCopyToClipboard(); const [open, setOpen] = useState(false); - const handleDelete = async () => { - try { - startTransition(async () => { + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (!next) setValue(""); + }; + + const handleDelete = () => { + startTransition(async () => { + try { const promise = submitAction(); toast.promise(promise, { loading: "Deleting...", @@ -50,15 +55,15 @@ export function FormAlertDialog({ }, }); await promise; - setOpen(false); - }); - } catch (error) { - console.error("Failed to revoke:", error); - } + handleOpenChange(false); + } catch (error) { + console.error("Failed to delete:", error); + } + }); }; return ( - + {children ?? (