diff --git a/client/src/App.tsx b/client/src/App.tsx index a44ff26..3ee718e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 = { 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 case "network": return + case "settings": + return + case "notifications": + return default: return ( <> diff --git a/client/src/components/layout/Sidebar.tsx b/client/src/components/layout/Sidebar.tsx index 27f7f30..463c532 100644 --- a/client/src/components/layout/Sidebar.tsx +++ b/client/src/components/layout/Sidebar.tsx @@ -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[] = [ diff --git a/client/src/components/pages/NotificationsPage.tsx b/client/src/components/pages/NotificationsPage.tsx new file mode 100644 index 0000000..bad7d2e --- /dev/null +++ b/client/src/components/pages/NotificationsPage.tsx @@ -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([]) + 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 ( +
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

+ Notifications + {unreadCount > 0 && ( + + {unreadCount} unread + + )} +

+

Server alerts and error notifications

+
+ {unreadCount > 0 && ( + + )} +
+ + {/* Notification List */} + {notifications.length === 0 ? ( +
+ +

All clear

+

No notifications yet. You'll see alerts here when issues are detected.

+
+ ) : ( +
+ {notifications.map(n => { + const cfg = typeConfig[n.type] + const Icon = cfg.icon + return ( +
!n.read && markRead(n.id)} + className={`cursor-pointer rounded-xl border p-4 transition-colors ${ + n.read ? "bg-card border-border" : `${cfg.bg}` + }`} + > +
+
+ +
+
+
+ {n.title} + + {n.type} + + {n.emailSent ? ( + + ) : ( + + )} + {!n.read && ( + + )} +
+

{n.message}

+
+ {n.source} + · + {timeAgo(n.timestamp)} +
+
+
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/client/src/components/pages/SettingsPage.tsx b/client/src/components/pages/SettingsPage.tsx new file mode 100644 index 0000000..d2ed6a6 --- /dev/null +++ b/client/src/components/pages/SettingsPage.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [showForm, setShowForm] = useState(false) + const [editId, setEditId] = useState(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" + ? + : + + const roleBadge = (role: string) => ( + + {roleIcon(role)} + {role === "server-admin" ? "Server Admin" : "Developer"} + + ) + + return ( +
+ {/* Header */} +
+
+

User Management

+

Manage who can access this dashboard and receive error alerts

+
+ +
+ + {error && ( +
+ {error} + +
+ )} + + {/* Add User Form */} + {showForm && ( +
+

New User

+
+
+ + setForm({ ...form, name: e.target.value })} + /> +
+
+ + setForm({ ...form, email: e.target.value })} + /> +
+
+ + +
+
+ +
+
+
+ + +
+
+ )} + + {/* User List */} + {loading ? ( +
+
+
+ ) : ( +
+
+ User + Role + Alerts + Actions +
+ {users.map(u => ( +
+
+
{u.name}
+
{u.email}
+
+
{roleBadge(u.role)}
+
+ +
+
+ +
+
+ ))} + {users.length === 0 && ( +
+ No users yet. Add your first user above. +
+ )} +
+ )} +
+ ) +} diff --git a/server/package.json b/server/package.json index 6788754..0f06a73 100644 --- a/server/package.json +++ b/server/package.json @@ -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" diff --git a/server/src/index.ts b/server/src/index.ts index 43aaccf..f9b7411 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 = 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 = 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...`); diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts new file mode 100644 index 0000000..d5bc49a --- /dev/null +++ b/server/src/routes/users.ts @@ -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 }); + } +}); diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts new file mode 100644 index 0000000..905b4c5 --- /dev/null +++ b/server/src/services/notifications.ts @@ -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>): 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 { + 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" ', + to: users.map(u => u.email).join(', '), + subject: `[${type.toUpperCase()}] ${title}`, + html: ` +
+
+

${type === 'error' ? 'Error Alert' : 'Warning'}: ${title}

+
+
+

${message}

+

Source: ${source}

+

Time: ${new Date().toLocaleString()}

+
+

+ View Dashboard | Eventify Server Monitor +

+
+
+ `, + }); + 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; +} diff --git a/server/src/types/index.ts b/server/src/types/index.ts index 2829dd6..3416a7f 100644 --- a/server/src/types/index.ts +++ b/server/src/types/index.ts @@ -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;