feat: Guest WiFi, network intelligence, firmware orchestration, platform automation, api-ssl (v0.16.3-beta)

Guest WiFi (Hotspot): one-click captive-portal wizard that creates the guest
SSID (virtual AP per radio), VLAN/bridge segregation + L3 VLAN interface, pool,
DHCP, portal, rate-limited guest profile, and masquerade; vouchers with limits +
printable sheets; live guest sessions; walled garden.

Network intelligence: per-client device fingerprinting (vendor/hostname →
category) with a persisted per-client override on the detail page; rogue/neighbor
AP detection from stored AP scans (evil-twin flagging); Mist-style anomaly
insights (client-count/CPU vs same-hour 14-day baseline + error bursts) in the
Operations engine.

Firmware orchestration: staged wave-based RouterOS rollouts (canary first) with
pre-upgrade backup, reboot-ride-out, version verification, halt-on-failure, and
scheduling; live per-device progress + cancel.

Platform & automation: scoped API tokens (mtm_, read/write, hashed at rest,
Bearer auth in requireAuth); outbound webhooks (12 events, HMAC-signed) fired
from the alert pipeline + firmware orchestrator; scheduled email reports.

api-ssl: RouterOSClient negotiates TLS on port 8729 and accepts RouterOS's
default self-signed certs (rejectUnauthorized:false), so SSL-secured API devices
connect without manual cert trust. Fixes #57.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-07-02 21:58:02 -07:00
co-authored by Claude Opus 4.8
parent efb430d934
commit 9dfb8a5333
38 changed files with 3517 additions and 20 deletions
+25 -1
View File
@@ -2,7 +2,7 @@
A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface. A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface.
![Version](https://img.shields.io/badge/version-0.16.2_Beta-blue) ![Version](https://img.shields.io/badge/version-0.16.3_Beta-blue)
![License](https://img.shields.io/badge/license-AGPLv3-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue)
![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white)
@@ -106,6 +106,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Moni
- **Capacity & health** — per-device CPU/memory meters sorted by pressure - **Capacity & health** — per-device CPU/memory meters sorted by pressure
- **Security posture rollup** — fleet average hardening score, high-severity finding count, and the lowest-scoring devices, deep-linking to the Security Center - **Security posture rollup** — fleet average hardening score, high-severity finding count, and the lowest-scoring devices, deep-linking to the Security Center
- **Recent activity feed** — merged config changes, user actions, and alerts - **Recent activity feed** — merged config changes, user actions, and alerts
- **Anomaly insights** (Mist-style) — compares each device's last 30 minutes of client-count and CPU against its **same-hour-of-day 14-day baseline** and flags ≥2.5σ deviations, plus error-log burst detection — surfaced alongside rogue-AP alerts in Things to handle
### Device Management ### Device Management
- Add, edit, and delete MikroTik devices (routers, switches, wireless APs) - Add, edit, and delete MikroTik devices (routers, switches, wireless APs)
@@ -113,6 +114,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Moni
- Firmware update availability detection - Firmware update availability detection
- Per-device notes, rack location, and physical address with map support - Per-device notes, rack location, and physical address with map support
- Device credential encryption at rest - Device credential encryption at rest
- **Encrypted API (api-ssl)** — devices whose API runs on the SSL port (8729) are reached over TLS automatically; RouterOS's default self-signed certificates are accepted, so the credentials and session are encrypted in transit without any manual certificate trust
- **Bulk device add** — "Try All" discovered devices runs as a server-side background job (survives browser tab close) with live progress and cancel support - **Bulk device add** — "Try All" discovered devices runs as a server-side background job (survives browser tab close) with live progress and cancel support
- CPU load and historical sparkline displayed correctly for all device types, including hardware switches that report 0% CPU via ASIC offloading - CPU load and historical sparkline displayed correctly for all device types, including hardware switches that report 0% CPU via ASIC offloading
- **Device tags** — colored labels for organizing and filtering devices; full tag management in Settings - **Device tags** — colored labels for organizing and filtering devices; full tag management in Settings
@@ -152,11 +154,20 @@ A Meraki/UniFi-grade firewall experience built on the full RouterOS feature set:
- Scheduled and on-demand **AP scans** (nearby access point discovery) - Scheduled and on-demand **AP scans** (nearby access point discovery)
- Real-time radio monitoring - Real-time radio monitoring
- Wireless client tracking with vendor lookup - Wireless client tracking with vendor lookup
- **Device fingerprinting** — every client is classified into a device category (server/NAS, computer, phone, TV, camera, printer, game console, voice assistant, smart-home, IoT, network gear…) from its OUI vendor and hostname, shown as an icon on the Clients list. The fingerprint is fully **overridable per client** on the client detail page, and the override persists across re-polls
- **RF Health** (fleet-wide on the Wireless page and per-AP on the Radios tab): - **RF Health** (fleet-wide on the Wireless page and per-AP on the Radios tab):
- **Channel usage map** across 2.4 / 5 / 6 GHz, computed from each radio's operating channel (live-resolved from `monitor` on the wifi package when channels are auto), highlighting in-use channels and **co-channel overlap** - **Channel usage map** across 2.4 / 5 / 6 GHz, computed from each radio's operating channel (live-resolved from `monitor` on the wifi package when channels are auto), highlighting in-use channels and **co-channel overlap**
- **AP deployment density** — connected clients plotted across the −90…−30 dBm signal scale, with a coverage-gap warning when too many clients connect at weak RSSI - **AP deployment density** — connected clients plotted across the −90…−30 dBm signal scale, with a coverage-gap warning when too many clients connect at weak RSSI
- **AP radio TX retries** histogram (0%→35%+) with a band selector, derived from per-client CCQ (legacy `wireless` driver) - **AP radio TX retries** histogram (0%→35%+) with a band selector, derived from per-client CCQ (legacy `wireless` driver)
- **WiFi connectivity success** funnel (Association / Authentication / DHCP) derived from device logs - **WiFi connectivity success** funnel (Association / Authentication / DHCP) derived from device logs
- **Rogue & neighbor AP detection** — cross-references stored AP-scan results against your own SSIDs and radio MACs. A foreign BSSID broadcasting one of *your* SSIDs is flagged as a **rogue / evil-twin** (and raised in Operations → Things to handle); everything else is a ranked neighbor-network inventory
### Guest WiFi (Hotspot)
A one-click captive-portal guest network on RouterOS Hotspot, under **Wireless → Guest WiFi**:
- **Guided setup wizard** — creates the whole guest network in one pass: a new **guest SSID** (a virtual AP on every physical radio), **VLAN segregation** (guest SSIDs tagged onto the bridge with an L3 VLAN interface for the portal — or a dedicated guest bridge when no VLAN is given), IP pool, DHCP, hotspot profile, portal server, a bandwidth-limited guest user profile, and an optional masquerade NAT rule. Idempotent, with honest warnings (e.g. bridge VLAN-filtering disabled). Can also target an existing interface.
- **Vouchers** — batch-generate access codes (`XXXX-XXXX`, no ambiguous characters) with validity hours, data caps, and a speed profile; **printable voucher sheets** (3-up cards with code, limits, and connection instructions) and a used/unused status table
- **Guests online** — live session table (code, IP, MAC, session time, down/up bytes) with one-click disconnect
- **Walled garden** — sites reachable before login, managed inline; per-server enable/disable
### Network Services ### Network Services
Each service supports multi-device management with conflict detection: Each service supports multi-device management with conflict detection:
@@ -201,11 +212,24 @@ Per-device diagnostic and testing tools accessible from the device detail Tools
| **Packet Capture** | Start the RouterOS sniffer, capture for 5–60 seconds, download a `.pcap` file directly to your browser (opens in Wireshark). Requires SSH credentials on the device. | | **Packet Capture** | Start the RouterOS sniffer, capture for 5–60 seconds, download a `.pcap` file directly to your browser (opens in Wireshark). Requires SSH credentials on the device. |
| **Bandwidth Test** | Measure throughput between two devices. Select any managed device as the target — the bandwidth-test server is automatically enabled on the target before the test and disabled afterward. Manual IP mode available for non-managed targets. | | **Bandwidth Test** | Measure throughput between two devices. Select any managed device as the target — the bandwidth-test server is automatically enabled on the target before the test and disabled afterward. Manual IP mode available for non-managed targets. |
### Firmware Orchestration
Staged fleet-wide RouterOS upgrades, under the top-level **Firmware** section:
- **Fleet versions** — current RouterOS, latest-known version, and pending RouterBOOT upgrades per device; **Check all for updates** queries every online device live
- **Staged rollouts in waves** — select updatable devices and assign each to a wave (wave 1 = **canary**); the orchestrator runs one rollout at a time, devices sequentially, through a verified pipeline: **pre-upgrade backup → install → ride out the reboot → verify it returned healthy on the new version → next device**
- **Halt on failure** stops the entire remaining rollout if any device fails, so a bad build never reaches the fleet; a reboot that comes back on the *old* version counts as a failure
- **Schedule** rollouts for a future time (pair with a maintenance window); live wave-grouped progress with animated per-device status, `from → to` versions, error detail, and **Cancel** (never interrupts an in-flight flash)
### Backups ### Backups
- Trigger RouterOS backups on demand via SSH - Trigger RouterOS backups on demand via SSH
- **Scheduled automatic backups** — pick a daily, weekly, or monthly schedule and time in Settings (no cron knowledge required); runs for all online devices - **Scheduled automatic backups** — pick a daily, weekly, or monthly schedule and time in Settings (no cron knowledge required); runs for all online devices
- Download and manage backup files from the UI - Download and manage backup files from the UI
### Platform & Automation
Make the platform scriptable and integrable, under **Settings → Automation**:
- **Scoped API tokens** — issue `mtm_…` tokens with **read** or **write** scope and optional expiry for scripting and IaC; the token is shown once (only a SHA-256 hash is stored) and maps onto the role model — no token can perform admin actions or manage other tokens. Use with `Authorization: Bearer mtm_…` against the full REST API
- **Outbound webhooks** — subscribe URLs to any of 12 events (device up/down, log errors, high CPU/memory, cert expiry, device discovered, firmware update, config drift, firmware rollout completed/failed); deliveries are JSON POSTs **HMAC-SHA256 signed** (`X-MTM-Signature`) when a secret is set, with last-status tracking and a Send-test button. Fired through the same alert pipeline (respecting alert rules, cooldowns, and maintenance windows)
- **Scheduled email reports** — daily/weekly/monthly HTML fleet summaries (devices online, outages + downtime, error/warning counts, updates pending, backups taken, top clients by traffic) to any recipient list, using your Alerting SMTP settings; Send-now for instant delivery
### Configuration History ### Configuration History
- Per-device config snapshots based on the device's full RouterOS `/export` — config-only, so counters and operational state never create noise — captured automatically whenever the configuration changes (deduplicated by content hash, so a snapshot is only stored on a real change) - Per-device config snapshots based on the device's full RouterOS `/export` — config-only, so counters and operational state never create noise — captured automatically whenever the configuration changes (deduplicated by content hash, so a snapshot is only stored on a real change)
- **Config History** tab on each device with a timeline of snapshots and a one-line summary of what changed (e.g. `+8 / −3 lines`) - **Config History** tab on each device with a timeline of snapshots and a one-line summary of what changed (e.g. `+8 / −3 lines`)
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mikrotik-manager-backend", "name": "mikrotik-manager-backend",
"version": "0.16.2-beta", "version": "0.16.3-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mikrotik-manager-backend", "name": "mikrotik-manager-backend",
"version": "0.16.2-beta", "version": "0.16.3-beta",
"dependencies": { "dependencies": {
"@influxdata/influxdb-client": "^1.33.2", "@influxdata/influxdb-client": "^1.33.2",
"@influxdata/influxdb-client-apis": "^1.35.0", "@influxdata/influxdb-client-apis": "^1.35.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mikrotik-manager-backend", "name": "mikrotik-manager-backend",
"version": "0.16.2-beta", "version": "0.16.3-beta",
"description": "Mikrotik Network Management Platform - Backend API", "description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
+62
View File
@@ -163,6 +163,68 @@ ALTER TABLE clients ADD COLUMN IF NOT EXISTS custom_name VARCHAR(255);
-- first_seen: when this client was first discovered ("connected since"); set once, preserved across polls -- first_seen: when this client was first discovered ("connected since"); set once, preserved across polls
ALTER TABLE clients ADD COLUMN IF NOT EXISTS first_seen TIMESTAMPTZ; ALTER TABLE clients ADD COLUMN IF NOT EXISTS first_seen TIMESTAMPTZ;
UPDATE clients SET first_seen = last_seen WHERE first_seen IS NULL; UPDATE clients SET first_seen = last_seen WHERE first_seen IS NULL;
-- custom_category: user override of the fingerprinted device category
ALTER TABLE clients ADD COLUMN IF NOT EXISTS custom_category VARCHAR(30);
-- Firmware orchestration: staged fleet upgrades in waves
CREATE TABLE IF NOT EXISTS firmware_rollouts (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|cancelled
halt_on_failure BOOLEAN NOT NULL DEFAULT TRUE,
pre_backup BOOLEAN NOT NULL DEFAULT TRUE,
scheduled_at TIMESTAMPTZ,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS firmware_rollout_devices (
id SERIAL PRIMARY KEY,
rollout_id INTEGER NOT NULL REFERENCES firmware_rollouts(id) ON DELETE CASCADE,
device_id INTEGER NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
wave INTEGER NOT NULL DEFAULT 1,
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending|backing_up|upgrading|rebooting|verifying|success|failed|skipped
from_version VARCHAR(30),
to_version VARCHAR(30),
error TEXT,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_fw_rollout_devices ON firmware_rollout_devices(rollout_id, wave, id);
-- Platform & automation: scoped API tokens, outbound webhooks, scheduled reports
CREATE TABLE IF NOT EXISTS api_tokens (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
token_hash VARCHAR(64) NOT NULL UNIQUE,
prefix VARCHAR(12) NOT NULL,
scope VARCHAR(10) NOT NULL DEFAULT 'read', -- read|write
created_by VARCHAR(50),
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS webhooks (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
url TEXT NOT NULL,
secret VARCHAR(128),
events TEXT[] NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_status INTEGER,
last_fired_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS report_schedules (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
frequency VARCHAR(10) NOT NULL DEFAULT 'weekly', -- daily|weekly|monthly
recipients TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_sent_at TIMESTAMPTZ,
next_run_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
ALTER TABLE events ADD COLUMN IF NOT EXISTS log_id VARCHAR(20); ALTER TABLE events ADD COLUMN IF NOT EXISTS log_id VARCHAR(20);
CREATE UNIQUE INDEX IF NOT EXISTS idx_events_device_log_id ON events(device_id, log_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_events_device_log_id ON events(device_id, log_id);
ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS neighbor_mac VARCHAR(17); ALTER TABLE topology_links ADD COLUMN IF NOT EXISTS neighbor_mac VARCHAR(17);
+14
View File
@@ -36,6 +36,11 @@ import devicesRoutes, { setPollerService as setDevicesPoller } from './routes/de
import clientsRoutes, { setPollerService as setClientsPoller } from './routes/clients'; import clientsRoutes, { setPollerService as setClientsPoller } from './routes/clients';
import eventsRoutes from './routes/events'; import eventsRoutes from './routes/events';
import backupsRoutes from './routes/backups'; import backupsRoutes from './routes/backups';
import guestWifiRoutes from './routes/guestWifi';
import firmwareRoutes from './routes/firmware';
import automationRoutes from './routes/automation';
import { reportService } from './services/ReportService';
import { firmwareOrchestrator } from './services/FirmwareOrchestrator';
import operationsRoutes from './routes/operations'; import operationsRoutes from './routes/operations';
import metricsRoutes from './routes/metrics'; import metricsRoutes from './routes/metrics';
import topologyRoutes, { setPollerService as setTopologyPoller } from './routes/topology'; import topologyRoutes, { setPollerService as setTopologyPoller } from './routes/topology';
@@ -208,6 +213,9 @@ app.use('/api/clients', clientsRoutes);
app.use('/api/events', eventsRoutes); app.use('/api/events', eventsRoutes);
app.use('/api/backups', backupsRoutes); app.use('/api/backups', backupsRoutes);
app.use('/api/operations', operationsRoutes); app.use('/api/operations', operationsRoutes);
app.use('/api/guest-wifi', guestWifiRoutes);
app.use('/api/firmware', firmwareRoutes);
app.use('/api/automation', automationRoutes);
app.use('/api/metrics', metricsRoutes); app.use('/api/metrics', metricsRoutes);
app.use('/api/topology', topologyRoutes); app.use('/api/topology', topologyRoutes);
app.use('/api/settings', settingsRoutes); app.use('/api/settings', settingsRoutes);
@@ -270,6 +278,12 @@ async function start(): Promise<void> {
// NetFlow/IPFIX collector (binds its UDP socket only when netflow_enabled) // NetFlow/IPFIX collector (binds its UDP socket only when netflow_enabled)
await netflowCollector.start(); await netflowCollector.start();
// Firmware rollout scheduler (starts rollouts whose scheduled_at has arrived)
firmwareOrchestrator.startScheduler();
// Scheduled report mailer (hourly check)
reportService.startScheduler();
// Start HTTP server // Start HTTP server
httpServer.listen(PORT, '0.0.0.0', () => { httpServer.listen(PORT, '0.0.0.0', () => {
console.log(`✓ Mikrotik Manager backend running on port ${PORT}`); console.log(`✓ Mikrotik Manager backend running on port ${PORT}`);
+37
View File
@@ -1,10 +1,14 @@
import { Request, Response, NextFunction } from 'express'; import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { createHash } from 'crypto';
import { query } from '../config/database';
export interface AuthPayload { export interface AuthPayload {
userId: number; userId: number;
username: string; username: string;
role: string; role: string;
/** Set when the request authenticated with an API token instead of a session. */
tokenAuth?: boolean;
} }
declare global { declare global {
@@ -26,6 +30,27 @@ export function verifyToken(token: string): AuthPayload {
return jwt.verify(token, JWT_SECRET) as AuthPayload; return jwt.verify(token, JWT_SECRET) as AuthPayload;
} }
// API tokens ("mtm_…") are hashed at rest; scope maps onto the existing role
// model (read → viewer, write → operator) so requireWrite/requireAdmin keep
// working unchanged. Admin actions always need a real session.
async function authenticateApiToken(token: string): Promise<AuthPayload | null> {
const hash = createHash('sha256').update(token).digest('hex');
const rows = await query<{ id: number; name: string; scope: string; expires_at: string | null }>(
`SELECT id, name, scope, expires_at FROM api_tokens WHERE token_hash = $1`, [hash]
).catch(() => []);
const t = rows[0];
if (!t) return null;
if (t.expires_at && new Date(t.expires_at).getTime() < Date.now()) return null;
// Fire-and-forget usage tracking
void query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [t.id]).catch(() => {});
return {
userId: -t.id,
username: `token:${t.name}`,
role: t.scope === 'write' ? 'operator' : 'viewer',
tokenAuth: true,
};
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void { export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) { if (!authHeader?.startsWith('Bearer ')) {
@@ -34,6 +59,18 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
} }
const token = authHeader.slice(7); const token = authHeader.slice(7);
if (token.startsWith('mtm_')) {
authenticateApiToken(token)
.then((payload) => {
if (!payload) { res.status(401).json({ error: 'Invalid or expired API token' }); return; }
req.user = payload;
next();
})
.catch(() => res.status(401).json({ error: 'Invalid or expired API token' }));
return;
}
try { try {
req.user = verifyToken(token); req.user = verifyToken(token);
next(); next();
+160
View File
@@ -0,0 +1,160 @@
import { Router, Request, Response } from 'express';
import { randomBytes, createHash } from 'crypto';
import { query, queryOne } from '../config/database';
import { requireAuth, requireAdmin, requireWrite } from '../middleware/auth';
import { webhookService, WEBHOOK_EVENTS } from '../services/WebhookService';
import { reportService, computeNextRun } from '../services/ReportService';
const router = Router();
router.use(requireAuth);
// ─── API tokens (admin only — a token can never mint more tokens) ─────────────
router.get('/tokens', requireAdmin, async (_req: Request, res: Response) => {
const rows = await query(
`SELECT id, name, prefix, scope, created_by, last_used_at, expires_at, created_at
FROM api_tokens ORDER BY created_at DESC`);
res.json(rows);
});
router.post('/tokens', requireAdmin, async (req: Request, res: Response) => {
const { name, scope, expires_days } = req.body as { name?: string; scope?: string; expires_days?: number };
if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });
if (scope !== 'read' && scope !== 'write') return res.status(400).json({ error: 'scope must be read or write' });
const token = `mtm_${randomBytes(24).toString('hex')}`;
const hash = createHash('sha256').update(token).digest('hex');
const expiresAt = expires_days && expires_days > 0
? new Date(Date.now() + expires_days * 86_400_000).toISOString()
: null;
const row = await queryOne<{ id: number }>(
`INSERT INTO api_tokens (name, token_hash, prefix, scope, created_by, expires_at)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
[name.trim().slice(0, 100), hash, token.slice(0, 12), scope, req.user?.username ?? null, expiresAt]);
// The full token is returned exactly once — only the hash is stored.
res.status(201).json({ id: row!.id, token });
});
router.delete('/tokens/:id', requireAdmin, async (req: Request, res: Response) => {
await query(`DELETE FROM api_tokens WHERE id = $1`, [parseInt(req.params.id, 10)]);
res.json({ ok: true });
});
// ─── Webhooks ──────────────────────────────────────────────────────────────────
router.get('/webhooks', async (_req: Request, res: Response) => {
const rows = await query(
`SELECT id, name, url, (secret IS NOT NULL AND secret != '') AS has_secret,
events, enabled, last_status, last_fired_at, created_at
FROM webhooks ORDER BY created_at DESC`);
res.json({ webhooks: rows, availableEvents: WEBHOOK_EVENTS });
});
router.post('/webhooks', requireWrite, async (req: Request, res: Response) => {
const { name, url, secret, events } = req.body as { name?: string; url?: string; secret?: string; events?: string[] };
if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });
if (!url || !/^https?:\/\//.test(url)) return res.status(400).json({ error: 'url must be http(s)' });
const evts = (Array.isArray(events) ? events : []).filter(e => (WEBHOOK_EVENTS as readonly string[]).includes(e));
if (evts.length === 0) return res.status(400).json({ error: 'subscribe to at least one event' });
const row = await queryOne<{ id: number }>(
`INSERT INTO webhooks (name, url, secret, events) VALUES ($1,$2,$3,$4) RETURNING id`,
[name.trim().slice(0, 100), url.trim(), secret?.trim() || null, evts]);
res.status(201).json({ id: row!.id });
});
router.put('/webhooks/:id', requireWrite, async (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
const { name, url, secret, events, enabled } = req.body as {
name?: string; url?: string; secret?: string | null; events?: string[]; enabled?: boolean;
};
const existing = await queryOne<{ id: number }>(`SELECT id FROM webhooks WHERE id = $1`, [id]);
if (!existing) return res.status(404).json({ error: 'Webhook not found' });
if (url !== undefined && !/^https?:\/\//.test(url)) return res.status(400).json({ error: 'url must be http(s)' });
const evts = events !== undefined
? (Array.isArray(events) ? events : []).filter(e => (WEBHOOK_EVENTS as readonly string[]).includes(e))
: undefined;
if (evts !== undefined && evts.length === 0) return res.status(400).json({ error: 'subscribe to at least one event' });
await query(
`UPDATE webhooks SET
name = COALESCE($2, name),
url = COALESCE($3, url),
secret = CASE WHEN $4::text IS NOT NULL THEN NULLIF($4, '') ELSE secret END,
events = COALESCE($5, events),
enabled = COALESCE($6, enabled)
WHERE id = $1`,
[id, name?.trim().slice(0, 100) ?? null, url?.trim() ?? null, secret ?? null, evts ?? null, enabled ?? null]);
res.json({ ok: true });
});
router.delete('/webhooks/:id', requireWrite, async (req: Request, res: Response) => {
await query(`DELETE FROM webhooks WHERE id = $1`, [parseInt(req.params.id, 10)]);
res.json({ ok: true });
});
router.post('/webhooks/:id/test', requireWrite, async (req: Request, res: Response) => {
try {
const result = await webhookService.sendTest(parseInt(req.params.id, 10));
res.json({ ok: result.status >= 200 && result.status < 300, status: result.status });
} catch (e) {
res.status(400).json({ error: (e as Error).message });
}
});
// ─── Scheduled reports ─────────────────────────────────────────────────────────
router.get('/reports', async (_req: Request, res: Response) => {
const rows = await query(`SELECT * FROM report_schedules ORDER BY created_at DESC`);
res.json(rows);
});
router.post('/reports', requireWrite, async (req: Request, res: Response) => {
const { name, frequency, recipients } = req.body as { name?: string; frequency?: string; recipients?: string };
if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });
if (!['daily', 'weekly', 'monthly'].includes(frequency ?? '')) return res.status(400).json({ error: 'frequency must be daily, weekly or monthly' });
const rcpts = (recipients ?? '').split(',').map(r => r.trim()).filter(Boolean);
if (rcpts.length === 0 || rcpts.some(r => !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(r))) {
return res.status(400).json({ error: 'recipients must be comma-separated email addresses' });
}
const row = await queryOne<{ id: number }>(
`INSERT INTO report_schedules (name, frequency, recipients, next_run_at)
VALUES ($1,$2,$3,$4) RETURNING id`,
[name.trim().slice(0, 100), frequency, rcpts.join(', '), computeNextRun(frequency!, new Date())]);
res.status(201).json({ id: row!.id });
});
router.put('/reports/:id', requireWrite, async (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
const { enabled } = req.body as { enabled?: boolean };
const result = await query(
`UPDATE report_schedules SET enabled = COALESCE($2, enabled) WHERE id = $1 RETURNING id`,
[id, enabled ?? null]);
if (!result.length) return res.status(404).json({ error: 'Report schedule not found' });
res.json({ ok: true });
});
router.delete('/reports/:id', requireWrite, async (req: Request, res: Response) => {
await query(`DELETE FROM report_schedules WHERE id = $1`, [parseInt(req.params.id, 10)]);
res.json({ ok: true });
});
// POST /api/automation/reports/:id/send-now — immediate delivery (also validates SMTP)
router.post('/reports/:id/send-now', requireWrite, async (req: Request, res: Response) => {
const row = await queryOne<{ name: string; frequency: 'daily' | 'weekly' | 'monthly'; recipients: string }>(
`SELECT name, frequency, recipients FROM report_schedules WHERE id = $1`, [parseInt(req.params.id, 10)]);
if (!row) return res.status(404).json({ error: 'Report schedule not found' });
try {
await reportService.send(row);
await query(`UPDATE report_schedules SET last_sent_at = NOW() WHERE id = $1`, [parseInt(req.params.id, 10)]);
res.json({ ok: true });
} catch (e) {
res.status(502).json({ error: (e as Error).message });
}
});
export default router;
+32 -2
View File
@@ -4,6 +4,18 @@ import { query } from '../config/database';
import { requireAuth, requireAdmin, requireWrite } from '../middleware/auth'; import { requireAuth, requireAdmin, requireWrite } from '../middleware/auth';
import { PollerService } from '../services/PollerService'; import { PollerService } from '../services/PollerService';
import { getQueryApi, bucket } from '../config/influxdb'; import { getQueryApi, bucket } from '../config/influxdb';
import { fingerprintClient, DEVICE_CATEGORIES, DeviceCategory } from '../utils/clientFingerprint';
// Effective category: the user's override wins; otherwise the fingerprint.
function withCategory<T extends Record<string, unknown>>(row: T): T & { device_category: string; auto_category: string } {
const auto = fingerprintClient({
vendor: row['vendor'] as string | null,
hostname: row['hostname'] as string | null,
custom_name: row['custom_name'] as string | null,
});
const custom = (row['custom_category'] as string | null) || null;
return { ...row, device_category: custom ?? auto, auto_category: auto };
}
const router = Router(); const router = Router();
router.use(requireAuth); router.use(requireAuth);
@@ -89,7 +101,7 @@ router.get('/', async (req: Request, res: Response) => {
); );
res.json({ res.json({
clients, clients: (clients as Record<string, unknown>[]).map(withCategory),
total: parseInt(countResult[0]?.total || '0', 10), total: parseInt(countResult[0]?.total || '0', 10),
}); });
}); });
@@ -133,7 +145,7 @@ router.get('/:mac', async (req: Request, res: Response) => {
); );
if (!rows.length) return res.status(404).json({ error: 'Client not found' }); if (!rows.length) return res.status(404).json({ error: 'Client not found' });
return res.json(rows[0]); return res.json(withCategory(rows[0]));
}); });
// GET /api/clients/:mac/presence?range=2h|24h|7d — connectivity timeline from InfluxDB // GET /api/clients/:mac/presence?range=2h|24h|7d — connectivity timeline from InfluxDB
@@ -324,6 +336,24 @@ router.put('/:mac/hostname', requireWrite, async (req: Request, res: Response) =
return res.json(result[0]); return res.json(result[0]);
}); });
// PUT /api/clients/:mac/category — override (or clear) the fingerprinted device category
router.put('/:mac/category', requireWrite, async (req: Request, res: Response) => {
const { category } = req.body as { category?: string | null };
const mac = req.params.mac.toLowerCase();
const clear = category === null || category === undefined || category === '';
if (!clear && !DEVICE_CATEGORIES.includes(category as DeviceCategory)) {
return res.status(400).json({ error: `category must be one of: ${DEVICE_CATEGORIES.join(', ')} (or null to revert to automatic)` });
}
const result = await query(
`UPDATE clients SET custom_category = $1 WHERE LOWER(mac_address) = $2 RETURNING *`,
[clear ? null : category, mac]
);
if (!result.length) return res.status(404).json({ error: 'Client not found' });
return res.json(withCategory(result[0] as Record<string, unknown>));
});
// PUT /api/clients/:mac/notes — update the comment/notes field // PUT /api/clients/:mac/notes — update the comment/notes field
router.put('/:mac/notes', requireWrite, async (req: Request, res: Response) => { router.put('/:mac/notes', requireWrite, async (req: Request, res: Response) => {
const { notes } = req.body as { notes?: string }; const { notes } = req.body as { notes?: string };
+135
View File
@@ -0,0 +1,135 @@
import { Router, Request, Response } from 'express';
import { query, queryOne } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
import { firmwareOrchestrator } from '../services/FirmwareOrchestrator';
const router = Router();
router.use(requireAuth);
// GET /api/firmware/overview — fleet versions + latest rollout
router.get('/overview', async (_req: Request, res: Response) => {
const [devices, latestRollout] = await Promise.all([
query(`SELECT id, name, device_type, status, model, ros_version, latest_ros_version,
firmware_update_available, firmware_version, upgrade_firmware_version,
routerboard_upgrade_available
FROM devices ORDER BY name ASC`),
queryOne<{ id: number }>(`SELECT id FROM firmware_rollouts ORDER BY created_at DESC LIMIT 1`),
]);
res.json({
devices,
latestRolloutId: latestRollout?.id ?? null,
runningRolloutId: firmwareOrchestrator.running,
});
});
// POST /api/firmware/check-all — refresh update availability on all online devices
router.post('/check-all', requireWrite, async (_req: Request, res: Response) => {
const devices = await query<DeviceRow>(`SELECT * FROM devices WHERE status='online'`);
const settled = await Promise.allSettled(devices.map(async (d) => {
const c = new DeviceCollector(d);
try {
await c.connect();
const s = await c.checkForUpdates();
const installed = (s['installed-version'] || '').trim();
const latest = (s['latest-version'] || '').trim();
const available = !!latest && latest !== installed;
await query(
`UPDATE devices SET ros_version=COALESCE(NULLIF($2,''), ros_version),
latest_ros_version=NULLIF($3,''), firmware_update_available=$4 WHERE id=$1`,
[d.id, installed, latest, available]);
return { name: d.name, installed, latest, available };
} finally { c.disconnect(); }
}));
res.json({
results: settled.map((s, i) => s.status === 'fulfilled'
? { ...s.value, ok: true }
: { name: devices[i].name, ok: false, error: (s.reason as Error)?.message }),
});
});
// POST /api/firmware/rollouts — create a rollout (optionally scheduled)
router.post('/rollouts', requireWrite, async (req: Request, res: Response) => {
const { name, halt_on_failure, pre_backup, scheduled_at, devices, start } = req.body as {
name?: string; halt_on_failure?: boolean; pre_backup?: boolean; scheduled_at?: string | null;
devices?: { device_id: number; wave: number }[]; start?: boolean;
};
if (!name || !name.trim()) return res.status(400).json({ error: 'name is required' });
if (!Array.isArray(devices) || devices.length === 0) return res.status(400).json({ error: 'devices array is required' });
for (const d of devices) {
if (!Number.isInteger(d.device_id) || !Number.isInteger(d.wave) || d.wave < 1 || d.wave > 9) {
return res.status(400).json({ error: 'each device needs device_id and wave (1-9)' });
}
}
if (scheduled_at && isNaN(Date.parse(scheduled_at))) return res.status(400).json({ error: 'scheduled_at must be a valid timestamp' });
const rollout = await queryOne<{ id: number }>(
`INSERT INTO firmware_rollouts (name, halt_on_failure, pre_backup, scheduled_at)
VALUES ($1,$2,$3,$4) RETURNING id`,
[name.trim().slice(0, 100), halt_on_failure !== false, pre_backup !== false, scheduled_at || null]);
for (const d of devices) {
await query(
`INSERT INTO firmware_rollout_devices (rollout_id, device_id, wave) VALUES ($1,$2,$3)`,
[rollout!.id, d.device_id, d.wave]);
}
if (start && !scheduled_at) {
try { await firmwareOrchestrator.start(rollout!.id); }
catch (e) { return res.status(409).json({ error: (e as Error).message, id: rollout!.id }); }
}
res.status(201).json({ id: rollout!.id });
});
// GET /api/firmware/rollouts — recent rollouts with progress counts
router.get('/rollouts', async (_req: Request, res: Response) => {
const rows = await query(`
SELECT r.*,
COUNT(d.id)::int AS device_count,
COUNT(d.id) FILTER (WHERE d.status = 'success')::int AS success_count,
COUNT(d.id) FILTER (WHERE d.status = 'failed')::int AS failed_count
FROM firmware_rollouts r
LEFT JOIN firmware_rollout_devices d ON d.rollout_id = r.id
GROUP BY r.id ORDER BY r.created_at DESC LIMIT 20`);
res.json(rows);
});
// GET /api/firmware/rollouts/:id — full rollout detail
router.get('/rollouts/:id', async (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
const [rollout, devices] = await Promise.all([
queryOne(`SELECT * FROM firmware_rollouts WHERE id=$1`, [id]),
query(`
SELECT rd.*, dev.name AS device_name, dev.device_type, dev.model
FROM firmware_rollout_devices rd JOIN devices dev ON dev.id = rd.device_id
WHERE rd.rollout_id = $1 ORDER BY rd.wave ASC, rd.id ASC`, [id]),
]);
if (!rollout) return res.status(404).json({ error: 'Rollout not found' });
res.json({ ...rollout, devices });
});
// POST /api/firmware/rollouts/:id/start
router.post('/rollouts/:id/start', requireWrite, async (req: Request, res: Response) => {
try {
await firmwareOrchestrator.start(parseInt(req.params.id, 10));
res.json({ ok: true });
} catch (e) {
res.status(409).json({ error: (e as Error).message });
}
});
// POST /api/firmware/rollouts/:id/cancel — stops before the next device (an
// in-flight upgrade is never interrupted mid-write)
router.post('/rollouts/:id/cancel', requireWrite, async (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
const rollout = await queryOne<{ status: string }>(`SELECT status FROM firmware_rollouts WHERE id=$1`, [id]);
if (!rollout) return res.status(404).json({ error: 'Rollout not found' });
if (rollout.status === 'pending') {
await query(`UPDATE firmware_rollouts SET status='cancelled', finished_at=NOW() WHERE id=$1`, [id]);
await query(`UPDATE firmware_rollout_devices SET status='skipped', error='Rollout cancelled' WHERE rollout_id=$1 AND status='pending'`, [id]);
return res.json({ ok: true });
}
firmwareOrchestrator.cancel(id);
res.json({ ok: true, note: 'Cancelling after the in-flight device finishes' });
});
export default router;
+200
View File
@@ -0,0 +1,200 @@
import { Router, Request, Response } from 'express';
import { randomBytes } from 'crypto';
import { queryOne } from '../config/database';
import { requireAuth, requireWrite } from '../middleware/auth';
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
const router = Router();
router.use(requireAuth);
async function getDevice(id: number): Promise<DeviceRow | null> {
return queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [id]);
}
// Run fn against a connected collector for ?deviceId= / body.deviceId
async function withDevice<T>(
req: Request, res: Response,
fn: (c: DeviceCollector) => Promise<T>
): Promise<T | undefined> {
const deviceId = parseInt(String(req.query.deviceId ?? (req.body as { deviceId?: number })?.deviceId ?? ''), 10);
if (!deviceId) { res.status(400).json({ error: 'deviceId is required' }); return undefined; }
const device = await getDevice(deviceId);
if (!device) { res.status(404).json({ error: 'Device not found' }); return undefined; }
const collector = new DeviceCollector(device);
try {
await collector.connect();
return await fn(collector);
} catch (err) {
res.status(502).json({ error: (err as Error).message });
return undefined;
} finally {
collector.disconnect();
}
}
// ─── Overview ────────────────────────────────────────────────────────────────
// GET /api/guest-wifi/overview?deviceId=
router.get('/overview', async (req: Request, res: Response) => {
const result = await withDevice(req, res, async (c) => {
const [servers, profiles, userProfiles, users, active, interfaces] = await Promise.all([
c.getHotspotServers(), c.getHotspotProfiles(), c.getHotspotUserProfiles(),
c.getHotspotUsers(), c.getHotspotActive(),
c.getInterfacesLive().catch(() => [] as Record<string, string>[]),
]);
return {
servers, profiles, userProfiles,
userCount: users.length,
activeCount: active.length,
interfaces: interfaces
.filter(i => /^(bridge|vlan|wifi|wlan|ether)/i.test(i['name'] || '') && i['disabled'] !== 'true')
.map(i => ({ name: i['name'], type: i['type'] || '' })),
};
});
if (result !== undefined) res.json(result);
});
// ─── Guided setup ─────────────────────────────────────────────────────────────
// POST /api/guest-wifi/setup
router.post('/setup', requireWrite, async (req: Request, res: Response) => {
const { name, interfaceName, gatewayCidr, poolRange, dnsName, rateLimit } = req.body as Record<string, string>;
const { ssid, vlanId, masquerade } = req.body as {
ssid?: { ssid?: string; passphrase?: string }; vlanId?: number; masquerade?: boolean;
};
if (!name || !/^[a-z0-9-]{2,24}$/i.test(name)) { res.status(400).json({ error: 'name (2-24 alphanumeric/dash) is required' }); return; }
if (!ssid?.ssid && !interfaceName) { res.status(400).json({ error: 'Provide a guest SSID to create, or an existing interfaceName' }); return; }
if (ssid?.ssid && (ssid.ssid.length < 1 || ssid.ssid.length > 32)) { res.status(400).json({ error: 'SSID must be 1-32 characters' }); return; }
if (ssid?.passphrase && ssid.passphrase.length < 8) { res.status(400).json({ error: 'Passphrase must be at least 8 characters (or empty for an open network)' }); return; }
if (vlanId !== undefined && (!Number.isInteger(vlanId) || vlanId < 1 || vlanId > 4094)) { res.status(400).json({ error: 'vlanId must be 1-4094' }); return; }
if (!/^\d{1,3}(\.\d{1,3}){3}\/\d{1,2}$/.test(gatewayCidr || '')) { res.status(400).json({ error: 'gatewayCidr must look like 10.5.50.1/24' }); return; }
if (!/^\d{1,3}(\.\d{1,3}){3}-\d{1,3}(\.\d{1,3}){3}$/.test(poolRange || '')) { res.status(400).json({ error: 'poolRange must look like 10.5.50.10-10.5.50.254' }); return; }
if (rateLimit && !/^\d+[kMG]?\/\d+[kMG]?$/.test(rateLimit)) { res.status(400).json({ error: 'rateLimit must look like 10M/10M (rx/tx)' }); return; }
const result = await withDevice(req, res, (c) =>
c.setupGuestNetwork({
name: name.toLowerCase(), gatewayCidr, poolRange, dnsName, rateLimit,
interfaceName: interfaceName || undefined,
ssid: ssid?.ssid ? { ssid: ssid.ssid, passphrase: ssid.passphrase || undefined } : undefined,
vlanId, masquerade: masquerade !== false,
})
);
if (result !== undefined) res.status(201).json(result);
});
// ─── Vouchers (hotspot users) ─────────────────────────────────────────────────
// Human-friendly voucher codes: no ambiguous chars (0/O, 1/I/L)
const CODE_CHARS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
function generateCode(len = 8): string {
const bytes = randomBytes(len);
let out = '';
for (let i = 0; i < len; i++) out += CODE_CHARS[bytes[i] % CODE_CHARS.length];
return `${out.slice(0, 4)}-${out.slice(4)}`;
}
// GET /api/guest-wifi/users?deviceId=
router.get('/users', async (req: Request, res: Response) => {
const result = await withDevice(req, res, (c) => c.getHotspotUsers());
if (result !== undefined) res.json(result);
});
// POST /api/guest-wifi/vouchers — batch-create voucher users
router.post('/vouchers', requireWrite, async (req: Request, res: Response) => {
const { count, durationHours, dataCapMB, userProfile } = req.body as {
count?: number; durationHours?: number; dataCapMB?: number; userProfile?: string;
};
const n = Math.min(Math.max(parseInt(String(count ?? 1), 10) || 1, 1), 100);
const result = await withDevice(req, res, async (c) => {
const existing = new Set((await c.getHotspotUsers()).map(u => u['name']));
const batch = new Date().toISOString().slice(0, 16).replace('T', ' ');
const codes: string[] = [];
for (let i = 0; i < n; i++) {
let code = generateCode();
let guard = 0;
while (existing.has(code) && guard++ < 20) code = generateCode();
existing.add(code);
const params: Record<string, string> = { name: code, comment: `voucher ${batch}` };
if (userProfile) params['profile'] = userProfile;
if (durationHours && durationHours > 0) params['limit-uptime'] = `${Math.floor(durationHours)}h`;
if (dataCapMB && dataCapMB > 0) params['limit-bytes-total'] = String(Math.floor(dataCapMB) * 1024 * 1024);
await c.addHotspotUser(params);
codes.push(code);
}
return { created: codes.length, codes };
});
if (result !== undefined) res.status(201).json(result);
});
// DELETE /api/guest-wifi/users/:id?deviceId=
router.delete('/users/:id', requireWrite, async (req: Request, res: Response) => {
const result = await withDevice(req, res, async (c) => {
await c.removeHotspotUser(req.params.id);
return { ok: true };
});
if (result !== undefined) res.json(result);
});
// ─── Active sessions ──────────────────────────────────────────────────────────
// GET /api/guest-wifi/active?deviceId=
router.get('/active', async (req: Request, res: Response) => {
const result = await withDevice(req, res, (c) => c.getHotspotActive());
if (result !== undefined) res.json(result);
});
// DELETE /api/guest-wifi/active/:id?deviceId= — kick a guest
router.delete('/active/:id', requireWrite, async (req: Request, res: Response) => {
const result = await withDevice(req, res, async (c) => {
await c.disconnectHotspotActive(req.params.id);
return { ok: true };
});
if (result !== undefined) res.json(result);
});
// ─── Walled garden ────────────────────────────────────────────────────────────
router.get('/walled-garden', async (req: Request, res: Response) => {
const result = await withDevice(req, res, (c) => c.getWalledGarden());
if (result !== undefined) res.json(result);
});
router.post('/walled-garden', requireWrite, async (req: Request, res: Response) => {
const { dstHost, comment } = req.body as { dstHost?: string; comment?: string };
if (!dstHost || !dstHost.trim()) { res.status(400).json({ error: 'dstHost is required' }); return; }
const result = await withDevice(req, res, async (c) => {
await c.addWalledGardenEntry(dstHost.trim(), comment);
return { ok: true };
});
if (result !== undefined) res.status(201).json(result);
});
router.delete('/walled-garden/:id', requireWrite, async (req: Request, res: Response) => {
const result = await withDevice(req, res, async (c) => {
await c.removeWalledGardenEntry(req.params.id);
return { ok: true };
});
if (result !== undefined) res.json(result);
});
// ─── Server enable/disable/remove ─────────────────────────────────────────────
router.put('/servers/:id', requireWrite, async (req: Request, res: Response) => {
const { disabled } = req.body as { disabled?: boolean };
const result = await withDevice(req, res, async (c) => {
await c.setHotspotServerDisabled(req.params.id, !!disabled);
return { ok: true };
});
if (result !== undefined) res.json(result);
});
router.delete('/servers/:id', requireWrite, async (req: Request, res: Response) => {
const result = await withDevice(req, res, async (c) => {
await c.removeHotspotServer(req.params.id);
return { ok: true };
});
if (result !== undefined) res.json(result);
});
export default router;
+149
View File
@@ -248,6 +248,40 @@ router.get('/insights', async (_req: Request, res: Response) => {
}); });
} }
// Rogue APs — evil twins broadcasting our SSIDs from foreign hardware
try {
const { classifyScans } = await import('../utils/rogueAp');
const [scans, radios, ifaceMacs] = await Promise.all([
query<{ device_id: number; device_name: string; scanned_at: string; data: unknown }>(`
SELECT DISTINCT ON (a.device_id) a.device_id, d.name AS device_name, a.scanned_at, a.data
FROM ap_scan_data a JOIN devices d ON d.id = a.device_id
ORDER BY a.device_id, a.scanned_at DESC`),
query<{ ssid: string | null; mac_address: string | null }>(`SELECT ssid, mac_address FROM wireless_interfaces`),
query<{ mac_address: string }>(`SELECT mac_address FROM interfaces WHERE mac_address IS NOT NULL`),
]);
const ownSsids = new Set(radios.map(r => r.ssid || '').filter(Boolean));
const ownBssids = new Set([
...radios.map(r => (r.mac_address || '').toLowerCase()).filter(Boolean),
...ifaceMacs.map(r => r.mac_address.toLowerCase()),
]);
const { rogues } = classifyScans(
scans.map(s => ({ deviceName: s.device_name, scannedAt: String(s.scanned_at), networks: Array.isArray(s.data) ? s.data as import('../utils/rogueAp').ScannedNetwork[] : [] })),
ownSsids, ownBssids
);
for (const r of rogues) {
attention.push({
sev: 'error', category: 'security',
title: `Rogue AP broadcasting "${r.ssid}"`,
body: `Foreign BSSID ${r.bssid}${r.vendor ? ` (${r.vendor})` : ''} at ${r.signal} dBm, seen by ${r.seenBy} — possible evil twin.`,
action: 'Open Wireless', path: '/wireless',
});
}
} catch { /* scan data unavailable */ }
// Baseline anomalies (client counts, CPU, error bursts)
const anomalies = await detectAnomalies();
for (const a of anomalies) attention.push(a);
// Severity ordering: error → warn → info // Severity ordering: error → warn → info
const sevRank: Record<Sev, number> = { error: 0, warn: 1, info: 2 }; const sevRank: Record<Sev, number> = { error: 0, warn: 1, info: 2 };
attention.sort((a, b) => sevRank[a.sev] - sevRank[b.sev]); attention.sort((a, b) => sevRank[a.sev] - sevRank[b.sev]);
@@ -258,6 +292,121 @@ router.get('/insights', async (_req: Request, res: Response) => {
res.json({ attention, capacity, activity }); res.json({ attention, capacity, activity });
}); });
// ─── Baseline anomaly detection ────────────────────────────────────────────────
// Mist-style "this is unusual for you" insights: compare the last 30 minutes
// against the same-hour-of-day baseline over the past 14 days (per device) and
// flag deviations of ≥ 2.5 standard deviations. Conservative thresholds — an
// insight should mean something.
async function influxGroupValues(flux: string): Promise<Map<string, number>> {
const out = new Map<string, number>();
try {
const api = getQueryApi();
await new Promise<void>((resolve, reject) => {
api.queryRows(flux, {
next(row, tableMeta) {
const o = tableMeta.toObject(row) as Record<string, unknown>;
const id = String(o['device_id'] || '');
if (id) out.set(id, Number(o['_value']) || 0);
},
error: reject,
complete: resolve,
});
});
} catch { /* influx unavailable */ }
return out;
}
async function detectAnomalies(): Promise<AttentionItem[]> {
const items: AttentionItem[] = [];
const hour = new Date().getUTCHours();
const nameById = new Map<string, { id: number; name: string }>();
const devices = await query<{ id: number; name: string }>(`SELECT id, name FROM devices`);
for (const d of devices) nameById.set(String(d.id), d);
const metric = async (measurement: string, field: string) => {
const base = `
from(bucket: "${bucket}")
|> range(start: -14d)
|> filter(fn: (r) => r["_measurement"] == "${measurement}")
|> filter(fn: (r) => r["_field"] == "${field}")
|> hourSelection(start: ${hour}, stop: ${hour})
|> group(columns: ["device_id"])`;
const [cur, mean, std] = await Promise.all([
influxGroupValues(`
from(bucket: "${bucket}")
|> range(start: -30m)
|> filter(fn: (r) => r["_measurement"] == "${measurement}")
|> filter(fn: (r) => r["_field"] == "${field}")
|> group(columns: ["device_id"])
|> mean()`),
influxGroupValues(`${base}\n |> mean()`),
influxGroupValues(`${base}\n |> stddev()`),
]);
return { cur, mean, std };
};
// Client-count deviations (device dropped clients / unusual surge)
const cc = await metric('client_counts', 'total_clients');
for (const [id, current] of cc.cur) {
const m = cc.mean.get(id), s = cc.std.get(id);
const dev = nameById.get(id);
if (!dev || m === undefined || s === undefined || s < 1) continue;
const z = (current - m) / s;
if (Math.abs(z) >= 2.5 && Math.abs(current - m) >= 5) {
items.push({
sev: 'warn', category: 'anomaly',
title: `Client count on ${dev.name} is unusually ${z < 0 ? 'low' : 'high'}`,
body: `${Math.round(current)} clients now vs ~${Math.round(m)} typical for this hour (14-day baseline).`,
action: 'View device', path: `/devices/${dev.id}`,
});
}
}
// Sustained CPU deviations (only flag high — idle is not a problem)
const cpu = await metric('device_resources', 'cpu_load');
for (const [id, current] of cpu.cur) {
const m = cpu.mean.get(id), s = cpu.std.get(id);
const dev = nameById.get(id);
if (!dev || m === undefined || s === undefined || s < 1) continue;
const z = (current - m) / s;
if (z >= 2.5 && current >= 50) {
items.push({
sev: 'warn', category: 'anomaly',
title: `CPU on ${dev.name} is far above its baseline`,
body: `${Math.round(current)}% for the last 30m vs ~${Math.round(m)}% typical for this hour.`,
action: 'View device', path: `/devices/${dev.id}`,
});
}
}
// Error-log bursts (Postgres, hourly rate vs 7-day average)
const bursts = await query<{ device_id: number; name: string; cur: string; avg_hourly: string }>(`
WITH cur AS (
SELECT device_id, COUNT(*) AS n FROM events
WHERE severity = 'error' AND event_time > NOW() - INTERVAL '1 hour'
GROUP BY device_id
), hist AS (
SELECT device_id, COUNT(*) / 168.0 AS avg_hourly FROM events
WHERE severity = 'error' AND event_time > NOW() - INTERVAL '7 days'
GROUP BY device_id
)
SELECT c.device_id, d.name, c.n AS cur, COALESCE(h.avg_hourly, 0) AS avg_hourly
FROM cur c JOIN devices d ON d.id = c.device_id
LEFT JOIN hist h ON h.device_id = c.device_id
WHERE c.n >= 10 AND c.n > 5 * GREATEST(COALESCE(h.avg_hourly, 0), 0.5)`);
for (const b of bursts) {
items.push({
sev: 'warn', category: 'anomaly',
title: `Error-log burst on ${b.name}`,
body: `${b.cur} error entries in the last hour (typically ~${Number(b.avg_hourly).toFixed(1)}/h).`,
action: 'Open Events', path: '/events',
});
}
return items;
}
// Recent activity merged from config snapshots, audit log, and events. // Recent activity merged from config snapshots, audit log, and events.
async function buildActivity() { async function buildActivity() {
const [configs, audits, events] = await Promise.all([ const [configs, audits, events] = await Promise.all([
+37
View File
@@ -846,4 +846,41 @@ router.get('/rf/connectivity', async (req: Request, res: Response) => {
}); });
}); });
// ─── Rogue / neighbor AP detection ────────────────────────────────────────────
// GET /api/wireless/rf/rogue — classify latest AP scans against our own SSIDs/BSSIDs
router.get('/rf/rogue', async (_req: Request, res: Response) => {
const { classifyScans } = await import('../utils/rogueAp');
const [scans, radios, ifaceMacs] = await Promise.all([
query<{ device_id: number; device_name: string; scanned_at: string; data: unknown }>(`
SELECT DISTINCT ON (a.device_id) a.device_id, d.name AS device_name, a.scanned_at, a.data
FROM ap_scan_data a JOIN devices d ON d.id = a.device_id
ORDER BY a.device_id, a.scanned_at DESC`),
query<{ ssid: string | null; mac_address: string | null }>(
`SELECT ssid, mac_address FROM wireless_interfaces`),
query<{ mac_address: string }>(
`SELECT mac_address FROM interfaces WHERE mac_address IS NOT NULL`),
]);
const ownSsids = new Set(radios.map(r => r.ssid || '').filter(Boolean));
const ownBssids = new Set([
...radios.map(r => (r.mac_address || '').toLowerCase()).filter(Boolean),
...ifaceMacs.map(r => r.mac_address.toLowerCase()),
]);
const records = scans.map(s => ({
deviceName: s.device_name,
scannedAt: String(s.scanned_at),
networks: Array.isArray(s.data) ? s.data as import('../utils/rogueAp').ScannedNetwork[] : [],
}));
const { rogues, neighbors } = classifyScans(records, ownSsids, ownBssids);
res.json({
rogues, neighbors,
lastScanAt: scans.length ? scans.map(s => String(s.scanned_at)).sort().pop() : null,
scannedDevices: scans.length,
});
});
export default router; export default router;
+11
View File
@@ -97,6 +97,17 @@ export class AlertService {
if (now < until) return; if (now < until) return;
cooldownUntil.set(cooldownKey, now + cooldownMs); cooldownUntil.set(cooldownKey, now + cooldownMs);
// Outbound webhooks fan out on the same (rule-gated, cooldown-respecting)
// event stream, independent of whether any alert channels are configured.
void import('./WebhookService').then(({ webhookService }) =>
webhookService.dispatch(eventType, {
message,
device_id: ctx.deviceId ?? null,
device_name: ctx.deviceName ?? null,
details: ctx.details ?? null,
})
).catch(() => {});
// Get enabled channels // Get enabled channels
const channels = await query<AlertChannel>( const channels = await query<AlertChannel>(
`SELECT * FROM alert_channels WHERE enabled = true ORDER BY id` `SELECT * FROM alert_channels WHERE enabled = true ORDER BY id`
@@ -0,0 +1,211 @@
// Staged firmware rollout orchestrator.
//
// Executes a rollout wave-by-wave (wave 1 = canary), each device sequentially:
// pre-upgrade backup → install RouterOS update → wait through the reboot →
// verify it came back healthy on the new version → next device.
// A failure marks the device failed and (with halt_on_failure) stops the whole
// rollout so a bad build never reaches the rest of the fleet. One rollout runs
// at a time; a lightweight scheduler starts rollouts whose scheduled_at has
// arrived (pair with a maintenance window by scheduling inside it).
import { query, queryOne } from '../config/database';
import { DeviceCollector, DeviceRow } from './mikrotik/DeviceCollector';
import { BackupService } from './BackupService';
const REBOOT_GRACE_MS = 25_000; // let the device actually go down
const REBOOT_POLL_MS = 15_000; // probe cadence while waiting
const REBOOT_TIMEOUT_MS = 12 * 60_000; // give slow flash writes room
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
interface RolloutRow {
id: number; name: string; status: string;
halt_on_failure: boolean; pre_backup: boolean;
}
interface RolloutDeviceRow {
id: number; rollout_id: number; device_id: number; wave: number; status: string;
}
export class FirmwareOrchestrator {
private activeRolloutId: number | null = null;
private cancelRequested = false;
private schedulerTimer: ReturnType<typeof setInterval> | null = null;
private backupService = new BackupService();
get running(): number | null { return this.activeRolloutId; }
startScheduler(): void {
if (this.schedulerTimer) return;
this.schedulerTimer = setInterval(() => {
this.startDueRollouts().catch(e => console.error('[Firmware] scheduler error:', e));
}, 60_000);
}
stopScheduler(): void {
if (this.schedulerTimer) { clearInterval(this.schedulerTimer); this.schedulerTimer = null; }
}
private async startDueRollouts(): Promise<void> {
if (this.activeRolloutId) return;
const due = await queryOne<{ id: number }>(
`SELECT id FROM firmware_rollouts
WHERE status = 'pending' AND scheduled_at IS NOT NULL AND scheduled_at <= NOW()
ORDER BY scheduled_at ASC LIMIT 1`);
if (due) await this.start(due.id).catch(e => console.error(`[Firmware] scheduled start of #${due.id} failed:`, e));
}
async start(rolloutId: number): Promise<void> {
if (this.activeRolloutId) throw new Error(`Rollout #${this.activeRolloutId} is already running`);
const rollout = await queryOne<RolloutRow>(`SELECT * FROM firmware_rollouts WHERE id = $1`, [rolloutId]);
if (!rollout) throw new Error('Rollout not found');
if (rollout.status !== 'pending') throw new Error(`Rollout is ${rollout.status} — only pending rollouts can start`);
this.activeRolloutId = rolloutId;
this.cancelRequested = false;
await query(`UPDATE firmware_rollouts SET status='running', started_at=NOW() WHERE id=$1`, [rolloutId]);
// Fire-and-forget the run loop; callers poll status via the API.
void this.run(rollout).catch(async (e) => {
console.error(`[Firmware] rollout #${rolloutId} crashed:`, e);
await query(`UPDATE firmware_rollouts SET status='failed', finished_at=NOW() WHERE id=$1`, [rolloutId]);
}).finally(() => { this.activeRolloutId = null; });
}
cancel(rolloutId: number): void {
if (this.activeRolloutId === rolloutId) this.cancelRequested = true;
}
private async run(rollout: RolloutRow): Promise<void> {
const items = await query<RolloutDeviceRow>(
`SELECT * FROM firmware_rollout_devices WHERE rollout_id=$1 ORDER BY wave ASC, id ASC`,
[rollout.id]);
let halted = false;
for (const item of items) {
if (this.cancelRequested || halted) {
await query(`UPDATE firmware_rollout_devices SET status='skipped',
error=$2 WHERE id=$1 AND status='pending'`,
[item.id, this.cancelRequested ? 'Rollout cancelled' : 'Halted: earlier device failed']);
continue;
}
const ok = await this.upgradeDevice(rollout, item);
if (!ok && rollout.halt_on_failure) halted = true;
}
const finalStatus = this.cancelRequested ? 'cancelled' : halted ? 'failed' : 'completed';
await query(`UPDATE firmware_rollouts SET status=$2, finished_at=NOW() WHERE id=$1`, [rollout.id, finalStatus]);
console.log(`[Firmware] rollout #${rollout.id} ${finalStatus}`);
if (finalStatus === 'completed' || finalStatus === 'failed') {
const counts = await queryOne<{ ok: string; failed: string }>(
`SELECT COUNT(*) FILTER (WHERE status='success')::text AS ok,
COUNT(*) FILTER (WHERE status='failed')::text AS failed
FROM firmware_rollout_devices WHERE rollout_id=$1`, [rollout.id]);
void import('./WebhookService').then(({ webhookService }) =>
webhookService.dispatch(finalStatus === 'completed' ? 'rollout_completed' : 'rollout_failed', {
rollout_id: rollout.id, name: rollout.name,
succeeded: parseInt(counts?.ok || '0', 10), failed: parseInt(counts?.failed || '0', 10),
})
).catch(() => {});
}
}
private async setItem(id: number, fields: Record<string, string | null>): Promise<void> {
const keys = Object.keys(fields);
const sets = keys.map((k, i) => `${k}=$${i + 2}`).join(', ');
await query(`UPDATE firmware_rollout_devices SET ${sets} WHERE id=$1`, [id, ...keys.map(k => fields[k])]);
}
private async upgradeDevice(rollout: RolloutRow, item: RolloutDeviceRow): Promise<boolean> {
const device = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id=$1`, [item.device_id]);
if (!device) {
await this.setItem(item.id, { status: 'failed', error: 'Device no longer exists' });
return false;
}
const fail = async (error: string) => {
console.error(`[Firmware] ${device.name}: ${error}`);
await this.setItem(item.id, { status: 'failed', error });
await query(`UPDATE firmware_rollout_devices SET finished_at=NOW() WHERE id=$1`, [item.id]);
return false;
};
await query(`UPDATE firmware_rollout_devices SET started_at=NOW() WHERE id=$1`, [item.id]);
const fromVersion = (device.ros_version || '').trim();
await this.setItem(item.id, { from_version: fromVersion || null });
// 1. Pre-upgrade backup
if (rollout.pre_backup) {
await this.setItem(item.id, { status: 'backing_up' });
try {
await this.backupService.createBackup({
id: device.id, name: device.name, ip_address: device.ip_address,
ssh_port: device.ssh_port ?? 22, ssh_username: device.ssh_username,
ssh_password_encrypted: device.ssh_password_encrypted,
api_username: device.api_username, api_password_encrypted: device.api_password_encrypted,
}, `Pre-upgrade backup (rollout "${rollout.name}")`, 'pre-upgrade');
} catch (e) {
return fail(`Pre-upgrade backup failed: ${(e as Error).message}`);
}
}
// 2. Kick off the install (device downloads, installs, and reboots itself)
await this.setItem(item.id, { status: 'upgrading' });
const collector = new DeviceCollector(device);
try {
await collector.connect();
const status = await collector.checkForUpdates();
const installed = (status['installed-version'] || '').trim();
const latest = (status['latest-version'] || '').trim();
if (!latest || latest === installed) {
await this.setItem(item.id, { status: 'skipped', error: 'Already up to date', to_version: installed || null });
await query(`UPDATE firmware_rollout_devices SET finished_at=NOW() WHERE id=$1`, [item.id]);
collector.disconnect();
return true;
}
await collector.installUpdate();
} catch (e) {
collector.disconnect();
return fail(`Update install failed: ${(e as Error).message}`);
}
collector.disconnect();
// 3. Ride out the reboot
await this.setItem(item.id, { status: 'rebooting' });
await sleep(REBOOT_GRACE_MS);
const deadline = Date.now() + REBOOT_TIMEOUT_MS;
let backOnline = false;
let newVersion = '';
while (Date.now() < deadline) {
if (this.cancelRequested) break;
const probe = new DeviceCollector(device);
try {
await probe.connect();
await this.setItem(item.id, { status: 'verifying' });
const resource = await probe.getSystemResource();
newVersion = (resource['version'] || '').split(' ')[0];
probe.disconnect();
backOnline = true;
break;
} catch {
probe.disconnect();
await sleep(REBOOT_POLL_MS);
}
}
if (!backOnline) {
return fail(`Device did not come back online within ${Math.round(REBOOT_TIMEOUT_MS / 60000)} minutes after the upgrade — check it manually (a pre-upgrade backup ${rollout.pre_backup ? 'exists' : 'was NOT taken'})`);
}
// 4. Verify the version actually moved
if (newVersion && fromVersion && newVersion === fromVersion) {
return fail(`Device rebooted but still reports ${newVersion} — the update did not apply`);
}
await this.setItem(item.id, { status: 'success', to_version: newVersion || null });
await query(`UPDATE firmware_rollout_devices SET finished_at=NOW() WHERE id=$1`, [item.id]);
await query(`UPDATE devices SET ros_version=COALESCE(NULLIF($2,''), ros_version), firmware_update_available=FALSE, status='online', last_seen=NOW() WHERE id=$1`,
[device.id, newVersion]);
console.log(`[Firmware] ${device.name}: upgraded to ${newVersion || 'unknown'}`);
return true;
}
}
export const firmwareOrchestrator = new FirmwareOrchestrator();
+150
View File
@@ -0,0 +1,150 @@
// Scheduled email reports — periodic fleet summaries (device health, alerts,
// top clients, updates, backups) delivered over the SMTP settings of the first
// enabled email alert channel. An hourly scheduler sends whatever is due.
import nodemailer from 'nodemailer';
import { query, queryOne } from '../config/database';
interface ScheduleRow {
id: number; name: string; frequency: 'daily' | 'weekly' | 'monthly';
recipients: string; enabled: boolean; next_run_at: string;
}
const PERIOD_DAYS: Record<string, number> = { daily: 1, weekly: 7, monthly: 30 };
export function computeNextRun(frequency: string, from: Date): Date {
const next = new Date(from);
if (frequency === 'daily') next.setDate(next.getDate() + 1);
else if (frequency === 'monthly') next.setMonth(next.getMonth() + 1);
else next.setDate(next.getDate() + 7);
return next;
}
const fmtBytes = (b: number) =>
b >= 1024 ** 4 ? `${(b / 1024 ** 4).toFixed(2)} TB`
: b >= 1024 ** 3 ? `${(b / 1024 ** 3).toFixed(2)} GB`
: b >= 1024 ** 2 ? `${(b / 1024 ** 2).toFixed(1)} MB`
: `${(b / 1024).toFixed(1)} KB`;
export class ReportService {
private timer: ReturnType<typeof setInterval> | null = null;
startScheduler(): void {
if (this.timer) return;
this.timer = setInterval(() => {
this.sendDue().catch(e => console.error('[Reports] scheduler error:', e));
}, 60 * 60_000);
}
stopScheduler(): void {
if (this.timer) { clearInterval(this.timer); this.timer = null; }
}
private async sendDue(): Promise<void> {
const due = await query<ScheduleRow>(
`SELECT * FROM report_schedules WHERE enabled = TRUE AND next_run_at <= NOW()`);
for (const s of due) {
try {
await this.send(s);
await query(
`UPDATE report_schedules SET last_sent_at = NOW(), next_run_at = $2 WHERE id = $1`,
[s.id, computeNextRun(s.frequency, new Date())]);
} catch (e) {
console.error(`[Reports] "${s.name}" failed:`, (e as Error).message);
// push next_run_at forward anyway so a broken SMTP config doesn't retry hourly forever
await query(`UPDATE report_schedules SET next_run_at = $2 WHERE id = $1`,
[s.id, computeNextRun(s.frequency, new Date())]);
}
}
}
async send(schedule: Pick<ScheduleRow, 'name' | 'frequency' | 'recipients'>): Promise<void> {
const smtp = await this.smtpConfig();
const html = await this.buildHtml(schedule.frequency);
const recipients = schedule.recipients.split(',').map(r => r.trim()).filter(Boolean);
if (recipients.length === 0) throw new Error('No recipients configured');
const transport = nodemailer.createTransport({
host: smtp.host, port: smtp.port, secure: smtp.secure,
auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined,
});
await transport.sendMail({
from: smtp.from,
to: recipients.join(', '),
subject: `MikroTik Manager — ${schedule.frequency} network report`,
html,
});
console.log(`[Reports] sent "${schedule.name}" to ${recipients.length} recipient(s)`);
}
// SMTP settings come from the first enabled email alert channel
private async smtpConfig(): Promise<{ host: string; port: number; secure: boolean; user?: string; pass?: string; from: string }> {
const ch = await queryOne<{ config: Record<string, unknown> }>(
`SELECT config FROM alert_channels WHERE type = 'email' AND enabled = TRUE ORDER BY id LIMIT 1`);
const cfg = ch?.config;
const host = cfg?.smtp_host as string | undefined;
if (!host) throw new Error('No enabled email alert channel — configure one under Settings → Alerting to send reports');
return {
host,
port: (cfg?.smtp_port as number) || 587,
secure: !!cfg?.smtp_secure,
user: cfg?.smtp_user as string | undefined,
pass: cfg?.smtp_pass as string | undefined,
from: (cfg?.from_address as string) || (cfg?.smtp_user as string) || 'reports@mikrotik-manager',
};
}
private async buildHtml(frequency: string): Promise<string> {
const days = PERIOD_DAYS[frequency] ?? 7;
const [devices, outages, alerts, topClients, backups] = await Promise.all([
query<{ total: string; online: string; updates: string }>(
`SELECT COUNT(*)::text AS total,
COUNT(*) FILTER (WHERE status='online')::text AS online,
COUNT(*) FILTER (WHERE firmware_update_available)::text AS updates
FROM devices`),
query<{ n: string; secs: string }>(
`SELECT COUNT(*)::text AS n, COALESCE(SUM(COALESCE(duration_seconds,0)),0)::text AS secs
FROM device_availability WHERE went_offline_at > NOW() - ($1 || ' days')::interval`, [days]),
query<{ errors: string; warnings: string }>(
`SELECT COUNT(*) FILTER (WHERE severity='error')::text AS errors,
COUNT(*) FILTER (WHERE severity='warning')::text AS warnings
FROM events WHERE event_time > NOW() - ($1 || ' days')::interval`, [days]),
query<{ name: string; bytes: string }>(
`SELECT COALESCE(NULLIF(c.custom_name,''), NULLIF(c.hostname,''), ctd.mac_address) AS name,
SUM(ctd.upload_bytes + ctd.download_bytes)::text AS bytes
FROM client_traffic_daily ctd
LEFT JOIN clients c ON LOWER(c.mac_address) = ctd.mac_address
WHERE ctd.day > CURRENT_DATE - $1::int
GROUP BY 1 ORDER BY SUM(ctd.upload_bytes + ctd.download_bytes) DESC LIMIT 5`, [days]),
query<{ n: string }>(
`SELECT COUNT(*)::text AS n FROM backups WHERE created_at > NOW() - ($1 || ' days')::interval`, [days]),
]);
const d = devices[0];
const outageMin = Math.round(parseInt(outages[0]?.secs || '0', 10) / 60);
const row = (label: string, value: string) =>
`<tr><td style="padding:6px 12px;color:#64748b;">${label}</td><td style="padding:6px 12px;font-weight:600;color:#0f172a;">${value}</td></tr>`;
const clientRows = topClients.map((c, i) =>
`<tr><td style="padding:4px 12px;color:#64748b;">${i + 1}. ${c.name}</td><td style="padding:4px 12px;color:#0f172a;">${fmtBytes(parseInt(c.bytes, 10))}</td></tr>`).join('');
return `
<div style="font-family:-apple-system,system-ui,sans-serif;max-width:560px;margin:0 auto;">
<h2 style="color:#0f172a;">MikroTik Manager — ${frequency} report</h2>
<p style="color:#64748b;font-size:13px;">Covering the last ${days} day${days !== 1 ? 's' : ''} · generated ${new Date().toUTCString()}</p>
<table style="border-collapse:collapse;width:100%;background:#f8fafc;border-radius:8px;">
${row('Devices online', `${d?.online ?? 0} / ${d?.total ?? 0}`)}
${row('Outages', `${outages[0]?.n ?? 0} (${outageMin} min total downtime)`)}
${row('Error events', alerts[0]?.errors ?? '0')}
${row('Warning events', alerts[0]?.warnings ?? '0')}
${row('Firmware updates available', d?.updates ?? '0')}
${row('Backups taken', backups[0]?.n ?? '0')}
</table>
${topClients.length ? `<h3 style="color:#0f172a;font-size:14px;margin-top:20px;">Top clients by traffic</h3>
<table style="border-collapse:collapse;width:100%;background:#f8fafc;border-radius:8px;">${clientRows}</table>` : ''}
<p style="color:#94a3b8;font-size:11px;margin-top:20px;">Sent by MikroTik Manager scheduled reports.</p>
</div>`;
}
}
export const reportService = new ReportService();
+94
View File
@@ -0,0 +1,94 @@
// Outbound webhooks — POSTs event payloads to subscribed URLs with an HMAC
// signature so receivers can verify authenticity. Fired from the alert
// pipeline (all alert event types) and the firmware orchestrator.
import { createHmac } from 'crypto';
import * as https from 'https';
import * as http from 'http';
import { URL } from 'url';
import { query } from '../config/database';
export const WEBHOOK_EVENTS = [
'device_offline', 'device_online', 'log_error', 'log_warning',
'high_cpu', 'high_memory', 'cert_expiry', 'device_discovered',
'firmware_update_available', 'config_drift',
'rollout_completed', 'rollout_failed',
] as const;
export type WebhookEvent = typeof WEBHOOK_EVENTS[number] | 'test';
interface WebhookRow {
id: number; name: string; url: string; secret: string | null;
events: string[]; enabled: boolean;
}
function postJson(urlStr: string, body: string, headers: Record<string, string>): Promise<number> {
return new Promise((resolve, reject) => {
let u: URL;
try { u = new URL(urlStr); } catch { reject(new Error('Invalid URL')); return; }
if (u.protocol !== 'http:' && u.protocol !== 'https:') { reject(new Error('URL must be http(s)')); return; }
const lib = u.protocol === 'https:' ? https : http;
const req = lib.request(u, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...headers },
timeout: 10_000,
}, (res) => {
res.resume(); // drain
resolve(res.statusCode ?? 0);
});
req.on('timeout', () => { req.destroy(new Error('Timed out after 10s')); });
req.on('error', reject);
req.write(body);
req.end();
});
}
export class WebhookService {
/** Fire an event to every enabled webhook subscribed to it (best-effort, non-blocking). */
async dispatch(event: WebhookEvent, data: Record<string, unknown>): Promise<void> {
const hooks = await query<WebhookRow>(
`SELECT id, name, url, secret, events, enabled FROM webhooks
WHERE enabled = TRUE AND $1 = ANY(events)`, [event]
).catch(() => [] as WebhookRow[]);
if (hooks.length === 0) return;
const body = JSON.stringify({
event,
timestamp: new Date().toISOString(),
data,
});
await Promise.allSettled(hooks.map(h => this.deliver(h, body)));
}
/** Send a sample payload to one webhook regardless of its subscriptions. */
async sendTest(id: number): Promise<{ status: number }> {
const rows = await query<WebhookRow>(`SELECT id, name, url, secret, events, enabled FROM webhooks WHERE id = $1`, [id]);
if (!rows[0]) throw new Error('Webhook not found');
const body = JSON.stringify({
event: 'test',
timestamp: new Date().toISOString(),
data: { message: 'MikroTik Manager webhook test — configuration looks good.' },
});
const status = await this.deliver(rows[0], body);
return { status };
}
private async deliver(hook: WebhookRow, body: string): Promise<number> {
const headers: Record<string, string> = { 'User-Agent': 'MikroTik-Manager-Webhook' };
if (hook.secret) {
headers['X-MTM-Signature'] = 'sha256=' + createHmac('sha256', hook.secret).update(body).digest('hex');
}
let status = 0;
try {
status = await postJson(hook.url, body, headers);
} catch (e) {
console.error(`[Webhook] "${hook.name}" delivery failed:`, (e as Error).message);
status = 0;
}
await query(`UPDATE webhooks SET last_status = $2, last_fired_at = NOW() WHERE id = $1`, [hook.id, status])
.catch(() => {});
return status;
}
}
export const webhookService = new WebhookService();
@@ -1580,6 +1580,10 @@ export class DeviceCollector {
return this.client.execute('/interface/bridge/print').catch(() => []); return this.client.execute('/interface/bridge/print').catch(() => []);
} }
async getInterfacesLive(): Promise<Record<string, string>[]> {
return this.client.execute('/interface/print').catch(() => []);
}
async getBridgePorts(): Promise<Record<string, string>[]> { async getBridgePorts(): Promise<Record<string, string>[]> {
return this.client.execute('/interface/bridge/port/print').catch(() => []); return this.client.execute('/interface/bridge/port/print').catch(() => []);
} }
@@ -1935,6 +1939,318 @@ export class DeviceCollector {
await this.client.execute('/queue/simple/remove', { '.id': id }); await this.client.execute('/queue/simple/remove', { '.id': id });
} }
// ─── Hotspot (Guest WiFi captive portal + vouchers) ─────────────────────────
async getHotspotServers(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/hotspot/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async setHotspotServerDisabled(id: string, disabled: boolean): Promise<void> {
await this.client.execute('/ip/hotspot/set', { '.id': id, disabled: disabled ? 'yes' : 'no' });
}
async removeHotspotServer(id: string): Promise<void> {
await this.client.execute('/ip/hotspot/remove', { '.id': id });
}
async getHotspotProfiles(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/hotspot/profile/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async addHotspotProfile(params: Record<string, string>): Promise<void> {
await this.client.execute('/ip/hotspot/profile/add', params);
}
async getHotspotUserProfiles(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/hotspot/user/profile/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async addHotspotUserProfile(params: Record<string, string>): Promise<void> {
await this.client.execute('/ip/hotspot/user/profile/add', params);
}
async getHotspotUsers(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/hotspot/user/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async addHotspotUser(params: Record<string, string>): Promise<void> {
await this.client.execute('/ip/hotspot/user/add', params);
}
async removeHotspotUser(id: string): Promise<void> {
await this.client.execute('/ip/hotspot/user/remove', { '.id': id });
}
async getHotspotActive(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/hotspot/active/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async disconnectHotspotActive(id: string): Promise<void> {
await this.client.execute('/ip/hotspot/active/remove', { '.id': id });
}
async getWalledGarden(): Promise<Record<string, string>[]> {
return this.client.execute('/ip/hotspot/walled-garden/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
}
async addWalledGardenEntry(dstHost: string, comment?: string): Promise<void> {
const params: Record<string, string> = { 'dst-host': dstHost, action: 'allow' };
if (comment) params['comment'] = comment;
await this.client.execute('/ip/hotspot/walled-garden/add', params);
}
async removeWalledGardenEntry(id: string): Promise<void> {
await this.client.execute('/ip/hotspot/walled-garden/remove', { '.id': id });
}
/**
* Full guest-network orchestration. Optionally creates the guest SSID itself
* (a virtual AP on every physical radio) and segregates the traffic onto a
* VLAN, then runs the captive-portal chain on the resulting interface:
*
* [virtual APs] → [bridge/VLAN wiring] → pool → gw address → DHCP →
* hotspot profile → portal server → guest user profile → [masquerade NAT]
*
* Topologies:
* - ssid + vlanId: new SSIDs joined to the main bridge with PVID=vlanId,
* portal binds to a new /interface/vlan on that bridge.
* - ssid only: new SSIDs joined to a dedicated guest bridge, portal
* binds to that bridge (guests stay off the main LAN).
* - neither: portal binds to the provided existing interface.
*/
async setupGuestNetwork(opts: {
name: string;
gatewayCidr: string;
poolRange: string;
dnsName?: string;
rateLimit?: string;
interfaceName?: string; // existing-interface path
ssid?: { ssid: string; passphrase?: string };
vlanId?: number;
masquerade?: boolean;
}): Promise<{
server: string; profile: string; userProfile: string; pool: string;
targetInterface: string; ssidInterfaces: string[]; vlanInterface?: string;
warnings: string[];
}> {
const { name, ssid, vlanId, masquerade } = opts;
const warnings: string[] = [];
const ssidInterfaces: string[] = [];
let targetInterface = opts.interfaceName || '';
let vlanInterface: string | undefined;
if (ssid) {
// 1. Create a virtual AP for the guest SSID on every physical radio
const pkg = await this.detectWifiPackage();
if (pkg === 'none') throw new Error('No wireless package detected on this device — cannot create a guest SSID');
const raw = pkg === 'wifi'
? await this.client.execute('/interface/wifi/print').catch(() => [] as Record<string, string>[])
: await this.client.execute('/interface/wireless/print').catch(() => [] as Record<string, string>[]);
const physicals = raw.filter(r => !r['master-interface'] && r['name']);
if (physicals.length === 0) throw new Error('No physical radios found to attach the guest SSID to');
const existingBySsid = raw.filter(r =>
(r['ssid'] || r['configuration.ssid'] || '') === ssid.ssid && r['master-interface']);
if (existingBySsid.length > 0) {
// Idempotency: SSID already exists — reuse it
for (const e of existingBySsid) ssidInterfaces.push(e['name']);
} else {
for (const radio of physicals) {
const ifaceName = await this.getNextInterfaceName();
const params: Record<string, string> = {
name: ifaceName,
'master-interface': radio['name'],
ssid: ssid.ssid,
disabled: 'no',
};
if (pkg !== 'wifi') params['mode'] = 'ap-bridge';
if (ssid.passphrase) {
params['passphrase'] = ssid.passphrase;
params['authentication-types'] = 'wpa2-psk';
}
await this.addWirelessInterface(params);
ssidInterfaces.push(ifaceName);
}
if (pkg !== 'wifi' && ssid.passphrase) {
warnings.push('Legacy wireless package: passphrase requires a security profile — the guest SSID was created OPEN. Assign a security profile manually if you need WPA2.');
}
}
// 2. Wire the new SSIDs into the L2 topology
const bridges = await this.getBridges();
if (vlanId) {
const mainBridge = bridges[0]?.['name'];
if (!mainBridge) throw new Error('No bridge found on this device to attach the guest VLAN to');
for (const iface of ssidInterfaces) {
await this.setInterfaceBridge(iface, mainBridge, vlanId);
await this.ensureVlanMembership(iface, vlanId);
}
// The bridge itself must be a tagged member so the L3 VLAN interface works
await this.ensureBridgeTaggedMember(mainBridge, vlanId);
// L3 VLAN interface the portal binds to
vlanInterface = `${name}-vlan${vlanId}`;
const vlans = await this.client.execute('/interface/vlan/print').catch(() => [] as Record<string, string>[]);
const existing = vlans.find(v => v['interface'] === mainBridge && v['vlan-id'] === String(vlanId));
if (existing) {
vlanInterface = existing['name'];
} else {
await this.client.execute('/interface/vlan/add', {
name: vlanInterface, 'vlan-id': String(vlanId), interface: mainBridge,
});
}
targetInterface = vlanInterface;
if (bridges[0]?.['vlan-filtering'] !== 'true') {
warnings.push(`Bridge "${mainBridge}" has VLAN filtering disabled — guest VLAN ${vlanId} tagging won't isolate traffic until you enable it (Ports → bridge → VLAN filtering). Enabling it can interrupt management access, so review trunk/tagged config first.`);
}
} else {
// Dedicated guest bridge keeps guests off the main LAN
const guestBridge = `${name}-br`;
if (!bridges.some(b => b['name'] === guestBridge)) {
await this.client.execute('/interface/bridge/add', { name: guestBridge });
}
for (const iface of ssidInterfaces) {
await this.setInterfaceBridge(iface, guestBridge);
}
targetInterface = guestBridge;
}
}
if (!targetInterface) throw new Error('No target interface — provide interfaceName or ssid');
// 3. Captive-portal chain on the target interface
const chain = await this.setupHotspot({
name, interfaceName: targetInterface,
gatewayCidr: opts.gatewayCidr, poolRange: opts.poolRange,
dnsName: opts.dnsName, rateLimit: opts.rateLimit,
});
// 4. Masquerade so guests can reach the internet
if (masquerade) {
const gwIp = opts.gatewayCidr.split('/')[0];
const prefix = opts.gatewayCidr.split('/')[1] || '24';
const network = `${this.networkAddressOf(gwIp, parseInt(prefix, 10))}/${prefix}`;
const natComment = `${name}-guest-masquerade`;
const natRules = await this.client.execute('/ip/firewall/nat/print').catch(() => [] as Record<string, string>[]);
if (!natRules.some(r => (r['comment'] || '') === natComment)) {
await this.client.execute('/ip/firewall/nat/add', {
chain: 'srcnat', action: 'masquerade', 'src-address': network, comment: natComment,
});
}
}
return { ...chain, targetInterface, ssidInterfaces, vlanInterface, warnings };
}
/** Ensure the bridge itself is a tagged member of a VLAN (needed for L3 VLAN interfaces). */
private async ensureBridgeTaggedMember(bridge: string, vlanId: number): Promise<void> {
const rows = await this.client
.execute('/interface/bridge/vlan/print', {}, [`?bridge=${bridge}`, `?vlan-ids=${vlanId}`])
.catch(() => [] as Record<string, string>[]);
const entry = rows[0];
if (entry?.['.id']) {
const tagged = (entry['tagged'] || '').split(',').map(s => s.trim()).filter(Boolean);
if (!tagged.includes(bridge)) {
await this.client.execute('/interface/bridge/vlan/set', {
'.id': entry['.id'], tagged: [...tagged, bridge].join(','),
});
}
} else {
await this.client.execute('/interface/bridge/vlan/add', {
bridge, 'vlan-ids': String(vlanId), tagged: bridge,
});
}
}
/**
* Orchestrated guest-hotspot setup — the API equivalent of RouterOS's
* interactive `/ip hotspot setup` wizard. Creates (idempotently, keyed on the
* guest network name): IP pool → gateway address on the interface → DHCP
* server + network → hotspot profile → hotspot server → guest user profile.
* Returns the names it created so the route can report/rollback.
*/
async setupHotspot(opts: {
name: string; // logical name, e.g. "guest" → pool/profile/server names derive from it
interfaceName: string; // bridge/vlan/wifi interface to run the portal on
gatewayCidr: string; // e.g. "10.5.50.1/24" — address added to the interface
poolRange: string; // e.g. "10.5.50.10-10.5.50.254"
dnsName?: string; // portal hostname, e.g. "wifi.guest"
rateLimit?: string; // default guest speed, e.g. "10M/10M" (rx/tx)
}): Promise<{ server: string; profile: string; userProfile: string; pool: string }> {
const { name, interfaceName, gatewayCidr, poolRange, dnsName, rateLimit } = opts;
const poolName = `${name}-pool`;
const profileName = `${name}-profile`;
const userProfileName = `${name}-guest`;
const gwIp = gatewayCidr.split('/')[0];
// 1. IP pool
const pools = await this.client.execute('/ip/pool/print').catch(() => []);
if (!pools.some(p => p['name'] === poolName)) {
await this.client.execute('/ip/pool/add', { name: poolName, ranges: poolRange });
}
// 2. Gateway address on the interface
const addrs = await this.client.execute('/ip/address/print').catch(() => []);
if (!addrs.some(a => (a['address'] || '') === gatewayCidr && (a['interface'] || '') === interfaceName)) {
await this.client.execute('/ip/address/add', { address: gatewayCidr, interface: interfaceName });
}
// 3. DHCP server + network
const dhcpServers = await this.client.execute('/ip/dhcp-server/print').catch(() => []);
if (!dhcpServers.some(s => s['name'] === `${name}-dhcp`)) {
await this.client.execute('/ip/dhcp-server/add', {
name: `${name}-dhcp`, interface: interfaceName, 'address-pool': poolName, disabled: 'no',
});
}
const prefix = gatewayCidr.split('/')[1] || '24';
const network = this.networkAddressOf(gwIp, parseInt(prefix, 10));
const dhcpNets = await this.client.execute('/ip/dhcp-server/network/print').catch(() => []);
if (!dhcpNets.some(n => (n['address'] || '') === `${network}/${prefix}`)) {
await this.client.execute('/ip/dhcp-server/network/add', {
address: `${network}/${prefix}`, gateway: gwIp, 'dns-server': gwIp,
});
}
// 4. Hotspot profile
const profiles = await this.getHotspotProfiles();
if (!profiles.some(p => p['name'] === profileName)) {
const params: Record<string, string> = {
name: profileName, 'hotspot-address': gwIp, 'login-by': 'http-chap,http-pap',
};
if (dnsName) params['dns-name'] = dnsName;
await this.addHotspotProfile(params);
}
// 5. Hotspot server
const servers = await this.getHotspotServers();
if (!servers.some(s => s['name'] === name)) {
await this.client.execute('/ip/hotspot/add', {
name, interface: interfaceName, 'address-pool': poolName, profile: profileName, disabled: 'no',
});
}
// 6. Guest user profile (bandwidth cap + no inter-guest traffic)
const userProfiles = await this.getHotspotUserProfiles();
if (!userProfiles.some(p => p['name'] === userProfileName)) {
const params: Record<string, string> = {
name: userProfileName, 'shared-users': '1',
};
if (rateLimit) params['rate-limit'] = rateLimit;
await this.addHotspotUserProfile(params);
}
return { server: name, profile: profileName, userProfile: userProfileName, pool: poolName };
}
/** Compute the network address for an IPv4 + prefix (e.g. 10.5.50.1/24 → 10.5.50.0). */
private networkAddressOf(ip: string, prefix: number): string {
const parts = ip.split('.').map(n => parseInt(n, 10));
if (parts.length !== 4 || parts.some(isNaN)) return ip;
const addr = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
const net = (addr & mask) >>> 0;
return [net >>> 24, (net >>> 16) & 255, (net >>> 8) & 255, net & 255].join('.');
}
// ─── IP Services (for security-posture audit + hardening) ─────────────────── // ─── IP Services (for security-posture audit + hardening) ───────────────────
async getServices(): Promise<Record<string, string>[]> { async getServices(): Promise<Record<string, string>[]> {
@@ -1,4 +1,5 @@
import * as net from 'net'; import * as net from 'net';
import * as tls from 'tls';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
@@ -34,15 +35,21 @@ export class RouterOSClient extends EventEmitter {
private tagCounter = 0; private tagCounter = 0;
private readonly useTls: boolean;
constructor( constructor(
private readonly host: string, private readonly host: string,
private readonly port: number = 8728, private readonly port: number = 8728,
private readonly username: string, private readonly username: string,
private readonly password: string, private readonly password: string,
private readonly connectTimeoutMs: number = 15000, private readonly connectTimeoutMs: number = 15000,
private readonly readTimeoutMs: number = 30000 private readonly readTimeoutMs: number = 30000,
useTls?: boolean
) { ) {
super(); super();
// api-ssl runs on 8729 by RouterOS convention (the same heuristic the
// security-posture code uses); callers can override explicitly.
this.useTls = useTls ?? port === 8729;
} }
async connect(): Promise<void> { async connect(): Promise<void> {
@@ -54,9 +61,7 @@ export class RouterOSClient extends EventEmitter {
this.socket?.destroy(); this.socket?.destroy();
}, this.connectTimeoutMs); }, this.connectTimeoutMs);
this.socket = net.createConnection({ host: this.host, port: this.port }); const onReady = async () => {
this.socket.on('connect', async () => {
clearTimeout(timer); clearTimeout(timer);
try { try {
await this.authenticate(); await this.authenticate();
@@ -67,7 +72,21 @@ export class RouterOSClient extends EventEmitter {
this.socket?.destroy(); this.socket?.destroy();
reject(err); reject(err);
} }
}); };
if (this.useTls) {
// api-ssl: RouterOS devices ship self-signed certificates by default,
// so certificate verification is intentionally disabled — the same
// trust model as the plaintext API this replaces, but with the
// credentials and session encrypted in transit.
this.socket = tls.connect(
{ host: this.host, port: this.port, rejectUnauthorized: false },
() => { void onReady(); }
);
} else {
this.socket = net.createConnection({ host: this.host, port: this.port });
this.socket.on('connect', () => { void onReady(); });
}
this.socket.on('data', (data: Buffer) => { this.socket.on('data', (data: Buffer) => {
this.buffer = Buffer.concat([this.buffer, data]); this.buffer = Buffer.concat([this.buffer, data]);
@@ -0,0 +1,37 @@
import { fingerprintClient } from '../clientFingerprint';
describe('fingerprintClient', () => {
it('classifies by vendor OUI', () => {
expect(fingerprintClient({ vendor: 'Ubiquiti Inc' })).toBe('network');
expect(fingerprintClient({ vendor: 'Routerboard.com' })).toBe('network');
expect(fingerprintClient({ vendor: 'Proxmox Server Solutions GmbH' })).toBe('server');
expect(fingerprintClient({ vendor: 'Espressif Inc.' })).toBe('iot');
expect(fingerprintClient({ vendor: 'Walter Kidde Portable Equipment, Inc.' })).toBe('smart-home');
expect(fingerprintClient({ vendor: 'Wyze Labs Inc' })).toBe('camera');
expect(fingerprintClient({ vendor: 'Reolink Innovation Limited' })).toBe('camera');
expect(fingerprintClient({ vendor: 'Amazon Technologies Inc.' })).toBe('voice-assistant');
expect(fingerprintClient({ vendor: 'Nanoleaf' })).toBe('smart-home');
expect(fingerprintClient({ vendor: 'Super Micro Computer, Inc.' })).toBe('server');
expect(fingerprintClient({ vendor: 'Dell Inc.' })).toBe('computer');
});
it('hostname beats vendor (device identity > NIC maker)', () => {
// LG Innotek NIC inside a webOS TV
expect(fingerprintClient({ vendor: 'LG Innotek', hostname: 'LGwebOSTV.tracelength.home' })).toBe('tv');
// Onkyo receiver with a LinkSprite NIC-alike hostname pattern
expect(fingerprintClient({ vendor: 'Onkyo Technology K.K.', hostname: 'Onkyo-TX-NR575.home' })).toBe('media');
// Apple defaults to phone, but a MacBook hostname reclassifies
expect(fingerprintClient({ vendor: 'Apple, Inc.', hostname: 'ricks-macbook-pro' })).toBe('computer');
expect(fingerprintClient({ vendor: 'Apple, Inc.', hostname: 'Ricks-iPhone' })).toBe('phone');
});
it('uses the custom name when hostname is missing', () => {
expect(fingerprintClient({ vendor: 'IEEE Registration Authority', custom_name: 'Garage Camera 2' })).toBe('camera');
});
it('stays unknown rather than guessing', () => {
expect(fingerprintClient({ vendor: 'Some Unheard Of Co' })).toBe('unknown');
expect(fingerprintClient({})).toBe('unknown');
expect(fingerprintClient({ vendor: null, hostname: null })).toBe('unknown');
});
});
@@ -0,0 +1,54 @@
import { classifyScans, ScanRecord } from '../rogueAp';
const scans: ScanRecord[] = [{
deviceName: 'wAP ax',
scannedAt: '2026-07-01T12:00:00Z',
networks: [
{ ssid: 'TestNet', entries: [
{ bssid: 'D0:EA:11:0A:DE:78', signal: -30, band: '2.4 GHz' }, // our own radio
{ bssid: 'AA:BB:CC:DD:EE:01', signal: -55, band: '2.4 GHz', vendor: 'EvilCo' }, // evil twin!
]},
{ ssid: 'NeighborNet', security: 'WPA2', entries: [
{ bssid: 'AA:BB:CC:DD:EE:02', signal: -70, band: '5 GHz', vendor: 'Netgear' },
]},
{ ssid: '', hidden: true, entries: [
{ bssid: 'AA:BB:CC:DD:EE:03', signal: -80, band: '5 GHz' },
]},
],
}, {
deviceName: '2GT-NW-100G',
scannedAt: '2026-07-01T12:05:00Z',
networks: [
// Same evil twin seen stronger from another AP — should dedup, keep strongest
{ ssid: 'TestNet', entries: [{ bssid: 'AA:BB:CC:DD:EE:01', signal: -48, band: '2.4 GHz', vendor: 'EvilCo' }] },
],
}];
const ownSsids = new Set(['TestNet', 'IoT']);
const ownBssids = new Set(['d0:ea:11:0a:de:78', 'd0:ea:11:0a:de:79']);
describe('classifyScans', () => {
const { rogues, neighbors } = classifyScans(scans, ownSsids, ownBssids);
it('flags a foreign BSSID broadcasting our SSID as rogue (evil twin)', () => {
expect(rogues).toHaveLength(1);
expect(rogues[0].bssid).toBe('aa:bb:cc:dd:ee:01');
expect(rogues[0].ssid).toBe('TestNet');
});
it('dedups across scanning devices, keeping the strongest sighting', () => {
expect(rogues[0].signal).toBe(-48);
expect(rogues[0].seenBy).toBe('2GT-NW-100G');
});
it('ignores our own radios entirely', () => {
const all = [...rogues, ...neighbors].map(x => x.bssid);
expect(all).not.toContain('d0:ea:11:0a:de:78');
});
it('classifies foreign SSIDs as neighbors, hidden ones labelled', () => {
expect(neighbors).toHaveLength(2);
expect(neighbors[0].ssid).toBe('NeighborNet'); // stronger first
expect(neighbors[1].ssid).toBe('(hidden)');
});
});
+83
View File
@@ -0,0 +1,83 @@
// Client device fingerprinting — classifies a client into a device category
// from its OUI vendor string, hostname, and connection type. Rule-based and
// deliberately conservative: hostname patterns (most specific) win over vendor
// keywords; unknown stays unknown rather than guessing.
export const DEVICE_CATEGORIES = [
'network', // routers, switches, APs
'server', // hypervisors, NAS, home-automation hubs
'computer', // laptops/desktops
'phone', // phones & tablets
'tv', // TVs, streaming boxes
'media', // AV receivers, speakers
'camera', // IP cameras, doorbells
'printer',
'game-console',
'voice-assistant',
'smart-home', // thermostats, lights, plugs, sleep tech, safety sensors
'iot', // generic embedded (ESP32 etc.)
'unknown',
] as const;
export type DeviceCategory = typeof DEVICE_CATEGORIES[number];
interface Rule { pattern: RegExp; category: DeviceCategory }
// Hostname rules — checked first (a hostname names the actual device, while a
// NIC vendor names whoever made the radio).
const HOSTNAME_RULES: Rule[] = [
{ pattern: /iphone|ipad|android|pixel|galaxy|oneplus|redmi/i, category: 'phone' },
{ pattern: /macbook|imac|mac-?mini|thinkpad|laptop|desktop|-pc\b|win(dows|1[01])|surface/i, category: 'computer' },
{ pattern: /appletv|apple-tv|chromecast|roku|firetv|fire-tv|shield|webos|bravia|-tv\b|tv\./i, category: 'tv' },
{ pattern: /\bcam(era)?s?\b|cam[-._\d]|doorbell|reolink|wyze|ring-|arlo/i, category: 'camera' },
{ pattern: /printer|officejet|laserjet|deskjet|epson|brother[-_]/i, category: 'printer' },
{ pattern: /ps[45]|playstation|xbox|nintendo/i, category: 'game-console' },
{ pattern: /echo[-_]|alexa|homepod|google-?home|nest-?mini|nest-?hub/i, category: 'voice-assistant' },
{ pattern: /sonos|denon|onkyo|yamaha|receiver|soundbar|onelink|speaker/i, category: 'media' },
{ pattern: /thermostat|hue|lifx|nanoleaf|plug|switchbot|tasmota|shelly|sensor/i, category: 'smart-home' },
{ pattern: /esp[-_]?\d|esp32|esp8266/i, category: 'iot' },
{ pattern: /proxmox|truenas|synology|unraid|nas\b|server|homeassistant|home-assistant|hassio/i, category: 'server' },
{ pattern: /router|switch|gateway|firewall|-ap\b|accesspoint|unifi|mikrotik/i, category: 'network' },
];
// Vendor (OUI) rules — fallback when the hostname doesn't identify the device.
const VENDOR_RULES: Rule[] = [
{ pattern: /ubiquiti|routerboard|mikrotik|cisco|netgear|aruba|juniper|zyxel|d-link|fortinet|ruckus|extreme networks/i, category: 'network' },
{ pattern: /proxmox|vmware|super micro|supermicro|qnap|synology|nutanix|nabu casa/i, category: 'server' },
{ pattern: /reolink|wyze|hikvision|dahua|axis communications|ring llc|arlo|eufy|ubnt.*cam/i, category: 'camera' },
{ pattern: /espressif|tuya|sonoff|itead|shelly|allterco|particle|nordic semi/i, category: 'iot' },
{ pattern: /walter kidde|first alert|nest labs|ecobee|honeywell|nanoleaf|signify|philips lighting|lifx|eight sleep|lutron|leviton|wemo|belkin/i, category: 'smart-home' },
{ pattern: /lg innotek|lg electronics|vizio|tcl|hisense|roku|sceptre/i, category: 'tv' },
{ pattern: /onkyo|sonos|denon|yamaha|bose|harman|bang & olufsen|linksprite/i, category: 'media' },
{ pattern: /sony interactive|nintendo|microsoft.*xbox/i, category: 'game-console' },
{ pattern: /hewlett[- ]packard|\bhp inc|canon|epson|brother industries|lexmark|xerox/i, category: 'printer' },
{ pattern: /raspberry pi/i, category: 'server' },
{ pattern: /intel corporate|asustek|micro-star|gigabyte|dell inc|lenovo|framework|hon hai|azurewave|realtek|liteon|clevo/i, category: 'computer' },
{ pattern: /samsung electro|xiaomi|huawei|oneplus|oppo|vivo mobile|motorola mobility/i, category: 'phone' },
{ pattern: /^apple/i, category: 'phone' }, // most Apple clients on a LAN are iPhones/iPads; hostname rules catch Macs
{ pattern: /amazon technologies/i, category: 'voice-assistant' }, // echoes & fire devices dominate Amazon OUIs
{ pattern: /google/i, category: 'smart-home' }, // nest/chromecast; hostname rules catch specific types
{ pattern: /texas instruments/i, category: 'iot' },
];
export function fingerprintClient(input: {
vendor?: string | null;
hostname?: string | null;
custom_name?: string | null;
}): DeviceCategory {
// Hostname first (also consider the user-assigned name — users name things truthfully)
for (const source of [input.hostname, input.custom_name]) {
const h = (source || '').trim();
if (!h) continue;
for (const rule of HOSTNAME_RULES) {
if (rule.pattern.test(h)) return rule.category;
}
}
const v = (input.vendor || '').trim();
if (v) {
for (const rule of VENDOR_RULES) {
if (rule.pattern.test(v)) return rule.category;
}
}
return 'unknown';
}
+71
View File
@@ -0,0 +1,71 @@
// Rogue / neighbor AP classification over stored AP-scan results.
//
// A scanned BSSID falls into one of three buckets:
// - ours: the BSSID belongs to a managed radio → ignored
// - ROGUE: broadcasts one of OUR SSIDs from a BSSID we don't own — an evil
// twin / SSID spoof, the classic WiFi attack Mist/UniFi alert on
// - neighbor: any other foreign AP — inventory, ranked by signal
export interface ScannedEntry {
bssid: string; vendor?: string; signal: number; freq?: number;
band?: string; channel_width?: string;
}
export interface ScannedNetwork {
ssid: string; security?: string; hidden?: boolean; entries: ScannedEntry[];
}
export interface ScanRecord {
deviceName: string;
scannedAt: string;
networks: ScannedNetwork[];
}
export interface RogueAp {
ssid: string; bssid: string; vendor: string; signal: number; band: string;
seenBy: string; scannedAt: string;
}
export interface NeighborAp {
ssid: string; bssid: string; vendor: string; signal: number; band: string;
security: string; seenBy: string; scannedAt: string;
}
export function classifyScans(
scans: ScanRecord[],
ownSsids: Set<string>, // SSIDs our managed radios broadcast
ownBssids: Set<string>, // every MAC we own (radio + interface MACs, lowercase)
): { rogues: RogueAp[]; neighbors: NeighborAp[] } {
const rogueByBssid = new Map<string, RogueAp>();
const neighborByBssid = new Map<string, NeighborAp>();
for (const scan of scans) {
for (const net of scan.networks) {
for (const e of net.entries) {
const bssid = (e.bssid || '').toLowerCase();
if (!bssid || ownBssids.has(bssid)) continue; // our own radios
if (net.ssid && ownSsids.has(net.ssid)) {
const prev = rogueByBssid.get(bssid);
if (!prev || e.signal > prev.signal) {
rogueByBssid.set(bssid, {
ssid: net.ssid, bssid, vendor: e.vendor || '', signal: e.signal,
band: e.band || '', seenBy: scan.deviceName, scannedAt: scan.scannedAt,
});
}
} else {
const prev = neighborByBssid.get(bssid);
if (!prev || e.signal > prev.signal) {
neighborByBssid.set(bssid, {
ssid: net.ssid || '(hidden)', bssid, vendor: e.vendor || '', signal: e.signal,
band: e.band || '', security: net.security || '',
seenBy: scan.deviceName, scannedAt: scan.scannedAt,
});
}
}
}
}
}
return {
rogues: [...rogueByBssid.values()].sort((a, b) => b.signal - a.signal),
neighbors: [...neighborByBssid.values()].sort((a, b) => b.signal - a.signal),
};
}
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mikrotik-manager-frontend", "name": "mikrotik-manager-frontend",
"version": "0.16.2-beta", "version": "0.16.3-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mikrotik-manager-frontend", "name": "mikrotik-manager-frontend",
"version": "0.16.2-beta", "version": "0.16.3-beta",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.101.0", "@tanstack/react-query": "^5.101.0",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mikrotik-manager-frontend", "name": "mikrotik-manager-frontend",
"version": "0.16.2-beta", "version": "0.16.3-beta",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+4
View File
@@ -9,6 +9,7 @@ import ClientsPage from './pages/ClientsPage';
import EventsPage from './pages/EventsPage'; import EventsPage from './pages/EventsPage';
import TopologyPage from './pages/TopologyPage'; import TopologyPage from './pages/TopologyPage';
import BackupsPage from './pages/BackupsPage'; import BackupsPage from './pages/BackupsPage';
import FirmwarePage from './pages/FirmwarePage';
import SettingsPage from './pages/SettingsPage'; import SettingsPage from './pages/SettingsPage';
import SwitchesOverviewPage from './pages/SwitchesOverviewPage'; import SwitchesOverviewPage from './pages/SwitchesOverviewPage';
import SwitchesSettingsPage from './pages/SwitchesSettingsPage'; import SwitchesSettingsPage from './pages/SwitchesSettingsPage';
@@ -17,6 +18,7 @@ import RouterSettingsPage from './pages/RouterSettingsPage';
import WirelessPage from './pages/WirelessPage'; import WirelessPage from './pages/WirelessPage';
import WirelessSettingsPage from './pages/WirelessSettingsPage'; import WirelessSettingsPage from './pages/WirelessSettingsPage';
import WirelessClientsPage from './pages/WirelessClientsPage'; import WirelessClientsPage from './pages/WirelessClientsPage';
import GuestWifiPage from './pages/GuestWifiPage';
import ClientDetailPage from './pages/ClientDetailPage'; import ClientDetailPage from './pages/ClientDetailPage';
import NetworkServicesOverviewPage from './pages/NetworkServicesOverviewPage'; import NetworkServicesOverviewPage from './pages/NetworkServicesOverviewPage';
import NetworkServicesDHCPPage from './pages/NetworkServicesDHCPPage'; import NetworkServicesDHCPPage from './pages/NetworkServicesDHCPPage';
@@ -56,6 +58,7 @@ export default function App() {
<Route path="events" element={<EventsPage />} /> <Route path="events" element={<EventsPage />} />
<Route path="topology" element={<TopologyPage />} /> <Route path="topology" element={<TopologyPage />} />
<Route path="backups" element={<BackupsPage />} /> <Route path="backups" element={<BackupsPage />} />
<Route path="firmware" element={<FirmwarePage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="switches" element={<SwitchesOverviewPage />} /> <Route path="switches" element={<SwitchesOverviewPage />} />
<Route path="switches/settings" element={<SwitchesSettingsPage />} /> <Route path="switches/settings" element={<SwitchesSettingsPage />} />
@@ -64,6 +67,7 @@ export default function App() {
<Route path="wireless" element={<WirelessPage />} /> <Route path="wireless" element={<WirelessPage />} />
<Route path="wireless/settings" element={<WirelessSettingsPage />} /> <Route path="wireless/settings" element={<WirelessSettingsPage />} />
<Route path="wireless/clients" element={<WirelessClientsPage />} /> <Route path="wireless/clients" element={<WirelessClientsPage />} />
<Route path="wireless/guest" element={<GuestWifiPage />} />
<Route path="network-services" element={<NetworkServicesOverviewPage />} /> <Route path="network-services" element={<NetworkServicesOverviewPage />} />
<Route path="network-services/dhcp" element={<NetworkServicesDHCPPage />} /> <Route path="network-services/dhcp" element={<NetworkServicesDHCPPage />} />
<Route path="network-services/dns" element={<NetworkServicesDNSPage />} /> <Route path="network-services/dns" element={<NetworkServicesDNSPage />} />
+3 -1
View File
@@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query';
import { import {
LayoutDashboard, Router, Users, Bell, GitBranch, HardDrive, LayoutDashboard, Router, Users, Bell, GitBranch, HardDrive,
Settings, Network, ChevronLeft, ChevronRight, Layers, ChevronDown, SlidersHorizontal, X, Wifi, Settings, Network, ChevronLeft, ChevronRight, Layers, ChevronDown, SlidersHorizontal, X, Wifi,
Server, Globe, Clock, Shield, FileText, Activity, BarChart3, Server, Globe, Clock, Shield, FileText, Activity, BarChart3, Ticket, ArrowUpCircle,
} from 'lucide-react'; } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import { APP_VERSION } from '../../version'; import { APP_VERSION } from '../../version';
@@ -19,6 +19,7 @@ const navItems = [
{ to: '/events', icon: Bell, label: 'Events' }, { to: '/events', icon: Bell, label: 'Events' },
{ to: '/topology', icon: GitBranch, label: 'Topology' }, { to: '/topology', icon: GitBranch, label: 'Topology' },
{ to: '/backups', icon: HardDrive, label: 'Backups' }, { to: '/backups', icon: HardDrive, label: 'Backups' },
{ to: '/firmware', icon: ArrowUpCircle, label: 'Firmware' },
]; ];
const switchSubItems = [ const switchSubItems = [
@@ -34,6 +35,7 @@ const routerSubItems = [
const wirelessSubItems = [ const wirelessSubItems = [
{ to: '/wireless', icon: Wifi, label: 'Overview' }, { to: '/wireless', icon: Wifi, label: 'Overview' },
{ to: '/wireless/clients', icon: Users, label: 'Clients' }, { to: '/wireless/clients', icon: Users, label: 'Clients' },
{ to: '/wireless/guest', icon: Ticket, label: 'Guest WiFi' },
{ to: '/wireless/settings', icon: SlidersHorizontal, label: 'Settings' }, { to: '/wireless/settings', icon: SlidersHorizontal, label: 'Settings' },
]; ];
@@ -0,0 +1,267 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
KeyRound, Webhook as WebhookIcon, Mail, Plus, Trash2, Copy, Check, Send,
RefreshCw, AlertTriangle, Power, X,
} from 'lucide-react';
import clsx from 'clsx';
import { automationApi } from '../../services/api';
import { formatDistanceToNow } from 'date-fns';
const EVENT_LABEL: Record<string, string> = {
device_offline: 'Device offline', device_online: 'Device online',
log_error: 'Log error', log_warning: 'Log warning',
high_cpu: 'High CPU', high_memory: 'High memory', cert_expiry: 'Cert expiry',
device_discovered: 'Device discovered', firmware_update_available: 'Firmware update',
config_drift: 'Config drift', rollout_completed: 'Rollout completed', rollout_failed: 'Rollout failed',
};
// ─── API tokens ────────────────────────────────────────────────────────────────
function TokensSection({ isAdmin }: { isAdmin: boolean }) {
const qc = useQueryClient();
const [name, setName] = useState('');
const [scope, setScope] = useState<'read' | 'write'>('read');
const [expires, setExpires] = useState('');
const [newToken, setNewToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const { data: tokens = [] } = useQuery({
queryKey: ['api-tokens'],
queryFn: () => automationApi.listTokens().then(r => r.data),
enabled: isAdmin,
});
const create = useMutation({
mutationFn: () => automationApi.createToken({ name: name.trim(), scope, expires_days: expires ? parseInt(expires, 10) : undefined }),
onSuccess: (r) => { setNewToken(r.data.token); setName(''); setExpires(''); qc.invalidateQueries({ queryKey: ['api-tokens'] }); },
});
const del = useMutation({ mutationFn: (id: number) => automationApi.deleteToken(id), onSuccess: () => qc.invalidateQueries({ queryKey: ['api-tokens'] }) });
if (!isAdmin) {
return <div className="card p-6 text-sm text-gray-400 dark:text-slate-500">API tokens can only be managed by administrators.</div>;
}
return (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<KeyRound className="w-4 h-4 text-blue-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">API Tokens</h3>
<span className="text-xs text-gray-400 dark:text-slate-500">for scripting &amp; IaC — send as <span className="font-mono">Authorization: Bearer mtm_…</span></span>
</div>
<div className="p-5 space-y-4">
{/* Newly created token (shown once) */}
{newToken && (
<div className="rounded-lg border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 p-3">
<div className="flex items-center gap-2 mb-2 text-sm font-medium text-green-700 dark:text-green-400">
<Check className="w-4 h-4" />Token created — copy it now, it won&apos;t be shown again
</div>
<div className="flex items-center gap-2">
<code className="flex-1 px-2 py-1.5 rounded bg-white dark:bg-slate-800 font-mono text-xs break-all text-gray-800 dark:text-slate-200">{newToken}</code>
<button className="btn-secondary flex items-center gap-1.5 text-xs" onClick={() => { navigator.clipboard.writeText(newToken); setCopied(true); setTimeout(() => setCopied(false), 2000); }}>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}{copied ? 'Copied' : 'Copy'}
</button>
<button className="p-1.5 text-gray-400 hover:text-gray-600" onClick={() => setNewToken(null)}><X className="w-4 h-4" /></button>
</div>
</div>
)}
{/* Create form */}
<div className="flex flex-wrap items-end gap-2">
<div><label className="label">Name</label><input className="input w-48" value={name} onChange={e => setName(e.target.value)} placeholder="ci-pipeline" /></div>
<div><label className="label">Scope</label>
<select className="input w-32" value={scope} onChange={e => setScope(e.target.value as 'read' | 'write')}>
<option value="read">Read only</option>
<option value="write">Read + write</option>
</select>
</div>
<div><label className="label">Expires (days)</label><input type="number" min={1} className="input w-28" value={expires} onChange={e => setExpires(e.target.value)} placeholder="never" /></div>
<button className="btn-primary flex items-center gap-1.5" disabled={!name.trim() || create.isPending} onClick={() => create.mutate()}>
<Plus className="w-4 h-4" />Create
</button>
</div>
{/* List */}
{tokens.length > 0 && (
<div className="divide-y divide-gray-100 dark:divide-slate-700/50 -mx-1">
{tokens.map(t => (
<div key={t.id} className="flex items-center gap-3 py-2.5 px-1">
<code className="font-mono text-xs text-gray-500 dark:text-slate-400">{t.prefix}…</code>
<span className="text-sm font-medium text-gray-900 dark:text-white">{t.name}</span>
<span className={clsx('px-1.5 py-0.5 rounded text-[10px] font-bold uppercase', t.scope === 'write' ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' : 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400')}>{t.scope}</span>
<span className="text-xs text-gray-400 dark:text-slate-500">
{t.last_used_at ? `used ${formatDistanceToNow(new Date(t.last_used_at))} ago` : 'never used'}
{t.expires_at ? ` · expires ${formatDistanceToNow(new Date(t.expires_at), { addSuffix: true })}` : ''}
</span>
<button className="ml-auto p-1.5 text-gray-400 hover:text-red-600" onClick={() => del.mutate(t.id)} title="Revoke"><Trash2 className="w-3.5 h-3.5" /></button>
</div>
))}
</div>
)}
<p className="text-xs text-gray-400 dark:text-slate-500">Write tokens map to Operator privileges; no token can perform admin actions or manage other tokens.</p>
</div>
</div>
);
}
// ─── Webhooks ──────────────────────────────────────────────────────────────────
function WebhooksSection() {
const qc = useQueryClient();
const [form, setForm] = useState({ name: '', url: '', secret: '', events: new Set<string>() });
const [testResult, setTestResult] = useState<Record<number, string>>({});
const { data } = useQuery({ queryKey: ['webhooks'], queryFn: () => automationApi.listWebhooks().then(r => r.data) });
const webhooks = data?.webhooks ?? [];
const events = data?.availableEvents ?? [];
const create = useMutation({
mutationFn: () => automationApi.createWebhook({ name: form.name.trim(), url: form.url.trim(), secret: form.secret.trim() || undefined, events: [...form.events] }),
onSuccess: () => { setForm({ name: '', url: '', secret: '', events: new Set() }); qc.invalidateQueries({ queryKey: ['webhooks'] }); },
});
const toggle = useMutation({ mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => automationApi.updateWebhook(id, { enabled }), onSuccess: () => qc.invalidateQueries({ queryKey: ['webhooks'] }) });
const del = useMutation({ mutationFn: (id: number) => automationApi.deleteWebhook(id), onSuccess: () => qc.invalidateQueries({ queryKey: ['webhooks'] }) });
const test = useMutation({
mutationFn: (id: number) => automationApi.testWebhook(id),
onSuccess: (r, id) => { setTestResult(p => ({ ...p, [id]: r.data.ok ? `OK (${r.data.status})` : `HTTP ${r.data.status}` })); setTimeout(() => setTestResult(p => { const n = { ...p }; delete n[id]; return n; }), 5000); qc.invalidateQueries({ queryKey: ['webhooks'] }); },
onError: (e: unknown, id) => setTestResult(p => ({ ...p, [id]: (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'failed' })),
});
const toggleEvent = (e: string) => setForm(f => {
const s = new Set(f.events);
if (s.has(e)) s.delete(e); else s.add(e);
return { ...f, events: s };
});
return (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<WebhookIcon className="w-4 h-4 text-violet-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Outbound Webhooks</h3>
<span className="text-xs text-gray-400 dark:text-slate-500">POST JSON on events · HMAC-signed with <span className="font-mono">X-MTM-Signature</span></span>
</div>
<div className="p-5 space-y-4">
{/* existing */}
{webhooks.map(h => (
<div key={h.id} className="rounded-lg border border-gray-200 dark:border-slate-700 p-3">
<div className="flex items-center gap-2 mb-1.5">
<span className={clsx('w-2 h-2 rounded-full flex-shrink-0', h.enabled ? 'bg-green-500' : 'bg-gray-400')} />
<span className="text-sm font-medium text-gray-900 dark:text-white">{h.name}</span>
{h.has_secret && <span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">signed</span>}
{h.last_status != null && <span className={clsx('text-[10px] px-1.5 py-0.5 rounded', h.last_status >= 200 && h.last_status < 300 ? 'bg-green-50 dark:bg-green-900/30 text-green-600 dark:text-green-400' : 'bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400')}>last: {h.last_status || 'err'}</span>}
<div className="ml-auto flex items-center gap-1">
{testResult[h.id] && <span className="text-xs text-gray-500 dark:text-slate-400 mr-1">{testResult[h.id]}</span>}
<button className="p-1.5 text-gray-400 hover:text-blue-600" title="Send test" onClick={() => test.mutate(h.id)}><Send className="w-3.5 h-3.5" /></button>
<button className="p-1.5 text-gray-400 hover:text-amber-600" title={h.enabled ? 'Disable' : 'Enable'} onClick={() => toggle.mutate({ id: h.id, enabled: !h.enabled })}><Power className="w-3.5 h-3.5" /></button>
<button className="p-1.5 text-gray-400 hover:text-red-600" title="Delete" onClick={() => del.mutate(h.id)}><Trash2 className="w-3.5 h-3.5" /></button>
</div>
</div>
<div className="font-mono text-xs text-gray-500 dark:text-slate-400 truncate">{h.url}</div>
<div className="flex flex-wrap gap-1 mt-1.5">
{h.events.map(e => <span key={e} className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400">{EVENT_LABEL[e] || e}</span>)}
</div>
</div>
))}
{/* create */}
<div className="rounded-lg border border-dashed border-gray-300 dark:border-slate-600 p-3 space-y-2.5">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<input className="input" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="Name (e.g. Slack, PagerDuty)" />
<input className="input sm:col-span-2 font-mono text-sm" value={form.url} onChange={e => setForm(f => ({ ...f, url: e.target.value }))} placeholder="https://hooks.example.com/…" />
</div>
<input className="input font-mono text-sm" value={form.secret} onChange={e => setForm(f => ({ ...f, secret: e.target.value }))} placeholder="Signing secret (optional, recommended)" />
<div className="flex flex-wrap gap-1.5">
{events.map(e => (
<button key={e} onClick={() => toggleEvent(e)}
className={clsx('text-xs px-2 py-1 rounded-md border transition-colors',
form.events.has(e) ? 'bg-violet-100 dark:bg-violet-900/30 border-violet-300 dark:border-violet-700 text-violet-700 dark:text-violet-300' : 'border-gray-200 dark:border-slate-600 text-gray-500 dark:text-slate-400 hover:bg-gray-50 dark:hover:bg-slate-700')}>
{EVENT_LABEL[e] || e}
</button>
))}
</div>
<div className="flex justify-end">
<button className="btn-primary flex items-center gap-1.5" disabled={!form.name.trim() || !form.url.trim() || form.events.size === 0 || create.isPending} onClick={() => create.mutate()}>
<Plus className="w-4 h-4" />Add webhook
</button>
</div>
{create.isError && <p className="text-xs text-red-500 flex items-center gap-1"><AlertTriangle className="w-3.5 h-3.5" />{(create.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Failed'}</p>}
</div>
</div>
</div>
);
}
// ─── Scheduled reports ───────────────────────────────────────────────────────────
function ReportsSection() {
const qc = useQueryClient();
const [form, setForm] = useState({ name: '', frequency: 'weekly', recipients: '' });
const [sendMsg, setSendMsg] = useState<Record<number, string>>({});
const { data: reports = [] } = useQuery({ queryKey: ['report-schedules'], queryFn: () => automationApi.listReports().then(r => r.data) });
const create = useMutation({
mutationFn: () => automationApi.createReport(form),
onSuccess: () => { setForm({ name: '', frequency: 'weekly', recipients: '' }); qc.invalidateQueries({ queryKey: ['report-schedules'] }); },
});
const toggle = useMutation({ mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => automationApi.updateReport(id, { enabled }), onSuccess: () => qc.invalidateQueries({ queryKey: ['report-schedules'] }) });
const del = useMutation({ mutationFn: (id: number) => automationApi.deleteReport(id), onSuccess: () => qc.invalidateQueries({ queryKey: ['report-schedules'] }) });
const sendNow = useMutation({
mutationFn: (id: number) => automationApi.sendReportNow(id),
onSuccess: (_r, id) => { setSendMsg(p => ({ ...p, [id]: 'Sent!' })); setTimeout(() => setSendMsg(p => { const n = { ...p }; delete n[id]; return n; }), 4000); qc.invalidateQueries({ queryKey: ['report-schedules'] }); },
onError: (e: unknown, id) => setSendMsg(p => ({ ...p, [id]: (e as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Send failed' })),
});
return (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<Mail className="w-4 h-4 text-emerald-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Scheduled Reports</h3>
<span className="text-xs text-gray-400 dark:text-slate-500">emailed fleet summaries — uses your Alerting SMTP settings</span>
</div>
<div className="p-5 space-y-4">
{reports.map(r => (
<div key={r.id} className="flex items-center gap-3 rounded-lg border border-gray-200 dark:border-slate-700 p-3">
<span className={clsx('w-2 h-2 rounded-full flex-shrink-0', r.enabled ? 'bg-green-500' : 'bg-gray-400')} />
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">{r.name} <span className="text-xs font-normal text-gray-400 capitalize">· {r.frequency}</span></div>
<div className="text-xs text-gray-400 dark:text-slate-500 truncate">{r.recipients}</div>
</div>
<span className="text-xs text-gray-400 dark:text-slate-500 ml-2">
{r.last_sent_at ? `last ${formatDistanceToNow(new Date(r.last_sent_at))} ago` : `next ${formatDistanceToNow(new Date(r.next_run_at), { addSuffix: true })}`}
</span>
<div className="ml-auto flex items-center gap-1">
{sendMsg[r.id] && <span className="text-xs text-gray-500 dark:text-slate-400 mr-1">{sendMsg[r.id]}</span>}
<button className="p-1.5 text-gray-400 hover:text-blue-600" title="Send now" onClick={() => sendNow.mutate(r.id)} disabled={sendNow.isPending}><Send className="w-3.5 h-3.5" /></button>
<button className="p-1.5 text-gray-400 hover:text-amber-600" title={r.enabled ? 'Disable' : 'Enable'} onClick={() => toggle.mutate({ id: r.id, enabled: !r.enabled })}><Power className="w-3.5 h-3.5" /></button>
<button className="p-1.5 text-gray-400 hover:text-red-600" title="Delete" onClick={() => del.mutate(r.id)}><Trash2 className="w-3.5 h-3.5" /></button>
</div>
</div>
))}
<div className="rounded-lg border border-dashed border-gray-300 dark:border-slate-600 p-3 flex flex-wrap items-end gap-2">
<div><label className="label">Name</label><input className="input w-44" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} placeholder="Weekly summary" /></div>
<div><label className="label">Frequency</label>
<select className="input w-32" value={form.frequency} onChange={e => setForm(f => ({ ...f, frequency: e.target.value }))}>
<option value="daily">Daily</option><option value="weekly">Weekly</option><option value="monthly">Monthly</option>
</select>
</div>
<div className="flex-1 min-w-[200px]"><label className="label">Recipients</label><input className="input" value={form.recipients} onChange={e => setForm(f => ({ ...f, recipients: e.target.value }))} placeholder="ops@company.com, noc@company.com" /></div>
<button className="btn-primary flex items-center gap-1.5" disabled={!form.name.trim() || !form.recipients.trim() || create.isPending} onClick={() => create.mutate()}><Plus className="w-4 h-4" />Add</button>
{create.isError && <p className="text-xs text-red-500 w-full flex items-center gap-1"><AlertTriangle className="w-3.5 h-3.5" />{(create.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Failed'}</p>}
</div>
</div>
</div>
);
}
export default function AutomationSettings({ isAdmin }: { isAdmin: boolean }) {
return (
<div className="space-y-4">
<TokensSection isAdmin={isAdmin} />
<WebhooksSection />
<ReportsSection />
</div>
);
}
@@ -0,0 +1,107 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ShieldAlert, Radio, ChevronDown, ChevronRight } from 'lucide-react';
import clsx from 'clsx';
import { wirelessApi } from '../../services/api';
import { formatDistanceToNow } from 'date-fns';
function signalCls(dbm: number): string {
if (dbm >= -55) return 'text-green-600 dark:text-green-400';
if (dbm >= -70) return 'text-yellow-600 dark:text-yellow-400';
return 'text-gray-400 dark:text-slate-500';
}
export default function RogueApsCard() {
const [showNeighbors, setShowNeighbors] = useState(false);
const { data } = useQuery({
queryKey: ['rogue-aps'],
queryFn: () => wirelessApi.getRogueAps().then(r => r.data),
refetchInterval: 5 * 60_000,
});
const rogues = data?.rogues ?? [];
const neighbors = data?.neighbors ?? [];
const hasScans = (data?.scannedDevices ?? 0) > 0;
return (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<ShieldAlert className={clsx('w-4 h-4', rogues.length > 0 ? 'text-red-500' : 'text-emerald-500')} />
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">Rogue &amp; Neighbor APs</h2>
<span className="ml-auto text-[11px] text-gray-400 dark:text-slate-500">
{data?.lastScanAt
? `last scan ${formatDistanceToNow(new Date(data.lastScanAt))} ago · ${data.scannedDevices} AP${data.scannedDevices !== 1 ? 's' : ''}`
: 'no scans yet'}
</span>
</div>
{!hasScans ? (
<div className="p-6 text-center text-sm text-gray-400 dark:text-slate-500">
No AP scan data yet. Run a <strong>Nearby Access Points</strong> scan from a device&apos;s Radios tab
(or enable scheduled scans in Wireless Settings) to detect evil twins and map neighboring networks.
</div>
) : (
<div>
{/* Rogues */}
{rogues.length === 0 ? (
<div className="px-5 py-3 flex items-center gap-2 text-sm text-emerald-600 dark:text-emerald-400">
<span className="w-2 h-2 rounded-full bg-emerald-500" />
No rogue APs — nothing is spoofing your SSIDs.
</div>
) : (
<div className="divide-y divide-red-100 dark:divide-red-900/30">
{rogues.map(r => (
<div key={r.bssid} className="px-5 py-3 bg-red-50/60 dark:bg-red-900/10 flex items-start gap-3">
<ShieldAlert className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-red-700 dark:text-red-400">
Foreign AP broadcasting &quot;{r.ssid}&quot; — possible evil twin
</div>
<div className="text-xs text-gray-500 dark:text-slate-400 mt-0.5 font-mono">
{r.bssid}{r.vendor ? ` (${r.vendor})` : ''} · {r.band} · seen by {r.seenBy}
</div>
</div>
<span className={clsx('font-mono text-xs flex-shrink-0', signalCls(r.signal))}>{r.signal} dBm</span>
</div>
))}
</div>
)}
{/* Neighbors (collapsed) */}
<button onClick={() => setShowNeighbors(v => !v)}
className="w-full px-5 py-2.5 flex items-center gap-2 text-xs text-gray-500 dark:text-slate-400 hover:bg-gray-50 dark:hover:bg-slate-700/40 transition-colors border-t border-gray-100 dark:border-slate-700/60">
{showNeighbors ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
<Radio className="w-3.5 h-3.5" />
{neighbors.length} neighboring network{neighbors.length !== 1 ? 's' : ''} detected
</button>
{showNeighbors && neighbors.length > 0 && (
<div className="overflow-x-auto border-t border-gray-100 dark:border-slate-700/60">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-50 dark:bg-slate-800/40">
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">SSID</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">BSSID / Vendor</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Band</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Security</th>
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Signal</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
{neighbors.slice(0, 25).map((n, i) => (
<tr key={n.bssid} className={clsx(i % 2 === 0 ? 'bg-white dark:bg-slate-900/20' : 'bg-gray-50 dark:bg-slate-800/40')}>
<td className="px-4 py-2 text-gray-800 dark:text-slate-200">{n.ssid}</td>
<td className="px-4 py-2 font-mono text-xs text-gray-500 dark:text-slate-400">{n.bssid}{n.vendor ? ` (${n.vendor})` : ''}</td>
<td className="px-4 py-2 text-xs text-gray-500 dark:text-slate-400">{n.band}</td>
<td className="px-4 py-2 text-xs text-gray-500 dark:text-slate-400">{n.security || '—'}</td>
<td className={clsx('px-4 py-2 text-right font-mono text-xs', signalCls(n.signal))}>{n.signal} dBm</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}
+40
View File
@@ -12,6 +12,7 @@ import {
} from 'recharts'; } from 'recharts';
import { format, formatDistanceToNow, parseISO } from 'date-fns'; import { format, formatDistanceToNow, parseISO } from 'date-fns';
import { clientsApi, trafficApi, devicesApi } from '../services/api'; import { clientsApi, trafficApi, devicesApi } from '../services/api';
import { CATEGORY_META, SELECTABLE_CATEGORIES } from '../utils/clientCategories';
import type { ClientDetail } from '../services/api'; import type { ClientDetail } from '../services/api';
import type { SignalPoint } from '../services/api'; import type { SignalPoint } from '../services/api';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -329,6 +330,15 @@ function ClientDetailsCard({ client, canWrite }: { client: ClientDetail; canWrit
}, },
}); });
const categoryMutation = useMutation({
mutationFn: (category: string | null) => clientsApi.updateCategory(client.mac_address, category),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['client-detail', client.mac_address] });
qc.invalidateQueries({ queryKey: ['clients'] });
qc.invalidateQueries({ queryKey: ['wireless-clients-page'] });
},
});
const handleWol = async () => { const handleWol = async () => {
setWolStatus('sending'); setWolStatus('sending');
try { try {
@@ -431,6 +441,36 @@ function ClientDetailsCard({ client, canWrite }: { client: ClientDetail; canWrit
<DetailRow label="Vendor">{client.vendor}</DetailRow> <DetailRow label="Vendor">{client.vendor}</DetailRow>
)} )}
{/* Device category — auto-fingerprinted, user-overridable */}
<DetailRow label="Device type">
<div className="flex items-center gap-2 min-w-0">
{canWrite ? (
<select
className="input py-1 text-sm w-auto max-w-[220px]"
value={client.custom_category ?? ''}
onChange={(e) => categoryMutation.mutate(e.target.value === '' ? null : e.target.value)}
disabled={categoryMutation.isPending}
>
<option value="">
Automatic — {CATEGORY_META[client.auto_category ?? 'unknown']?.label ?? 'Unknown'}
</option>
{SELECTABLE_CATEGORIES.filter(c => c !== 'unknown').map(c => (
<option key={c} value={c}>{CATEGORY_META[c].label}</option>
))}
</select>
) : (
<span>{CATEGORY_META[client.device_category ?? 'unknown']?.label ?? 'Unknown'}</span>
)}
{client.custom_category && (
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 flex-shrink-0"
title={`Auto-detected as: ${CATEGORY_META[client.auto_category ?? 'unknown']?.label ?? 'Unknown'}`}>
manual
</span>
)}
{categoryMutation.isPending && <span className="text-xs text-gray-400">saving…</span>}
</div>
</DetailRow>
<DetailRow label="Type"> <DetailRow label="Type">
<span className={clsx( <span className={clsx(
'capitalize', 'capitalize',
+18 -2
View File
@@ -1,7 +1,11 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Search, Wifi, Network, Users, X, Pencil, Trash2, ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react'; import {
Search, Wifi, Network, Users, X, Pencil, Trash2, ChevronUp, ChevronDown, ChevronsUpDown,
ChevronLeft, ChevronRight, RefreshCw,
} from 'lucide-react';
import { CATEGORY_META } from '../utils/clientCategories';
import { clientsApi } from '../services/api'; import { clientsApi } from '../services/api';
import type { Client } from '../types'; import type { Client } from '../types';
import { useCanWrite } from '../hooks/useCanWrite'; import { useCanWrite } from '../hooks/useCanWrite';
@@ -46,6 +50,17 @@ function getPageList(current0: number, totalPages: number): (number | 'ellipsis'
return out; return out;
} }
function CategoryIcon({ category }: { category?: string }) {
const meta = category && category !== 'unknown' ? CATEGORY_META[category] : undefined;
if (!meta) return null;
const Icon = meta.icon;
return (
<span title={meta.label} className="flex-shrink-0 text-gray-400 dark:text-slate-500">
<Icon className="w-3.5 h-3.5" />
</span>
);
}
function formatDataBytes(bytes: number): string { function formatDataBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -365,7 +380,8 @@ export default function ClientsPage() {
className="hover:bg-gray-50 dark:hover:bg-slate-700/30" className="hover:bg-gray-50 dark:hover:bg-slate-700/30"
> >
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<div className="flex items-center gap-1 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<CategoryIcon category={client.device_category} />
<button <button
onClick={() => navigate(`/clients/${encodeURIComponent(client.mac_address)}`)} onClick={() => navigate(`/clients/${encodeURIComponent(client.mac_address)}`)}
className="font-medium text-blue-600 dark:text-blue-400 hover:underline text-left truncate" className="font-medium text-blue-600 dark:text-blue-400 hover:underline text-left truncate"
+382
View File
@@ -0,0 +1,382 @@
import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
ArrowUpCircle, RefreshCw, CheckCircle, XCircle, AlertTriangle, Clock,
HardDrive, ShieldAlert, Rocket, Ban, ChevronRight,
} from 'lucide-react';
import clsx from 'clsx';
import { firmwareApi } from '../services/api';
import type { FirmwareRolloutDevice } from '../services/api';
import { useCanWrite } from '../hooks/useCanWrite';
import { formatDistanceToNow } from 'date-fns';
const ITEM_STATUS: Record<string, { label: string; cls: string; spin?: boolean }> = {
pending: { label: 'Pending', cls: 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400' },
backing_up: { label: 'Backing up', cls: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400', spin: true },
upgrading: { label: 'Upgrading', cls: 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-400', spin: true },
rebooting: { label: 'Rebooting', cls: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400', spin: true },
verifying: { label: 'Verifying', cls: 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-400', spin: true },
success: { label: 'Success', cls: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' },
failed: { label: 'Failed', cls: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' },
skipped: { label: 'Skipped', cls: 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400' },
};
const ROLLOUT_STATUS: Record<string, string> = {
pending: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300',
running: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
completed: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400',
failed: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400',
cancelled: 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400',
};
function TypePill({ type }: { type: string }) {
const label = type === 'wireless_ap' ? 'AP' : type === 'switch' ? 'SW' : type === 'router' ? 'RTR' : '—';
return <span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400">{label}</span>;
}
// ─── Rollout progress panel ────────────────────────────────────────────────────
function RolloutPanel({ rolloutId, canWrite }: { rolloutId: number; canWrite: boolean }) {
const qc = useQueryClient();
const { data: rollout } = useQuery({
queryKey: ['fw-rollout', rolloutId],
queryFn: () => firmwareApi.getRollout(rolloutId).then(r => r.data),
refetchInterval: (q) => {
const s = q.state.data?.status;
return s === 'running' || s === 'pending' ? 4_000 : false;
},
});
const cancelMutation = useMutation({
mutationFn: () => firmwareApi.cancelRollout(rolloutId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['fw-rollout', rolloutId] }),
});
if (!rollout) return null;
const waves = [...new Set((rollout.devices ?? []).map(d => d.wave))].sort((a, b) => a - b);
const active = rollout.status === 'running' || rollout.status === 'pending';
return (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2 flex-wrap">
<Rocket className="w-4 h-4 text-blue-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{rollout.name}</h3>
<span className={clsx('px-2 py-0.5 rounded-full text-xs font-medium capitalize', ROLLOUT_STATUS[rollout.status])}>
{rollout.status}
</span>
{rollout.scheduled_at && rollout.status === 'pending' && (
<span className="text-xs text-gray-400 dark:text-slate-500 flex items-center gap-1">
<Clock className="w-3 h-3" />starts {formatDistanceToNow(new Date(rollout.scheduled_at), { addSuffix: true })}
</span>
)}
<span className="ml-auto flex items-center gap-3 text-xs text-gray-400 dark:text-slate-500">
{rollout.pre_backup && <span className="flex items-center gap-1"><HardDrive className="w-3 h-3" />pre-backup</span>}
{rollout.halt_on_failure && <span className="flex items-center gap-1"><ShieldAlert className="w-3 h-3" />halt on failure</span>}
{canWrite && active && (
<button onClick={() => cancelMutation.mutate()} disabled={cancelMutation.isPending}
className="flex items-center gap-1 px-2 py-1 rounded-lg text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
<Ban className="w-3 h-3" />Cancel
</button>
)}
</span>
</div>
{waves.map(w => (
<div key={w}>
<div className="px-5 py-1.5 bg-gray-50 dark:bg-slate-800/50 text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-slate-500">
Wave {w}{w === 1 ? ' — canary' : ''}
</div>
{(rollout.devices ?? []).filter(d => d.wave === w).map((d: FirmwareRolloutDevice) => {
const meta = ITEM_STATUS[d.status] ?? ITEM_STATUS.pending;
return (
<div key={d.id} className="px-5 py-2.5 flex items-center gap-3 border-t border-gray-100 dark:border-slate-700/60">
<span className={clsx('px-2 py-0.5 rounded-full text-xs font-medium flex items-center gap-1 flex-shrink-0', meta.cls)}>
{meta.spin && <RefreshCw className="w-3 h-3 animate-spin" />}
{d.status === 'success' && <CheckCircle className="w-3 h-3" />}
{d.status === 'failed' && <XCircle className="w-3 h-3" />}
{meta.label}
</span>
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">{d.device_name}</span>
<span className="font-mono text-xs text-gray-400 dark:text-slate-500 flex items-center gap-1 flex-shrink-0">
{d.from_version || '—'}
{(d.to_version || d.status === 'success') && <><ChevronRight className="w-3 h-3" />{d.to_version || '?'}</>}
</span>
{d.error && <span className="text-xs text-red-500 truncate" title={d.error}>{d.error}</span>}
<span className="ml-auto text-[11px] text-gray-400 dark:text-slate-500 flex-shrink-0">
{d.finished_at ? formatDistanceToNow(new Date(d.finished_at), { addSuffix: true }) : ''}
</span>
</div>
);
})}
</div>
))}
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function FirmwarePage() {
const qc = useQueryClient();
const canWrite = useCanWrite();
const [selected, setSelected] = useState<Map<number, number>>(new Map()); // deviceId → wave
const [rolloutName, setRolloutName] = useState('');
const [preBackup, setPreBackup] = useState(true);
const [haltOnFailure, setHaltOnFailure] = useState(true);
const [scheduleAt, setScheduleAt] = useState('');
const [viewRolloutId, setViewRolloutId] = useState<number | null>(null);
const [checkResult, setCheckResult] = useState<string | null>(null);
const { data: overview, isLoading } = useQuery({
queryKey: ['fw-overview'],
queryFn: () => firmwareApi.overview().then(r => r.data),
refetchInterval: 30_000,
});
const { data: rollouts = [] } = useQuery({
queryKey: ['fw-rollouts'],
queryFn: () => firmwareApi.listRollouts().then(r => r.data),
refetchInterval: 15_000,
});
const devices = overview?.devices ?? [];
const updatable = devices.filter(d => d.firmware_update_available && d.status === 'online');
const upToDate = devices.filter(d => !d.firmware_update_available && d.ros_version).length;
const activeRolloutId = viewRolloutId ?? overview?.runningRolloutId ?? overview?.latestRolloutId ?? null;
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['fw-overview'] });
qc.invalidateQueries({ queryKey: ['fw-rollouts'] });
};
const checkAll = useMutation({
mutationFn: () => firmwareApi.checkAll(),
onSuccess: (r) => {
const avail = r.data.results.filter(x => x.ok && x.available).length;
const failed = r.data.results.filter(x => !x.ok).length;
setCheckResult(`Checked ${r.data.results.length} device(s): ${avail} update(s) available${failed ? `, ${failed} unreachable` : ''}`);
setTimeout(() => setCheckResult(null), 6000);
invalidate();
},
});
const createRollout = useMutation({
mutationFn: () => firmwareApi.createRollout({
name: rolloutName.trim() || `RouterOS upgrade ${new Date().toISOString().slice(0, 10)}`,
halt_on_failure: haltOnFailure,
pre_backup: preBackup,
scheduled_at: scheduleAt ? new Date(scheduleAt).toISOString() : null,
start: !scheduleAt,
devices: [...selected.entries()].map(([device_id, wave]) => ({ device_id, wave })),
}),
onSuccess: (r) => {
setSelected(new Map());
setViewRolloutId(r.data.id);
invalidate();
},
});
const toggleDevice = (id: number) => {
setSelected(prev => {
const next = new Map(prev);
if (next.has(id)) next.delete(id);
else next.set(id, next.size === 0 ? 1 : 2); // first pick becomes the canary
return next;
});
};
const setWave = (id: number, wave: number) => setSelected(prev => new Map(prev).set(id, wave));
const selectedCount = selected.size;
const waveSummary = useMemo(() => {
const byWave = new Map<number, number>();
for (const w of selected.values()) byWave.set(w, (byWave.get(w) ?? 0) + 1);
return [...byWave.entries()].sort((a, b) => a[0] - b[0]).map(([w, n]) => `wave ${w}: ${n}`).join(' · ');
}, [selected]);
return (
<div className="space-y-5">
{/* Header */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Firmware</h1>
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
Staged RouterOS upgrades — canary first, wave by wave, with pre-backups and health verification
</p>
</div>
<div className="flex items-center gap-2">
{checkResult && <span className="text-xs text-green-600 dark:text-green-400">{checkResult}</span>}
{canWrite && (
<button className="btn-secondary flex items-center gap-2" onClick={() => checkAll.mutate()} disabled={checkAll.isPending}>
<RefreshCw className={clsx('w-4 h-4', checkAll.isPending && 'animate-spin')} />
{checkAll.isPending ? 'Checking fleet…' : 'Check all for updates'}
</button>
)}
</div>
</div>
{/* KPIs */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div className="card p-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-green-50 dark:bg-green-900/20"><CheckCircle className="w-5 h-5 text-green-600 dark:text-green-400" /></div>
<div><div className="text-2xl font-bold text-gray-900 dark:text-white">{upToDate}</div>
<div className="text-xs text-gray-500 dark:text-slate-400">Up to date</div></div>
</div>
<div className="card p-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-50 dark:bg-amber-900/20"><ArrowUpCircle className="w-5 h-5 text-amber-600 dark:text-amber-400" /></div>
<div><div className="text-2xl font-bold text-gray-900 dark:text-white">{devices.filter(d => d.firmware_update_available).length}</div>
<div className="text-xs text-gray-500 dark:text-slate-400">Updates available</div></div>
</div>
<div className="card p-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-50 dark:bg-blue-900/20"><Rocket className="w-5 h-5 text-blue-600 dark:text-blue-400" /></div>
<div><div className="text-2xl font-bold text-gray-900 dark:text-white">{overview?.runningRolloutId ? 'Running' : '—'}</div>
<div className="text-xs text-gray-500 dark:text-slate-400">Active rollout</div></div>
</div>
</div>
{/* Fleet table */}
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Fleet versions</h3>
{canWrite && updatable.length > 0 && (
<button className="ml-auto text-xs text-blue-600 dark:text-blue-400 hover:underline"
onClick={() => setSelected(new Map(updatable.map((d, i) => [d.id, i === 0 ? 1 : 2])))}>
Select all updatable ({updatable.length})
</button>
)}
</div>
{isLoading ? (
<div className="p-8 text-center text-sm text-gray-400"><RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading…</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
{canWrite && <th className="px-4 py-2.5 w-8" />}
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Device</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Model</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">RouterOS</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Latest</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">RouterBOOT</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Status</th>
{canWrite && <th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Wave</th>}
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
{devices.map((d, i) => {
const isSel = selected.has(d.id);
const selectable = canWrite && d.firmware_update_available && d.status === 'online';
return (
<tr key={d.id} className={clsx('transition-colors', isSel ? 'bg-blue-50/60 dark:bg-blue-900/10' : i % 2 === 0 ? 'bg-white dark:bg-slate-900/20' : 'bg-gray-50 dark:bg-slate-800/40')}>
{canWrite && (
<td className="px-4 py-2.5">
<input type="checkbox" className="w-4 h-4 rounded" checked={isSel} disabled={!selectable}
onChange={() => toggleDevice(d.id)} />
</td>
)}
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<TypePill type={d.device_type} />
<span className="font-medium text-gray-900 dark:text-white">{d.name}</span>
{d.status !== 'online' && <span className="text-[10px] text-red-400">offline</span>}
</div>
</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">{d.model || '—'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-700 dark:text-slate-300">{d.ros_version || '—'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{d.latest_ros_version || '—'}</td>
<td className="px-4 py-2.5 text-xs">
{d.routerboard_upgrade_available
? <span className="px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 font-mono">{d.firmware_version} → {d.upgrade_firmware_version}</span>
: <span className="font-mono text-gray-400 dark:text-slate-500">{d.firmware_version || '—'}</span>}
</td>
<td className="px-4 py-2.5">
{d.firmware_update_available
? <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400">Update available</span>
: <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400">Up to date</span>}
</td>
{canWrite && (
<td className="px-4 py-2.5">
{isSel && (
<select className="input py-0.5 text-xs w-auto" value={selected.get(d.id)}
onChange={e => setWave(d.id, parseInt(e.target.value, 10))}>
{[1, 2, 3].map(w => <option key={w} value={w}>{w === 1 ? '1 — canary' : String(w)}</option>)}
</select>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Rollout builder bar */}
{canWrite && selectedCount > 0 && (
<div className="px-5 py-4 border-t border-gray-200 dark:border-slate-700 bg-blue-50/40 dark:bg-blue-900/10 space-y-3">
<div className="flex flex-wrap items-center gap-3">
<input className="input w-64" placeholder={`RouterOS upgrade ${new Date().toISOString().slice(0, 10)}`}
value={rolloutName} onChange={e => setRolloutName(e.target.value)} />
<label className="flex items-center gap-1.5 text-sm text-gray-700 dark:text-slate-300 cursor-pointer">
<input type="checkbox" className="w-4 h-4 rounded" checked={preBackup} onChange={e => setPreBackup(e.target.checked)} />
Pre-upgrade backup
</label>
<label className="flex items-center gap-1.5 text-sm text-gray-700 dark:text-slate-300 cursor-pointer">
<input type="checkbox" className="w-4 h-4 rounded" checked={haltOnFailure} onChange={e => setHaltOnFailure(e.target.checked)} />
Halt on failure
</label>
<label className="flex items-center gap-1.5 text-sm text-gray-700 dark:text-slate-300">
<Clock className="w-4 h-4 text-gray-400" />
<input type="datetime-local" className="input py-1 text-xs w-auto" value={scheduleAt} onChange={e => setScheduleAt(e.target.value)} />
</label>
</div>
<div className="flex items-center gap-3">
<button className="btn-primary flex items-center gap-2" disabled={createRollout.isPending}
onClick={() => createRollout.mutate()}>
{createRollout.isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Rocket className="w-4 h-4" />}
{scheduleAt ? 'Schedule rollout' : 'Start rollout now'} ({selectedCount} device{selectedCount !== 1 ? 's' : ''})
</button>
<span className="text-xs text-gray-500 dark:text-slate-400">{waveSummary}</span>
{createRollout.isError && (
<span className="text-xs text-red-500 flex items-center gap-1">
<AlertTriangle className="w-3.5 h-3.5" />
{(createRollout.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Failed to create rollout'}
</span>
)}
{scheduleAt && (
<span className="text-xs text-gray-400 dark:text-slate-500">
Tip: schedule inside a maintenance window to avoid disrupting users.
</span>
)}
</div>
</div>
)}
</div>
{/* Latest / running rollout */}
{activeRolloutId && <RolloutPanel rolloutId={activeRolloutId} canWrite={canWrite} />}
{/* Past rollouts */}
{rollouts.length > 1 && (
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">History</h3>
</div>
<div className="divide-y divide-gray-100 dark:divide-slate-700/50">
{rollouts.map(r => (
<button key={r.id} onClick={() => setViewRolloutId(r.id)}
className="w-full px-5 py-2.5 flex items-center gap-3 text-left hover:bg-gray-50 dark:hover:bg-slate-700/40 transition-colors">
<span className={clsx('px-2 py-0.5 rounded-full text-xs font-medium capitalize flex-shrink-0', ROLLOUT_STATUS[r.status])}>{r.status}</span>
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">{r.name}</span>
<span className="text-xs text-gray-400 dark:text-slate-500">
{r.success_count}/{r.device_count} succeeded{(r.failed_count ?? 0) > 0 ? ` · ${r.failed_count} failed` : ''}
</span>
<span className="ml-auto text-[11px] text-gray-400 dark:text-slate-500 flex-shrink-0">
{formatDistanceToNow(new Date(r.created_at), { addSuffix: true })}
</span>
</button>
))}
</div>
</div>
)}
</div>
);
}
+607
View File
@@ -0,0 +1,607 @@
import { useMemo, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Ticket, Wifi, Users, Globe, Power, Trash2, RefreshCw, Plus, Printer,
CheckCircle, AlertTriangle, X, Clock, Database,
} from 'lucide-react';
import clsx from 'clsx';
import { guestWifiApi, devicesApi } from '../services/api';
import { useCanWrite } from '../hooks/useCanWrite';
import type { Device } from '../types';
type HS = Record<string, string>;
function formatBytes(raw: string | number | undefined): string {
const b = Number(raw ?? 0);
if (!b) return '0 B';
if (b < 1024) return `${b} B`;
if (b < 1024 ** 2) return `${(b / 1024).toFixed(1)} KB`;
if (b < 1024 ** 3) return `${(b / 1024 ** 2).toFixed(1)} MB`;
return `${(b / 1024 ** 3).toFixed(2)} GB`;
}
// ─── Voucher print sheet ─────────────────────────────────────────────────────
function printVouchers(codes: string[], opts: { network: string; durationHours?: number; dataCapMB?: number }) {
const cards = codes.map(code => `
<div class="card">
<div class="net">📶 ${opts.network}</div>
<div class="code">${code}</div>
<div class="meta">
${opts.durationHours ? `Valid ${opts.durationHours}h` : 'No time limit'}
${opts.dataCapMB ? ` · ${opts.dataCapMB >= 1024 ? (opts.dataCapMB / 1024).toFixed(0) + ' GB' : opts.dataCapMB + ' MB'}` : ''}
</div>
<div class="hint">Connect to WiFi, enter this code as username.<br/>Leave password blank.</div>
</div>`).join('');
const html = `<!doctype html><html><head><title>Guest WiFi Vouchers</title><style>
body { font-family: -apple-system, system-ui, sans-serif; margin: 24px; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.card { border: 1.5px dashed #94a3b8; border-radius: 10px; padding: 14px; text-align: center; break-inside: avoid; }
.net { font-size: 12px; color: #475569; margin-bottom: 6px; }
.code { font-size: 20px; font-weight: 700; letter-spacing: 1px; font-family: ui-monospace, monospace; }
.meta { font-size: 11px; color: #64748b; margin-top: 6px; }
.hint { font-size: 9.5px; color: #94a3b8; margin-top: 8px; line-height: 1.4; }
@media print { body { margin: 8px; } }
</style></head><body><div class="grid">${cards}</div><script>window.print()</script></body></html>`;
const w = window.open('', '_blank', 'width=900,height=700');
if (w) { w.document.write(html); w.document.close(); }
}
// ─── Setup wizard ─────────────────────────────────────────────────────────────
function SetupWizard({ deviceId, interfaces, isAP, onDone }: {
deviceId: number;
interfaces: { name: string; type: string }[];
isAP: boolean;
onDone: () => void;
}) {
const [mode, setMode] = useState<'ssid' | 'existing'>(isAP ? 'ssid' : 'existing');
const [form, setForm] = useState({
name: 'guest',
ssid: 'Guest WiFi',
passphrase: '',
vlanId: '',
interfaceName: interfaces[0]?.name ?? '',
gatewayCidr: '10.5.50.1/24',
poolRange: '10.5.50.10-10.5.50.254',
dnsName: '',
rateLimit: '10M/10M',
masquerade: true,
});
const set = <K extends keyof typeof form>(k: K) => (v: typeof form[K]) => setForm(f => ({ ...f, [k]: v }));
const [warnings, setWarnings] = useState<string[]>([]);
const mutation = useMutation({
mutationFn: () => guestWifiApi.setup(deviceId, {
name: form.name,
gatewayCidr: form.gatewayCidr,
poolRange: form.poolRange,
dnsName: form.dnsName || undefined,
rateLimit: form.rateLimit || undefined,
masquerade: form.masquerade,
...(mode === 'ssid'
? {
ssid: { ssid: form.ssid, passphrase: form.passphrase || undefined },
vlanId: form.vlanId ? parseInt(form.vlanId, 10) : undefined,
}
: { interfaceName: form.interfaceName }),
}),
onSuccess: (r) => {
setWarnings(r.data.warnings ?? []);
if ((r.data.warnings ?? []).length === 0) onDone();
},
});
// Setup succeeded but produced warnings — show them with a continue button
if (warnings.length > 0) {
return (
<div className="card p-6 max-w-2xl">
<div className="flex items-center gap-2 mb-3">
<CheckCircle className="w-5 h-5 text-green-500" />
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Guest network created — review these</h2>
</div>
<div className="space-y-2 mb-4">
{warnings.map((w, i) => (
<div key={i} className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-xs text-amber-700 dark:text-amber-400">
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />{w}
</div>
))}
</div>
<div className="flex justify-end">
<button className="btn-primary" onClick={onDone}>Continue to Guest WiFi</button>
</div>
</div>
);
}
return (
<div className="card p-6 max-w-2xl">
<div className="flex items-center gap-2 mb-1">
<Ticket className="w-5 h-5 text-blue-500" />
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Set up Guest WiFi</h2>
</div>
<p className="text-sm text-gray-500 dark:text-slate-400 mb-4">
Builds the whole guest network in one pass: broadcasts a new guest SSID, isolates the traffic
(VLAN or dedicated bridge), and stands up the captive portal — pool, DHCP, portal server, rate-limited guest profile, NAT.
</p>
{/* Mode toggle */}
<div className="flex rounded-lg border border-gray-200 dark:border-slate-700 p-1 gap-1 w-fit mb-5">
{isAP && (
<button onClick={() => setMode('ssid')}
className={clsx('px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
mode === 'ssid' ? 'bg-blue-600 text-white' : 'text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700')}>
Create new guest SSID
</button>
)}
<button onClick={() => setMode('existing')}
className={clsx('px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
mode === 'existing' ? 'bg-blue-600 text-white' : 'text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700')}>
Use existing interface
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{mode === 'ssid' ? (
<>
<div>
<label className="label">Guest SSID</label>
<input className="input" value={form.ssid} onChange={e => set('ssid')(e.target.value)} placeholder="Guest WiFi" maxLength={32} />
<p className="text-xs text-gray-400 mt-1">A new virtual AP is created on every radio (2.4 + 5 GHz).</p>
</div>
<div>
<label className="label">WiFi password (optional)</label>
<input className="input font-mono" value={form.passphrase} onChange={e => set('passphrase')(e.target.value)} placeholder="empty = open network" />
<p className="text-xs text-gray-400 mt-1">Open is typical for captive portals — the voucher is the gate.</p>
</div>
<div>
<label className="label">Guest VLAN ID (recommended)</label>
<input type="number" min={1} max={4094} className="input font-mono" value={form.vlanId} onChange={e => set('vlanId')(e.target.value)} placeholder="e.g. 50" />
<p className="text-xs text-gray-400 mt-1">Tags guest traffic for firewall/isolation. Empty = dedicated guest bridge instead.</p>
</div>
</>
) : (
<div>
<label className="label">Interface</label>
<select className="input" value={form.interfaceName} onChange={e => set('interfaceName')(e.target.value)}>
{interfaces.map(i => <option key={i.name} value={i.name}>{i.name}{i.type ? ` (${i.type})` : ''}</option>)}
</select>
<p className="text-xs text-gray-400 mt-1">An existing guest bridge/VLAN/SSID interface.</p>
</div>
)}
<div>
<label className="label">Network name</label>
<input className="input" value={form.name} onChange={e => set('name')(e.target.value)} placeholder="guest" />
<p className="text-xs text-gray-400 mt-1">Names the hotspot, pool &amp; profiles (a–z, 0–9, dash).</p>
</div>
<div>
<label className="label">Gateway address (CIDR)</label>
<input className="input font-mono" value={form.gatewayCidr} onChange={e => set('gatewayCidr')(e.target.value)} placeholder="10.5.50.1/24" />
</div>
<div>
<label className="label">DHCP pool range</label>
<input className="input font-mono" value={form.poolRange} onChange={e => set('poolRange')(e.target.value)} placeholder="10.5.50.10-10.5.50.254" />
</div>
<div>
<label className="label">Guest speed limit (rx/tx)</label>
<input className="input font-mono" value={form.rateLimit} onChange={e => set('rateLimit')(e.target.value)} placeholder="10M/10M" />
<p className="text-xs text-gray-400 mt-1">Per-guest cap. Leave empty for unlimited.</p>
</div>
<div>
<label className="label">Portal DNS name (optional)</label>
<input className="input font-mono" value={form.dnsName} onChange={e => set('dnsName')(e.target.value)} placeholder="wifi.guest" />
</div>
</div>
<label className="mt-4 flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-slate-300">
<input type="checkbox" className="w-4 h-4 rounded" checked={form.masquerade} onChange={e => set('masquerade')(e.target.checked)} />
NAT guest traffic to the internet (adds a masquerade rule for the guest subnet)
</label>
{mode === 'existing' && (
<div className="mt-4 flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-xs text-amber-700 dark:text-amber-400">
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>Pick a dedicated guest interface. Enabling a hotspot on your main LAN interface will put a captive portal in front of <em>every</em> client on it.</span>
</div>
)}
{mutation.isError && (
<p className="mt-3 text-sm text-red-500">
{(mutation.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Setup failed'}
</p>
)}
<div className="mt-5 flex justify-end">
<button className="btn-primary flex items-center gap-2"
disabled={mutation.isPending || (mode === 'ssid' ? !form.ssid.trim() : !form.interfaceName)}
onClick={() => mutation.mutate()}>
{mutation.isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <CheckCircle className="w-4 h-4" />}
{mutation.isPending ? 'Setting up…' : 'Create Guest Network'}
</button>
</div>
</div>
);
}
// ─── Voucher generator ───────────────────────────────────────────────────────
function VoucherGenerator({ deviceId, userProfiles, networkName, onCreated }: {
deviceId: number;
userProfiles: HS[];
networkName: string;
onCreated: () => void;
}) {
const [count, setCount] = useState(10);
const [durationHours, setDurationHours] = useState(24);
const [dataCapMB, setDataCapMB] = useState(0);
const [profile, setProfile] = useState(userProfiles.find(p => (p['name'] || '').includes('guest'))?.['name'] ?? userProfiles[0]?.['name'] ?? '');
const [result, setResult] = useState<string[] | null>(null);
const mutation = useMutation({
mutationFn: () => guestWifiApi.createVouchers(deviceId, {
count,
durationHours: durationHours > 0 ? durationHours : undefined,
dataCapMB: dataCapMB > 0 ? dataCapMB : undefined,
userProfile: profile || undefined,
}),
onSuccess: (r) => { setResult(r.data.codes); onCreated(); },
});
return (
<div className="card p-5">
<div className="flex items-center gap-2 mb-4">
<Plus className="w-4 h-4 text-green-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Generate vouchers</h3>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div>
<label className="label">Count</label>
<input type="number" min={1} max={100} className="input" value={count} onChange={e => setCount(parseInt(e.target.value) || 1)} />
</div>
<div>
<label className="label">Valid for (hours)</label>
<input type="number" min={0} className="input" value={durationHours} onChange={e => setDurationHours(parseInt(e.target.value) || 0)} />
<p className="text-[10px] text-gray-400 mt-0.5">0 = unlimited</p>
</div>
<div>
<label className="label">Data cap (MB)</label>
<input type="number" min={0} className="input" value={dataCapMB} onChange={e => setDataCapMB(parseInt(e.target.value) || 0)} />
<p className="text-[10px] text-gray-400 mt-0.5">0 = unlimited</p>
</div>
<div>
<label className="label">Speed profile</label>
<select className="input" value={profile} onChange={e => setProfile(e.target.value)}>
{userProfiles.map(p => (
<option key={p['.id'] ?? p['name']} value={p['name']}>
{p['name']}{p['rate-limit'] ? ` (${p['rate-limit']})` : ''}
</option>
))}
</select>
</div>
</div>
{mutation.isError && (
<p className="mt-3 text-sm text-red-500">
{(mutation.error as { response?: { data?: { error?: string } } })?.response?.data?.error || 'Failed to create vouchers'}
</p>
)}
<div className="mt-4 flex justify-end">
<button className="btn-primary flex items-center gap-2" disabled={mutation.isPending} onClick={() => mutation.mutate()}>
{mutation.isPending ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Ticket className="w-4 h-4" />}
{mutation.isPending ? 'Generating…' : `Generate ${count}`}
</button>
</div>
{/* Result modal */}
{result && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => setResult(null)}>
<div className="card w-full max-w-lg p-5" onClick={e => e.stopPropagation()}>
<div className="flex items-center gap-2 mb-3">
<CheckCircle className="w-5 h-5 text-green-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{result.length} voucher{result.length !== 1 ? 's' : ''} created</h3>
<button className="ml-auto text-gray-400 hover:text-gray-600" onClick={() => setResult(null)}><X className="w-4 h-4" /></button>
</div>
<div className="grid grid-cols-3 gap-2 max-h-64 overflow-y-auto mb-4">
{result.map(c => (
<div key={c} className="px-2 py-1.5 rounded bg-gray-100 dark:bg-slate-700 text-center font-mono text-sm text-gray-800 dark:text-slate-200">{c}</div>
))}
</div>
<div className="flex justify-end gap-2">
<button className="btn-secondary" onClick={() => setResult(null)}>Close</button>
<button className="btn-primary flex items-center gap-2"
onClick={() => printVouchers(result, { network: networkName, durationHours: durationHours > 0 ? durationHours : undefined, dataCapMB: dataCapMB > 0 ? dataCapMB : undefined })}>
<Printer className="w-4 h-4" />Print sheet
</button>
</div>
</div>
</div>
)}
</div>
);
}
// ─── Page ────────────────────────────────────────────────────────────────────
export default function GuestWifiPage() {
const qc = useQueryClient();
const canWrite = useCanWrite();
const [deviceId, setDeviceId] = useState<number | ''>('');
const [wgHost, setWgHost] = useState('');
const { data: devices = [] } = useQuery({
queryKey: ['devices'],
queryFn: () => devicesApi.list().then(r => r.data),
staleTime: 60_000,
});
// Hotspots belong on the device that serves the guest network: an AP or the
// gateway router. Switches (and 'other') aren't sensible portal hosts.
const online = (devices as Device[]).filter(
d => d.status === 'online' && (d.device_type === 'wireless_ap' || d.device_type === 'router')
);
const devId = typeof deviceId === 'number' ? deviceId : (online[0]?.id ?? 0);
const { data: overview, isLoading, refetch, isFetching } = useQuery({
queryKey: ['guest-wifi-overview', devId],
queryFn: () => guestWifiApi.overview(devId).then(r => r.data),
enabled: devId > 0,
});
const hasServer = (overview?.servers?.length ?? 0) > 0;
const { data: users = [] } = useQuery({
queryKey: ['guest-wifi-users', devId],
queryFn: () => guestWifiApi.users(devId).then(r => r.data),
enabled: devId > 0 && hasServer,
});
const { data: active = [] } = useQuery({
queryKey: ['guest-wifi-active', devId],
queryFn: () => guestWifiApi.active(devId).then(r => r.data),
enabled: devId > 0 && hasServer,
refetchInterval: 30_000,
});
const { data: walledGarden = [] } = useQuery({
queryKey: ['guest-wifi-wg', devId],
queryFn: () => guestWifiApi.walledGarden(devId).then(r => r.data),
enabled: devId > 0 && hasServer,
});
const invalidateAll = () => {
qc.invalidateQueries({ queryKey: ['guest-wifi-overview', devId] });
qc.invalidateQueries({ queryKey: ['guest-wifi-users', devId] });
qc.invalidateQueries({ queryKey: ['guest-wifi-active', devId] });
qc.invalidateQueries({ queryKey: ['guest-wifi-wg', devId] });
};
const toggleServer = useMutation({
mutationFn: ({ id, disabled }: { id: string; disabled: boolean }) => guestWifiApi.setServerDisabled(devId, id, disabled),
onSuccess: invalidateAll,
});
const deleteUser = useMutation({ mutationFn: (id: string) => guestWifiApi.deleteUser(devId, id), onSuccess: invalidateAll });
const kickGuest = useMutation({ mutationFn: (id: string) => guestWifiApi.disconnect(devId, id), onSuccess: invalidateAll });
const addWg = useMutation({ mutationFn: (host: string) => guestWifiApi.addWalledGarden(devId, host), onSuccess: () => { setWgHost(''); invalidateAll(); } });
const removeWg = useMutation({ mutationFn: (id: string) => guestWifiApi.removeWalledGarden(devId, id), onSuccess: invalidateAll });
const vouchers = useMemo(() => users.filter(u => (u['comment'] || '').startsWith('voucher')), [users]);
const networkName = overview?.servers?.[0]?.['name'] ?? 'Guest WiFi';
return (
<div className="space-y-5">
{/* Header */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Guest WiFi</h1>
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
Captive portal, vouchers, and guest session management (RouterOS Hotspot)
</p>
</div>
<div className="flex items-center gap-2">
<select className="input w-auto" value={devId} onChange={e => setDeviceId(parseInt(e.target.value))}>
{online.map(d => (
<option key={d.id} value={d.id}>
{d.name} ({d.device_type === 'wireless_ap' ? 'AP' : 'Router'})
</option>
))}
</select>
<button className="btn-secondary flex items-center gap-2" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={clsx('w-4 h-4', isFetching && 'animate-spin')} />Refresh
</button>
</div>
</div>
{online.length === 0 ? (
<div className="card p-10 text-center text-sm text-gray-400 dark:text-slate-500">No online devices.</div>
) : isLoading ? (
<div className="card p-10 text-center text-sm text-gray-400 dark:text-slate-500">
<RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading hotspot state…
</div>
) : !hasServer ? (
canWrite
? <SetupWizard deviceId={devId} interfaces={overview?.interfaces ?? []}
isAP={online.find(d => d.id === devId)?.device_type === 'wireless_ap'} onDone={invalidateAll} />
: <div className="card p-10 text-center text-sm text-gray-400 dark:text-slate-500">No guest network configured on this device.</div>
) : (
<>
{/* Server status + KPIs */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{overview!.servers.map(s => {
const disabled = s['disabled'] === 'true';
return (
<div key={s['.id']} className="card p-4 flex items-center gap-3">
<div className={clsx('p-2 rounded-lg', disabled ? 'bg-gray-100 dark:bg-slate-700' : 'bg-green-50 dark:bg-green-900/20')}>
<Wifi className={clsx('w-5 h-5', disabled ? 'text-gray-400' : 'text-green-600 dark:text-green-400')} />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white truncate">{s['name']}</div>
<div className="text-xs text-gray-400 dark:text-slate-500 font-mono truncate">{s['interface']} · {disabled ? 'disabled' : 'running'}</div>
</div>
{canWrite && (
<button onClick={() => toggleServer.mutate({ id: s['.id'], disabled: !disabled })}
className={clsx('ml-auto p-1.5 rounded-lg transition-colors', disabled ? 'text-gray-400 hover:text-green-600' : 'text-green-500 hover:text-red-500')}
title={disabled ? 'Enable portal' : 'Disable portal'}>
<Power className="w-4 h-4" />
</button>
)}
</div>
);
})}
<div className="card p-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-50 dark:bg-blue-900/20"><Users className="w-5 h-5 text-blue-600 dark:text-blue-400" /></div>
<div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">{active.length}</div>
<div className="text-xs text-gray-500 dark:text-slate-400">Guests online</div>
</div>
</div>
<div className="card p-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-violet-50 dark:bg-violet-900/20"><Ticket className="w-5 h-5 text-violet-600 dark:text-violet-400" /></div>
<div>
<div className="text-2xl font-bold text-gray-900 dark:text-white">{vouchers.length}</div>
<div className="text-xs text-gray-500 dark:text-slate-400">Vouchers issued</div>
</div>
</div>
</div>
{/* Voucher generator */}
{canWrite && (
<VoucherGenerator deviceId={devId} userProfiles={overview!.userProfiles} networkName={networkName} onCreated={invalidateAll} />
)}
{/* Vouchers table */}
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<Ticket className="w-4 h-4 text-violet-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Vouchers ({vouchers.length})</h3>
{vouchers.length > 0 && (
<button className="ml-auto text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
onClick={() => printVouchers(vouchers.map(v => v['name']), { network: networkName })}>
<Printer className="w-3.5 h-3.5" />Print all
</button>
)}
</div>
{vouchers.length === 0 ? (
<div className="p-6 text-center text-sm text-gray-400 dark:text-slate-500">No vouchers yet — generate a batch above.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Code</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Profile</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Time limit</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Used</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Data</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Batch</th>
<th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
{vouchers.map((v, i) => {
const used = (v['uptime'] && v['uptime'] !== '0s');
return (
<tr key={v['.id'] ?? i} className={clsx('hover:bg-blue-50/40 dark:hover:bg-slate-700/30', i % 2 === 0 ? 'bg-white dark:bg-slate-900/20' : 'bg-gray-50 dark:bg-slate-800/40')}>
<td className="px-4 py-2.5 font-mono font-semibold text-gray-900 dark:text-white">{v['name']}</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">{v['profile'] || 'default'}</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400"><Clock className="w-3 h-3 inline mr-1" />{v['limit-uptime'] || '∞'}</td>
<td className="px-4 py-2.5 text-xs">
<span className={clsx('px-1.5 py-0.5 rounded-full font-medium', used ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' : 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400')}>
{used ? `used ${v['uptime']}` : 'unused'}
</span>
</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400"><Database className="w-3 h-3 inline mr-1" />{v['limit-bytes-total'] ? formatBytes(v['limit-bytes-total']) : '∞'}</td>
<td className="px-4 py-2.5 text-xs text-gray-400 dark:text-slate-500">{(v['comment'] || '').replace('voucher ', '')}</td>
<td className="px-4 py-2.5 text-right">
{canWrite && (
<button onClick={() => deleteUser.mutate(v['.id'])} className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-slate-700" title="Delete voucher">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Active guests */}
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<Users className="w-4 h-4 text-blue-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Guests online ({active.length})</h3>
</div>
{active.length === 0 ? (
<div className="p-6 text-center text-sm text-gray-400 dark:text-slate-500">No guests connected right now.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">User / Code</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">IP</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">MAC</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Session</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase">Down / Up</th>
<th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-slate-700/50">
{active.map((a, i) => (
<tr key={a['.id'] ?? i} className={clsx('hover:bg-blue-50/40 dark:hover:bg-slate-700/30', i % 2 === 0 ? 'bg-white dark:bg-slate-900/20' : 'bg-gray-50 dark:bg-slate-800/40')}>
<td className="px-4 py-2.5 font-mono font-medium text-gray-900 dark:text-white">{a['user'] || '—'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 dark:text-slate-400">{a['address'] || '—'}</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-600 dark:text-slate-400">{a['mac-address'] || '—'}</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">{a['uptime'] || '—'}</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">{formatBytes(a['bytes-out'])} / {formatBytes(a['bytes-in'])}</td>
<td className="px-4 py-2.5 text-right">
{canWrite && (
<button onClick={() => kickGuest.mutate(a['.id'])} className="text-xs text-red-500 hover:text-red-600 hover:underline" title="Disconnect guest">
Disconnect
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Walled garden */}
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<Globe className="w-4 h-4 text-emerald-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Walled garden</h3>
<span className="text-xs text-gray-400 dark:text-slate-500">sites reachable before login</span>
</div>
<div className="p-4 space-y-3">
{canWrite && (
<div className="flex gap-2">
<input className="input flex-1 font-mono" value={wgHost} onChange={e => setWgHost(e.target.value)}
placeholder="*.example.com" onKeyDown={e => { if (e.key === 'Enter' && wgHost.trim()) addWg.mutate(wgHost.trim()); }} />
<button className="btn-secondary flex items-center gap-1.5" disabled={!wgHost.trim() || addWg.isPending} onClick={() => addWg.mutate(wgHost.trim())}>
<Plus className="w-4 h-4" />Allow
</button>
</div>
)}
{walledGarden.length === 0 ? (
<p className="text-sm text-gray-400 dark:text-slate-500">No walled-garden entries.</p>
) : (
<div className="flex flex-wrap gap-2">
{walledGarden.map((w, i) => (
<span key={w['.id'] ?? i} className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-400 text-xs font-mono">
{w['dst-host'] || w['dst-address'] || '—'}
{canWrite && (
<button onClick={() => removeWg.mutate(w['.id'])} className="hover:text-red-500"><X className="w-3 h-3" /></button>
)}
</span>
))}
</div>
)}
</div>
</div>
</>
)}
</div>
);
}
+8 -2
View File
@@ -2,7 +2,7 @@ import { useState, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
Settings, Users, Key, Plus, Trash2, CheckCircle, AlertCircle, Pencil, X, Settings, Users, Key, Plus, Trash2, CheckCircle, AlertCircle, Pencil, X,
ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList, FileText, ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList, FileText, Zap,
} from 'lucide-react'; } from 'lucide-react';
import { settingsApi, authApi, certApi, alertsApi, auditLogApi, tagsApi, maintenanceApi, configTemplatesApi } from '../services/api'; import { settingsApi, authApi, certApi, alertsApi, auditLogApi, tagsApi, maintenanceApi, configTemplatesApi } from '../services/api';
import type { MaintenanceWindow, ConfigTemplate } from '../services/api'; import type { MaintenanceWindow, ConfigTemplate } from '../services/api';
@@ -16,6 +16,7 @@ import {
} from '../utils/backupSchedule'; } from '../utils/backupSchedule';
import clsx from 'clsx'; import clsx from 'clsx';
import CredentialPresetsSettings from '../components/settings/CredentialPresetsSettings'; import CredentialPresetsSettings from '../components/settings/CredentialPresetsSettings';
import AutomationSettings from '../components/settings/AutomationSettings';
const ROLE_META: Record<UserRole, { label: string; color: string; desc: string }> = { const ROLE_META: Record<UserRole, { label: string; color: string; desc: string }> = {
admin: { label: 'Admin', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', desc: 'Full access including user management' }, admin: { label: 'Admin', color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', desc: 'Full access including user management' },
@@ -47,7 +48,7 @@ export default function SettingsPage() {
const { theme, setTheme } = useThemeStore(); const { theme, setTheme } = useThemeStore();
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin';
const canWrite = user?.role !== 'viewer'; const canWrite = user?.role !== 'viewer';
const [activeTab, setActiveTab] = useState<'general' | 'users' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance' | 'templates'>('general'); const [activeTab, setActiveTab] = useState<'general' | 'users' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance' | 'templates' | 'automation'>('general');
const [auditSearch, setAuditSearch] = useState(''); const [auditSearch, setAuditSearch] = useState('');
const [auditPage, setAuditPage] = useState(1); const [auditPage, setAuditPage] = useState(1);
const [newTagName, setNewTagName] = useState(''); const [newTagName, setNewTagName] = useState('');
@@ -470,6 +471,7 @@ export default function SettingsPage() {
{ key: 'security' as const, label: 'My Password', icon: Key }, { key: 'security' as const, label: 'My Password', icon: Key },
{ key: 'certificate' as const, label: 'Certificate', icon: Lock }, { key: 'certificate' as const, label: 'Certificate', icon: Lock },
{ key: 'alerting' as const, label: 'Alerting', icon: Bell }, { key: 'alerting' as const, label: 'Alerting', icon: Bell },
{ key: 'automation' as const, label: 'Automation', icon: Zap },
...(isAdmin ? [ ...(isAdmin ? [
{ key: 'templates' as const, label: 'Config Templates', icon: FileText }, { key: 'templates' as const, label: 'Config Templates', icon: FileText },
{ key: 'tags' as const, label: 'Tags', icon: ShieldCheck }, { key: 'tags' as const, label: 'Tags', icon: ShieldCheck },
@@ -1774,6 +1776,10 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
)} )}
{/* ── Automation (API tokens, webhooks, scheduled reports) ── */}
{activeTab === 'automation' && (
<AutomationSettings isAdmin={isAdmin} />
)}
{/* ── Config Templates ── */} {/* ── Config Templates ── */}
{activeTab === 'templates' && ( {activeTab === 'templates' && (
<div className="space-y-4"> <div className="space-y-4">
+4
View File
@@ -6,6 +6,7 @@ import {
import { wirelessApi } from '../services/api'; import { wirelessApi } from '../services/api';
import type { WirelessAP } from '../types'; import type { WirelessAP } from '../types';
import RfHealth from '../components/wireless/RfHealth'; import RfHealth from '../components/wireless/RfHealth';
import RogueApsCard from '../components/wireless/RogueApsCard';
import clsx from 'clsx'; import clsx from 'clsx';
function timeAgo(ts: string | undefined): string { function timeAgo(ts: string | undefined): string {
@@ -101,6 +102,9 @@ export default function WirelessPage() {
{/* RF Health — channel map, density, TX retries, connectivity */} {/* RF Health — channel map, density, TX retries, connectivity */}
{aps.length > 0 && <RfHealth />} {aps.length > 0 && <RfHealth />}
{/* Rogue & neighbor AP detection (from stored AP scans) */}
{aps.length > 0 && <RogueApsCard />}
{/* AP table */} {/* AP table */}
<div className="card overflow-hidden"> <div className="card overflow-hidden">
<div className="px-5 py-4 border-b border-gray-200 dark:border-slate-700"> <div className="px-5 py-4 border-b border-gray-200 dark:border-slate-700">
+124
View File
@@ -368,6 +368,8 @@ export const clientsApi = {
api.put<Client>(`/clients/${mac}/notes`, { notes }), api.put<Client>(`/clients/${mac}/notes`, { notes }),
updateHostname: (mac: string, hostname: string) => updateHostname: (mac: string, hostname: string) =>
api.put<Client>(`/clients/${mac}/hostname`, { hostname }), api.put<Client>(`/clients/${mac}/hostname`, { hostname }),
updateCategory: (mac: string, category: string | null) =>
api.put<Client>(`/clients/${encodeURIComponent(mac)}/category`, { category }),
purgeStale: () => api.post<{ message: string; count: number }>('/clients/purge'), purgeStale: () => api.post<{ message: string; count: number }>('/clients/purge'),
}; };
@@ -413,6 +415,124 @@ export const eventsApi = {
}; };
// ─── Backups ────────────────────────────────────────────────────────────────── // ─── Backups ──────────────────────────────────────────────────────────────────
// ─── Guest WiFi (Hotspot) ───────────────────────────────────────────────────────
type HS = Record<string, string>;
export interface GuestWifiOverview {
servers: HS[];
profiles: HS[];
userProfiles: HS[];
userCount: number;
activeCount: number;
interfaces: { name: string; type: string }[];
}
export const guestWifiApi = {
overview: (deviceId: number) =>
api.get<GuestWifiOverview>('/guest-wifi/overview', { params: { deviceId }, timeout: 60_000 }),
setup: (deviceId: number, data: {
name: string; gatewayCidr: string; poolRange: string; dnsName?: string; rateLimit?: string;
interfaceName?: string; ssid?: { ssid: string; passphrase?: string }; vlanId?: number; masquerade?: boolean;
}) =>
api.post<{
server: string; profile: string; userProfile: string; pool: string;
targetInterface: string; ssidInterfaces: string[]; vlanInterface?: string; warnings: string[];
}>('/guest-wifi/setup', { deviceId, ...data }, { timeout: 120_000 }),
users: (deviceId: number) => api.get<HS[]>('/guest-wifi/users', { params: { deviceId } }),
createVouchers: (deviceId: number, data: { count: number; durationHours?: number; dataCapMB?: number; userProfile?: string }) =>
api.post<{ created: number; codes: string[] }>('/guest-wifi/vouchers', { deviceId, ...data }, { timeout: 120_000 }),
deleteUser: (deviceId: number, id: string) =>
api.delete(`/guest-wifi/users/${encodeURIComponent(id)}`, { params: { deviceId } }),
active: (deviceId: number) => api.get<HS[]>('/guest-wifi/active', { params: { deviceId } }),
disconnect: (deviceId: number, id: string) =>
api.delete(`/guest-wifi/active/${encodeURIComponent(id)}`, { params: { deviceId } }),
walledGarden: (deviceId: number) => api.get<HS[]>('/guest-wifi/walled-garden', { params: { deviceId } }),
addWalledGarden: (deviceId: number, dstHost: string, comment?: string) =>
api.post('/guest-wifi/walled-garden', { deviceId, dstHost, comment }),
removeWalledGarden: (deviceId: number, id: string) =>
api.delete(`/guest-wifi/walled-garden/${encodeURIComponent(id)}`, { params: { deviceId } }),
setServerDisabled: (deviceId: number, id: string, disabled: boolean) =>
api.put(`/guest-wifi/servers/${encodeURIComponent(id)}`, { deviceId, disabled }),
removeServer: (deviceId: number, id: string) =>
api.delete(`/guest-wifi/servers/${encodeURIComponent(id)}`, { params: { deviceId } }),
};
// ─── Platform & automation ──────────────────────────────────────────────────────
export interface ApiToken {
id: number; name: string; prefix: string; scope: 'read' | 'write';
created_by: string | null; last_used_at: string | null; expires_at: string | null; created_at: string;
}
export interface Webhook {
id: number; name: string; url: string; has_secret: boolean;
events: string[]; enabled: boolean; last_status: number | null; last_fired_at: string | null; created_at: string;
}
export interface ReportSchedule {
id: number; name: string; frequency: 'daily' | 'weekly' | 'monthly';
recipients: string; enabled: boolean; last_sent_at: string | null; next_run_at: string; created_at: string;
}
export const automationApi = {
// tokens (admin only)
listTokens: () => api.get<ApiToken[]>('/automation/tokens'),
createToken: (data: { name: string; scope: 'read' | 'write'; expires_days?: number }) =>
api.post<{ id: number; token: string }>('/automation/tokens', data),
deleteToken: (id: number) => api.delete(`/automation/tokens/${id}`),
// webhooks
listWebhooks: () => api.get<{ webhooks: Webhook[]; availableEvents: string[] }>('/automation/webhooks'),
createWebhook: (data: { name: string; url: string; secret?: string; events: string[] }) =>
api.post<{ id: number }>('/automation/webhooks', data),
updateWebhook: (id: number, data: Partial<{ name: string; url: string; secret: string; events: string[]; enabled: boolean }>) =>
api.put(`/automation/webhooks/${id}`, data),
deleteWebhook: (id: number) => api.delete(`/automation/webhooks/${id}`),
testWebhook: (id: number) => api.post<{ ok: boolean; status: number }>(`/automation/webhooks/${id}/test`),
// reports
listReports: () => api.get<ReportSchedule[]>('/automation/reports'),
createReport: (data: { name: string; frequency: string; recipients: string }) =>
api.post<{ id: number }>('/automation/reports', data),
updateReport: (id: number, data: { enabled?: boolean }) => api.put(`/automation/reports/${id}`, data),
deleteReport: (id: number) => api.delete(`/automation/reports/${id}`),
sendReportNow: (id: number) => api.post(`/automation/reports/${id}/send-now`, undefined, { timeout: 60_000 }),
};
// ─── Firmware orchestration ─────────────────────────────────────────────────────
export interface FirmwareDeviceRow {
id: number; name: string; device_type: string; status: string; model: string | null;
ros_version: string | null; latest_ros_version: string | null;
firmware_update_available: boolean;
firmware_version: string | null; upgrade_firmware_version: string | null;
routerboard_upgrade_available: boolean;
}
export interface FirmwareRollout {
id: number; name: string; status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
halt_on_failure: boolean; pre_backup: boolean;
scheduled_at: string | null; started_at: string | null; finished_at: string | null; created_at: string;
device_count?: number; success_count?: number; failed_count?: number;
}
export interface FirmwareRolloutDevice {
id: number; rollout_id: number; device_id: number; wave: number;
status: 'pending' | 'backing_up' | 'upgrading' | 'rebooting' | 'verifying' | 'success' | 'failed' | 'skipped';
from_version: string | null; to_version: string | null; error: string | null;
started_at: string | null; finished_at: string | null;
device_name: string; device_type: string; model: string | null;
}
export const firmwareApi = {
overview: () =>
api.get<{ devices: FirmwareDeviceRow[]; latestRolloutId: number | null; runningRolloutId: number | null }>('/firmware/overview'),
checkAll: () =>
api.post<{ results: { name: string; ok: boolean; installed?: string; latest?: string; available?: boolean; error?: string }[] }>(
'/firmware/check-all', undefined, { timeout: 180_000 }),
createRollout: (data: {
name: string; halt_on_failure: boolean; pre_backup: boolean;
scheduled_at?: string | null; start?: boolean;
devices: { device_id: number; wave: number }[];
}) => api.post<{ id: number }>('/firmware/rollouts', data),
listRollouts: () => api.get<FirmwareRollout[]>('/firmware/rollouts'),
getRollout: (id: number) =>
api.get<FirmwareRollout & { devices: FirmwareRolloutDevice[] }>(`/firmware/rollouts/${id}`),
startRollout: (id: number) => api.post(`/firmware/rollouts/${id}/start`),
cancelRollout: (id: number) => api.post(`/firmware/rollouts/${id}/cancel`),
};
// ─── Operations ──────────────────────────────────────────────────────────────── // ─── Operations ────────────────────────────────────────────────────────────────
export interface OpsAttentionItem { export interface OpsAttentionItem {
sev: 'error' | 'warn' | 'info'; sev: 'error' | 'warn' | 'info';
@@ -754,6 +874,10 @@ export const wirelessApi = {
getAPScanHistory: (apId: number, limit = 5) => getAPScanHistory: (apId: number, limit = 5) =>
api.get(`/wireless/${apId}/ap-scan-history`, { params: { limit } }), api.get(`/wireless/${apId}/ap-scan-history`, { params: { limit } }),
// Rogue / neighbor AP detection (classified from stored AP scans)
getRogueAps: () =>
api.get<import('../types').RogueApReport>('/wireless/rf/rogue'),
// RF Health (fleet-wide, or scoped to one AP via deviceId) // RF Health (fleet-wide, or scoped to one AP via deviceId)
getChannelUsage: (deviceId?: number) => getChannelUsage: (deviceId?: number) =>
api.get<import('../types').RfChannelRow[]>('/wireless/rf/channels', { params: { deviceId } }), api.get<import('../types').RfChannelRow[]>('/wireless/rf/channels', { params: { deviceId } }),
+20
View File
@@ -190,6 +190,12 @@ export interface Client {
device_name?: string; device_name?: string;
mac_address: string; mac_address: string;
custom_name?: string; custom_name?: string;
/** Effective device category: user override if set, else the fingerprint. */
device_category?: string;
/** What the fingerprint engine detected (before any user override). */
auto_category?: string;
/** User's manual category override, if any. */
custom_category?: string | null;
hostname?: string; hostname?: string;
vendor?: string; vendor?: string;
ip_address?: string; ip_address?: string;
@@ -364,6 +370,20 @@ export interface WirelessMetricPoint {
noise_floor?: number; noise_floor?: number;
} }
// ─── Rogue AP detection ─────────────────────────────────────────────────────
export interface RogueAp {
ssid: string; bssid: string; vendor: string; signal: number; band: string;
seenBy: string; scannedAt: string;
}
export interface NeighborAp extends RogueAp { security: string }
export interface RogueApReport {
rogues: RogueAp[];
neighbors: NeighborAp[];
lastScanAt: string | null;
scannedDevices: number;
}
// ─── RF Health ────────────────────────────────────────────────────────────── // ─── RF Health ──────────────────────────────────────────────────────────────
export interface RfChannelRow { export interface RfChannelRow {
+24
View File
@@ -0,0 +1,24 @@
import {
Network, Server, Monitor, Smartphone, Tv, Speaker, Camera, Printer,
Gamepad2, Mic, Home, Cpu, HelpCircle, type LucideIcon,
} from 'lucide-react';
// Device categories (must match the backend's DEVICE_CATEGORIES)
export const CATEGORY_META: Record<string, { icon: LucideIcon; label: string }> = {
network: { icon: Network, label: 'Network gear' },
server: { icon: Server, label: 'Server / NAS' },
computer: { icon: Monitor, label: 'Computer' },
phone: { icon: Smartphone, label: 'Phone / tablet' },
tv: { icon: Tv, label: 'TV / streaming' },
media: { icon: Speaker, label: 'AV / speaker' },
camera: { icon: Camera, label: 'Camera' },
printer: { icon: Printer, label: 'Printer' },
'game-console': { icon: Gamepad2, label: 'Game console' },
'voice-assistant': { icon: Mic, label: 'Voice assistant' },
'smart-home': { icon: Home, label: 'Smart home' },
iot: { icon: Cpu, label: 'IoT device' },
unknown: { icon: HelpCircle, label: 'Unknown' },
};
/** Categories a user can pick when overriding (same order as the meta map). */
export const SELECTABLE_CATEGORIES = Object.keys(CATEGORY_META);
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.16.2 Beta'; export const APP_VERSION = 'v0.16.3 Beta';