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
+4
View File
@@ -821,6 +821,10 @@ const DEFAULT_SETTINGS = [
{ key: 'retention_events_days', value: 30 },
{ key: 'backup_schedule_enabled', value: false },
{ key: 'backup_schedule_cron', value: '0 2 * * *' },
// Scheduled work was evaluated against the container clock, which is UTC in
// the shipped image. A "02:00" backup therefore fired at 02:00 UTC regardless
// of where the operator was — seven hours out on the US west coast (#117).
{ key: 'app_timezone', value: 'UTC' },
{ key: 'mac_scan_enabled', value: true },
{ key: 'mac_scan_interval', value: 300 },
{ key: 'reverse_dns_enabled', value: false },
+5 -45
View File
@@ -8,6 +8,7 @@ import { Point } from '@influxdata/influxdb-client';
import { alertService } from './AlertService';
import { runConfigHealth } from './changeGuard/configHealth';
import type { GuardDevice } from './changeGuard/ChangeGuard';
import { cronMatches } from '../utils/cron';
// ─── Tuning ───────────────────────────────────────────────────────────────────
@@ -240,6 +241,8 @@ export class PollerService {
const apScanIntervalHours = (appSettings['ap_scan_interval_hours'] as number) || 24;
const backupScheduleEnabled = appSettings['backup_schedule_enabled'] === true;
const backupScheduleCron = (appSettings['backup_schedule_cron'] as string) || '0 2 * * *';
// Schedules are the operator's local time, not the container's (#117).
const appTimezone = (appSettings['app_timezone'] as string) || 'UTC';
const configSnapEnabled = appSettings['config_snapshot_enabled'] !== false;
const configSnapIntervalMin = (appSettings['config_snapshot_interval_min'] as number) || 60;
const configHealthEnabled = appSettings['config_health_enabled'] !== false;
@@ -395,7 +398,7 @@ export class PollerService {
// Scheduled backups — fire when the cron expression matches the current minute/hour.
// Redis key with 1-hour TTL prevents double-firing within the same cron window.
if (backupScheduleEnabled && this.cronMatchesNow(backupScheduleCron)) {
if (backupScheduleEnabled && cronMatches(backupScheduleCron, new Date(), appTimezone)) {
const backupKey = 'task:scheduled_backup';
const lastBackup = await this.getTimestamp(backupKey);
if (now - lastBackup > 3_600_000) {
@@ -419,7 +422,7 @@ export class PollerService {
'spectral_scan_interval_hours', 'ap_scan_enabled',
'ap_scan_interval_hours', 'backup_schedule_enabled',
'backup_schedule_cron', 'config_snapshot_enabled',
'config_snapshot_interval_min')`
'config_snapshot_interval_min', 'app_timezone')`
);
const map: Record<string, unknown> = {};
for (const row of rows) map[row.key] = row.value;
@@ -429,49 +432,6 @@ export class PollerService {
}
}
// Returns true if the 5-part cron expression matches the current time.
// Evaluates all five fields — minute, hour, day-of-month, month, day-of-week —
// so weekly (e.g. `0 3 * * 0`) and monthly (`0 3 1 * *`) schedules fire only on
// the right day, not every day. Supports: *, exact numbers, comma lists,
// ranges (a-b), and step values (*/n). Day-of-month and day-of-week follow the
// standard cron rule: when both are restricted the job runs if either matches;
// when only one is restricted, only that one must match.
private cronMatchesNow(cron: string): boolean {
const parts = cron.trim().split(/\s+/);
if (parts.length < 5) return false;
const [minuteField, hourField, domField, monthField, dowField] = parts;
const now = new Date();
const matchField = (field: string, val: number): boolean => {
if (field === '*') return true;
return field.split(',').some((f) => {
if (f.includes('/')) {
const [base, step] = f.split('/');
const start = base === '*' ? 0 : Number(base);
return val >= start && (val - start) % Number(step) === 0;
}
if (f.includes('-')) {
const [lo, hi] = f.split('-').map(Number);
return val >= lo && val <= hi;
}
return Number(f) === val;
});
};
if (!matchField(minuteField, now.getMinutes())) return false;
if (!matchField(hourField, now.getHours())) return false;
if (!matchField(monthField, now.getMonth() + 1)) return false; // cron months are 1-12
// Day-of-month (1-31) and day-of-week (0-6, Sun=0). getDay() returns 0 for Sunday.
const domRestricted = domField !== '*';
const dowRestricted = dowField !== '*';
const domMatch = matchField(domField, now.getDate());
const dowMatch = matchField(dowField, now.getDay());
let dayMatch: boolean;
if (!domRestricted && !dowRestricted) dayMatch = true;
else if (domRestricted && dowRestricted) dayMatch = domMatch || dowMatch;
else dayMatch = domRestricted ? domMatch : dowMatch;
return dayMatch;
}
private async runScheduledBackups(): Promise<void> {
const { BackupService } = await import('./BackupService');
+94
View File
@@ -0,0 +1,94 @@
import { cronMatches, zonedParts, matchField } from '../cron';
// 2026-09-02T09:30:00Z — a Wednesday.
const AT = new Date('2026-09-02T09:30:00Z');
describe('zonedParts', () => {
it('reads wall-clock fields in the requested zone', () => {
expect(zonedParts(AT, 'UTC')).toEqual({ minute: 30, hour: 9, dom: 2, month: 9, dow: 3 });
// UTC-7 in September.
expect(zonedParts(AT, 'America/Los_Angeles')).toEqual({ minute: 30, hour: 2, dom: 2, month: 9, dow: 3 });
// UTC+3 in September.
expect(zonedParts(AT, 'Europe/Sofia')).toEqual({ minute: 30, hour: 12, dom: 2, month: 9, dow: 3 });
});
it('rolls the date backwards across the dateline', () => {
// 01:00 UTC on the 2nd is still the 1st in Los Angeles.
const p = zonedParts(new Date('2026-09-02T01:00:00Z'), 'America/Los_Angeles');
expect(p.dom).toBe(1);
expect(p.hour).toBe(18);
expect(p.dow).toBe(2); // Tuesday, not Wednesday
});
it('handles midnight as hour 0, never 24', () => {
expect(zonedParts(new Date('2026-09-02T00:00:00Z'), 'UTC').hour).toBe(0);
});
it('respects daylight saving rather than a fixed offset', () => {
const summer = zonedParts(new Date('2026-07-01T12:00:00Z'), 'America/New_York').hour; // EDT, -4
const winter = zonedParts(new Date('2026-01-01T12:00:00Z'), 'America/New_York').hour; // EST, -5
expect(summer).toBe(8);
expect(winter).toBe(7);
});
it('falls back to UTC for an unknown zone instead of throwing', () => {
expect(zonedParts(AT, 'Mars/Olympus_Mons')).toEqual(zonedParts(AT, 'UTC'));
});
});
describe('matchField', () => {
it('handles wildcards, lists, ranges and steps', () => {
expect(matchField('*', 5)).toBe(true);
expect(matchField('5', 5)).toBe(true);
expect(matchField('1,3,5', 3)).toBe(true);
expect(matchField('1,3,5', 4)).toBe(false);
expect(matchField('1-5', 4)).toBe(true);
expect(matchField('1-5', 6)).toBe(false);
expect(matchField('*/15', 30)).toBe(true);
expect(matchField('*/15', 31)).toBe(false);
});
it('rejects a zero or nonsense step rather than dividing by it', () => {
expect(matchField('*/0', 5)).toBe(false);
expect(matchField('*/x', 5)).toBe(false);
});
});
describe('cronMatches — the #117 bug', () => {
it('fires a 02:00 job at 02:00 local, not 02:00 UTC', () => {
// The exact failure: 09:30 UTC is 02:30 in Los Angeles.
const at0230 = new Date('2026-09-02T09:30:00Z');
expect(cronMatches('30 2 * * *', at0230, 'America/Los_Angeles')).toBe(true);
expect(cronMatches('30 2 * * *', at0230, 'UTC')).toBe(false);
});
it('does not fire the same job twice in different zones', () => {
const at = new Date('2026-09-02T09:30:00Z');
const zones = ['UTC', 'America/Los_Angeles', 'Europe/Sofia'];
const fired = zones.filter((z) => cronMatches('30 2 * * *', at, z));
expect(fired).toEqual(['America/Los_Angeles']);
});
it('matches day-of-week in the local zone', () => {
// 01:00 UTC Wednesday is still Tuesday evening in Los Angeles.
const at = new Date('2026-09-02T01:00:00Z');
expect(cronMatches('0 18 * * 2', at, 'America/Los_Angeles')).toBe(true); // Tue
expect(cronMatches('0 18 * * 3', at, 'America/Los_Angeles')).toBe(false); // Wed
});
it('runs when either dom or dow matches, per the standard rule', () => {
const at = new Date('2026-09-02T09:30:00Z'); // 2nd, a Wednesday, 09:30 UTC
expect(cronMatches('30 9 2 * 0', at, 'UTC')).toBe(true); // dom matches, dow does not
expect(cronMatches('30 9 15 * 3', at, 'UTC')).toBe(true); // dow matches, dom does not
expect(cronMatches('30 9 15 * 0', at, 'UTC')).toBe(false); // neither
});
it('requires all five fields', () => {
expect(cronMatches('30 9 * *', AT, 'UTC')).toBe(false);
expect(cronMatches('', AT, 'UTC')).toBe(false);
});
it('defaults to UTC when no zone is given', () => {
expect(cronMatches('30 9 * * *', AT)).toBe(true);
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* Cron matching in a named timezone.
*
* Split out of PollerService because the bug it fixes was invisible in place:
* scheduling used `Date`'s own accessors, which read the *process* timezone.
* The shipped container runs UTC, so a "02:00" backup fired at 02:00 UTC no
* matter where the operator was — seven hours early on the US west coast, and
* on the wrong day for anyone far enough east (#117).
*
* Zone handling goes through Intl rather than offset arithmetic, which gets
* daylight saving right without a date library or a table of rules.
*/
export interface ZonedParts {
minute: number;
hour: number;
/** Day of month, 1-31. */
dom: number;
/** Month, 1-12 — cron's numbering, not JavaScript's. */
month: number;
/** Day of week, 0-6 with Sunday as 0. */
dow: number;
}
const DOW: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
/** Wall-clock fields of an instant in a given zone. Falls back to UTC if unknown. */
export function zonedParts(date: Date, timeZone: string): ZonedParts {
try {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone, hour12: false,
minute: '2-digit', hour: '2-digit', day: '2-digit', month: '2-digit', weekday: 'short',
}).formatToParts(date).map((p) => [p.type, p.value])
);
return {
minute: Number(parts.minute),
// Some environments render midnight as hour "24".
hour: Number(parts.hour) % 24,
dom: Number(parts.day),
month: Number(parts.month),
dow: DOW[parts.weekday] ?? 0,
};
} catch {
return {
minute: date.getUTCMinutes(), hour: date.getUTCHours(), dom: date.getUTCDate(),
month: date.getUTCMonth() + 1, dow: date.getUTCDay(),
};
}
}
/** Does one cron field match a value? Supports `*`, lists, ranges and steps. */
export function matchField(field: string, val: number): boolean {
if (field === '*') return true;
return field.split(',').some((f) => {
if (f.includes('/')) {
const [base, step] = f.split('/');
const start = base === '*' ? 0 : Number(base);
const by = Number(step);
if (!Number.isFinite(by) || by <= 0) return false;
return val >= start && (val - start) % by === 0;
}
if (f.includes('-')) {
const [lo, hi] = f.split('-').map(Number);
return val >= lo && val <= hi;
}
return Number(f) === val;
});
}
/**
* Does a 5-field cron expression match this instant, read in `timeZone`?
*
* Day-of-month and day-of-week follow the standard rule: when both are
* restricted the job runs if *either* matches, not both.
*/
export function cronMatches(cron: string, at: Date, timeZone = 'UTC'): boolean {
const parts = cron.trim().split(/\s+/);
if (parts.length < 5) return false;
const [minuteField, hourField, domField, monthField, dowField] = parts;
const now = zonedParts(at, timeZone);
if (!matchField(minuteField, now.minute)) return false;
if (!matchField(hourField, now.hour)) return false;
if (!matchField(monthField, now.month)) return false;
const domRestricted = domField !== '*';
const dowRestricted = dowField !== '*';
const domMatch = matchField(domField, now.dom);
const dowMatch = matchField(dowField, now.dow);
if (domRestricted && dowRestricted) return domMatch || dowMatch;
if (domRestricted) return domMatch;
if (dowRestricted) return dowMatch;
return true;
}