diff --git a/README.md b/README.md index fc4d226..5f4c5b1 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.10.7_Beta-blue) +![Version](https://img.shields.io/badge/version-0.10.8_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) @@ -139,6 +139,7 @@ Each service supports multi-device management with conflict detection: | **DNS** | Upstream servers, static records (A/AAAA/CNAME/MX/NS/PTR/TXT/SRV), cache flush, DoH | | **NTP** | Server (broadcast/manycast), client (unicast/multicast), sync status | | **WireGuard** | Interface management, peer configuration, public key display, RX/TX stats | +| **Syslog** | Logging actions (remote/memory/disk/echo) and routing rules; single-device or push-to-all with per-entry coverage; enable/disable rules | ### Network Topology - Auto-discovered network map using LLDP, CDP, and MNDP neighbor data diff --git a/backend/package.json b/backend/package.json index 1bbdcc8..2f36b6d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.10.7-beta", + "version": "0.10.8-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/routes/networkServices.ts b/backend/src/routes/networkServices.ts index cd799b6..ec93b84 100644 --- a/backend/src/routes/networkServices.ts +++ b/backend/src/routes/networkServices.ts @@ -44,19 +44,21 @@ router.get('/overview', async (_req: Request, res: Response) => { const collector = new DeviceCollector(device); try { await collector.connect(); - const [dhcpV4, dhcpV6, dns, ntp, wg] = await Promise.allSettled([ + const [dhcpV4, dhcpV6, dns, ntp, wg, syslogActions] = await Promise.allSettled([ collector.getDhcpServers('ipv4'), collector.getDhcpServers('ipv6'), collector.getDnsSettings(), collector.getNtpSettings(), collector.getWireGuardInterfaces(), + collector.getSyslogActions(), ]); - const v4Servers = dhcpV4.status === 'fulfilled' ? dhcpV4.value : []; - const v6Servers = dhcpV6.status === 'fulfilled' ? dhcpV6.value : []; - const dnsRow = dns.status === 'fulfilled' ? dns.value : null; - const ntpRow = ntp.status === 'fulfilled' ? ntp.value : null; - const wgIfaces = wg.status === 'fulfilled' ? wg.value : []; + const v4Servers = dhcpV4.status === 'fulfilled' ? dhcpV4.value : []; + const v6Servers = dhcpV6.status === 'fulfilled' ? dhcpV6.value : []; + const dnsRow = dns.status === 'fulfilled' ? dns.value : null; + const ntpRow = ntp.status === 'fulfilled' ? ntp.value : null; + const wgIfaces = wg.status === 'fulfilled' ? wg.value : []; + const syslogActs = syslogActions.status === 'fulfilled' ? syslogActions.value : []; return { id: device.id, name: device.name, ip_address: device.ip_address, @@ -65,10 +67,11 @@ router.get('/overview', async (_req: Request, res: Response) => { dns: dnsRow ? { allow_remote: dnsRow['allow-remote-requests'] === 'yes', servers: dnsRow['servers'] || '' } : null, ntp: ntpRow ? { server_enabled: ntpRow.server['enabled'] === 'yes', client_enabled: ntpRow.client['enabled'] === 'yes' } : null, wireguard: { total: wgIfaces.length, running: wgIfaces.filter(i => i['running'] === 'true').length }, + syslog: { remote_count: syslogActs.filter(a => a['type'] === 'remote').length }, }; } catch (err) { return { id: device.id, name: device.name, ip_address: device.ip_address, - dhcp_v4: null, dhcp_v6: null, dns: null, ntp: null, wireguard: null, + dhcp_v4: null, dhcp_v6: null, dns: null, ntp: null, wireguard: null, syslog: null, error: (err as Error).message }; } finally { collector.disconnect(); @@ -398,4 +401,91 @@ router.delete('/wireguard/peer/:id', requireWrite, async (req: Request, res: Res }); }); +// ─── Syslog ─────────────────────────────────────────────────────────────────── + +// GET /api/network-services/syslog?deviceId=X +router.get('/syslog', async (req: Request, res: Response) => { + const deviceId = deviceIdParam(req, res); if (!deviceId) return; + await withDevice(deviceId, res, async (collector) => { + const [actions, rules] = await Promise.allSettled([ + collector.getSyslogActions(), + collector.getSyslogRules(), + ]); + res.json({ + actions: actions.status === 'fulfilled' ? actions.value : [], + rules: rules.status === 'fulfilled' ? rules.value : [], + }); + }); +}); + +// POST /api/network-services/syslog/action?deviceId=X +router.post('/syslog/action', requireWrite, async (req: Request, res: Response) => { + const deviceId = deviceIdParam(req, res); if (!deviceId) return; + await withDevice(deviceId, res, async (collector) => { + await collector.addSyslogAction(req.body); + const updated = await collector.getSyslogActions(); + res.json(updated); + }); +}); + +// PUT /api/network-services/syslog/action/:id?deviceId=X +router.put('/syslog/action/:id', requireWrite, async (req: Request, res: Response) => { + const deviceId = deviceIdParam(req, res); if (!deviceId) return; + await withDevice(deviceId, res, async (collector) => { + await collector.updateSyslogAction(req.params.id, req.body); + const updated = await collector.getSyslogActions(); + res.json(updated); + }); +}); + +// DELETE /api/network-services/syslog/action/:id?deviceId=X +router.delete('/syslog/action/:id', requireWrite, async (req: Request, res: Response) => { + const deviceId = deviceIdParam(req, res); if (!deviceId) return; + await withDevice(deviceId, res, async (collector) => { + await collector.removeSyslogAction(req.params.id); + res.json({ success: true }); + }); +}); + +// PUT /api/network-services/syslog/rule/toggle?deviceId=X — must be before /:id +router.put('/syslog/rule/toggle', requireWrite, async (req: Request, res: Response) => { + const deviceId = deviceIdParam(req, res); if (!deviceId) return; + const { ruleId, disabled } = req.body; + if (!ruleId) return res.status(400).json({ error: 'ruleId is required' }); + if (typeof disabled !== 'boolean') return res.status(400).json({ error: 'disabled (boolean) is required' }); + await withDevice(deviceId, res, async (collector) => { + await collector.toggleSyslogRule(ruleId, disabled); + res.json({ success: true }); + }); +}); + +// POST /api/network-services/syslog/rule?deviceId=X +router.post('/syslog/rule', requireWrite, async (req: Request, res: Response) => { + const deviceId = deviceIdParam(req, res); if (!deviceId) return; + await withDevice(deviceId, res, async (collector) => { + await collector.addSyslogRule(req.body); + const updated = await collector.getSyslogRules(); + res.json(updated); + }); +}); + +// PUT /api/network-services/syslog/rule/:id?deviceId=X +router.put('/syslog/rule/:id', requireWrite, async (req: Request, res: Response) => { + const deviceId = deviceIdParam(req, res); if (!deviceId) return; + await withDevice(deviceId, res, async (collector) => { + await collector.updateSyslogRule(req.params.id, req.body); + const updated = await collector.getSyslogRules(); + res.json(updated); + }); +}); + +// DELETE /api/network-services/syslog/rule/:id?deviceId=X +router.delete('/syslog/rule/:id', requireWrite, async (req: Request, res: Response) => { + const deviceId = deviceIdParam(req, res); if (!deviceId) return; + await withDevice(deviceId, res, async (collector) => { + await collector.removeSyslogRule(req.params.id); + res.json({ success: true }); + }); +}); + export default router; diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts index 1feef57..d8729fd 100644 --- a/backend/src/services/mikrotik/DeviceCollector.ts +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -2476,6 +2476,44 @@ export class DeviceCollector { } } + // Syslog (logging actions + rules) ─────────────────────────────────────────── + + async getSyslogActions(): Promise[]> { + return this.client.execute('/system/logging/action/print', { detail: '' }).catch(() => [] as Record[]); + } + + async addSyslogAction(params: Record): Promise { + await this.client.execute('/system/logging/action/add', params); + } + + async updateSyslogAction(id: string, params: Record): Promise { + await this.client.execute('/system/logging/action/set', { '.id': id, ...params }); + } + + async removeSyslogAction(id: string): Promise { + await this.client.execute('/system/logging/action/remove', { '.id': id }); + } + + async getSyslogRules(): Promise[]> { + return this.client.execute('/system/logging/print', { detail: '' }).catch(() => [] as Record[]); + } + + async addSyslogRule(params: Record): Promise { + await this.client.execute('/system/logging/add', params); + } + + async updateSyslogRule(id: string, params: Record): Promise { + await this.client.execute('/system/logging/set', { '.id': id, ...params }); + } + + async removeSyslogRule(id: string): Promise { + await this.client.execute('/system/logging/remove', { '.id': id }); + } + + async toggleSyslogRule(id: string, disabled: boolean): Promise { + await this.client.execute('/system/logging/set', { '.id': id, 'disabled': disabled ? 'yes' : 'no' }); + } + // WireGuard ─────────────────────────────────────────────────────────────────── async getWireGuardInterfaces(): Promise[]> { diff --git a/frontend/package.json b/frontend/package.json index a85866a..31e357b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.10.7-beta", + "version": "0.10.8-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index be1dc36..b488027 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ import NetworkServicesDHCPPage from './pages/NetworkServicesDHCPPage'; import NetworkServicesDNSPage from './pages/NetworkServicesDNSPage'; import NetworkServicesNTPPage from './pages/NetworkServicesNTPPage'; import NetworkServicesWireGuardPage from './pages/NetworkServicesWireGuardPage'; +import NetworkServicesSyslogPage from './pages/NetworkServicesSyslogPage'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token); @@ -65,6 +66,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 7e28eaf..f958ca7 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query'; import { LayoutDashboard, Router, Users, Bell, GitBranch, HardDrive, Settings, Network, ChevronLeft, ChevronRight, Layers, ChevronDown, SlidersHorizontal, X, Wifi, - Server, Globe, Clock, Shield, + Server, Globe, Clock, Shield, FileText, } from 'lucide-react'; import clsx from 'clsx'; import { APP_VERSION } from '../../version'; @@ -40,7 +40,8 @@ const networkServicesSubItems = [ { to: '/network-services/dhcp', icon: Server, label: 'DHCP' }, { to: '/network-services/dns', icon: Globe, label: 'DNS' }, { to: '/network-services/ntp', icon: Clock, label: 'NTP' }, - { to: '/network-services/wireguard', icon: Shield, label: 'WireGuard' }, + { to: '/network-services/wireguard', icon: Shield, label: 'WireGuard' }, + { to: '/network-services/syslog', icon: FileText, label: 'Syslog' }, ]; interface SidebarProps { diff --git a/frontend/src/pages/NetworkServicesOverviewPage.tsx b/frontend/src/pages/NetworkServicesOverviewPage.tsx index 311b470..eb6ba96 100644 --- a/frontend/src/pages/NetworkServicesOverviewPage.tsx +++ b/frontend/src/pages/NetworkServicesOverviewPage.tsx @@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { Server, RefreshCw, CheckCircle, XCircle, MinusCircle, - Globe, Clock, Shield, Wifi, + Globe, Clock, Shield, Wifi, FileText, } from 'lucide-react'; import clsx from 'clsx'; import { networkServicesApi } from '../services/api'; @@ -16,6 +16,7 @@ interface DeviceServiceRow { dns: { allow_remote: boolean; servers: string } | null; ntp: { server_enabled: boolean; client_enabled: boolean } | null; wireguard: { total: number; running: number } | null; + syslog: { remote_count: number } | null; error?: string; } @@ -58,7 +59,8 @@ export default function NetworkServicesOverviewPage() { { key: 'dhcp_v6', label: 'DHCP v6', icon: Server, href: '/network-services/dhcp' }, { key: 'dns', label: 'DNS', icon: Globe, href: '/network-services/dns' }, { key: 'ntp', label: 'NTP', icon: Clock, href: '/network-services/ntp' }, - { key: 'wireguard', label: 'WireGuard', icon: Shield, href: '/network-services/wireguard' }, + { key: 'wireguard', label: 'WireGuard', icon: Shield, href: '/network-services/wireguard' }, + { key: 'syslog', label: 'Syslog', icon: FileText, href: '/network-services/syslog' }, ] as const; // KPI: for each service, how many devices have it active? @@ -72,6 +74,7 @@ export default function NetworkServicesOverviewPage() { if (col.key === 'dns') return (svc as { allow_remote: boolean }).allow_remote; if (col.key === 'ntp') return (svc as { server_enabled: boolean }).server_enabled || (svc as { client_enabled: boolean }).client_enabled; if (col.key === 'wireguard') return (svc as { running: number }).running > 0; + if (col.key === 'syslog') return (svc as { remote_count: number }).remote_count > 0; return false; }).length; return { ...col, active, total: data.length }; @@ -101,6 +104,10 @@ export default function NetworkServicesOverviewPage() { if (s.total === 0) return ; return 0} label={`${s.running}/${s.total} up`} />; } + if (key === 'syslog') { + const s = svc as { remote_count: number }; + return 0} label={s.remote_count > 0 ? `${s.remote_count} remote` : 'No remote'} />; + } return ; } diff --git a/frontend/src/pages/NetworkServicesSyslogPage.tsx b/frontend/src/pages/NetworkServicesSyslogPage.tsx new file mode 100644 index 0000000..8c53f73 --- /dev/null +++ b/frontend/src/pages/NetworkServicesSyslogPage.tsx @@ -0,0 +1,1106 @@ +import { useState, useEffect } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + FileText, RefreshCw, AlertTriangle, Plus, Pencil, Trash2, X, Save, + CheckCircle, XCircle, Layers, +} from 'lucide-react'; +import clsx from 'clsx'; +import { networkServicesApi, devicesApi } from '../services/api'; +import { useCanWrite } from '../hooks/useCanWrite'; + +type NS = Record; +type Mode = 'single' | 'all'; + +interface PushResult { name: string; success: boolean; error?: string } +interface DeviceCoverage { deviceId: number; deviceName: string; rowId: string } +interface AggregatedRow { key: string; sample: NS; coverage: DeviceCoverage[] } +interface PendingOp { + type: 'action' | 'rule'; + operation: 'add' | 'update'; + data: NS; + coverage?: DeviceCoverage[]; // only set for updates +} + +const BUILTIN_ACTION_NAMES = ['memory', 'disk', 'echo', 'remote']; + +const SYSLOG_FACILITIES = [ + 'kern', 'user', 'mail', 'daemon', 'auth', 'syslog', 'lpr', 'news', + 'uucp', 'cron', 'local0', 'local1', 'local2', 'local3', 'local4', + 'local5', 'local6', 'local7', +]; + +const SYSLOG_SEVERITIES = [ + 'auto', 'debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency', +]; + +const ACTION_TYPES = ['remote', 'memory', 'disk', 'echo']; + +const COMMON_TOPICS = 'account, caps, bridge, ddns, dhcp, dns, error, firewall, hotspot, info, interface, ipsec, ntp, ospf, ppp, radius, rip, route, script, snmp, system, warning, wireless'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function aggregateActions( + deviceData: { deviceId: number; deviceName: string; actions: NS[] }[] +): AggregatedRow[] { + const map = new Map(); + for (const d of deviceData) { + for (const a of d.actions) { + const key = a['name'] ?? ''; + if (!map.has(key)) map.set(key, { key, sample: a, coverage: [] }); + map.get(key)!.coverage.push({ deviceId: d.deviceId, deviceName: d.deviceName, rowId: a['.id'] ?? '' }); + } + } + return Array.from(map.values()); +} + +function aggregateRules( + deviceData: { deviceId: number; deviceName: string; rules: NS[] }[] +): AggregatedRow[] { + const map = new Map(); + for (const d of deviceData) { + for (const r of d.rules) { + const key = `${r['topics'] ?? ''}|${r['action'] ?? ''}`; + if (!map.has(key)) map.set(key, { key, sample: r, coverage: [] }); + map.get(key)!.coverage.push({ deviceId: d.deviceId, deviceName: d.deviceName, rowId: r['.id'] ?? '' }); + } + } + return Array.from(map.values()); +} + +// ─── Coverage badge ─────────────────────────────────────────────────────────── + +function CoverageBadge({ coverage, total }: { coverage: DeviceCoverage[]; total: number }) { + const n = coverage.length; + const allMatch = n === total; + return ( + c.deviceName).join(', ')} + className={clsx('inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium cursor-default', + allMatch + ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' + : 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400')}> + {n}/{total} devices + + ); +} + +// ─── Toggle ────────────────────────────────────────────────────────────────── + +function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) { + return ( + + ); +} + +// ─── ActionForm Modal ───────────────────────────────────────────────────────── + +interface ActionFormProps { + existing?: NS; + allDevices?: boolean; + onSave: (data: NS) => void; + onClose: () => void; + isPending: boolean; + error?: string; +} + +function ActionForm({ existing, allDevices, onSave, onClose, isPending, error }: ActionFormProps) { + const isBuiltin = existing ? BUILTIN_ACTION_NAMES.includes(existing['name'] ?? '') : false; + + const [name, setName] = useState(existing?.['name'] ?? ''); + const [type, setType] = useState(existing?.['type'] ?? 'remote'); + const [remote, setRemote] = useState(existing?.['remote'] ?? ''); + const [port, setPort] = useState(existing?.['remote-port'] ?? '514'); + const [srcAddr, setSrcAddr] = useState(existing?.['src-address'] ?? ''); + const [facility, setFacility] = useState(existing?.['syslog-facility'] ?? 'daemon'); + const [severity, setSeverity] = useState(existing?.['syslog-severity'] ?? 'auto'); + const [bsd, setBsd] = useState((existing?.['bsd-syslog'] ?? 'no') === 'yes'); + + const canSave = name.trim() !== '' && (type !== 'remote' || remote.trim() !== ''); + + function handleSubmit() { + const data: NS = { name: name.trim(), type }; + if (srcAddr.trim()) data['src-address'] = srcAddr.trim(); + if (type === 'remote') { + data['remote'] = remote.trim(); + data['remote-port'] = port || '514'; + data['syslog-facility'] = facility; + data['syslog-severity'] = severity; + data['bsd-syslog'] = bsd ? 'yes' : 'no'; + } + onSave(data); + } + + return ( +
+
+
+

+ {existing ? 'Edit Logging Action' : allDevices ? 'Add Logging Action — All Devices' : 'Add Logging Action'} +

+ +
+
+
+ + setName(e.target.value)} + placeholder="remote-syslog" disabled={isBuiltin} /> + {isBuiltin &&

Built-in action names cannot be changed.

} +
+
+ + +
+ {type === 'remote' && ( + <> +
+ + setRemote(e.target.value)} + placeholder="192.168.1.100 or syslog.example.com" /> +
+
+
+ + setPort(e.target.value)} + placeholder="514" min="1" max="65535" /> +
+
+ + setSrcAddr(e.target.value)} placeholder="optional" /> +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
BSD Syslog (RFC3164)
+

Use legacy BSD format instead of RFC5424.

+
+
+ + )} + {error &&

{error}

} +
+
+ + +
+
+
+ ); +} + +// ─── RuleForm Modal ─────────────────────────────────────────────────────────── + +interface RuleFormProps { + existing?: NS; + actions: NS[]; + allDevices?: boolean; + onSave: (data: NS) => void; + onClose: () => void; + isPending: boolean; + error?: string; +} + +function RuleForm({ existing, actions, allDevices, onSave, onClose, isPending, error }: RuleFormProps) { + const [topics, setTopics] = useState(existing?.['topics'] ?? ''); + const [action, setAction] = useState(existing?.['action'] ?? (allDevices ? '' : (actions[0]?.['name'] ?? ''))); + const [prefix, setPrefix] = useState(existing?.['prefix'] ?? ''); + const [disabled, setDisabled] = useState((existing?.['disabled'] ?? 'false') === 'true'); + + const canSave = topics.trim() !== '' && action.trim() !== ''; + + function handleSubmit() { + const data: NS = { topics: topics.trim(), action: action.trim() }; + if (prefix.trim()) data['prefix'] = prefix.trim(); + data['disabled'] = disabled ? 'yes' : 'no'; + onSave(data); + } + + return ( +
+
+
+

+ {existing ? 'Edit Logging Rule' : allDevices ? 'Add Logging Rule — All Devices' : 'Add Logging Rule'} +

