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/<mac>) 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4e6c9f481e
commit
6cfca387d8
@@ -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.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -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)
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Map<string, { cpu: number; memTotal: number; memUsed: number }>> {
|
||||
const out = new Map<string, { cpu: number; memTotal: number; memUsed: number }>();
|
||||
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<void>((resolve, reject) => {
|
||||
api.queryRows(flux, {
|
||||
next(row, tableMeta) {
|
||||
const o = tableMeta.toObject(row) as Record<string, unknown>;
|
||||
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<void>((resolve, reject) => {
|
||||
api.queryRows(flux, {
|
||||
next(row, tableMeta) {
|
||||
const o = tableMeta.toObject(row) as Record<string, unknown>;
|
||||
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<Sev, number> = { 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<DeviceRow>(`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<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(); 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;
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.16.1-beta",
|
||||
"version": "0.16.2-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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<string | null>(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<string>) {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
@@ -736,26 +776,150 @@ function OperationsView({
|
||||
<div className="card" style={{ padding: '18px 20px' }}>
|
||||
<div className="text-[13px] font-semibold mb-3" style={{ color: 'var(--ink)' }}>Quick actions</div>
|
||||
<div className="grid gap-2" style={{ gridTemplateColumns: '1fr 1fr' }}>
|
||||
{quickActions.map(({ label, sub, icon: Icon, path, color }) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => navigate(path)}
|
||||
className="text-left rounded-[6px] px-[14px] py-[12px] transition-colors"
|
||||
style={{ background: 'var(--surface-2)', border: '1px solid var(--line)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--surface-3)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--surface-2)')}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-[3px]">
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0" style={{ color }} />
|
||||
<span className="text-[12.5px] font-semibold" style={{ color }}>{label}</span>
|
||||
</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--ink-3)' }}>{sub}</div>
|
||||
</button>
|
||||
))}
|
||||
{actions.map(({ key, label, sub, icon: Icon, color, run }) => {
|
||||
const isBusy = busy === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={run}
|
||||
disabled={!!busy}
|
||||
className="text-left rounded-[6px] px-[14px] py-[12px] transition-colors disabled:opacity-60"
|
||||
style={{ background: 'var(--surface-2)', border: '1px solid var(--line)' }}
|
||||
onMouseEnter={(e) => { if (!busy) e.currentTarget.style.background = 'var(--surface-3)'; }}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--surface-2)')}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-[3px]">
|
||||
{isBusy
|
||||
? <RefreshCw className="w-3.5 h-3.5 flex-shrink-0 animate-spin" style={{ color }} />
|
||||
: <Icon className="w-3.5 h-3.5 flex-shrink-0" style={{ color }} />}
|
||||
<span className="text-[12.5px] font-semibold" style={{ color }}>{label}</span>
|
||||
</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--ink-3)' }}>{isBusy ? 'Working…' : sub}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{actionMsg && (
|
||||
<div
|
||||
className="mt-3 flex items-center gap-2 text-[12px] rounded-[6px] px-3 py-2"
|
||||
style={{
|
||||
background: actionMsg.ok ? 'var(--good-bg, rgba(34,197,94,0.1))' : 'var(--bad-bg, rgba(239,68,68,0.1))',
|
||||
color: actionMsg.ok ? 'var(--good)' : 'var(--bad)',
|
||||
}}
|
||||
>
|
||||
{actionMsg.ok ? <CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" /> : <AlertTriangle className="w-3.5 h-3.5 flex-shrink-0" />}
|
||||
{actionMsg.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capacity + Security rollup */}
|
||||
<div className="grid gap-4" style={{ gridTemplateColumns: '1.3fr 1fr' }}>
|
||||
{/* Capacity / health */}
|
||||
<div className="card" style={{ overflow: 'hidden' }}>
|
||||
<div className="flex items-center gap-2 px-5 py-[14px]" style={{ borderBottom: '1px solid var(--line)' }}>
|
||||
<Cpu className="w-4 h-4" style={{ color: 'var(--accent)' }} />
|
||||
<span className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>Capacity & health</span>
|
||||
<span className="mono text-[11px] ml-auto" style={{ color: 'var(--ink-3)' }}>CPU · MEM</span>
|
||||
</div>
|
||||
{sortedCapacity.length === 0 ? (
|
||||
<div className="py-8 text-center text-[13px]" style={{ color: 'var(--ink-3)' }}>No resource data yet.</div>
|
||||
) : (
|
||||
<div className="px-5 py-3 space-y-3">
|
||||
{sortedCapacity.slice(0, 8).map((c) => (
|
||||
<button key={c.id} onClick={() => navigate(`/devices/${c.id}`)}
|
||||
className="w-full text-left flex items-center gap-3 group">
|
||||
<div className="text-[12.5px] font-medium truncate" style={{ color: 'var(--ink)', width: 130 }}>{c.name}</div>
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
{([['CPU', c.cpu], ['MEM', c.mem_pct]] as const).map(([lbl, v]) => (
|
||||
<div key={lbl} className="flex-1 flex items-center gap-1.5">
|
||||
<span className="mono text-[9px]" style={{ color: 'var(--ink-4)', width: 22 }}>{lbl}</span>
|
||||
<div className="flex-1 h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--surface-3)' }}>
|
||||
<div className="h-full rounded-full" style={{ width: `${Math.min(100, v)}%`, background: meterColor(v) }} />
|
||||
</div>
|
||||
<span className="mono text-[10px] num-tab" style={{ color: 'var(--ink-3)', width: 30, textAlign: 'right' }}>{v}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Security posture rollup */}
|
||||
<button
|
||||
onClick={() => navigate('/security')}
|
||||
className="card text-left transition-colors"
|
||||
style={{ overflow: 'hidden', padding: 0 }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.borderColor = 'var(--line-strong, var(--line))')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.borderColor = '')}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-5 py-[14px]" style={{ borderBottom: '1px solid var(--line)' }}>
|
||||
<Shield className="w-4 h-4" style={{ color: 'var(--accent)' }} />
|
||||
<span className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>Security posture</span>
|
||||
<ChevronRight className="w-4 h-4 ml-auto" style={{ color: 'var(--ink-3)' }} />
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
<div className="flex items-end gap-3 mb-3">
|
||||
<span className="text-[34px] font-semibold leading-none num-tab" style={{ color: scoreColor(avgScore) }}>
|
||||
{avgScore ?? '—'}
|
||||
</span>
|
||||
<span className="text-[12px] mb-1" style={{ color: 'var(--ink-3)' }}>
|
||||
avg hardening · {scored.length} scanned
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[12px] mb-3" style={{ color: highFindings > 0 ? 'var(--bad)' : 'var(--ink-3)' }}>
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
{highFindings} high-severity finding{highFindings !== 1 ? 's' : ''}
|
||||
</div>
|
||||
{worstDevices.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{worstDevices.map((d) => (
|
||||
<div key={d.id} className="flex items-center gap-2 text-[12px]">
|
||||
<StatusDot color={scoreColor(d.score)} size={6} />
|
||||
<span className="truncate" style={{ color: 'var(--ink-2)' }}>{d.name}</span>
|
||||
<span className="mono ml-auto num-tab" style={{ color: scoreColor(d.score) }}>{d.score ?? '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Activity feed */}
|
||||
<div className="card" style={{ overflow: 'hidden' }}>
|
||||
<div className="flex items-center gap-2 px-5 py-[14px]" style={{ borderBottom: '1px solid var(--line)' }}>
|
||||
<Clock className="w-4 h-4" style={{ color: 'var(--accent)' }} />
|
||||
<span className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>Recent activity</span>
|
||||
</div>
|
||||
{activity.length === 0 ? (
|
||||
<div className="py-8 text-center text-[13px]" style={{ color: 'var(--ink-3)' }}>No recent activity.</div>
|
||||
) : (
|
||||
<div>
|
||||
{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 (
|
||||
<div key={i} className="flex items-start gap-3 px-5 py-[11px]"
|
||||
style={{ borderBottom: i < activity.length - 1 ? '1px solid var(--line-soft)' : 'none' }}>
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0 mt-[2px]" style={{ color }} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[12.5px] truncate" style={{ color: 'var(--ink)' }}>{a.title}</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--ink-3)' }}>{a.sub}</div>
|
||||
</div>
|
||||
<span className="mono text-[10.5px] flex-shrink-0" style={{ color: 'var(--ink-4)' }}>
|
||||
{formatDistanceToNow(new Date(a.at), { addSuffix: false })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fleet strip */}
|
||||
<div className="card" style={{ padding: '18px 20px' }}>
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
@@ -798,6 +962,47 @@ function OperationsView({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal device picker */}
|
||||
{pickTerminal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
|
||||
onClick={() => setPickTerminal(false)}>
|
||||
<div className="card w-full max-w-sm" style={{ overflow: 'hidden' }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2 px-5 py-[14px]" style={{ borderBottom: '1px solid var(--line)' }}>
|
||||
<Terminal className="w-4 h-4" style={{ color: 'var(--violet)' }} />
|
||||
<span className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>Open terminal — pick a device</span>
|
||||
<button onClick={() => setPickTerminal(false)} className="ml-auto" style={{ color: 'var(--ink-3)' }}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[360px] overflow-y-auto">
|
||||
{[...deviceList].sort((a, b) => (a.status === 'online' ? 0 : 1) - (b.status === 'online' ? 0 : 1)).map((d) => (
|
||||
<button key={d.id} disabled={d.status !== 'online'}
|
||||
onClick={() => { setTerminalDevice({ id: d.id, name: d.name }); setPickTerminal(false); }}
|
||||
className="w-full text-left flex items-center gap-3 px-5 py-[11px] transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
style={{ borderBottom: '1px solid var(--line-soft)' }}
|
||||
onMouseEnter={(e) => { if (d.status === 'online') e.currentTarget.style.background = 'var(--surface-3)'; }}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
|
||||
<StatusDot color={d.status === 'online' ? 'var(--good)' : 'var(--bad)'} size={6} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] font-medium truncate" style={{ color: 'var(--ink)' }}>{d.name}</div>
|
||||
<div className="mono text-[10.5px]" style={{ color: 'var(--ink-3)' }}>{d.ip_address}</div>
|
||||
</div>
|
||||
{d.status !== 'online' && <span className="text-[10px]" style={{ color: 'var(--ink-4)' }}>offline</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{terminalDevice && (
|
||||
<TerminalModal
|
||||
deviceId={terminalDevice.id}
|
||||
deviceName={terminalDevice.name}
|
||||
onClose={() => setTerminalDevice(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<OpsInsights>('/operations/insights'),
|
||||
backupAll: () => api.post<OpsActionResult>('/operations/backup-all', undefined, { timeout: 180_000 }),
|
||||
syncAll: () => api.post<OpsActionResult>('/operations/sync-all', undefined, { timeout: 180_000 }),
|
||||
};
|
||||
|
||||
export const backupsApi = {
|
||||
list: (deviceId?: number) =>
|
||||
api.get<Backup[]>('/backups', { params: deviceId ? { deviceId } : {} }),
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.16.1 Beta';
|
||||
export const APP_VERSION = 'v0.16.2 Beta';
|
||||
|
||||
Reference in New Issue
Block a user