generated from methanium/template
Big updates
This commit is contained in:
parent
de54eb0b90
commit
56ae701d02
5 changed files with 401 additions and 214 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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
|
||||
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>
|
||||
)}
|
||||
|
||||
{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 items-start gap-8 lg:grid-cols-[20rem_minmax(0,1fr)]">
|
||||
<form
|
||||
onSubmit={create}
|
||||
className="rounded-xl border border-[#C5CED6] bg-[#EDF1F4] p-4 dark:border-[#242D38] dark:bg-[#242D38]"
|
||||
>
|
||||
<h2 className="font-medium">Create incident</h2>
|
||||
|
||||
<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>
|
||||
<Label className="mt-5 block text-sm" htmlFor="title">
|
||||
Title
|
||||
</Label>
|
||||
<Input
|
||||
required
|
||||
id="title"
|
||||
name="title"
|
||||
maxLength={120}
|
||||
className="mt-2 w-full"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-3 flex items-center gap-2 font-semibold">
|
||||
Active incidents{" "}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{active.length}
|
||||
</span>
|
||||
<Label className="mt-4 block text-sm" htmlFor="message">
|
||||
Message
|
||||
</Label>
|
||||
<Textarea
|
||||
required
|
||||
id="message"
|
||||
name="message"
|
||||
maxLength={2000}
|
||||
rows={6}
|
||||
className="py-2"
|
||||
/>
|
||||
|
||||
<Label className="mt-4 block text-sm" htmlFor="severity">
|
||||
Severity
|
||||
</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" />
|
||||
) : (
|
||||
<Plus className="size-4" />
|
||||
)}
|
||||
Publish incident
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<section aria-labelledby="active-incidents">
|
||||
<div className="mb-4 flex items-baseline gap-2">
|
||||
<h2 id="active-incidents" className="font-medium">
|
||||
Active incidents
|
||||
</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"
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{active.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{loading && (
|
||||
<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-[#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-[#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>
|
||||
</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>
|
||||
))}
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{resolved.length > 0 && (
|
||||
<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="flex flex-col gap-1 py-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium [overflow-wrap:anywhere]">
|
||||
{incident.title}
|
||||
</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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<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} />
|
||||
))}
|
||||
</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) => (
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue