feat(ui): modern clients pagination + unified syslog page (v0.16.0-beta)

Clients:
- Replace Previous/Next buttons with a "Show N entries" dropdown
  (25/50/100/200, persisted to localStorage) and numbered page selection
  with prev/next chevrons and ellipses for long ranges.

Syslog (Network Services):
- Drop the Single Device / All Devices mode toggle; always show the
  fleet-aggregated view with coverage badges.
- Add a per-section device dropdown + "Add to device" button beside
  "Add to All Devices" for targeting a single device (direct save).
- Edit / enable-disable / remove remain coverage-scoped, so single-device
  management is preserved without the separate mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-06-19 08:53:19 -07:00
co-authored by Claude Opus 4.8
parent 6e56048f2f
commit 1be161e67a
8 changed files with 437 additions and 628 deletions
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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": {
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.15.1-beta",
"version": "0.16.0-beta",
"private": true,
"type": "module",
"scripts": {
+91 -16
View File
@@ -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<number>([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<number | null>(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() {
</div>
{/* Pagination */}
{total > PAGE_SIZE && (
<div className="flex items-center justify-between text-sm">
<span className="text-gray-500 dark:text-slate-400">
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 (
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 text-sm">
{/* Entries-per-page + range summary */}
<div className="flex items-center gap-2 text-gray-500 dark:text-slate-400">
<span>Show</span>
<select
value={pageSize}
onChange={(e) => handlePageSizeChange(Number(e.target.value))}
className="text-sm rounded-lg border border-line bg-surface-2 text-ink px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-accent"
>
{PAGE_SIZE_OPTIONS.map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
<span>entries</span>
<span className="ml-1 hidden sm:inline">
({page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total})
</span>
<div className="flex gap-2">
</div>
{/* Numbered page selection */}
{totalPages > 1 && (
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="btn-secondary py-1"
aria-label="Previous page"
className="inline-flex items-center justify-center w-8 h-8 rounded-lg text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
Previous
<ChevronLeft className="w-4 h-4" />
</button>
{getPageList(page, totalPages).map((item, i) =>
item === 'ellipsis' ? (
<span key={`e${i}`} className="px-1.5 text-gray-400 dark:text-slate-500 select-none">…</span>
) : (
<button
onClick={() => setPage((p) => p + 1)}
disabled={(page + 1) * PAGE_SIZE >= total}
className="btn-secondary py-1"
key={item}
onClick={() => setPage(item - 1)}
aria-current={item - 1 === page ? 'page' : undefined}
className={clsx(
'inline-flex items-center justify-center min-w-[2rem] h-8 px-2 rounded-lg text-sm font-medium transition-colors',
item - 1 === page
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700'
)}
>
Next
{item}
</button>
)
)}
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
aria-label="Next page"
className="inline-flex items-center justify-center w-8 h-8 rounded-lg text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
);
})()}
</>
)}
</div>
+125 -391
View File
@@ -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<string, string>;
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 }:
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md">
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{existing ? 'Edit Logging Action' : allDevices ? 'Add Logging Action — All Devices' : 'Add Logging Action'}
</h3>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{title}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
<X className="w-4 h-4" />
</button>
@@ -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
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md">
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-slate-700">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{existing ? 'Edit Logging Rule' : allDevices ? 'Add Logging Rule — All Devices' : 'Add Logging Rule'}
</h3>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{title}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
<X className="w-4 h-4" />
</button>
@@ -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 (
<div className="flex items-center gap-1">
<select
value={value}
onChange={e => onChange(Number(e.target.value))}
className="text-xs rounded-lg border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-gray-700 dark:text-slate-200 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500 max-w-[10rem]"
title="Choose a device to add to"
>
{devices.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
<button onClick={onAdd}
className="flex items-center gap-1 px-2.5 py-1 border border-blue-600 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-slate-700 text-xs font-medium rounded-lg transition-colors whitespace-nowrap">
<Plus className="w-3.5 h-3.5" />Add to device
</button>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function NetworkServicesSyslogPage() {
const canWrite = useCanWrite();
const qc = useQueryClient();
const [mode, setMode] = useState<Mode>('single');
const [selectedDeviceId, setSelectedDeviceId] = useState<number | ''>('');
// 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);
// 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);
// All-devices state
// Selected target for the per-section "Add to device" dropdowns
const [addActionDeviceId, setAddActionDeviceId] = useState<number | ''>('');
const [addRuleDeviceId, setAddRuleDeviceId] = useState<number | ''>('');
// Push / delete-all flow
const [pendingPush, setPendingPush] = useState<PendingOp | null>(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 (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Name</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Type</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Destination</th>
<th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((action, i) => {
const isBuiltin = isBuiltinFn(action);
return (
<tr key={action['.id'] ?? i}
className={clsx('border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40',
i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40')}>
<td className="px-4 py-3 font-medium text-gray-900 dark:text-white">
{action['name']}{isBuiltin && <span className="ml-2 text-xs text-gray-400 dark:text-slate-500">(built-in)</span>}
</td>
<td className="px-4 py-3">
<span className={clsx('inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
action['type'] === 'remote'
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400'
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400')}>
{action['type']}
</span>
</td>
<td className="px-4 py-3 text-gray-700 dark:text-slate-300">
{action['type'] === 'remote' ? (
<div>
<span className="font-mono text-xs">{action['remote'] || '—'}:{action['remote-port'] || '514'}</span>
<div className="text-xs text-gray-400 dark:text-slate-500 mt-0.5">
{action['syslog-facility'] || 'daemon'} / {action['syslog-severity'] || 'auto'}
{action['bsd-syslog'] === 'yes' && ' · BSD'}
</div>
</div>
) : '—'}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
{canWrite && (
<button onClick={() => onEdit(action)}
className="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-slate-700 transition-colors" title="Edit">
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{canWrite && !isBuiltin && (
<button onClick={() => onDelete(action)}
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-slate-700 transition-colors" title="Delete">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function renderRulesTable(rows: NS[], onEdit: (r: NS) => void, onToggle: ((r: NS) => void) | null, onDelete: (r: NS) => void) {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Topics</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Action</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Prefix</th>
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Status</th>
<th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((rule, i) => {
const isDisabled = rule['disabled'] === 'true' || rule['disabled'] === 'yes';
return (
<tr key={rule['.id'] ?? i}
className={clsx('border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40',
i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40')}>
<td className="px-4 py-3 font-mono text-xs text-gray-900 dark:text-white">{rule['topics'] || '—'}</td>
<td className="px-4 py-3 text-gray-700 dark:text-slate-300">{rule['action'] || '—'}</td>
<td className="px-4 py-3 text-gray-500 dark:text-slate-400 text-xs">{rule['prefix'] || '—'}</td>
<td className="px-4 py-3">
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
isDisabled
? 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400'
: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400')}>
<span className={clsx('w-1.5 h-1.5 rounded-full', isDisabled ? 'bg-gray-400' : 'bg-green-500')} />
{isDisabled ? 'Disabled' : 'Enabled'}
</span>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
{canWrite && (
<>
<button onClick={() => onEdit(rule)}
className="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-slate-700 transition-colors" title="Edit">
<Pencil className="w-3.5 h-3.5" />
</button>
{onToggle && (
<button onClick={() => onToggle(rule)} disabled={togglePending}
className="p-1.5 rounded-lg text-gray-400 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-slate-700 transition-colors disabled:opacity-50"
title={isDisabled ? 'Enable' : 'Disable'}>
<span className="text-xs font-medium">{isDisabled ? 'En' : 'Dis'}</span>
</button>
)}
<button onClick={() => onDelete(rule)}
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-slate-700 transition-colors" title="Delete">
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
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() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Syslog</h1>
<p className="text-sm text-gray-500 dark:text-slate-400">Logging actions and routing rules configuration</p>
<p className="text-sm text-gray-500 dark:text-slate-400">
Logging actions and routing rules across your fleet. Coverage shows how many devices share each entry.
</p>
</div>
{mode === 'single' && deviceId > 0 && (
<button onClick={() => refetch()} disabled={isFetching}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-slate-600 text-sm text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-50 transition-colors">
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />Refresh
</button>
)}
{mode === 'all' && onlineDevices.length > 0 && (
{onlineDevices.length > 0 && (
<button onClick={() => allRefetch()} disabled={allFetching}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-slate-600 text-sm text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-50 transition-colors">
<RefreshCw className={clsx('w-3.5 h-3.5', allFetching && 'animate-spin')} />Refresh
@@ -689,108 +530,6 @@ export default function NetworkServicesSyslogPage() {
)}
</div>
{/* Mode toggle */}
<div className="card p-1 flex w-fit rounded-lg gap-1">
<button onClick={() => setMode('single')}
className={clsx('px-4 py-1.5 rounded-md text-sm font-medium transition-colors',
mode === 'single'
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700')}>
Single Device
</button>
<button onClick={() => setMode('all')}
className={clsx('flex items-center gap-1.5 px-4 py-1.5 rounded-md text-sm font-medium transition-colors',
mode === 'all'
? 'bg-blue-600 text-white shadow-sm'
: 'text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700')}>
<Layers className="w-3.5 h-3.5" />All Devices
</button>
</div>
{/* ═══ SINGLE DEVICE ═══════════════════════════════════════════════════ */}
{mode === 'single' && (
<>
<div className="card p-4">
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-2">Select Device</label>
<select className="input w-full max-w-xs" value={selectedDeviceId}
onChange={e => setSelectedDeviceId(e.target.value === '' ? '' : parseInt(e.target.value))}>
<option value="">— Choose a device —</option>
{devices.map(d => (
<option key={d.id} value={d.id}>{d.name} ({d.ip_address}){d.status !== 'online' ? ' — offline' : ''}</option>
))}
</select>
{selectedDevice?.status !== 'online' && deviceId > 0 && (
<div className="mt-2 flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
<AlertTriangle className="w-3.5 h-3.5" />This device is currently offline.
</div>
)}
</div>
{deviceId === 0 && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Select a device above.</div>}
{deviceId > 0 && isLoading && <div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">Loading…</div>}
{deviceId > 0 && error && (
<div className="card p-4 flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />Failed: {(error as Error).message}
</div>
)}
{syslog && (
<div className="space-y-6">
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<FileText className="w-4 h-4 text-blue-500" />
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">
Logging Actions<span className="ml-2 text-xs font-normal text-gray-400 dark:text-slate-500">({singleActions.length})</span>
</h2>
{canWrite && (
<button onClick={() => setActionForm({})}
className="ml-auto flex items-center gap-1 px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-lg transition-colors">
<Plus className="w-3.5 h-3.5" />Add Action
</button>
)}
</div>
{singleActions.length === 0
? <div className="p-6 text-center text-sm text-gray-400 dark:text-slate-500">No logging actions found.</div>
: renderActionsTable(
singleActions,
r => BUILTIN_ACTION_NAMES.includes(r['name'] ?? ''),
r => setActionForm({ existing: r }),
r => { setConfirmDelete({ type: 'action', row: r }); setDeleteError(''); }
)
}
</div>
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<FileText className="w-4 h-4 text-indigo-500" />
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">
Logging Rules<span className="ml-2 text-xs font-normal text-gray-400 dark:text-slate-500">({singleRules.length})</span>
</h2>
{canWrite && (
<button onClick={() => setRuleForm({})}
className="ml-auto flex items-center gap-1 px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-lg transition-colors">
<Plus className="w-3.5 h-3.5" />Add Rule
</button>
)}
</div>
{singleRules.length === 0
? <div className="p-6 text-center text-sm text-gray-400 dark:text-slate-500">No logging rules found.</div>
: renderRulesTable(
singleRules,
r => setRuleForm({ existing: r }),
r => handleToggleRule(r['.id'], !(r['disabled'] === 'true' || r['disabled'] === 'yes')),
r => { setConfirmDelete({ type: 'rule', row: r }); setDeleteError(''); }
)
}
</div>
</div>
)}
</>
)}
{/* ═══ ALL DEVICES ═════════════════════════════════════════════════════ */}
{mode === 'all' && (
<div className="space-y-6">
{onlineDevices.length === 0 && (
<div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">No online devices found.</div>
)}
@@ -800,19 +539,27 @@ export default function NetworkServicesSyslogPage() {
)}
{onlineDevices.length > 0 && !allLoading && (
<>
<div className="space-y-6">
{/* Logging Actions */}
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex flex-wrap items-center gap-2">
<FileText className="w-4 h-4 text-blue-500" />
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">
Logging Actions<span className="ml-2 text-xs font-normal text-gray-400 dark:text-slate-500">({aggregatedActions.length} unique)</span>
</h2>
{canWrite && (
<div className="ml-auto flex items-center gap-2">
<AddToDevice
devices={onlineDevices}
value={actionTarget}
onChange={setAddActionDeviceId}
onAdd={() => { setSaveError(''); setActionForm({ targetDeviceId: Number(actionTarget) }); }}
/>
<button onClick={() => setActionForm({})}
className="ml-auto flex items-center gap-1 px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-lg transition-colors">
className="flex items-center gap-1 px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-lg transition-colors whitespace-nowrap">
<Plus className="w-3.5 h-3.5" />Add to All Devices
</button>
</div>
)}
</div>
{aggregatedActions.length === 0
@@ -864,14 +611,14 @@ export default function NetworkServicesSyslogPage() {
{canWrite && (
<button
onClick={() => setActionForm({ existing: sample, allCoverage: coverage })}
className="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-slate-700 transition-colors" title="Edit on all devices">
className="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-slate-700 transition-colors" title="Edit on covered devices">
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{canWrite && !isBuiltin && (
<button
onClick={() => setConfirmDeleteAll({ type: 'action', coverage, label: `action "${sample['name']}"` })}
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-slate-700 transition-colors" title="Remove from all devices">
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-slate-700 transition-colors" title="Remove from covered devices">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
@@ -889,16 +636,24 @@ export default function NetworkServicesSyslogPage() {
{/* Logging Rules */}
<div className="card overflow-hidden">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex flex-wrap items-center gap-2">
<FileText className="w-4 h-4 text-indigo-500" />
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">
Logging Rules<span className="ml-2 text-xs font-normal text-gray-400 dark:text-slate-500">({aggregatedRules.length} unique)</span>
</h2>
{canWrite && (
<div className="ml-auto flex items-center gap-2">
<AddToDevice
devices={onlineDevices}
value={ruleTarget}
onChange={setAddRuleDeviceId}
onAdd={() => { setSaveError(''); setRuleForm({ targetDeviceId: Number(ruleTarget) }); }}
/>
<button onClick={() => setRuleForm({})}
className="ml-auto flex items-center gap-1 px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-lg transition-colors">
className="flex items-center gap-1 px-2.5 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs font-medium rounded-lg transition-colors whitespace-nowrap">
<Plus className="w-3.5 h-3.5" />Add to All Devices
</button>
</div>
)}
</div>
{aggregatedRules.length === 0
@@ -941,7 +696,7 @@ export default function NetworkServicesSyslogPage() {
{canWrite && (
<button
onClick={() => setRuleForm({ existing: sample, allCoverage: coverage })}
className="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-slate-700 transition-colors" title="Edit on all devices">
className="p-1.5 rounded-lg text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-slate-700 transition-colors" title="Edit on covered devices">
<Pencil className="w-3.5 h-3.5" />
</button>
)}
@@ -950,14 +705,14 @@ export default function NetworkServicesSyslogPage() {
onClick={() => handleToggleRuleAll(coverage, !isDisabled)}
disabled={togglePending}
className="p-1.5 rounded-lg text-gray-400 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-slate-700 transition-colors disabled:opacity-50"
title={isDisabled ? 'Enable on all devices' : 'Disable on all devices'}>
title={isDisabled ? 'Enable on covered devices' : 'Disable on covered devices'}>
<span className="text-xs font-medium">{isDisabled ? 'En' : 'Dis'}</span>
</button>
)}
{canWrite && (
<button
onClick={() => setConfirmDeleteAll({ type: 'rule', coverage, label: `rule "${sample['topics']}"` })}
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-slate-700 transition-colors" title="Remove from all devices">
className="p-1.5 rounded-lg text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-slate-700 transition-colors" title="Remove from covered devices">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
@@ -999,32 +754,37 @@ export default function NetworkServicesSyslogPage() {
</div>
</div>
)}
</>
)}
</div>
)}
{/* ── Modals ───────────────────────────────────────────────────────────── */}
{actionForm && mode === 'single' && (
<ActionForm existing={actionForm.existing} onSave={handleSaveAction}
{actionForm && (
actionForm.targetDeviceId ? (
<ActionForm existing={actionForm.existing}
targetName={devices.find(d => d.id === actionForm.targetDeviceId)?.name}
onSave={data => handleSingleAdd('action', data, actionForm.targetDeviceId!)}
onClose={() => { setActionForm(null); setSaveError(''); }}
isPending={savePending} error={saveError} />
)}
{actionForm && mode === 'all' && (
) : (
<ActionForm allDevices existing={actionForm.existing}
onSave={data => handleAllDevicesSave('action', data, actionForm.allCoverage)}
onClose={() => setActionForm(null)} isPending={false} />
)
)}
{ruleForm && mode === 'single' && (
<RuleForm existing={ruleForm.existing} actions={singleActions} onSave={handleSaveRule}
{ruleForm && (
ruleForm.targetDeviceId ? (
<RuleForm existing={ruleForm.existing} actions={ruleTargetActions}
targetName={devices.find(d => d.id === ruleForm.targetDeviceId)?.name}
onSave={data => handleSingleAdd('rule', data, ruleForm.targetDeviceId!)}
onClose={() => { setRuleForm(null); setSaveError(''); }}
isPending={savePending} error={saveError} />
)}
{ruleForm && mode === 'all' && (
) : (
<RuleForm allDevices existing={ruleForm.existing} actions={[]}
onSave={data => handleAllDevicesSave('rule', data, ruleForm.allCoverage)}
onClose={() => setRuleForm(null)} isPending={false} />
)
)}
{/* Confirm push to all */}
@@ -1075,7 +835,7 @@ export default function NetworkServicesSyslogPage() {
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Remove from all devices?</h3>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Remove from devices?</h3>
<p className="mt-1 text-sm text-gray-600 dark:text-slate-300">
This will remove the <strong>{confirmDeleteAll.label}</strong> from{' '}
<strong>{confirmDeleteAll.coverage.length} device{confirmDeleteAll.coverage.length !== 1 ? 's' : ''}</strong>:{' '}
@@ -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</button>
<button onClick={executeDeleteAll} disabled={isPushing}
className="flex items-center gap-1.5 px-4 py-1.5 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors">
{isPushing ? <><RefreshCw className="w-3.5 h-3.5 animate-spin" />Removing…</> : 'Remove from All'}
</button>
</div>
</div>
</div>
)}
{/* Single-device delete confirm */}
{confirmDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm">
<div className="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Delete Logging {confirmDelete.type === 'action' ? 'Action' : 'Rule'}
</h3>
<p className="text-sm text-gray-600 dark:text-slate-300">
{confirmDelete.type === 'action'
? <>Delete action <strong>{confirmDelete.row['name']}</strong>? Rules referencing this action will stop working.</>
: <>Delete rule for topics <strong>{confirmDelete.row['topics']}</strong>?</>
}
</p>
{deleteError && <p className="text-sm text-red-500">{deleteError}</p>}
<div className="flex justify-end gap-2">
<button onClick={() => { setConfirmDelete(null); setDeleteError(''); }}
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">Cancel</button>
<button onClick={handleSingleDelete} disabled={deletePending}
className="px-4 py-1.5 bg-red-600 hover:bg-red-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors">
{deletePending ? 'Deleting…' : 'Delete'}
{isPushing ? <><RefreshCw className="w-3.5 h-3.5 animate-spin" />Removing…</> : 'Remove'}
</button>
</div>
</div>
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.15.1 Beta';
export const APP_VERSION = 'v0.16.0 Beta';