feat: status page authoring UI

This commit is contained in:
2026-08-25 08:32:43 +00:00
parent bf10023f35
commit 1452928b75
4 changed files with 1152 additions and 0 deletions
@@ -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<StatusPage> {
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 (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className={`relative h-[21px] w-[38px] flex-shrink-0 rounded-full transition-colors ${checked ? "bg-success" : "bg-border"}`}
>
<span
className={`absolute top-[2px] h-[17px] w-[17px] rounded-full transition-transform ${
checked ? "translate-x-[19px] bg-accent-ink" : "translate-x-[2px] bg-text-tertiary"
}`}
/>
</button>
);
}
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 (
<Card>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-bold tracking-[-0.02em] text-text-primary">Details</h2>
<p className="mt-0.5 text-sm text-text-secondary">What visitors see at the top of the page.</p>
</div>
<Badge variant={draft.published ? "success" : "neutral"}>{draft.published ? "published" : "draft"}</Badge>
</div>
<div className="mb-5 flex items-center justify-between gap-4 rounded border border-border bg-surface-2 px-3.5 py-3">
<div>
<p className="text-sm font-semibold text-text-primary">Published</p>
<p className="mt-0.5 max-w-[52ch] text-xs text-text-tertiary">
Anyone with the link can read this page. Unpublished pages return not found, so you can compose before announcing.
</p>
</div>
<Switch checked={draft.published} onChange={(v) => setDraft({ ...draft, published: v })} label="Published" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Title</label>
<input type="text" value={draft.title} onChange={(e) => setDraft({ ...draft, title: e.target.value })} className={inputClass} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Page address</label>
<input type="text" value={pageId} disabled className={`${inputClass} font-mono opacity-60`} />
<p className="mt-1 text-xs text-text-tertiary">Fixed once created the link is already out there.</p>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Description</label>
<input
type="text"
value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
className={inputClass}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Logo URL</label>
<input
type="text"
value={draft.logoUrl}
onChange={(e) => setDraft({ ...draft, logoUrl: e.target.value })}
placeholder="https://acme.example.com/logo.svg"
className={`${inputClass} font-mono`}
/>
</div>
</div>
<div className="mt-4">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Notice</label>
<input
type="text"
value={draft.banner.text}
onChange={(e) => 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}
/>
<p className="mt-1 text-xs text-text-tertiary">Shown above everything else. Clear it to remove the notice.</p>
</div>
</Card>
);
}
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 (
<div className="rounded border border-border bg-surface-2">
<div className="flex items-center gap-2.5 border-b border-border px-3 py-2.5">
<input
type="text"
value={section.name}
onChange={(e) => onChange({ ...section, name: e.target.value })}
aria-label="Section name"
placeholder="Section name"
className={`${inputClass} max-w-[220px] bg-surface`}
/>
<button type="button" onClick={onRemove} className="ml-auto font-mono text-xs text-text-tertiary hover:text-danger">
remove section
</button>
</div>
{section.entries.map((entry, i) => {
const monitor = monitors.find((m) => m.monitor_id === entry.monitor_id);
return (
<div
key={entry.monitor_id}
className="grid grid-cols-1 items-center gap-3 border-t border-border-soft px-3 py-2.5 sm:grid-cols-[1fr_1fr_auto]"
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-text-primary">{monitor?.name ?? entry.monitor_id}</p>
{monitor && <p className="mt-0.5 font-mono text-[11px] text-text-tertiary">{monitorMeta(monitor)}</p>}
</div>
<input
type="text"
value={entry.display_name ?? ""}
onChange={(e) => {
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}
/>
<button
type="button"
onClick={() => onChange({ ...section, entries: section.entries.filter((_, j) => j !== i) })}
className="justify-self-start font-mono text-xs text-text-tertiary hover:text-danger sm:justify-self-auto"
>
remove
</button>
</div>
);
})}
<div className="px-3 py-2.5">
<select
value=""
disabled={available.length === 0}
onChange={(e) => {
const monitorId = e.target.value;
if (!monitorId) return;
onChange({ ...section, entries: [...section.entries, { monitor_id: monitorId, display_name: "" }] });
}}
className={`${inputClass} max-w-xs`}
>
<option value="">{available.length === 0 ? "All monitors added" : "Add monitor…"}</option>
{available.map((m) => (
<option key={m.monitor_id} value={m.monitor_id}>
{m.name}
</option>
))}
</select>
</div>
</div>
);
}
function ComponentsPanel({
draft,
setDraft,
monitors,
}: {
draft: Draft;
setDraft: (d: Draft) => void;
monitors: Monitor[];
}) {
return (
<Card>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-bold tracking-[-0.02em] text-text-primary">Components</h2>
<p className="mt-0.5 text-sm text-text-secondary">
Monitors grouped for the public page. Grouping here is separate from the groups on Monitors.
</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setDraft({ ...draft, sections: [...draft.sections, { name: "", entries: [] }] })}
>
Add section
</Button>
</div>
{draft.sections.length === 0 ? (
<p className="rounded border border-dashed border-border px-4 py-6 text-center text-sm text-text-tertiary">
No sections yet. Add one and choose the monitors it should show.
</p>
) : (
<div className="space-y-4">
{draft.sections.map((section, i) => (
<SectionEditor
key={i}
section={section}
monitors={monitors}
onChange={(s) => {
const sections = [...draft.sections];
sections[i] = s;
setDraft({ ...draft, sections });
}}
onRemove={() => setDraft({ ...draft, sections: draft.sections.filter((_, j) => j !== i) })}
/>
))}
</div>
)}
</Card>
);
}
// ---------------------------------------------------------------------------
// 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<string[]>(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 (
<Modal
open
title={initial ? "Edit" : kind === "incident" ? "Open incident" : "Schedule maintenance"}
onClose={onClose}
>
<div className="space-y-4">
{error && (
<div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger" role="alert">
{(error as Error).message}
</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Title</label>
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} className={inputClass} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Impact</label>
<select value={impact} onChange={(e) => setImpact(e.target.value as (typeof IMPACTS)[number])} className={inputClass}>
{IMPACTS.map((i) => (
<option key={i} value={i}>
{i}
</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Status</label>
<select value={status} onChange={(e) => setStatus(e.target.value)} className={inputClass}>
{statuses.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
</div>
{kind === "maintenance" && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Starts</label>
<input
type="datetime-local"
value={scheduledStart}
onChange={(e) => setScheduledStart(e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Ends</label>
<input type="datetime-local" value={scheduledEnd} onChange={(e) => setScheduledEnd(e.target.value)} className={inputClass} />
</div>
</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Affected components</label>
<div className="max-h-40 space-y-1 overflow-auto rounded border border-border p-2">
{monitors.length === 0 && <p className="px-1 py-1 text-xs text-text-tertiary">No monitors yet.</p>}
{monitors.map((m) => (
<label key={m.monitor_id} className="flex items-center gap-2 rounded px-1 py-1 hover:bg-surface-2">
<input
type="checkbox"
checked={affected.includes(m.monitor_id)}
onChange={() => toggleMonitor(m.monitor_id)}
className="h-4 w-4 accent-accent"
/>
<span className="text-sm text-text-primary">{m.name}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button variant="secondary" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button variant="primary" loading={isPending} disabled={!title.trim()} onClick={() => save()}>
{initial ? "Save" : kind === "incident" ? "Open incident" : "Schedule maintenance"}
</Button>
</div>
</div>
</Modal>
);
}
function PostUpdateModal({ pageId, incident, onClose }: { pageId: string; incident: StatusIncident; onClose: () => void }) {
const queryClient = useQueryClient();
const toast = useToast();
const [status, setStatus] = useState<string>(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 (
<Modal open title="Post update" onClose={onClose}>
<div className="space-y-4">
{error && (
<div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger" role="alert">
{(error as Error).message}
</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Status</label>
<select value={status} onChange={(e) => setStatus(e.target.value)} className={inputClass}>
{INCIDENT_STATUSES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Update</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={4}
placeholder="What changed since the last update."
className={`${inputClass} resize-none`}
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button variant="secondary" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button variant="primary" loading={isPending} disabled={!body.trim()} onClick={() => post()}>
Post update
</Button>
</div>
</div>
</Modal>
);
}
function incidentMeta(inc: StatusIncident): string {
if (inc.kind === "maintenance" && inc.scheduled_start) {
const start = new Date(inc.scheduled_start);
const end = inc.scheduled_end ? new Date(inc.scheduled_end) : null;
const date = start.toLocaleDateString(undefined, { day: "numeric", month: "short" });
const startTime = start.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
const endTime = end ? end.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) : null;
return `${date}, ${startTime}${endTime ? `${endTime}` : ""} UTC`;
}
if (inc.status === "resolved" && inc.resolved_at) {
return `${relativeTime(inc.started_at)} · resolved ${relativeTime(inc.resolved_at)}`;
}
return `Opened ${relativeTime(inc.started_at)} · ${inc.updates?.length ?? 0} update${inc.updates?.length === 1 ? "" : "s"}`;
}
function IncidentsPanel({ pageId, monitors }: { pageId: string; monitors: Monitor[] }) {
const {
data: incidents,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ["status-pages", pageId, "incidents"],
queryFn: () => api.listStatusIncidents(pageId),
});
const [openForm, setOpenForm] = useState<"incident" | "maintenance" | null>(null);
const [editing, setEditing] = useState<StatusIncident | null>(null);
const [posting, setPosting] = useState<StatusIncident | null>(null);
const monitorName = useMemo(() => {
const m = new Map(monitors.map((mon) => [mon.monitor_id, mon.name]));
return (id: string) => m.get(id) ?? id;
}, [monitors]);
return (
<Card>
{(openForm || editing) && (
<IncidentFormModal
pageId={pageId}
kind={editing?.kind ?? openForm ?? "incident"}
monitors={monitors}
initial={editing ?? undefined}
onClose={() => {
setOpenForm(null);
setEditing(null);
}}
/>
)}
{posting && <PostUpdateModal pageId={pageId} incident={posting} onClose={() => setPosting(null)} />}
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-base font-bold tracking-[-0.02em] text-text-primary">Incidents</h2>
<p className="mt-0.5 text-sm text-text-secondary">Written by you. Outages Vantage detects appear on the page automatically.</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setOpenForm("maintenance")}>
Schedule maintenance
</Button>
<Button variant="primary" size="sm" onClick={() => setOpenForm("incident")}>
Open incident
</Button>
</div>
</div>
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<CenteredSpinner />}
isEmpty={!incidents || incidents.length === 0}
empty={<p className="py-8 text-center text-sm text-text-tertiary">No incidents or maintenance windows yet.</p>}
>
<div className="divide-y divide-border-soft">
{incidents?.map((inc) => (
<div key={inc.incident_id} className="flex items-start justify-between gap-4 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-text-primary">{inc.title}</p>
<p className="mt-0.5 text-xs text-text-tertiary">
{incidentMeta(inc)}
{inc.affected_monitors && inc.affected_monitors.length > 0 && (
<> · affects {inc.affected_monitors.map(monitorName).join(", ")}</>
)}
</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2.5">
<Badge variant={statusVariant(inc.status)}>{inc.status.replace("_", " ")}</Badge>
{isOpenIncident(inc) ? (
<Button variant="secondary" size="sm" onClick={() => setPosting(inc)}>
Post update
</Button>
) : (
<Button variant="secondary" size="sm" onClick={() => setEditing(inc)}>
Edit
</Button>
)}
</div>
</div>
))}
</div>
</AsyncBoundary>
</Card>
);
}
// ---------------------------------------------------------------------------
export default function StatusPageEditorPage() {
const params = useParams();
const pageId = params.pageId as string;
const queryClient = useQueryClient();
const toast = useToast();
const {
data: page,
isLoading,
error,
} = useQuery({
queryKey: ["status-pages", pageId],
queryFn: () => api.getStatusPage(pageId),
});
const { data: monitors } = useQuery({
queryKey: ["monitors"],
queryFn: () => api.listMonitors(),
});
const [draft, setDraft] = useState<Draft | null>(null);
// Seeded once the page loads. Refetches after that (e.g. after Save)
// must not stomp on in-progress edits, so this only runs while draft is
// still unset.
useEffect(() => {
if (page && !draft) setDraft(draftFromPage(page));
}, [page, draft]);
const {
mutate: save,
isPending: isSaving,
error: saveError,
} = useMutation({
mutationFn: () => {
if (!draft) throw new Error("nothing to save");
return api.updateStatusPage(pageId, draftToInput(draft));
},
onSuccess: (updated) => {
queryClient.invalidateQueries({ queryKey: ["status-pages"] });
queryClient.setQueryData(["status-pages", pageId], updated);
setDraft(draftFromPage(updated));
toast.success("Saved.");
},
});
if (isLoading || !draft) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<CenteredSpinner />
</div>
);
}
if (error || !page) {
return (
<div className="p-4 sm:p-6 lg:p-8">
<p className="text-sm text-danger">{(error as Error)?.message ?? "Status page not found."}</p>
</div>
);
}
return (
<div className="p-4 sm:p-6 lg:p-8">
<Link href="/status-pages" className="mb-2.5 inline-block text-sm text-text-secondary hover:text-text-primary">
All status pages
</Link>
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-text-primary">{page.title}</h1>
<PublicUrlLine pageId={pageId} />
</div>
<div className="flex gap-2">
<Button href={`/status/${pageId}`} variant="secondary" target="_blank" rel="noreferrer">
View page
</Button>
<Button variant="primary" loading={isSaving} onClick={() => save()}>
Save changes
</Button>
</div>
</div>
{saveError && (
<div className="mb-5 rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger" role="alert">
{(saveError as Error).message}
</div>
)}
<div className="space-y-5">
<DetailsPanel draft={draft} setDraft={setDraft} pageId={pageId} />
<ComponentsPanel draft={draft} setDraft={setDraft} monitors={monitors ?? []} />
<IncidentsPanel pageId={pageId} monitors={monitors ?? []} />
</div>
</div>
);
}
function PublicUrlLine({ pageId }: { pageId: string }) {
const [copied, setCopied] = useState(false);
const url = publicUrl(pageId);
async function copy() {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="mt-1.5 flex items-center gap-2">
<code className="rounded border border-border bg-well px-2 py-1 font-mono text-xs text-text-secondary">
{url.replace(/^https?:\/\//, "")}
</code>
<button type="button" onClick={copy} className="font-mono text-xs text-text-tertiary hover:text-accent">
{copied ? "copied" : "copy"}
</button>
</div>
);
}
+253
View File
@@ -0,0 +1,253 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { api, StatusPage } from "@/lib/api";
import {
AsyncBoundary,
Badge,
Button,
Card,
EmptyState,
Modal,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableSkeleton,
useToast,
} from "@/components/ui";
// Same rule as services.ValidatePageID on the server. Checked here purely so
// a typo is a red field rather than a round trip that comes back 400.
const PAGE_ID_RE = /^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/;
function componentCount(page: StatusPage): number {
return page.sections.reduce((sum, s) => sum + s.entries.length, 0);
}
function publicUrl(pageId: string): string {
const origin = typeof window !== "undefined" ? window.location.origin : "";
return `${origin}/status/${pageId}`;
}
function CopyLink({ pageId }: { pageId: string }) {
const [copied, setCopied] = useState(false);
const url = publicUrl(pageId);
async function copy() {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<button
type="button"
onClick={copy}
className="max-w-full truncate rounded border border-border bg-well px-2 py-1 font-mono text-[11px] text-text-tertiary transition-colors hover:text-accent"
title={url}
>
{copied ? "Copied!" : url.replace(/^https?:\/\//, "")}
</button>
);
}
function CreateStatusPageModal({ onClose }: { onClose: () => void }) {
const queryClient = useQueryClient();
const toast = useToast();
const router = useRouter();
const [pageId, setPageId] = useState("");
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [logoUrl, setLogoUrl] = useState("");
const [touched, setTouched] = useState(false);
const idValid = PAGE_ID_RE.test(pageId);
const {
mutate: create,
isPending,
error,
} = useMutation({
mutationFn: () =>
api.createStatusPage({
page_id: pageId,
title: title.trim(),
description: description.trim() || undefined,
logo_url: logoUrl.trim() || undefined,
}),
onSuccess: (page) => {
queryClient.invalidateQueries({ queryKey: ["status-pages"] });
toast.success(`Created ${page.title}. Add sections and publish when it is ready.`);
router.push(`/status-pages/${page.page_id}`);
},
});
return (
<Modal open title="New status page" onClose={onClose}>
<div className="space-y-4">
{error && (
<div className="rounded border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger" role="alert">
{(error as Error).message}
</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Page address</label>
<input
type="text"
value={pageId}
onChange={(e) => setPageId(e.target.value.toLowerCase())}
onBlur={() => setTouched(true)}
placeholder="api"
className="w-full rounded border border-border bg-surface-2 px-3 py-2 font-mono text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
<p className="mt-1 text-xs text-text-tertiary">
{`Becomes ${publicUrl(pageId || "<address>")}. Fixed once created lowercase letters, numbers and hyphens, 3-40 characters.`}
</p>
{touched && pageId.length > 0 && !idValid && <p className="mt-1 text-xs text-danger">Not a valid page address.</p>}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Acme Platform Status"
className="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"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Description</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Live availability for the Acme API and dashboard."
className="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"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">Logo URL</label>
<input
type="text"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
placeholder="https://acme.example.com/logo.svg"
className="w-full rounded border border-border bg-surface-2 px-3 py-2 font-mono text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button variant="secondary" onClick={onClose} disabled={isPending}>
Cancel
</Button>
<Button variant="primary" loading={isPending} disabled={!idValid || !title.trim()} onClick={() => create()}>
Create page
</Button>
</div>
</div>
</Modal>
);
}
export default function StatusPagesListPage() {
const [showCreate, setShowCreate] = useState(false);
const {
data: pages,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ["status-pages"],
queryFn: () => api.listStatusPages(),
});
return (
<div className="p-4 sm:p-6 lg:p-8">
{showCreate && <CreateStatusPageModal onClose={() => setShowCreate(false)} />}
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Status Pages</h1>
<p className="mt-1 text-sm text-text-secondary">
{pages?.length ?? 0} page{pages?.length !== 1 ? "s" : ""}
</p>
</div>
<Button variant="primary" onClick={() => setShowCreate(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
New status page
</Button>
</div>
<Card padding={false}>
<AsyncBoundary
isLoading={isLoading}
error={error}
onRetry={refetch}
skeleton={<TableSkeleton columns={4} />}
isEmpty={!pages || pages.length === 0}
empty={
<EmptyState
title="No status pages yet."
description="Create a page, group monitors into sections, and publish it to give customers and partners a place to check availability."
icon={
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<rect x="3" y="4.5" width="18" height="15" rx="2.25" />
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 15.75v-3M12 15.75v-6M17.25 15.75v-4.5" />
</svg>
}
action={{ label: "Create your first page", onClick: () => setShowCreate(true) }}
/>
}
>
<Table>
<Thead>
<Tr>
<Th>Title</Th>
<Th>Address</Th>
<Th>Components</Th>
<Th>Status</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{pages?.map((page) => (
<Tr key={page.page_id}>
<Td label="Title">
<span className="font-medium text-text-primary">{page.title}</span>
</Td>
<Td label="Address">
<CopyLink pageId={page.page_id} />
</Td>
<Td label="Components">
<span className="text-text-secondary">{componentCount(page)}</span>
</Td>
<Td label="Status">
<Badge variant={page.published ? "success" : "neutral"}>{page.published ? "Published" : "Draft"}</Badge>
</Td>
<Td>
<Button href={`/status-pages/${page.page_id}`} variant="ghost" size="sm">
Edit <span aria-hidden="true"></span>
<span className="sr-only">{page.title}</span>
</Button>
</Td>
</Tr>
))}
</Tbody>
</Table>
</AsyncBoundary>
</Card>
</div>
);
}
+10
View File
@@ -119,6 +119,15 @@ function MonitorIcon() {
);
}
function StatusIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<rect x="3" y="4.5" width="18" height="15" rx="2.25" strokeLinecap="round" strokeLinejoin="round" />
<path strokeLinecap="round" strokeLinejoin="round" d="M6.75 15.75v-3M12 15.75v-6M17.25 15.75v-4.5" />
</svg>
);
}
function StepsIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -198,6 +207,7 @@ const navGroups: NavGroup[] = [
{
label: "Instance",
items: [
{ href: "/status-pages", label: "Status Pages", icon: <StatusIcon />, adminOnly: true },
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings/license", label: "Licence", icon: <LicenceIcon />, adminOnly: true },
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, adminOnly: true },
+124
View File
@@ -111,6 +111,67 @@ export interface Rollup {
sum_latency: number;
}
// ---------------------------------------------------------------------------
// Status page authoring types. These mirror server/internal/models/statuspage.go
// field for field -- that Go file is the contract. The public shapes
// (PublicDay, PublicComponent, PublicSection, PublicIncident, StatusSnapshot)
// live further down this file, added by the public status page task; this
// block must not redeclare them.
// ---------------------------------------------------------------------------
export type StatusPageKind = "incident" | "maintenance";
export type StatusImpact = "none" | "minor" | "major" | "critical";
export interface StatusPageEntry {
monitor_id: string;
display_name?: string;
}
export interface StatusPageSection {
name: string;
entries: StatusPageEntry[];
}
export interface StatusPageBanner {
enabled: boolean;
level?: string;
text?: string;
}
export interface StatusPage {
page_id: string;
title: string;
description?: string;
logo_url?: string;
published: boolean;
banner: StatusPageBanner;
sections: StatusPageSection[];
created_at: string;
updated_at: string;
}
export interface StatusIncidentUpdate {
at: string;
status: string;
body: string;
author?: string;
}
export interface StatusIncident {
incident_id: string;
page_ids: string[];
kind: StatusPageKind;
title: string;
impact: StatusImpact;
affected_monitors?: string[];
status: string;
scheduled_start?: string;
scheduled_end?: string;
updates: StatusIncidentUpdate[];
started_at: string;
resolved_at?: string;
}
export type ChannelType = "webhook" | "smtp" | "discord" | "slack" | "telegram";
/**
@@ -682,6 +743,69 @@ export const api = {
return request<MonitorSample[]>(`/monitors/${monitorId}/samples?minutes=${minutes}`);
},
listStatusPages(): Promise<StatusPage[]> {
return request<StatusPage[]>("/status-pages");
},
getStatusPage(pageId: string): Promise<StatusPage> {
return request<StatusPage>(`/status-pages/${pageId}`);
},
createStatusPage(input: Partial<StatusPage>): Promise<StatusPage> {
return request<StatusPage>("/status-pages", { method: "POST", body: JSON.stringify(input) });
},
updateStatusPage(pageId: string, input: Partial<StatusPage>): Promise<StatusPage> {
return request<StatusPage>(`/status-pages/${pageId}`, {
method: "PUT",
body: JSON.stringify(input),
});
},
deleteStatusPage(pageId: string): Promise<void> {
return request<void>(`/status-pages/${pageId}`, { method: "DELETE" });
},
listStatusIncidents(pageId: string): Promise<StatusIncident[]> {
return request<StatusIncident[]>(`/status-pages/${pageId}/incidents`);
},
createStatusIncident(pageId: string, input: Partial<StatusIncident>): Promise<StatusIncident> {
return request<StatusIncident>(`/status-pages/${pageId}/incidents`, {
method: "POST",
body: JSON.stringify(input),
});
},
updateStatusIncident(
pageId: string,
incidentId: string,
input: Partial<StatusIncident>,
): Promise<StatusIncident> {
return request<StatusIncident>(`/status-pages/${pageId}/incidents/${incidentId}`, {
method: "PUT",
body: JSON.stringify(input),
});
},
deleteStatusIncident(pageId: string, incidentId: string): Promise<void> {
return request<void>(`/status-pages/${pageId}/incidents/${incidentId}`, {
method: "DELETE",
});
},
postStatusIncidentUpdate(
pageId: string,
incidentId: string,
status: string,
body: string,
): Promise<StatusIncident> {
return request<StatusIncident>(`/status-pages/${pageId}/incidents/${incidentId}/updates`, {
method: "POST",
body: JSON.stringify({ status, body }),
});
},
listChannels(): Promise<NotificationChannel[]> {
return request<NotificationChannel[]>("/channels");
},