From 1e8c15075af667b19af48d10eb1153e588e44c61 Mon Sep 17 00:00:00 2001 From: Soham Date: Tue, 20 Oct 2026 14:38:43 +0530 Subject: [PATCH] Harden network analyzer for internal production Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .env.example | 11 +- .github/workflows/ci.yml | 18 ++ README.md | 16 +- backend/src/auth.test.ts | 2 +- backend/src/auth.ts | 27 +- backend/src/persistence.ts | 89 +++++- backend/src/routes/index.ts | 178 ++++++++++++ backend/src/schemas.test.ts | 23 ++ backend/src/schemas/analytics.ts | 4 + backend/src/schemas/auth.ts | 6 + backend/src/schemas/devices.ts | 10 + backend/src/schemas/reports.ts | 3 + backend/src/schemas/settings.ts | 10 + backend/src/server.ts | 259 ++++++----------- backend/src/services/dashboard.test.ts | 12 + backend/src/services/dashboard.ts | 57 ++++ backend/src/services/hotspot.ts | 5 + backend/src/services/live.test.ts | 17 ++ backend/src/services/live.ts | 90 ++++-- backend/src/services/monitor.ts | 16 ++ backend/src/services/scheduler.test.ts | 2 +- backend/src/services/scheduler.ts | 9 + backend/src/services/settings.ts | 30 +- backend/src/services/simulation.ts | 7 +- backend/src/services/speedtest.ts | 27 +- backend/src/services/target-policy.ts | 61 ++++ database/migrations/001_initial_schema.sql | 74 +++++ database/migrations/002_app_settings.sql | 5 + database/migrations/003_audit_events.sql | 11 + frontend/src/main.tsx | 29 +- frontend/tsconfig.tsbuildinfo | 2 +- package-lock.json | 317 +++++++++++++++++++++ package.json | 10 +- 33 files changed, 1191 insertions(+), 246 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 backend/src/routes/index.ts create mode 100644 backend/src/schemas.test.ts create mode 100644 backend/src/schemas/analytics.ts create mode 100644 backend/src/schemas/auth.ts create mode 100644 backend/src/schemas/devices.ts create mode 100644 backend/src/schemas/reports.ts create mode 100644 backend/src/schemas/settings.ts create mode 100644 backend/src/services/dashboard.test.ts create mode 100644 backend/src/services/dashboard.ts create mode 100644 backend/src/services/monitor.ts create mode 100644 backend/src/services/target-policy.ts create mode 100644 database/migrations/001_initial_schema.sql create mode 100644 database/migrations/002_app_settings.sql create mode 100644 database/migrations/003_audit_events.sql diff --git a/.env.example b/.env.example index 010698e..00ed6bf 100644 --- a/.env.example +++ b/.env.example @@ -2,12 +2,16 @@ PORT=4000 CLIENT_ORIGIN=http://localhost:5173 SIMULATION_MODE=false MONITORING_INTERVAL_SECONDS=30 -MONITORED_TARGETS=192.168.1.1,8.8.8.8,google.com +MONITORED_TARGETS=192.168.1.1 +MONITORING_ALLOWED_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 +MONITORING_ALLOWED_HOSTNAMES= +ALLOW_LOOPBACK_TARGETS=false +ALLOW_PUBLIC_TARGETS=false HOTSPOT_SUBNET=192.168.137.0/24 TSHARK_PATH=C:\Program Files\Wireshark\tshark.exe -JWT_SECRET=replace-with-a-long-random-secret +JWT_SECRET=generate-a-random-secret-at-least-32-characters-long ADMIN_EMAIL=admin@network.local -ADMIN_PASSWORD=change-this-development-password +ADMIN_PASSWORD=replace-with-a-unique-password-at-least-12-characters VIEWER_EMAIL= VIEWER_PASSWORD= DB_HOST=localhost @@ -17,3 +21,4 @@ DB_PASSWORD=change-me DB_NAME=smart_network_monitoring DB_ENABLED=false DB_CONNECTION_LIMIT=10 +MAX_MANUAL_CHECKS=4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1c541c6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,18 @@ +name: CI + +on: + push: + pull_request: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - run: npm run test + - run: npm run build diff --git a/README.md b/README.md index 9ad9b14..7b56bf4 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A full-stack academic network-monitoring project that makes network performance - Express + TypeScript REST API with live Windows ping checks, local interface traffic measurement, optional simulation, a 30-second monitoring cycle, KPI aggregation, health score, alert evaluation, and on-demand checks. - Statistics: mean, median, variance, standard deviation, coefficient of variation, P25/P50/P75/P90/P95/P99, moving average, trend, Pearson correlation, and Z-score/IQR anomalies. - MySQL schema including indexed `network_metrics`, users, devices, alerts, alert rules, and monitoring logs; a seven-day seed script is included. -- Authentication uses bcrypt-backed bootstrap credentials and JWT bearer tokens. Development defaults to `admin@network.local` / `admin123`; set `ADMIN_EMAIL`, `ADMIN_PASSWORD`, and a strong `JWT_SECRET` before use outside local development. Optional viewer credentials can be configured with `VIEWER_EMAIL` and `VIEWER_PASSWORD`. +- Authentication uses bcrypt-backed bootstrap credentials and JWT bearer tokens. `ADMIN_EMAIL`, `ADMIN_PASSWORD`, and a randomly generated `JWT_SECRET` of at least 32 characters are required at startup. Optional viewer credentials can be configured with `VIEWER_EMAIL` and `VIEWER_PASSWORD`. ## Architecture @@ -17,16 +17,16 @@ React client → REST API → Express monitoring service → live Windows ICMP p └──────────────────────────→ MySQL historical data ``` -The browser accesses only the API. Login is required for all API routes except `/api/auth/login`; administrator credentials are required for device creation, alert changes, simulation runs, and settings changes. The current session token is held in browser session storage and expires after eight hours. Monitoring state is still in memory; MySQL persistence is planned for the next phase. +The browser accesses only the API. Login is required for all API routes except `/api/auth/login`; administrator credentials are required for device creation, alert changes, simulation runs, and settings changes. The current session token is held in browser session storage and expires after one hour. Monitoring state is held in memory when persistence is disabled and is restored from MySQL when `DB_ENABLED=true`. ## Run locally -1. Copy `.env.example` to `.env`, set a strong `JWT_SECRET` and non-default `ADMIN_PASSWORD`, and change `MONITORED_TARGETS` to IP addresses or hostnames you are authorized to monitor. For example: `192.168.1.1,192.168.1.10,google.com`. +1. Copy `.env.example` to `.env`, generate a strong secret (for example, `node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"`), set a unique `ADMIN_PASSWORD`, and configure `MONITORING_ALLOWED_CIDRS` or `MONITORING_ALLOWED_HOSTNAMES` for systems you are authorized to monitor. Public targets require `ALLOW_PUBLIC_TARGETS=true`; loopback requires `ALLOW_LOOPBACK_TARGETS=true`. 2. Run `npm install` in the project root. 3. Run `npm run dev`. 4. Open `http://localhost:5173`. -The backend is available at `http://localhost:4000`. Live Mode is the default (`SIMULATION_MODE=false`); it monitors `127.0.0.1` if no targets are configured. Set `SIMULATION_MODE=true` for the built-in demo scenario. To create the database, run `mysql -u root -p < database/schema.sql` then `mysql -u root -p < database/seed.sql`. +The backend is available at `http://localhost:4000`. Live Mode is the default (`SIMULATION_MODE=false`). Set `SIMULATION_MODE=true` for the built-in demo scenario. For a new MySQL database, create the database and run `database/schema.sql`; application startup then applies ordered files in `database/migrations/`. The migration runner records each applied version and is safe to rerun. Seed data can be loaded with `mysql -u root -p < database/seed.sql`. ## Key API endpoints @@ -42,6 +42,10 @@ The backend is available at `http://localhost:4000`. Live Mode is the default (` All endpoints except `POST /api/auth/login` require `Authorization: Bearer `. The login limiter allows five attempts per IP/email key within fifteen minutes. The current bootstrap users are environment-based; database-backed users will be introduced with persistence. +Process health is available at `GET /health`; `GET /ready` reports whether initialization has completed and identifies the active persistence mode. Responses include an `x-request-id` header for operational tracing. + +When MySQL persistence is enabled, application settings are stored in `app_settings` and take precedence over the local configuration file. Settings changes update the scheduler interval, alert latency threshold, and monitoring mode without leaving the previous scheduler or hotspot capture running. + ## Health and anomaly formulas `health = availability×0.30 + latency×0.20 + packet-loss×0.20 + bandwidth×0.20 + errors×0.10`. @@ -62,8 +66,8 @@ When Windows Mobile Hotspot is enabled, the **Hotspot client traffic** panel ide ## Security notes -- Never use the development password or JWT fallback in production. Production startup fails when `JWT_SECRET` or `ADMIN_PASSWORD` is missing. -- Only monitor systems you own or are authorized to test. Target allowlisting and stronger SSRF protections will be added with the live monitoring repository work. +- Never commit secrets. Startup fails when authentication configuration is missing or weak. +- Only monitor systems you own or are authorized to test. Targets are resolved before probing and must match the configured CIDR/hostname policy; loopback, multicast, link-local, broadcast, and cloud metadata addresses are blocked by default. - Logs contain request method, path, status, and a generic error message; passwords, tokens, and request bodies are not logged. ## Future scope diff --git a/backend/src/auth.test.ts b/backend/src/auth.test.ts index 4c852ea..c9e71b1 100644 --- a/backend/src/auth.test.ts +++ b/backend/src/auth.test.ts @@ -3,7 +3,7 @@ import test from 'node:test' import type { NextFunction, Request, Response } from 'express' import { authenticate, issueToken, loginRateLimit, requireRole } from './auth.js' -process.env.JWT_SECRET = 'test-secret-with-enough-entropy' +process.env.JWT_SECRET = 'test-secret-with-enough-entropy-for-auth-tests' const response = () => { let statusCode = 200 diff --git a/backend/src/auth.ts b/backend/src/auth.ts index e51c830..d557790 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -18,6 +18,10 @@ type JwtClaims = { role: UserRole } +const tokenIssuer = process.env.JWT_ISSUER || 'network-analyzer' +const tokenAudience = process.env.JWT_AUDIENCE || 'network-analyzer-client' +const weakPasswords = new Set(['admin123', 'change-this-development-password', 'password', 'password123']) + const claimsSchema = z.object({ id: z.string().min(1), email: z.string().email(), @@ -27,13 +31,24 @@ const claimsSchema = z.object({ const getSecret = () => { const secret = process.env.JWT_SECRET?.trim() - if (!secret && process.env.NODE_ENV === 'production') { - throw new Error('JWT_SECRET must be configured in production') - } - return secret || 'development-only-change-me' + if (!secret) throw new Error('JWT_SECRET must be configured') + if (secret.length < 32 || weakPasswords.has(secret)) throw new Error('JWT_SECRET must be at least 32 characters and not a known development value') + return secret } -export const issueToken = (user: AuthUser) => jwt.sign(user, getSecret(), { expiresIn: '8h' }) +export const validateAuthConfig = () => { + const adminEmail = process.env.ADMIN_EMAIL?.trim() + const adminPassword = process.env.ADMIN_PASSWORD + if (!adminEmail || !z.string().email().safeParse(adminEmail).success) throw new Error('ADMIN_EMAIL must be configured with a valid email address') + if (!adminPassword || adminPassword.length < 12 || weakPasswords.has(adminPassword)) throw new Error('ADMIN_PASSWORD must be at least 12 characters and not a known development value') + getSecret() +} + +export const issueToken = (user: AuthUser) => jwt.sign(user, getSecret(), { + expiresIn: '1h', + issuer: tokenIssuer, + audience: tokenAudience +}) export const authenticate = (req: Request, res: Response, next: NextFunction) => { const header = req.header('authorization') @@ -44,7 +59,7 @@ export const authenticate = (req: Request, res: Response, next: NextFunction) => } try { - const claims = claimsSchema.parse(jwt.verify(token, getSecret())) + const claims = claimsSchema.parse(jwt.verify(token, getSecret(), { issuer: tokenIssuer, audience: tokenAudience })) req.user = claims next() } catch { diff --git a/backend/src/persistence.ts b/backend/src/persistence.ts index 4e1322b..bc8bc44 100644 --- a/backend/src/persistence.ts +++ b/backend/src/persistence.ts @@ -1,6 +1,9 @@ import mysql, { type Pool, type RowDataPacket } from 'mysql2/promise' +import fs from 'node:fs/promises' +import path from 'node:path' import type { AuthUser } from './auth.js' import type { Alert, Device, Metric } from './types.js' +import type { AppSettings } from './services/settings.js' export type StoredUser = AuthUser & { passwordHash: string } @@ -58,9 +61,12 @@ export interface MonitorRepository { saveDevice(device: Device): Promise saveMetric(deviceId: number, metric: Metric): Promise saveAlert(alert: Alert): Promise + saveState(devices: Device[], alerts: Alert[]): Promise saveLog(deviceId: number, result: 'success' | 'failure', responseTime: number | null, errorMessage?: string): Promise findUserByEmail(email: string): Promise upsertUser(user: StoredUser): Promise + loadSettings(): Promise | null> + saveSettings(settings: AppSettings): Promise } class NoopRepository implements MonitorRepository { @@ -73,9 +79,12 @@ class NoopRepository implements MonitorRepository { async saveDevice(_device: Device) {} async saveMetric(_deviceId: number, _metric: Metric) {} async saveAlert(_alert: Alert) {} + async saveState(_devices: Device[], _alerts: Alert[]) {} async saveLog(_deviceId: number, _result: 'success' | 'failure', _responseTime: number | null, _errorMessage?: string) {} async findUserByEmail(_email: string) { return null } async upsertUser(_user: StoredUser) {} + async loadSettings() { return null } + async saveSettings(_settings: AppSettings) {} } class MySqlRepository implements MonitorRepository { @@ -93,7 +102,8 @@ class MySqlRepository implements MonitorRepository { connectionLimit: Number(process.env.DB_CONNECTION_LIMIT || 10), queueLimit: 0, enableKeepAlive: true, - connectTimeout: 5000 + connectTimeout: 5000, + multipleStatements: true }) } @@ -104,14 +114,26 @@ class MySqlRepository implements MonitorRepository { applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ) `) - await this.pool.query(` - CREATE TABLE IF NOT EXISTS app_settings ( - setting_key VARCHAR(100) PRIMARY KEY, - setting_value JSON NOT NULL, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP - ) - `) - await this.pool.query("INSERT IGNORE INTO schema_migrations(version) VALUES ('002-app-settings')") + const migrationsDirectory = path.resolve(process.cwd(), 'database', 'migrations') + const files = (await fs.readdir(migrationsDirectory)).filter(file => /^\d+_[a-z0-9-]+\.sql$/.test(file)).sort() + for (const file of files) { + const version = file.slice(0, file.indexOf('_')) + const [rows] = await this.pool.query('SELECT version FROM schema_migrations WHERE version = ?', [version]) + if (rows.length) continue + const sql = await fs.readFile(path.join(migrationsDirectory, file), 'utf8') + const connection = await this.pool.getConnection() + try { + await connection.beginTransaction() + await connection.query(sql) + await connection.execute('INSERT INTO schema_migrations(version) VALUES (?)', [version]) + await connection.commit() + } catch (error) { + await connection.rollback() + throw new Error(`Migration ${file} failed: ${error instanceof Error ? error.message : String(error)}`) + } finally { + connection.release() + } + } await this.pool.query('SELECT 1') } @@ -184,6 +206,40 @@ class MySqlRepository implements MonitorRepository { ) } + async saveState(devices: Device[], alerts: Alert[]) { + const connection = await this.pool.getConnection() + try { + await connection.beginTransaction() + for (const device of devices) { + await connection.execute( + `INSERT INTO devices(id, name, ip_address, hostname, type, location, monitoring_interval, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE name=VALUES(name), ip_address=VALUES(ip_address), hostname=VALUES(hostname), type=VALUES(type), location=VALUES(location), monitoring_interval=VALUES(monitoring_interval), status=VALUES(status)`, + [device.id, device.name, device.address || null, device.address || null, device.type, device.location, device.monitoringInterval, device.status] + ) + await connection.execute( + `INSERT INTO network_metrics(device_id, timestamp, latency_ms, packet_loss_percent, download_mbps, upload_mbps, availability_percent, error_count, response_time_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [device.id, new Date(device.latest.timestamp), device.latest.latencyMs, device.latest.packetLossPercent, device.latest.downloadMbps, device.latest.uploadMbps, device.latest.availabilityPercent, device.latest.errorCount, device.latest.responseTimeMs] + ) + } + for (const alert of alerts) { + await connection.execute( + `INSERT INTO alerts(id, device_id, metric, value, threshold, severity, message, status, created_at, resolved_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE value=VALUES(value), threshold=VALUES(threshold), severity=VALUES(severity), message=VALUES(message), status=VALUES(status), resolved_at=IF(VALUES(status) = 'resolved', COALESCE(resolved_at, CURRENT_TIMESTAMP), NULL)`, + [alert.id, alert.deviceId, alert.metric, alert.value, alert.threshold, alert.severity, alert.message, alert.status, new Date(alert.createdAt), null] + ) + } + await connection.commit() + } catch (error) { + await connection.rollback() + throw error + } finally { + connection.release() + } + } + async saveLog(deviceId: number, result: 'success' | 'failure', responseTime: number | null, errorMessage?: string) { await this.pool.execute( 'INSERT INTO monitoring_logs(device_id, timestamp, check_type, result, response_time, error_message) VALUES (?, ?, ?, ?, ?, ?)', @@ -204,6 +260,21 @@ class MySqlRepository implements MonitorRepository { [user.name, user.email.toLowerCase(), user.passwordHash, user.role] ) } + + async loadSettings() { + const [rows] = await this.pool.query('SELECT setting_value FROM app_settings WHERE setting_key = ? LIMIT 1', ['application']) + const value = rows[0]?.setting_value + if (!value) return null + return typeof value === 'string' ? JSON.parse(value) as Partial : value as Partial + } + + async saveSettings(settings: AppSettings) { + await this.pool.execute( + `INSERT INTO app_settings(setting_key, setting_value) VALUES (?, ?) + ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value)`, + ['application', JSON.stringify(settings)] + ) + } } const emptyMetric = (timestamp: string): Metric => ({ timestamp, latencyMs: 0, packetLossPercent: 100, downloadMbps: 0, uploadMbps: 0, availabilityPercent: 0, errorCount: 0, responseTimeMs: 0 }) diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts new file mode 100644 index 0000000..b39b526 --- /dev/null +++ b/backend/src/routes/index.ts @@ -0,0 +1,178 @@ +import { Router } from 'express' +import bcrypt from 'bcryptjs' +import { z } from 'zod' +import { authenticate, issueToken, loginRateLimit, requireRole } from '../auth.js' +import { loginSchema } from '../schemas/auth.js' +import { deviceIdSchema, deviceSchema } from '../schemas/devices.js' +import { analyticsMetricSchema, anomalyMethodSchema } from '../schemas/analytics.js' +import { reportPeriodSchema } from '../schemas/reports.js' +import { settingsSchema } from '../schemas/settings.js' +import type { MonitorRepository } from '../persistence.js' +import type { AppSettings } from '../services/settings.js' +import type { MonitorService } from '../services/monitor.js' +import { pearson, statistics } from '../services/statistics.js' +import { createDashboard } from '../services/dashboard.js' +import { speedTestService } from '../services/speedtest.js' + +export type RouteContext = { + repository: MonitorRepository + getMonitor: () => MonitorService + getMode: () => 'live' | 'simulation' + getSettings: () => AppSettings + saveSettings: (settings: AppSettings) => Promise + switchMode: (mode: AppSettings['operatingMode']) => Promise + persistState: () => Promise + persistDiagnostic: (deviceId: number, error?: unknown) => Promise + refreshScheduler: () => void + maxManualChecks: number +} + +export const createApiRouter = (context: RouteContext) => { + const router = Router() + const activeManualChecks = new Set() + const getDashboard = () => createDashboard(context.getMonitor(), context.getMode(), context.getMode() === 'live' ? speedTestService.getLatestResult() : null) + + router.post('/auth/login', loginRateLimit, async (req, res, next) => { + try { + const body = loginSchema.parse(req.body) + const { repository } = context + const adminEmail = process.env.ADMIN_EMAIL || '' + const adminPassword = process.env.ADMIN_PASSWORD || '' + const viewerEmail = process.env.VIEWER_EMAIL || '' + const viewerPassword = process.env.VIEWER_PASSWORD || '' + const storedUser = await repository.findUserByEmail(body.email) + const adminHash = adminPassword ? await bcrypt.hash(adminPassword, 10) : '' + const viewerHash = viewerPassword ? await bcrypt.hash(viewerPassword, 10) : '' + const isStoredUser = Boolean(storedUser && await bcrypt.compare(body.password, storedUser.passwordHash)) + const isAdmin = body.email.toLowerCase() === adminEmail.toLowerCase() && Boolean(adminHash) && await bcrypt.compare(body.password, adminHash) + const isViewer = body.email.toLowerCase() === viewerEmail.toLowerCase() && Boolean(viewerHash) && await bcrypt.compare(body.password, viewerHash) + if (!isStoredUser && !isAdmin && !isViewer) return res.status(401).json({ message: 'Invalid credentials' }) + const user = storedUser && isStoredUser ? { id: storedUser.id, email: storedUser.email, name: storedUser.name, role: storedUser.role } + : isAdmin ? { id: 'admin', email: adminEmail, name: 'Admin User', role: 'admin' as const } + : { id: 'viewer', email: viewerEmail, name: 'Viewer', role: 'viewer' as const } + res.json({ token: issueToken(user), user }) + } catch (error) { next(error) } + }) + router.post('/auth/logout', (_req, res) => res.status(204).end()) + router.get('/dashboard', async (_req, res, next) => { + try { + const monitor = context.getMonitor() + if ('refreshTraffic' in monitor && typeof monitor.refreshTraffic === 'function') { + await monitor.refreshTraffic() + await context.persistState() + } + res.json(getDashboard()) + } catch (error) { next(error) } + }) + router.get('/devices', (_req, res) => res.json(context.getMonitor().devices)) + router.post('/devices', requireRole('admin'), async (req, res, next) => { + try { + const device = context.getMonitor().addDevice(deviceSchema.parse(req.body)) + await context.persistState() + context.refreshScheduler() + res.status(201).json(device) + } catch (error) { next(error) } + }) + router.get('/devices/:id', (req, res, next) => { + try { res.json(context.getMonitor().getDevice(deviceIdSchema.parse(req.params.id))) } catch (error) { next(error) } + }) + router.get('/monitoring/:deviceId', (req, res, next) => { + try { + const id = deviceIdSchema.parse(req.params.deviceId) + context.getMonitor().getDevice(id) + res.json(context.getMonitor().metrics.get(id) || []) + } catch (error) { next(error) } + }) + router.get('/monitoring/:deviceId/latest', (req, res, next) => { + try { res.json(context.getMonitor().getDevice(deviceIdSchema.parse(req.params.deviceId)).latest) } catch (error) { next(error) } + }) + router.post('/monitoring/check/:deviceId', async (req, res, next) => { + try { + const id = deviceIdSchema.parse(req.params.deviceId) + const monitor = context.getMonitor() + monitor.getDevice(id) + if (activeManualChecks.size >= context.maxManualChecks || activeManualChecks.has(id)) { + res.status(429).json({ message: 'A monitoring check is already in progress or capacity is full.' }) + return + } + activeManualChecks.add(id) + try { + const metric = await monitor.check(id) + await context.persistState() + await context.persistDiagnostic(id) + res.json(metric) + } finally { activeManualChecks.delete(id) } + } catch (error) { next(error) } + }) + router.get('/hotspot/clients', async (_req, res, next) => { + try { + const monitor = context.getMonitor() + if (!('getHotspotSnapshot' in monitor) || typeof monitor.getHotspotSnapshot !== 'function') { + res.json({ enabled: false, captureActive: false, message: 'Hotspot client traffic is available only in Live Mode.', clients: [] }) + return + } + res.json(await monitor.getHotspotSnapshot()) + } catch (error) { next(error) } + }) + router.get('/analytics/:deviceId/statistics', (req, res, next) => { + try { + const records = context.getMonitor().metrics.get(deviceIdSchema.parse(req.params.deviceId)) + if (!records) throw Object.assign(new Error('Device not found'), { status: 404 }) + const metric = analyticsMetricSchema.parse(String(req.query.metric || 'latencyMs')) + const method = anomalyMethodSchema.parse(String(req.query.method || 'zscore')) + res.json({ ...statistics(records, metric, method), correlation: pearson(records) }) + } catch (error) { next(error) } + }) + router.get('/analytics/:deviceId/anomalies', (req, res, next) => { + try { + const records = context.getMonitor().metrics.get(deviceIdSchema.parse(req.params.deviceId)) + if (!records) throw Object.assign(new Error('Device not found'), { status: 404 }) + res.json(statistics(records, 'latencyMs', anomalyMethodSchema.parse(String(req.query.method || 'zscore'))).anomalies) + } catch (error) { next(error) } + }) + router.get('/alerts', (_req, res) => res.json(context.getMonitor().alerts)) + router.get('/diagnostics', (_req, res) => res.json(context.getMonitor().diagnostics.slice(0, 100))) + router.put('/alerts/:id/:action', requireRole('admin'), async (req, res, next) => { + try { + const action = z.enum(['acknowledge', 'resolve']).parse(req.params.action) + const alert = context.getMonitor().updateAlert(deviceIdSchema.parse(req.params.id), action === 'acknowledge' ? 'acknowledged' : 'resolved') + await context.repository.saveAlert(alert) + res.json(alert) + } catch (error) { next(error) } + }) + router.post('/simulation/run', requireRole('admin'), async (_req, res, next) => { + try { + await context.getMonitor().runScenario() + await context.persistState() + res.json(getDashboard()) + } catch (error) { next(error) } + }) + router.get('/reports/:period', (req, res, next) => { + try { + const period = reportPeriodSchema.parse(req.params.period) + const data = getDashboard() + res.json({ report: { title: `${period === 'weekly' ? 'Weekly' : 'Daily'} network report`, generatedAt: data.generatedAt, networkAvailability: `${data.kpis.availability.value}%`, averageLatency: `${data.kpis.averageLatency.value} ms`, packetLoss: `${data.kpis.packetLoss.value}%`, averageDownload: `${data.kpis.download.value} Mbps`, activeAlerts: data.alerts.filter(alert => alert.status === 'active').length, topProblematicDevices: data.problematic.slice(0, 3).map(item => `${item.device.name} (${item.score})`).join(', ') } }) + } catch (error) { next(error) } + }) + router.get('/settings', (_req, res) => res.json(context.getSettings())) + router.put('/settings', requireRole('admin'), async (req, res, next) => { + try { + const settings = settingsSchema.parse(req.body) + const previous = context.getSettings() + await context.saveSettings(settings) + if (previous.operatingMode !== settings.operatingMode) await context.switchMode(settings.operatingMode) + const monitor = context.getMonitor() + monitor.setLatencyThreshold(settings.latencyThresholdMs) + monitor.devices.forEach(device => { device.monitoringInterval = settings.checkIntervalSeconds }) + context.refreshScheduler() + res.json(context.getSettings()) + } catch (error) { next(error) } + }) + return router +} + +export const protectApi = () => { + const middleware = Router() + middleware.use((req, res, next) => req.path === '/auth/login' ? next() : authenticate(req, res, next)) + return middleware +} diff --git a/backend/src/schemas.test.ts b/backend/src/schemas.test.ts new file mode 100644 index 0000000..a8b2d85 --- /dev/null +++ b/backend/src/schemas.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { deviceIdSchema } from './schemas/devices.js' +import { reportPeriodSchema } from './schemas/reports.js' +import { settingsSchema } from './schemas/settings.js' + +test('rejects invalid route identifiers and report periods', () => { + assert.throws(() => deviceIdSchema.parse('0')) + assert.throws(() => deviceIdSchema.parse('not-an-id')) + assert.throws(() => reportPeriodSchema.parse('monthly')) +}) + +test('validates complete runtime settings', () => { + const settings = settingsSchema.parse({ + checkIntervalSeconds: 30, + dashboardRefreshMode: 'Every 10 seconds', + latencyThresholdMs: 100, + displayName: 'Admin', + email: 'admin@example.com', + operatingMode: 'live' + }) + assert.equal(settings.operatingMode, 'live') +}) diff --git a/backend/src/schemas/analytics.ts b/backend/src/schemas/analytics.ts new file mode 100644 index 0000000..2c17e98 --- /dev/null +++ b/backend/src/schemas/analytics.ts @@ -0,0 +1,4 @@ +import { z } from 'zod' + +export const analyticsMetricSchema = z.enum(['latencyMs', 'packetLossPercent', 'downloadMbps', 'uploadMbps', 'availabilityPercent']) +export const anomalyMethodSchema = z.enum(['zscore', 'iqr']) diff --git a/backend/src/schemas/auth.ts b/backend/src/schemas/auth.ts new file mode 100644 index 0000000..1800afe --- /dev/null +++ b/backend/src/schemas/auth.ts @@ -0,0 +1,6 @@ +import { z } from 'zod' + +export const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1).max(256) +}) diff --git a/backend/src/schemas/devices.ts b/backend/src/schemas/devices.ts new file mode 100644 index 0000000..ae2068a --- /dev/null +++ b/backend/src/schemas/devices.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +export const deviceIdSchema = z.coerce.number().int().positive() +export const deviceSchema = z.object({ + name: z.string().min(2).max(80), + address: z.string().min(2).max(253), + type: z.string().min(1).max(40).optional(), + location: z.string().max(150).optional(), + monitoringInterval: z.number().int().min(5).max(3600).optional() +}) diff --git a/backend/src/schemas/reports.ts b/backend/src/schemas/reports.ts new file mode 100644 index 0000000..d10d3f3 --- /dev/null +++ b/backend/src/schemas/reports.ts @@ -0,0 +1,3 @@ +import { z } from 'zod' + +export const reportPeriodSchema = z.enum(['daily', 'weekly']) diff --git a/backend/src/schemas/settings.ts b/backend/src/schemas/settings.ts new file mode 100644 index 0000000..0d2b0b0 --- /dev/null +++ b/backend/src/schemas/settings.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +export const settingsSchema = z.object({ + checkIntervalSeconds: z.number().int().min(5).max(3600), + dashboardRefreshMode: z.string().min(1), + latencyThresholdMs: z.number().int().min(1).max(5000), + displayName: z.string().min(1).max(100), + email: z.string().email(), + operatingMode: z.enum(['live', 'simulation']) +}) diff --git a/backend/src/server.ts b/backend/src/server.ts index fb6f9bc..6e13e17 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1,212 +1,137 @@ import 'dotenv/config' +import { randomUUID } from 'node:crypto' import express from 'express' import cors from 'cors' import bcrypt from 'bcryptjs' -import { z } from 'zod' +import { ZodError } from 'zod' import { SimulationService } from './services/simulation.js' import { LiveMonitoringService } from './services/live.js' import { speedTestService } from './services/speedtest.js' -import { pearson, statistics } from './services/statistics.js' -import { settingsService } from './services/settings.js' -import { authenticate, clearExpiredLoginAttempts, issueToken, loginRateLimit, requireRole } from './auth.js' +import { settingsService, type AppSettings } from './services/settings.js' +import { validateAuthConfig, clearExpiredLoginAttempts } from './auth.js' import { createRepository } from './persistence.js' import { MonitoringScheduler } from './services/scheduler.js' +import { createApiRouter, protectApi, type RouteContext } from './routes/index.js' +import type { MonitorService } from './services/monitor.js' const app = express() const port = Number(process.env.PORT || 4000) -let simulationMode = process.env.SIMULATION_MODE === 'true' -let monitor: SimulationService | LiveMonitoringService = simulationMode ? new SimulationService() : new LiveMonitoringService() const repository = createRepository() const scheduler = new MonitoringScheduler() -const adminEmail = process.env.ADMIN_EMAIL || 'admin@network.local' -const adminPassword = process.env.ADMIN_PASSWORD || (process.env.NODE_ENV === 'production' ? '' : 'admin123') -const viewerEmail = process.env.VIEWER_EMAIL || '' -const viewerPassword = process.env.VIEWER_PASSWORD || '' -if (process.env.NODE_ENV === 'production' && (!adminPassword || !process.env.JWT_SECRET)) { - throw new Error('ADMIN_PASSWORD and JWT_SECRET must be configured in production') +const maxManualChecks = Number(process.env.MAX_MANUAL_CHECKS || 4) +let monitor: MonitorService = process.env.SIMULATION_MODE === 'true' ? new SimulationService() : new LiveMonitoringService() +let ready = false + +validateAuthConfig() + +const persistState = async () => { + if (repository.enabled) await repository.saveState(monitor.devices, monitor.alerts) } -const adminPasswordHash = adminPassword ? bcrypt.hash(adminPassword, 10) : Promise.resolve('') -const viewerPasswordHash = viewerPassword ? bcrypt.hash(viewerPassword, 10) : Promise.resolve('') -const persistMonitorState = async () => { +const persistDiagnostic = async (deviceId: number, fallbackError?: unknown) => { if (!repository.enabled) return - await Promise.all(monitor.devices.map(async device => { - await repository.saveDevice(device) - await repository.saveMetric(device.id, device.latest) - })) - await Promise.all(monitor.alerts.map(alert => repository.saveAlert(alert))) + const diagnostic = monitor.diagnostics.find(item => item.deviceId === deviceId) + const error = fallbackError instanceof Error ? fallbackError.message : typeof fallbackError === 'string' ? fallbackError : diagnostic?.message + await repository.saveLog(deviceId, error ? 'failure' : 'success', diagnostic?.responseTimeMs ?? null, error) } const restoreMonitorState = async () => { if (!repository.enabled) return const devices = await repository.loadDevices() if (!devices.length) return - const metrics = await repository.loadMetrics(devices.map(device => device.id)) - const alerts = await repository.loadAlerts() - monitor.hydrate(devices, metrics, alerts) + monitor.hydrate(devices, await repository.loadMetrics(devices.map(device => device.id)), await repository.loadAlerts()) } const ensureBootstrapUsers = async () => { if (!repository.enabled) return - if (adminPassword) await repository.upsertUser({ id: 'admin', email: adminEmail, name: 'Admin User', role: 'admin', passwordHash: await adminPasswordHash }) - if (viewerEmail && viewerPassword) await repository.upsertUser({ id: 'viewer', email: viewerEmail, name: 'Viewer', role: 'viewer', passwordHash: await viewerPasswordHash }) -} - -const persistDiagnostic = async (deviceId: number, fallbackError?: unknown) => { - if (!repository.enabled) return - const diagnostic = monitor.diagnostics.find(item => item.deviceId === deviceId) - const error = fallbackError instanceof Error ? fallbackError.message : typeof fallbackError === 'string' ? fallbackError : diagnostic?.message - await repository.saveLog(deviceId, error ? 'failure' : 'success', diagnostic?.responseTimeMs ?? null, error) + const adminPassword = process.env.ADMIN_PASSWORD || '' + const viewerPassword = process.env.VIEWER_PASSWORD || '' + if (adminPassword) await repository.upsertUser({ id: 'admin', email: process.env.ADMIN_EMAIL || '', name: 'Admin User', role: 'admin', passwordHash: await bcrypt.hash(adminPassword, 10) }) + if (process.env.VIEWER_EMAIL && viewerPassword) await repository.upsertUser({ id: 'viewer', email: process.env.VIEWER_EMAIL, name: 'Viewer', role: 'viewer', passwordHash: await bcrypt.hash(viewerPassword, 10) }) } const schedulerOptions = () => ({ - onCheckComplete: async (deviceId: number) => { await persistMonitorState(); await persistDiagnostic(deviceId) }, + onCheckComplete: async (deviceId: number) => { await persistState(); await persistDiagnostic(deviceId) }, onCheckError: async (deviceId: number, error: unknown) => { console.error(`Scheduled check failed for device ${deviceId}`, error); await persistDiagnostic(deviceId, error) } }) +const switchMode = async (mode: AppSettings['operatingMode']) => { + scheduler.stop() + if (monitor instanceof LiveMonitoringService) monitor.stop() + monitor = mode === 'simulation' ? new SimulationService() : new LiveMonitoringService() + await restoreMonitorState() + monitor.setLatencyThreshold(settingsService.getSettings().latencyThresholdMs) + monitor.devices.forEach(device => { device.monitoringInterval = settingsService.getSettings().checkIntervalSeconds }) + scheduler.start(monitor, schedulerOptions()) +} + +const context: RouteContext = { + repository, + getMonitor: () => monitor, + getMode: () => monitor instanceof SimulationService ? 'simulation' : 'live', + getSettings: () => settingsService.getSettings(), + saveSettings: async settings => { + await settingsService.saveSettings(settings) + if (repository.enabled) await repository.saveSettings(settingsService.getSettings()) + }, + switchMode, + persistState, + persistDiagnostic, + refreshScheduler: () => scheduler.refresh(), + maxManualChecks +} + app.use(cors({ origin: process.env.CLIENT_ORIGIN || 'http://localhost:5173' })) app.use(express.json()) -app.use('/api', (req, res, next) => { - if (req.path === '/auth/login') return next() - authenticate(req, res, next) +app.use((req, res, next) => { + res.setHeader('x-request-id', req.header('x-request-id') || randomUUID()) + next() }) +app.get('/health', (_req, res) => res.json({ status: 'ok' })) +app.get('/ready', (_req, res) => ready ? res.json({ status: 'ready', persistence: repository.enabled ? 'enabled' : 'memory' }) : res.status(503).json({ status: 'not_ready' })) +app.use('/api', protectApi()) +app.use('/api', createApiRouter(context)) setInterval(clearExpiredLoginAttempts, 15 * 60 * 1000).unref() -const dashboard = () => { - const devices = monitor.devices; - const latest = devices.map(d => d.latest); - const avg = (fn: (m: typeof latest[number]) => number) => latest.reduce((s, m) => s + fn(m), 0) / Math.max(latest.length, 1); - const availability = avg(m => m.availabilityPercent); - const latency = avg(m => m.latencyMs); - const loss = avg(m => m.packetLossPercent); - const download = avg(m => m.downloadMbps); - const upload = avg(m => m.uploadMbps); - const errors = avg(m => m.errorCount); - - const speed = !simulationMode ? speedTestService.getLatestResult() : null; - const displayDownload = speed ? speed.downloadMbps : download; - const displayUpload = speed ? speed.uploadMbps : upload; - - const factors = { - availability: availability, - latency: Math.max(0, 100 - latency / 1.8), - packetLoss: Math.max(0, 100 - loss * 15), - bandwidth: Math.min(100, displayDownload / 1.1), - errorRate: Math.max(0, 100 - errors * 20) - }; - const score = Object.entries(factors).reduce((s, [k, v]) => s + v * ({ availability: .3, latency: .2, packetLoss: .2, bandwidth: .2, errorRate: .1 }[k as keyof typeof factors]), 0); - const kpi = (value: number, previous: number, unit: string) => ({ value: +value.toFixed(1), previous: +previous.toFixed(1), unit, trend: Array.from({ length: 12 }, (_, i) => +(value * (.92 + Math.sin(i * .8) * .06)).toFixed(1)) }); - const all = monitor.metrics.get(1) || []; - const traffic = all.slice(-24); - const problematic = devices.map(d => { - const m = d.latest; - const active = monitor.alerts.filter(a => a.deviceId === d.id && a.status === 'active').length; - const risk = Math.min(100, (100 - m.availabilityPercent) * .3 + m.latencyMs * .25 + m.packetLossPercent * 10 + Math.max(0, 60 - m.downloadMbps) * .35 + active * 12); - return { device: d, score: +risk.toFixed(0), level: risk > 65 ? 'Critical' : risk > 35 ? 'Warning' : 'Healthy' } - }).sort((a, b) => b.score - a.score); - - return { - mode: simulationMode ? 'simulation' : 'live', - generatedAt: new Date().toISOString(), - kpis: { - totalDevices: kpi(devices.length, devices.length - 1, ''), - onlineDevices: kpi(devices.filter(d => d.status === 'online').length, devices.length - 1, ''), - offlineDevices: kpi(devices.filter(d => d.status === 'offline').length, 1, ''), - averageLatency: kpi(latency, latency * 1.08, 'ms'), - packetLoss: kpi(loss, loss * .87, '%'), - availability: kpi(availability, availability - .5, '%'), - download: kpi(download, download * .95, 'Mbps'), - upload: kpi(upload, upload * 1.05, 'Mbps') - }, - capacity: speed ? { download: speed.downloadMbps, upload: speed.uploadMbps } : null, - health: { score: +score.toFixed(0), status: score >= 90 ? 'Excellent' : score >= 75 ? 'Healthy' : score >= 55 ? 'Warning' : 'Critical', explanation: score >= 90 ? 'All monitored indicators are within target ranges.' : 'One or more indicators need attention.', factors }, - traffic, devices, alerts: monitor.alerts.slice(0, 20), problematic - }; -} -app.post('/api/auth/login', loginRateLimit, async (req, res, next) => { - try { - const body = z.object({ email: z.string().email(), password: z.string().min(1).max(256) }).parse(req.body) - const storedUser = await repository.findUserByEmail(body.email) - const isStoredUser = Boolean(storedUser && await bcrypt.compare(body.password, storedUser.passwordHash)) - const isAdmin = body.email.toLowerCase() === adminEmail.toLowerCase() && Boolean(adminPassword) && await bcrypt.compare(body.password, await adminPasswordHash) - const isViewer = Boolean(viewerEmail && viewerPassword) && body.email.toLowerCase() === viewerEmail.toLowerCase() && await bcrypt.compare(body.password, await viewerPasswordHash) - if (!isStoredUser && !isAdmin && !isViewer) return res.status(401).json({ message: 'Invalid credentials' }) - const user = storedUser && isStoredUser ? { id: storedUser.id, email: storedUser.email, name: storedUser.name, role: storedUser.role } - : isAdmin - ? { id: 'admin', email: adminEmail, name: 'Admin User', role: 'admin' as const } - : { id: 'viewer', email: viewerEmail, name: 'Viewer', role: 'viewer' as const } - res.json({ token: issueToken(user), user }) - } catch (error) { next(error) } -}) -app.post('/api/auth/logout', (_req, res) => res.status(204).end()) -app.get('/api/dashboard',async(_req,res,next)=>{try{if(monitor instanceof LiveMonitoringService){await monitor.refreshTraffic();await persistMonitorState()}res.json(dashboard())}catch(e){next(e)}}); -app.get('/api/devices',(_req,res)=>res.json(monitor.devices)); -app.get('/api/hotspot/clients', async (_req, res, next) => { - try { - if (!(monitor instanceof LiveMonitoringService)) return res.json({ enabled: false, captureActive: false, message: 'Hotspot client traffic is available only in Live Mode.', clients: [] }) - res.json(await monitor.getHotspotSnapshot()) - } catch (error) { next(error) } -}); -app.post('/api/devices', requireRole('admin'), async (req,res,next)=>{try{const body=z.object({name:z.string().min(2).max(80),address:z.string().min(2).max(253),type:z.string().optional(),location:z.string().optional(),monitoringInterval:z.number().int().min(5).max(3600).optional()}).parse(req.body);const device=monitor.addDevice(body);await persistMonitorState();scheduler.refresh();res.status(201).json(device)}catch(e){next(e)}}); -app.get('/api/devices/:id',(req,res,next)=>{try{res.json(monitor.getDevice(+req.params.id))}catch(e){next(e)}}) -app.get('/api/monitoring/:deviceId',(req,res)=>res.json(monitor.metrics.get(+req.params.deviceId)||[])); -app.get('/api/monitoring/:deviceId/latest',(req,res,next)=>{try{res.json(monitor.getDevice(+req.params.deviceId).latest)}catch(e){next(e)}}) -app.post('/api/monitoring/check/:deviceId',async(req,res,next)=>{try{const deviceId=+req.params.deviceId;const metric=await monitor.check(deviceId);await persistMonitorState();await persistDiagnostic(deviceId);res.json(metric)}catch(e){next(e)}}) -app.get('/api/analytics/:deviceId/statistics',(req,res,next)=>{try{const records=monitor.metrics.get(+req.params.deviceId);if(!records)throw Object.assign(new Error('Device not found'),{status:404});const metric=String(req.query.metric||'latencyMs') as keyof typeof records[number];if(!['latencyMs','packetLossPercent','downloadMbps','uploadMbps','availabilityPercent'].includes(metric))throw Object.assign(new Error('Unsupported metric'),{status:400});res.json({...statistics(records,metric,String(req.query.method||'zscore')),correlation:pearson(records)})}catch(e){next(e)}}) -app.get('/api/analytics/:deviceId/anomalies',(req,res,next)=>{try{const records=monitor.metrics.get(+req.params.deviceId)||[];res.json(statistics(records,'latencyMs',String(req.query.method||'zscore')).anomalies)}catch(e){next(e)}}) -app.get('/api/alerts',(_req,res)=>res.json(monitor.alerts)); -app.get('/api/diagnostics',(_req,res)=>res.json(monitor.diagnostics.slice(0, 100))); -app.put('/api/alerts/:id/:action', requireRole('admin'), async (req,res,next)=>{try{const action=z.enum(['acknowledge','resolve']).parse(req.params.action);const alert=monitor.updateAlert(+req.params.id,action==='acknowledge'?'acknowledged':'resolved');await repository.saveAlert(alert);res.json(alert)}catch(e){next(e)}}) -app.post('/api/simulation/run', requireRole('admin'), async(_req,res,next)=>{try{await monitor.runScenario();await persistMonitorState();res.json(dashboard())}catch(e){next(e)}}); -app.get('/api/reports/:period',(req,res)=>{const data=dashboard();const daily={title:`${req.params.period==='weekly'?'Weekly':'Daily'} network report`,generatedAt:data.generatedAt,networkAvailability:`${data.kpis.availability.value}%`,averageLatency:`${data.kpis.averageLatency.value} ms`,packetLoss:`${data.kpis.packetLoss.value}%`,averageDownload:`${data.kpis.download.value} Mbps`,activeAlerts:data.alerts.filter(a=>a.status==='active').length,topProblematicDevices:data.problematic.slice(0,3).map(x=>`${x.device.name} (${x.score})`).join(', ')};res.json({report:daily})}); - -app.get('/api/settings', (req, res) => res.json(settingsService.getSettings())); -app.put('/api/settings', requireRole('admin'), async (req, res, next) => { - try { - const body = z.object({ - checkIntervalSeconds: z.number().int().min(5).max(3600), - dashboardRefreshMode: z.string(), - latencyThresholdMs: z.number().int().min(1).max(5000), - displayName: z.string().min(1).max(100), - email: z.string().email(), - operatingMode: z.enum(['live', 'simulation']) - }).parse(req.body); - - const oldMode = settingsService.getSettings().operatingMode; - await settingsService.saveSettings(body); - - if (oldMode !== body.operatingMode) { - simulationMode = body.operatingMode === 'simulation'; - monitor = simulationMode ? new SimulationService() : new LiveMonitoringService(); - await restoreMonitorState(); - scheduler.start(monitor, { onCheckComplete: persistMonitorState, onCheckError: (deviceId, error) => console.error(`Scheduled check failed for device ${deviceId}`, error) }); - } - - res.json(settingsService.getSettings()); - } catch (e) { next(e) } -}); - -import { ZodError } from 'zod'; - -app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { - if (err instanceof ZodError) { - const message = err.issues.map(i => `${i.path.join('.')}: ${i.message}`).join(', '); - res.status(400).json({ message: message || 'Validation failed', issues: err.issues }); - return; - } - const e = err as { status?: number; message?: string; issues?: unknown }; - console.error(JSON.stringify({ method: _req.method, path: _req.path, status: e.status || 500, message: e.message || 'Unhandled error' })) - res.status(e.status || 500).json({ message: e.status ? e.message || 'Invalid request' : 'Internal server error', issues: e.status ? e.issues : undefined }); -}); const start = async () => { await repository.initialize() await ensureBootstrapUsers() await settingsService.loadSettings() - simulationMode = settingsService.getSettings().operatingMode === 'simulation'; - monitor = simulationMode ? new SimulationService() : new LiveMonitoringService(); - await restoreMonitorState() - scheduler.start(monitor, { onCheckComplete: persistMonitorState, onCheckError: (deviceId, error) => console.error(`Scheduled check failed for device ${deviceId}`, error) }); - app.listen(port, () => console.log(`Monitoring API listening on http://localhost:${port}`)); + const environmentMode = process.env.SIMULATION_MODE === 'true' ? 'simulation' : process.env.SIMULATION_MODE === 'false' ? 'live' : null + if (environmentMode && settingsService.getSettings().operatingMode !== environmentMode) { + await settingsService.saveSettings({ operatingMode: environmentMode }) + } + if (repository.enabled) { + const storedSettings = await repository.loadSettings() + if (storedSettings) await settingsService.saveSettings(storedSettings) + else await repository.saveSettings(settingsService.getSettings()) + } + await switchMode(settingsService.getSettings().operatingMode) + ready = true + const server = app.listen(port, () => console.log(`Monitoring API listening on http://localhost:${port}`)) + const shutdown = async (signal: string) => { + console.log(`Received ${signal}; shutting down monitoring API`) + ready = false + scheduler.stop() + if (monitor instanceof LiveMonitoringService) monitor.stop() + speedTestService.stop() + await repository.close() + await new Promise(resolve => server.close(() => resolve())) + } + process.once('SIGINT', () => { void shutdown('SIGINT') }) + process.once('SIGTERM', () => { void shutdown('SIGTERM') }) } +app.use((error: unknown, req: express.Request, res: express.Response, _next: express.NextFunction) => { + if (error instanceof ZodError) { + const message = error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join(', ') + res.status(400).json({ message: message || 'Validation failed', issues: error.issues }) + return + } + const typed = error as { status?: number; message?: string; issues?: unknown } + const status = typed.status || 500 + console.error(JSON.stringify({ requestId: res.getHeader('x-request-id'), method: req.method, path: req.path, status, message: typed.message || 'Unhandled error' })) + res.status(status).json({ message: status === 500 ? 'Internal server error' : typed.message || 'Invalid request', issues: status === 500 ? undefined : typed.issues }) +}) + start().catch(error => { console.error('Unable to start monitoring API', error); process.exitCode = 1 }) diff --git a/backend/src/services/dashboard.test.ts b/backend/src/services/dashboard.test.ts new file mode 100644 index 0000000..15af088 --- /dev/null +++ b/backend/src/services/dashboard.test.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createDashboard } from './dashboard.js' +import type { MonitorService } from './monitor.js' + +test('creates a finite empty dashboard without devices', () => { + const monitor = { devices: [], metrics: new Map(), alerts: [], diagnostics: [] } as unknown as MonitorService + const dashboard = createDashboard(monitor, 'simulation', null) + assert.equal(dashboard.kpis.totalDevices.value, 0) + assert.equal(dashboard.traffic.length, 0) + assert.equal(Number.isFinite(dashboard.health.score), true) +}) diff --git a/backend/src/services/dashboard.ts b/backend/src/services/dashboard.ts new file mode 100644 index 0000000..2adf42e --- /dev/null +++ b/backend/src/services/dashboard.ts @@ -0,0 +1,57 @@ +import type { Metric } from '../types.js' +import type { MonitorService } from './monitor.js' + +export const createDashboard = (monitor: MonitorService, mode: 'live' | 'simulation', capacity: { downloadMbps: number; uploadMbps: number } | null) => { + const devices = monitor.devices + const latest = devices.map(device => device.latest) + const avg = (fn: (metric: Metric) => number) => latest.length ? latest.reduce((sum, metric) => sum + fn(metric), 0) / latest.length : 0 + const availability = avg(metric => metric.availabilityPercent) + const latency = avg(metric => metric.latencyMs) + const loss = avg(metric => metric.packetLossPercent) + const download = avg(metric => metric.downloadMbps) + const upload = avg(metric => metric.uploadMbps) + const errors = avg(metric => metric.errorCount) + const displayDownload = capacity?.downloadMbps ?? download + const factors = { + availability, + latency: Math.max(0, 100 - latency / 1.8), + packetLoss: Math.max(0, 100 - loss * 15), + bandwidth: Math.min(100, displayDownload / 1.1), + errorRate: Math.max(0, 100 - errors * 20) + } + const weights = { availability: .3, latency: .2, packetLoss: .2, bandwidth: .2, errorRate: .1 } + const score = Object.entries(factors).reduce((sum, [key, value]) => sum + value * weights[key as keyof typeof weights], 0) + const kpi = (value: number, previous: number, unit: string) => ({ + value: +value.toFixed(1), + previous: +previous.toFixed(1), + unit, + trend: Array.from({ length: 12 }, (_, index) => +(value * (.92 + Math.sin(index * .8) * .06)).toFixed(1)) + }) + const traffic = (monitor.metrics.get(devices[0]?.id || 0) || []).slice(-24) + const problematic = devices.map(device => { + const metric = device.latest + const active = monitor.alerts.filter(alert => alert.deviceId === device.id && alert.status === 'active').length + const risk = Math.min(100, (100 - metric.availabilityPercent) * .3 + metric.latencyMs * .25 + metric.packetLossPercent * 10 + Math.max(0, 60 - metric.downloadMbps) * .35 + active * 12) + return { device, score: +risk.toFixed(0), level: risk > 65 ? 'Critical' : risk > 35 ? 'Warning' : 'Healthy' } + }).sort((left, right) => right.score - left.score) + return { + mode, + generatedAt: new Date().toISOString(), + kpis: { + totalDevices: kpi(devices.length, Math.max(0, devices.length - 1), ''), + onlineDevices: kpi(devices.filter(device => device.status === 'online').length, Math.max(0, devices.length - 1), ''), + offlineDevices: kpi(devices.filter(device => device.status === 'offline').length, 0, ''), + averageLatency: kpi(latency, latency * 1.08, 'ms'), + packetLoss: kpi(loss, loss * .87, '%'), + availability: kpi(availability, availability - .5, '%'), + download: kpi(download, download * .95, 'Mbps'), + upload: kpi(upload, upload * 1.05, 'Mbps') + }, + capacity, + health: { score: +score.toFixed(0), status: score >= 90 ? 'Excellent' : score >= 75 ? 'Healthy' : score >= 55 ? 'Warning' : 'Critical', explanation: score >= 90 ? 'All monitored indicators are within target ranges.' : 'One or more indicators need attention.', factors }, + traffic, + devices, + alerts: monitor.alerts.slice(0, 20), + problematic + } +} diff --git a/backend/src/services/hotspot.ts b/backend/src/services/hotspot.ts index 8a9bf55..23cc452 100644 --- a/backend/src/services/hotspot.ts +++ b/backend/src/services/hotspot.ts @@ -59,6 +59,11 @@ export class HotspotTrafficService { this.hotspot = null } + stop() { + this.stopCapture() + this.clients.clear() + } + private async findHotspotInterface(): Promise { const script = `$ip=Get-NetIPAddress -AddressFamily IPv4 | Where-Object {$_.IPAddress -like '${subnetPrefix}*'} | Select-Object -First 1; if($ip){$adapter=Get-NetAdapter -InterfaceIndex $ip.InterfaceIndex; @{ip=$ip.IPAddress;interfaceAlias=$adapter.Name;interfaceGuid=$adapter.InterfaceGuid;interfaceIndex=$ip.InterfaceIndex}|ConvertTo-Json -Compress}` try { diff --git a/backend/src/services/live.test.ts b/backend/src/services/live.test.ts index 1506567..ed76f15 100644 --- a/backend/src/services/live.test.ts +++ b/backend/src/services/live.test.ts @@ -15,3 +15,20 @@ test('parses packet loss when no response samples are present', () => { const result = parsePingOutput('Packets: Sent = 4, Received = 1, Lost = 3 (75% loss),') assert.deepEqual(result, { received: 1, loss: 75, average: 0 }) }) + +test('parses a successful Linux ping summary', () => { + const output = ` +PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data. +64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.035 ms +64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.053 ms +64 bytes from 127.0.0.1: icmp_seq=3 ttl=64 time=0.024 ms +64 bytes from 127.0.0.1: icmp_seq=4 ttl=64 time=0.080 ms + +--- 127.0.0.1 ping statistics --- +4 packets transmitted, 4 received, 0% packet loss, time 3095ms +rtt min/avg/max/mdev = 0.024/0.048/0.080/0.021 ms + ` + const result = parsePingOutput(output) + assert.deepEqual(result, { received: 4, loss: 0, average: 0.048 }) +}) + diff --git a/backend/src/services/live.ts b/backend/src/services/live.ts index 982d538..da0b67c 100644 --- a/backend/src/services/live.ts +++ b/backend/src/services/live.ts @@ -1,11 +1,11 @@ import { spawn } from 'node:child_process' +import net from 'node:net' import type { Alert, Device, Metric, MonitoringDiagnostic, Status } from '../types.js' import { HotspotTrafficService, type HotspotClient, type HotspotSnapshot } from './hotspot.js' +import { assertTargetSyntax, isConfiguredAddressAllowed, isConfiguredHostname, validateTarget } from './target-policy.js' const now = () => new Date().toISOString() const round = (value: number, digits = 2) => +value.toFixed(digits) -const addressPattern = /^(?=.{1,253}$)(?:localhost|(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9][a-zA-Z0-9-]*|(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3})$/ - function run(command: string, args: string[], timeout = 8000): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { windowsHide: true, shell: false }) @@ -18,14 +18,51 @@ function run(command: string, args: string[], timeout = 8000): Promise { }) } +let lastTrafficSample: { time: number; rxBytes: number; txBytes: number } | null = null + async function localTraffic(): Promise<{ downloadMbps: number; uploadMbps: number }> { - // Windows exposes instantaneous interface byte rates through this performance class. - const command = "$a=Get-CimInstance Win32_PerfFormattedData_Tcpip_NetworkInterface | Where-Object {$_.Name -notmatch 'Loopback|Teredo|isatap'}; @{received=(($a | Measure-Object BytesReceivedPersec -Sum).Sum);sent=(($a | Measure-Object BytesSentPersec -Sum).Sum)} | ConvertTo-Json -Compress" + if (process.platform === 'win32') { + // Windows exposes instantaneous interface byte rates through this performance class. + const command = "$a=Get-CimInstance Win32_PerfFormattedData_Tcpip_NetworkInterface | Where-Object {$_.Name -notmatch 'Loopback|Teredo|isatap'}; @{received=(($a | Measure-Object BytesReceivedPersec -Sum).Sum);sent=(($a | Measure-Object BytesSentPersec -Sum).Sum)} | ConvertTo-Json -Compress" + try { + const raw = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], 15000) + const rates = JSON.parse(raw) as { received?: number; sent?: number } + return { downloadMbps: round(((rates.received || 0) * 8) / 1_000_000), uploadMbps: round(((rates.sent || 0) * 8) / 1_000_000) } + } catch { return { downloadMbps: 0, uploadMbps: 0 } } + } + + // Linux: read /proc/net/dev try { - const raw = await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], 15000) - const rates = JSON.parse(raw) as { received?: number; sent?: number } - return { downloadMbps: round(((rates.received || 0) * 8) / 1_000_000), uploadMbps: round(((rates.sent || 0) * 8) / 1_000_000) } - } catch { return { downloadMbps: 0, uploadMbps: 0 } } + const fs = await import('node:fs/promises') + const content = await fs.readFile('/proc/net/dev', 'utf8') + let totalRx = 0 + let totalTx = 0 + for (const line of content.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('Inter-') || trimmed.startsWith('face') || trimmed.startsWith('lo:')) continue + const parts = trimmed.split(/\s+/) + if (parts.length >= 10) { + totalRx += Number(parts[1]) || 0 + totalTx += Number(parts[9]) || 0 + } + } + const currentTime = Date.now() + if (!lastTrafficSample) { + lastTrafficSample = { time: currentTime, rxBytes: totalRx, txBytes: totalTx } + return { downloadMbps: 0, uploadMbps: 0 } + } + const timeDiffSec = (currentTime - lastTrafficSample.time) / 1000 + if (timeDiffSec <= 0) return { downloadMbps: 0, uploadMbps: 0 } + const rxRate = Math.max(0, (totalRx - lastTrafficSample.rxBytes) / timeDiffSec) + const txRate = Math.max(0, (totalTx - lastTrafficSample.txBytes) / timeDiffSec) + lastTrafficSample = { time: currentTime, rxBytes: totalRx, txBytes: totalTx } + return { + downloadMbps: round((rxRate * 8) / 1_000_000), + uploadMbps: round((txRate * 8) / 1_000_000) + } + } catch { + return { downloadMbps: 0, uploadMbps: 0 } + } } import os from 'node:os' @@ -44,10 +81,13 @@ async function physicalNetworkOnline(): Promise { const isLoopback = (address: string) => address === 'localhost' || address === '::1' || /^127\./.test(address) export const parsePingOutput = (output: string) => { - const received = Number(output.match(/Received\s*=\s*(\d+)/i)?.[1] || 0) - const loss = Number(output.match(/\((\d+)%\s*loss\)/i)?.[1] || (100 - received / 4 * 100)) - const samples = [...output.matchAll(/time[=<]\s*(\d+)\s*ms/gi)].map(match => Number(match[1])) - const average = Number(output.match(/Average\s*=\s*(\d+)ms/i)?.[1] || (samples.length ? samples.reduce((sum, value) => sum + value, 0) / samples.length : 0)) + const receivedMatch = output.match(/Received\s*=\s*(\d+)/i) || output.match(/(\d+)\s+(?:packets\s+)?received/i) + const received = Number(receivedMatch?.[1] || 0) + const lossMatch = output.match(/\((\d+)%\s*loss\)/i) || output.match(/(\d+(?:\.\d+)?)%\s*(?:packet\s+)?loss/i) + const loss = lossMatch ? Number(lossMatch[1]) : (100 - (received / 4) * 100) + const samples = [...output.matchAll(/time[=<]\s*([\d.]+)\s*ms/gi)].map(match => Number(match[1])) + const avgMatch = output.match(/Average\s*=\s*(\d+)ms/i) || output.match(/rtt [^=]+=\s*[\d.]+\/([\d.]+)\//i) + const average = Number(avgMatch?.[1] || (samples.length ? samples.reduce((sum, value) => sum + value, 0) / samples.length : 0)) return { received, loss, average } } @@ -55,7 +95,12 @@ async function probe(address: string): Promise<{ status: Status; metric: Metric; const count = 4 if (isLoopback(address) && !(await physicalNetworkOnline())) return { status: 'offline', metric: { timestamp: now(), latencyMs: 0, packetLossPercent: 100, downloadMbps: 0, uploadMbps: 0, availabilityPercent: 0, errorCount: count, responseTimeMs: 0 }, errorMessage: 'No active physical IPv4 network interface.' } try { - const output = await run('ping', ['-n', String(count), '-w', '1000', address], 6500) + await validateTarget(address) + const isWin = process.platform === 'win32' + const pingArgs = isWin + ? ['-n', String(count), '-w', '1000', address] + : ['-c', String(count), '-W', '1', address] + const output = await run('ping', pingArgs, 6500) const { received, loss, average } = parsePingOutput(output) const traffic = await localTraffic() const online = received > 0 @@ -69,17 +114,28 @@ async function probe(address: string): Promise<{ status: Status; metric: Metric; export class LiveMonitoringService { devices: Device[] = []; metrics = new Map(); alerts: Alert[] = []; diagnostics: MonitoringDiagnostic[] = []; private alertId = 1 + private latencyThreshold = 100 private hotspotTraffic = new HotspotTrafficService() private hotspotClients: HotspotClient[] = [] constructor() { - const targets = (process.env.MONITORED_TARGETS || '127.0.0.1').split(',').map(value => value.trim()).filter(value => addressPattern.test(value)) + const configuredTargets = (process.env.MONITORED_TARGETS || '').split(',').map(value => value.trim()).filter(Boolean) + configuredTargets.forEach(address => { + const normalized = assertTargetSyntax(address) + if (net.isIPv4(normalized) && !isConfiguredAddressAllowed(normalized)) throw new Error(`Target ${address} is outside the configured monitoring policy`) + if (!net.isIPv4(normalized) && !isConfiguredHostname(normalized) && process.env.ALLOW_PUBLIC_TARGETS !== 'true') { + throw new Error(`Hostname ${address} is not included in MONITORING_ALLOWED_HOSTNAMES`) + } + }) + const targets = configuredTargets targets.forEach((address, index) => this.addDevice({ name: address === '127.0.0.1' ? 'This computer' : `Target-${index + 1}`, address, type: 'Computer', location: 'Real network', monitoringInterval: Number(process.env.MONITORING_INTERVAL_SECONDS || 30) })) } hydrate(devices: Device[], metrics: Map, alerts: Alert[]) { if (!devices.length) return; this.devices=devices; this.metrics=metrics; this.alerts=alerts; this.devices.forEach(device => { const history=this.metrics.get(device.id) || []; if (history.length) { device.latest=history.at(-1)!; device.lastChecked=device.latest.timestamp } }); this.alertId=Math.max(0,...alerts.map(alert=>alert.id))+1 } getDevice(id: number) { const device = this.devices.find(item => item.id === id); if (!device) throw Object.assign(new Error('Device not found'), { status: 404 }); return device } addDevice(input: Partial) { const address = input.address?.trim() || '' - if (!addressPattern.test(address)) throw Object.assign(new Error('Enter a valid IPv4 address or hostname.'), { status: 400 }) + assertTargetSyntax(address) + if (net.isIPv4(address) && !isConfiguredAddressAllowed(address)) throw Object.assign(new Error('Target is outside the configured monitoring policy.'), { status: 400 }) + if (!net.isIPv4(address) && !isConfiguredHostname(address) && process.env.ALLOW_PUBLIC_TARGETS !== 'true') throw Object.assign(new Error('Hostname is not included in MONITORING_ALLOWED_HOSTNAMES.'), { status: 400 }) if (this.devices.some(device => device.address.toLowerCase() === address.toLowerCase())) throw Object.assign(new Error('This address is already monitored.'), { status: 409 }) const id = Math.max(...this.devices.map(device => device.id), 0) + 1 const metric: Metric = { timestamp: now(), latencyMs: 0, packetLossPercent: 0, downloadMbps: 0, uploadMbps: 0, availabilityPercent: 0, errorCount: 0, responseTimeMs: 0 } @@ -129,9 +185,11 @@ export class LiveMonitoringService { } async runScenario() { await this.tick(); return 0 } async getHotspotSnapshot(): Promise { const snapshot = await this.hotspotTraffic.snapshot(); this.hotspotClients = snapshot.clients; return snapshot } + stop() { this.hotspotTraffic.stop() } + setLatencyThreshold(threshold: number) { this.latencyThreshold = threshold } updateAlert(id: number, status: 'acknowledged' | 'resolved') { const alert = this.alerts.find(item => item.id === id); if (!alert) throw Object.assign(new Error('Alert not found'), { status: 404 }); alert.status = status; return alert } private evaluate(device: Device, metric: Metric) { - const checks: [string, number, number, string][] = [['latencyMs', metric.latencyMs, 100, 'High latency'], ['packetLossPercent', metric.packetLossPercent, 3, 'High packet loss']] + const checks: [string, number, number, string][] = [['latencyMs', metric.latencyMs, this.latencyThreshold, 'High latency'], ['packetLossPercent', metric.packetLossPercent, 3, 'High packet loss']] if (device.status === 'offline') checks.push(['availabilityPercent', 0, 95, 'Device offline']) for (const [metricName, value, threshold, label] of checks) { const matches = metricName === 'availabilityPercent' ? value < threshold : value > threshold diff --git a/backend/src/services/monitor.ts b/backend/src/services/monitor.ts new file mode 100644 index 0000000..5d7f096 --- /dev/null +++ b/backend/src/services/monitor.ts @@ -0,0 +1,16 @@ +import type { Alert, Device, Metric, MonitoringDiagnostic } from '../types.js' + +export interface MonitorService { + devices: Device[] + metrics: Map + alerts: Alert[] + diagnostics: MonitoringDiagnostic[] + check(deviceId: number): Promise + tick(): Promise + runScenario(): Promise + getDevice(id: number): Device + addDevice(input: Partial): Device + hydrate(devices: Device[], metrics: Map, alerts: Alert[]): void + updateAlert(id: number, status: 'acknowledged' | 'resolved'): Alert + setLatencyThreshold(threshold: number): void +} diff --git a/backend/src/services/scheduler.test.ts b/backend/src/services/scheduler.test.ts index 7118df4..22ffe5c 100644 --- a/backend/src/services/scheduler.test.ts +++ b/backend/src/services/scheduler.test.ts @@ -28,7 +28,7 @@ test('does not overlap checks when the scheduler is refreshed mid-probe', async } const scheduler = new MonitoringScheduler() scheduler.start(target) - await new Promise(resolve => setImmediate(resolve)) + await new Promise(resolve => setTimeout(resolve, 20)) assert.equal(calls, 1) assert.equal(scheduler.isRunning(device.id), true) scheduler.refresh() diff --git a/backend/src/services/scheduler.ts b/backend/src/services/scheduler.ts index c592b5d..a60c906 100644 --- a/backend/src/services/scheduler.ts +++ b/backend/src/services/scheduler.ts @@ -47,6 +47,15 @@ export class MonitoringScheduler { } } + setIntervalSeconds(seconds: number) { + if (!Number.isInteger(seconds) || seconds < 5 || seconds > 3600) throw new Error('Scheduler interval must be between 5 and 3600 seconds') + for (const [deviceId, timer] of this.timers) { + clearTimeout(timer) + this.timers.delete(deviceId) + } + this.refresh() + } + isRunning(deviceId: number) { return this.running.has(deviceId) } scheduledDeviceIds() { return [...this.timers.keys()].sort((left, right) => left - right) } diff --git a/backend/src/services/settings.ts b/backend/src/services/settings.ts index dc2d214..5ffaebd 100644 --- a/backend/src/services/settings.ts +++ b/backend/src/services/settings.ts @@ -1,5 +1,6 @@ import fs from 'fs/promises'; import path from 'path'; +import { z } from 'zod'; export interface AppSettings { checkIntervalSeconds: number; @@ -16,9 +17,18 @@ const defaultSettings: AppSettings = { latencyThresholdMs: 100, displayName: 'Admin User', email: 'admin@example.com', - operatingMode: 'live' + operatingMode: process.env.SIMULATION_MODE === 'true' ? 'simulation' : 'live' }; +const settingsSchema = z.object({ + checkIntervalSeconds: z.number().int().min(5).max(3600), + dashboardRefreshMode: z.string().min(1), + latencyThresholdMs: z.number().int().min(1).max(5000), + displayName: z.string().min(1).max(100), + email: z.string().email(), + operatingMode: z.enum(['live', 'simulation']) +}); + export class SettingsService { private configPath = path.join(process.cwd(), 'config.json'); private settings: AppSettings = { ...defaultSettings }; @@ -26,16 +36,22 @@ export class SettingsService { async loadSettings() { try { const data = await fs.readFile(this.configPath, 'utf8'); - this.settings = { ...defaultSettings, ...JSON.parse(data) }; - } catch (e) { - // File doesn't exist or is invalid, use defaults - await this.saveSettings(this.settings); + this.settings = settingsSchema.parse(JSON.parse(data)); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + await this.saveSettings(defaultSettings); + return; + } + throw new Error(`Unable to load settings from ${this.configPath}: ${error instanceof Error ? error.message : String(error)}`); } } async saveSettings(newSettings: Partial) { - this.settings = { ...this.settings, ...newSettings }; - await fs.writeFile(this.configPath, JSON.stringify(this.settings, null, 2)); + const next = settingsSchema.parse({ ...this.settings, ...newSettings }); + const temporaryPath = `${this.configPath}.${process.pid}.tmp`; + await fs.writeFile(temporaryPath, JSON.stringify(next, null, 2), { mode: 0o600 }); + await fs.rename(temporaryPath, this.configPath); + this.settings = next; } getSettings() { diff --git a/backend/src/services/simulation.ts b/backend/src/services/simulation.ts index 4eb14c0..1b1e817 100644 --- a/backend/src/services/simulation.ts +++ b/backend/src/services/simulation.ts @@ -1,9 +1,11 @@ import type { Alert, Device, Metric, MonitoringDiagnostic, Status } from '../types.js' +import { assertTargetSyntax, isConfiguredAddressAllowed, isConfiguredHostname } from './target-policy.js' const now = () => new Date().toISOString(); const clamp=(v:number,min:number,max:number)=>Math.min(max,Math.max(min,v)); const round=(v:number,d=1)=>+v.toFixed(d) const seedDevices=[['Edge-Router-01','192.168.1.1','Router','Core Room'],['Core-Switch-01','192.168.1.2','Switch','Core Room'],['App-Server-01','192.168.1.10','Server','Server Rack A'],['Database-01','192.168.1.11','Server','Server Rack B'],['Lab-PC-01','192.168.1.31','Computer','Computer Lab'],['Lab-PC-02','192.168.1.32','Computer','Computer Lab'],['West-AP-01','192.168.1.41','Access Point','West Wing'],['East-AP-01','192.168.1.42','Access Point','East Wing'],['Security-Cam-01','192.168.1.61','IoT Device','Main Entrance'],['IoT-Gateway-01','192.168.1.62','IoT Device','Floor 2'],['Print-Server-01','192.168.1.70','Server','Admin Office'],['Backup-Server-01','192.168.1.72','Server','Server Rack B']] export class SimulationService { devices: Device[]=[]; metrics=new Map(); alerts:Alert[]=[]; diagnostics:MonitoringDiagnostic[]=[]; incident=0; private alertId=1; + private latencyThreshold = 100 constructor(){ this.devices=seedDevices.map((d,i)=>{const latest=this.makeMetric(i+1, i===7?'warning':'online',new Date());return {id:i+1,name:d[0],address:d[1],type:d[2],location:d[3],status:i===7?'warning':'online',monitoringInterval:30,createdAt:now(),lastChecked:now(),latest}}); this.devices.forEach(d=>{const history:Metric[]=[];for(let i=143;i>=0;i--){const time=new Date(Date.now()-i*30*60*1000);history.push(this.makeMetric(d.id,d.status,time,i%41===0&&d.id===8));}this.metrics.set(d.id,history);d.latest=history.at(-1)!}); this.evaluateAll() } hydrate(devices: Device[], metrics: Map, alerts: Alert[]) { if (!devices.length) return; this.devices=devices; this.metrics=metrics; this.alerts=alerts; this.devices.forEach(device => { const history=this.metrics.get(device.id) || []; if (history.length) { device.latest=history.at(-1)!; device.lastChecked=device.latest.timestamp } }); this.alertId=Math.max(0,...alerts.map(alert=>alert.id))+1 } private makeMetric(id:number,status:Status,timestamp:Date,anomaly=false):Metric { const phase=(Date.now()/600000+id*1.7); const jitter=Math.sin(phase*2.3)*4+Math.cos(phase*1.17)*2; const troubled=status==='warning'||anomaly; const offline=status==='offline'; const latency=offline?0:clamp(34+jitter+(troubled?65:0),8,220); const loss=offline?100:clamp(.35+Math.max(0,jitter)/7+(troubled?5.4:0),0,25); const download=offline?0:clamp(87+Math.sin(phase)*15-(troubled?58:0),1,160); const upload=offline?0:clamp(31+Math.cos(phase*1.4)*6-(troubled?20:0),.2,70); return {timestamp:timestamp.toISOString(),latencyMs:round(latency),packetLossPercent:round(loss,2),downloadMbps:round(download),uploadMbps:round(upload),availabilityPercent:offline?0:round(100-loss*.12),errorCount:offline?5:troubled?2:0,responseTimeMs:round(latency+2)} } @@ -11,8 +13,9 @@ export class SimulationService { async tick(){await Promise.all(this.devices.map(d=>this.check(d.id)))} async runScenario(){this.incident=(this.incident+1)%5;await this.tick();return this.incident} getDevice(id:number){const device=this.devices.find(d=>d.id===id);if(!device)throw Object.assign(new Error('Device not found'),{status:404});return device} - addDevice(input:Partial){const id=Math.max(...this.devices.map(d=>d.id),0)+1;const metric=this.makeMetric(id,'online',new Date());const device:Device={id,name:input.name||`Device-${id}`,address:input.address||'',type:input.type||'Computer',location:input.location||'Unspecified',status:'online',monitoringInterval:input.monitoringInterval||30,createdAt:now(),lastChecked:now(),latest:metric};this.devices.push(device);this.metrics.set(id,[metric]);return device} + addDevice(input:Partial){const address=assertTargetSyntax(input.address||'');if (!isConfiguredAddressAllowed(address) && !isConfiguredHostname(address)) throw Object.assign(new Error('Target is outside the configured monitoring policy.'),{status:400});const id=Math.max(...this.devices.map(d=>d.id),0)+1;const metric=this.makeMetric(id,'online',new Date());const device:Device={id,name:input.name||address,address,type:input.type||'Computer',location:input.location||'Unspecified',status:'online',monitoringInterval:input.monitoringInterval||30,createdAt:now(),lastChecked:now(),latest:metric};this.devices.push(device);this.metrics.set(id,[metric]);return device } private evaluateAll(){this.devices.forEach(d=>this.evaluate(d,d.latest))} - private evaluate(device:Device,metric:Metric){const checks:[string,number,number,string][]=[['latencyMs',metric.latencyMs,100,'High latency'],['packetLossPercent',metric.packetLossPercent,3,'High packet loss'],['downloadMbps',metric.downloadMbps,10,'Low bandwidth']];if(device.status==='offline')checks.push(['availabilityPercent',0,95,'Device offline']);for(const [metricName,value,threshold,label] of checks){const match=metricName==='downloadMbps'?valuethreshold;if(match&&!this.alerts.some(a=>a.deviceId===device.id&&a.metric===metricName&&a.status==='active'))this.alerts.unshift({id:this.alertId++,deviceId:device.id,deviceName:device.name,metric:metricName,value:round(value),threshold,severity:device.status==='offline'||value>(threshold*1.5)?'critical':'warning',message:`${label}: ${device.name}`,status:'active',createdAt:metric.timestamp})}} + private evaluate(device:Device,metric:Metric){const checks:[string,number,number,string][]=[['latencyMs',metric.latencyMs,this.latencyThreshold,'High latency'],['packetLossPercent',metric.packetLossPercent,3,'High packet loss'],['downloadMbps',metric.downloadMbps,10,'Low bandwidth']];if(device.status==='offline')checks.push(['availabilityPercent',0,95,'Device offline']);for(const [metricName,value,threshold,label] of checks){const match=metricName==='downloadMbps'?valuethreshold;if(match&&!this.alerts.some(a=>a.deviceId===device.id&&a.metric===metricName&&a.status==='active'))this.alerts.unshift({id:this.alertId++,deviceId:device.id,deviceName:device.name,metric:metricName,value:round(value),threshold,severity:device.status==='offline'||value>(threshold*1.5)?'critical':'warning',message:`${label}: ${device.name}`,status:'active',createdAt:metric.timestamp})}} updateAlert(id:number,status:'acknowledged'|'resolved'){const a=this.alerts.find(x=>x.id===id);if(!a)throw Object.assign(new Error('Alert not found'),{status:404});a.status=status;return a} + setLatencyThreshold(threshold:number){this.latencyThreshold=threshold} } diff --git a/backend/src/services/speedtest.ts b/backend/src/services/speedtest.ts index 478ccf4..87f7d8c 100644 --- a/backend/src/services/speedtest.ts +++ b/backend/src/services/speedtest.ts @@ -2,9 +2,14 @@ export class SpeedTestService { private lastResult: { downloadMbps: number; uploadMbps: number; timestamp: string } | null = null; private isRunning = false; private intervalTimer: NodeJS.Timeout | null = null; + private startupTimer: NodeJS.Timeout | null = null; + private controller: AbortController | null = null; constructor() { - setTimeout(() => this.runTest(), 5000); + this.startupTimer = setTimeout(() => { + this.startupTimer = null; + void this.runTest(); + }, 5000); this.startInterval(30 * 60 * 1000); } @@ -13,15 +18,26 @@ export class SpeedTestService { this.intervalTimer = setInterval(() => this.runTest(), ms); } + stop() { + if (this.startupTimer) clearTimeout(this.startupTimer); + this.startupTimer = null; + if (this.intervalTimer) clearInterval(this.intervalTimer); + this.intervalTimer = null; + this.controller?.abort(); + this.controller = null; + } + async runTest() { if (this.isRunning) return; this.isRunning = true; + this.controller = new AbortController(); try { console.log('Running internet speed test (this takes a few seconds)...'); const dlBytes = 25000000; const dlStart = performance.now(); - const dlRes = await fetch(`https://speed.cloudflare.com/__down?bytes=${dlBytes}`); + const dlRes = await fetch(`https://speed.cloudflare.com/__down?bytes=${dlBytes}`, { signal: this.controller.signal }); + if (!dlRes.ok) throw new Error(`Download test returned HTTP ${dlRes.status}`) await dlRes.arrayBuffer(); const dlEnd = performance.now(); const dlDurationSec = (dlEnd - dlStart) / 1000; @@ -30,11 +46,13 @@ export class SpeedTestService { const ulBytes = 5000000; const payload = new Uint8Array(ulBytes); const ulStart = performance.now(); - await fetch('https://speed.cloudflare.com/__up', { + const ulRes = await fetch('https://speed.cloudflare.com/__up', { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, - body: payload + body: payload, + signal: this.controller.signal }); + if (!ulRes.ok) throw new Error(`Upload test returned HTTP ${ulRes.status}`) const ulEnd = performance.now(); const ulDurationSec = (ulEnd - ulStart) / 1000; const uploadMbps = (ulBytes * 8 / 1000000) / ulDurationSec; @@ -49,6 +67,7 @@ export class SpeedTestService { console.error('Internet speed test failed:', e instanceof Error ? e.message : e); } finally { this.isRunning = false; + this.controller = null; } } diff --git a/backend/src/services/target-policy.ts b/backend/src/services/target-policy.ts new file mode 100644 index 0000000..fb91dd5 --- /dev/null +++ b/backend/src/services/target-policy.ts @@ -0,0 +1,61 @@ +import dns from 'node:dns/promises' +import net from 'node:net' + +const ipv4ToNumber = (address: string) => address.split('.').reduce((value, octet) => (value * 256) + Number(octet), 0) + +const configuredCidrs = () => (process.env.MONITORING_ALLOWED_CIDRS || '10.0.0.0/8,172.16.0.0/12,192.168.0.0/16') + .split(',').map(value => value.trim()).filter(Boolean) + +const configuredHostnames = () => new Set((process.env.MONITORING_ALLOWED_HOSTNAMES || '') + .split(',').map(value => value.trim().toLowerCase()).filter(Boolean)) + +const matchesCidr = (address: string, cidr: string) => { + const [network, prefixText] = cidr.split('/') + const prefix = Number(prefixText) + if (net.isIPv4(address) !== true || !net.isIPv4(network) || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0 + return (ipv4ToNumber(address) & mask) === (ipv4ToNumber(network) & mask) +} + +const isForbiddenAddress = (address: string) => { + if (!net.isIPv4(address)) return true + const value = ipv4ToNumber(address) + const loopback = (value >>> 24) === 127 + const linkLocal = (value >>> 24) === 169 && ((value >>> 16) & 255) === 254 + const multicast = (value >>> 28) === 14 + const broadcast = value === 0xffffffff + const metadata = value === ipv4ToNumber('169.254.169.254') + if (metadata || linkLocal || multicast || broadcast) return true + if (loopback && process.env.ALLOW_LOOPBACK_TARGETS !== 'true') return true + return false +} + +const isAllowedAddress = (address: string) => { + if (isForbiddenAddress(address)) return false + if (process.env.ALLOW_PUBLIC_TARGETS === 'true') return true + return configuredCidrs().some(cidr => matchesCidr(address, cidr)) +} + +export const isConfiguredAddressAllowed = (address: string) => isAllowedAddress(address) + +export const assertTargetSyntax = (address: string) => { + const normalized = address.trim().toLowerCase() + if (!normalized || normalized.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/.test(normalized)) { + throw Object.assign(new Error('Enter a valid IPv4 address or hostname.'), { status: 400 }) + } + return normalized +} + +export const validateTarget = async (address: string) => { + const normalized = assertTargetSyntax(address) + const addresses = net.isIPv4(normalized) ? [normalized] : (await dns.lookup(normalized, { all: true })).map(result => result.address) + if (!addresses.length || addresses.some(resolved => !isAllowedAddress(resolved))) { + throw Object.assign(new Error('Monitoring target is outside the configured target policy.'), { status: 400 }) + } + if (!net.isIPv4(normalized) && !configuredHostnames().has(normalized) && process.env.ALLOW_PUBLIC_TARGETS !== 'true') { + throw Object.assign(new Error('Hostname is not included in MONITORING_ALLOWED_HOSTNAMES.'), { status: 400 }) + } + return addresses +} + +export const isConfiguredHostname = (address: string) => configuredHostnames().has(address.trim().toLowerCase()) diff --git a/database/migrations/001_initial_schema.sql b/database/migrations/001_initial_schema.sql new file mode 100644 index 0000000..e2186a4 --- /dev/null +++ b/database/migrations/001_initial_schema.sql @@ -0,0 +1,74 @@ +CREATE TABLE IF NOT EXISTS users ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(100) NOT NULL, + email VARCHAR(255) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + role ENUM('admin', 'viewer') NOT NULL DEFAULT 'viewer', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS devices ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(100) NOT NULL, + ip_address VARCHAR(45) NULL, + hostname VARCHAR(253) NULL, + type VARCHAR(40) NOT NULL, + location VARCHAR(150) NULL, + monitoring_interval INT NOT NULL DEFAULT 30, + status ENUM('online', 'offline', 'warning') NOT NULL DEFAULT 'offline', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_address (ip_address), + UNIQUE KEY unique_hostname (hostname) +); + +CREATE TABLE IF NOT EXISTS network_metrics ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + device_id BIGINT NOT NULL, + timestamp DATETIME NOT NULL, + latency_ms DECIMAL(10, 2) NULL, + packet_loss_percent DECIMAL(6, 3) NULL, + download_mbps DECIMAL(10, 2) NULL, + upload_mbps DECIMAL(10, 2) NULL, + availability_percent DECIMAL(6, 3) NULL, + error_count INT NOT NULL DEFAULT 0, + response_time_ms DECIMAL(10, 2) NULL, + CONSTRAINT fk_metrics_device FOREIGN KEY(device_id) REFERENCES devices(id) ON DELETE CASCADE, + INDEX idx_metrics_device_timestamp (device_id, timestamp), + INDEX idx_metrics_timestamp(timestamp) +); + +CREATE TABLE IF NOT EXISTS alert_rules ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + metric VARCHAR(50) NOT NULL, + operator ENUM('gt', 'lt', 'gte', 'lte') NOT NULL, + threshold DECIMAL(12, 3) NOT NULL, + severity ENUM('info', 'warning', 'critical') NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE +); + +CREATE TABLE IF NOT EXISTS alerts ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + device_id BIGINT NOT NULL, + metric VARCHAR(50) NOT NULL, + value DECIMAL(12, 3) NOT NULL, + threshold DECIMAL(12, 3) NULL, + severity ENUM('info', 'warning', 'critical') NOT NULL, + message VARCHAR(500) NOT NULL, + status ENUM('active', 'acknowledged', 'resolved') NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + resolved_at DATETIME NULL, + CONSTRAINT fk_alert_device FOREIGN KEY(device_id) REFERENCES devices(id) ON DELETE CASCADE, + INDEX idx_alert_status_created(status, created_at) +); + +CREATE TABLE IF NOT EXISTS monitoring_logs ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + device_id BIGINT NOT NULL, + timestamp DATETIME NOT NULL, + check_type VARCHAR(40) NOT NULL, + result ENUM('success', 'failure') NOT NULL, + response_time DECIMAL(10, 2) NULL, + error_message VARCHAR(500) NULL, + CONSTRAINT fk_log_device FOREIGN KEY(device_id) REFERENCES devices(id) ON DELETE CASCADE, + INDEX idx_logs_device_timestamp(device_id, timestamp) +); diff --git a/database/migrations/002_app_settings.sql b/database/migrations/002_app_settings.sql new file mode 100644 index 0000000..cd3a82c --- /dev/null +++ b/database/migrations/002_app_settings.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS app_settings ( + setting_key VARCHAR(100) PRIMARY KEY, + setting_value JSON NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); diff --git a/database/migrations/003_audit_events.sql b/database/migrations/003_audit_events.sql new file mode 100644 index 0000000..c37d688 --- /dev/null +++ b/database/migrations/003_audit_events.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS audit_events ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + actor_id VARCHAR(100) NULL, + action VARCHAR(100) NOT NULL, + resource_type VARCHAR(60) NOT NULL, + resource_id VARCHAR(100) NULL, + request_id VARCHAR(100) NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_audit_created_at(created_at), + INDEX idx_audit_actor_created(actor_id, created_at) +); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 1a42a1b..39e66f6 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { createRoot } from 'react-dom/client' -import { Activity, Bell, ChevronRight, CircleAlert, Gauge, Laptop, Moon, Network, Play, Plus, Radio, Search, Server, Settings, Sun, User, Wifi } from 'lucide-react' +import { Activity, Bell, ChevronRight, CircleAlert, Gauge, Laptop, Moon, Network, Play, Plus, Radio, Server, Settings, Sun, User, Wifi } from 'lucide-react' import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' import { api } from './api' import type { AuthUser } from './api' @@ -10,13 +10,13 @@ import './styles.css' import './hotspot.css' const fmt = (value: number, digits = 1) => - new Intl.NumberFormat('en-US', { maximumFractionDigits: digits }).format(value) + Number.isFinite(value) ? new Intl.NumberFormat('en-US', { maximumFractionDigits: digits }).format(value) : '—' const pretty = (value: string) => value.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase()) const date = (value: string) => - new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + Number.isNaN(new Date(value).getTime()) ? '—' : new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) function Badge({ status }: { status: string }) { return {status} @@ -48,9 +48,9 @@ function DashboardPage({ data, onScenario }: { data: Dashboard; onScenario: () = return <>

