diff --git a/README.md b/README.md index d4b8d98..7d00e2e 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.15.1_Beta-blue) +![Version](https://img.shields.io/badge/version-0.16.0_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) diff --git a/backend/package-lock.json b/backend/package-lock.json index 76c7f40..311726d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mikrotik-manager-backend", - "version": "0.15.1-beta", + "version": "0.16.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mikrotik-manager-backend", - "version": "0.15.1-beta", + "version": "0.16.0-beta", "dependencies": { "@influxdata/influxdb-client": "^1.33.2", "@influxdata/influxdb-client-apis": "^1.35.0", diff --git a/backend/package.json b/backend/package.json index 6468515..f1a995f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.15.1-beta", + "version": "0.16.0-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1e5189f..9816143 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mikrotik-manager-frontend", - "version": "0.15.1-beta", + "version": "0.16.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mikrotik-manager-frontend", - "version": "0.15.1-beta", + "version": "0.16.0-beta", "dependencies": { "@tanstack/react-query": "^5.101.0", "@types/leaflet": "^1.9.21", diff --git a/frontend/package.json b/frontend/package.json index cc4e762..1d74110 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.15.1-beta", + "version": "0.16.0-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/pages/ClientsPage.tsx b/frontend/src/pages/ClientsPage.tsx index 1e111cd..da0e9dd 100644 --- a/frontend/src/pages/ClientsPage.tsx +++ b/frontend/src/pages/ClientsPage.tsx @@ -1,7 +1,7 @@ import { useState, useRef, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; -import { Search, Wifi, Network, Users, X, Pencil, Trash2, ChevronUp, ChevronDown, ChevronsUpDown, RefreshCw } from 'lucide-react'; +import { Search, Wifi, Network, Users, X, Pencil, Trash2, ChevronUp, ChevronDown, ChevronsUpDown, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react'; import { clientsApi } from '../services/api'; import type { Client } from '../types'; import { useCanWrite } from '../hooks/useCanWrite'; @@ -19,6 +19,33 @@ const REFRESH_OPTIONS = [ const STORAGE_KEY = 'clients-refresh-interval'; +const PAGE_SIZE_OPTIONS = [25, 50, 100, 200] as const; +const PAGE_SIZE_KEY = 'clients-page-size'; + +function readStoredPageSize(): number { + const n = Number(localStorage.getItem(PAGE_SIZE_KEY)); + return (PAGE_SIZE_OPTIONS as readonly number[]).includes(n) ? n : 50; +} + +// Build a compact list of page numbers (1-based) with ellipses, always keeping +// the first, last, current page and its immediate neighbours visible. +function getPageList(current0: number, totalPages: number): (number | 'ellipsis')[] { + const cur = current0 + 1; + const wanted = new Set([1, totalPages]); + for (let i = cur - 1; i <= cur + 1; i++) { + if (i >= 1 && i <= totalPages) wanted.add(i); + } + const sorted = Array.from(wanted).sort((a, b) => a - b); + const out: (number | 'ellipsis')[] = []; + let prev = 0; + for (const p of sorted) { + if (prev && p - prev > 1) out.push('ellipsis'); + out.push(p); + prev = p; + } + return out; +} + function formatDataBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; @@ -139,7 +166,7 @@ export default function ClientsPage() { const [refreshInterval, setRefreshInterval] = useState(readStoredInterval); const refreshIntervalRef = useRef(refreshInterval); useEffect(() => { refreshIntervalRef.current = refreshInterval; }, [refreshInterval]); - const PAGE_SIZE = 50; + const [pageSize, setPageSize] = useState(readStoredPageSize); const handleIntervalChange = (val: number | null) => { setRefreshInterval(val); @@ -147,6 +174,12 @@ export default function ClientsPage() { localStorage.setItem(STORAGE_KEY, String(val)); }; + const handlePageSizeChange = (n: number) => { + setPageSize(n); + setPage(0); + localStorage.setItem(PAGE_SIZE_KEY, String(n)); + }; + // Sorting is server-side (across the whole dataset, not just the current // page), so changing the sort returns to page 1 and refetches. const toggleSort = (col: string) => { @@ -156,10 +189,10 @@ export default function ClientsPage() { }; const { data, isLoading } = useQuery({ - queryKey: ['clients', { search, showAll, page, sortCol, sortDir }], + queryKey: ['clients', { search, showAll, page, pageSize, sortCol, sortDir }], queryFn: () => clientsApi - .list({ search: search || undefined, active: showAll ? undefined : true, limit: PAGE_SIZE, offset: page * PAGE_SIZE, sort: sortCol, dir: sortDir }) + .list({ search: search || undefined, active: showAll ? undefined : true, limit: pageSize, offset: page * pageSize, sort: sortCol, dir: sortDir }) .then((r) => r.data), refetchInterval: refreshInterval ?? false, // Keep the current rows on screen while a sort/page/search refetch is in @@ -421,29 +454,71 @@ export default function ClientsPage() { {/* Pagination */} - {total > PAGE_SIZE && ( -
- - Showing {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total} - -
- - + {total > 0 && (() => { + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + return ( +
+ {/* Entries-per-page + range summary */} +
+ Show + + entries + + ({page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total}) + +
+ + {/* Numbered page selection */} + {totalPages > 1 && ( +
+ + {getPageList(page, totalPages).map((item, i) => + item === 'ellipsis' ? ( + … + ) : ( + + ) + )} + +
+ )}
-
- )} + ); + })()} )}
diff --git a/frontend/src/pages/NetworkServicesSyslogPage.tsx b/frontend/src/pages/NetworkServicesSyslogPage.tsx index e2303a5..7252356 100644 --- a/frontend/src/pages/NetworkServicesSyslogPage.tsx +++ b/frontend/src/pages/NetworkServicesSyslogPage.tsx @@ -1,15 +1,14 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { FileText, RefreshCw, AlertTriangle, Plus, Pencil, Trash2, X, Save, - CheckCircle, XCircle, Layers, + CheckCircle, XCircle, } 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 } @@ -114,13 +113,14 @@ function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: ( interface ActionFormProps { existing?: NS; allDevices?: boolean; + targetName?: string; // single target device name (single-device add) onSave: (data: NS) => void; onClose: () => void; isPending: boolean; error?: string; } -function ActionForm({ existing, allDevices, onSave, onClose, isPending, error }: ActionFormProps) { +function ActionForm({ existing, allDevices, targetName, onSave, onClose, isPending, error }: ActionFormProps) { const isBuiltin = existing ? BUILTIN_ACTION_NAMES.includes(existing['name'] ?? '') : false; const [name, setName] = useState(existing?.['name'] ?? ''); @@ -134,6 +134,12 @@ function ActionForm({ existing, allDevices, onSave, onClose, isPending, error }: const canSave = name.trim() !== '' && (type !== 'remote' || remote.trim() !== ''); + const title = existing + ? 'Edit Logging Action' + : targetName + ? `Add Logging Action — ${targetName}` + : allDevices ? 'Add Logging Action — All Devices' : 'Add Logging Action'; + function handleSubmit() { const data: NS = { name: name.trim(), type }; if (srcAddr.trim()) data['src-address'] = srcAddr.trim(); @@ -151,9 +157,7 @@ function ActionForm({ existing, allDevices, onSave, onClose, isPending, error }:
-

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

+

{title}

@@ -232,13 +236,14 @@ interface RuleFormProps { existing?: NS; actions: NS[]; allDevices?: boolean; + targetName?: string; // single target device name (single-device add) onSave: (data: NS) => void; onClose: () => void; isPending: boolean; error?: string; } -function RuleForm({ existing, actions, allDevices, onSave, onClose, isPending, error }: RuleFormProps) { +function RuleForm({ existing, actions, allDevices, targetName, 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'] ?? ''); @@ -246,6 +251,12 @@ function RuleForm({ existing, actions, allDevices, onSave, onClose, isPending, e const canSave = topics.trim() !== '' && action.trim() !== ''; + const title = existing + ? 'Edit Logging Rule' + : targetName + ? `Add Logging Rule — ${targetName}` + : allDevices ? 'Add Logging Rule — All Devices' : 'Add Logging Rule'; + function handleSubmit() { const data: NS = { topics: topics.trim(), action: action.trim() }; if (prefix.trim()) data['prefix'] = prefix.trim(); @@ -257,9 +268,7 @@ function RuleForm({ existing, actions, allDevices, onSave, onClose, isPending, e
-

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

+

{title}

@@ -309,26 +318,53 @@ function RuleForm({ existing, actions, allDevices, onSave, onClose, isPending, e ); } +// ─── Add-to-device control (dropdown + button) ───────────────────────────────── + +function AddToDevice({ + devices, value, onChange, onAdd, +}: { + devices: { id: number; name: string }[]; + value: number | ''; + onChange: (id: number) => void; + onAdd: () => void; +}) { + return ( +
+ + +
+ ); +} + // ─── 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(''); + // Form state. `targetDeviceId` → single-device add; `allCoverage` → edit + // across the devices that already have the row; neither → add to all. + const [actionForm, setActionForm] = useState<{ existing?: NS; allCoverage?: DeviceCoverage[]; targetDeviceId?: number } | null>(null); + const [ruleForm, setRuleForm] = useState<{ existing?: NS; allCoverage?: DeviceCoverage[]; targetDeviceId?: number } | null>(null); + const [savePending, setSavePending] = useState(false); + const [saveError, setSaveError] = useState(''); const [togglePending, setTogglePending] = useState(false); - const [savePending, setSavePending] = useState(false); - const [saveError, setSaveError] = useState(''); - // All-devices state + // Selected target for the per-section "Add to device" dropdowns + const [addActionDeviceId, setAddActionDeviceId] = useState(''); + const [addRuleDeviceId, setAddRuleDeviceId] = useState(''); + + // Push / delete-all flow const [pendingPush, setPendingPush] = useState(null); const [confirmDeleteAll, setConfirmDeleteAll] = useState<{ type: 'action' | 'rule'; coverage: DeviceCoverage[]; label: string } | null>(null); const [isPushing, setIsPushing] = useState(false); @@ -342,17 +378,7 @@ export default function NetworkServicesSyslogPage() { 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 + // Aggregated data across all online devices const allDevicesKey = onlineDevices.map(d => d.id).join(','); const { data: allDevicesSyslog = [], isLoading: allLoading, refetch: allRefetch, isFetching: allFetching } = useQuery({ queryKey: ['ns-syslog-all', allDevicesKey], @@ -364,92 +390,35 @@ export default function NetworkServicesSyslogPage() { .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, + enabled: 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'] }); - } + // Resolve the active add-target for each section (default: first online device) + const actionTarget = (typeof addActionDeviceId === 'number' ? addActionDeviceId : onlineDevices[0]?.id) ?? ''; + const ruleTarget = (typeof addRuleDeviceId === 'number' ? addRuleDeviceId : onlineDevices[0]?.id) ?? ''; + const ruleTargetActions = allDevicesSyslog.find(ds => ds.deviceId === ruleTarget)?.actions ?? []; 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) { + // ── Single-device add (direct, no review) ───────────────────────────────── + async function handleSingleAdd(type: 'action' | 'rule', data: NS, targetDeviceId: number) { 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))); + if (type === 'action') await networkServicesApi.addSyslogAction(targetDeviceId, data); + else await networkServicesApi.addSyslogRule(targetDeviceId, data); + setActionForm(null); setRuleForm(null); invalidateAll(); - } finally { setTogglePending(false); } + } catch (e) { setSaveError((e as Error).message); } + finally { setSavePending(false); } } - // ── All-devices handlers ────────────────────────────────────────────────── - + // ── All-devices add / update (review then push) ─────────────────────────── function handleAllDevicesSave(type: 'action' | 'rule', data: NS, coverage?: DeviceCoverage[]) { setPendingPush({ type, operation: coverage ? 'update' : 'add', data, coverage }); setActionForm(null); setRuleForm(null); @@ -533,136 +502,12 @@ export default function NetworkServicesSyslogPage() { 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 && ( - - )} - - - )} -
-
-
- ); + 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); } } // ───────────────────────────────────────────────────────────────────────── @@ -673,15 +518,11 @@ export default function NetworkServicesSyslogPage() {

