feat: add notifications system with alert engine, email, scheduler, and traffic services
This commit is contained in:
@@ -3,17 +3,15 @@
|
|||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"runtimeExecutable": "npx",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["tsx", "src/index.ts"],
|
"runtimeArgs": ["run", "--workspace=server", "dev"],
|
||||||
"port": 3001,
|
"port": 3002
|
||||||
"cwd": "server"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"runtimeExecutable": "npx",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": ["vite", "--port", "5173"],
|
"runtimeArgs": ["run", "--workspace=client", "dev"],
|
||||||
"port": 5173,
|
"port": 5173
|
||||||
"cwd": "client"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { ContainersPage } from "@/components/pages/ContainersPage"
|
|||||||
import { MemoryPage } from "@/components/pages/MemoryPage"
|
import { MemoryPage } from "@/components/pages/MemoryPage"
|
||||||
import { StoragePage } from "@/components/pages/StoragePage"
|
import { StoragePage } from "@/components/pages/StoragePage"
|
||||||
import { NetworkPage } from "@/components/pages/NetworkPage"
|
import { NetworkPage } from "@/components/pages/NetworkPage"
|
||||||
import { SettingsPage } from "@/components/pages/SettingsPage"
|
|
||||||
import { NotificationsPage } from "@/components/pages/NotificationsPage"
|
import { NotificationsPage } from "@/components/pages/NotificationsPage"
|
||||||
import { LoginPage } from "@/components/ui/animated-characters-login-page"
|
import { LoginPage } from "@/components/ui/animated-characters-login-page"
|
||||||
import { useServerHealth } from "@/hooks/useServerHealth"
|
import { useServerHealth } from "@/hooks/useServerHealth"
|
||||||
@@ -45,12 +44,11 @@ const queryClient = new QueryClient({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const PAGE_TITLES: Record<string, string> = {
|
const PAGE_TITLES: Record<string, string> = {
|
||||||
dashboard: "Dashboard",
|
dashboard: "Dashboard",
|
||||||
containers: "Containers",
|
containers: "Containers",
|
||||||
memory: "Memory",
|
memory: "Memory",
|
||||||
storage: "Storage",
|
storage: "Storage",
|
||||||
network: "Network",
|
network: "Network",
|
||||||
settings: "Settings",
|
|
||||||
notifications: "Notifications",
|
notifications: "Notifications",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +134,7 @@ function DashboardContent({ activePage, onLogout }: { activePage: string; onLogo
|
|||||||
const system = data.system
|
const system = data.system
|
||||||
const memory = data.memory
|
const memory = data.memory
|
||||||
const disk = data.disk
|
const disk = data.disk
|
||||||
const containers = data.docker ?? []
|
const containers = Array.isArray(data.docker) ? data.docker : []
|
||||||
const nginx = data.nginx
|
const nginx = data.nginx
|
||||||
|
|
||||||
const renderPage = () => {
|
const renderPage = () => {
|
||||||
@@ -149,8 +147,6 @@ function DashboardContent({ activePage, onLogout }: { activePage: string; onLogo
|
|||||||
return <StoragePage disk={disk} />
|
return <StoragePage disk={disk} />
|
||||||
case "network":
|
case "network":
|
||||||
return <NetworkPage containers={containers} nginx={nginx} />
|
return <NetworkPage containers={containers} nginx={nginx} />
|
||||||
case "settings":
|
|
||||||
return <SettingsPage />
|
|
||||||
case "notifications":
|
case "notifications":
|
||||||
return <NotificationsPage />
|
return <NotificationsPage />
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RefreshCw, Bell } from "lucide-react"
|
import { RefreshCw } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -70,13 +70,6 @@ export function Header({
|
|||||||
<RefreshCw className="size-4 text-muted-foreground" />
|
<RefreshCw className="size-4 text-muted-foreground" />
|
||||||
</button>
|
</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">
|
<div className="flex size-9 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
||||||
BS
|
BS
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import {
|
|||||||
MemoryStick,
|
MemoryStick,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Wifi,
|
Wifi,
|
||||||
Bell,
|
|
||||||
Settings,
|
Settings,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Server,
|
Server,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Bell,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { useNotificationSummary } from "@/hooks/useNotifications"
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -20,17 +21,17 @@ interface NavItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mainNav: NavItem[] = [
|
const mainNav: NavItem[] = [
|
||||||
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
|
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
{ id: "containers", label: "Containers", icon: Container },
|
{ id: "containers", label: "Containers", icon: Container },
|
||||||
{ id: "memory", label: "Memory", icon: MemoryStick },
|
{ id: "memory", label: "Memory", icon: MemoryStick },
|
||||||
{ id: "storage", label: "Storage", icon: HardDrive },
|
{ id: "storage", label: "Storage", icon: HardDrive },
|
||||||
{ id: "network", label: "Network", icon: Wifi },
|
{ id: "network", label: "Network", icon: Wifi },
|
||||||
{ id: "notifications", label: "Notifications", icon: Bell },
|
{ id: "notifications", label: "Notifications", icon: Bell },
|
||||||
]
|
]
|
||||||
|
|
||||||
const bottomNav: NavItem[] = [
|
const bottomNav: NavItem[] = [
|
||||||
{ id: "settings", label: "Settings", icon: Settings },
|
{ id: "settings", label: "Settings", icon: Settings },
|
||||||
{ id: "help", label: "Help & Support", icon: HelpCircle },
|
{ id: "help", label: "Help & Support", icon: HelpCircle },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@@ -43,10 +44,12 @@ function NavButton({
|
|||||||
item,
|
item,
|
||||||
active,
|
active,
|
||||||
onClick,
|
onClick,
|
||||||
|
badge,
|
||||||
}: {
|
}: {
|
||||||
item: NavItem
|
item: NavItem
|
||||||
active: boolean
|
active: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
|
badge?: number
|
||||||
}) {
|
}) {
|
||||||
const Icon = item.icon
|
const Icon = item.icon
|
||||||
return (
|
return (
|
||||||
@@ -60,11 +63,25 @@ function NavButton({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="size-5 shrink-0" />
|
<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>
|
</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) {
|
export function Sidebar({ activePage, onNavigate, onLogout }: SidebarProps) {
|
||||||
return (
|
return (
|
||||||
<aside className="flex h-screen w-60 shrink-0 flex-col border-r bg-sidebar-background">
|
<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 */}
|
{/* Main nav */}
|
||||||
<nav className="flex flex-1 flex-col gap-1 px-3">
|
<nav className="flex flex-1 flex-col gap-1 px-3">
|
||||||
{mainNav.map((item) => (
|
{mainNav.map((item) =>
|
||||||
<NavButton
|
item.id === "notifications" ? (
|
||||||
key={item.id}
|
<NotificationBadge
|
||||||
item={item}
|
key={item.id}
|
||||||
active={activePage === item.id}
|
item={item}
|
||||||
onClick={() => onNavigate(item.id)}
|
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" />
|
<Separator className="my-4" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,169 +1,551 @@
|
|||||||
import { useEffect, useState, useCallback } from "react"
|
import { useState } from "react"
|
||||||
import { AlertTriangle, AlertCircle, Info, CheckCircle2, Mail, MailX, CheckCheck } from "lucide-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 {
|
type Tab = "history" | "thresholds" | "email"
|
||||||
id: string
|
|
||||||
type: "error" | "warning" | "info" | "recovery"
|
// ── Severity badge ──
|
||||||
title: string
|
function SeverityBadge({ severity }: { severity: AlertSeverity }) {
|
||||||
message: string
|
if (severity === "critical") return (
|
||||||
source: string
|
<Badge className="bg-red-100 text-red-700 border-red-200 gap-1">
|
||||||
timestamp: string
|
<AlertCircle className="size-3" />critical
|
||||||
read: boolean
|
</Badge>
|
||||||
emailSent: boolean
|
)
|
||||||
|
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 timeAgo(iso: string) {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
function getToken() {
|
const mins = Math.floor(diff / 60_000)
|
||||||
return localStorage.getItem(TOKEN_KEY) ?? ""
|
if (mins < 1) return "just now"
|
||||||
}
|
|
||||||
|
|
||||||
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"
|
|
||||||
if (mins < 60) return `${mins}m ago`
|
if (mins < 60) return `${mins}m ago`
|
||||||
const hrs = Math.floor(mins / 60)
|
const hrs = Math.floor(mins / 60)
|
||||||
if (hrs < 24) return `${hrs}h ago`
|
if (hrs < 24) return `${hrs}h ago`
|
||||||
const days = Math.floor(hrs / 24)
|
return `${Math.floor(hrs / 24)}d ago`
|
||||||
return `${days}d ago`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeConfig = {
|
// ── Tab 1: Alert History ──
|
||||||
error: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50 border-red-200", badge: "bg-red-100 text-red-700" },
|
function AlertHistoryTab() {
|
||||||
warning: { icon: AlertTriangle, color: "text-amber-500", bg: "bg-amber-50 border-amber-200", badge: "bg-amber-100 text-amber-700" },
|
const [page, setPage] = useState(1)
|
||||||
info: { icon: Info, color: "text-blue-500", bg: "bg-blue-50 border-blue-200", badge: "bg-blue-100 text-blue-700" },
|
const [statusFilter, setStatusFilter] = useState("all")
|
||||||
recovery: { icon: CheckCircle2, color: "text-green-500", bg: "bg-green-50 border-green-200", badge: "bg-green-100 text-green-700" },
|
const limit = 20
|
||||||
}
|
|
||||||
|
|
||||||
export function NotificationsPage() {
|
const { data, isLoading } = useNotifications(page, limit, statusFilter)
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
const acknowledge = useAcknowledgeAlert()
|
||||||
const [loading, setLoading] = useState(true)
|
const deleteAlert = useDeleteAlert()
|
||||||
|
const clearResolved = useClearResolved()
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const alerts: AlertRecord[] = data?.alerts ?? []
|
||||||
try {
|
const total = data?.total ?? 0
|
||||||
setLoading(true)
|
const totalPages = Math.max(1, Math.ceil(total / limit))
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-3xl space-y-6">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex gap-2">
|
||||||
<div>
|
{(["all", "active", "resolved"] as const).map(s => (
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
<button
|
||||||
Notifications
|
key={s}
|
||||||
{unreadCount > 0 && (
|
onClick={() => { setStatusFilter(s); setPage(1) }}
|
||||||
<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">
|
className={cn(
|
||||||
{unreadCount} unread
|
"px-3 py-1.5 rounded-md text-sm font-medium transition-colors capitalize",
|
||||||
</span>
|
statusFilter === s
|
||||||
)}
|
? "bg-primary text-primary-foreground"
|
||||||
</h2>
|
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||||
<p className="text-sm text-muted-foreground">Server alerts and error notifications</p>
|
)}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{unreadCount > 0 && (
|
<Button
|
||||||
<button
|
variant="outline"
|
||||||
onClick={markAllRead}
|
size="sm"
|
||||||
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted"
|
onClick={() => clearResolved.mutate()}
|
||||||
>
|
disabled={clearResolved.isPending}
|
||||||
<CheckCheck className="size-4" />
|
className="text-red-600 border-red-200 hover:bg-red-50"
|
||||||
Mark all read
|
>
|
||||||
</button>
|
<Trash2 className="size-4 mr-1.5" />
|
||||||
)}
|
Clear Resolved
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notification List */}
|
<Card>
|
||||||
{notifications.length === 0 ? (
|
<CardContent className="p-0">
|
||||||
<div className="rounded-xl border bg-card px-5 py-16 text-center">
|
{isLoading ? (
|
||||||
<CheckCircle2 className="mx-auto size-12 text-green-400" />
|
<div className="p-8 text-center text-muted-foreground text-sm">Loading…</div>
|
||||||
<h3 className="mt-4 font-medium text-foreground">All clear</h3>
|
) : alerts.length === 0 ? (
|
||||||
<p className="mt-1 text-sm text-muted-foreground">No notifications yet. You'll see alerts here when issues are detected.</p>
|
<div className="p-12 text-center">
|
||||||
</div>
|
<Bell className="size-10 mx-auto text-muted-foreground/40 mb-3" />
|
||||||
) : (
|
<p className="text-sm text-muted-foreground">No alerts found</p>
|
||||||
<div className="space-y-3">
|
</div>
|
||||||
{notifications.map(n => {
|
) : (
|
||||||
const cfg = typeConfig[n.type]
|
<Table>
|
||||||
const Icon = cfg.icon
|
<TableHeader>
|
||||||
return (
|
<TableRow>
|
||||||
<div
|
<TableHead>Severity</TableHead>
|
||||||
key={n.id}
|
<TableHead>Metric</TableHead>
|
||||||
onClick={() => !n.read && markRead(n.id)}
|
<TableHead>Message</TableHead>
|
||||||
className={`cursor-pointer rounded-xl border p-4 transition-colors ${
|
<TableHead>Value</TableHead>
|
||||||
n.read ? "bg-card border-border" : `${cfg.bg}`
|
<TableHead>Time</TableHead>
|
||||||
}`}
|
<TableHead>Status</TableHead>
|
||||||
>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
<div className="flex items-start gap-3">
|
</TableRow>
|
||||||
<div className={`mt-0.5 ${cfg.color}`}>
|
</TableHeader>
|
||||||
<Icon className="size-5" />
|
<TableBody>
|
||||||
</div>
|
{alerts.map(alert => (
|
||||||
<div className="flex-1 min-w-0">
|
<TableRow key={alert.id} className={alert.acknowledged ? "opacity-60" : ""}>
|
||||||
<div className="flex items-center gap-2">
|
<TableCell><SeverityBadge severity={alert.severity} /></TableCell>
|
||||||
<span className="font-medium text-foreground">{n.title}</span>
|
<TableCell className="font-medium text-sm">{METRIC_LABELS[alert.metric]}</TableCell>
|
||||||
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase ${cfg.badge}`}>
|
<TableCell className="text-sm text-muted-foreground max-w-xs truncate">{alert.message}</TableCell>
|
||||||
{n.type}
|
<TableCell className="text-sm font-mono">{String(alert.value)}</TableCell>
|
||||||
</span>
|
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">{timeAgo(alert.firedAt)}</TableCell>
|
||||||
{n.emailSent ? (
|
<TableCell>
|
||||||
<span title="Email sent"><Mail className="size-3.5 text-green-500" /></span>
|
{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 && (
|
</TableCell>
|
||||||
<span className="size-2 rounded-full bg-blue-500" />
|
<TableCell className="text-right">
|
||||||
)}
|
<div className="flex justify-end gap-1">
|
||||||
</div>
|
{!alert.acknowledged && (
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{n.message}</p>
|
<button
|
||||||
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
|
onClick={() => acknowledge.mutate(alert.id)}
|
||||||
<span>{n.source}</span>
|
className="p-1.5 rounded hover:bg-muted transition-colors"
|
||||||
<span>·</span>
|
title="Acknowledge"
|
||||||
<span>{timeAgo(n.timestamp)}</span>
|
>
|
||||||
</div>
|
<CheckCheck className="size-4 text-muted-foreground" />
|
||||||
</div>
|
</button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
<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>
|
||||||
)}
|
)}
|
||||||
</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
|
checkedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TrafficInfo {
|
||||||
|
totalConnections: number
|
||||||
|
topIpConnections: number
|
||||||
|
topIp: string
|
||||||
|
requestsPerMinute: number
|
||||||
|
errors5xxPerMinute: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerOverview {
|
export interface ServerOverview {
|
||||||
system: SystemHealth | null
|
system: SystemHealth | null
|
||||||
memory: MemoryStats | null
|
memory: MemoryStats | null
|
||||||
disk: DiskStats | null
|
disk: DiskStats | null
|
||||||
docker: ContainerStats[] | null
|
docker: ContainerStats[] | null
|
||||||
nginx: NginxStatus | null
|
nginx: NginxStatus | null
|
||||||
|
traffic: TrafficInfo | null
|
||||||
errors: Record<string, string>
|
errors: Record<string, string>
|
||||||
timestamp: string
|
timestamp: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,23 @@ services:
|
|||||||
- SSH_USER=ubuntu
|
- SSH_USER=ubuntu
|
||||||
- SSH_KEY_PATH=/root/.ssh/id_rsa
|
- SSH_KEY_PATH=/root/.ssh/id_rsa
|
||||||
- PORT=3002
|
- PORT=3002
|
||||||
|
- SMTP_HOST=mail.bshtech.net
|
||||||
|
- SMTP_PORT=587
|
||||||
|
- SMTP_USER=noreply@bshtech.net
|
||||||
|
- SMTP_PASS=Ev3ntifyN0Reply2026
|
||||||
|
- DATA_DIR=/app/data
|
||||||
|
- APP_URL=https://status.eventifyplus.com
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- /home/ubuntu/.ssh/id_rsa:/root/.ssh/id_rsa:ro
|
- /home/ubuntu/.ssh/id_rsa:/root/.ssh/id_rsa:ro
|
||||||
|
- monitor-data:/app/data
|
||||||
|
networks:
|
||||||
|
- eventify-net
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
monitor-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
eventify-net:
|
||||||
|
external: true
|
||||||
|
|||||||
51
package-lock.json
generated
51
package-lock.json
generated
@@ -3016,6 +3016,23 @@
|
|||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node-cron": {
|
||||||
|
"version": "3.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
|
||||||
|
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "6.4.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.23.tgz",
|
||||||
|
"integrity": "sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||||
@@ -4451,6 +4468,27 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-cron": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"uuid": "8.3.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "6.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||||
|
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -5305,6 +5343,15 @@
|
|||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
@@ -5456,12 +5503,16 @@
|
|||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"node-cron": "^3.0.0",
|
||||||
|
"nodemailer": "^6.9.0",
|
||||||
"ssh2": "^1.16.0"
|
"ssh2": "^1.16.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node-cron": "^3.0.0",
|
||||||
|
"@types/nodemailer": "^6.4.0",
|
||||||
"@types/ssh2": "^1.15.0",
|
"@types/ssh2": "^1.15.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.8.0"
|
"typescript": "^5.8.0"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"node-cron": "^3.0.0",
|
||||||
"nodemailer": "^6.9.0",
|
"nodemailer": "^6.9.0",
|
||||||
"ssh2": "^1.16.0"
|
"ssh2": "^1.16.0"
|
||||||
},
|
},
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node-cron": "^3.0.0",
|
||||||
"@types/nodemailer": "^6.4.0",
|
"@types/nodemailer": "^6.4.0",
|
||||||
"@types/ssh2": "^1.15.0",
|
"@types/ssh2": "^1.15.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
|
|||||||
@@ -25,4 +25,12 @@ export const config = {
|
|||||||
],
|
],
|
||||||
jwtSecret: process.env.JWT_SECRET ?? 'eventify-server-monitor-secret-key-change-in-production',
|
jwtSecret: process.env.JWT_SECRET ?? 'eventify-server-monitor-secret-key-change-in-production',
|
||||||
},
|
},
|
||||||
} as const;
|
smtp: {
|
||||||
|
host: process.env.SMTP_HOST ?? '',
|
||||||
|
port: parseInt(process.env.SMTP_PORT ?? '587', 10),
|
||||||
|
user: process.env.SMTP_USER ?? '',
|
||||||
|
pass: process.env.SMTP_PASS ?? '',
|
||||||
|
},
|
||||||
|
dataDir: process.env.DATA_DIR ?? './data',
|
||||||
|
appUrl: process.env.APP_URL ?? 'https://status.eventifyplus.com',
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,18 +2,21 @@ import express from 'express';
|
|||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname, resolve } from 'path';
|
import { dirname, resolve } from 'path';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync, mkdirSync } from 'fs';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
import { healthRouter } from './routes/health.js';
|
import { healthRouter } from './routes/health.js';
|
||||||
import { authRouter } from './routes/auth.js';
|
import { authRouter } from './routes/auth.js';
|
||||||
import { usersRouter } from './routes/users.js';
|
import { notificationRouter } from './routes/notifications.js';
|
||||||
import { requireAuth } from './middleware/auth.js';
|
import { requireAuth } from './middleware/auth.js';
|
||||||
import { sshManager } from './ssh/client.js';
|
import { sshManager } from './ssh/client.js';
|
||||||
import { createNotification } from './services/notifications.js';
|
import { startScheduler, stopScheduler } from './services/scheduler.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
mkdirSync(config.dataDir, { recursive: true });
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// ── Middleware ──
|
// ── Middleware ──
|
||||||
@@ -27,8 +30,8 @@ app.use(express.json());
|
|||||||
|
|
||||||
// ── API Routes ──
|
// ── API Routes ──
|
||||||
app.use('/api/auth', authRouter);
|
app.use('/api/auth', authRouter);
|
||||||
|
app.use('/api/notifications', requireAuth, notificationRouter);
|
||||||
app.use('/api', requireAuth, healthRouter);
|
app.use('/api', requireAuth, healthRouter);
|
||||||
app.use('/api', requireAuth, usersRouter);
|
|
||||||
|
|
||||||
// ── Static file serving for production SPA ──
|
// ── Static file serving for production SPA ──
|
||||||
const clientDistPath = resolve(__dirname, '../../client/dist');
|
const clientDistPath = resolve(__dirname, '../../client/dist');
|
||||||
@@ -45,101 +48,15 @@ if (existsSync(clientDistPath)) {
|
|||||||
const server = app.listen(config.port, () => {
|
const server = app.listen(config.port, () => {
|
||||||
console.log(`[Server] Listening on http://localhost:${config.port}`);
|
console.log(`[Server] Listening on http://localhost:${config.port}`);
|
||||||
console.log(`[Server] SSH target: ${config.ssh.user}@${config.ssh.host}`);
|
console.log(`[Server] SSH target: ${config.ssh.user}@${config.ssh.host}`);
|
||||||
|
startScheduler();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Error monitoring: check health every 60s and alert on issues ──
|
|
||||||
const MONITOR_INTERVAL = 60_000;
|
|
||||||
let previousErrors: Set<string> = new Set();
|
|
||||||
|
|
||||||
async function monitorHealth() {
|
|
||||||
try {
|
|
||||||
const { getSystemInfo } = await import('./services/system.js');
|
|
||||||
const { getDockerInfo } = await import('./services/docker.js');
|
|
||||||
const { getMemoryInfo } = await import('./services/memory.js');
|
|
||||||
const { getDiskInfo } = await import('./services/disk.js');
|
|
||||||
|
|
||||||
const currentErrors: Set<string> = new Set();
|
|
||||||
|
|
||||||
// Check system
|
|
||||||
try {
|
|
||||||
const sys = await getSystemInfo();
|
|
||||||
if (sys.cpuPercent > 90) {
|
|
||||||
const key = 'cpu-high';
|
|
||||||
currentErrors.add(key);
|
|
||||||
if (!previousErrors.has(key)) {
|
|
||||||
await createNotification('warning', 'High CPU Usage', `CPU at ${sys.cpuPercent.toFixed(1)}%`, 'System Monitor');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const key = 'system-down';
|
|
||||||
currentErrors.add(key);
|
|
||||||
if (!previousErrors.has(key)) {
|
|
||||||
await createNotification('error', 'System Check Failed', (e as Error).message, 'System Monitor');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check memory
|
|
||||||
try {
|
|
||||||
const mem = await getMemoryInfo();
|
|
||||||
if (mem.usedPercent > 90) {
|
|
||||||
const key = 'memory-high';
|
|
||||||
currentErrors.add(key);
|
|
||||||
if (!previousErrors.has(key)) {
|
|
||||||
await createNotification('warning', 'High Memory Usage', `Memory at ${mem.usedPercent.toFixed(1)}%`, 'System Monitor');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// Check disk
|
|
||||||
try {
|
|
||||||
const disk = await getDiskInfo();
|
|
||||||
if (disk.usedPercent > 85) {
|
|
||||||
const key = 'disk-high';
|
|
||||||
currentErrors.add(key);
|
|
||||||
if (!previousErrors.has(key)) {
|
|
||||||
await createNotification('warning', 'Disk Space Low', `Disk at ${disk.usedPercent.toFixed(1)}% used`, 'System Monitor');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// Check Docker containers
|
|
||||||
try {
|
|
||||||
const containers = await getDockerInfo();
|
|
||||||
for (const c of containers) {
|
|
||||||
if (c.status && !c.status.toLowerCase().includes('up')) {
|
|
||||||
const key = `container-down-${c.name}`;
|
|
||||||
currentErrors.add(key);
|
|
||||||
if (!previousErrors.has(key)) {
|
|
||||||
await createNotification('error', `Container Down: ${c.name}`, `Status: ${c.status}`, 'Docker Monitor');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
// Check for recoveries
|
|
||||||
for (const prev of previousErrors) {
|
|
||||||
if (!currentErrors.has(prev)) {
|
|
||||||
await createNotification('recovery', `Recovered: ${prev}`, `The issue "${prev}" has been resolved.`, 'System Monitor');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
previousErrors = currentErrors;
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Monitor] Health check error:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start monitoring after SSH connection is ready (delay 10s)
|
|
||||||
setTimeout(() => {
|
|
||||||
monitorHealth();
|
|
||||||
setInterval(monitorHealth, MONITOR_INTERVAL);
|
|
||||||
console.log('[Monitor] Health monitoring started (60s interval)');
|
|
||||||
}, 10_000);
|
|
||||||
|
|
||||||
// ── Graceful shutdown ──
|
// ── Graceful shutdown ──
|
||||||
async function shutdown(signal: string) {
|
async function shutdown(signal: string) {
|
||||||
console.log(`\n[Server] ${signal} received, shutting down gracefully...`);
|
console.log(`\n[Server] ${signal} received, shutting down gracefully...`);
|
||||||
|
|
||||||
|
stopScheduler();
|
||||||
|
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
console.log('[Server] HTTP server closed');
|
console.log('[Server] HTTP server closed');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getMemoryInfo } from '../services/memory.js';
|
|||||||
import { getDiskInfo } from '../services/disk.js';
|
import { getDiskInfo } from '../services/disk.js';
|
||||||
import { getDockerInfo } from '../services/docker.js';
|
import { getDockerInfo } from '../services/docker.js';
|
||||||
import { getNginxStatus } from '../services/nginx.js';
|
import { getNginxStatus } from '../services/nginx.js';
|
||||||
|
import { getTrafficInfo } from '../services/traffic.js';
|
||||||
import type { OverviewResponse } from '../types/index.js';
|
import type { OverviewResponse } from '../types/index.js';
|
||||||
|
|
||||||
export const healthRouter = Router();
|
export const healthRouter = Router();
|
||||||
@@ -27,6 +28,7 @@ healthRouter.get('/overview', async (_req, res) => {
|
|||||||
const disk = await safe('disk', getDiskInfo);
|
const disk = await safe('disk', getDiskInfo);
|
||||||
const docker = await safe('docker', getDockerInfo);
|
const docker = await safe('docker', getDockerInfo);
|
||||||
const nginx = await safe('nginx', getNginxStatus);
|
const nginx = await safe('nginx', getNginxStatus);
|
||||||
|
const traffic = await safe('traffic', getTrafficInfo);
|
||||||
|
|
||||||
const response: OverviewResponse = {
|
const response: OverviewResponse = {
|
||||||
system,
|
system,
|
||||||
@@ -34,6 +36,7 @@ healthRouter.get('/overview', async (_req, res) => {
|
|||||||
disk,
|
disk,
|
||||||
docker,
|
docker,
|
||||||
nginx,
|
nginx,
|
||||||
|
traffic,
|
||||||
errors,
|
errors,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
133
server/src/routes/notifications.ts
Normal file
133
server/src/routes/notifications.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import {
|
||||||
|
getAlerts,
|
||||||
|
getThresholds,
|
||||||
|
saveThresholds,
|
||||||
|
getPreferences,
|
||||||
|
savePreferences,
|
||||||
|
updateAlert,
|
||||||
|
deleteAlert,
|
||||||
|
clearResolvedAlerts,
|
||||||
|
} from '../services/store.js';
|
||||||
|
import { sendTestEmail, sendSampleAlerts } from '../services/email.js';
|
||||||
|
import type { ThresholdsConfig, NotificationPreferences } from '../types/index.js';
|
||||||
|
|
||||||
|
export const notificationRouter = Router();
|
||||||
|
|
||||||
|
// GET /api/notifications/summary
|
||||||
|
notificationRouter.get('/summary', (_req, res) => {
|
||||||
|
const alerts = getAlerts();
|
||||||
|
const active = alerts.filter(a => a.resolvedAt === null);
|
||||||
|
res.json({
|
||||||
|
activeWarnings: active.filter(a => a.severity === 'warning').length,
|
||||||
|
activeCriticals: active.filter(a => a.severity === 'critical').length,
|
||||||
|
unacknowledged: alerts.filter(a => !a.acknowledged).length,
|
||||||
|
total: alerts.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/notifications/thresholds
|
||||||
|
notificationRouter.get('/thresholds', (_req, res) => {
|
||||||
|
res.json(getThresholds());
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/notifications/thresholds
|
||||||
|
notificationRouter.put('/thresholds', (req, res) => {
|
||||||
|
const body = req.body as ThresholdsConfig;
|
||||||
|
if (!body?.thresholds || !Array.isArray(body.thresholds)) {
|
||||||
|
res.status(400).json({ error: 'Invalid thresholds payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveThresholds(body);
|
||||||
|
res.json(getThresholds());
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/notifications/preferences
|
||||||
|
notificationRouter.get('/preferences', (_req, res) => {
|
||||||
|
res.json(getPreferences());
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/notifications/preferences
|
||||||
|
notificationRouter.put('/preferences', (req, res) => {
|
||||||
|
const body = req.body as NotificationPreferences;
|
||||||
|
if (!body?.recipients || !Array.isArray(body.recipients)) {
|
||||||
|
res.status(400).json({ error: 'Invalid preferences payload' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
savePreferences(body);
|
||||||
|
res.json(getPreferences());
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/notifications/test-email
|
||||||
|
notificationRouter.post('/test-email', async (req, res) => {
|
||||||
|
const { email } = req.body as { email?: string };
|
||||||
|
if (!email) {
|
||||||
|
res.status(400).json({ error: 'email is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await sendTestEmail(email);
|
||||||
|
res.json({ success: true, message: `Test email sent to ${email}` });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/notifications/test-all — send sample emails for every alert type
|
||||||
|
notificationRouter.post('/test-all', async (req, res) => {
|
||||||
|
const { email } = req.body as { email?: string };
|
||||||
|
if (!email) {
|
||||||
|
res.status(400).json({ error: 'email is required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await sendSampleAlerts(email);
|
||||||
|
res.json({ success: true, message: `Sent ${result.sent.length} sample emails to ${email}`, types: result.sent });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: (err as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/notifications/clear
|
||||||
|
notificationRouter.delete('/clear', (_req, res) => {
|
||||||
|
const count = clearResolvedAlerts();
|
||||||
|
res.json({ success: true, cleared: count });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/notifications — list with filters
|
||||||
|
notificationRouter.get('/', (req, res) => {
|
||||||
|
const page = Math.max(1, parseInt(String(req.query.page ?? '1'), 10));
|
||||||
|
const limit = Math.min(100, Math.max(1, parseInt(String(req.query.limit ?? '20'), 10)));
|
||||||
|
const status = String(req.query.status ?? 'all');
|
||||||
|
|
||||||
|
let alerts = getAlerts();
|
||||||
|
|
||||||
|
if (status === 'active') alerts = alerts.filter(a => a.resolvedAt === null);
|
||||||
|
if (status === 'resolved') alerts = alerts.filter(a => a.resolvedAt !== null);
|
||||||
|
|
||||||
|
const total = alerts.length;
|
||||||
|
const start = (page - 1) * limit;
|
||||||
|
const items = alerts.slice(start, start + limit);
|
||||||
|
|
||||||
|
res.json({ alerts: items, total, page, limit });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/notifications/:id/acknowledge
|
||||||
|
notificationRouter.patch('/:id/acknowledge', (req, res) => {
|
||||||
|
const updated = updateAlert(req.params.id, { acknowledged: true });
|
||||||
|
if (!updated) {
|
||||||
|
res.status(404).json({ error: 'Alert not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(updated);
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/notifications/:id
|
||||||
|
notificationRouter.delete('/:id', (req, res) => {
|
||||||
|
const ok = deleteAlert(req.params.id);
|
||||||
|
if (!ok) {
|
||||||
|
res.status(404).json({ error: 'Alert not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
245
server/src/services/alertEngine.ts
Normal file
245
server/src/services/alertEngine.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { getThresholds, getPreferences, addAlert, getActiveAlerts, updateAlert } from './store.js';
|
||||||
|
import { sendAlertEmail, sendRecoveryEmail } from './email.js';
|
||||||
|
import type { OverviewResponse } from '../types/index.js';
|
||||||
|
import type { AlertRecord, AlertSeverity, MetricKey } from '../types/notifications.js';
|
||||||
|
|
||||||
|
// In-memory cooldown map: "metric:severity" → last fired timestamp (ms)
|
||||||
|
const cooldownMap = new Map<string, number>();
|
||||||
|
|
||||||
|
function isCooledDown(metric: MetricKey, severity: AlertSeverity, cooldownMs: number): boolean {
|
||||||
|
const key = `${metric}:${severity}`;
|
||||||
|
const last = cooldownMap.get(key);
|
||||||
|
if (!last) return true;
|
||||||
|
return Date.now() - last >= cooldownMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stampCooldown(metric: MetricKey, severity: AlertSeverity): void {
|
||||||
|
cooldownMap.set(`${metric}:${severity}`, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAlert(
|
||||||
|
metric: MetricKey,
|
||||||
|
severity: AlertSeverity,
|
||||||
|
message: string,
|
||||||
|
value: number | string,
|
||||||
|
threshold: number | string
|
||||||
|
): AlertRecord {
|
||||||
|
return {
|
||||||
|
id: randomUUID(),
|
||||||
|
metric,
|
||||||
|
severity,
|
||||||
|
message,
|
||||||
|
value,
|
||||||
|
threshold,
|
||||||
|
firedAt: new Date().toISOString(),
|
||||||
|
resolvedAt: null,
|
||||||
|
emailSent: false,
|
||||||
|
acknowledged: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluateMetrics(overview: OverviewResponse): Promise<void> {
|
||||||
|
const { thresholds } = getThresholds();
|
||||||
|
const prefs = getPreferences();
|
||||||
|
const cooldownMs = prefs.cooldownMinutes * 60_000;
|
||||||
|
const newAlerts: AlertRecord[] = [];
|
||||||
|
|
||||||
|
for (const t of thresholds) {
|
||||||
|
if (!t.enabled) continue;
|
||||||
|
|
||||||
|
// ── Extract metric value ──
|
||||||
|
let value: number | string | null = null;
|
||||||
|
|
||||||
|
switch (t.metric) {
|
||||||
|
case 'cpu_percent':
|
||||||
|
value = overview.system?.cpuPercent ?? null;
|
||||||
|
break;
|
||||||
|
case 'load_avg_1m':
|
||||||
|
value = overview.system?.loadAvg?.[0] ?? null;
|
||||||
|
break;
|
||||||
|
case 'memory_percent':
|
||||||
|
value = overview.memory?.usedPercent ?? null;
|
||||||
|
break;
|
||||||
|
case 'disk_percent':
|
||||||
|
value = overview.disk?.usedPercent ?? null;
|
||||||
|
break;
|
||||||
|
case 'nginx_status':
|
||||||
|
value = overview.nginx?.status !== 'active' ? 1 : 0;
|
||||||
|
break;
|
||||||
|
case 'container_stopped': {
|
||||||
|
const stopped = (overview.docker ?? []).filter(
|
||||||
|
c => !c.status.toLowerCase().includes('up')
|
||||||
|
).length;
|
||||||
|
value = stopped;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'connections_total':
|
||||||
|
value = overview.traffic?.totalConnections ?? null;
|
||||||
|
break;
|
||||||
|
case 'connections_per_ip':
|
||||||
|
value = overview.traffic?.topIpConnections ?? null;
|
||||||
|
break;
|
||||||
|
case 'request_rate':
|
||||||
|
value = overview.traffic?.requestsPerMinute ?? null;
|
||||||
|
break;
|
||||||
|
case 'error_rate_5xx':
|
||||||
|
value = overview.traffic?.errors5xxPerMinute ?? null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metric unreachable
|
||||||
|
if (value === null) {
|
||||||
|
if (isCooledDown(t.metric, 'info', cooldownMs)) {
|
||||||
|
const alert = createAlert(
|
||||||
|
t.metric, 'info',
|
||||||
|
`Metric "${t.metric}" could not be fetched`,
|
||||||
|
'N/A', 'N/A'
|
||||||
|
);
|
||||||
|
addAlert(alert);
|
||||||
|
newAlerts.push(alert);
|
||||||
|
stampCooldown(t.metric, 'info');
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Numeric threshold checks ──
|
||||||
|
if (t.metric === 'nginx_status') {
|
||||||
|
// Boolean: fires if nginx is not active
|
||||||
|
if (value === 1) {
|
||||||
|
const nginxStatus = overview.nginx?.status ?? 'unknown';
|
||||||
|
if (isCooledDown(t.metric, 'critical', cooldownMs)) {
|
||||||
|
// Resolve any existing warning first
|
||||||
|
resolveMetricAlerts(t.metric, 'warning', newAlerts);
|
||||||
|
const alert = createAlert(
|
||||||
|
t.metric, 'critical',
|
||||||
|
`Nginx is ${nginxStatus}`,
|
||||||
|
nginxStatus, 'active'
|
||||||
|
);
|
||||||
|
addAlert(alert);
|
||||||
|
newAlerts.push(alert);
|
||||||
|
stampCooldown(t.metric, 'critical');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolveRecoveredAlerts(t.metric, newAlerts, prefs.recipients);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.metric === 'container_stopped') {
|
||||||
|
if ((value as number) > 0) {
|
||||||
|
const names = (overview.docker ?? [])
|
||||||
|
.filter(c => !c.status.toLowerCase().includes('up'))
|
||||||
|
.map(c => c.name)
|
||||||
|
.join(', ');
|
||||||
|
if (isCooledDown(t.metric, 'warning', cooldownMs)) {
|
||||||
|
const alert = createAlert(
|
||||||
|
t.metric, 'warning',
|
||||||
|
`${value} container(s) not running: ${names}`,
|
||||||
|
`${value} stopped`, '0 stopped'
|
||||||
|
);
|
||||||
|
addAlert(alert);
|
||||||
|
newAlerts.push(alert);
|
||||||
|
stampCooldown(t.metric, 'warning');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolveRecoveredAlerts(t.metric, newAlerts, prefs.recipients);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numVal = value as number;
|
||||||
|
|
||||||
|
// Build context-rich messages for traffic metrics
|
||||||
|
function buildMessage(severity: 'warning' | 'critical'): string {
|
||||||
|
const level = severity === 'critical' ? 'critically high' : 'high';
|
||||||
|
switch (t.metric) {
|
||||||
|
case 'connections_total':
|
||||||
|
return `${numVal} active TCP connections — potential DDoS or traffic spike (${level})`;
|
||||||
|
case 'connections_per_ip': {
|
||||||
|
const ip = overview.traffic?.topIp ?? 'unknown';
|
||||||
|
return `Single IP (${ip}) has ${numVal} connections — possible DDoS attack (${level})`;
|
||||||
|
}
|
||||||
|
case 'request_rate':
|
||||||
|
return `${numVal} HTTP requests/min — abnormally high API call volume (${level})`;
|
||||||
|
case 'error_rate_5xx':
|
||||||
|
return `${numVal} server errors (5xx) in the last minute — service degradation (${level})`;
|
||||||
|
default:
|
||||||
|
return `${t.metric.replace(/_/g, ' ')} is ${level} at ${numVal.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numVal >= t.critical) {
|
||||||
|
// Escalate: resolve active warning first
|
||||||
|
resolveMetricAlerts(t.metric, 'warning', newAlerts);
|
||||||
|
if (isCooledDown(t.metric, 'critical', cooldownMs)) {
|
||||||
|
const alert = createAlert(
|
||||||
|
t.metric, 'critical',
|
||||||
|
buildMessage('critical'),
|
||||||
|
parseFloat(numVal.toFixed(2)), t.critical
|
||||||
|
);
|
||||||
|
addAlert(alert);
|
||||||
|
newAlerts.push(alert);
|
||||||
|
stampCooldown(t.metric, 'critical');
|
||||||
|
}
|
||||||
|
} else if (numVal >= t.warning) {
|
||||||
|
if (isCooledDown(t.metric, 'warning', cooldownMs)) {
|
||||||
|
const alert = createAlert(
|
||||||
|
t.metric, 'warning',
|
||||||
|
buildMessage('warning'),
|
||||||
|
parseFloat(numVal.toFixed(2)), t.warning
|
||||||
|
);
|
||||||
|
addAlert(alert);
|
||||||
|
newAlerts.push(alert);
|
||||||
|
stampCooldown(t.metric, 'warning');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Value back to normal — resolve any active alerts
|
||||||
|
resolveRecoveredAlerts(t.metric, newAlerts, prefs.recipients);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send emails for new alerts
|
||||||
|
for (const alert of newAlerts) {
|
||||||
|
if (!prefs.enabledSeverities.includes(alert.severity)) continue;
|
||||||
|
const enabledRecipients = prefs.recipients.filter(r => r.enabled);
|
||||||
|
if (!enabledRecipients.length) continue;
|
||||||
|
try {
|
||||||
|
await sendAlertEmail(alert, enabledRecipients);
|
||||||
|
updateAlert(alert.id, { emailSent: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[AlertEngine] Failed to send email for alert ${alert.id}:`, (err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newAlerts.length > 0) {
|
||||||
|
console.log(`[AlertEngine] ${newAlerts.length} new alert(s) fired.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMetricAlerts(
|
||||||
|
metric: MetricKey,
|
||||||
|
severity: AlertSeverity,
|
||||||
|
_newAlerts: AlertRecord[]
|
||||||
|
): void {
|
||||||
|
const active = getActiveAlerts(metric).filter(a => a.severity === severity);
|
||||||
|
for (const a of active) {
|
||||||
|
updateAlert(a.id, { resolvedAt: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRecoveredAlerts(
|
||||||
|
metric: MetricKey,
|
||||||
|
_newAlerts: AlertRecord[],
|
||||||
|
recipients: { email: string; name: string; enabled: boolean }[]
|
||||||
|
): void {
|
||||||
|
const active = getActiveAlerts(metric);
|
||||||
|
for (const a of active) {
|
||||||
|
const resolved = updateAlert(a.id, { resolvedAt: new Date().toISOString() });
|
||||||
|
if (resolved) {
|
||||||
|
sendRecoveryEmail(resolved, recipients.filter(r => r.enabled)).catch((err: Error) =>
|
||||||
|
console.error(`[AlertEngine] Recovery email failed:`, err.message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
633
server/src/services/email.ts
Normal file
633
server/src/services/email.ts
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import type { AlertRecord, EmailRecipient } from '../types/index.js';
|
||||||
|
|
||||||
|
function createTransporter() {
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: config.smtp.host,
|
||||||
|
port: config.smtp.port,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: config.smtp.user,
|
||||||
|
pass: config.smtp.pass,
|
||||||
|
},
|
||||||
|
tls: { rejectUnauthorized: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const METRIC_LABELS: Record<string, 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 Status',
|
||||||
|
connections_total: 'Total Connections',
|
||||||
|
connections_per_ip: 'Connections per IP',
|
||||||
|
request_rate: 'Request Rate',
|
||||||
|
error_rate_5xx: '5xx Error Rate',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Severity theme config ──
|
||||||
|
interface SeverityTheme {
|
||||||
|
gradient: string;
|
||||||
|
badgeBg: string;
|
||||||
|
badgeText: string;
|
||||||
|
accentColor: string;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_THEMES: Record<string, SeverityTheme> = {
|
||||||
|
critical: {
|
||||||
|
gradient: 'linear-gradient(135deg, #dc2626 0%, #991b1b 100%)',
|
||||||
|
badgeBg: '#fef2f2',
|
||||||
|
badgeText: '#dc2626',
|
||||||
|
accentColor: '#dc2626',
|
||||||
|
icon: '⚠', // ⚠
|
||||||
|
label: 'CRITICAL',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||||
|
badgeBg: '#fffbeb',
|
||||||
|
badgeText: '#d97706',
|
||||||
|
accentColor: '#d97706',
|
||||||
|
icon: '⚠', // ⚠
|
||||||
|
label: 'WARNING',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
gradient: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||||
|
badgeBg: '#eff6ff',
|
||||||
|
badgeText: '#2563eb',
|
||||||
|
accentColor: '#2563eb',
|
||||||
|
icon: 'ℹ', // ℹ
|
||||||
|
label: 'INFO',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const RECOVERY_THEME = {
|
||||||
|
gradient: 'linear-gradient(135deg, #22c55e 0%, #15803d 100%)',
|
||||||
|
badgeBg: '#f0fdf4',
|
||||||
|
badgeText: '#16a34a',
|
||||||
|
accentColor: '#16a34a',
|
||||||
|
icon: '✔', // ✔
|
||||||
|
label: 'RESOLVED',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Shared layout wrapper ──
|
||||||
|
function emailWrapper(content: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background-color:#f1f5f9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;-webkit-font-smoothing:antialiased;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f1f5f9;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:40px 16px;">
|
||||||
|
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%;">
|
||||||
|
${content}
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:24px 0 0;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:16px 24px;border-top:1px solid #e2e8f0;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-right:8px;vertical-align:middle;">
|
||||||
|
<div style="width:24px;height:24px;background:linear-gradient(135deg,#6366f1 0%,#4f46e5 100%);border-radius:6px;text-align:center;line-height:24px;color:#fff;font-size:13px;font-weight:700;">E</div>
|
||||||
|
</td>
|
||||||
|
<td style="vertical-align:middle;">
|
||||||
|
<span style="font-size:13px;font-weight:600;color:#475569;">Eventify Server Monitor</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:8px 0 0;font-size:12px;color:#94a3b8;">
|
||||||
|
<a href="${config.appUrl}" style="color:#6366f1;text-decoration:none;">status.eventifyplus.com</a>
|
||||||
|
· Automated alert — do not reply
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alert email (critical / warning / info) ──
|
||||||
|
function buildAlertHtml(alert: AlertRecord): string {
|
||||||
|
const theme = SEVERITY_THEMES[alert.severity] ?? SEVERITY_THEMES.info;
|
||||||
|
const label = METRIC_LABELS[alert.metric] ?? alert.metric;
|
||||||
|
const firedAt = new Date(alert.firedAt).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata' });
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<!-- Header with gradient -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:${theme.gradient};padding:32px 32px 28px;border-radius:12px 12px 0 0;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<!-- Severity badge -->
|
||||||
|
<div style="display:inline-block;background:rgba(255,255,255,0.2);border-radius:20px;padding:4px 14px 4px 10px;margin-bottom:16px;">
|
||||||
|
<span style="color:#fff;font-size:12px;font-weight:700;letter-spacing:1.5px;">${theme.icon} ${theme.label} ALERT</span>
|
||||||
|
</div>
|
||||||
|
<h1 style="margin:0 0 6px;color:#ffffff;font-size:22px;font-weight:700;line-height:1.3;">
|
||||||
|
${label} Threshold Breached
|
||||||
|
</h1>
|
||||||
|
<p style="margin:0;color:rgba(255,255,255,0.85);font-size:14px;">
|
||||||
|
Detected at ${firedAt} IST
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Body card -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#ffffff;padding:0;border-left:1px solid #e2e8f0;border-right:1px solid #e2e8f0;">
|
||||||
|
<!-- Metric value highlight -->
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px 32px 20px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${theme.badgeBg};border-radius:10px;border:1px solid ${theme.accentColor}20;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 24px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td width="50%">
|
||||||
|
<p style="margin:0 0 4px;font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#64748b;font-weight:600;">Current Value</p>
|
||||||
|
<p style="margin:0;font-size:28px;font-weight:800;color:${theme.accentColor};line-height:1.2;">${alert.value}</p>
|
||||||
|
</td>
|
||||||
|
<td width="50%" style="text-align:right;">
|
||||||
|
<p style="margin:0 0 4px;font-size:11px;text-transform:uppercase;letter-spacing:1px;color:#64748b;font-weight:600;">Threshold</p>
|
||||||
|
<p style="margin:0;font-size:28px;font-weight:800;color:#334155;line-height:1.2;">${alert.threshold}</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Details -->
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 32px 28px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border-collapse:separate;border-spacing:0;">
|
||||||
|
<!-- Metric -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 0;border-bottom:1px solid #f1f5f9;" width="140">
|
||||||
|
<span style="font-size:13px;color:#64748b;font-weight:500;">Metric</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:12px 0;border-bottom:1px solid #f1f5f9;">
|
||||||
|
<span style="font-size:14px;color:#0f172a;font-weight:600;">${label}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Severity -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 0;border-bottom:1px solid #f1f5f9;">
|
||||||
|
<span style="font-size:13px;color:#64748b;font-weight:500;">Severity</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:12px 0;border-bottom:1px solid #f1f5f9;">
|
||||||
|
<span style="display:inline-block;background:${theme.badgeBg};color:${theme.badgeText};font-size:12px;font-weight:700;padding:3px 10px;border-radius:12px;border:1px solid ${theme.accentColor}30;text-transform:uppercase;letter-spacing:0.5px;">${alert.severity}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Message -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 0;" valign="top">
|
||||||
|
<span style="font-size:13px;color:#64748b;font-weight:500;">Details</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:12px 0;">
|
||||||
|
<span style="font-size:14px;color:#334155;line-height:1.5;">${alert.message}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 32px 32px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="background:${theme.gradient};border-radius:8px;">
|
||||||
|
<a href="${config.appUrl}" target="_blank" style="display:inline-block;padding:12px 28px;color:#ffffff;text-decoration:none;font-size:14px;font-weight:600;letter-spacing:0.3px;">
|
||||||
|
View Dashboard →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Bottom border radius -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#ffffff;height:4px;border-radius:0 0 12px 12px;border-left:1px solid #e2e8f0;border-right:1px solid #e2e8f0;border-bottom:1px solid #e2e8f0;"></td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
return emailWrapper(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recovery email ──
|
||||||
|
function buildRecoveryHtml(alert: AlertRecord): string {
|
||||||
|
const theme = RECOVERY_THEME;
|
||||||
|
const label = METRIC_LABELS[alert.metric] ?? alert.metric;
|
||||||
|
const firedAt = new Date(alert.firedAt).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata' });
|
||||||
|
const resolvedAt = alert.resolvedAt
|
||||||
|
? new Date(alert.resolvedAt).toLocaleString('en-IN', { timeZone: 'Asia/Kolkata' })
|
||||||
|
: 'N/A';
|
||||||
|
|
||||||
|
const content = `
|
||||||
|
<!-- Header with gradient -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:${theme.gradient};padding:32px 32px 28px;border-radius:12px 12px 0 0;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="display:inline-block;background:rgba(255,255,255,0.2);border-radius:20px;padding:4px 14px 4px 10px;margin-bottom:16px;">
|
||||||
|
<span style="color:#fff;font-size:12px;font-weight:700;letter-spacing:1.5px;">${theme.icon} ${theme.label}</span>
|
||||||
|
</div>
|
||||||
|
<h1 style="margin:0 0 6px;color:#ffffff;font-size:22px;font-weight:700;line-height:1.3;">
|
||||||
|
${label} — Back to Normal
|
||||||
|
</h1>
|
||||||
|
<p style="margin:0;color:rgba(255,255,255,0.85);font-size:14px;">
|
||||||
|
Resolved at ${resolvedAt} IST
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Body card -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#ffffff;padding:0;border-left:1px solid #e2e8f0;border-right:1px solid #e2e8f0;">
|
||||||
|
<!-- Recovery status highlight -->
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px 32px 20px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:${theme.badgeBg};border-radius:10px;border:1px solid ${theme.accentColor}20;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 24px;text-align:center;">
|
||||||
|
<div style="font-size:36px;line-height:1;">✓</div>
|
||||||
|
<p style="margin:8px 0 0;font-size:18px;font-weight:700;color:${theme.accentColor};">All Clear</p>
|
||||||
|
<p style="margin:4px 0 0;font-size:13px;color:#64748b;">The metric has returned to acceptable levels</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Timeline -->
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 32px 28px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 0;border-bottom:1px solid #f1f5f9;" width="140">
|
||||||
|
<span style="font-size:13px;color:#64748b;font-weight:500;">Metric</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:12px 0;border-bottom:1px solid #f1f5f9;">
|
||||||
|
<span style="font-size:14px;color:#0f172a;font-weight:600;">${label}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 0;border-bottom:1px solid #f1f5f9;">
|
||||||
|
<span style="font-size:13px;color:#64748b;font-weight:500;">Alert Fired</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:12px 0;border-bottom:1px solid #f1f5f9;">
|
||||||
|
<span style="font-size:14px;color:#334155;">${firedAt} IST</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 0;border-bottom:1px solid #f1f5f9;">
|
||||||
|
<span style="font-size:13px;color:#64748b;font-weight:500;">Resolved</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:12px 0;border-bottom:1px solid #f1f5f9;">
|
||||||
|
<span style="font-size:14px;color:${theme.accentColor};font-weight:600;">${resolvedAt} IST</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:12px 0;">
|
||||||
|
<span style="font-size:13px;color:#64748b;font-weight:500;">Previous Alert</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding:12px 0;">
|
||||||
|
<span style="font-size:14px;color:#334155;">${alert.message}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 32px 32px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="background:${theme.gradient};border-radius:8px;">
|
||||||
|
<a href="${config.appUrl}" target="_blank" style="display:inline-block;padding:12px 28px;color:#ffffff;text-decoration:none;font-size:14px;font-weight:600;letter-spacing:0.3px;">
|
||||||
|
View Dashboard →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Bottom border radius -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#ffffff;height:4px;border-radius:0 0 12px 12px;border-left:1px solid #e2e8f0;border-right:1px solid #e2e8f0;border-bottom:1px solid #e2e8f0;"></td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
return emailWrapper(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test email (basic SMTP check) ──
|
||||||
|
function buildTestHtml(): string {
|
||||||
|
const content = `
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:linear-gradient(135deg,#6366f1 0%,#4f46e5 100%);padding:32px 32px 28px;border-radius:12px 12px 0 0;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div style="display:inline-block;background:rgba(255,255,255,0.2);border-radius:20px;padding:4px 14px 4px 10px;margin-bottom:16px;">
|
||||||
|
<span style="color:#fff;font-size:12px;font-weight:700;letter-spacing:1.5px;">⚡ SMTP TEST</span>
|
||||||
|
</div>
|
||||||
|
<h1 style="margin:0 0 6px;color:#ffffff;font-size:22px;font-weight:700;">
|
||||||
|
Connection Verified
|
||||||
|
</h1>
|
||||||
|
<p style="margin:0;color:rgba(255,255,255,0.85);font-size:14px;">
|
||||||
|
Your SMTP settings are configured correctly
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#ffffff;padding:28px 32px 32px;border-left:1px solid #e2e8f0;border-right:1px solid #e2e8f0;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f0fdf4;border-radius:10px;border:1px solid #bbf7d0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 24px;text-align:center;">
|
||||||
|
<div style="font-size:36px;line-height:1;">✓</div>
|
||||||
|
<p style="margin:8px 0 0;font-size:16px;font-weight:700;color:#16a34a;">SMTP is Working</p>
|
||||||
|
<p style="margin:6px 0 0;font-size:13px;color:#64748b;line-height:1.5;">
|
||||||
|
This confirms that Eventify Server Monitor can send<br>alert and recovery emails through your mail server.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:20px;">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||||
|
<tr>
|
||||||
|
<td style="background:linear-gradient(135deg,#6366f1 0%,#4f46e5 100%);border-radius:8px;">
|
||||||
|
<a href="${config.appUrl}" target="_blank" style="display:inline-block;padding:12px 28px;color:#ffffff;text-decoration:none;font-size:14px;font-weight:600;letter-spacing:0.3px;">
|
||||||
|
View Dashboard →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Bottom border radius -->
|
||||||
|
<tr>
|
||||||
|
<td style="background:#ffffff;height:4px;border-radius:0 0 12px 12px;border-left:1px solid #e2e8f0;border-right:1px solid #e2e8f0;border-bottom:1px solid #e2e8f0;"></td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
return emailWrapper(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public send functions ──
|
||||||
|
|
||||||
|
export async function sendAlertEmail(alert: AlertRecord, recipients: EmailRecipient[]): Promise<void> {
|
||||||
|
const enabled = recipients.filter(r => r.enabled);
|
||||||
|
if (!enabled.length || !config.smtp.host) return;
|
||||||
|
|
||||||
|
const transporter = createTransporter();
|
||||||
|
const label = METRIC_LABELS[alert.metric] ?? alert.metric;
|
||||||
|
const subject = `[${alert.severity.toUpperCase()}] ${label} alert — Eventify Server`;
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"Eventify Monitor" <${config.smtp.user}>`,
|
||||||
|
to: enabled.map(r => `"${r.name}" <${r.email}>`).join(', '),
|
||||||
|
subject,
|
||||||
|
html: buildAlertHtml(alert),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendRecoveryEmail(alert: AlertRecord, recipients: EmailRecipient[]): Promise<void> {
|
||||||
|
const enabled = recipients.filter(r => r.enabled);
|
||||||
|
if (!enabled.length || !config.smtp.host) return;
|
||||||
|
|
||||||
|
const transporter = createTransporter();
|
||||||
|
const label = METRIC_LABELS[alert.metric] ?? alert.metric;
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"Eventify Monitor" <${config.smtp.user}>`,
|
||||||
|
to: enabled.map(r => `"${r.name}" <${r.email}>`).join(', '),
|
||||||
|
subject: `[RESOLVED] ${label} — Eventify Server`,
|
||||||
|
html: buildRecoveryHtml(alert),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendTestEmail(to: string): Promise<void> {
|
||||||
|
if (!config.smtp.host) throw new Error('SMTP not configured');
|
||||||
|
const transporter = createTransporter();
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: `"Eventify Monitor" <${config.smtp.user}>`,
|
||||||
|
to,
|
||||||
|
subject: 'SMTP Test — Eventify Server Monitor',
|
||||||
|
html: buildTestHtml(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Send sample emails for every alert type ──
|
||||||
|
export async function sendSampleAlerts(to: string): Promise<{ sent: string[] }> {
|
||||||
|
if (!config.smtp.host) throw new Error('SMTP not configured');
|
||||||
|
const transporter = createTransporter();
|
||||||
|
const from = `"Eventify Monitor" <${config.smtp.user}>`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const sent: string[] = [];
|
||||||
|
|
||||||
|
// 1. Critical alert
|
||||||
|
const criticalAlert: AlertRecord = {
|
||||||
|
id: 'sample-critical',
|
||||||
|
metric: 'cpu_percent',
|
||||||
|
severity: 'critical',
|
||||||
|
message: 'CPU utilization is critically high at 97.2% — sustained for over 5 minutes',
|
||||||
|
value: 97.2,
|
||||||
|
threshold: 95,
|
||||||
|
firedAt: now,
|
||||||
|
resolvedAt: null,
|
||||||
|
emailSent: true,
|
||||||
|
acknowledged: false,
|
||||||
|
};
|
||||||
|
await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject: '[CRITICAL] CPU Usage alert — Eventify Server (Sample)',
|
||||||
|
html: buildAlertHtml(criticalAlert),
|
||||||
|
});
|
||||||
|
sent.push('critical');
|
||||||
|
|
||||||
|
// 2. Warning alert
|
||||||
|
const warningAlert: AlertRecord = {
|
||||||
|
id: 'sample-warning',
|
||||||
|
metric: 'memory_percent',
|
||||||
|
severity: 'warning',
|
||||||
|
message: 'Memory usage is high at 82.5% — consider checking for memory leaks',
|
||||||
|
value: 82.5,
|
||||||
|
threshold: 80,
|
||||||
|
firedAt: now,
|
||||||
|
resolvedAt: null,
|
||||||
|
emailSent: true,
|
||||||
|
acknowledged: false,
|
||||||
|
};
|
||||||
|
await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject: '[WARNING] Memory Usage alert — Eventify Server (Sample)',
|
||||||
|
html: buildAlertHtml(warningAlert),
|
||||||
|
});
|
||||||
|
sent.push('warning');
|
||||||
|
|
||||||
|
// 3. Info alert
|
||||||
|
const infoAlert: AlertRecord = {
|
||||||
|
id: 'sample-info',
|
||||||
|
metric: 'nginx_status',
|
||||||
|
severity: 'info',
|
||||||
|
message: 'Metric "nginx_status" could not be fetched — service may be temporarily unreachable',
|
||||||
|
value: 'N/A',
|
||||||
|
threshold: 'N/A',
|
||||||
|
firedAt: now,
|
||||||
|
resolvedAt: null,
|
||||||
|
emailSent: true,
|
||||||
|
acknowledged: false,
|
||||||
|
};
|
||||||
|
await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject: '[INFO] Nginx Status alert — Eventify Server (Sample)',
|
||||||
|
html: buildAlertHtml(infoAlert),
|
||||||
|
});
|
||||||
|
sent.push('info');
|
||||||
|
|
||||||
|
// 4. DDoS / High connections alert (critical)
|
||||||
|
const ddosAlert: AlertRecord = {
|
||||||
|
id: 'sample-ddos',
|
||||||
|
metric: 'connections_per_ip',
|
||||||
|
severity: 'critical',
|
||||||
|
message: 'Single IP (185.220.101.42) has 347 connections — possible DDoS attack (critically high)',
|
||||||
|
value: 347,
|
||||||
|
threshold: 100,
|
||||||
|
firedAt: now,
|
||||||
|
resolvedAt: null,
|
||||||
|
emailSent: true,
|
||||||
|
acknowledged: false,
|
||||||
|
};
|
||||||
|
await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject: '[CRITICAL] Connections per IP alert — Eventify Server (Sample)',
|
||||||
|
html: buildAlertHtml(ddosAlert),
|
||||||
|
});
|
||||||
|
sent.push('ddos');
|
||||||
|
|
||||||
|
// 5. High API request rate (warning)
|
||||||
|
const apiRateAlert: AlertRecord = {
|
||||||
|
id: 'sample-api-rate',
|
||||||
|
metric: 'request_rate',
|
||||||
|
severity: 'warning',
|
||||||
|
message: '2,847 HTTP requests/min — abnormally high API call volume (high)',
|
||||||
|
value: 2847,
|
||||||
|
threshold: 1000,
|
||||||
|
firedAt: now,
|
||||||
|
resolvedAt: null,
|
||||||
|
emailSent: true,
|
||||||
|
acknowledged: false,
|
||||||
|
};
|
||||||
|
await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject: '[WARNING] Request Rate alert — Eventify Server (Sample)',
|
||||||
|
html: buildAlertHtml(apiRateAlert),
|
||||||
|
});
|
||||||
|
sent.push('api-rate');
|
||||||
|
|
||||||
|
// 6. 5xx error spike (critical)
|
||||||
|
const errorAlert: AlertRecord = {
|
||||||
|
id: 'sample-5xx',
|
||||||
|
metric: 'error_rate_5xx',
|
||||||
|
severity: 'critical',
|
||||||
|
message: '73 server errors (5xx) in the last minute — service degradation (critically high)',
|
||||||
|
value: 73,
|
||||||
|
threshold: 50,
|
||||||
|
firedAt: now,
|
||||||
|
resolvedAt: null,
|
||||||
|
emailSent: true,
|
||||||
|
acknowledged: false,
|
||||||
|
};
|
||||||
|
await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject: '[CRITICAL] 5xx Error Rate alert — Eventify Server (Sample)',
|
||||||
|
html: buildAlertHtml(errorAlert),
|
||||||
|
});
|
||||||
|
sent.push('5xx-errors');
|
||||||
|
|
||||||
|
// 7. Recovery email
|
||||||
|
const recoveredAlert: AlertRecord = {
|
||||||
|
id: 'sample-recovery',
|
||||||
|
metric: 'connections_total',
|
||||||
|
severity: 'warning',
|
||||||
|
message: 'Total connections were high at 1,247 — traffic has normalized',
|
||||||
|
value: 1247,
|
||||||
|
threshold: 500,
|
||||||
|
firedAt: new Date(Date.now() - 45 * 60_000).toISOString(), // 45 min ago
|
||||||
|
resolvedAt: now,
|
||||||
|
emailSent: true,
|
||||||
|
acknowledged: false,
|
||||||
|
};
|
||||||
|
await transporter.sendMail({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
subject: '[RESOLVED] Total Connections — Eventify Server (Sample)',
|
||||||
|
html: buildRecoveryHtml(recoveredAlert),
|
||||||
|
});
|
||||||
|
sent.push('recovery');
|
||||||
|
|
||||||
|
return { sent };
|
||||||
|
}
|
||||||
66
server/src/services/scheduler.ts
Normal file
66
server/src/services/scheduler.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import cron from 'node-cron';
|
||||||
|
import { getSystemInfo } from './system.js';
|
||||||
|
import { getMemoryInfo } from './memory.js';
|
||||||
|
import { getDiskInfo } from './disk.js';
|
||||||
|
import { getDockerInfo } from './docker.js';
|
||||||
|
import { getNginxStatus } from './nginx.js';
|
||||||
|
import { getTrafficInfo } from './traffic.js';
|
||||||
|
import { evaluateMetrics } from './alertEngine.js';
|
||||||
|
import type { OverviewResponse } from '../types/index.js';
|
||||||
|
|
||||||
|
let task: cron.ScheduledTask | null = null;
|
||||||
|
|
||||||
|
async function runHealthCheck(): Promise<void> {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
const safe = async <T>(name: string, fn: () => Promise<T>): Promise<T | null> => {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (err) {
|
||||||
|
errors[name] = (err as Error).message ?? 'Unknown error';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const system = await safe('system', getSystemInfo);
|
||||||
|
const memory = await safe('memory', getMemoryInfo);
|
||||||
|
const disk = await safe('disk', getDiskInfo);
|
||||||
|
const docker = await safe('docker', getDockerInfo);
|
||||||
|
const nginx = await safe('nginx', getNginxStatus);
|
||||||
|
const traffic = await safe('traffic', getTrafficInfo);
|
||||||
|
|
||||||
|
const overview: OverviewResponse = {
|
||||||
|
system,
|
||||||
|
memory,
|
||||||
|
disk,
|
||||||
|
docker,
|
||||||
|
nginx,
|
||||||
|
traffic,
|
||||||
|
errors,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await evaluateMetrics(overview);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Scheduler] Alert evaluation error:', (err as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startScheduler(): void {
|
||||||
|
if (task) return;
|
||||||
|
console.log('[Scheduler] Starting health check scheduler (every 1 minute)');
|
||||||
|
task = cron.schedule('*/1 * * * *', () => {
|
||||||
|
runHealthCheck().catch(err =>
|
||||||
|
console.error('[Scheduler] Health check failed:', (err as Error).message)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopScheduler(): void {
|
||||||
|
if (task) {
|
||||||
|
task.stop();
|
||||||
|
task = null;
|
||||||
|
console.log('[Scheduler] Stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
143
server/src/services/store.ts
Normal file
143
server/src/services/store.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
import type { AlertRecord, ThresholdsConfig, NotificationPreferences } from '../types/index.js';
|
||||||
|
|
||||||
|
const MAX_ALERTS = 500;
|
||||||
|
|
||||||
|
function dataPath(filename: string): string {
|
||||||
|
return resolve(config.dataDir, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDir(): void {
|
||||||
|
if (!existsSync(config.dataDir)) {
|
||||||
|
mkdirSync(config.dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson<T>(filename: string, fallback: T): T {
|
||||||
|
ensureDir();
|
||||||
|
const path = dataPath(filename);
|
||||||
|
if (!existsSync(path)) return fallback;
|
||||||
|
try {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf8')) as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson<T>(filename: string, data: T): void {
|
||||||
|
ensureDir();
|
||||||
|
const path = dataPath(filename);
|
||||||
|
const tmp = path + '.tmp';
|
||||||
|
writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
renameSync(tmp, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Thresholds ──
|
||||||
|
|
||||||
|
const defaultThresholds: ThresholdsConfig = {
|
||||||
|
thresholds: [
|
||||||
|
{ metric: 'cpu_percent', warning: 80, critical: 95, enabled: true },
|
||||||
|
{ metric: 'load_avg_1m', warning: 4, critical: 8, enabled: true },
|
||||||
|
{ metric: 'memory_percent', warning: 80, critical: 95, enabled: true },
|
||||||
|
{ metric: 'disk_percent', warning: 80, critical: 95, enabled: true },
|
||||||
|
{ metric: 'nginx_status', warning: 0, critical: 0, enabled: true },
|
||||||
|
{ metric: 'container_stopped', warning: 0, critical: 0, enabled: true },
|
||||||
|
{ metric: 'connections_total', warning: 500, critical: 1000, enabled: true },
|
||||||
|
{ metric: 'connections_per_ip', warning: 50, critical: 100, enabled: true },
|
||||||
|
{ metric: 'request_rate', warning: 1000, critical: 3000, enabled: true },
|
||||||
|
{ metric: 'error_rate_5xx', warning: 10, critical: 50, enabled: true },
|
||||||
|
],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getThresholds(): ThresholdsConfig {
|
||||||
|
const saved = readJson<ThresholdsConfig>('thresholds.json', defaultThresholds);
|
||||||
|
// Merge any new default metrics that don't exist in the saved file
|
||||||
|
const savedMetrics = new Set(saved.thresholds.map(t => t.metric));
|
||||||
|
const missing = defaultThresholds.thresholds.filter(t => !savedMetrics.has(t.metric));
|
||||||
|
if (missing.length > 0) {
|
||||||
|
saved.thresholds.push(...missing);
|
||||||
|
writeJson('thresholds.json', saved);
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveThresholds(data: ThresholdsConfig): void {
|
||||||
|
writeJson('thresholds.json', { ...data, updatedAt: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alerts ──
|
||||||
|
|
||||||
|
export function getAlerts(): AlertRecord[] {
|
||||||
|
return readJson<AlertRecord[]>('alerts.json', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAlerts(alerts: AlertRecord[]): void {
|
||||||
|
// Cap at MAX_ALERTS (FIFO — drop oldest resolved first, then oldest overall)
|
||||||
|
let trimmed = alerts;
|
||||||
|
if (trimmed.length > MAX_ALERTS) {
|
||||||
|
const resolved = trimmed.filter(a => a.resolvedAt !== null);
|
||||||
|
const active = trimmed.filter(a => a.resolvedAt === null);
|
||||||
|
const combined = [...active, ...resolved].slice(0, MAX_ALERTS);
|
||||||
|
trimmed = combined;
|
||||||
|
}
|
||||||
|
writeJson('alerts.json', trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addAlert(alert: AlertRecord): void {
|
||||||
|
const alerts = getAlerts();
|
||||||
|
alerts.unshift(alert);
|
||||||
|
saveAlerts(alerts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAlert(id: string, patch: Partial<AlertRecord>): AlertRecord | null {
|
||||||
|
const alerts = getAlerts();
|
||||||
|
const idx = alerts.findIndex(a => a.id === id);
|
||||||
|
if (idx === -1) return null;
|
||||||
|
alerts[idx] = { ...alerts[idx], ...patch };
|
||||||
|
saveAlerts(alerts);
|
||||||
|
return alerts[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAlert(id: string): boolean {
|
||||||
|
const alerts = getAlerts();
|
||||||
|
const filtered = alerts.filter(a => a.id !== id);
|
||||||
|
if (filtered.length === alerts.length) return false;
|
||||||
|
saveAlerts(filtered);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearResolvedAlerts(): number {
|
||||||
|
const alerts = getAlerts();
|
||||||
|
const active = alerts.filter(a => a.resolvedAt === null);
|
||||||
|
const count = alerts.length - active.length;
|
||||||
|
saveAlerts(active);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveAlerts(metric?: string): AlertRecord[] {
|
||||||
|
const alerts = getAlerts();
|
||||||
|
return alerts.filter(a => a.resolvedAt === null && (!metric || a.metric === metric));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Preferences ──
|
||||||
|
|
||||||
|
const defaultPreferences: NotificationPreferences = {
|
||||||
|
recipients: [
|
||||||
|
{ email: 'nafih@bshtechnologies.in', name: 'Nafih', enabled: true },
|
||||||
|
{ email: 'vivek@bshtechnologies.in', name: 'Vivek', enabled: true },
|
||||||
|
],
|
||||||
|
cooldownMinutes: 15,
|
||||||
|
enabledSeverities: ['critical', 'warning', 'info'],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPreferences(): NotificationPreferences {
|
||||||
|
return readJson<NotificationPreferences>('preferences.json', defaultPreferences);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePreferences(data: NotificationPreferences): void {
|
||||||
|
writeJson('preferences.json', { ...data, updatedAt: new Date().toISOString() });
|
||||||
|
}
|
||||||
36
server/src/services/traffic.ts
Normal file
36
server/src/services/traffic.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { sshManager } from '../ssh/client.js';
|
||||||
|
import type { TrafficInfo } from '../types/index.js';
|
||||||
|
|
||||||
|
export async function getTrafficInfo(): Promise<TrafficInfo> {
|
||||||
|
// Run all traffic checks in a single SSH command for efficiency
|
||||||
|
const script = [
|
||||||
|
// 1. Total established TCP connections to ports 80/443
|
||||||
|
`echo "CONN_TOTAL=$(ss -t state established '( dport = :80 or dport = :443 or sport = :80 or sport = :443 )' 2>/dev/null | tail -n +2 | wc -l)"`,
|
||||||
|
|
||||||
|
// 2. Top IP by connection count + its count
|
||||||
|
`TOP=$(ss -t state established '( dport = :80 or dport = :443 or sport = :80 or sport = :443 )' 2>/dev/null | tail -n +2 | awk '{print $5}' | rev | cut -d: -f2- | rev | sort | uniq -c | sort -rn | head -1); echo "TOP_IP_COUNT=$(echo "$TOP" | awk '{print $1}')"; echo "TOP_IP=$(echo "$TOP" | awk '{print $2}')"`,
|
||||||
|
|
||||||
|
// 3. Nginx request rate (requests in last 60 seconds from access log)
|
||||||
|
`if [ -f /var/log/nginx/access.log ]; then CUTOFF=$(date -d '1 minute ago' '+%d/%b/%Y:%H:%M' 2>/dev/null || date -v-1M '+%d/%b/%Y:%H:%M' 2>/dev/null); REQ=$(awk -v cutoff="$CUTOFF" '$4 >= "["cutoff { count++ } END { print count+0 }' /var/log/nginx/access.log 2>/dev/null); echo "REQ_RATE=$REQ"; else echo "REQ_RATE=0"; fi`,
|
||||||
|
|
||||||
|
// 4. 5xx error rate (5xx responses in last 60 seconds)
|
||||||
|
`if [ -f /var/log/nginx/access.log ]; then CUTOFF=$(date -d '1 minute ago' '+%d/%b/%Y:%H:%M' 2>/dev/null || date -v-1M '+%d/%b/%Y:%H:%M' 2>/dev/null); ERR=$(awk -v cutoff="$CUTOFF" '$4 >= "["cutoff && $9 ~ /^5[0-9][0-9]$/ { count++ } END { print count+0 }' /var/log/nginx/access.log 2>/dev/null); echo "ERR_5XX=$ERR"; else echo "ERR_5XX=0"; fi`,
|
||||||
|
].join(' && ');
|
||||||
|
|
||||||
|
const output = await sshManager.execCommand(script);
|
||||||
|
|
||||||
|
// Parse key=value pairs from output
|
||||||
|
const vars = new Map<string, string>();
|
||||||
|
for (const line of output.split('\n')) {
|
||||||
|
const match = line.match(/^(\w+)=(.*)$/);
|
||||||
|
if (match) vars.set(match[1], match[2].trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalConnections: parseInt(vars.get('CONN_TOTAL') ?? '0', 10) || 0,
|
||||||
|
topIpConnections: parseInt(vars.get('TOP_IP_COUNT') ?? '0', 10) || 0,
|
||||||
|
topIp: vars.get('TOP_IP') ?? 'none',
|
||||||
|
requestsPerMinute: parseInt(vars.get('REQ_RATE') ?? '0', 10) || 0,
|
||||||
|
errors5xxPerMinute: parseInt(vars.get('ERR_5XX') ?? '0', 10) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// ─── Server Health Monitoring API Types ───
|
// ─── Server Health Monitoring API Types ───
|
||||||
|
export * from './notifications.js';
|
||||||
|
|
||||||
export interface SystemInfo {
|
export interface SystemInfo {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
@@ -45,28 +46,12 @@ export interface NginxStatus {
|
|||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── User & Notification Types ───
|
export interface TrafficInfo {
|
||||||
|
totalConnections: number;
|
||||||
export type UserRole = 'developer' | 'server-admin';
|
topIpConnections: number;
|
||||||
|
topIp: string;
|
||||||
export interface MonitorUser {
|
requestsPerMinute: number;
|
||||||
id: string;
|
errors5xxPerMinute: number;
|
||||||
email: string;
|
|
||||||
name: string;
|
|
||||||
role: UserRole;
|
|
||||||
notifyOnError: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Notification {
|
|
||||||
id: string;
|
|
||||||
type: 'error' | 'warning' | 'info' | 'recovery';
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
source: string;
|
|
||||||
timestamp: string;
|
|
||||||
read: boolean;
|
|
||||||
emailSent: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OverviewResponse {
|
export interface OverviewResponse {
|
||||||
@@ -75,6 +60,7 @@ export interface OverviewResponse {
|
|||||||
disk: DiskInfo | null;
|
disk: DiskInfo | null;
|
||||||
docker: DockerContainer[] | null;
|
docker: DockerContainer[] | null;
|
||||||
nginx: NginxStatus | null;
|
nginx: NginxStatus | null;
|
||||||
|
traffic: TrafficInfo | null;
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|||||||
60
server/src/types/notifications.ts
Normal file
60
server/src/types/notifications.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// ─── Notification & Alerting 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 NotificationPreferences {
|
||||||
|
recipients: EmailRecipient[];
|
||||||
|
cooldownMinutes: number;
|
||||||
|
enabledSeverities: AlertSeverity[];
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailRecipient {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationSummary {
|
||||||
|
activeWarnings: number;
|
||||||
|
activeCriticals: number;
|
||||||
|
unacknowledged: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user