From 1452928b75a5266c8b096c00ba3ee142179aab61 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 25 Aug 2026 08:32:43 +0000 Subject: [PATCH] feat: status page authoring UI --- web/app/(app)/status-pages/[pageId]/page.tsx | 765 +++++++++++++++++++ web/app/(app)/status-pages/page.tsx | 253 ++++++ web/components/Sidebar.tsx | 10 + web/lib/api.ts | 124 +++ 4 files changed, 1152 insertions(+) create mode 100644 web/app/(app)/status-pages/[pageId]/page.tsx create mode 100644 web/app/(app)/status-pages/page.tsx diff --git a/web/app/(app)/status-pages/[pageId]/page.tsx b/web/app/(app)/status-pages/[pageId]/page.tsx new file mode 100644 index 0000000..1f97260 --- /dev/null +++ b/web/app/(app)/status-pages/[pageId]/page.tsx @@ -0,0 +1,765 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; +import Link from "next/link"; +import { + api, + Monitor, + StatusIncident, + StatusPage, + StatusPageSection, +} from "@/lib/api"; +import { AsyncBoundary, Badge, Button, Card, CenteredSpinner, Modal, useToast } from "@/components/ui"; +import { relativeTime } from "@/components/monitors/MonitorVisuals"; + +/* + * Details and Components are edited as one local draft and saved together by + * the single "Save changes" button in the header, matching artboard 2 of the + * mockup. Incidents are their own timeline and mutate immediately — opening + * one, posting an update or editing a maintenance window has no "unsaved" + * state to lose, so there is nothing to batch. + */ + +function publicUrl(pageId: string): string { + const origin = typeof window !== "undefined" ? window.location.origin : ""; + return `${origin}/status/${pageId}`; +} + +interface Draft { + title: string; + description: string; + logoUrl: string; + published: boolean; + banner: { enabled: boolean; level: string; text: string }; + sections: StatusPageSection[]; +} + +function draftFromPage(page: StatusPage): Draft { + return { + title: page.title, + description: page.description ?? "", + logoUrl: page.logo_url ?? "", + published: page.published, + banner: { enabled: page.banner?.enabled ?? false, level: page.banner?.level ?? "", text: page.banner?.text ?? "" }, + // Deep copy so section/entry edits never mutate the query cache directly. + sections: page.sections.map((s) => ({ name: s.name, entries: s.entries.map((e) => ({ ...e })) })), + }; +} + +function draftToInput(draft: Draft): Partial { + return { + title: draft.title.trim(), + description: draft.description.trim() || undefined, + logo_url: draft.logoUrl.trim() || undefined, + published: draft.published, + banner: { + enabled: draft.banner.enabled, + level: draft.banner.text.trim() ? draft.banner.level || "info" : undefined, + text: draft.banner.text.trim() || undefined, + }, + sections: draft.sections + .filter((s) => s.name.trim().length > 0) + .map((s) => ({ name: s.name.trim(), entries: s.entries })), + }; +} + +const inputClass = + "w-full rounded border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"; + +function Switch({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label: string }) { + return ( + + ); +} + +function monitorMeta(m: Monitor): string { + return `${m.type} · every ${m.interval_sec}s`; +} + +function DetailsPanel({ draft, setDraft, pageId }: { draft: Draft; setDraft: (d: Draft) => void; pageId: string }) { + return ( + +
+
+

Details

+

What visitors see at the top of the page.

+
+ {draft.published ? "published" : "draft"} +
+ +
+
+

Published

+

+ Anyone with the link can read this page. Unpublished pages return not found, so you can compose before announcing. +

+
+ setDraft({ ...draft, published: v })} label="Published" /> +
+ +
+
+ + setDraft({ ...draft, title: e.target.value })} className={inputClass} /> +
+
+ + +

Fixed once created — the link is already out there.

+
+
+ + setDraft({ ...draft, description: e.target.value })} + className={inputClass} + /> +
+
+ + setDraft({ ...draft, logoUrl: e.target.value })} + placeholder="https://acme.example.com/logo.svg" + className={`${inputClass} font-mono`} + /> +
+
+ +
+ + setDraft({ ...draft, banner: { ...draft.banner, text: e.target.value, enabled: e.target.value.trim().length > 0 } })} + placeholder="Europe region only. US and APAC are unaffected." + className={inputClass} + /> +

Shown above everything else. Clear it to remove the notice.

