feat: status page authoring UI
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user