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 ( ); })}
Name Type Destination Actions
{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 ( ); })}
Topics Action Prefix Status Actions
{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 ( ); })}
Name Type Destination Coverage Actions
{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 ( ); })}
Topics Action Prefix Status Coverage Actions
{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}

}
)}
); }