feat: schedule in the operator's timezone, and make Top Talkers searchable (v0.23.21-beta)
Closes #117. **Scheduled work ran on the container's clock.** cronMatchesNow used Date's own accessors, which read the process timezone — UTC in the shipped image. A backup set for 02:00 therefore fired at 02:00 UTC wherever the operator was: seven hours early on the US west coast, and on the wrong day entirely for anyone far enough east. The settings page said "server time", which was accurate and precisely the problem. Reported by a user managing devices across regions, who described it as a configuration gap; it is a correctness bug. There is now an app_timezone setting, chosen from the zones the runtime knows and offered alongside the browser's own as a one-click default. Schedules are evaluated in it, and the page states the zone and the current time in it, so a wrong setting is obvious immediately rather than at 2am three days later. The matching logic moved to utils/cron.ts with 13 tests, because the bug was invisible where it lived: it only manifests when the process zone and the operator's differ, which never happens in a developer's own shell. The tests pin the cases that matter — the same instant matching in exactly one of three zones, a job on the 2nd UTC still being the 1st in Los Angeles, day-of-week resolved locally, and daylight saving handled by Intl rather than by a fixed offset. Zone handling goes through Intl for that last reason; offset arithmetic would be wrong twice a year. An unknown zone falls back to UTC rather than throwing, so a bad value cannot stop every schedule in the product. **Top Talkers was ten rows with no way to look anything up**, which is unusable on a network of any size — you could see the loudest ten and nothing else. It now filters on name, IP, MAC or vendor, and ranks 10, 25 or 50. Filtering is client-side because the rows are already present; asking the server per keystroke would be slower and no more accurate. Requested in the discussion thread, where the backend already served everything needed and only the UI was missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
dd71271473
commit
7d02bedc5f
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.23.20-beta",
|
||||
"version": "0.23.21-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Settings, Users, Key, Plus, Trash2, CheckCircle, AlertCircle, Pencil, X,
|
||||
@@ -51,6 +51,20 @@ export default function SettingsPage() {
|
||||
const { theme, setTheme } = useThemeStore();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const canWrite = user?.role !== 'viewer';
|
||||
// Timezone for scheduled work. The browser's zone is offered as a shortcut
|
||||
// because it is almost always what the operator means.
|
||||
const timezoneOptions = useMemo<string[]>(() => {
|
||||
const supported = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf;
|
||||
try {
|
||||
return supported ? supported('timeZone') : ['UTC'];
|
||||
} catch {
|
||||
return ['UTC'];
|
||||
}
|
||||
}, []);
|
||||
const browserTimezone = useMemo(() => {
|
||||
try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return ''; }
|
||||
}, []);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'users' | 'sso' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance' | 'templates' | 'automation' | 'poller'>('general');
|
||||
const [auditSearch, setAuditSearch] = useState('');
|
||||
const [auditPage, setAuditPage] = useState(1);
|
||||
@@ -823,6 +837,15 @@ export default function SettingsPage() {
|
||||
const enabled = !!settings['backup_schedule_enabled'];
|
||||
const sched = parseBackupCron(settings['backup_schedule_cron'] as string);
|
||||
const controlsDisabled = !isAdmin || !enabled;
|
||||
const appTimezone = (settings['app_timezone'] as string) || 'UTC';
|
||||
// Showing the clock in the chosen zone makes a wrong setting
|
||||
// obvious immediately, rather than at 2am three days later.
|
||||
let currentTimeInAppTz = '';
|
||||
try {
|
||||
currentTimeInAppTz = new Intl.DateTimeFormat(undefined, {
|
||||
timeZone: appTimezone, hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).format(new Date());
|
||||
} catch { currentTimeInAppTz = 'unknown zone'; }
|
||||
const apply = (next: typeof sched) =>
|
||||
updateSettingsMutation.mutate({ backup_schedule_cron: scheduleToCron(next) });
|
||||
return (
|
||||
@@ -910,9 +933,37 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timezone belongs beside the schedule, because it is the
|
||||
schedule's unit. Scheduled work used to be evaluated against
|
||||
the container clock — UTC in the shipped image — so "02:00"
|
||||
meant 02:00 UTC wherever you were (#117). */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="text-xs text-gray-500 dark:text-slate-400">Timezone</label>
|
||||
<select
|
||||
value={appTimezone}
|
||||
disabled={!isAdmin}
|
||||
onChange={(e) => updateSettingsMutation.mutate({ app_timezone: e.target.value })}
|
||||
className="input text-xs py-1 max-w-[260px]"
|
||||
>
|
||||
{timezoneOptions.map((tz) => <option key={tz} value={tz}>{tz}</option>)}
|
||||
</select>
|
||||
{browserTimezone && browserTimezone !== appTimezone && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isAdmin}
|
||||
onClick={() => updateSettingsMutation.mutate({ app_timezone: browserTimezone })}
|
||||
className="text-xs underline text-blue-600 dark:text-blue-400 disabled:opacity-50"
|
||||
>
|
||||
Use {browserTimezone}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className={clsx('text-xs', enabled ? 'text-gray-500 dark:text-slate-400' : 'text-gray-400 dark:text-slate-600')}>
|
||||
{enabled
|
||||
? <>Runs <span className="font-medium text-gray-700 dark:text-slate-300">{describeBackupSchedule(sched).toLowerCase()}</span>, server time.</>
|
||||
? <>Runs <span className="font-medium text-gray-700 dark:text-slate-300">{describeBackupSchedule(sched).toLowerCase()}</span>{' '}
|
||||
in <span className="font-medium text-gray-700 dark:text-slate-300">{appTimezone}</span>
|
||||
{' '}(currently {currentTimeInAppTz}).</>
|
||||
: 'Enable to choose a backup schedule.'}
|
||||
</p>
|
||||
{sched.frequency === 'monthly' && (
|
||||
|
||||
@@ -54,6 +54,13 @@ function clientLabel(c: { mac: string; custom_name: string | null; hostname: str
|
||||
|
||||
export default function TrafficAnalyticsPage() {
|
||||
const [range, setRange] = useState<Range>('24h');
|
||||
// Top Talkers was a fixed list of ten with no way to look anything up, which
|
||||
// is unusable on a network of any size — you could see the loudest ten and
|
||||
// nothing else. Filtering happens client-side because the rows are already
|
||||
// here; asking the server again per keystroke would be slower and no more
|
||||
// accurate.
|
||||
const [clientQuery, setClientQuery] = useState('');
|
||||
const [topLimit, setTopLimit] = useState(10);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: series = [], isLoading: seriesLoading } = useQuery({
|
||||
@@ -63,8 +70,8 @@ export default function TrafficAnalyticsPage() {
|
||||
});
|
||||
|
||||
const { data: topClients = [], isLoading: clientsLoading } = useQuery({
|
||||
queryKey: ['traffic-top-clients', range],
|
||||
queryFn: () => trafficApi.topClients(range, 10).then(r => r.data),
|
||||
queryKey: ['traffic-top-clients', range, topLimit],
|
||||
queryFn: () => trafficApi.topClients(range, topLimit).then(r => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
@@ -88,6 +95,17 @@ export default function TrafficAnalyticsPage() {
|
||||
const totalAppBytes = apps.reduce((s, a) => s + a.bytes, 0);
|
||||
const maxClientBytes = topClients[0]?.total_bytes || 1;
|
||||
|
||||
// Match on anything the row actually shows — name, IP, MAC or vendor — so a
|
||||
// search for "192.168.1." or "apple" both behave the way people expect.
|
||||
const visibleClients = (() => {
|
||||
const q = clientQuery.trim().toLowerCase();
|
||||
if (!q) return topClients;
|
||||
return topClients.filter((c) =>
|
||||
[clientLabel(c), c.ip_address, c.mac, c.vendor]
|
||||
.some((v) => v && String(v).toLowerCase().includes(q))
|
||||
);
|
||||
})();
|
||||
|
||||
// Collector data-health: how much of the received traffic we could attribute
|
||||
const exporters = [...(status?.exporters || [])].sort((a, b) => b.flows - a.flows);
|
||||
const pktReceived = status?.packetsReceived || 0;
|
||||
@@ -223,15 +241,42 @@ export default function TrafficAnalyticsPage() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
{/* Top talkers */}
|
||||
<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">
|
||||
<BarChart3 className="w-4 h-4 text-blue-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">Top Talkers</h2>
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<BarChart3 className="w-4 h-4 text-blue-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">Top Talkers</h2>
|
||||
<span className="text-xs text-gray-400 dark:text-slate-500 ml-auto">
|
||||
{clientQuery.trim()
|
||||
? `${visibleClients.length} of ${topClients.length}`
|
||||
: `top ${topClients.length}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={clientQuery}
|
||||
onChange={(e) => setClientQuery(e.target.value)}
|
||||
placeholder="Filter by IP, name, MAC or vendor…"
|
||||
className="input text-xs py-1 flex-1 min-w-0"
|
||||
/>
|
||||
<select
|
||||
value={topLimit}
|
||||
onChange={(e) => setTopLimit(Number(e.target.value))}
|
||||
className="input text-xs py-1 w-auto"
|
||||
title="How many clients to rank"
|
||||
>
|
||||
{[10, 25, 50].map((n) => <option key={n} value={n}>Top {n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{topClients.length === 0
|
||||
? <div className="p-6 text-center text-sm text-gray-400 dark:text-slate-500">No client traffic recorded.</div>
|
||||
: visibleClients.length === 0
|
||||
? <div className="p-6 text-center text-sm text-gray-400 dark:text-slate-500">
|
||||
No client in the top {topClients.length} matches “{clientQuery.trim()}”.
|
||||
</div>
|
||||
: (
|
||||
<div className="divide-y divide-gray-100 dark:divide-slate-800">
|
||||
{topClients.map(c => {
|
||||
{visibleClients.map(c => {
|
||||
const isReal = c.mac !== 'unknown' && c.mac !== 'other';
|
||||
return (
|
||||
<div key={c.mac}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.23.20 Beta';
|
||||
export const APP_VERSION = 'v0.23.21 Beta';
|
||||
|
||||
Reference in New Issue
Block a user