+
+
+ ); +} + +function SectionEditor({ + section, + monitors, + onChange, + onRemove, +}: { + section: StatusPageSection; + monitors: Monitor[]; + onChange: (s: StatusPageSection) => void; + onRemove: () => void; +}) { + const usedIds = new Set(section.entries.map((e) => e.monitor_id)); + const available = monitors.filter((m) => !usedIds.has(m.monitor_id)); + + return ( +
+
+ onChange({ ...section, name: e.target.value })} + aria-label="Section name" + placeholder="Section name" + className={`${inputClass} max-w-[220px] bg-surface`} + /> + +
+ + {section.entries.map((entry, i) => { + const monitor = monitors.find((m) => m.monitor_id === entry.monitor_id); + return ( +
+
+

{monitor?.name ?? entry.monitor_id}

+ {monitor &&

{monitorMeta(monitor)}

} +
+ { + const entries = [...section.entries]; + entries[i] = { ...entry, display_name: e.target.value }; + onChange({ ...section, entries }); + }} + placeholder={monitor?.name ?? entry.monitor_id} + aria-label={`Public name for ${monitor?.name ?? entry.monitor_id}`} + className={inputClass} + /> + +
+ ); + })} + +
+ +
+
+ ); +} + +function ComponentsPanel({ + draft, + setDraft, + monitors, +}: { + draft: Draft; + setDraft: (d: Draft) => void; + monitors: Monitor[]; +}) { + return ( + +
+
+

Components

+

+ Monitors grouped for the public page. Grouping here is separate from the groups on Monitors. +

+
+ +
+ + {draft.sections.length === 0 ? ( +

+ No sections yet. Add one and choose the monitors it should show. +

+ ) : ( +
+ {draft.sections.map((section, i) => ( + { + const sections = [...draft.sections]; + sections[i] = s; + setDraft({ ...draft, sections }); + }} + onRemove={() => setDraft({ ...draft, sections: draft.sections.filter((_, j) => j !== i) })} + /> + ))} +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Incidents +// --------------------------------------------------------------------------- + +const INCIDENT_STATUSES = ["investigating", "identified", "monitoring", "resolved"] as const; +const MAINTENANCE_STATUSES = ["scheduled", "in_progress", "completed"] as const; +const IMPACTS = ["none", "minor", "major", "critical"] as const; + +function statusVariant(status: string): "success" | "warning" | "danger" | "neutral" | "accent" { + if (status === "resolved" || status === "completed") return "success"; + if (status === "monitoring" || status === "in_progress") return "warning"; + if (status === "scheduled") return "accent"; + if (status === "investigating" || status === "identified") return "danger"; + return "neutral"; +} + +function isOpenIncident(inc: StatusIncident): boolean { + return inc.kind === "incident" && inc.status !== "resolved"; +} + +function toLocalInput(iso?: string): string { + if (!iso) return ""; + const d = new Date(iso); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function fromLocalInput(s: string): string | undefined { + if (!s) return undefined; + const d = new Date(s); + return Number.isNaN(d.getTime()) ? undefined : d.toISOString(); +} + +function IncidentFormModal({ + pageId, + kind, + monitors, + initial, + onClose, +}: { + pageId: string; + kind: "incident" | "maintenance"; + monitors: Monitor[]; + initial?: StatusIncident; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const toast = useToast(); + const statuses = kind === "incident" ? INCIDENT_STATUSES : MAINTENANCE_STATUSES; + + const [title, setTitle] = useState(initial?.title ?? ""); + const [impact, setImpact] = useState(initial?.impact ?? "minor"); + const [status, setStatus] = useState(initial?.status ?? statuses[0]); + const [affected, setAffected] = useState(initial?.affected_monitors ?? []); + const [scheduledStart, setScheduledStart] = useState(toLocalInput(initial?.scheduled_start)); + const [scheduledEnd, setScheduledEnd] = useState(toLocalInput(initial?.scheduled_end)); + + const { + mutate: save, + isPending, + error, + } = useMutation({ + mutationFn: () => { + const body = { + kind, + title: title.trim(), + impact, + status, + affected_monitors: affected, + scheduled_start: kind === "maintenance" ? fromLocalInput(scheduledStart) : undefined, + scheduled_end: kind === "maintenance" ? fromLocalInput(scheduledEnd) : undefined, + // UpdateStatusIncident requires page_ids on the body -- it is not + // merged with the existing document, so omitting it here fails + // validateIncident's "at least one page is required" on every edit. + ...(initial ? { page_ids: initial.page_ids } : {}), + }; + return initial ? api.updateStatusIncident(pageId, initial.incident_id, body) : api.createStatusIncident(pageId, body); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["status-pages", pageId, "incidents"] }); + toast.success(initial ? "Updated." : kind === "incident" ? "Incident opened." : "Maintenance scheduled."); + onClose(); + }, + }); + + function toggleMonitor(id: string) { + setAffected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); + } + + return ( + +
+ {error && ( +
+ {(error as Error).message} +
+ )} + +
+ + setTitle(e.target.value)} className={inputClass} /> +
+ +
+
+ + +
+
+ + +
+
+ + {kind === "maintenance" && ( +
+
+ + setScheduledStart(e.target.value)} + className={inputClass} + /> +
+
+ + setScheduledEnd(e.target.value)} className={inputClass} /> +
+
+ )} + +
+ +
+ {monitors.length === 0 &&

No monitors yet.

} + {monitors.map((m) => ( + + ))} +
+
+ +
+ + +
+
+
+ ); +} + +function PostUpdateModal({ pageId, incident, onClose }: { pageId: string; incident: StatusIncident; onClose: () => void }) { + const queryClient = useQueryClient(); + const toast = useToast(); + const [status, setStatus] = useState(incident.status === "investigating" ? "identified" : "monitoring"); + const [body, setBody] = useState(""); + + const { + mutate: post, + isPending, + error, + } = useMutation({ + mutationFn: () => api.postStatusIncidentUpdate(pageId, incident.incident_id, status, body.trim()), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["status-pages", pageId, "incidents"] }); + toast.success("Update posted."); + onClose(); + }, + }); + + return ( + +
+ {error && ( +
+ {(error as Error).message} +
+ )} +
+ + +
+
+ +