feat: add Settings (user management) and Notifications pages

Backend:
- User management API: GET/POST/PUT/DELETE /api/users
  Supports two roles: developer, server-admin
  Each user has notifyOnError toggle for email alerts
- Notification API: GET /api/notifications, mark read, unread count
- Email service: sends alerts via SMTP (mail.bshtech.net/noreply@bshtech.net)
  to all users with notifyOnError=true on error/warning events
- Health monitor: checks CPU, memory, disk, Docker containers every 60s
  Creates notifications + sends email on threshold breaches
  Detects container down, high CPU (>90%), memory (>90%), disk (>85%)
  Sends recovery notifications when issues resolve
- File-based JSON storage for users and notifications (data/ directory)
- Added nodemailer dependency

Frontend:
- Settings page: user list with role badges, email alert toggles,
  add/remove user forms. Roles: Developer (blue), Server Admin (amber)
- Notifications page: real-time alert feed with type icons (error/warning/
  info/recovery), read/unread state, email sent indicator, time ago display
- Added Notifications to sidebar navigation
- Wired both pages into App.tsx router

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 08:23:00 +05:30
parent f98bd60c29
commit a246bee2aa
9 changed files with 836 additions and 0 deletions

View File

@@ -14,6 +14,8 @@ import { ContainersPage } from "@/components/pages/ContainersPage"
import { MemoryPage } from "@/components/pages/MemoryPage"
import { StoragePage } from "@/components/pages/StoragePage"
import { NetworkPage } from "@/components/pages/NetworkPage"
import { SettingsPage } from "@/components/pages/SettingsPage"
import { NotificationsPage } from "@/components/pages/NotificationsPage"
import { LoginPage } from "@/components/ui/animated-characters-login-page"
import { useServerHealth } from "@/hooks/useServerHealth"
import { useCpuLive } from "@/hooks/useCpuLive"
@@ -48,6 +50,8 @@ const PAGE_TITLES: Record<string, string> = {
memory: "Memory",
storage: "Storage",
network: "Network",
settings: "Settings",
notifications: "Notifications",
}
function DashboardContent({ activePage, onLogout }: { activePage: string; onLogout: () => void }) {
@@ -145,6 +149,10 @@ function DashboardContent({ activePage, onLogout }: { activePage: string; onLogo
return <StoragePage disk={disk} />
case "network":
return <NetworkPage containers={containers} nginx={nginx} />
case "settings":
return <SettingsPage />
case "notifications":
return <NotificationsPage />
default:
return (
<>

View File

@@ -4,6 +4,7 @@ import {
MemoryStick,
HardDrive,
Wifi,
Bell,
Settings,
HelpCircle,
Server,
@@ -24,6 +25,7 @@ const mainNav: NavItem[] = [
{ id: "memory", label: "Memory", icon: MemoryStick },
{ id: "storage", label: "Storage", icon: HardDrive },
{ id: "network", label: "Network", icon: Wifi },
{ id: "notifications", label: "Notifications", icon: Bell },
]
const bottomNav: NavItem[] = [

View File

@@ -0,0 +1,169 @@
import { useEffect, useState, useCallback } from "react"
import { AlertTriangle, AlertCircle, Info, CheckCircle2, Mail, MailX, CheckCheck } from "lucide-react"
interface Notification {
id: string
type: "error" | "warning" | "info" | "recovery"
title: string
message: string
source: string
timestamp: string
read: boolean
emailSent: boolean
}
const TOKEN_KEY = "eventify-auth-token"
function getToken() {
return localStorage.getItem(TOKEN_KEY) ?? ""
}
async function apiFetch(path: string, opts: RequestInit = {}) {
const res = await fetch(path, {
...opts,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${getToken()}`,
...opts.headers,
},
})
if (!res.ok) throw new Error("Request failed")
return res.json()
}
function timeAgo(ts: string): string {
const diff = Date.now() - new Date(ts).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return "Just now"
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
const days = Math.floor(hrs / 24)
return `${days}d ago`
}
const typeConfig = {
error: { icon: AlertCircle, color: "text-red-500", bg: "bg-red-50 border-red-200", badge: "bg-red-100 text-red-700" },
warning: { icon: AlertTriangle, color: "text-amber-500", bg: "bg-amber-50 border-amber-200", badge: "bg-amber-100 text-amber-700" },
info: { icon: Info, color: "text-blue-500", bg: "bg-blue-50 border-blue-200", badge: "bg-blue-100 text-blue-700" },
recovery: { icon: CheckCircle2, color: "text-green-500", bg: "bg-green-50 border-green-200", badge: "bg-green-100 text-green-700" },
}
export function NotificationsPage() {
const [notifications, setNotifications] = useState<Notification[]>([])
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
try {
setLoading(true)
const data = await apiFetch("/api/notifications?limit=100")
setNotifications(data)
} catch {
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const markRead = async (id: string) => {
setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: true } : n))
try { await apiFetch(`/api/notifications/${id}/read`, { method: "POST" }) } catch {}
}
const markAllRead = async () => {
setNotifications(prev => prev.map(n => ({ ...n, read: true })))
try { await apiFetch("/api/notifications/read-all", { method: "POST" }) } catch {}
}
const unreadCount = notifications.filter(n => !n.read).length
if (loading) {
return (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
return (
<div className="mx-auto max-w-3xl space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">
Notifications
{unreadCount > 0 && (
<span className="ml-2 inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">
{unreadCount} unread
</span>
)}
</h2>
<p className="text-sm text-muted-foreground">Server alerts and error notifications</p>
</div>
{unreadCount > 0 && (
<button
onClick={markAllRead}
className="inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-muted"
>
<CheckCheck className="size-4" />
Mark all read
</button>
)}
</div>
{/* Notification List */}
{notifications.length === 0 ? (
<div className="rounded-xl border bg-card px-5 py-16 text-center">
<CheckCircle2 className="mx-auto size-12 text-green-400" />
<h3 className="mt-4 font-medium text-foreground">All clear</h3>
<p className="mt-1 text-sm text-muted-foreground">No notifications yet. You'll see alerts here when issues are detected.</p>
</div>
) : (
<div className="space-y-3">
{notifications.map(n => {
const cfg = typeConfig[n.type]
const Icon = cfg.icon
return (
<div
key={n.id}
onClick={() => !n.read && markRead(n.id)}
className={`cursor-pointer rounded-xl border p-4 transition-colors ${
n.read ? "bg-card border-border" : `${cfg.bg}`
}`}
>
<div className="flex items-start gap-3">
<div className={`mt-0.5 ${cfg.color}`}>
<Icon className="size-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">{n.title}</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase ${cfg.badge}`}>
{n.type}
</span>
{n.emailSent ? (
<Mail className="size-3.5 text-green-500" title="Email sent" />
) : (
<MailX className="size-3.5 text-muted-foreground" title="Email not sent" />
)}
{!n.read && (
<span className="size-2 rounded-full bg-blue-500" />
)}
</div>
<p className="mt-1 text-sm text-muted-foreground">{n.message}</p>
<div className="mt-2 flex items-center gap-3 text-xs text-muted-foreground">
<span>{n.source}</span>
<span>&middot;</span>
<span>{timeAgo(n.timestamp)}</span>
</div>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,248 @@
import { useEffect, useState, useCallback } from "react"
import { UserPlus, Trash2, Shield, Code, Bell, BellOff, Pencil, 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 [editId, setEditId] = useState<string | null>(null)
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 handleUpdate = async (id: string) => {
const user = users.find(u => u.id === id)
if (!user) return
try {
setError("")
await apiFetch(`/api/users/${id}`, {
method: "PUT",
body: JSON.stringify({ name: user.name, role: user.role, notifyOnError: user.notifyOnError }),
})
setEditId(null)
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>
)
}

View File

@@ -12,12 +12,14 @@
"dotenv": "^16.4.0",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.3",
"nodemailer": "^6.9.0",
"ssh2": "^1.16.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/nodemailer": "^6.4.0",
"@types/ssh2": "^1.15.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0"

View File

@@ -6,8 +6,10 @@ import { existsSync } from 'fs';
import { config } from './config.js';
import { healthRouter } from './routes/health.js';
import { authRouter } from './routes/auth.js';
import { usersRouter } from './routes/users.js';
import { requireAuth } from './middleware/auth.js';
import { sshManager } from './ssh/client.js';
import { createNotification } from './services/notifications.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -26,6 +28,7 @@ app.use(express.json());
// ── API Routes ──
app.use('/api/auth', authRouter);
app.use('/api', requireAuth, healthRouter);
app.use('/api', requireAuth, usersRouter);
// ── Static file serving for production SPA ──
const clientDistPath = resolve(__dirname, '../../client/dist');
@@ -44,6 +47,95 @@ const server = app.listen(config.port, () => {
console.log(`[Server] SSH target: ${config.ssh.user}@${config.ssh.host}`);
});
// ── 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 ──
async function shutdown(signal: string) {
console.log(`\n[Server] ${signal} received, shutting down gracefully...`);

View File

@@ -0,0 +1,99 @@
// server/src/routes/users.ts
import { Router } from 'express';
import {
getUsers, addUser, updateUser, deleteUser,
getNotifications, markNotificationRead, markAllRead, getUnreadCount,
} from '../services/notifications.js';
export const usersRouter = Router();
// ── User Management ──
// GET /api/users
usersRouter.get('/users', (_req, res) => {
try {
res.json(getUsers());
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/users
usersRouter.post('/users', (req, res) => {
try {
const { email, name, role, notifyOnError } = req.body;
if (!email || !name || !role) {
res.status(400).json({ error: 'email, name, and role are required' });
return;
}
if (!['developer', 'server-admin'].includes(role)) {
res.status(400).json({ error: 'role must be "developer" or "server-admin"' });
return;
}
const user = addUser(email, name, role, notifyOnError ?? true);
res.status(201).json(user);
} catch (err) {
res.status(400).json({ error: (err as Error).message });
}
});
// PUT /api/users/:id
usersRouter.put('/users/:id', (req, res) => {
try {
const user = updateUser(req.params.id, req.body);
res.json(user);
} catch (err) {
res.status(404).json({ error: (err as Error).message });
}
});
// DELETE /api/users/:id
usersRouter.delete('/users/:id', (req, res) => {
try {
deleteUser(req.params.id);
res.json({ success: true });
} catch (err) {
res.status(404).json({ error: (err as Error).message });
}
});
// ── Notifications ──
// GET /api/notifications
usersRouter.get('/notifications', (req, res) => {
try {
const limit = parseInt(req.query.limit as string) || 50;
res.json(getNotifications(limit));
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// GET /api/notifications/unread-count
usersRouter.get('/notifications/unread-count', (_req, res) => {
try {
res.json({ count: getUnreadCount() });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/notifications/:id/read
usersRouter.post('/notifications/:id/read', (req, res) => {
try {
markNotificationRead(req.params.id);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});
// POST /api/notifications/read-all
usersRouter.post('/notifications/read-all', (_req, res) => {
try {
markAllRead();
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: (err as Error).message });
}
});

View File

@@ -0,0 +1,192 @@
// server/src/services/notifications.ts
import { createTransport } from 'nodemailer';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { randomUUID } from 'crypto';
import type { MonitorUser, Notification, UserRole } from '../types/index.js';
// ─── Persistent JSON storage (simple file-based) ───
const DATA_DIR = resolve(process.cwd(), 'data');
const USERS_FILE = resolve(DATA_DIR, 'users.json');
const NOTIFICATIONS_FILE = resolve(DATA_DIR, 'notifications.json');
function ensureDataDir() {
const { mkdirSync } = require('fs');
try { mkdirSync(DATA_DIR, { recursive: true }); } catch {}
}
function loadUsers(): MonitorUser[] {
ensureDataDir();
if (!existsSync(USERS_FILE)) {
// Seed with the existing config users
const seed: MonitorUser[] = [
{ id: randomUUID(), email: 'nafih@bshtechnologies.in', name: 'Nafih', role: 'server-admin', notifyOnError: true, createdAt: new Date().toISOString() },
{ id: randomUUID(), email: 'vivek@bshtechnologies.in', name: 'Vivek', role: 'developer', notifyOnError: true, createdAt: new Date().toISOString() },
];
writeFileSync(USERS_FILE, JSON.stringify(seed, null, 2));
return seed;
}
return JSON.parse(readFileSync(USERS_FILE, 'utf-8'));
}
function saveUsers(users: MonitorUser[]) {
ensureDataDir();
writeFileSync(USERS_FILE, JSON.stringify(users, null, 2));
}
function loadNotifications(): Notification[] {
ensureDataDir();
if (!existsSync(NOTIFICATIONS_FILE)) {
writeFileSync(NOTIFICATIONS_FILE, '[]');
return [];
}
return JSON.parse(readFileSync(NOTIFICATIONS_FILE, 'utf-8'));
}
function saveNotifications(notifs: Notification[]) {
ensureDataDir();
// Keep only last 200 notifications
const trimmed = notifs.slice(-200);
writeFileSync(NOTIFICATIONS_FILE, JSON.stringify(trimmed, null, 2));
}
// ─── SMTP Transport ───
const smtpTransport = createTransport({
host: process.env.SMTP_HOST ?? 'mail.bshtech.net',
port: parseInt(process.env.SMTP_PORT ?? '587'),
secure: false,
auth: {
user: process.env.SMTP_USER ?? 'noreply@bshtech.net',
pass: process.env.SMTP_PASS ?? 'Ev3ntifyN0Reply2026',
},
tls: { rejectUnauthorized: false },
});
// ─── Public API ───
export function getUsers(): MonitorUser[] {
return loadUsers();
}
export function addUser(email: string, name: string, role: UserRole, notifyOnError = true): MonitorUser {
const users = loadUsers();
if (users.find(u => u.email === email)) {
throw new Error(`User with email ${email} already exists`);
}
const user: MonitorUser = {
id: randomUUID(),
email,
name,
role,
notifyOnError,
createdAt: new Date().toISOString(),
};
users.push(user);
saveUsers(users);
return user;
}
export function updateUser(id: string, updates: Partial<Pick<MonitorUser, 'name' | 'role' | 'notifyOnError'>>): MonitorUser {
const users = loadUsers();
const idx = users.findIndex(u => u.id === id);
if (idx < 0) throw new Error('User not found');
users[idx] = { ...users[idx], ...updates };
saveUsers(users);
return users[idx];
}
export function deleteUser(id: string): void {
const users = loadUsers();
const filtered = users.filter(u => u.id !== id);
if (filtered.length === users.length) throw new Error('User not found');
saveUsers(filtered);
}
export function getNotifications(limit = 50): Notification[] {
return loadNotifications().reverse().slice(0, limit);
}
export function markNotificationRead(id: string): void {
const notifs = loadNotifications();
const n = notifs.find(n => n.id === id);
if (n) {
n.read = true;
saveNotifications(notifs);
}
}
export function markAllRead(): void {
const notifs = loadNotifications();
notifs.forEach(n => n.read = true);
saveNotifications(notifs);
}
export function getUnreadCount(): number {
return loadNotifications().filter(n => !n.read).length;
}
export async function createNotification(
type: Notification['type'],
title: string,
message: string,
source: string,
): Promise<Notification> {
const notif: Notification = {
id: randomUUID(),
type,
title,
message,
source,
timestamp: new Date().toISOString(),
read: false,
emailSent: false,
};
// Save to file
const notifs = loadNotifications();
notifs.push(notif);
saveNotifications(notifs);
// Send email to subscribed users
if (type === 'error' || type === 'warning') {
const users = loadUsers().filter(u => u.notifyOnError);
if (users.length > 0) {
try {
await smtpTransport.sendMail({
from: '"Eventify Monitor" <noreply@bshtech.net>',
to: users.map(u => u.email).join(', '),
subject: `[${type.toUpperCase()}] ${title}`,
html: `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background: ${type === 'error' ? '#dc2626' : '#f59e0b'}; color: white; padding: 16px 24px; border-radius: 8px 8px 0 0;">
<h2 style="margin: 0; font-size: 18px;">${type === 'error' ? 'Error Alert' : 'Warning'}: ${title}</h2>
</div>
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 12px; color: #374151;">${message}</p>
<p style="margin: 0 0 8px; color: #6b7280; font-size: 13px;"><strong>Source:</strong> ${source}</p>
<p style="margin: 0; color: #6b7280; font-size: 13px;"><strong>Time:</strong> ${new Date().toLocaleString()}</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;">
<p style="margin: 0; color: #9ca3af; font-size: 12px;">
<a href="https://status.eventifyplus.com" style="color: #2563eb;">View Dashboard</a> | Eventify Server Monitor
</p>
</div>
</div>
`,
});
notif.emailSent = true;
// Update the saved notification
const updated = loadNotifications();
const idx = updated.findIndex(n => n.id === notif.id);
if (idx >= 0) {
updated[idx].emailSent = true;
saveNotifications(updated);
}
console.log(`[Notifications] Email sent to ${users.length} users for: ${title}`);
} catch (err) {
console.error(`[Notifications] Failed to send email:`, err);
}
}
}
return notif;
}

View File

@@ -45,6 +45,30 @@ export interface NginxStatus {
checkedAt: string;
}
// ─── User & Notification Types ───
export type UserRole = 'developer' | 'server-admin';
export interface MonitorUser {
id: string;
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 {
system: SystemInfo | null;
memory: MemoryInfo | null;