Big updates

This commit is contained in:
Alois 2026-07-29 22:14:01 +02:00
commit 56ae701d02
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
5 changed files with 401 additions and 214 deletions

View file

@ -5,7 +5,7 @@ use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{get, post},
routing::{delete, get, post},
};
use tower_http::{
services::{ServeDir, ServeFile},
@ -25,6 +25,7 @@ pub async fn serve(
let app = Router::new()
.route("/api/admin", get(admin))
.route("/api/incidents", get(list).post(create))
.route("/api/incidents/{id}", delete(remove))
.route("/api/incidents/{id}/resolve", post(resolve))
.fallback_service(assets)
.layer(TraceLayer::new_for_http())
@ -62,9 +63,20 @@ async fn resolve(State(state): State<Arc<AppState>>, Path(id): Path<Uuid>) -> Re
}
}
async fn remove(State(state): State<Arc<AppState>>, Path(id): Path<Uuid>) -> Response {
match state.incidents.delete(id).await {
Ok(_) => {
state.publish().await;
StatusCode::NO_CONTENT.into_response()
}
Err(error) => error_response(error),
}
}
fn error_response(error: IncidentError) -> Response {
let status = match error {
IncidentError::Empty => StatusCode::BAD_REQUEST,
IncidentError::Active => StatusCode::CONFLICT,
IncidentError::NotFound => StatusCode::NOT_FOUND,
IncidentError::Storage(_) | IncidentError::Parse(_) => StatusCode::INTERNAL_SERVER_ERROR,
};

View file

@ -18,6 +18,8 @@ pub enum IncidentError {
Empty,
#[error("incident not found")]
NotFound,
#[error("active incidents cannot be deleted")]
Active,
#[error("failed to persist incidents: {0}")]
Storage(#[from] std::io::Error),
#[error("failed to parse incidents: {0}")]
@ -73,6 +75,20 @@ impl IncidentStore {
Ok(resolved)
}
pub async fn delete(&self, id: Uuid) -> Result<Incident, IncidentError> {
let mut incidents = self.incidents.lock().await;
let index = incidents
.iter()
.position(|incident| incident.id == id)
.ok_or(IncidentError::NotFound)?;
if incidents[index].resolved_at.is_none() {
return Err(IncidentError::Active);
}
let deleted = incidents.remove(index);
self.persist(&incidents).await?;
Ok(deleted)
}
async fn persist(&self, incidents: &[Incident]) -> Result<(), std::io::Error> {
if let Some(parent) = self.path.parent() {
tokio::fs::create_dir_all(parent).await?;
@ -125,4 +141,35 @@ mod tests {
.await;
assert!(matches!(result, Err(IncidentError::Empty)));
}
#[tokio::test]
async fn only_deletes_resolved_incidents() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("incidents.json");
let store = IncidentStore::load(path.clone()).await.unwrap();
let created = store
.create(NewIncident {
title: "Service disruption".into(),
message: "Investigating elevated errors.".into(),
severity: Severity::Major,
})
.await
.unwrap();
assert!(matches!(
store.delete(created.id).await,
Err(IncidentError::Active)
));
store.resolve(created.id).await.unwrap();
store.delete(created.id).await.unwrap();
assert!(
IncidentStore::load(path)
.await
.unwrap()
.list()
.await
.is_empty()
);
}
}

View file

@ -42,7 +42,7 @@ export function Header() {
className="hidden md:flex h-9 text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
render={<a target="_blank" href="mailto:security@methanium.net" />}
>
Report Security Issue <ExternalLink />
Report security issue <ExternalLink />
</Button>
</nav>
<div className="flex h-full items-center gap-2">

View file

@ -1,9 +1,27 @@
import { Button } from "@methanium/ui";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
cn,
} 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 { Check, Loader2, Plus, ShieldAlert, Trash2 } from "lucide-react";
import { type FormEvent, useEffect, useRef, useState } from "react";
import type { Incident, Severity } from "../status";
import { formatTime } from "../utils";
export const Route = createFileRoute("/edit")({
beforeLoad: async () => {
@ -17,17 +35,25 @@ export const Route = createFileRoute("/edit")({
component: IncidentEditor,
});
const severityLabels: Record<Severity, string> = {
minor: "Minor",
major: "Major",
critical: "Critical",
};
function IncidentEditor() {
const [incidents, setIncidents] = useState<Incident[]>([]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function refresh() {
try {
const response = await fetch("/api/incidents", { cache: "no-store" });
if (!response.ok)
if (!response.ok) {
throw new Error(`Request failed with HTTP ${response.status}`);
}
setIncidents((await response.json()) as Incident[]);
setError(null);
} catch (cause) {
@ -43,16 +69,17 @@ function IncidentEditor() {
async function create(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
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"),
title: data.get("title"),
message: data.get("message"),
severity: data.get("severity"),
}),
});
if (!response.ok) {
@ -61,7 +88,7 @@ function IncidentEditor() {
body.error ?? `Request failed with HTTP ${response.status}`,
);
}
event.currentTarget.reset();
form.reset();
await refresh();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
@ -75,176 +102,290 @@ function IncidentEditor() {
const response = await fetch(`/api/incidents/${id}/resolve`, {
method: "POST",
});
if (!response.ok)
if (!response.ok) {
throw new Error(`Request failed with HTTP ${response.status}`);
}
await refresh();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
}
}
async function deleteIncident(id: string) {
setDeletingId(id);
try {
const response = await fetch(`/api/incidents/${id}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error(`Request failed with HTTP ${response.status}`);
}
await refresh();
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setDeletingId(null);
}
}
const active = incidents.filter((incident) => !incident.resolvedAt);
const resolved = incidents
.filter((incident) => incident.resolvedAt)
.reverse();
.toReversed();
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>
<main className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-4 py-10 sm:px-6">
<header className="flex items-center gap-3">
<ShieldAlert className="size-7" />
<h1 className="text-xl font-medium">Incident management</h1>
</header>
{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">
<div
role="alert"
className="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300"
>
{error}
</div>
)}
<div className="grid gap-8 lg:grid-cols-[22rem_1fr]">
<div className="grid items-start gap-8 lg:grid-cols-[20rem_minmax(0,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"
className="rounded-xl border border-[#C5CED6] bg-[#EDF1F4] p-4 dark:border-[#242D38] dark:bg-[#242D38]"
>
<h2 className="font-medium">Create incident</h2>
<Label className="mt-5 block text-sm" htmlFor="title">
Title
</label>
<input
</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"
className="mt-2 w-full"
/>
<label
className="mt-4 block text-xs font-semibold text-muted-foreground"
htmlFor="message"
>
<Label className="mt-4 block text-sm" htmlFor="message">
Message
</label>
<textarea
</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"
rows={6}
className="py-2"
/>
<label
className="mt-4 block text-xs font-semibold text-muted-foreground"
htmlFor="severity"
>
<Label className="mt-4 block text-sm" 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>
</Label>
<Select name="severity" defaultValue="minor">
<SelectTrigger id="severity" className="mt-2 w-full">
<SelectValue>
{(value) => severityLabels[value as Severity]}
</SelectValue>
</SelectTrigger>
<SelectContent className="border!">
<SelectItem value="minor">Minor</SelectItem>
<SelectItem value="major">Major</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
</SelectContent>
</Select>
<Button type="submit" disabled={submitting} className="mt-5 w-full">
{submitting && <Loader2 className="size-4 animate-spin" />}Publish
incident
{submitting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Plus className="size-4" />
)}
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">
<section aria-labelledby="active-incidents">
<div className="mb-4 flex items-baseline gap-2">
<h2 id="active-incidents" className="font-medium">
Active incidents
</h2>
<span className="text-sm text-muted-foreground">
{active.length}
</span>
</h2>
<div className="space-y-3">
</div>
<div className="flex flex-col gap-3">
{loading && (
<div className="flex items-center gap-2 rounded-xl border border-border bg-card p-5 text-sm text-muted-foreground">
<div className="flex items-center gap-2 py-8 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">
<div className="rounded-xl border border-dashed border-[#C5CED6] px-4 py-10 text-center text-sm text-muted-foreground dark:border-[#445161]">
No active incidents
</div>
)}
{active.map((incident) => (
<article
key={incident.id}
className="rounded-xl border border-border bg-card/80 p-5"
className="rounded-xl border border-[#C5CED6] bg-[#EDF1F4] dark:border-[#242D38] dark:bg-[#242D38]"
>
<div className="p-4">
<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 className="min-w-0">
<h3 className="font-medium [overflow-wrap:anywhere]">
{incident.title}
</h3>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
onClick={() => void resolve(incident.id)}
>
<CheckCircle2 className="size-4" />
<Check className="size-4" />
Resolve
</Button>
</div>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">
{incident.message}
</p>
<IncidentExcerpt
incident={incident}
lines={3}
className="mt-3"
/>
</div>
<div className="flex gap-2 border-t border-[#C5CED6] px-4 py-2 text-xs text-muted-foreground dark:border-[#445161]">
<span className="capitalize">{incident.severity}</span>
<span>Started {formatTime(incident.createdAt)}</span>
</div>
</article>
))}
</div>
</section>
</div>
{resolved.length > 0 && (
<>
<h2 className="mb-3 mt-8 font-semibold">Resolved history</h2>
<div className="space-y-2">
<section aria-labelledby="resolved-incidents">
<div className="mb-4 flex items-baseline gap-2">
<h2 id="resolved-incidents" className="font-medium">
Resolved
</h2>
<span className="text-sm text-muted-foreground">
{resolved.length}
</span>
</div>
<div className="divide-y divide-[#C5CED6] border-y border-[#C5CED6] dark:divide-[#445161] dark:border-[#445161]">
{resolved.map((incident) => (
<div
key={incident.id}
className="rounded-lg border border-border/70 bg-card/50 px-4 py-3"
className="flex flex-col gap-1 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
>
<div className="flex justify-between gap-3">
<span className="text-sm font-medium">
<div className="min-w-0">
<p className="font-medium [overflow-wrap:anywhere]">
{incident.title}
</span>
<span className="text-xs text-muted-foreground">
{new Date(incident.resolvedAt!).toLocaleDateString()}
</span>
</p>
<IncidentExcerpt incident={incident} lines={1} />
</div>
<div className="flex shrink-0 items-center justify-between gap-3 sm:justify-end">
<p className="text-xs text-muted-foreground">
{formatTime(incident.resolvedAt!)}
</p>
<Button
type="button"
variant="destructive"
size="sm"
disabled={deletingId === incident.id}
onClick={() => void deleteIncident(incident.id)}
>
{deletingId === incident.id ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
Delete
</Button>
</div>
</div>
))}
</div>
</>
</section>
)}
</div>
</div>
</div>
</main>
);
}
function IncidentExcerpt({
incident,
lines,
className,
}: {
incident: Incident;
lines: 1 | 3;
className?: string;
}) {
const messageRef = useRef<HTMLParagraphElement>(null);
const [truncated, setTruncated] = useState(false);
useEffect(() => {
const message = messageRef.current;
if (!message) return;
const update = () =>
setTruncated(
message.scrollHeight > message.clientHeight + 1 ||
message.scrollWidth > message.clientWidth + 1,
);
update();
const observer = new ResizeObserver(update);
observer.observe(message);
return () => observer.disconnect();
}, [incident.message]);
const severity =
incident.severity.charAt(0).toUpperCase() + incident.severity.slice(1);
return (
<div className={cn("min-w-0", className)}>
<p
ref={messageRef}
className={cn(
"text-sm text-muted-foreground [overflow-wrap:anywhere]",
lines === 1 ? "truncate" : "line-clamp-3 whitespace-pre-wrap",
)}
>
{incident.message}
</p>
{truncated && (
<Dialog>
<DialogTrigger
render={
<Button
type="button"
variant="link"
size="sm"
className="h-auto px-0 py-0"
>
Read more
</Button>
}
/>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{incident.title}</DialogTitle>
<DialogDescription>
{severity} incident started {formatTime(incident.createdAt)}
{incident.resolvedAt &&
` and resolved ${formatTime(incident.resolvedAt)}`}
</DialogDescription>
</DialogHeader>
<p className="max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-foreground [overflow-wrap:anywhere]">
{incident.message}
</p>
</DialogContent>
</Dialog>
)}
</div>
);
}

View file

@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router";
import {
AlertTriangle,
Check,
ChevronRight,
Clock3,
ExternalLink,
RefreshCw,
@ -10,7 +11,7 @@ import {
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { Incident, Service, ServiceStatus, Severity } from "../status";
import type { Incident, Service, ServiceStatus } from "../status";
import { useStatus } from "../use-status";
import { formatTime } from "../utils";
import {
@ -30,6 +31,7 @@ export const Route = createFileRoute("/")({ component: Home });
function Home() {
const { snapshot } = useStatus();
const [showPastIncidents, setShowPastIncidents] = useState(false);
const services =
snapshot?.categories.flatMap((category) => category.services) ?? [];
const failures = services.filter(
@ -37,6 +39,11 @@ function Home() {
);
const activeIncidents =
snapshot?.incidents.filter((incident) => !incident.resolvedAt) ?? [];
const pastIncidents =
snapshot?.incidents
.filter((incident) => incident.resolvedAt)
.toReversed()
.slice(0, 5) ?? [];
const pending = services.some((service) => service.status === "unknown");
const ready = snapshot !== null && !pending;
const healthy =
@ -72,11 +79,13 @@ function Home() {
</div>
</div>
{activeIncidents.length > 0 && (
<div className="grid w-full grid-cols-1 justify-center gap-6 pb-10 sm:grid-cols-[repeat(auto-fit,24rem)]">
{activeIncidents.map((incident) => (
<ActiveIncidentCard key={incident.id} incident={incident} />
<IncidentCard key={incident.id} incident={incident} />
))}
</div>
)}
<div className="w-full flex flex-col lg:flex-row! gap-15 items-center justify-center">
{snapshot?.categories.map((category) => (
@ -103,27 +112,40 @@ function Home() {
))}
</div>
{!!snapshot?.incidents.some((incident) => incident.resolvedAt) && (
<section className="mt-12" aria-labelledby="resolved-incidents">
<h2 id="resolved-incidents" className="mb-4 text-lg font-semibold">
Recently resolved
</h2>
<div className="space-y-3">
{snapshot.incidents
.filter((incident) => incident.resolvedAt)
.reverse()
.slice(0, 5)
.map((incident) => (
<IncidentCard key={incident.id} incident={incident} resolved />
{pastIncidents.length > 0 && (
<section className="pt-10 w-full flex flex-col items-center">
<Button
type="button"
variant="ghost"
aria-expanded={showPastIncidents}
aria-controls="past-incidents"
onClick={() => setShowPastIncidents((visible) => !visible)}
>
<ChevronRight
className={cn(
"size-4 transition-transform",
showPastIncidents && "rotate-90",
)}
/>
Show Past Incidents
</Button>
{showPastIncidents && (
<div
id="past-incidents"
className="mt-4 grid w-full grid-cols-1 justify-center gap-6 sm:grid-cols-[repeat(auto-fit,24rem)]"
>
{pastIncidents.map((incident) => (
<IncidentCard key={incident.id} incident={incident} />
))}
</div>
)}
</section>
)}
</main>
);
}
function ActiveIncidentCard({ incident }: { incident: Incident }) {
function IncidentCard({ incident }: { incident: Incident }) {
const messageRef = useRef<HTMLParagraphElement>(null);
const [truncated, setTruncated] = useState(false);
@ -173,7 +195,9 @@ function ActiveIncidentCard({ incident }: { incident: Incident }) {
<DialogHeader>
<DialogTitle>{incident.title}</DialogTitle>
<DialogDescription>
{severity} incident started {formatTime(incident.createdAt)}
{severity} incident{" "}
{incident.resolvedAt ? "resolved" : "started"}{" "}
{formatTime(incident.resolvedAt ?? incident.createdAt)}
</DialogDescription>
</DialogHeader>
<p className="max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-foreground [overflow-wrap:anywhere]">
@ -184,7 +208,10 @@ function ActiveIncidentCard({ incident }: { incident: Incident }) {
)}
<div className="mt-auto flex gap-3 border-t border-[#C5CED6] px-4 py-2 dark:border-[#242D38]">
<p>{severity}</p>
<p>Started {formatTime(incident.createdAt)}</p>
<p>
{incident.resolvedAt ? "Resolved" : "Started"}{" "}
{formatTime(incident.resolvedAt ?? incident.createdAt)}
</p>
</div>
</CardContent>
</article>
@ -260,43 +287,3 @@ function ServiceRow({
</div>
);
}
const severityStyles: Record<Severity, string> = {
minor:
"border-amber-500/30 bg-amber-500/[0.07] text-amber-700 dark:text-amber-300",
major:
"border-orange-500/30 bg-orange-500/[0.07] text-orange-700 dark:text-orange-300",
critical:
"border-red-500/35 bg-red-500/[0.08] text-red-700 dark:text-red-300",
};
function IncidentCard({
incident,
resolved = false,
}: {
incident: Incident;
resolved?: boolean;
}) {
return (
<article
className={`rounded-xl border p-5 ${resolved ? "border-border bg-card/60" : severityStyles[incident.severity]}`}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="font-semibold">{incident.title}</h3>
<span className="text-[0.65rem] font-bold uppercase tracking-[0.18em]">
{resolved ? "Resolved" : incident.severity}
</span>
</div>
<p
className={`mt-2 text-sm leading-6 ${resolved ? "text-muted-foreground" : "text-foreground/80"}`}
>
{incident.message}
</p>
<p className="mt-3 text-xs text-muted-foreground">
{resolved && incident.resolvedAt
? `Resolved ${formatTime(incident.resolvedAt)}`
: `Started ${formatTime(incident.createdAt)}`}
</p>
</article>
);
}