diff --git a/.gitignore b/.gitignore index 5ef6a52..b1a4d73 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# alert persistence +/data/alerts.json diff --git a/package-lock.json b/package-lock.json index 416649a..597e581 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "oversnitch-tmp", + "name": "oversnitch", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "oversnitch-tmp", + "name": "oversnitch", "version": "0.1.0", "dependencies": { "next": "16.2.3", diff --git a/package.json b/package.json index 09307d1..95a37ae 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "oversnitch-tmp", + "name": "oversnitch", "version": "0.1.0", "private": true, "scripts": { diff --git a/src/app/alerts/[id]/AlertDetail.tsx b/src/app/alerts/[id]/AlertDetail.tsx new file mode 100644 index 0000000..6437e0b --- /dev/null +++ b/src/app/alerts/[id]/AlertDetail.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Alert, AlertSeverity } from "@/lib/types"; + +const severityAccent: Record = { + danger: "border-l-red-500", + warning: "border-l-yellow-500", + info: "border-l-blue-500", +}; + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +function shortDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + month: "short", day: "numeric", hour: "numeric", minute: "2-digit", + }); +} + +export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) { + const [alert, setAlert] = useState(initialAlert); + const [actionLoading, setActionLoading] = useState(false); + const [commentText, setCommentText] = useState(""); + const [commentLoading, setCommentLoading] = useState(false); + const [error, setError] = useState(null); + + async function toggleStatus() { + setActionLoading(true); + setError(null); + try { + const newStatus = alert.status === "open" ? "closed" : "open"; + const res = await fetch(`/api/alerts/${alert.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: newStatus }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + setAlert(await res.json()); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setActionLoading(false); + } + } + + async function submitComment(e: React.FormEvent) { + e.preventDefault(); + if (!commentText.trim()) return; + setCommentLoading(true); + setError(null); + try { + const res = await fetch(`/api/alerts/${alert.id}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: commentText.trim() }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const comment = await res.json(); + setAlert((prev) => ({ ...prev, comments: [...prev.comments, comment] })); + setCommentText(""); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setCommentLoading(false); + } + } + + const isOpen = alert.status === "open"; + const isResolved = alert.closeReason === "resolved"; + const statusTime = isOpen ? alert.firstSeen : (alert.closedAt ?? alert.firstSeen); + + return ( +
+ + + + + + All Alerts + + + {error && ( +
+ {error} +
+ )} + + {/* Alert card */} +
+ + {/* Status row */} +
+ + {isOpen && } + {isOpen ? "Open" : isResolved ? "Auto-resolved" : "Closed"} + · + {timeAgo(statusTime)} + + + +
+ + {/* Title + description */} +
+

{alert.title}

+

{alert.description}

+
+
+ + {/* Notes */} +
+

Notes

+ + {alert.comments.length === 0 && ( +

No notes yet.

+ )} + + {alert.comments.map((c) => ( +
+

{shortDate(c.createdAt)}

+

{c.body}

+
+ ))} + +
+