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:
@@ -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"
|
||||
|
||||
@@ -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...`);
|
||||
|
||||
99
server/src/routes/users.ts
Normal file
99
server/src/routes/users.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
192
server/src/services/notifications.ts
Normal file
192
server/src/services/notifications.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user