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. 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) ![License](https://img.shields.io/badge/license-AGPLv3-blue)
![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![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) ![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", "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 -1
View File
@@ -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": {
+2 -2
View File
@@ -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 -1
View File
@@ -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": {
+101 -26
View File
@@ -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">
</span> {/* Entries-per-page + range summary */}
<div className="flex gap-2"> <div className="flex items-center gap-2 text-gray-500 dark:text-slate-400">
<button <span>Show</span>
onClick={() => setPage((p) => Math.max(0, p - 1))} <select
disabled={page === 0} value={pageSize}
className="btn-secondary py-1" 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"
Previous >
</button> {PAGE_SIZE_OPTIONS.map((n) => (
<button <option key={n} value={n}>{n}</option>
onClick={() => setPage((p) => p + 1)} ))}
disabled={(page + 1) * PAGE_SIZE >= total} </select>
className="btn-secondary py-1" <span>entries</span>
> <span className="ml-1 hidden sm:inline">
Next ({page * pageSize + 1}–{Math.min((page + 1) * pageSize, total)} of {total})
</button> </span>
</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}
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"
>
<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
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'
)}
>
{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> );
)} })()}
</> </>
)} )}
</div> </div>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.15.1 Beta'; export const APP_VERSION = 'v0.16.0 Beta';