mirror of
https://github.com/Dvorinka/Dash.git
synced 2026-10-28 07:41:27 +00:00
feat: ops layer — incidents, maintenance, status pages, badges, metrics (merge plan M4)
incidents + incident_updates + maintenance_windows + status_pages tables. Monitor transitions auto-open incidents on down and auto-resolve on recovery; an active maintenance window covering the monitor suppresses alerts and auto-incidents, and status pages report the covered monitor as `maintenance`. Public status view: GET /api/status-pages/:slug/public aggregates overall state, monitor uptime, systems, open incidents, and scheduled maintenance; rendered read-only at /status/:slug. GET /api/badge/:kind/:id.svg serves stateless shields; GET /api/metrics exposes Prometheus text over existing tables; POST /api/import/csv?kind=monitors|domains does header-driven bulk import; /api/export v2 folds monitors/domains/systems rows into the backup payload. Frontend: /incidents with timeline + status flow, /status admin for pages + windows, /status/:slug public page; nav and palette wired. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
74c987c18a
commit
c84a4be876
17 files changed
+2212
-11
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, Route, useLocation } from "wouter";
|
||||
import { Activity, Globe, LayoutGrid, Moon, Plus, Search, Server, Settings, Sun } from "lucide-react";
|
||||
import { Activity, AlertTriangle, Globe, LayoutGrid, Megaphone, Moon, Plus, Search, Server, Settings, Sun } from "lucide-react";
|
||||
import { BoardProvider, useBoard } from "@/board/store";
|
||||
import { Board } from "@/board/Board";
|
||||
import { ServiceDialog } from "@/components/ServiceDialog";
|
||||
@@ -13,6 +13,9 @@ import { DomainsPage } from "@/pages/DomainsPage";
|
||||
import { DomainDetailPage } from "@/pages/DomainDetailPage";
|
||||
import { SystemsPage } from "@/pages/SystemsPage";
|
||||
import { SystemDetailPage } from "@/pages/SystemDetailPage";
|
||||
import { IncidentsPage } from "@/pages/IncidentsPage";
|
||||
import { StatusPagesPage } from "@/pages/StatusPagesPage";
|
||||
import { StatusPublicPage } from "@/pages/StatusPublicPage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Item } from "@/types";
|
||||
@@ -30,6 +33,8 @@ const NAV = [
|
||||
{ href: "/monitors", label: "Monitors", icon: Activity },
|
||||
{ href: "/domains", label: "Domains", icon: Globe },
|
||||
{ href: "/systems", label: "Systems", icon: Server },
|
||||
{ href: "/incidents", label: "Incidents", icon: AlertTriangle },
|
||||
{ href: "/status", label: "Status", icon: Megaphone },
|
||||
];
|
||||
|
||||
function Shell() {
|
||||
@@ -150,6 +155,15 @@ function Shell() {
|
||||
<Route path="/systems/:id">
|
||||
{(p) => <SystemDetailPage id={p.id} />}
|
||||
</Route>
|
||||
<Route path="/incidents">
|
||||
<IncidentsPage />
|
||||
</Route>
|
||||
<Route path="/status">
|
||||
<StatusPagesPage />
|
||||
</Route>
|
||||
<Route path="/status/:slug">
|
||||
{(p) => <StatusPublicPage slug={p.slug} />}
|
||||
</Route>
|
||||
|
||||
<ServiceDialog open={svcOpen} onOpenChange={setSvcOpen} item={editing?.kind === "service" ? editing : undefined} />
|
||||
<WidgetDialog open={wdgOpen} onOpenChange={setWdgOpen} item={editing?.kind === "widget" ? editing : undefined} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { Activity, ArrowRight, Download, Globe, LayoutGrid, Moon, Plus, Server, Settings, Sun } from "lucide-react";
|
||||
import { Activity, AlertTriangle, ArrowRight, Download, Globe, LayoutGrid, Megaphone, Moon, Plus, Server, Settings, Sun } from "lucide-react";
|
||||
import { useBoard } from "@/board/store";
|
||||
import { hostOf, IconImg } from "@/board/primitives";
|
||||
import { rendererLabels, rendererList } from "@/renderers";
|
||||
@@ -46,6 +46,8 @@ export function CommandPalette({
|
||||
{ id: "nav-monitors", group: "Actions", glyph: Activity, label: "Go to Monitors", run: () => navigate("/monitors") },
|
||||
{ id: "nav-domains", group: "Actions", glyph: Globe, label: "Go to Domains", run: () => navigate("/domains") },
|
||||
{ id: "nav-systems", group: "Actions", glyph: Server, label: "Go to Systems", run: () => navigate("/systems") },
|
||||
{ id: "nav-incidents", group: "Actions", glyph: AlertTriangle, label: "Go to Incidents", run: () => navigate("/incidents") },
|
||||
{ id: "nav-status", group: "Actions", glyph: Megaphone, label: "Go to Status pages", run: () => navigate("/status") },
|
||||
{ id: "add-service", group: "Actions", glyph: Plus, label: "Add service", hint: "new", run: onAddService },
|
||||
{ id: "add-widget", group: "Actions", glyph: Plus, label: "Add widget", hint: "new", run: onAddWidget },
|
||||
{ id: "settings", group: "Actions", glyph: Settings, label: "Open settings", run: onSettings },
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { api } from "@/api";
|
||||
import type { Incident } from "@/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SEV_COLOR = {
|
||||
critical: "text-down",
|
||||
major: "text-down",
|
||||
minor: "text-text-faint",
|
||||
} satisfies Record<NonNullable<Incident["severity"]>, string>;
|
||||
|
||||
type Status = NonNullable<Incident["status"]>;
|
||||
const NEXT: Partial<Record<Status, { label: string; to: Status }[]>> = {
|
||||
open: [{ label: "Acknowledge", to: "ack" }, { label: "Resolve", to: "resolved" }],
|
||||
ack: [{ label: "Resolve", to: "resolved" }],
|
||||
resolved: [{ label: "Close", to: "closed" }],
|
||||
};
|
||||
|
||||
export function IncidentsPage() {
|
||||
const [incidents, setIncidents] = useState<Incident[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [dlgOpen, setDlgOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [sev, setSev] = useState("major");
|
||||
const [msg, setMsg] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const { data } = await api.GET("/api/incidents", showAll ? {} : { params: { query: { status: "open" } } });
|
||||
if (data) setIncidents(data);
|
||||
setLoaded(true);
|
||||
}, [showAll]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const t = setInterval(() => void load(), 30_000);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
async function create() {
|
||||
if (!title.trim()) return;
|
||||
setBusy(true);
|
||||
// The select only offers the three severities, but its value is a
|
||||
// plain string — narrow at the boundary instead of asserting.
|
||||
const severity = sev === "critical" || sev === "minor" ? sev : "major";
|
||||
await api.POST("/api/incidents", { body: { title: title.trim(), severity, message: msg || undefined } });
|
||||
setBusy(false);
|
||||
setDlgOpen(false);
|
||||
setTitle(""); setMsg("");
|
||||
void load();
|
||||
}
|
||||
|
||||
async function transition(id: string | undefined, to: Status, message?: string) {
|
||||
if (!id) return;
|
||||
await api.PATCH("/api/incidents/{id}", { params: { path: { id } }, body: { status: to, message } });
|
||||
await load();
|
||||
}
|
||||
|
||||
async function addNote(id: string | undefined) {
|
||||
if (!id || !note.trim()) return;
|
||||
await api.POST("/api/incidents/{id}/updates", { params: { path: { id } }, body: { message: note.trim() } });
|
||||
setNote("");
|
||||
await load();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">Incidents</h1>
|
||||
<p className="text-[12px] text-text-faint">Auto-opened on monitor outages, or tracked manually.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAll((v) => !v)}>
|
||||
{showAll ? "Open only" : "Show all"}
|
||||
</Button>
|
||||
<Button onClick={() => setDlgOpen(true)}>
|
||||
<Plus size={13} strokeWidth={2.2} /> New incident
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loaded && incidents.length === 0 ? (
|
||||
<div className="rounded-[10px] border border-dashed border-border px-6 py-14 text-center">
|
||||
<p className="text-[13px] text-text-dim">No open incidents.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{incidents.map((inc) => (
|
||||
<div key={inc.id} className="rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={cn("font-mono text-[10px] uppercase tracking-[0.08em]",
|
||||
inc.severity ? SEV_COLOR[inc.severity] : "text-text-faint")}>
|
||||
{inc.severity}
|
||||
</span>
|
||||
<button type="button" className="min-w-0 flex-1 truncate text-left text-[13px] font-medium hover:underline"
|
||||
onClick={() => setExpanded(expanded === inc.id ? null : (inc.id ?? null))}>
|
||||
{inc.title}
|
||||
</button>
|
||||
<span className="font-mono text-[10.5px] uppercase text-text-faint">{inc.status}</span>
|
||||
<span className="font-mono text-[10.5px] text-text-faint">
|
||||
{inc.createdAt ? new Date(inc.createdAt).toLocaleString() : ""}
|
||||
</span>
|
||||
{(inc.status ? (NEXT[inc.status] ?? []) : []).map((n) => (
|
||||
<Button key={n.to} variant="outline" size="sm" onClick={() => void transition(inc.id, n.to)}>
|
||||
{n.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button variant="outline" size="icon" aria-label="Delete"
|
||||
onClick={() => {
|
||||
if (inc.id && confirm(`Delete incident "${inc.title}"?`)) {
|
||||
void api.DELETE("/api/incidents/{id}", { params: { path: { id: inc.id } } }).then(load);
|
||||
}
|
||||
}}>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
{expanded === inc.id && (
|
||||
<div className="mt-3 border-t border-border/60 pt-3">
|
||||
<div className="mb-2.5 flex flex-col gap-1.5">
|
||||
{(inc.updates ?? []).map((u) => (
|
||||
<div key={u.id} className="flex items-baseline gap-2.5 text-[12px]">
|
||||
<span className="shrink-0 font-mono text-[10px] uppercase tracking-wide text-text-faint">{u.status}</span>
|
||||
<span className="flex-1 text-text-dim">{u.message}</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-text-faint">
|
||||
{u.at ? new Date(u.at).toLocaleString() : ""}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(inc.updates ?? []).length === 0 && (
|
||||
<p className="text-[11.5px] text-text-faint">No updates yet.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={note} onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Add a note…" className="h-8 text-[12px]"
|
||||
onKeyDown={(e) => { if (e.key === "Enter") void addNote(inc.id); }} />
|
||||
<Button variant="outline" size="sm" onClick={() => void addNote(inc.id)}>Post</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dlgOpen} onOpenChange={setDlgOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New incident</DialogTitle>
|
||||
<DialogDescription>Track an outage or degradation manually.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="inc-title">Title</Label>
|
||||
<Input id="inc-title" value={title} onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="NAS offline" autoFocus />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Severity</Label>
|
||||
<Select value={sev} onValueChange={setSev}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="minor">minor</SelectItem>
|
||||
<SelectItem value="major">major</SelectItem>
|
||||
<SelectItem value="critical">critical</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="inc-msg">Note (optional)</Label>
|
||||
<Input id="inc-msg" value={msg} onChange={(e) => setMsg(e.target.value)} placeholder="First observed…" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDlgOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button onClick={() => void create()} disabled={busy || !title.trim()}>
|
||||
{busy ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "wouter";
|
||||
import { ExternalLink, Plus, Trash2 } from "lucide-react";
|
||||
import { api } from "@/api";
|
||||
import type { MaintenanceWindow, MonitorView, StatusPage } from "@/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Admin for public status pages + maintenance windows. The public view
|
||||
// itself lives at /status/:slug.
|
||||
|
||||
export function StatusPagesPage() {
|
||||
const [pages, setPages] = useState<StatusPage[]>([]);
|
||||
const [windows, setWindows] = useState<MaintenanceWindow[]>([]);
|
||||
const [monitors, setMonitors] = useState<MonitorView[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const [pageOpen, setPageOpen] = useState(false);
|
||||
const [pTitle, setPTitle] = useState("");
|
||||
const [pSlug, setPSlug] = useState("");
|
||||
const [pDesc, setPDesc] = useState("");
|
||||
const [pMonitors, setPMonitors] = useState<string[]>([]);
|
||||
|
||||
const [winOpen, setWinOpen] = useState(false);
|
||||
const [wTitle, setWTitle] = useState("");
|
||||
const [wStart, setWStart] = useState("");
|
||||
const [wEnd, setWEnd] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [{ data: p }, { data: w }, { data: m }] = await Promise.all([
|
||||
api.GET("/api/status-pages"),
|
||||
api.GET("/api/maintenance"),
|
||||
api.GET("/api/monitors"),
|
||||
]);
|
||||
if (p) setPages(p);
|
||||
if (w) setWindows(w);
|
||||
if (m) setMonitors(m);
|
||||
setLoaded(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const t = setInterval(() => void load(), 30_000);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
async function createPage() {
|
||||
if (!pTitle.trim()) return;
|
||||
setBusy(true); setErr("");
|
||||
const { error } = await api.POST("/api/status-pages", {
|
||||
body: {
|
||||
title: pTitle.trim(),
|
||||
slug: pSlug.trim() || undefined,
|
||||
description: pDesc.trim(),
|
||||
monitorIds: pMonitors,
|
||||
},
|
||||
});
|
||||
setBusy(false);
|
||||
if (error) { setErr("Could not create — slug may be taken."); return; }
|
||||
setPageOpen(false); setPTitle(""); setPSlug(""); setPDesc(""); setPMonitors([]);
|
||||
void load();
|
||||
}
|
||||
|
||||
async function createWindow() {
|
||||
if (!wTitle.trim() || !wStart || !wEnd) return;
|
||||
setBusy(true); setErr("");
|
||||
const { error } = await api.POST("/api/maintenance", {
|
||||
body: {
|
||||
title: wTitle.trim(),
|
||||
startsAt: new Date(wStart).toISOString(),
|
||||
endsAt: new Date(wEnd).toISOString(),
|
||||
},
|
||||
});
|
||||
setBusy(false);
|
||||
if (error) { setErr("Invalid window — check times."); return; }
|
||||
setWinOpen(false); setWTitle(""); setWStart(""); setWEnd("");
|
||||
void load();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-5xl px-7 py-8">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">Status pages</h1>
|
||||
<p className="text-[12px] text-text-faint">Public live views at <code className="font-mono">/status/<slug></code>.</p>
|
||||
</div>
|
||||
<Button onClick={() => setPageOpen(true)}>
|
||||
<Plus size={13} strokeWidth={2.2} /> New page
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loaded && pages.length === 0 ? (
|
||||
<div className="mb-8 rounded-[10px] border border-dashed border-border px-6 py-10 text-center">
|
||||
<p className="text-[13px] text-text-dim">No status pages yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-8 flex flex-col gap-2">
|
||||
{pages.map((p) => (
|
||||
<div key={p.id} className="flex items-center gap-3 rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{p.title}</span>
|
||||
<code className="font-mono text-[11px] text-text-faint">/status/{p.slug}</code>
|
||||
<Link href={`/status/${p.slug}`} className="shrink-0">
|
||||
<Button variant="outline" size="icon" aria-label="Open public page"><ExternalLink size={12} /></Button>
|
||||
</Link>
|
||||
<Button variant="outline" size="icon" aria-label="Delete"
|
||||
onClick={() => {
|
||||
if (p.id && confirm(`Delete status page "${p.title}"?`)) {
|
||||
void api.DELETE("/api/status-pages/{id}", { params: { path: { id: p.id } } }).then(load);
|
||||
}
|
||||
}}>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">Maintenance windows</h2>
|
||||
<p className="text-[12px] text-text-faint">Active windows mute alerts + auto-incidents and mark covered monitors.</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setWinOpen(true)}>
|
||||
<Plus size={13} strokeWidth={2.2} /> Schedule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loaded && windows.length === 0 ? (
|
||||
<div className="rounded-[10px] border border-dashed border-border px-6 py-10 text-center">
|
||||
<p className="text-[13px] text-text-dim">No maintenance scheduled.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{windows.map((w) => (
|
||||
<div key={w.id} className="flex items-center gap-3 rounded-[10px] border border-border bg-surface px-4 py-3">
|
||||
<span className={cn("size-1.5 shrink-0 rounded-full", w.active ? "bg-up" : "bg-text-faint/40")} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{w.title}</span>
|
||||
<span className="font-mono text-[10.5px] text-text-faint">
|
||||
{new Date(w.startsAt ?? "").toLocaleString()} → {new Date(w.endsAt ?? "").toLocaleString()}
|
||||
</span>
|
||||
<span className={cn("font-mono text-[10px] uppercase", w.active ? "text-up" : "text-text-faint")}>
|
||||
{w.active ? "active" : "scheduled"}
|
||||
</span>
|
||||
<Button variant="outline" size="icon" aria-label="Delete"
|
||||
onClick={() => {
|
||||
if (w.id && confirm(`Delete window "${w.title}"?`)) {
|
||||
void api.DELETE("/api/maintenance/{id}", { params: { path: { id: w.id } } }).then(load);
|
||||
}
|
||||
}}>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={pageOpen} onOpenChange={setPageOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New status page</DialogTitle>
|
||||
<DialogDescription>Public live view over selected monitors.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sp-title">Title</Label>
|
||||
<Input id="sp-title" value={pTitle} onChange={(e) => setPTitle(e.target.value)} placeholder="Homelab status" autoFocus />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sp-slug">Slug</Label>
|
||||
<Input id="sp-slug" value={pSlug} onChange={(e) => setPSlug(e.target.value)} placeholder="auto" className="font-mono text-[12px]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="sp-desc">Description</Label>
|
||||
<Input id="sp-desc" value={pDesc} onChange={(e) => setPDesc(e.target.value)} placeholder="optional" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Monitors</Label>
|
||||
<div className="max-h-40 overflow-y-auto rounded-[7px] border border-border p-2">
|
||||
{monitors.map((m) => (
|
||||
<label key={m.id} className="flex items-center gap-2 px-1 py-1 text-[12px]">
|
||||
<input type="checkbox" className="accent-current"
|
||||
checked={pMonitors.includes(m.id ?? "")}
|
||||
onChange={(e) => setPMonitors((v) =>
|
||||
e.target.checked ? [...v, m.id ?? ""] : v.filter((x) => x !== m.id))} />
|
||||
{m.name}
|
||||
</label>
|
||||
))}
|
||||
{monitors.length === 0 && <p className="px-1 py-1 text-[11.5px] text-text-faint">No monitors yet — empty selection shows all.</p>}
|
||||
</div>
|
||||
<p className="text-[11px] text-text-faint">Leave all unchecked to include every active monitor.</p>
|
||||
</div>
|
||||
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPageOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button onClick={() => void createPage()} disabled={busy || !pTitle.trim()}>
|
||||
{busy ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={winOpen} onOpenChange={setWinOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Schedule maintenance</DialogTitle>
|
||||
<DialogDescription>Suppresses alerts and auto-incidents for the window.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mw-title">Title</Label>
|
||||
<Input id="mw-title" value={wTitle} onChange={(e) => setWTitle(e.target.value)} placeholder="Network upgrade" autoFocus />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mw-start">Starts</Label>
|
||||
<Input id="mw-start" type="datetime-local" value={wStart} onChange={(e) => setWStart(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="mw-end">Ends</Label>
|
||||
<Input id="mw-end" type="datetime-local" value={wEnd} onChange={(e) => setWEnd(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
{err ? <p className="text-[12px] text-destructive">{err}</p> : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setWinOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button onClick={() => void createWindow()} disabled={busy || !wTitle.trim() || !wStart || !wEnd}>
|
||||
{busy ? "Saving…" : "Schedule"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "@/api";
|
||||
import type { PublicStatus } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Public status page — clean read-only view; refreshes every 30s.
|
||||
// Route: /status/:slug -> GET /api/status-pages/:slug/public.
|
||||
|
||||
// status strings arrive as plain `string` — map with functions, not
|
||||
// open dictionaries, so the lookup type stays honest.
|
||||
function overallBanner(overall?: string) {
|
||||
if (overall === "down") return { label: "Major outage", cls: "bg-down" };
|
||||
if (overall === "maintenance") return { label: "Under maintenance", cls: "bg-text-faint/60" };
|
||||
if (overall === "up") return { label: "All systems operational", cls: "bg-up" };
|
||||
return { label: "Checking…", cls: "bg-text-faint/40" };
|
||||
}
|
||||
|
||||
function dotCls(status?: string) {
|
||||
if (status === "up") return "bg-up";
|
||||
if (status === "down") return "bg-down";
|
||||
if (status === "maintenance") return "bg-text-faint/60";
|
||||
return "bg-text-faint/40";
|
||||
}
|
||||
|
||||
export function StatusPublicPage({ slug }: { slug: string }) {
|
||||
const [st, setSt] = useState<PublicStatus | null>(null);
|
||||
const [missing, setMissing] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const { data, response } = await api.GET("/api/status-pages/{slug}/public", { params: { path: { slug } } });
|
||||
if (response.status === 404) setMissing(true);
|
||||
if (data) setSt(data);
|
||||
}, [slug]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const t = setInterval(() => void load(), 30_000);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
if (missing) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">Status page not found.</main>;
|
||||
if (!st) return <main className="mx-auto max-w-2xl px-7 py-16 text-center text-[13px] text-text-faint">Loading…</main>;
|
||||
|
||||
const banner = overallBanner(st.overall);
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-2xl px-7 py-10">
|
||||
<div className="mb-6 text-center">
|
||||
<h1 className="text-[17px] font-semibold tracking-tight">{st.title}</h1>
|
||||
{st.description ? <p className="mt-1 text-[12px] text-text-faint">{st.description}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className={cn("mb-8 rounded-[10px] px-5 py-3.5 text-center text-[13px] font-medium text-white", banner.cls)}>
|
||||
{banner.label}
|
||||
</div>
|
||||
|
||||
{(st.monitors ?? []).length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Services</h2>
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
{(st.monitors ?? []).map((m) => (
|
||||
<div key={m.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
|
||||
<span className={cn("size-1.5 shrink-0 rounded-full", dotCls(m.status))} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px]">{m.name}</span>
|
||||
<span className="font-mono text-[10.5px] text-text-faint">{m.uptime24h?.toFixed(1)}%</span>
|
||||
<span className="w-20 text-right font-mono text-[10.5px] uppercase text-text-faint">{m.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(st.systems ?? []).length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Systems</h2>
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
{(st.systems ?? []).map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
|
||||
<span className={cn("size-1.5 shrink-0 rounded-full", dotCls(s.status))} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px]">{s.name}</span>
|
||||
<span className="font-mono text-[10.5px] uppercase text-text-faint">{s.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(st.maintenance ?? []).length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Maintenance</h2>
|
||||
<div className="overflow-hidden rounded-[10px] border border-border">
|
||||
{(st.maintenance ?? []).map((w) => (
|
||||
<div key={w.id} className="flex items-center gap-3 border-b border-border/60 px-4 py-2.5 last:border-0">
|
||||
<span className="min-w-0 flex-1 truncate text-[13px]">{w.title}</span>
|
||||
<span className="font-mono text-[10.5px] text-text-faint">
|
||||
{new Date(w.startsAt ?? "").toLocaleString()} → {new Date(w.endsAt ?? "").toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(st.incidents ?? []).length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-2 font-mono text-[10.5px] uppercase tracking-[0.12em] text-text-faint">Incidents</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{(st.incidents ?? []).map((inc) => (
|
||||
<div key={inc.id} className="rounded-[10px] border border-border px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{inc.title}</span>
|
||||
<span className="font-mono text-[10.5px] uppercase text-text-faint">{inc.status}</span>
|
||||
</div>
|
||||
{(inc.updates ?? []).slice(-3).map((u) => (
|
||||
<p key={u.id} className="mt-1.5 text-[11.5px] text-text-faint">
|
||||
<span className="font-mono text-[10px] uppercase">{u.status}</span> — {u.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<p className="mt-10 text-center font-mono text-[10px] text-text-faint">powered by dash</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,11 @@ export type System = components["schemas"]["System"];
|
||||
export type StatSample = components["schemas"]["StatSample"];
|
||||
/** Stats rows are StatSample plus the server-side timestamp. */
|
||||
export type SystemStat = StatSample & { ts?: string };
|
||||
export type Incident = components["schemas"]["Incident"];
|
||||
export type IncidentInput = components["schemas"]["IncidentInput"];
|
||||
export type MaintenanceWindow = components["schemas"]["MaintenanceWindow"];
|
||||
export type StatusPage = components["schemas"]["StatusPage"];
|
||||
export type PublicStatus = components["schemas"]["PublicStatus"];
|
||||
|
||||
export type Theme = "dark" | "light";
|
||||
export type RendererName = "bento" | "cards" | "index" | "console";
|
||||
|
||||
Reference in new issue
Block a user