+ +
+
+
+ + setTopics(e.target.value)} placeholder="info,error,!debug" /> +

+ Comma-separated. Prefix with ! to exclude. Common topics: {COMMON_TOPICS}. +

+
+
+ + {allDevices || actions.length === 0 ? ( + <> + setAction(e.target.value)} placeholder="remote" /> +

+ Enter the action name as it appears on each device (e.g. remote). +

+ + ) : ( + + )} +
+
+ + setPrefix(e.target.value)} placeholder="optional message prefix" /> +
+
+ +
Disabled
+
+ {error &&

{error}

} +
+
+ + +
+
+
+ ); +} + +// ─── Main Page ──────────────────────────────────────────────────────────────── + +export default function NetworkServicesSyslogPage() { + const canWrite = useCanWrite(); + const qc = useQueryClient(); + + const [mode, setMode] = useState('single'); + const [selectedDeviceId, setSelectedDeviceId] = useState(''); + + // Single-device form state (allCoverage set when editing in All Devices mode) + const [actionForm, setActionForm] = useState<{ existing?: NS; allCoverage?: DeviceCoverage[] } | null>(null); + const [ruleForm, setRuleForm] = useState<{ existing?: NS; allCoverage?: DeviceCoverage[] } | null>(null); + const [confirmDelete, setConfirmDelete] = useState<{ type: 'action' | 'rule'; row: NS } | null>(null); + const [deletePending, setDeletePending] = useState(false); + const [deleteError, setDeleteError] = useState(''); + const [togglePending, setTogglePending] = useState(false); + const [savePending, setSavePending] = useState(false); + const [saveError, setSaveError] = useState(''); + + // All-devices state + const [pendingPush, setPendingPush] = useState(null); + const [confirmDeleteAll, setConfirmDeleteAll] = useState<{ type: 'action' | 'rule'; coverage: DeviceCoverage[]; label: string } | null>(null); + const [isPushing, setIsPushing] = useState(false); + const [opResults, setOpResults] = useState<{ label: string; results: PushResult[] } | null>(null); + + const { data: devices = [] } = useQuery({ + queryKey: ['devices'], + queryFn: () => devicesApi.list().then(r => r.data), + staleTime: 60_000, + }); + + const onlineDevices = devices.filter(d => d.status === 'online'); + + const deviceId = typeof selectedDeviceId === 'number' ? selectedDeviceId : 0; + const selectedDevice = devices.find(d => d.id === deviceId); + + // Single-device data + const { data: syslog, isLoading, refetch, isFetching, error } = useQuery({ + queryKey: ['ns-syslog', deviceId], + queryFn: () => networkServicesApi.getSyslog(deviceId).then(r => r.data), + enabled: mode === 'single' && deviceId > 0, + }); + + // All-devices aggregated data + const allDevicesKey = onlineDevices.map(d => d.id).join(','); + const { data: allDevicesSyslog = [], isLoading: allLoading, refetch: allRefetch, isFetching: allFetching } = useQuery({ + queryKey: ['ns-syslog-all', allDevicesKey], + queryFn: async () => { + const results = await Promise.allSettled( + onlineDevices.map(d => networkServicesApi.getSyslog(d.id).then(r => ({ deviceId: d.id, deviceName: d.name, ...r.data }))) + ); + return results + .filter(r => r.status === 'fulfilled') + .map(r => (r as PromiseFulfilledResult<{ deviceId: number; deviceName: string; actions: NS[]; rules: NS[] }>).value); + }, + enabled: mode === 'all' && onlineDevices.length > 0, + }); + + const aggregatedActions = aggregateActions(allDevicesSyslog); + const aggregatedRules = aggregateRules(allDevicesSyslog); + + const singleActions = syslog?.actions ?? []; + const singleRules = syslog?.rules ?? []; + + function invalidateSingle() { + qc.invalidateQueries({ queryKey: ['ns-syslog', deviceId] }); + qc.invalidateQueries({ queryKey: ['network-services-overview'] }); + } + + function invalidateAll() { + qc.invalidateQueries({ queryKey: ['ns-syslog-all'] }); + qc.invalidateQueries({ queryKey: ['network-services-overview'] }); + } + + useEffect(() => { + setActionForm(null); setRuleForm(null); setSaveError(''); + setPendingPush(null); setOpResults(null); + }, [mode]); + + useEffect(() => { + setActionForm(null); setRuleForm(null); setSaveError(''); + }, [deviceId]); + + // ── Single-device handlers ──────────────────────────────────────────────── + + async function handleSaveAction(data: NS) { + setSaveError(''); setSavePending(true); + try { + if (actionForm?.existing) { + await networkServicesApi.updateSyslogAction(deviceId, actionForm.existing['.id'], data); + } else { + await networkServicesApi.addSyslogAction(deviceId, data); + } + setActionForm(null); invalidateSingle(); + } catch (e) { setSaveError((e as Error).message); } + finally { setSavePending(false); } + } + + async function handleSaveRule(data: NS) { + setSaveError(''); setSavePending(true); + try { + if (ruleForm?.existing) { + await networkServicesApi.updateSyslogRule(deviceId, ruleForm.existing['.id'], data); + } else { + await networkServicesApi.addSyslogRule(deviceId, data); + } + setRuleForm(null); invalidateSingle(); + } catch (e) { setSaveError((e as Error).message); } + finally { setSavePending(false); } + } + + async function handleSingleDelete() { + if (!confirmDelete) return; + setDeleteError(''); setDeletePending(true); + try { + if (confirmDelete.type === 'action') { + await networkServicesApi.deleteSyslogAction(deviceId, confirmDelete.row['.id']); + } else { + await networkServicesApi.deleteSyslogRule(deviceId, confirmDelete.row['.id']); + } + setConfirmDelete(null); invalidateSingle(); + } catch (e) { setDeleteError((e as Error).message); } + finally { setDeletePending(false); } + } + + async function handleToggleRule(id: string, disabled: boolean) { + setTogglePending(true); + try { await networkServicesApi.toggleSyslogRule(deviceId, id, disabled); invalidateSingle(); } + finally { setTogglePending(false); } + } + + async function handleToggleRuleAll(coverage: DeviceCoverage[], disabled: boolean) { + setTogglePending(true); + try { + await Promise.allSettled(coverage.map(c => networkServicesApi.toggleSyslogRule(c.deviceId, c.rowId, disabled))); + invalidateAll(); + } finally { setTogglePending(false); } + } + + // ── All-devices handlers ────────────────────────────────────────────────── + + function handleAllDevicesSave(type: 'action' | 'rule', data: NS, coverage?: DeviceCoverage[]) { + setPendingPush({ type, operation: coverage ? 'update' : 'add', data, coverage }); + setActionForm(null); setRuleForm(null); + } + + async function executePush() { + if (!pendingPush) return; + setIsPushing(true); + const { type, operation, data, coverage } = pendingPush; + const noun = type === 'action' ? 'action' : 'rule'; + const label = operation === 'update' + ? `Update ${noun} "${type === 'action' ? data['name'] : data['topics']}"` + : `Add ${noun} "${type === 'action' ? data['name'] : data['topics']}"`; + + let settled: PromiseSettledResult[]; + let targetNames: string[]; + + if (operation === 'update' && coverage) { + settled = await Promise.allSettled( + coverage.map(c => + type === 'action' + ? networkServicesApi.updateSyslogAction(c.deviceId, c.rowId, data) + : networkServicesApi.updateSyslogRule(c.deviceId, c.rowId, data) + ) + ); + targetNames = coverage.map(c => c.deviceName); + } else { + settled = await Promise.allSettled( + onlineDevices.map(d => + type === 'action' + ? networkServicesApi.addSyslogAction(d.id, data) + : networkServicesApi.addSyslogRule(d.id, data) + ) + ); + targetNames = onlineDevices.map(d => d.name); + } + + setIsPushing(false); setPendingPush(null); + setOpResults({ + label, + results: targetNames.map((name, i) => ({ + name, + success: settled[i].status === 'fulfilled', + error: settled[i].status === 'rejected' ? (settled[i] as PromiseRejectedResult).reason?.message : undefined, + })), + }); + invalidateAll(); + } + + async function executeDeleteAll() { + if (!confirmDeleteAll) return; + setIsPushing(true); + const { type, coverage, label } = confirmDeleteAll; + const settled = await Promise.allSettled( + coverage.map(c => + type === 'action' + ? networkServicesApi.deleteSyslogAction(c.deviceId, c.rowId) + : networkServicesApi.deleteSyslogRule(c.deviceId, c.rowId) + ) + ); + setIsPushing(false); setConfirmDeleteAll(null); + setOpResults({ + label: `Remove ${label}`, + results: coverage.map((c, i) => ({ + name: c.deviceName, + success: settled[i].status === 'fulfilled', + error: settled[i].status === 'rejected' ? (settled[i] as PromiseRejectedResult).reason?.message : undefined, + })), + }); + invalidateAll(); + } + + // ── Shared table renderers ──────────────────────────────────────────────── + + function renderActionsTable(rows: NS[], isBuiltinFn: (r: NS) => boolean, onEdit: (r: NS) => void, onDelete: (r: NS) => void) { + return ( +
+ + + + + + + + + + + {rows.map((action, i) => { + const isBuiltin = isBuiltinFn(action); + return ( + + + + + + + ); + })} + +
NameTypeDestinationActions
+ {action['name']}{isBuiltin && (built-in)} + + + {action['type']} + + + {action['type'] === 'remote' ? ( +
+ {action['remote'] || '—'}:{action['remote-port'] || '514'} +
+ {action['syslog-facility'] || 'daemon'} / {action['syslog-severity'] || 'auto'} + {action['bsd-syslog'] === 'yes' && ' · BSD'} +
+
+ ) : '—'} +
+
+ {canWrite && ( + + )} + {canWrite && !isBuiltin && ( + + )} +
+
+
+ ); + } + + function renderRulesTable(rows: NS[], onEdit: (r: NS) => void, onToggle: ((r: NS) => void) | null, onDelete: (r: NS) => void) { + return ( +
+ + + + + + + + + + + + {rows.map((rule, i) => { + const isDisabled = rule['disabled'] === 'true' || rule['disabled'] === 'yes'; + return ( + + + + + + + + ); + })} + +
TopicsActionPrefixStatusActions
{rule['topics'] || '—'}{rule['action'] || '—'}{rule['prefix'] || '—'} + + + {isDisabled ? 'Disabled' : 'Enabled'} + + +
+ {canWrite && ( + <> + + {onToggle && ( + + )} + + + )} +
+
+
+ ); + } + + // ───────────────────────────────────────────────────────────────────────── + + return ( +
+ {/* Header */} +
+
+

