Add basic status page

This commit is contained in:
Alois 2026-07-29 21:52:32 +02:00
commit de54eb0b90
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
43 changed files with 6080 additions and 244 deletions

View file

@ -0,0 +1,250 @@
import { Button } from "@methanium/ui";
import { createFileRoute, notFound } from "@tanstack/react-router";
import { CheckCircle2, Loader2, ShieldAlert } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import type { Incident, Severity } from "../status";
export const Route = createFileRoute("/edit")({
beforeLoad: async () => {
try {
const response = await fetch("/api/admin", { cache: "no-store" });
if (response.status !== 204) throw notFound();
} catch {
throw notFound();
}
},
component: IncidentEditor,
});
function IncidentEditor() {
const [incidents, setIncidents] = useState<Incident[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function refresh() {
try {
const response = await fetch("/api/incidents", { cache: "no-store" });
if (!response.ok)
throw new Error(`Request failed with HTTP ${response.status}`);
setIncidents((await response.json()) as Incident[]);
setError(null);
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setLoading(false);
}
}
useEffect(() => {
void refresh();
}, []);
async function create(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
const form = new FormData(event.currentTarget);
try {
const response = await fetch("/api/incidents", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
title: form.get("title"),
message: form.get("message"),
severity: form.get("severity"),
}),
});
if (!response.ok) {
const body = (await response.json()) as { error?: string };
throw new Error(
body.error ?? `Request failed with HTTP ${response.status}`,
);
}
event.currentTarget.reset();
await refresh();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setSubmitting(false);
}
}
async function resolve(id: string) {
try {
const response = await fetch(`/api/incidents/${id}/resolve`, {
method: "POST",
});
if (!response.ok)
throw new Error(`Request failed with HTTP ${response.status}`);
await refresh();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
}
}
const active = incidents.filter((incident) => !incident.resolvedAt);
const resolved = incidents
.filter((incident) => incident.resolvedAt)
.reverse();
return (
<main className="status-canvas min-h-[calc(100vh-3.5rem)]">
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-14">
<div className="mb-8 flex items-start gap-4">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-red-500/10 text-red-500">
<ShieldAlert className="size-5" />
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-muted-foreground">
Unprotected administration
</p>
<h1 className="mt-1 text-3xl font-semibold tracking-tight">
Incident editor
</h1>
<p className="mt-2 text-sm text-muted-foreground">
This interface has no login and should only be exposed on a
trusted network.
</p>
</div>
</div>
{error && (
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-600 dark:text-red-300">
{error}
</div>
)}
<div className="grid gap-8 lg:grid-cols-[22rem_1fr]">
<form
onSubmit={create}
className="h-fit rounded-2xl border border-border bg-card/80 p-5 shadow-sm"
>
<h2 className="font-semibold">Create incident</h2>
<label
className="mt-5 block text-xs font-semibold text-muted-foreground"
htmlFor="title"
>
Title
</label>
<input
required
id="title"
name="title"
maxLength={120}
className="mt-2 h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring"
/>
<label
className="mt-4 block text-xs font-semibold text-muted-foreground"
htmlFor="message"
>
Message
</label>
<textarea
required
id="message"
name="message"
maxLength={2000}
rows={5}
className="mt-2 w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
/>
<label
className="mt-4 block text-xs font-semibold text-muted-foreground"
htmlFor="severity"
>
Severity
</label>
<select
id="severity"
name="severity"
defaultValue="minor"
className="mt-2 h-10 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring"
>
{(["minor", "major", "critical"] as Severity[]).map(
(severity) => (
<option key={severity} value={severity}>
{severity[0].toUpperCase() + severity.slice(1)}
</option>
),
)}
</select>
<Button type="submit" disabled={submitting} className="mt-5 w-full">
{submitting && <Loader2 className="size-4 animate-spin" />}Publish
incident
</Button>
</form>
<div>
<h2 className="mb-3 flex items-center gap-2 font-semibold">
Active incidents{" "}
<span className="text-xs text-muted-foreground">
{active.length}
</span>
</h2>
<div className="space-y-3">
{loading && (
<div className="flex items-center gap-2 rounded-xl border border-border bg-card p-5 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading incidents
</div>
)}
{!loading && active.length === 0 && (
<div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
No active incidents
</div>
)}
{active.map((incident) => (
<article
key={incident.id}
className="rounded-xl border border-border bg-card/80 p-5"
>
<div className="flex items-start justify-between gap-4">
<div>
<span className="text-[0.65rem] font-bold uppercase tracking-[0.18em] text-red-500">
{incident.severity}
</span>
<h3 className="mt-1 font-semibold">{incident.title}</h3>
</div>
<Button
variant="outline"
size="sm"
onClick={() => void resolve(incident.id)}
>
<CheckCircle2 className="size-4" />
Resolve
</Button>
</div>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">
{incident.message}
</p>
</article>
))}
</div>
{resolved.length > 0 && (
<>
<h2 className="mb-3 mt-8 font-semibold">Resolved history</h2>
<div className="space-y-2">
{resolved.map((incident) => (
<div
key={incident.id}
className="rounded-lg border border-border/70 bg-card/50 px-4 py-3"
>
<div className="flex justify-between gap-3">
<span className="text-sm font-medium">
{incident.title}
</span>
<span className="text-xs text-muted-foreground">
{new Date(incident.resolvedAt!).toLocaleDateString()}
</span>
</div>
</div>
))}
</div>
</>
)}
</div>
</div>
</div>
</main>
);
}