{data.mode === 'simulation' ? 'Simulation mode · backend-generated data' : 'Live monitoring · ICMP probes + local interface traffic'}

Network overview

{data.mode === 'simulation' ? 'Real-time health, traffic, and performance signals across your estate.' : 'Latency, loss, and reachability come from real ping checks. Traffic reflects this computer’s network interface.'}

{kpis.map(([key, Icon, label]) => { const item = data.kpis[key]; const change = ((item.value - item.previous) / Math.max(Math.abs(item.previous), 1)) * 100; const precision = key === 'download' || key === 'upload' ? 2 : 1; return
{change > 0 ? '+' : ''}{fmt(change)}%

{label}

{fmt(item.value, precision)}{item.unit}

})}
-

Network traffic

Throughput · {data.capacity ? `Max Capacity: ${data.capacity.download} Mbps Down / ${data.capacity.upload} Mbps Up` : 'last 24 checks'}

+

Network traffic

Throughput · {data.capacity ? `Max Capacity: ${data.capacity.download} Mbps Down / ${data.capacity.upload} Mbps Up` : 'last 24 checks'}

{traffic.length ? :

No traffic samples are available yet.

}

Network health

Weighted performance score

{data.health.score}/ 100

{data.health.status}

{data.health.explanation}

Availability 30% · Latency 20% · Packet loss 20% · Bandwidth 20% · Errors 10%

