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:
2GT-Rich
2026-09-02 08:18:08 -07:00
co-authored by Claude Opus 5
parent dd71271473
commit 7d02bedc5f
10 changed files with 307 additions and 57 deletions
+53 -2
View File
@@ -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' && (
+51 -6
View File
@@ -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
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.23.20 Beta';
export const APP_VERSION = 'v0.23.21 Beta';