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:
co-authored by
Claude Opus 4.8
parent
6e56048f2f
commit
1be161e67a
@@ -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.
|
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.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "0.15.1-beta",
|
"version": "0.16.0-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "0.15.1-beta",
|
"version": "0.16.0-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@influxdata/influxdb-client": "^1.33.2",
|
"@influxdata/influxdb-client": "^1.33.2",
|
||||||
"@influxdata/influxdb-client-apis": "^1.35.0",
|
"@influxdata/influxdb-client-apis": "^1.35.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "0.15.1-beta",
|
"version": "0.16.0-beta",
|
||||||
"description": "Mikrotik Network Management Platform - Backend API",
|
"description": "Mikrotik Network Management Platform - Backend API",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-frontend",
|
"name": "mikrotik-manager-frontend",
|
||||||
"version": "0.15.1-beta",
|
"version": "0.16.0-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mikrotik-manager-frontend",
|
"name": "mikrotik-manager-frontend",
|
||||||
"version": "0.15.1-beta",
|
"version": "0.16.0-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.101.0",
|
"@tanstack/react-query": "^5.101.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-frontend",
|
"name": "mikrotik-manager-frontend",
|
||||||
"version": "0.15.1-beta",
|
"version": "0.16.0-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { clientsApi } from '../services/api';
|
||||||
import type { Client } from '../types';
|
import type { Client } from '../types';
|
||||||
import { useCanWrite } from '../hooks/useCanWrite';
|
import { useCanWrite } from '../hooks/useCanWrite';
|
||||||
@@ -19,6 +19,33 @@ const REFRESH_OPTIONS = [
|
|||||||
|
|
||||||
const STORAGE_KEY = 'clients-refresh-interval';
|
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 {
|
function formatDataBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
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 [refreshInterval, setRefreshInterval] = useState<number | null>(readStoredInterval);
|
||||||
const refreshIntervalRef = useRef(refreshInterval);
|
const refreshIntervalRef = useRef(refreshInterval);
|
||||||
useEffect(() => { refreshIntervalRef.current = refreshInterval; }, [refreshInterval]);
|
useEffect(() => { refreshIntervalRef.current = refreshInterval; }, [refreshInterval]);
|
||||||
const PAGE_SIZE = 50;
|
const [pageSize, setPageSize] = useState(readStoredPageSize);
|
||||||
|
|
||||||
const handleIntervalChange = (val: number | null) => {
|
const handleIntervalChange = (val: number | null) => {
|
||||||
setRefreshInterval(val);
|
setRefreshInterval(val);
|
||||||
@@ -147,6 +174,12 @@ export default function ClientsPage() {
|
|||||||
localStorage.setItem(STORAGE_KEY, String(val));
|
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
|
// Sorting is server-side (across the whole dataset, not just the current
|
||||||
// page), so changing the sort returns to page 1 and refetches.
|
// page), so changing the sort returns to page 1 and refetches.
|
||||||
const toggleSort = (col: string) => {
|
const toggleSort = (col: string) => {
|
||||||
@@ -156,10 +189,10 @@ export default function ClientsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['clients', { search, showAll, page, sortCol, sortDir }],
|
queryKey: ['clients', { search, showAll, page, pageSize, sortCol, sortDir }],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
clientsApi
|
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),
|
.then((r) => r.data),
|
||||||
refetchInterval: refreshInterval ?? false,
|
refetchInterval: refreshInterval ?? false,
|
||||||
// Keep the current rows on screen while a sort/page/search refetch is in
|
// Keep the current rows on screen while a sort/page/search refetch is in
|
||||||
@@ -421,29 +454,71 @@ export default function ClientsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{total > PAGE_SIZE && (
|
{total > 0 && (() => {
|
||||||
<div className="flex items-center justify-between text-sm">
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
<span className="text-gray-500 dark:text-slate-400">
|
return (
|
||||||
Showing {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
|
<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>
|
</span>
|
||||||
<div className="flex gap-2">
|
</div>
|
||||||
|
|
||||||
|
{/* Numbered page selection */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
disabled={page === 0}
|
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>
|
</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
|
<button
|
||||||
onClick={() => setPage((p) => p + 1)}
|
key={item}
|
||||||
disabled={(page + 1) * PAGE_SIZE >= total}
|
onClick={() => setPage(item - 1)}
|
||||||
className="btn-secondary py-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>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
FileText, RefreshCw, AlertTriangle, Plus, Pencil, Trash2, X, Save,
|
FileText, RefreshCw, AlertTriangle, Plus, Pencil, Trash2, X, Save,
|
||||||
CheckCircle, XCircle, Layers,
|
CheckCircle, XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { networkServicesApi, devicesApi } from '../services/api';
|
import { networkServicesApi, devicesApi } from '../services/api';
|
||||||
import { useCanWrite } from '../hooks/useCanWrite';
|
import { useCanWrite } from '../hooks/useCanWrite';
|
||||||
|
|
||||||
type NS = Record<string, string>;
|
type NS = Record<string, string>;
|
||||||
type Mode = 'single' | 'all';
|
|
||||||
|
|
||||||
interface PushResult { name: string; success: boolean; error?: string }
|
interface PushResult { name: string; success: boolean; error?: string }
|
||||||
interface DeviceCoverage { deviceId: number; deviceName: string; rowId: string }
|
interface DeviceCoverage { deviceId: number; deviceName: string; rowId: string }
|
||||||
@@ -114,13 +113,14 @@ function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (
|
|||||||
interface ActionFormProps {
|
interface ActionFormProps {
|
||||||
existing?: NS;
|
existing?: NS;
|
||||||
allDevices?: boolean;
|
allDevices?: boolean;
|
||||||
|
targetName?: string; // single target device name (single-device add)
|
||||||
onSave: (data: NS) => void;
|
onSave: (data: NS) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
error?: string;
|
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 isBuiltin = existing ? BUILTIN_ACTION_NAMES.includes(existing['name'] ?? '') : false;
|
||||||
|
|
||||||
const [name, setName] = useState(existing?.['name'] ?? '');
|
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 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() {
|
function handleSubmit() {
|
||||||
const data: NS = { name: name.trim(), type };
|
const data: NS = { name: name.trim(), type };
|
||||||
if (srcAddr.trim()) data['src-address'] = srcAddr.trim();
|
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="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="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">
|
<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">
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||||
{existing ? 'Edit Logging Action' : allDevices ? 'Add Logging Action — All Devices' : 'Add Logging Action'}
|
|
||||||
</h3>
|
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -232,13 +236,14 @@ interface RuleFormProps {
|
|||||||
existing?: NS;
|
existing?: NS;
|
||||||
actions: NS[];
|
actions: NS[];
|
||||||
allDevices?: boolean;
|
allDevices?: boolean;
|
||||||
|
targetName?: string; // single target device name (single-device add)
|
||||||
onSave: (data: NS) => void;
|
onSave: (data: NS) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
error?: string;
|
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 [topics, setTopics] = useState(existing?.['topics'] ?? '');
|
||||||
const [action, setAction] = useState(existing?.['action'] ?? (allDevices ? '' : (actions[0]?.['name'] ?? '')));
|
const [action, setAction] = useState(existing?.['action'] ?? (allDevices ? '' : (actions[0]?.['name'] ?? '')));
|
||||||
const [prefix, setPrefix] = useState(existing?.['prefix'] ?? '');
|
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 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() {
|
function handleSubmit() {
|
||||||
const data: NS = { topics: topics.trim(), action: action.trim() };
|
const data: NS = { topics: topics.trim(), action: action.trim() };
|
||||||
if (prefix.trim()) data['prefix'] = prefix.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="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="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">
|
<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">
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">{title}</h3>
|
||||||
{existing ? 'Edit Logging Rule' : allDevices ? 'Add Logging Rule — All Devices' : 'Add Logging Rule'}
|
|
||||||
</h3>
|
|
||||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 dark:hover:text-slate-300">
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</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 ────────────────────────────────────────────────────────────────
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function NetworkServicesSyslogPage() {
|
export default function NetworkServicesSyslogPage() {
|
||||||
const canWrite = useCanWrite();
|
const canWrite = useCanWrite();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
const [mode, setMode] = useState<Mode>('single');
|
// Form state. `targetDeviceId` → single-device add; `allCoverage` → edit
|
||||||
const [selectedDeviceId, setSelectedDeviceId] = useState<number | ''>('');
|
// across the devices that already have the row; neither → add to all.
|
||||||
|
const [actionForm, setActionForm] = useState<{ existing?: NS; allCoverage?: DeviceCoverage[]; targetDeviceId?: number } | null>(null);
|
||||||
// Single-device form state (allCoverage set when editing in All Devices mode)
|
const [ruleForm, setRuleForm] = useState<{ existing?: NS; allCoverage?: DeviceCoverage[]; targetDeviceId?: number } | null>(null);
|
||||||
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 [savePending, setSavePending] = useState(false);
|
||||||
const [saveError, setSaveError] = useState('');
|
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 [pendingPush, setPendingPush] = useState<PendingOp | null>(null);
|
||||||
const [confirmDeleteAll, setConfirmDeleteAll] = useState<{ type: 'action' | 'rule'; coverage: DeviceCoverage[]; label: string } | null>(null);
|
const [confirmDeleteAll, setConfirmDeleteAll] = useState<{ type: 'action' | 'rule'; coverage: DeviceCoverage[]; label: string } | null>(null);
|
||||||
const [isPushing, setIsPushing] = useState(false);
|
const [isPushing, setIsPushing] = useState(false);
|
||||||
@@ -342,17 +378,7 @@ export default function NetworkServicesSyslogPage() {
|
|||||||
|
|
||||||
const onlineDevices = devices.filter(d => d.status === 'online');
|
const onlineDevices = devices.filter(d => d.status === 'online');
|
||||||
|
|
||||||
const deviceId = typeof selectedDeviceId === 'number' ? selectedDeviceId : 0;
|
// Aggregated data across all online devices
|
||||||
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 allDevicesKey = onlineDevices.map(d => d.id).join(',');
|
||||||
const { data: allDevicesSyslog = [], isLoading: allLoading, refetch: allRefetch, isFetching: allFetching } = useQuery({
|
const { data: allDevicesSyslog = [], isLoading: allLoading, refetch: allRefetch, isFetching: allFetching } = useQuery({
|
||||||
queryKey: ['ns-syslog-all', allDevicesKey],
|
queryKey: ['ns-syslog-all', allDevicesKey],
|
||||||
@@ -364,92 +390,35 @@ export default function NetworkServicesSyslogPage() {
|
|||||||
.filter(r => r.status === 'fulfilled')
|
.filter(r => r.status === 'fulfilled')
|
||||||
.map(r => (r as PromiseFulfilledResult<{ deviceId: number; deviceName: string; actions: NS[]; rules: NS[] }>).value);
|
.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 aggregatedActions = aggregateActions(allDevicesSyslog);
|
||||||
const aggregatedRules = aggregateRules(allDevicesSyslog);
|
const aggregatedRules = aggregateRules(allDevicesSyslog);
|
||||||
|
|
||||||
const singleActions = syslog?.actions ?? [];
|
// Resolve the active add-target for each section (default: first online device)
|
||||||
const singleRules = syslog?.rules ?? [];
|
const actionTarget = (typeof addActionDeviceId === 'number' ? addActionDeviceId : onlineDevices[0]?.id) ?? '';
|
||||||
|
const ruleTarget = (typeof addRuleDeviceId === 'number' ? addRuleDeviceId : onlineDevices[0]?.id) ?? '';
|
||||||
function invalidateSingle() {
|
const ruleTargetActions = allDevicesSyslog.find(ds => ds.deviceId === ruleTarget)?.actions ?? [];
|
||||||
qc.invalidateQueries({ queryKey: ['ns-syslog', deviceId] });
|
|
||||||
qc.invalidateQueries({ queryKey: ['network-services-overview'] });
|
|
||||||
}
|
|
||||||
|
|
||||||
function invalidateAll() {
|
function invalidateAll() {
|
||||||
qc.invalidateQueries({ queryKey: ['ns-syslog-all'] });
|
qc.invalidateQueries({ queryKey: ['ns-syslog-all'] });
|
||||||
qc.invalidateQueries({ queryKey: ['network-services-overview'] });
|
qc.invalidateQueries({ queryKey: ['network-services-overview'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
// ── Single-device add (direct, no review) ─────────────────────────────────
|
||||||
setActionForm(null); setRuleForm(null); setSaveError('');
|
async function handleSingleAdd(type: 'action' | 'rule', data: NS, targetDeviceId: number) {
|
||||||
setPendingPush(null); setOpResults(null);
|
|
||||||
}, [mode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActionForm(null); setRuleForm(null); setSaveError('');
|
|
||||||
}, [deviceId]);
|
|
||||||
|
|
||||||
// ── Single-device handlers ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function handleSaveAction(data: NS) {
|
|
||||||
setSaveError(''); setSavePending(true);
|
setSaveError(''); setSavePending(true);
|
||||||
try {
|
try {
|
||||||
if (actionForm?.existing) {
|
if (type === 'action') await networkServicesApi.addSyslogAction(targetDeviceId, data);
|
||||||
await networkServicesApi.updateSyslogAction(deviceId, actionForm.existing['.id'], data);
|
else await networkServicesApi.addSyslogRule(targetDeviceId, data);
|
||||||
} else {
|
setActionForm(null); setRuleForm(null);
|
||||||
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();
|
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[]) {
|
function handleAllDevicesSave(type: 'action' | 'rule', data: NS, coverage?: DeviceCoverage[]) {
|
||||||
setPendingPush({ type, operation: coverage ? 'update' : 'add', data, coverage });
|
setPendingPush({ type, operation: coverage ? 'update' : 'add', data, coverage });
|
||||||
setActionForm(null); setRuleForm(null);
|
setActionForm(null); setRuleForm(null);
|
||||||
@@ -533,136 +502,12 @@ export default function NetworkServicesSyslogPage() {
|
|||||||
invalidateAll();
|
invalidateAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Shared table renderers ────────────────────────────────────────────────
|
async function handleToggleRuleAll(coverage: DeviceCoverage[], disabled: boolean) {
|
||||||
|
setTogglePending(true);
|
||||||
function renderActionsTable(rows: NS[], isBuiltinFn: (r: NS) => boolean, onEdit: (r: NS) => void, onDelete: (r: NS) => void) {
|
try {
|
||||||
return (
|
await Promise.allSettled(coverage.map(c => networkServicesApi.toggleSyslogRule(c.deviceId, c.rowId, disabled)));
|
||||||
<div className="overflow-x-auto">
|
invalidateAll();
|
||||||
<table className="w-full text-sm">
|
} finally { setTogglePending(false); }
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -673,15 +518,11 @@ export default function NetworkServicesSyslogPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Syslog</h1>
|
<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>
|
</div>
|
||||||
{mode === 'single' && deviceId > 0 && (
|
{onlineDevices.length > 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 && (
|
|
||||||
<button onClick={() => allRefetch()} disabled={allFetching}
|
<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">
|
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
|
<RefreshCw className={clsx('w-3.5 h-3.5', allFetching && 'animate-spin')} />Refresh
|
||||||
@@ -689,108 +530,6 @@ export default function NetworkServicesSyslogPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{onlineDevices.length === 0 && (
|
||||||
<div className="card p-8 text-center text-sm text-gray-400 dark:text-slate-500">No online devices found.</div>
|
<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 && (
|
{onlineDevices.length > 0 && !allLoading && (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
{/* Logging Actions */}
|
{/* Logging Actions */}
|
||||||
<div className="card overflow-hidden">
|
<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" />
|
<FileText className="w-4 h-4 text-blue-500" />
|
||||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">
|
<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>
|
Logging Actions<span className="ml-2 text-xs font-normal text-gray-400 dark:text-slate-500">({aggregatedActions.length} unique)</span>
|
||||||
</h2>
|
</h2>
|
||||||
{canWrite && (
|
{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({})}
|
<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
|
<Plus className="w-3.5 h-3.5" />Add to All Devices
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{aggregatedActions.length === 0
|
{aggregatedActions.length === 0
|
||||||
@@ -864,14 +611,14 @@ export default function NetworkServicesSyslogPage() {
|
|||||||
{canWrite && (
|
{canWrite && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActionForm({ existing: sample, allCoverage: coverage })}
|
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" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canWrite && !isBuiltin && (
|
{canWrite && !isBuiltin && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmDeleteAll({ type: 'action', coverage, label: `action "${sample['name']}"` })}
|
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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -889,16 +636,24 @@ export default function NetworkServicesSyslogPage() {
|
|||||||
|
|
||||||
{/* Logging Rules */}
|
{/* Logging Rules */}
|
||||||
<div className="card overflow-hidden">
|
<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" />
|
<FileText className="w-4 h-4 text-indigo-500" />
|
||||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">
|
<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>
|
Logging Rules<span className="ml-2 text-xs font-normal text-gray-400 dark:text-slate-500">({aggregatedRules.length} unique)</span>
|
||||||
</h2>
|
</h2>
|
||||||
{canWrite && (
|
{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({})}
|
<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
|
<Plus className="w-3.5 h-3.5" />Add to All Devices
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{aggregatedRules.length === 0
|
{aggregatedRules.length === 0
|
||||||
@@ -941,7 +696,7 @@ export default function NetworkServicesSyslogPage() {
|
|||||||
{canWrite && (
|
{canWrite && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setRuleForm({ existing: sample, allCoverage: coverage })}
|
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" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -950,14 +705,14 @@ export default function NetworkServicesSyslogPage() {
|
|||||||
onClick={() => handleToggleRuleAll(coverage, !isDisabled)}
|
onClick={() => handleToggleRuleAll(coverage, !isDisabled)}
|
||||||
disabled={togglePending}
|
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"
|
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>
|
<span className="text-xs font-medium">{isDisabled ? 'En' : 'Dis'}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canWrite && (
|
{canWrite && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmDeleteAll({ type: 'rule', coverage, label: `rule "${sample['topics']}"` })}
|
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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -999,32 +754,37 @@ export default function NetworkServicesSyslogPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Modals ───────────────────────────────────────────────────────────── */}
|
{/* ── Modals ───────────────────────────────────────────────────────────── */}
|
||||||
|
|
||||||
{actionForm && mode === 'single' && (
|
{actionForm && (
|
||||||
<ActionForm existing={actionForm.existing} onSave={handleSaveAction}
|
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(''); }}
|
onClose={() => { setActionForm(null); setSaveError(''); }}
|
||||||
isPending={savePending} error={saveError} />
|
isPending={savePending} error={saveError} />
|
||||||
)}
|
) : (
|
||||||
{actionForm && mode === 'all' && (
|
|
||||||
<ActionForm allDevices existing={actionForm.existing}
|
<ActionForm allDevices existing={actionForm.existing}
|
||||||
onSave={data => handleAllDevicesSave('action', data, actionForm.allCoverage)}
|
onSave={data => handleAllDevicesSave('action', data, actionForm.allCoverage)}
|
||||||
onClose={() => setActionForm(null)} isPending={false} />
|
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(''); }}
|
onClose={() => { setRuleForm(null); setSaveError(''); }}
|
||||||
isPending={savePending} error={saveError} />
|
isPending={savePending} error={saveError} />
|
||||||
)}
|
) : (
|
||||||
{ruleForm && mode === 'all' && (
|
|
||||||
<RuleForm allDevices existing={ruleForm.existing} actions={[]}
|
<RuleForm allDevices existing={ruleForm.existing} actions={[]}
|
||||||
onSave={data => handleAllDevicesSave('rule', data, ruleForm.allCoverage)}
|
onSave={data => handleAllDevicesSave('rule', data, ruleForm.allCoverage)}
|
||||||
onClose={() => setRuleForm(null)} isPending={false} />
|
onClose={() => setRuleForm(null)} isPending={false} />
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Confirm push to all */}
|
{/* Confirm push to all */}
|
||||||
@@ -1075,7 +835,7 @@ export default function NetworkServicesSyslogPage() {
|
|||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<AlertTriangle className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
<AlertTriangle className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<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">
|
<p className="mt-1 text-sm text-gray-600 dark:text-slate-300">
|
||||||
This will remove the <strong>{confirmDeleteAll.label}</strong> from{' '}
|
This will remove the <strong>{confirmDeleteAll.label}</strong> from{' '}
|
||||||
<strong>{confirmDeleteAll.coverage.length} device{confirmDeleteAll.coverage.length !== 1 ? 's' : ''}</strong>:{' '}
|
<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>
|
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}
|
<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">
|
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'}
|
{isPushing ? <><RefreshCw className="w-3.5 h-3.5 animate-spin" />Removing…</> : 'Remove'}
|
||||||
</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'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = 'v0.15.1 Beta';
|
export const APP_VERSION = 'v0.16.0 Beta';
|
||||||
|
|||||||
Reference in New Issue
Block a user