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}, extract::{Path, State},
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::{get, post}, routing::{delete, get, post},
}; };
use tower_http::{ use tower_http::{
services::{ServeDir, ServeFile}, services::{ServeDir, ServeFile},
@ -25,6 +25,7 @@ pub async fn serve(
let app = Router::new() let app = Router::new()
.route("/api/admin", get(admin)) .route("/api/admin", get(admin))
.route("/api/incidents", get(list).post(create)) .route("/api/incidents", get(list).post(create))
.route("/api/incidents/{id}", delete(remove))
.route("/api/incidents/{id}/resolve", post(resolve)) .route("/api/incidents/{id}/resolve", post(resolve))
.fallback_service(assets) .fallback_service(assets)
.layer(TraceLayer::new_for_http()) .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 { fn error_response(error: IncidentError) -> Response {
let status = match error { let status = match error {
IncidentError::Empty => StatusCode::BAD_REQUEST, IncidentError::Empty => StatusCode::BAD_REQUEST,
IncidentError::Active => StatusCode::CONFLICT,
IncidentError::NotFound => StatusCode::NOT_FOUND, IncidentError::NotFound => StatusCode::NOT_FOUND,
IncidentError::Storage(_) | IncidentError::Parse(_) => StatusCode::INTERNAL_SERVER_ERROR, IncidentError::Storage(_) | IncidentError::Parse(_) => StatusCode::INTERNAL_SERVER_ERROR,
}; };

View file

@ -18,6 +18,8 @@ pub enum IncidentError {
Empty, Empty,
#[error("incident not found")] #[error("incident not found")]
NotFound, NotFound,
#[error("active incidents cannot be deleted")]
Active,
#[error("failed to persist incidents: {0}")] #[error("failed to persist incidents: {0}")]
Storage(#[from] std::io::Error), Storage(#[from] std::io::Error),
#[error("failed to parse incidents: {0}")] #[error("failed to parse incidents: {0}")]
@ -73,6 +75,20 @@ impl IncidentStore {
Ok(resolved) 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> { async fn persist(&self, incidents: &[Incident]) -> Result<(), std::io::Error> {
if let Some(parent) = self.path.parent() { if let Some(parent) = self.path.parent() {
tokio::fs::create_dir_all(parent).await?; tokio::fs::create_dir_all(parent).await?;
@ -125,4 +141,35 @@ mod tests {
.await; .await;
assert!(matches!(result, Err(IncidentError::Empty))); 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" 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" />} render={<a target="_blank" href="mailto:security@methanium.net" />}
> >
Report Security Issue <ExternalLink /> Report security issue <ExternalLink />
</Button> </Button>
</nav> </nav>
<div className="flex h-full items-center gap-2"> <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 { createFileRoute, notFound } from "@tanstack/react-router";
import { CheckCircle2, Loader2, ShieldAlert } from "lucide-react"; import { Check, Loader2, Plus, ShieldAlert, Trash2 } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react"; import { type FormEvent, useEffect, useRef, useState } from "react";
import type { Incident, Severity } from "../status"; import type { Incident, Severity } from "../status";
import { formatTime } from "../utils";
export const Route = createFileRoute("/edit")({ export const Route = createFileRoute("/edit")({
beforeLoad: async () => { beforeLoad: async () => {
@ -17,17 +35,25 @@ export const Route = createFileRoute("/edit")({
component: IncidentEditor, component: IncidentEditor,
}); });
const severityLabels: Record<Severity, string> = {
minor: "Minor",
major: "Major",
critical: "Critical",
};
function IncidentEditor() { function IncidentEditor() {
const [incidents, setIncidents] = useState<Incident[]>([]); const [incidents, setIncidents] = useState<Incident[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
async function refresh() { async function refresh() {
try { try {
const response = await fetch("/api/incidents", { cache: "no-store" }); const response = await fetch("/api/incidents", { cache: "no-store" });
if (!response.ok) if (!response.ok) {
throw new Error(`Request failed with HTTP ${response.status}`); throw new Error(`Request failed with HTTP ${response.status}`);
}
setIncidents((await response.json()) as Incident[]); setIncidents((await response.json()) as Incident[]);
setError(null); setError(null);
} catch (cause) { } catch (cause) {
@ -43,16 +69,17 @@ function IncidentEditor() {
async function create(event: FormEvent<HTMLFormElement>) { async function create(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
setSubmitting(true); setSubmitting(true);
const form = new FormData(event.currentTarget);
try { try {
const response = await fetch("/api/incidents", { const response = await fetch("/api/incidents", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
title: form.get("title"), title: data.get("title"),
message: form.get("message"), message: data.get("message"),
severity: form.get("severity"), severity: data.get("severity"),
}), }),
}); });
if (!response.ok) { if (!response.ok) {
@ -61,7 +88,7 @@ function IncidentEditor() {
body.error ?? `Request failed with HTTP ${response.status}`, body.error ?? `Request failed with HTTP ${response.status}`,
); );
} }
event.currentTarget.reset(); form.reset();
await refresh(); await refresh();
} catch (cause) { } catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause)); setError(cause instanceof Error ? cause.message : String(cause));
@ -75,176 +102,290 @@ function IncidentEditor() {
const response = await fetch(`/api/incidents/${id}/resolve`, { const response = await fetch(`/api/incidents/${id}/resolve`, {
method: "POST", method: "POST",
}); });
if (!response.ok) if (!response.ok) {
throw new Error(`Request failed with HTTP ${response.status}`); throw new Error(`Request failed with HTTP ${response.status}`);
}
await refresh(); await refresh();
} catch (cause) { } catch (cause) {
setError(cause instanceof Error ? cause.message : String(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 active = incidents.filter((incident) => !incident.resolvedAt);
const resolved = incidents const resolved = incidents
.filter((incident) => incident.resolvedAt) .filter((incident) => incident.resolvedAt)
.reverse(); .toReversed();
return ( return (
<main className="status-canvas min-h-[calc(100vh-3.5rem)]"> <main className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-4 py-10 sm:px-6">
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-14"> <header className="flex items-center gap-3">
<div className="mb-8 flex items-start gap-4"> <ShieldAlert className="size-7" />
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-red-500/10 text-red-500"> <h1 className="text-xl font-medium">Incident management</h1>
<ShieldAlert className="size-5" /> </header>
</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 && ( {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} {error}
</div> </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 <form
onSubmit={create} onSubmit={create}
className="h-fit rounded-2xl border border-border bg-card/80 p-5 shadow-sm" className="rounded-xl border border-[#C5CED6] bg-[#EDF1F4] p-4 dark:border-[#242D38] dark:bg-[#242D38]"
>
<h2 className="font-semibold">Create incident</h2>
<label
className="mt-5 block text-xs font-semibold text-muted-foreground"
htmlFor="title"
> >
<h2 className="font-medium">Create incident</h2>
<Label className="mt-5 block text-sm" htmlFor="title">
Title Title
</label> </Label>
<input <Input
required required
id="title" id="title"
name="title" name="title"
maxLength={120} 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" <Label className="mt-4 block text-sm" htmlFor="message">
htmlFor="message"
>
Message Message
</label> </Label>
<textarea <Textarea
required required
id="message" id="message"
name="message" name="message"
maxLength={2000} maxLength={2000}
rows={5} rows={6}
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" className="py-2"
/> />
<label
className="mt-4 block text-xs font-semibold text-muted-foreground" <Label className="mt-4 block text-sm" htmlFor="severity">
htmlFor="severity"
>
Severity Severity
</label> </Label>
<select <Select name="severity" defaultValue="minor">
id="severity" <SelectTrigger id="severity" className="mt-2 w-full">
name="severity" <SelectValue>
defaultValue="minor" {(value) => severityLabels[value as Severity]}
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" </SelectValue>
> </SelectTrigger>
{(["minor", "major", "critical"] as Severity[]).map( <SelectContent className="border!">
(severity) => ( <SelectItem value="minor">Minor</SelectItem>
<option key={severity} value={severity}> <SelectItem value="major">Major</SelectItem>
{severity[0].toUpperCase() + severity.slice(1)} <SelectItem value="critical">Critical</SelectItem>
</option> </SelectContent>
), </Select>
)}
</select>
<Button type="submit" disabled={submitting} className="mt-5 w-full"> <Button type="submit" disabled={submitting} className="mt-5 w-full">
{submitting && <Loader2 className="size-4 animate-spin" />}Publish {submitting ? (
incident <Loader2 className="size-4 animate-spin" />
) : (
<Plus className="size-4" />
)}
Publish incident
</Button> </Button>
</form> </form>
<div> <section aria-labelledby="active-incidents">
<h2 className="mb-3 flex items-center gap-2 font-semibold"> <div className="mb-4 flex items-baseline gap-2">
Active incidents{" "} <h2 id="active-incidents" className="font-medium">
<span className="text-xs text-muted-foreground"> Active incidents
</h2>
<span className="text-sm text-muted-foreground">
{active.length} {active.length}
</span> </span>
</h2> </div>
<div className="space-y-3">
<div className="flex flex-col gap-3">
{loading && ( {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" /> <Loader2 className="size-4 animate-spin" />
Loading incidents Loading incidents
</div> </div>
)} )}
{!loading && active.length === 0 && ( {!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 No active incidents
</div> </div>
)} )}
{active.map((incident) => ( {active.map((incident) => (
<article <article
key={incident.id} 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 className="flex items-start justify-between gap-4">
<div> <div className="min-w-0">
<span className="text-[0.65rem] font-bold uppercase tracking-[0.18em] text-red-500"> <h3 className="font-medium [overflow-wrap:anywhere]">
{incident.severity} {incident.title}
</span> </h3>
<h3 className="mt-1 font-semibold">{incident.title}</h3>
</div> </div>
<Button <Button
type="button"
variant="outline" variant="outline"
size="sm" size="sm"
className="shrink-0"
onClick={() => void resolve(incident.id)} onClick={() => void resolve(incident.id)}
> >
<CheckCircle2 className="size-4" /> <Check className="size-4" />
Resolve Resolve
</Button> </Button>
</div> </div>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground"> <IncidentExcerpt
{incident.message} incident={incident}
</p> 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> </article>
))} ))}
</div> </div>
</section>
</div>
{resolved.length > 0 && ( {resolved.length > 0 && (
<> <section aria-labelledby="resolved-incidents">
<h2 className="mb-3 mt-8 font-semibold">Resolved history</h2> <div className="mb-4 flex items-baseline gap-2">
<div className="space-y-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) => ( {resolved.map((incident) => (
<div <div
key={incident.id} 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"> <div className="min-w-0">
<span className="text-sm font-medium"> <p className="font-medium [overflow-wrap:anywhere]">
{incident.title} {incident.title}
</span> </p>
<span className="text-xs text-muted-foreground"> <IncidentExcerpt incident={incident} lines={1} />
{new Date(incident.resolvedAt!).toLocaleDateString()} </div>
</span> <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> </div>
))} ))}
</div> </div>
</> </section>
)} )}
</div>
</div>
</div>
</main> </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 { import {
AlertTriangle, AlertTriangle,
Check, Check,
ChevronRight,
Clock3, Clock3,
ExternalLink, ExternalLink,
RefreshCw, RefreshCw,
@ -10,7 +11,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useEffect, useRef, useState } from "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 { useStatus } from "../use-status";
import { formatTime } from "../utils"; import { formatTime } from "../utils";
import { import {
@ -30,6 +31,7 @@ export const Route = createFileRoute("/")({ component: Home });
function Home() { function Home() {
const { snapshot } = useStatus(); const { snapshot } = useStatus();
const [showPastIncidents, setShowPastIncidents] = useState(false);
const services = const services =
snapshot?.categories.flatMap((category) => category.services) ?? []; snapshot?.categories.flatMap((category) => category.services) ?? [];
const failures = services.filter( const failures = services.filter(
@ -37,6 +39,11 @@ function Home() {
); );
const activeIncidents = const activeIncidents =
snapshot?.incidents.filter((incident) => !incident.resolvedAt) ?? []; 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 pending = services.some((service) => service.status === "unknown");
const ready = snapshot !== null && !pending; const ready = snapshot !== null && !pending;
const healthy = const healthy =
@ -72,11 +79,13 @@ function Home() {
</div> </div>
</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)]"> <div className="grid w-full grid-cols-1 justify-center gap-6 pb-10 sm:grid-cols-[repeat(auto-fit,24rem)]">
{activeIncidents.map((incident) => ( {activeIncidents.map((incident) => (
<ActiveIncidentCard key={incident.id} incident={incident} /> <IncidentCard key={incident.id} incident={incident} />
))} ))}
</div> </div>
)}
<div className="w-full flex flex-col lg:flex-row! gap-15 items-center justify-center"> <div className="w-full flex flex-col lg:flex-row! gap-15 items-center justify-center">
{snapshot?.categories.map((category) => ( {snapshot?.categories.map((category) => (
@ -103,27 +112,40 @@ function Home() {
))} ))}
</div> </div>
{!!snapshot?.incidents.some((incident) => incident.resolvedAt) && ( {pastIncidents.length > 0 && (
<section className="mt-12" aria-labelledby="resolved-incidents"> <section className="pt-10 w-full flex flex-col items-center">
<h2 id="resolved-incidents" className="mb-4 text-lg font-semibold"> <Button
Recently resolved type="button"
</h2> variant="ghost"
<div className="space-y-3"> aria-expanded={showPastIncidents}
{snapshot.incidents aria-controls="past-incidents"
.filter((incident) => incident.resolvedAt) onClick={() => setShowPastIncidents((visible) => !visible)}
.reverse() >
.slice(0, 5) <ChevronRight
.map((incident) => ( className={cn(
<IncidentCard key={incident.id} incident={incident} resolved /> "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> </div>
)}
</section> </section>
)} )}
</main> </main>
); );
} }
function ActiveIncidentCard({ incident }: { incident: Incident }) { function IncidentCard({ incident }: { incident: Incident }) {
const messageRef = useRef<HTMLParagraphElement>(null); const messageRef = useRef<HTMLParagraphElement>(null);
const [truncated, setTruncated] = useState(false); const [truncated, setTruncated] = useState(false);
@ -173,7 +195,9 @@ function ActiveIncidentCard({ incident }: { incident: Incident }) {
<DialogHeader> <DialogHeader>
<DialogTitle>{incident.title}</DialogTitle> <DialogTitle>{incident.title}</DialogTitle>
<DialogDescription> <DialogDescription>
{severity} incident started {formatTime(incident.createdAt)} {severity} incident{" "}
{incident.resolvedAt ? "resolved" : "started"}{" "}
{formatTime(incident.resolvedAt ?? incident.createdAt)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<p className="max-h-[60vh] overflow-y-auto whitespace-pre-wrap text-foreground [overflow-wrap:anywhere]"> <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]"> <div className="mt-auto flex gap-3 border-t border-[#C5CED6] px-4 py-2 dark:border-[#242D38]">
<p>{severity}</p> <p>{severity}</p>
<p>Started {formatTime(incident.createdAt)}</p> <p>
{incident.resolvedAt ? "Resolved" : "Started"}{" "}
{formatTime(incident.resolvedAt ?? incident.createdAt)}
</p>
</div> </div>
</CardContent> </CardContent>
</article> </article>
@ -260,43 +287,3 @@ function ServiceRow({
</div> </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>
);
}