Syslog

+

Logging actions and routing rules configuration

+
+ {mode === 'single' && deviceId > 0 && ( + + )} + {mode === 'all' && onlineDevices.length > 0 && ( + + )} +
+ + {/* Mode toggle */} +
+ + +
+ + {/* ═══ SINGLE DEVICE ═══════════════════════════════════════════════════ */} + {mode === 'single' && ( + <> +
+ + + {selectedDevice?.status !== 'online' && deviceId > 0 && ( +
+ This device is currently offline. +
+ )} +
+ + {deviceId === 0 &&
Select a device above.
} + {deviceId > 0 && isLoading &&
Loading…
} + {deviceId > 0 && error && ( +
+ Failed: {(error as Error).message} +
+ )} + + {syslog && ( +
+
+
+ +

+ Logging Actions({singleActions.length}) +

+ {canWrite && ( + + )} +
+ {singleActions.length === 0 + ?
No logging actions found.
+ : renderActionsTable( + singleActions, + r => BUILTIN_ACTION_NAMES.includes(r['name'] ?? ''), + r => setActionForm({ existing: r }), + r => { setConfirmDelete({ type: 'action', row: r }); setDeleteError(''); } + ) + } +
+ +
+
+ +

+ Logging Rules({singleRules.length}) +

+ {canWrite && ( + + )} +
+ {singleRules.length === 0 + ?
No logging rules found.
+ : renderRulesTable( + singleRules, + r => setRuleForm({ existing: r }), + r => handleToggleRule(r['.id'], !(r['disabled'] === 'true' || r['disabled'] === 'yes')), + r => { setConfirmDelete({ type: 'rule', row: r }); setDeleteError(''); } + ) + } +
+
+ )} + + )} + + {/* ═══ ALL DEVICES ═════════════════════════════════════════════════════ */} + {mode === 'all' && ( +
+ {onlineDevices.length === 0 && ( +
No online devices found.
+ )} + + {onlineDevices.length > 0 && allLoading && ( +
Loading from all devices…
+ )} + + {onlineDevices.length > 0 && !allLoading && ( + <> + {/* Logging Actions */} +
+
+ +

+ Logging Actions({aggregatedActions.length} unique) +

+ {canWrite && ( + + )} +
+ {aggregatedActions.length === 0 + ?
No logging actions found across online devices.
+ : ( +
+ + + + + + + + + + + + {aggregatedActions.map(({ key, sample, coverage }, i) => { + const isBuiltin = BUILTIN_ACTION_NAMES.includes(sample['name'] ?? ''); + return ( + + + + + + + + ); + })} + +
NameTypeDestinationCoverageActions
+ {sample['name']}{isBuiltin && (built-in)} + + + {sample['type']} + + + {sample['type'] === 'remote' ? ( +
+ {sample['remote'] || '—'}:{sample['remote-port'] || '514'} +
+ {sample['syslog-facility'] || 'daemon'} / {sample['syslog-severity'] || 'auto'} + {sample['bsd-syslog'] === 'yes' && ' · BSD'} +
+
+ ) : '—'} +
+
+ {canWrite && ( + + )} + {canWrite && !isBuiltin && ( + + )} +
+
+
+ ) + } +
+ + {/* Logging Rules */} +
+
+ +

+ Logging Rules({aggregatedRules.length} unique) +

+ {canWrite && ( + + )} +
+ {aggregatedRules.length === 0 + ?
No logging rules found across online devices.
+ : ( +
+ + + + + + + + + + + + + {aggregatedRules.map(({ key, sample, coverage }, i) => { + const isDisabled = sample['disabled'] === 'true' || sample['disabled'] === 'yes'; + return ( + + + + + + + + + ); + })} + +
TopicsActionPrefixStatusCoverageActions
{sample['topics'] || '—'}{sample['action'] || '—'}{sample['prefix'] || '—'} + + + {isDisabled ? 'Disabled' : 'Enabled'} + + +
+ {canWrite && ( + + )} + {canWrite && ( + + )} + {canWrite && ( + + )} +
+
+
+ ) + } +
+ + {/* Operation results */} + {opResults && ( +
+
+

{opResults.label}

+ + {opResults.results.filter(r => r.success).length}/{opResults.results.length} succeeded + + +
+
+ {opResults.results.map(r => ( +
+ {r.success + ? + : } + {r.name} + {r.error && {r.error}} + {r.success && applied} +
+ ))} +
+
+ )} + + )} +
+ )} + + {/* ── Modals ───────────────────────────────────────────────────────────── */} + + {actionForm && mode === 'single' && ( + { setActionForm(null); setSaveError(''); }} + isPending={savePending} error={saveError} /> + )} + {actionForm && mode === 'all' && ( + handleAllDevicesSave('action', data, actionForm.allCoverage)} + onClose={() => setActionForm(null)} isPending={false} /> + )} + {ruleForm && mode === 'single' && ( + { setRuleForm(null); setSaveError(''); }} + isPending={savePending} error={saveError} /> + )} + {ruleForm && mode === 'all' && ( + handleAllDevicesSave('rule', data, ruleForm.allCoverage)} + onClose={() => setRuleForm(null)} isPending={false} /> + )} + + {/* Confirm push to all */} + {pendingPush && ( +
+
+
+ +
+

+ {pendingPush.operation === 'update' ? 'Update on all affected devices?' : 'Push to all online devices?'} +

+

+ {pendingPush.operation === 'update' ? 'This will update' : 'This will add'} the {pendingPush.type === 'action' ? 'logging action' : 'logging rule'}{' '} + "{pendingPush.type === 'action' ? pendingPush.data['name'] : pendingPush.data['topics']}"{' '} + {pendingPush.operation === 'update' ? 'on' : 'to'}{' '} + + {pendingPush.operation === 'update' && pendingPush.coverage + ? `${pendingPush.coverage.length} device${pendingPush.coverage.length !== 1 ? 's' : ''}` + : `${onlineDevices.length} device${onlineDevices.length !== 1 ? 's' : ''}`} + :{' '} + {pendingPush.operation === 'update' && pendingPush.coverage + ? pendingPush.coverage.map(c => c.deviceName).join(', ') + : onlineDevices.map(d => d.name).join(', ')}. +

+ {pendingPush.operation === 'add' &&

Offline devices are skipped automatically.

} +
+
+
+ + +
+
+
+ )} + + {/* Confirm delete from all */} + {confirmDeleteAll && ( +
+
+
+ +
+

Remove from all devices?

+

+ This will remove the {confirmDeleteAll.label} from{' '} + {confirmDeleteAll.coverage.length} device{confirmDeleteAll.coverage.length !== 1 ? 's' : ''}:{' '} + {confirmDeleteAll.coverage.map(c => c.deviceName).join(', ')}. +

+
+
+
+ + +
+
+
+ )} + + {/* Single-device delete confirm */} + {confirmDelete && ( +
+
+

+ Delete Logging {confirmDelete.type === 'action' ? 'Action' : 'Rule'} +

+

+ {confirmDelete.type === 'action' + ? <>Delete action {confirmDelete.row['name']}? Rules referencing this action will stop working. + : <>Delete rule for topics {confirmDelete.row['topics']}? + } +

+ {deleteError &&

{deleteError}

} +
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 8e51553..d4c807a 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -684,6 +684,24 @@ export const networkServicesApi = { }) => api.put<{ server: NS; client: NS }>('/network-services/ntp', settings, { params: { deviceId } }), + // ── Syslog ──────────────────────────────────────────────────────────────── + getSyslog: (deviceId: number) => + api.get<{ actions: NS[]; rules: NS[] }>('/network-services/syslog', { params: { deviceId } }), + addSyslogAction: (deviceId: number, body: NS) => + api.post('/network-services/syslog/action', body, { params: { deviceId } }), + updateSyslogAction: (deviceId: number, id: string, body: NS) => + api.put(`/network-services/syslog/action/${encodeURIComponent(id)}`, body, { params: { deviceId } }), + deleteSyslogAction: (deviceId: number, id: string) => + api.delete(`/network-services/syslog/action/${encodeURIComponent(id)}`, { params: { deviceId } }), + addSyslogRule: (deviceId: number, body: NS) => + api.post('/network-services/syslog/rule', body, { params: { deviceId } }), + updateSyslogRule: (deviceId: number, id: string, body: NS) => + api.put(`/network-services/syslog/rule/${encodeURIComponent(id)}`, body, { params: { deviceId } }), + deleteSyslogRule: (deviceId: number, id: string) => + api.delete(`/network-services/syslog/rule/${encodeURIComponent(id)}`, { params: { deviceId } }), + toggleSyslogRule: (deviceId: number, ruleId: string, disabled: boolean) => + api.put('/network-services/syslog/rule/toggle', { ruleId, disabled }, { params: { deviceId } }), + // ── WireGuard ───────────────────────────────────────────────────────────── getWireGuard: (deviceId: number) => api.get<{ interfaces: NS[]; peers: NS[] }>('/network-services/wireguard', { params: { deviceId } }), diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 639680d..473a335 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.10.7 Beta'; +export const APP_VERSION = 'v0.10.8 Beta';