generated from methanium/template
Add basic status page
This commit is contained in:
parent
0e4d64e6f2
commit
de54eb0b90
43 changed files with 6080 additions and 244 deletions
250
apps/frontend/src/routes/edit.tsx
Normal file
250
apps/frontend/src/routes/edit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue