feat: add notifications system with alert engine, email, scheduler, and traffic services
This commit is contained in:
@@ -14,7 +14,6 @@ import { ContainersPage } from "@/components/pages/ContainersPage"
|
||||
import { MemoryPage } from "@/components/pages/MemoryPage"
|
||||
import { StoragePage } from "@/components/pages/StoragePage"
|
||||
import { NetworkPage } from "@/components/pages/NetworkPage"
|
||||
import { SettingsPage } from "@/components/pages/SettingsPage"
|
||||
import { NotificationsPage } from "@/components/pages/NotificationsPage"
|
||||
import { LoginPage } from "@/components/ui/animated-characters-login-page"
|
||||
import { useServerHealth } from "@/hooks/useServerHealth"
|
||||
@@ -45,12 +44,11 @@ const queryClient = new QueryClient({
|
||||
})
|
||||
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
dashboard: "Dashboard",
|
||||
containers: "Containers",
|
||||
memory: "Memory",
|
||||
storage: "Storage",
|
||||
network: "Network",
|
||||
settings: "Settings",
|
||||
dashboard: "Dashboard",
|
||||
containers: "Containers",
|
||||
memory: "Memory",
|
||||
storage: "Storage",
|
||||
network: "Network",
|
||||
notifications: "Notifications",
|
||||
}
|
||||
|
||||
@@ -136,7 +134,7 @@ function DashboardContent({ activePage, onLogout }: { activePage: string; onLogo
|
||||
const system = data.system
|
||||
const memory = data.memory
|
||||
const disk = data.disk
|
||||
const containers = data.docker ?? []
|
||||
const containers = Array.isArray(data.docker) ? data.docker : []
|
||||
const nginx = data.nginx
|
||||
|
||||
const renderPage = () => {
|
||||
@@ -149,8 +147,6 @@ function DashboardContent({ activePage, onLogout }: { activePage: string; onLogo
|
||||
return <StoragePage disk={disk} />
|
||||
case "network":
|
||||
return <NetworkPage containers={containers} nginx={nginx} />
|
||||
case "settings":
|
||||
return <SettingsPage />
|
||||
case "notifications":
|
||||
return <NotificationsPage />
|
||||
default:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RefreshCw, Bell } from "lucide-react"
|
||||
import { RefreshCw } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Select,
|
||||
@@ -70,13 +70,6 @@ export function Header({
|
||||
<RefreshCw className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="flex size-9 items-center justify-center rounded-lg border transition-colors hover:bg-muted"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
<div className="flex size-9 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
||||
BS
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,15 @@ import {
|
||||
MemoryStick,
|
||||
HardDrive,
|
||||
Wifi,
|
||||
Bell,
|
||||
Settings,
|
||||
HelpCircle,
|
||||
Server,
|
||||
LogOut,
|
||||
Bell,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { useNotificationSummary } from "@/hooks/useNotifications"
|
||||
|
||||
interface NavItem {
|
||||
id: string
|
||||
@@ -20,17 +21,17 @@ interface NavItem {
|
||||
}
|
||||
|
||||
const mainNav: NavItem[] = [
|
||||
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ id: "containers", label: "Containers", icon: Container },
|
||||
{ id: "memory", label: "Memory", icon: MemoryStick },
|
||||
{ id: "storage", label: "Storage", icon: HardDrive },
|
||||
{ id: "network", label: "Network", icon: Wifi },
|
||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ id: "containers", label: "Containers", icon: Container },
|
||||
{ id: "memory", label: "Memory", icon: MemoryStick },
|
||||
{ id: "storage", label: "Storage", icon: HardDrive },
|
||||
{ id: "network", label: "Network", icon: Wifi },
|
||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||
]
|
||||
|
||||
const bottomNav: NavItem[] = [
|
||||
{ id: "settings", label: "Settings", icon: Settings },
|
||||
{ id: "help", label: "Help & Support", icon: HelpCircle },
|
||||
{ id: "settings", label: "Settings", icon: Settings },
|
||||
{ id: "help", label: "Help & Support", icon: HelpCircle },
|
||||
]
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -43,10 +44,12 @@ function NavButton({
|
||||
item,
|
||||
active,
|
||||
onClick,
|
||||
badge,
|
||||
}: {
|
||||
item: NavItem
|
||||
active: boolean
|
||||
onClick: () => void
|
||||
badge?: number
|
||||
}) {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
@@ -60,11 +63,25 @@ function NavButton({
|
||||
)}
|
||||
>
|
||||
<Icon className="size-5 shrink-0" />
|
||||
<span>{item.label}</span>
|
||||
<span className="flex-1 text-left">{item.label}</span>
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<span className={cn(
|
||||
"inline-flex size-5 items-center justify-center rounded-full text-xs font-bold",
|
||||
active ? "bg-primary-foreground text-primary" : "bg-red-500 text-white"
|
||||
)}>
|
||||
{badge > 99 ? "99+" : badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationBadge({ active, onClick, item }: { active: boolean; onClick: () => void; item: NavItem }) {
|
||||
const { data } = useNotificationSummary()
|
||||
const count = data ? data.activeCriticals + data.activeWarnings : 0
|
||||
return <NavButton item={item} active={active} onClick={onClick} badge={count} />
|
||||
}
|
||||
|
||||
export function Sidebar({ activePage, onNavigate, onLogout }: SidebarProps) {
|
||||
return (
|
||||
<aside className="flex h-screen w-60 shrink-0 flex-col border-r bg-sidebar-background">
|
||||
@@ -80,14 +97,23 @@ export function Sidebar({ activePage, onNavigate, onLogout }: SidebarProps) {
|
||||
|
||||
{/* Main nav */}
|
||||
<nav className="flex flex-1 flex-col gap-1 px-3">
|
||||
{mainNav.map((item) => (
|
||||
<NavButton
|
||||
key={item.id}
|
||||
item={item}
|
||||
active={activePage === item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
/>
|
||||
))}
|
||||
{mainNav.map((item) =>
|
||||
item.id === "notifications" ? (
|
||||
<NotificationBadge
|
||||
key={item.id}
|
||||
item={item}
|
||||
active={activePage === item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
/>
|
||||
) : (
|
||||
<NavButton
|
||||
key={item.id}
|
||||
item={item}
|
||||
active={activePage === item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
|
||||
@@ -1,169 +1,551 @@
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
import { AlertTriangle, AlertCircle, Info, CheckCircle2, Mail, MailX, CheckCheck } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { Bell, CheckCheck, Trash2, Mail, Save, Plus, X, AlertTriangle, AlertCircle, Info, Send } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
useNotifications,
|
||||
useAcknowledgeAlert,
|
||||
useDeleteAlert,
|
||||
useClearResolved,
|
||||
useThresholds,
|
||||
useUpdateThresholds,
|
||||
usePreferences,
|
||||
useUpdatePreferences,
|
||||
useSendTestEmail,
|
||||
useSendSampleAlerts,
|
||||
} from "@/hooks/useNotifications"
|
||||
import type { AlertRecord, AlertSeverity, MetricKey, ThresholdConfig, EmailRecipient } from "@/types/notifications"
|
||||
import { METRIC_LABELS } from "@/types/notifications"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface Notification {
|
||||
id: string
|
||||
type: "error" | "warning" | "info" | "recovery"
|
||||
title: string
|
||||
message: string
|
||||
source: string
|
||||
timestamp: string
|
||||
read: boolean
|
||||
emailSent: boolean
|
||||
type Tab = "history" | "thresholds" | "email"
|
||||
|
||||
// ── Severity badge ──
|
||||
function SeverityBadge({ severity }: { severity: AlertSeverity }) {
|
||||
if (severity === "critical") return (
|
||||
<Badge className="bg-red-100 text-red-700 border-red-200 gap-1">
|
||||
<AlertCircle className="size-3" />critical
|
||||
</Badge>
|
||||
)
|
||||
if (severity === "warning") return (
|
||||
<Badge className="bg-amber-100 text-amber-700 border-amber-200 gap-1">
|
||||
<AlertTriangle className="size-3" />warning
|
||||
</Badge>
|
||||
)
|
||||
return (
|
||||
<Badge className="bg-blue-100 text-blue-700 border-blue-200 gap-1">
|
||||
<Info className="size-3" />info
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const TOKEN_KEY = "eventify-auth-token"
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? ""
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, opts: RequestInit = {}) {
|
||||
const res = await fetch(path, {
|
||||
...opts,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
...opts.headers,
|
||||
},
|
||||
})
|
||||
if (!res.ok) throw new Error("Request failed")
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function timeAgo(ts: string): string {
|
||||
const diff = Date.now() - new Date(ts).getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return "Just now"
|
||||
function timeAgo(iso: 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`
|
||||
const days = Math.floor(hrs / 24)
|
||||
return `${days}d ago`
|
||||
return `${Math.floor(hrs / 24)}d ago`
|
||||
}
|
||||
|
||||
const typeConfig = {
|
||||
error: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50 border-red-200", badge: "bg-red-100 text-red-700" },
|
||||
warning: { icon: AlertTriangle, color: "text-amber-500", bg: "bg-amber-50 border-amber-200", badge: "bg-amber-100 text-amber-700" },
|
||||
info: { icon: Info, color: "text-blue-500", bg: "bg-blue-50 border-blue-200", badge: "bg-blue-100 text-blue-700" },
|
||||
recovery: { icon: CheckCircle2, color: "text-green-500", bg: "bg-green-50 border-green-200", badge: "bg-green-100 text-green-700" },
|
||||
}
|
||||
// ── Tab 1: Alert History ──
|
||||
function AlertHistoryTab() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const limit = 20
|
||||
|
||||
export function NotificationsPage() {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const { data, isLoading } = useNotifications(page, limit, statusFilter)
|
||||
const acknowledge = useAcknowledgeAlert()
|
||||
const deleteAlert = useDeleteAlert()
|
||||
const clearResolved = useClearResolved()
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await apiFetch("/api/notifications?limit=100")
|
||||
setNotifications(data)
|
||||
} catch {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const markRead = async (id: string) => {
|
||||
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n))
|
||||
try { await apiFetch(`/api/notifications/${id}/read`, { method: "POST" }) } catch {}
|
||||
}
|
||||
|
||||
const markAllRead = async () => {
|
||||
setNotifications(prev => prev.map(n => ({ ...n, read: true })))
|
||||
try { await apiFetch("/api/notifications/read-all", { method: "POST" }) } catch {}
|
||||
}
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.read).length
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const alerts: AlertRecord[] = data?.alerts ?? []
|
||||
const total = data?.total ?? 0
|
||||
const totalPages = Math.max(1, Math.ceil(total / limit))
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Notifications
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-2 inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">
|
||||
{unreadCount} unread
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">Server alerts and error notifications</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex gap-2">
|
||||
{(["all", "active", "resolved"] as const).map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => { setStatusFilter(s); setPage(1) }}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-md text-sm font-medium transition-colors capitalize",
|
||||
statusFilter === s
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
)}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<CheckCheck className="size-4" />
|
||||
Mark all read
|
||||
</button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => clearResolved.mutate()}
|
||||
disabled={clearResolved.isPending}
|
||||
className="text-red-600 border-red-200 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="size-4 mr-1.5" />
|
||||
Clear Resolved
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Notification List */}
|
||||
{notifications.length === 0 ? (
|
||||
<div className="rounded-xl border bg-card px-5 py-16 text-center">
|
||||
<CheckCircle2 className="mx-auto size-12 text-green-400" />
|
||||
<h3 className="mt-4 font-medium text-foreground">All clear</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">No notifications yet. You'll see alerts here when issues are detected.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{notifications.map(n => {
|
||||
const cfg = typeConfig[n.type]
|
||||
const Icon = cfg.icon
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
onClick={() => !n.read && markRead(n.id)}
|
||||
className={`cursor-pointer rounded-xl border p-4 transition-colors ${
|
||||
n.read ? "bg-card border-border" : `${cfg.bg}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`mt-0.5 ${cfg.color}`}>
|
||||
<Icon className="size-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">{n.title}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase ${cfg.badge}`}>
|
||||
{n.type}
|
||||
</span>
|
||||
{n.emailSent ? (
|
||||
<span title="Email sent"><Mail className="size-3.5 text-green-500" /></span>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-muted-foreground text-sm">Loading…</div>
|
||||
) : alerts.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<Bell className="size-10 mx-auto text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">No alerts found</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Severity</TableHead>
|
||||
<TableHead>Metric</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
<TableHead>Value</TableHead>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{alerts.map(alert => (
|
||||
<TableRow key={alert.id} className={alert.acknowledged ? "opacity-60" : ""}>
|
||||
<TableCell><SeverityBadge severity={alert.severity} /></TableCell>
|
||||
<TableCell className="font-medium text-sm">{METRIC_LABELS[alert.metric]}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground max-w-xs truncate">{alert.message}</TableCell>
|
||||
<TableCell className="text-sm font-mono">{String(alert.value)}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">{timeAgo(alert.firedAt)}</TableCell>
|
||||
<TableCell>
|
||||
{alert.resolvedAt ? (
|
||||
<Badge variant="outline" className="text-green-600 border-green-200">resolved</Badge>
|
||||
) : (
|
||||
<span title="Email not sent"><MailX className="size-3.5 text-muted-foreground" /></span>
|
||||
<Badge variant="outline" className="text-orange-600 border-orange-200">active</Badge>
|
||||
)}
|
||||
{!n.read && (
|
||||
<span className="size-2 rounded-full bg-blue-500" />
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{n.message}</p>
|
||||
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>{n.source}</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(n.timestamp)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{!alert.acknowledged && (
|
||||
<button
|
||||
onClick={() => acknowledge.mutate(alert.id)}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors"
|
||||
title="Acknowledge"
|
||||
>
|
||||
<CheckCheck className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => deleteAlert.mutate(alert.id)}
|
||||
className="p-1.5 rounded hover:bg-red-50 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="size-4 text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>Showing {((page - 1) * limit) + 1}–{Math.min(page * limit, total)} of {total}</span>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="outline" size="sm" disabled={page === 1} onClick={() => setPage(p => p - 1)}>Prev</Button>
|
||||
<Button variant="outline" size="sm" disabled={page === totalPages} onClick={() => setPage(p => p + 1)}>Next</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab 2: Thresholds ──
|
||||
const THRESHOLD_DESCRIPTIONS: Record<MetricKey, string> = {
|
||||
cpu_percent: "CPU utilization percentage",
|
||||
load_avg_1m: "1-minute load average",
|
||||
memory_percent: "RAM usage percentage",
|
||||
disk_percent: "Disk usage percentage",
|
||||
nginx_status: "Fires if Nginx is not active (boolean)",
|
||||
container_stopped: "Fires if any Docker container is not running",
|
||||
connections_total: "Total active TCP connections (DDoS indicator)",
|
||||
connections_per_ip: "Max connections from a single IP (DDoS indicator)",
|
||||
request_rate: "HTTP requests per minute (API abuse detection)",
|
||||
error_rate_5xx: "Server errors (5xx) per minute",
|
||||
}
|
||||
|
||||
function ThresholdsTab() {
|
||||
const { data, isLoading } = useThresholds()
|
||||
const update = useUpdateThresholds()
|
||||
const [local, setLocal] = useState<ThresholdConfig[] | null>(null)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const thresholds: ThresholdConfig[] = local ?? data?.thresholds ?? []
|
||||
const isBooleanMetric = (m: MetricKey) => m === "nginx_status" || m === "container_stopped"
|
||||
|
||||
function handleChange(metric: MetricKey, field: keyof ThresholdConfig, value: unknown) {
|
||||
const base = local ?? data?.thresholds ?? []
|
||||
setLocal(base.map(t => t.metric === metric ? { ...t, [field]: value } : t))
|
||||
setSaved(false)
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!data) return
|
||||
update.mutate(
|
||||
{ thresholds, updatedAt: new Date().toISOString() },
|
||||
{
|
||||
onSuccess: () => { setLocal(null); setSaved(true); setTimeout(() => setSaved(false), 3000) },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="p-8 text-center text-muted-foreground text-sm">Loading…</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{thresholds.map(t => (
|
||||
<Card key={t.metric}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold">{METRIC_LABELS[t.metric]}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`en-${t.metric}`} className="text-xs text-muted-foreground cursor-pointer">Enabled</Label>
|
||||
<Checkbox
|
||||
id={`en-${t.metric}`}
|
||||
checked={t.enabled}
|
||||
onCheckedChange={v => handleChange(t.metric, "enabled", !!v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{THRESHOLD_DESCRIPTIONS[t.metric]}</p>
|
||||
</CardHeader>
|
||||
{!isBooleanMetric(t.metric) && (
|
||||
<CardContent className="pt-0 grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-amber-600">Warning (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0} max={100} step={1}
|
||||
value={t.warning}
|
||||
onChange={e => handleChange(t.metric, "warning", parseFloat(e.target.value))}
|
||||
className="h-8 text-sm"
|
||||
disabled={!t.enabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-red-600">Critical (%)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0} max={100} step={1}
|
||||
value={t.critical}
|
||||
onChange={e => handleChange(t.metric, "critical", parseFloat(e.target.value))}
|
||||
className="h-8 text-sm"
|
||||
disabled={!t.enabled}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={handleSave} disabled={update.isPending} className="gap-2">
|
||||
<Save className="size-4" />
|
||||
{update.isPending ? "Saving…" : "Save Thresholds"}
|
||||
</Button>
|
||||
{saved && <span className="text-sm text-green-600 font-medium">Saved!</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tab 3: Email Settings ──
|
||||
function EmailSettingsTab() {
|
||||
const { data, isLoading } = usePreferences()
|
||||
const update = useUpdatePreferences()
|
||||
const testEmail = useSendTestEmail()
|
||||
const sampleAlerts = useSendSampleAlerts()
|
||||
|
||||
const [local, setLocal] = useState<{ recipients: EmailRecipient[]; cooldownMinutes: number; enabledSeverities: AlertSeverity[] } | null>(null)
|
||||
const [newEmail, setNewEmail] = useState("")
|
||||
const [newName, setNewName] = useState("")
|
||||
const [testTo, setTestTo] = useState("")
|
||||
const [testMsg, setTestMsg] = useState("")
|
||||
const [sampleMsg, setSampleMsg] = useState("")
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
const prefs = local ?? (data ? { recipients: data.recipients, cooldownMinutes: data.cooldownMinutes, enabledSeverities: data.enabledSeverities } : null)
|
||||
|
||||
function setPrefs(patch: Partial<typeof prefs>) {
|
||||
const base = prefs ?? { recipients: [], cooldownMinutes: 15, enabledSeverities: ["critical", "warning", "info"] as AlertSeverity[] }
|
||||
setLocal({ ...base, ...patch })
|
||||
setSaved(false)
|
||||
}
|
||||
|
||||
function addRecipient() {
|
||||
if (!newEmail.trim() || !newName.trim()) return
|
||||
setPrefs({ recipients: [...(prefs?.recipients ?? []), { email: newEmail.trim(), name: newName.trim(), enabled: true }] })
|
||||
setNewEmail(""); setNewName("")
|
||||
}
|
||||
|
||||
function removeRecipient(email: string) {
|
||||
setPrefs({ recipients: (prefs?.recipients ?? []).filter(r => r.email !== email) })
|
||||
}
|
||||
|
||||
function toggleRecipient(email: string) {
|
||||
setPrefs({ recipients: (prefs?.recipients ?? []).map(r => r.email === email ? { ...r, enabled: !r.enabled } : r) })
|
||||
}
|
||||
|
||||
function toggleSeverity(sev: AlertSeverity) {
|
||||
const current = prefs?.enabledSeverities ?? []
|
||||
const next = current.includes(sev) ? current.filter(s => s !== sev) : [...current, sev]
|
||||
setPrefs({ enabledSeverities: next })
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!data || !prefs) return
|
||||
update.mutate(
|
||||
{ ...data, ...prefs, updatedAt: new Date().toISOString() },
|
||||
{ onSuccess: () => { setLocal(null); setSaved(true); setTimeout(() => setSaved(false), 3000) } }
|
||||
)
|
||||
}
|
||||
|
||||
function handleTestEmail() {
|
||||
if (!testTo.trim()) return
|
||||
testEmail.mutate(testTo.trim(), {
|
||||
onSuccess: (r) => setTestMsg(r.message),
|
||||
onError: (e) => setTestMsg(`Error: ${e.message}`),
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading || !prefs) return <div className="p-8 text-center text-muted-foreground text-sm">Loading…</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
{/* Recipients */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">Email Recipients</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{prefs.recipients.map(r => (
|
||||
<div key={r.email} className="flex items-center gap-3">
|
||||
<Checkbox checked={r.enabled} onCheckedChange={() => toggleRecipient(r.email)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{r.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{r.email}</p>
|
||||
</div>
|
||||
<button onClick={() => removeRecipient(r.email)} className="p-1 rounded hover:bg-red-50 transition-colors">
|
||||
<X className="size-4 text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Input
|
||||
placeholder="Name"
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
className="h-8 text-sm w-32"
|
||||
/>
|
||||
<Input
|
||||
placeholder="email@example.com"
|
||||
value={newEmail}
|
||||
onChange={e => setNewEmail(e.target.value)}
|
||||
className="h-8 text-sm flex-1 min-w-40"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={addRecipient} className="h-8 gap-1">
|
||||
<Plus className="size-3.5" />Add
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Alert Severity Filter */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">Email for Severities</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex gap-4">
|
||||
{(["critical", "warning", "info"] as AlertSeverity[]).map(sev => (
|
||||
<div key={sev} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`sev-${sev}`}
|
||||
checked={prefs.enabledSeverities.includes(sev)}
|
||||
onCheckedChange={() => toggleSeverity(sev)}
|
||||
/>
|
||||
<Label htmlFor={`sev-${sev}`} className="capitalize cursor-pointer text-sm">{sev}</Label>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cooldown */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">Alert Cooldown</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">Minimum time between repeated emails for the same metric</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Select
|
||||
value={String(prefs.cooldownMinutes)}
|
||||
onValueChange={v => setPrefs({ cooldownMinutes: parseInt(v) })}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5 minutes</SelectItem>
|
||||
<SelectItem value="10">10 minutes</SelectItem>
|
||||
<SelectItem value="15">15 minutes</SelectItem>
|
||||
<SelectItem value="30">30 minutes</SelectItem>
|
||||
<SelectItem value="60">60 minutes</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Test Email */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">Test SMTP Connection</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Send test to email@example.com"
|
||||
value={testTo}
|
||||
onChange={e => setTestTo(e.target.value)}
|
||||
className="h-8 text-sm flex-1"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={handleTestEmail} disabled={testEmail.isPending} className="h-8 gap-1.5">
|
||||
<Mail className="size-3.5" />
|
||||
{testEmail.isPending ? "Sending…" : "Send Test"}
|
||||
</Button>
|
||||
</div>
|
||||
{testMsg && (
|
||||
<p className={cn("text-sm", testMsg.startsWith("Error") ? "text-red-600" : "text-green-600")}>
|
||||
{testMsg}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Send All Sample Alerts */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-semibold">Preview All Alert Emails</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">Send sample emails for every alert type — Critical, Warning, Info, and Recovery</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Send samples to email@example.com"
|
||||
value={testTo}
|
||||
onChange={e => setTestTo(e.target.value)}
|
||||
className="h-8 text-sm flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!testTo.trim()) return
|
||||
sampleAlerts.mutate(testTo.trim(), {
|
||||
onSuccess: (r) => setSampleMsg(r.message),
|
||||
onError: (e) => setSampleMsg(`Error: ${e.message}`),
|
||||
})
|
||||
}}
|
||||
disabled={sampleAlerts.isPending}
|
||||
className="h-8 gap-1.5"
|
||||
>
|
||||
<Send className="size-3.5" />
|
||||
{sampleAlerts.isPending ? "Sending 7 emails…" : "Send All Samples"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full font-medium">Critical</span>
|
||||
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full font-medium">Warning</span>
|
||||
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full font-medium">Info</span>
|
||||
<span className="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full font-medium">DDoS</span>
|
||||
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full font-medium">API Rate</span>
|
||||
<span className="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full font-medium">5xx Errors</span>
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full font-medium">Recovery</span>
|
||||
</div>
|
||||
{sampleMsg && (
|
||||
<p className={cn("text-sm", sampleMsg.startsWith("Error") ? "text-red-600" : "text-green-600")}>
|
||||
{sampleMsg}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={handleSave} disabled={update.isPending} className="gap-2">
|
||||
<Save className="size-4" />
|
||||
{update.isPending ? "Saving…" : "Save Settings"}
|
||||
</Button>
|
||||
{saved && <span className="text-sm text-green-600 font-medium">Saved!</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Page ──
|
||||
export function NotificationsPage() {
|
||||
const [tab, setTab] = useState<Tab>("history")
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: "history", label: "Alert History" },
|
||||
{ id: "thresholds", label: "Thresholds" },
|
||||
{ id: "email", label: "Email Settings" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{tabs.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={cn(
|
||||
"px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors",
|
||||
tab === t.id
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "history" && <AlertHistoryTab />}
|
||||
{tab === "thresholds" && <ThresholdsTab />}
|
||||
{tab === "email" && <EmailSettingsTab />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import { useEffect, useState, useCallback } from "react"
|
||||
import { UserPlus, Trash2, Shield, Code, Bell, BellOff, Check, X } from "lucide-react"
|
||||
|
||||
interface MonitorUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: "developer" | "server-admin"
|
||||
notifyOnError: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const TOKEN_KEY = "eventify-auth-token"
|
||||
|
||||
function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? ""
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, opts: RequestInit = {}) {
|
||||
const res = await fetch(path, {
|
||||
...opts,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${getToken()}`,
|
||||
...opts.headers,
|
||||
},
|
||||
})
|
||||
if (!res.ok) throw new Error((await res.json()).error ?? "Request failed")
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const [users, setUsers] = useState<MonitorUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [form, setForm] = useState({ email: "", name: "", role: "developer" as MonitorUser["role"], notifyOnError: true })
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await apiFetch("/api/users")
|
||||
setUsers(data)
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!form.email || !form.name) { setError("Email and name are required"); return }
|
||||
try {
|
||||
setError("")
|
||||
await apiFetch("/api/users", { method: "POST", body: JSON.stringify(form) })
|
||||
setShowForm(false)
|
||||
setForm({ email: "", name: "", role: "developer", notifyOnError: true })
|
||||
load()
|
||||
} catch (e) { setError((e as Error).message) }
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (!confirm(`Remove ${name} from the user list?`)) return
|
||||
try {
|
||||
await apiFetch(`/api/users/${id}`, { method: "DELETE" })
|
||||
load()
|
||||
} catch (e) { setError((e as Error).message) }
|
||||
}
|
||||
|
||||
const toggleNotify = async (id: string) => {
|
||||
const user = users.find(u => u.id === id)
|
||||
if (!user) return
|
||||
setUsers(users.map(u => u.id === id ? { ...u, notifyOnError: !u.notifyOnError } : u))
|
||||
try {
|
||||
await apiFetch(`/api/users/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ notifyOnError: !user.notifyOnError }),
|
||||
})
|
||||
} catch (e) {
|
||||
load() // revert on error
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const roleIcon = (role: string) =>
|
||||
role === "server-admin"
|
||||
? <Shield className="size-4 text-amber-500" />
|
||||
: <Code className="size-4 text-blue-500" />
|
||||
|
||||
const roleBadge = (role: string) => (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
role === "server-admin" ? "bg-amber-100 text-amber-700" : "bg-blue-100 text-blue-700"
|
||||
}`}>
|
||||
{roleIcon(role)}
|
||||
{role === "server-admin" ? "Server Admin" : "Developer"}
|
||||
</span>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">User Management</h2>
|
||||
<p className="text-sm text-muted-foreground">Manage who can access this dashboard and receive error alerts</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<UserPlus className="size-4" />
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError("")} className="ml-2 font-medium underline">Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add User Form */}
|
||||
{showForm && (
|
||||
<div className="rounded-xl border bg-card p-5 space-y-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">New User</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">Name</label>
|
||||
<input
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
placeholder="John Doe"
|
||||
value={form.name}
|
||||
onChange={e => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">Email</label>
|
||||
<input
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
placeholder="john@example.com"
|
||||
value={form.email}
|
||||
onChange={e => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">Role</label>
|
||||
<select
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm focus:border-primary focus:outline-none"
|
||||
value={form.role}
|
||||
onChange={e => setForm({ ...form, role: e.target.value as MonitorUser["role"] })}
|
||||
>
|
||||
<option value="developer">Developer</option>
|
||||
<option value="server-admin">Server Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.notifyOnError}
|
||||
onChange={e => setForm({ ...form, notifyOnError: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
Receive error alerts
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleAdd} className="inline-flex items-center gap-1 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90">
|
||||
<Check className="size-4" /> Add
|
||||
</button>
|
||||
<button onClick={() => { setShowForm(false); setError("") }} className="inline-flex items-center gap-1 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted">
|
||||
<X className="size-4" /> Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User List */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border bg-card">
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-4 border-b px-5 py-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<span>User</span>
|
||||
<span>Role</span>
|
||||
<span>Alerts</span>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
{users.map(u => (
|
||||
<div key={u.id} className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-4 border-b px-5 py-4 last:border-b-0">
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{u.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{u.email}</div>
|
||||
</div>
|
||||
<div>{roleBadge(u.role)}</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggleNotify(u.id)}
|
||||
className={`flex size-8 items-center justify-center rounded-lg transition-colors ${
|
||||
u.notifyOnError ? "text-green-600 hover:bg-green-50" : "text-muted-foreground hover:bg-muted"
|
||||
}`}
|
||||
title={u.notifyOnError ? "Receiving alerts" : "Alerts disabled"}
|
||||
>
|
||||
{u.notifyOnError ? <Bell className="size-4" /> : <BellOff className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => handleDelete(u.id, u.name)}
|
||||
className="flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-red-50 hover:text-red-600"
|
||||
title="Remove user"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<div className="px-5 py-12 text-center text-sm text-muted-foreground">
|
||||
No users yet. Add your first user above.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
148
client/src/hooks/useNotifications.ts
Normal file
148
client/src/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import type {
|
||||
AlertsResponse,
|
||||
ThresholdsConfig,
|
||||
NotificationPreferences,
|
||||
NotificationSummary,
|
||||
} from "@/types/notifications"
|
||||
|
||||
const TOKEN_KEY = "eventify-auth-token"
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
return { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }
|
||||
}
|
||||
|
||||
async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, { ...options, headers: authHeaders() })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: res.statusText }))
|
||||
throw new Error(err.error ?? `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// ── Alert list ──
|
||||
|
||||
export function useNotifications(page = 1, limit = 20, status = "all") {
|
||||
return useQuery<AlertsResponse>({
|
||||
queryKey: ["notifications", page, limit, status],
|
||||
queryFn: () =>
|
||||
apiFetch<AlertsResponse>(
|
||||
`/api/notifications?page=${page}&limit=${limit}&status=${status}`
|
||||
),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Summary (used for sidebar badge) ──
|
||||
|
||||
export function useNotificationSummary() {
|
||||
return useQuery<NotificationSummary>({
|
||||
queryKey: ["notifications-summary"],
|
||||
queryFn: () => apiFetch<NotificationSummary>("/api/notifications/summary"),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Thresholds ──
|
||||
|
||||
export function useThresholds() {
|
||||
return useQuery<ThresholdsConfig>({
|
||||
queryKey: ["notifications-thresholds"],
|
||||
queryFn: () => apiFetch<ThresholdsConfig>("/api/notifications/thresholds"),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateThresholds() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: ThresholdsConfig) =>
|
||||
apiFetch<ThresholdsConfig>("/api/notifications/thresholds", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["notifications-thresholds"] }),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Preferences ──
|
||||
|
||||
export function usePreferences() {
|
||||
return useQuery<NotificationPreferences>({
|
||||
queryKey: ["notifications-preferences"],
|
||||
queryFn: () => apiFetch<NotificationPreferences>("/api/notifications/preferences"),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdatePreferences() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (data: NotificationPreferences) =>
|
||||
apiFetch<NotificationPreferences>("/api/notifications/preferences", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["notifications-preferences"] }),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Alert mutations ──
|
||||
|
||||
export function useAcknowledgeAlert() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/notifications/${id}/acknowledge`, { method: "PATCH" }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["notifications"] })
|
||||
qc.invalidateQueries({ queryKey: ["notifications-summary"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteAlert() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/notifications/${id}`, { method: "DELETE" }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["notifications"] })
|
||||
qc.invalidateQueries({ queryKey: ["notifications-summary"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useClearResolved() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch<{ success: boolean; cleared: number }>("/api/notifications/clear", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["notifications"] })
|
||||
qc.invalidateQueries({ queryKey: ["notifications-summary"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSendTestEmail() {
|
||||
return useMutation({
|
||||
mutationFn: (email: string) =>
|
||||
apiFetch<{ success: boolean; message: string }>("/api/notifications/test-email", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export function useSendSampleAlerts() {
|
||||
return useMutation({
|
||||
mutationFn: (email: string) =>
|
||||
apiFetch<{ success: boolean; message: string; types: string[] }>("/api/notifications/test-all", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
})
|
||||
}
|
||||
80
client/src/types/notifications.ts
Normal file
80
client/src/types/notifications.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// ─── Client-side Notification Types ───
|
||||
|
||||
export type AlertSeverity = 'critical' | 'warning' | 'info';
|
||||
|
||||
export type MetricKey =
|
||||
| 'cpu_percent'
|
||||
| 'load_avg_1m'
|
||||
| 'memory_percent'
|
||||
| 'disk_percent'
|
||||
| 'nginx_status'
|
||||
| 'container_stopped'
|
||||
| 'connections_total'
|
||||
| 'connections_per_ip'
|
||||
| 'request_rate'
|
||||
| 'error_rate_5xx';
|
||||
|
||||
export interface ThresholdConfig {
|
||||
metric: MetricKey;
|
||||
warning: number;
|
||||
critical: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ThresholdsConfig {
|
||||
thresholds: ThresholdConfig[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AlertRecord {
|
||||
id: string;
|
||||
metric: MetricKey;
|
||||
severity: AlertSeverity;
|
||||
message: string;
|
||||
value: number | string;
|
||||
threshold: number | string;
|
||||
firedAt: string;
|
||||
resolvedAt: string | null;
|
||||
emailSent: boolean;
|
||||
acknowledged: boolean;
|
||||
}
|
||||
|
||||
export interface EmailRecipient {
|
||||
email: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationPreferences {
|
||||
recipients: EmailRecipient[];
|
||||
cooldownMinutes: number;
|
||||
enabledSeverities: AlertSeverity[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface NotificationSummary {
|
||||
activeWarnings: number;
|
||||
activeCriticals: number;
|
||||
unacknowledged: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface AlertsResponse {
|
||||
alerts: AlertRecord[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export const METRIC_LABELS: Record<MetricKey, string> = {
|
||||
cpu_percent: 'CPU Usage',
|
||||
load_avg_1m: 'Load Average (1m)',
|
||||
memory_percent: 'Memory Usage',
|
||||
disk_percent: 'Disk Usage',
|
||||
nginx_status: 'Nginx Status',
|
||||
container_stopped: 'Container Stopped',
|
||||
connections_total: 'Total Connections',
|
||||
connections_per_ip: 'Connections per IP',
|
||||
request_rate: 'Request Rate',
|
||||
error_rate_5xx: '5xx Error Rate',
|
||||
};
|
||||
@@ -43,12 +43,21 @@ export interface NginxStatus {
|
||||
checkedAt: string
|
||||
}
|
||||
|
||||
export interface TrafficInfo {
|
||||
totalConnections: number
|
||||
topIpConnections: number
|
||||
topIp: string
|
||||
requestsPerMinute: number
|
||||
errors5xxPerMinute: number
|
||||
}
|
||||
|
||||
export interface ServerOverview {
|
||||
system: SystemHealth | null
|
||||
memory: MemoryStats | null
|
||||
disk: DiskStats | null
|
||||
docker: ContainerStats[] | null
|
||||
nginx: NginxStatus | null
|
||||
traffic: TrafficInfo | null
|
||||
errors: Record<string, string>
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user