-

Latency monitoring

Milliseconds per check

P95 {fmt([...data.traffic].sort((a,b) => a.latencyMs-b.latencyMs)[Math.floor(data.traffic.length*.95)].latencyMs)} ms
+

Latency monitoring

Milliseconds per check

P95 {fmt([...data.traffic].sort((a,b) => a.latencyMs-b.latencyMs)[Math.min(data.traffic.length - 1, Math.floor(data.traffic.length*.95))]?.latencyMs ?? Number.NaN)} ms
{data.traffic.length ? :

No historical measurements are available yet.

}

Device status

Current reachability

{pie.map(p => )}
{pie.map(p => {p.name} {p.value})}

Top problematic devices

Dynamic risk score from downtime, latency, loss and alerts

{data.problematic.slice(0, 4).map((item, i) =>
0{i+1}
{item.device.name}{item.device.address} · {item.device.location}
{item.score}
)}
@@ -74,7 +74,7 @@ function DevicesPage({ devices, refresh }: { devices: Device[]; refresh: () => v function MonitoringPage({ data, refresh }: { data: Dashboard; refresh: () => void }) { const [deviceId, setDeviceId] = useState(data.devices[0]?.id || 1); const [records, setRecords] = useState([]); const [checking, setChecking] = useState(false); const device = data.devices.find(item => item.id === deviceId) || data.devices[0]; useEffect(() => { if (device) api.metrics(device.id).then(setRecords).catch(() => setRecords([])) }, [device?.id, data.generatedAt]); if (!device) return
Add a device to begin monitoring.
; const chartData=records.slice(-30).map(metric=>({...metric,time:date(metric.timestamp)})); const check=async()=>{setChecking(true);await api.check(device.id);await refresh();setChecking(false)}; return <>

{data.mode === 'live' ? 'Live probes · Windows ICMP' : 'Demo monitoring probes'}

Monitoring

Inspect a target’s latest measurement history and trigger an on-demand probe.

Selected target

{device.name}

{device.address} · {device.type} · {device.location}

Latency{fmt(device.latest.latencyMs)} ms
Packet loss{fmt(device.latest.packetLossPercent,2)}%
Availability{fmt(device.latest.availabilityPercent,1)}%
Last check{date(device.lastChecked)}

{data.mode === 'live' ? 'What is measured' : 'Simulation enabled'}

{data.mode === 'live' ? 'Four ICMP echo requests determine reachability, latency and packet loss. Local traffic is sampled from this PC’s active Windows adapter.' : 'Metrics are generated by the backend’s controlled scenario service.'}

Latency & packet loss

Latest {chartData.length} probe measurements

Local interface traffic

Traffic on the computer running this server

} -function AnalyticsPage({ devices }: { devices: Device[] }) { const [deviceId,setDeviceId]=useState(devices[0]?.id ?? 1); const [metric,setMetric]=useState('latencyMs'); const [method,setMethod]=useState('zscore'); const [stats,setStats]=useState(null); useEffect(()=>{api.statistics(deviceId, metric, method).then(setStats).catch(()=>setStats(null))},[deviceId,metric,method]); if(!stats) return
Calculating statistical analysis…
; const entries=[['Mean',stats.mean],['Median',stats.median],['Minimum',stats.min],['Maximum',stats.max],['Std. deviation',stats.standardDeviation],['Variance',stats.variance],['P95',stats.percentiles.P95],['P99',stats.percentiles.P99]]; return <>

Statistical analysis

Analytics

Describe performance distributions, trends and abnormal behavior.

{entries.map(([label,value])=>

{label}

{fmt(value as number,2)}

)}

Trend

{stats.trend.direction} · {fmt(stats.trend.percentChange)}% change from first to last observation

({i,value}))}>

Anomaly detection ({method === 'zscore' ? 'Z-score |z| > 3' : 'IQR fences'})

{stats.anomalies.length} anomalous records identified from {stats.count} monitoring observations.

{stats.anomalies.slice(0,5).map((a,i)=>
{fmt(a.value,2)}{date(a.timestamp)} · expected {a.expected} · z {fmt(a.zScore,2)}
)}{!stats.anomalies.length&&

No anomalies in this period.

}

Correlation analysis

Pearson r = {fmt(stats.correlation,2)}. This indicates {Math.abs(stats.correlation) > .7 ? 'a strong' : Math.abs(stats.correlation) > .4 ? 'a moderate' : 'a weak'} relationship between latency and packet loss; correlation does not establish causation.

} +function AnalyticsPage({ devices }: { devices: Device[] }) { const [deviceId,setDeviceId]=useState(devices[0]?.id ?? 1); const [metric,setMetric]=useState('latencyMs'); const [method,setMethod]=useState('zscore'); const [stats,setStats]=useState(null); useEffect(()=>{if (!devices.length) return; api.statistics(deviceId, metric, method).then(setStats).catch(()=>setStats(null))},[deviceId,metric,method,devices.length]); if(!devices.length) return
Add a device before opening analytics.
; if(!stats) return
Calculating statistical analysis…
; const entries=[['Mean',stats.mean],['Median',stats.median],['Minimum',stats.min],['Maximum',stats.max],['Std. deviation',stats.standardDeviation],['Variance',stats.variance],['P95',stats.percentiles.P95],['P99',stats.percentiles.P99]]; return <>

Statistical analysis

Analytics

Describe performance distributions, trends and abnormal behavior.

{entries.map(([label,value])=>

{label}

{fmt(value as number,2)}

)}

Trend

{stats.trend.direction} · {fmt(stats.trend.percentChange)}% change from first to last observation

({i,value}))}>

