From 6cfca387d88a556225bee45bbf05e4a2db9828d3 Mon Sep 17 00:00:00 2001 From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:12:16 -0700 Subject: [PATCH] feat(ops): operations insights engine + working quick actions; fix global search (v0.16.2-beta) Global search: - Clicking/Enter on a client result now opens that client's detail page (/clients/) instead of dumping to the clients list. Operations dashboard (new backend route /api/operations): - GET /insights: server-side attention engine (offline, updates, CPU/memory pressure, missing backups, recent outages, weak WiFi clients, co-channel overlap, high TX retries, error events) + per-device capacity + merged activity feed (config changes, audit actions, alerts; login noise filtered). - POST /backup-all and /sync-all: fleet-wide actions over online devices. - Quick Actions now execute: Run discovery, Backup all, Sync config, and Open terminal (device picker -> live SSH TerminalModal); inline status. - New cards: Capacity & health meters, Security posture rollup (reuses per-device posture, cached), Recent activity feed. Co-Authored-By: Claude Opus 4.8 --- README.md | 8 +- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/index.ts | 2 + backend/src/routes/operations.ts | 348 ++++++++++++++++++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- .../src/components/layout/GlobalSearch.tsx | 2 +- frontend/src/pages/DashboardPage.tsx | 323 +++++++++++++--- frontend/src/services/api.ts | 30 ++ frontend/src/version.ts | 2 +- 11 files changed, 659 insertions(+), 68 deletions(-) create mode 100644 backend/src/routes/operations.ts diff --git a/README.md b/README.md index bc388a7..f457489 100644 --- a/README.md +++ b/README.md @@ -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. -![Version](https://img.shields.io/badge/version-0.16.1_Beta-blue) +![Version](https://img.shields.io/badge/version-0.16.2_Beta-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue) ![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) @@ -100,6 +100,12 @@ A self-hosted, full-stack network management platform for MikroTik devices. Moni - Device type distribution chart - Firmware update notifications with per-device details - Historical client count graph (1h → 30d range) +- **Operations view** — a second dashboard mode focused on running the network: + - **Things to handle** — a server-side insights engine that surfaces actionable issues across the fleet: offline devices, firmware/RouterBOOT updates, CPU/memory pressure, devices missing recent backups, connectivity flapping, and WiFi quality problems (weak-signal clients, co-channel overlap, high TX-retry radios), severity-ordered + - **Quick actions that actually run** — Run discovery, Back up all online devices, Sync config (pull `/export` + re-collect), and Open terminal (device picker → live in-browser SSH terminal), each with inline progress/result + - **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 + - **Recent activity feed** — merged config changes, user actions, and alerts ### Device Management - Add, edit, and delete MikroTik devices (routers, switches, wireless APs) diff --git a/backend/package-lock.json b/backend/package-lock.json index a74572e..d3cb683 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mikrotik-manager-backend", - "version": "0.16.1-beta", + "version": "0.16.2-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mikrotik-manager-backend", - "version": "0.16.1-beta", + "version": "0.16.2-beta", "dependencies": { "@influxdata/influxdb-client": "^1.33.2", "@influxdata/influxdb-client-apis": "^1.35.0", diff --git a/backend/package.json b/backend/package.json index a44ef30..29bb6f2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.16.1-beta", + "version": "0.16.2-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/index.ts b/backend/src/index.ts index 3871580..fe4ffa3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -36,6 +36,7 @@ import devicesRoutes, { setPollerService as setDevicesPoller } from './routes/de import clientsRoutes, { setPollerService as setClientsPoller } from './routes/clients'; import eventsRoutes from './routes/events'; import backupsRoutes from './routes/backups'; +import operationsRoutes from './routes/operations'; import metricsRoutes from './routes/metrics'; import topologyRoutes, { setPollerService as setTopologyPoller } from './routes/topology'; import settingsRoutes from './routes/settings'; @@ -206,6 +207,7 @@ app.use('/api/devices', devicesRoutes); app.use('/api/clients', clientsRoutes); app.use('/api/events', eventsRoutes); app.use('/api/backups', backupsRoutes); +app.use('/api/operations', operationsRoutes); app.use('/api/metrics', metricsRoutes); app.use('/api/topology', topologyRoutes); app.use('/api/settings', settingsRoutes); diff --git a/backend/src/routes/operations.ts b/backend/src/routes/operations.ts new file mode 100644 index 0000000..a2d009d --- /dev/null +++ b/backend/src/routes/operations.ts @@ -0,0 +1,348 @@ +import { Router, Request, Response } from 'express'; +import { query } from '../config/database'; +import { getQueryApi } from '../config/influxdb'; +import { requireAuth, requireWrite } from '../middleware/auth'; +import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector'; +import { BackupService, BackupDevice } from '../services/BackupService'; + +const router = Router(); +router.use(requireAuth); + +const backupService = new BackupService(); + +const bucket = process.env.INFLUXDB_BUCKET || 'mikrotik'; + +type Sev = 'error' | 'warn' | 'info'; +interface AttentionItem { + sev: Sev; category: string; title: string; body: string; action: string; path: string; +} + +// Latest value per device for a set of device_resources fields. +async function latestResources(): Promise> { + const out = new Map(); + const flux = ` + from(bucket: "${bucket}") + |> range(start: -15m) + |> filter(fn: (r) => r["_measurement"] == "device_resources") + |> filter(fn: (r) => r["_field"] == "cpu_load" or r["_field"] == "memory_total" or r["_field"] == "memory_used") + |> group(columns: ["device_id", "_field"]) + |> last() + `; + try { + const api = getQueryApi(); + await new Promise((resolve, reject) => { + api.queryRows(flux, { + next(row, tableMeta) { + const o = tableMeta.toObject(row) as Record; + const id = String(o['device_id'] || ''); + const field = String(o['_field'] || ''); + const val = Number(o['_value']) || 0; + const e = out.get(id) || { cpu: 0, memTotal: 0, memUsed: 0 }; + if (field === 'cpu_load') e.cpu = val; + else if (field === 'memory_total') e.memTotal = val; + else if (field === 'memory_used') e.memUsed = val; + out.set(id, e); + }, + error: reject, + complete: resolve, + }); + }); + } catch { /* influx unavailable → empty */ } + return out; +} + +// Latest TX-retry % per radio (from the RF Health series). +async function latestRetries(): Promise<{ device_id: string; device_name: string; interface: string; pct: number }[]> { + const rows: { device_id: string; device_name: string; interface: string; pct: number }[] = []; + const flux = ` + from(bucket: "${bucket}") + |> range(start: -1h) + |> filter(fn: (r) => r["_measurement"] == "wireless_radio_quality") + |> filter(fn: (r) => r["_field"] == "tx_retry_pct") + |> group(columns: ["device_id", "device_name", "interface"]) + |> last() + `; + try { + const api = getQueryApi(); + await new Promise((resolve, reject) => { + api.queryRows(flux, { + next(row, tableMeta) { + const o = tableMeta.toObject(row) as Record; + rows.push({ + device_id: String(o['device_id'] || ''), + device_name: String(o['device_name'] || ''), + interface: String(o['interface'] || ''), + pct: Number(o['_value']) || 0, + }); + }, + error: reject, + complete: resolve, + }); + }); + } catch { /* ignore */ } + return rows; +} + +// GET /api/operations/insights — aggregated operational intelligence (no live +// device connections; all from Postgres + InfluxDB so it's fast). +router.get('/insights', async (_req: Request, res: Response) => { + const [ + devices, missingBackup, recentOutages, weakWifi, coChannel, eventCounts, + ] = await Promise.all([ + query<{ + id: number; name: string; status: string; last_seen: string | null; + device_type: string; ros_version: string | null; + firmware_update_available: boolean; latest_ros_version: string | null; + routerboard_upgrade_available: boolean; firmware_version: string | null; + upgrade_firmware_version: string | null; + }>(`SELECT id, name, status, last_seen, device_type, ros_version, + firmware_update_available, latest_ros_version, + routerboard_upgrade_available, firmware_version, upgrade_firmware_version + FROM devices`), + query<{ id: number; name: string; last_backup: string | null }>(` + SELECT d.id, d.name, MAX(b.created_at) AS last_backup + FROM devices d LEFT JOIN backups b ON b.device_id = d.id + GROUP BY d.id + HAVING MAX(b.created_at) IS NULL OR MAX(b.created_at) < NOW() - INTERVAL '7 days'`), + query<{ device_id: number; name: string; outages: string }>(` + SELECT a.device_id, d.name, COUNT(*) AS outages + FROM device_availability a JOIN devices d ON d.id = a.device_id + WHERE a.went_offline_at > NOW() - INTERVAL '7 days' + GROUP BY a.device_id, d.name`), + query<{ count: string }>(` + SELECT COUNT(*) AS count FROM clients + WHERE active = TRUE AND client_type = 'wireless' + AND signal_strength IS NOT NULL AND signal_strength < -75`), + query<{ frequency: number; radios: string }>(` + SELECT frequency, COUNT(*) AS radios FROM wireless_interfaces + WHERE disabled = FALSE AND frequency IS NOT NULL AND frequency > 0 + AND (config_json->>'master-interface') IS NULL + GROUP BY frequency HAVING COUNT(*) > 1`), + query<{ errors: string; warnings: string }>(` + SELECT COUNT(*) FILTER (WHERE severity = 'error') AS errors, + COUNT(*) FILTER (WHERE severity = 'warning') AS warnings + FROM events WHERE event_time > NOW() - INTERVAL '24 hours'`), + ]); + + const [resources, retries] = await Promise.all([latestResources(), latestRetries()]); + + const attention: AttentionItem[] = []; + + for (const d of devices) { + if (d.status === 'offline') { + attention.push({ + sev: 'error', category: 'availability', + title: `${d.name} is unreachable`, + body: `Last seen ${d.last_seen ? timeAgo(d.last_seen) : 'never'}.`, + action: 'View device', path: `/devices/${d.id}`, + }); + } + } + for (const d of devices) { + if (d.firmware_update_available) { + attention.push({ + sev: 'warn', category: 'updates', + title: `RouterOS update available for ${d.name}`, + body: `Running ${d.ros_version}${d.latest_ros_version ? `, ${d.latest_ros_version} available` : ''}.`, + action: 'Review update', path: `/devices/${d.id}?tab=config`, + }); + } + if (d.routerboard_upgrade_available) { + attention.push({ + sev: 'info', category: 'updates', + title: `RouterBOOT upgrade available for ${d.name}`, + body: `Firmware ${d.firmware_version}${d.upgrade_firmware_version ? ` → ${d.upgrade_firmware_version}` : ''}.`, + action: 'Review upgrade', path: `/devices/${d.id}?tab=config`, + }); + } + } + + // Capacity pressure (CPU / memory) + const capacity: { id: number; name: string; cpu: number; mem_pct: number }[] = []; + for (const d of devices) { + const r = resources.get(String(d.id)); + if (!r) continue; + const memPct = r.memTotal > 0 ? Math.round((r.memUsed / r.memTotal) * 100) : 0; + capacity.push({ id: d.id, name: d.name, cpu: Math.round(r.cpu), mem_pct: memPct }); + if (r.cpu >= 85) { + attention.push({ + sev: 'warn', category: 'capacity', + title: `${d.name} CPU is high (${Math.round(r.cpu)}%)`, + body: 'Sustained high CPU can drop management responsiveness and routing throughput.', + action: 'View device', path: `/devices/${d.id}`, + }); + } + if (memPct >= 90) { + attention.push({ + sev: 'warn', category: 'capacity', + title: `${d.name} memory is high (${memPct}%)`, + body: 'Low free memory risks instability; check for leaks or oversized tables.', + action: 'View device', path: `/devices/${d.id}`, + }); + } + } + + // Backups + for (const b of missingBackup) { + attention.push({ + sev: 'warn', category: 'backups', + title: `${b.name} has no recent backup`, + body: b.last_backup ? `Last backup ${timeAgo(b.last_backup)}.` : 'No backup has ever been taken.', + action: 'Back up', path: '/backups', + }); + } + + // Reliability — recent outages (device still online now, but flapped) + for (const o of recentOutages) { + const dev = devices.find(d => d.id === o.device_id); + if (dev && dev.status === 'offline') continue; // already covered by the offline item + attention.push({ + sev: 'info', category: 'reliability', + title: `${o.name} had ${o.outages} outage${Number(o.outages) !== 1 ? 's' : ''} this week`, + body: 'Recent connectivity flapping — worth checking power, uplink, or PoE.', + action: 'View device', path: `/devices/${o.device_id}`, + }); + } + + // WiFi — weak clients + const weakCount = parseInt(weakWifi[0]?.count || '0', 10); + if (weakCount > 0) { + attention.push({ + sev: 'warn', category: 'wifi', + title: `${weakCount} wireless client${weakCount !== 1 ? 's' : ''} on weak signal`, + body: 'Clients connected below −75 dBm — likely coverage gaps or far-roaming devices.', + action: 'Open Wireless', path: '/wireless', + }); + } + + // WiFi — co-channel overlap + if (coChannel.length > 0) { + const chans = coChannel.map(c => freqToChannel(c.frequency)).filter(Boolean); + attention.push({ + sev: 'warn', category: 'wifi', + title: `Co-channel overlap on ${coChannel.length} channel${coChannel.length !== 1 ? 's' : ''}`, + body: `Multiple radios share channel${chans.length ? ` ${chans.join(', ')}` : ''}. Overlapping cells reduce airtime — re-plan channels.`, + action: 'Open Wireless', path: '/wireless', + }); + } + + // WiFi — high TX retries + const badRetries = retries.filter(r => r.pct >= 15); + for (const r of badRetries) { + attention.push({ + sev: 'warn', category: 'wifi', + title: `High TX retries on ${r.device_name} (${r.interface})`, + body: `${r.pct.toFixed(0)}% transmit retries — interference or weak clients on this radio.`, + action: 'Open Wireless', path: '/wireless', + }); + } + + // Recent error events + const errCount = parseInt(eventCounts[0]?.errors || '0', 10); + if (errCount > 0) { + attention.push({ + sev: 'info', category: 'events', + title: `${errCount} error-level event${errCount !== 1 ? 's' : ''} in the last 24h`, + body: 'Recent error logs across the fleet may need a look.', + action: 'Open Events', path: '/events', + }); + } + + // Severity ordering: error → warn → info + const sevRank: Record = { error: 0, warn: 1, info: 2 }; + attention.sort((a, b) => sevRank[a.sev] - sevRank[b.sev]); + + // Activity feed — recent config changes, user actions, and notable events + const activity = await buildActivity(); + + res.json({ attention, capacity, activity }); +}); + +// Recent activity merged from config snapshots, audit log, and events. +async function buildActivity() { + const [configs, audits, events] = await Promise.all([ + query<{ at: string; name: string; summary: string | null }>(` + SELECT dc.collected_at AS at, d.name, dc.change_summary AS summary + FROM device_configs dc JOIN devices d ON d.id = dc.device_id + WHERE dc.change_summary IS NOT NULL AND dc.change_summary != '' + ORDER BY dc.collected_at DESC LIMIT 8`), + query<{ at: string; username: string | null; method: string; summary: string | null }>(` + SELECT created_at AS at, username, method, summary + FROM audit_log + WHERE method <> 'GET' AND status_code < 400 + AND path NOT ILIKE '%login%' AND path NOT ILIKE '%logout%' AND path NOT ILIKE '%/auth/%' + ORDER BY created_at DESC LIMIT 8`), + query<{ at: string; severity: string; message: string; name: string | null }>(` + SELECT e.event_time AS at, e.severity, e.message, d.name + FROM events e LEFT JOIN devices d ON d.id = e.device_id + WHERE e.severity IN ('error', 'warning') + ORDER BY e.event_time DESC LIMIT 8`), + ]); + + type Act = { at: string; kind: 'config' | 'audit' | 'alert'; sev?: string; title: string; sub: string }; + const items: Act[] = []; + for (const c of configs) items.push({ at: c.at, kind: 'config', title: c.summary || 'Configuration changed', sub: c.name }); + for (const a of audits) items.push({ at: a.at, kind: 'audit', title: a.summary || `${a.method} request`, sub: a.username || 'system' }); + for (const e of events) items.push({ at: e.at, kind: 'alert', sev: e.severity, title: e.message, sub: e.name || 'fleet' }); + items.sort((a, b) => new Date(b.at).getTime() - new Date(a.at).getTime()); + return items.slice(0, 14); +} + +// POST /api/operations/backup-all — back up every online device (parallel, best-effort) +router.post('/backup-all', requireWrite, async (_req: Request, res: Response) => { + const devices = await query(`SELECT * FROM devices WHERE status = 'online'`); + const settled = await Promise.allSettled(devices.map(d => { + const dev: BackupDevice = { + id: d.id, name: d.name, ip_address: d.ip_address, + ssh_port: d.ssh_port ?? 22, ssh_username: d.ssh_username, + ssh_password_encrypted: d.ssh_password_encrypted, + api_username: d.api_username, api_password_encrypted: d.api_password_encrypted, + }; + return backupService.createBackup(dev, 'Operations: backup all'); + })); + res.json({ + total: devices.length, + results: settled.map((s, i) => ({ + name: devices[i].name, + ok: s.status === 'fulfilled', + error: s.status === 'rejected' ? (s.reason as Error)?.message : undefined, + })), + }); +}); + +// POST /api/operations/sync-all — pull latest config/state from every online device +router.post('/sync-all', requireWrite, async (_req: Request, res: Response) => { + const devices = await query(`SELECT * FROM devices WHERE status = 'online'`); + const settled = await Promise.allSettled(devices.map(async (d) => { + const c = new DeviceCollector(d); + try { await c.connect(); await c.collectAll(); } + finally { c.disconnect(); } + })); + res.json({ + total: devices.length, + results: settled.map((s, i) => ({ + name: devices[i].name, + ok: s.status === 'fulfilled', + error: s.status === 'rejected' ? (s.reason as Error)?.message : undefined, + })), + }); +}); + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function timeAgo(ts: string): string { + const diff = Date.now() - new Date(ts).getTime(); + if (diff < 60_000) return 'just now'; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + return `${Math.floor(diff / 86_400_000)}d ago`; +} + +function freqToChannel(freq: number): number | null { + if (freq >= 2400 && freq < 2500) return freq === 2484 ? 14 : Math.round((freq - 2407) / 5); + if (freq >= 4900 && freq < 5925) return Math.round((freq - 5000) / 5); + if (freq >= 5925 && freq <= 7125) return Math.round((freq - 5950) / 5); + return null; +} + +export default router; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 22d54f5..a6842f2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mikrotik-manager-frontend", - "version": "0.16.1-beta", + "version": "0.16.2-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mikrotik-manager-frontend", - "version": "0.16.1-beta", + "version": "0.16.2-beta", "dependencies": { "@tanstack/react-query": "^5.101.0", "@types/leaflet": "^1.9.21", diff --git a/frontend/package.json b/frontend/package.json index aeec0b5..30ac35a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.16.1-beta", + "version": "0.16.2-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/components/layout/GlobalSearch.tsx b/frontend/src/components/layout/GlobalSearch.tsx index 6add290..60fd6ca 100644 --- a/frontend/src/components/layout/GlobalSearch.tsx +++ b/frontend/src/components/layout/GlobalSearch.tsx @@ -89,7 +89,7 @@ export default function GlobalSearch() { setQuery(''); setResults(null); if (kind === 'device') navigate(`/devices/${id}`); - else if (kind === 'client') navigate('/clients'); + else if (kind === 'client') navigate(`/clients/${encodeURIComponent(String(id))}`); else navigate('/events'); }, [navigate]); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 2b64a38..5aa62f6 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -7,10 +7,13 @@ import { import { Users, AlertTriangle, Wifi, MapPin, ArrowUpCircle, Cpu, X, RefreshCw, Terminal, HardDrive, GitBranch, Activity, + Shield, Clock, FileText, Bell, ChevronRight, CheckCircle2, } from 'lucide-react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; -import { metricsApi, eventsApi, devicesApi, clientsApi, trafficApi } from '../services/api'; +import { metricsApi, eventsApi, devicesApi, clientsApi, trafficApi, operationsApi, topologyApi } from '../services/api'; +import type { OpsAttentionItem, OpsCapacityRow, OpsActivityItem } from '../services/api'; +import TerminalModal from '../components/TerminalModal'; import { useSocket } from '../hooks/useSocket'; import { useQueryClient } from '@tanstack/react-query'; import { format, formatDistanceToNow } from 'date-fns'; @@ -588,57 +591,94 @@ function OperationsView({ // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any; }) { + const qc = useQueryClient(); const allOnline = (summary?.devices.offline ?? 0) === 0 && (summary?.devices.total ?? 0) > 0; const statusText = allOnline ? "Everything's running." : `${summary?.devices.offline} device${summary?.devices.offline !== 1 ? 's' : ''} unreachable.`; const statusColor = allOnline ? 'var(--accent)' : 'var(--warn)'; - const thingsToHandle = useMemo(() => { - const items: { sev: 'error' | 'warn' | 'info'; title: string; body: string; action: string; path: string }[] = []; + const deviceList = devices as Device[]; + const onlineDevices = deviceList.filter(d => d.status === 'online'); - for (const d of devices as Device[]) { - if (d.status === 'offline') { - items.push({ - sev: 'error', - title: `${d.name} is unreachable`, - body: `Last seen ${d.last_seen ? formatDistanceToNow(new Date(d.last_seen), { addSuffix: true }) : 'never'}.`, - action: 'View device', - path: `/devices/${d.id}`, - }); - } - } - for (const d of devices as Device[]) { - if (d.firmware_update_available) { - items.push({ - sev: 'warn', - title: `RouterOS update available for ${d.name}`, - body: `Running ${d.ros_version}${d.latest_ros_version ? `, ${d.latest_ros_version} available` : ''}.`, - action: 'Review update', - path: `/devices/${d.id}?tab=config`, - }); - } - if (d.routerboard_upgrade_available) { - items.push({ - sev: 'info', - title: `RouterBOOT upgrade available for ${d.name}`, - body: `Firmware ${d.firmware_version}${d.upgrade_firmware_version ? ` → ${d.upgrade_firmware_version}` : ''}.`, - action: 'Review upgrade', - path: `/devices/${d.id}?tab=config`, - }); - } - } - return items; - }, [devices]); + // Server-aggregated operational insights (attention, capacity, activity) + const { data: insights } = useQuery({ + queryKey: ['ops-insights'], + queryFn: () => operationsApi.insights().then(r => r.data), + refetchInterval: 60_000, + }); + const thingsToHandle: OpsAttentionItem[] = insights?.attention ?? []; + const capacity: OpsCapacityRow[] = insights?.capacity ?? []; + const activity: OpsActivityItem[] = insights?.activity ?? []; - const quickActions = [ - { label: 'Run discovery', sub: 'Scan ARP / CDP / MNDP', icon: Wifi, path: '/devices', color: 'var(--accent)' }, - { label: 'Backup all', sub: `${(devices as Device[]).length} devices`, icon: HardDrive, path: '/backups', color: 'var(--info)' }, - { label: 'Open terminal', sub: 'Pick a device', icon: Terminal, path: '/devices', color: 'var(--violet)' }, - { label: 'Sync config', sub: 'Pull latest /export', icon: RefreshCw, path: '/devices', color: 'var(--ink-2)' }, - { label: 'Tail events', sub: 'Live syslog stream', icon: Activity, path: '/events', color: 'var(--ink-2)' }, - { label: 'Open topology', sub: 'Visualize links', icon: GitBranch, path: '/topology',color: 'var(--ink-2)' }, + // Fleet security posture rollup (live per-device; cached 5 min, lazy) + const { data: posture } = useQuery({ + queryKey: ['ops-security', onlineDevices.map(d => d.id).join(',')], + enabled: onlineDevices.length > 0, + staleTime: 5 * 60_000, + refetchOnWindowFocus: false, + queryFn: async () => { + const settled = await Promise.allSettled( + onlineDevices.map(d => + devicesApi.getSecurityPosture(d.id).then(r => ({ id: d.id, name: d.name, score: r.data.score, checks: r.data.checks })) + ) + ); + return settled.flatMap(s => s.status === 'fulfilled' ? [s.value] : []); + }, + }); + + // Action state + const [busy, setBusy] = useState(null); + const [actionMsg, setActionMsg] = useState<{ text: string; ok: boolean } | null>(null); + const [pickTerminal, setPickTerminal] = useState(false); + const [terminalDevice, setTerminalDevice] = useState<{ id: number; name: string } | null>(null); + + async function runAction(key: string, fn: () => Promise) { + setBusy(key); setActionMsg(null); + try { setActionMsg({ text: await fn(), ok: true }); } + catch (e) { + const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error || (e as Error).message || 'Action failed'; + setActionMsg({ text: msg, ok: false }); + } finally { setBusy(null); } + } + + const actions = [ + { key: 'discovery', label: 'Run discovery', sub: 'Scan ARP / CDP / MNDP', icon: Wifi, color: 'var(--accent)', + run: () => runAction('discovery', async () => { + const r = await topologyApi.discover(); + return (r.data as { message?: string })?.message || 'Discovery triggered'; + }) }, + { key: 'backup', label: 'Backup all', sub: `${onlineDevices.length} online`, icon: HardDrive, color: 'var(--info)', + run: () => runAction('backup', async () => { + const r = await operationsApi.backupAll(); + const ok = r.data.results.filter(x => x.ok).length; + return `Backed up ${ok}/${r.data.total} device${r.data.total !== 1 ? 's' : ''}`; + }) }, + { key: 'terminal', label: 'Open terminal', sub: 'Pick a device', icon: Terminal, color: 'var(--violet)', + run: () => { setActionMsg(null); setPickTerminal(true); } }, + { key: 'sync', label: 'Sync config', sub: 'Pull latest /export', icon: RefreshCw, color: 'var(--ink-2)', + run: () => runAction('sync', async () => { + const r = await operationsApi.syncAll(); + const ok = r.data.results.filter(x => x.ok).length; + qc.invalidateQueries({ queryKey: ['ops-insights'] }); + return `Synced ${ok}/${r.data.total} device${r.data.total !== 1 ? 's' : ''}`; + }) }, + { key: 'events', label: 'Tail events', sub: 'Live syslog stream', icon: Activity, color: 'var(--ink-2)', + run: () => navigate('/events') }, + { key: 'topology', label: 'Open topology', sub: 'Visualize links', icon: GitBranch, color: 'var(--ink-2)', + run: () => navigate('/topology') }, ]; + // Security rollup derived values + const scored = (posture ?? []).filter(p => p.score !== null); + const avgScore = scored.length ? Math.round(scored.reduce((s, p) => s + (p.score ?? 0), 0) / scored.length) : null; + const highFindings = (posture ?? []).reduce((s, p) => s + p.checks.filter(c => c.severity === 'high').length, 0); + const worstDevices = [...scored].sort((a, b) => (a.score ?? 0) - (b.score ?? 0)).slice(0, 3); + const scoreColor = (s: number | null) => s == null ? 'var(--ink-3)' : s >= 85 ? 'var(--good)' : s >= 60 ? 'var(--warn)' : 'var(--bad)'; + + const sortedCapacity = [...capacity].sort((a, b) => Math.max(b.cpu, b.mem_pct) - Math.max(a.cpu, a.mem_pct)); + const meterColor = (v: number) => v >= 90 ? 'var(--bad)' : v >= 75 ? 'var(--warn)' : 'var(--accent)'; + const sevColor = (s: string) => s === 'error' ? 'var(--bad)' : s === 'warn' ? 'var(--warn)' : 'var(--info)'; + const activityIcon = (kind: string) => kind === 'config' ? FileText : kind === 'alert' ? Bell : Activity; return (
@@ -736,26 +776,150 @@ function OperationsView({
Quick actions
- {quickActions.map(({ label, sub, icon: Icon, path, color }) => ( - - ))} + {actions.map(({ key, label, sub, icon: Icon, color, run }) => { + const isBusy = busy === key; + return ( + + ); + })}
+ {actionMsg && ( +
+ {actionMsg.ok ? : } + {actionMsg.text} +
+ )}
+ {/* Capacity + Security rollup */} +
+ {/* Capacity / health */} +
+
+ + Capacity & health + CPU · MEM +
+ {sortedCapacity.length === 0 ? ( +
No resource data yet.
+ ) : ( +
+ {sortedCapacity.slice(0, 8).map((c) => ( + + ))} +
+ )} +
+ + {/* Security posture rollup */} + +
+ + {/* Activity feed */} +
+
+ + Recent activity +
+ {activity.length === 0 ? ( +
No recent activity.
+ ) : ( +
+ {activity.map((a, i) => { + const Icon = activityIcon(a.kind); + const color = a.kind === 'alert' ? sevColor(a.sev === 'error' ? 'error' : 'warn') : a.kind === 'config' ? 'var(--info)' : 'var(--ink-3)'; + return ( +
+ +
+
{a.title}
+
{a.sub}
+
+ + {formatDistanceToNow(new Date(a.at), { addSuffix: false })} + +
+ ); + })} +
+ )} +
+ {/* Fleet strip */}
@@ -798,6 +962,47 @@ function OperationsView({ })}
+ + {/* Terminal device picker */} + {pickTerminal && ( +
setPickTerminal(false)}> +
e.stopPropagation()}> +
+ + Open terminal — pick a device + +
+
+ {[...deviceList].sort((a, b) => (a.status === 'online' ? 0 : 1) - (b.status === 'online' ? 0 : 1)).map((d) => ( + + ))} +
+
+
+ )} + + {terminalDevice && ( + setTerminalDevice(null)} + /> + )} ); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 8dc3d35..2b66011 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -413,6 +413,36 @@ export const eventsApi = { }; // ─── Backups ────────────────────────────────────────────────────────────────── +// ─── Operations ──────────────────────────────────────────────────────────────── +export interface OpsAttentionItem { + sev: 'error' | 'warn' | 'info'; + category: string; + title: string; + body: string; + action: string; + path: string; +} +export interface OpsCapacityRow { id: number; name: string; cpu: number; mem_pct: number } +export interface OpsActivityItem { + at: string; + kind: 'config' | 'audit' | 'alert'; + sev?: string; + title: string; + sub: string; +} +export interface OpsInsights { + attention: OpsAttentionItem[]; + capacity: OpsCapacityRow[]; + activity: OpsActivityItem[]; +} +export interface OpsActionResult { total: number; results: { name: string; ok: boolean; error?: string }[] } + +export const operationsApi = { + insights: () => api.get('/operations/insights'), + backupAll: () => api.post('/operations/backup-all', undefined, { timeout: 180_000 }), + syncAll: () => api.post('/operations/sync-all', undefined, { timeout: 180_000 }), +}; + export const backupsApi = { list: (deviceId?: number) => api.get('/backups', { params: deviceId ? { deviceId } : {} }), diff --git a/frontend/src/version.ts b/frontend/src/version.ts index c599a9d..3be8795 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.16.1 Beta'; +export const APP_VERSION = 'v0.16.2 Beta';