feat: add configurable refresh interval to Clients page (closes #32)

Add a right-justified refresh interval dropdown above the client table
with options for Not Updating, 30 s, 1 min, and 3 min. The chosen
interval controls both React Query polling and WebSocket-triggered
invalidations — selecting Not Updating fully freezes the list until
page reload. Preference persists via localStorage.
This commit is contained in:
2GT-Rich
2026-04-23 16:20:22 -07:00
parent 68d88d0306
commit ac39598257
5 changed files with 55 additions and 8 deletions
+2 -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.10.6_Beta-blue)
![Version](https://img.shields.io/badge/version-0.10.7_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)
@@ -150,6 +150,7 @@ Each service supports multi-device management with conflict detection:
- Filter by device, type, active status, or search by MAC/IP/hostname
- Client detail page with connection history and vendor identification
- Historical client count metrics
- Configurable list refresh interval (Not Updating / 30 s / 1 min / 3 min) with preference saved per browser
### Backups
- Trigger RouterOS backups on demand via SSH
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
"version": "0.10.6-beta",
"version": "0.10.7-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
"version": "0.10.6-beta",
"version": "0.10.7-beta",
"private": true,
"type": "module",
"scripts": {
+50 -4
View File
@@ -1,7 +1,7 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Search, Wifi, Network, Users, X, Pencil, Trash2, ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
import { Search, Wifi, Network, Users, X, Pencil, Trash2, ChevronUp, ChevronDown, ChevronsUpDown, RefreshCw } from 'lucide-react';
import { clientsApi } from '../services/api';
import type { Client } from '../types';
import { useCanWrite } from '../hooks/useCanWrite';
@@ -10,6 +10,22 @@ import { formatDistanceToNow } from 'date-fns';
import clsx from 'clsx';
const REFRESH_OPTIONS = [
{ label: 'Not Updating', value: null },
{ label: '30 seconds', value: 30_000 },
{ label: '1 minute', value: 60_000 },
{ label: '3 minutes', value: 180_000 },
] as const;
const STORAGE_KEY = 'clients-refresh-interval';
function readStoredInterval(): number | null {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === 'null') return null;
const n = Number(raw);
return REFRESH_OPTIONS.some((o) => o.value === n) ? n : 30_000;
}
function ClientModal({
client,
onClose,
@@ -113,8 +129,17 @@ export default function ClientsPage() {
const [editingClient, setEditingClient] = useState<Client | null>(null);
const [sortCol, setSortCol] = useState<string>('last_seen');
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
const [refreshInterval, setRefreshInterval] = useState<number | null>(readStoredInterval);
const refreshIntervalRef = useRef(refreshInterval);
refreshIntervalRef.current = refreshInterval;
const PAGE_SIZE = 50;
const handleIntervalChange = (val: number | null) => {
setRefreshInterval(val);
refreshIntervalRef.current = val;
localStorage.setItem(STORAGE_KEY, String(val));
};
const toggleSort = (col: string) => {
if (sortCol === col) setSortDir((d) => d === 'asc' ? 'desc' : 'asc');
else { setSortCol(col); setSortDir('asc'); }
@@ -126,7 +151,7 @@ export default function ClientsPage() {
clientsApi
.list({ search: search || undefined, active: showAll ? undefined : true, limit: PAGE_SIZE, offset: page * PAGE_SIZE })
.then((r) => r.data),
refetchInterval: 30_000,
refetchInterval: refreshInterval ?? false,
});
const [purgeResult, setPurgeResult] = useState('');
@@ -140,7 +165,11 @@ export default function ClientsPage() {
});
useSocket({
'clients:updated': () => queryClient.invalidateQueries({ queryKey: ['clients'] }),
'clients:updated': () => {
if (refreshIntervalRef.current !== null) {
queryClient.invalidateQueries({ queryKey: ['clients'] });
}
},
});
const rawClients = data?.clients ?? [];
@@ -227,6 +256,23 @@ export default function ClientsPage() {
{purgeResult && (
<span className="text-xs text-green-600 dark:text-green-400">{purgeResult}</span>
)}
<div className="flex items-center gap-1.5 ml-auto">
<RefreshCw className="w-3.5 h-3.5 text-gray-400 dark:text-slate-500" />
<select
value={String(refreshInterval)}
onChange={(e) => {
const raw = e.target.value;
handleIntervalChange(raw === 'null' ? null : Number(raw));
}}
className="text-sm rounded-lg border border-gray-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-gray-700 dark:text-slate-200 px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{REFRESH_OPTIONS.map((o) => (
<option key={String(o.value)} value={String(o.value)}>
{o.label}
</option>
))}
</select>
</div>
</div>
{isLoading ? (
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.10.6 Beta';
export const APP_VERSION = 'v0.10.7 Beta';