Anomaly detection ({method === 'zscore' ? 'Z-score |z| > 3' : 'IQR fences'})

{stats.anomalies.length} anomalous records identified from {stats.count} monitoring observations.

{stats.anomalies.slice(0,5).map((a,i)=>
{fmt(a.value,2)}{date(a.timestamp)} · expected {a.expected} · z {fmt(a.zScore,2)}
)}{!stats.anomalies.length&&

No anomalies in this period.

}

Correlation analysis

Pearson r = {fmt(stats.correlation,2)}. This indicates {Math.abs(stats.correlation) > .7 ? 'a strong' : Math.abs(stats.correlation) > .4 ? 'a moderate' : 'a weak'} relationship between latency and packet loss; correlation does not establish causation.

} function AlertsPage({ alerts, refresh }: { alerts: Alert[]; refresh: () => void }) { const update=async(id:number, action:'acknowledge'|'resolve')=>{await api.updateAlert(id,action);refresh()}; return <>

Rules-based & statistical events

Alerts

Thresholds and detected anomalies from recent monitoring.

} function ReportsPage() { const [report,setReport]=useState|null>(null); const generate=async(period:'daily'|'weekly')=>setReport((await api.report(period)).report); return <>

Shareable summaries

Reports

Generate dynamic monitoring summaries from stored measurements.

{report&&

{String(report.title)}

Generated {String(report.generatedAt)}

{Object.entries(report).filter(([k])=>!['title','generatedAt'].includes(k)).map(([k,v])=>
{pretty(k)}
{typeof v === 'object' ? JSON.stringify(v) : String(v)}
)}
} } @@ -154,7 +154,7 @@ function SettingsPage({ data, refresh }: { data: Dashboard; refresh: () => void )} {activeTab === 'profile' && (
-

User Profile & API Keys

Manage your account and developer API keys.

+

User Profile

Manage the display name and email shown in the console.

{ e.preventDefault(); const formData = new FormData(e.currentTarget); save({ displayName: String(formData.get('name')), email: String(formData.get('email')) }) }} style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem', marginTop: '1rem' }}>
- -
-

Developer API Keys

-

Use these keys to authenticate with the API programmatically.

-
- - - -
- -
)} {activeTab === 'advanced' && ( @@ -192,8 +181,6 @@ function SettingsPage({ data, refresh }: { data: Dashboard; refresh: () => void

Danger Zone

Actions here cannot be undone. Proceed with caution.

- -
@@ -204,7 +191,7 @@ function SettingsPage({ data, refresh }: { data: Dashboard; refresh: () => void } -function ConsoleApp() { const [data,setData]=useState(null); const [page,setPage]=useState('Dashboard'); const [dark,setDark]=useState(true); const [error,setError]=useState(''); const load=async()=>{try{setData(await api.dashboard());setError('')}catch(e){setError(e instanceof Error ? e.message:'Could not reach the monitoring API')}}; useEffect(()=>{load();const timer=setInterval(load,10000);return()=>clearInterval(timer)},[]); const nav=['Dashboard','Devices','Analytics','Alerts','Reports','Settings']; const content=useMemo(()=>{if(!data)return
Loading backend monitoring data…
; if(page==='Dashboard')return {await api.scenario();load()}}/>; if(page==='Devices')return ; if(page==='Analytics')return ; if(page==='Alerts')return ; if(page==='Reports')return ; return },[data,page]); return
System operational
{error?
{error}
:
{content}
}
} +function ConsoleApp() { const [data,setData]=useState(null); const [page,setPage]=useState('Dashboard'); const [dark,setDark]=useState(true); const [error,setError]=useState(''); const load=async()=>{try{setData(await api.dashboard());setError('')}catch(e){setError(e instanceof Error ? e.message:'Could not reach the monitoring API')}}; useEffect(()=>{load();const timer=setInterval(load,10000);return()=>clearInterval(timer)},[]); const nav=['Dashboard','Devices','Monitoring','Analytics','Alerts','Reports','Settings']; const content=useMemo(()=>{if(!data)return
Loading backend monitoring data…
; if(page==='Dashboard')return {await api.scenario();load()}}/>; if(page==='Devices')return ; if(page==='Monitoring')return ; if(page==='Analytics')return ; if(page==='Alerts')return ; if(page==='Reports')return ; return },[data,page]); return
System operational
{error?
{error}
:
{content}
}
} function App() { const [user, setUser] = useState(() => api.hasSession() ? { id: 'session', email: '', name: 'Operator', role: 'viewer' } : null) diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 74c1a14..d8a523b 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/api.ts","./src/main.tsx","./src/types.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/LoginPage.tsx","./src/api.ts","./src/main.tsx","./src/types.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ed93d08..67877ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "backend" ], "devDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.64.6", "concurrently": "^9.1.2" } }, @@ -610,10 +611,301 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "license": "MIT" }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.6", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.6.tgz", + "integrity": "sha512-05v6wFKDKJPZs9xlytVrrRu6IbRuoSvlH9xNwEOafXsKyjBFZPvAcZz2VIAB/M+lblYeKa9iOYC4RDVdfcAAaw==", + "cpu": [ + "x64" + ], + "dev": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.62.4", "cpu": [ @@ -2774,6 +3066,19 @@ "node": ">= 0.6" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "license": "MIT", @@ -4592,6 +4897,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/rxjs": { "version": "7.8.2", "dev": true, diff --git a/package.json b/package.json index 91c16cd..b9f9b59 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,18 @@ "name": "smart-network-monitoring", "private": true, "version": "1.0.0", - "workspaces": ["frontend", "backend"], + "workspaces": [ + "frontend", + "backend" + ], "scripts": { "dev": "concurrently \"npm run dev --workspace backend\" \"npm run dev --workspace frontend\"", "build": "npm run build --workspace backend && npm run build --workspace frontend", "start": "npm run start --workspace backend", "test": "npm run test --workspace backend" }, - "devDependencies": { "concurrently": "^9.1.2" } + "devDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.64.6", + "concurrently": "^9.1.2" + } }