Syslog

-

Logging actions and routing rules configuration

+

+ Logging actions and routing rules across your fleet. Coverage shows how many devices share each entry. +

- {mode === 'single' && deviceId > 0 && ( - - )} - {mode === 'all' && onlineDevices.length > 0 && ( + {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(''); } - ) - } -
-
- )} - + {onlineDevices.length === 0 && ( +
No online devices found.
)} - {/* ═══ ALL DEVICES ═════════════════════════════════════════════════════ */} - {mode === 'all' && ( + {onlineDevices.length > 0 && allLoading && ( +
Loading from all devices…
+ )} + + {onlineDevices.length > 0 && !allLoading && (
- {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} -
- ))} -
+ {/* Logging Actions */} +
+
+ +

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

+ {canWrite && ( +
+ { setSaveError(''); setActionForm({ targetDeviceId: Number(actionTarget) }); }} + /> +
)} - +
+ {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 && ( +
+ { setSaveError(''); setRuleForm({ targetDeviceId: Number(ruleTarget) }); }} + /> + +
+ )} +
+ {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 && ( + actionForm.targetDeviceId ? ( + d.id === actionForm.targetDeviceId)?.name} + onSave={data => handleSingleAdd('action', data, actionForm.targetDeviceId!)} + onClose={() => { setActionForm(null); setSaveError(''); }} + isPending={savePending} error={saveError} /> + ) : ( + handleAllDevicesSave('action', data, actionForm.allCoverage)} + onClose={() => setActionForm(null)} isPending={false} /> + ) )} - {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} /> + + {ruleForm && ( + ruleForm.targetDeviceId ? ( + d.id === ruleForm.targetDeviceId)?.name} + onSave={data => handleSingleAdd('rule', data, ruleForm.targetDeviceId!)} + onClose={() => { setRuleForm(null); setSaveError(''); }} + isPending={savePending} error={saveError} /> + ) : ( + handleAllDevicesSave('rule', data, ruleForm.allCoverage)} + onClose={() => setRuleForm(null)} isPending={false} /> + ) )} {/* Confirm push to all */} @@ -1075,7 +835,7 @@ export default function NetworkServicesSyslogPage() {
-

Remove from all devices?

+

Remove from devices?

This will remove the {confirmDeleteAll.label} from{' '} {confirmDeleteAll.coverage.length} device{confirmDeleteAll.coverage.length !== 1 ? 's' : ''}:{' '} @@ -1088,33 +848,7 @@ export default function NetworkServicesSyslogPage() { className="px-4 py-1.5 text-sm text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 rounded-lg transition-colors disabled:opacity-50">Cancel -

-
-
- )} - - {/* 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/version.ts b/frontend/src/version.ts index 0843b85..bcaf576 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.15.1 Beta'; +export const APP_VERSION = 'v0.16.0 Beta';