fix(poller): drain stale backlogs, and stop reporting health that isn't (v0.23.19-beta)
v0.23.17 stopped the poller creating an unbounded backlog but did nothing about
the one already there. A reporter with sixty devices upgraded and still had
752,318 jobs queued, nine stale devices, and an endpoint cheerfully reporting
1.59x headroom. Fair to call that not fixed.
Three faults, all mine.
**Retention never touched pending work.** cleanupJobHistory cleaned 'completed'
and 'failed' only. The queue of outstanding polls — the part the operator
actually feels — was out of scope, so an upgrade left it draining for days.
Stale pending jobs are now swept, at startup and every five minutes. The rule is
that a periodic poll which has waited longer than its own cadence is worthless:
running it yields a reading the next cycle would have taken anyway, while
occupying a worker the live fleet is waiting on. Discarding queued work is safe
precisely because it is periodic — the schedule re-enqueues within one interval.
**Deduplication failed in the case it existed for.** The in-flight claim was a
Redis key with a 135-second TTL. Under a large backlog a job waits hours, so the
claim lapsed long before it ran and the scheduler re-enqueued every cycle: the
guard worked only on a queue that was already short.
Replaced with a deterministic job id, which cannot lapse. While a job for a
device and kind exists in any state, adding it again is a no-op. That requires
finished jobs to be removed immediately or the id would stay taken and the
device would never be polled again — which costs nothing now that
device_poll_stats records every attempt, duration and error.
**The headroom metric lied.** It describes flow and is blind to accumulated
work, so it read "healthy" beside three-quarters of a million queued jobs. It
now reports backlog and drain ETA alongside, plus a single status verdict that
cannot say ok while a backlog exists. Verified by injecting 15,125 jobs: status
went to 'draining' with headroom still at 85x, which is exactly the case the old
metric got wrong.
Also added, because the reporter asked and was right to: an operator-triggered
drain (POST /api/system/poller/drain), and a Polling tab in Settings showing
queue depth, headroom, backlog, per-device staleness and the drain control. It
was all buried in an API response before. A dashboard insight fires when the
poller falls behind, so a starved poller announces itself rather than presenting
as a fleet of broken devices.
Two corrections found by testing rather than reasoning:
* BullMQ rejects ':' in a custom job id. `fast:2` threw on every enqueue and
killed the scheduler outright — three of four devices went unpolled for
eight minutes before it was caught. Hyphens now, and the dedup is exercised
directly: 500 enqueues of one device yield one queued job.
* Redis maxmemory-policy moves from volatile-lru to noeviction. I had reasoned
that only TTL-bearing keys are evicted and BullMQ job hashes carry none —
true, but its *control* keys do. Job locks and stalled-check markers both
expire, and evicting one causes duplicate processing or a stalled-job storm.
BullMQ warns about this on connect and was right.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
deb71363c9
commit
745a7cda93
+3
-2
@@ -31,6 +31,7 @@ INFLUXDB_ADMIN_PASSWORD=admin_password_123
|
|||||||
# POLLER_JOB_RETENTION_SEC=3600
|
# POLLER_JOB_RETENTION_SEC=3600
|
||||||
# POLLER_JOB_RETENTION_COUNT=5000
|
# POLLER_JOB_RETENTION_COUNT=5000
|
||||||
|
|
||||||
# Redis memory ceiling. Only TTL-bearing keys are evicted (volatile-lru), so
|
# Redis memory ceiling. Eviction is disabled (noeviction) because BullMQ's locks
|
||||||
# queued jobs are never discarded; the cap exists to protect the host.
|
# and stalled-check keys carry TTLs and must never be evicted; the cap exists to
|
||||||
|
# protect the host, and retention keeps usage well below it.
|
||||||
# REDIS_MAXMEMORY=512mb
|
# REDIS_MAXMEMORY=512mb
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/version-0.23.18_Beta-blue" alt="Version" />
|
<img src="https://img.shields.io/badge/version-0.23.19_Beta-blue" alt="Version" />
|
||||||
<img src="https://img.shields.io/badge/license-AGPLv3-blue" alt="License" />
|
<img src="https://img.shields.io/badge/license-AGPLv3-blue" alt="License" />
|
||||||
<img src="https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white" alt="Docker" />
|
<img src="https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white" alt="Docker" />
|
||||||
<img src="https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
<img src="https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "0.23.18-beta",
|
"version": "0.23.19-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": {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { redis } from './config/redis';
|
|||||||
import { runMigrations } from './db/migrate';
|
import { runMigrations } from './db/migrate';
|
||||||
import { errorHandler } from './middleware/errorHandler';
|
import { errorHandler } from './middleware/errorHandler';
|
||||||
import { PollerService } from './services/PollerService';
|
import { PollerService } from './services/PollerService';
|
||||||
|
import { setSharedPollerService } from './services/pollerRef';
|
||||||
import {
|
import {
|
||||||
setBulkAddPollerService,
|
setBulkAddPollerService,
|
||||||
startBulkAddWorker,
|
startBulkAddWorker,
|
||||||
@@ -374,6 +375,7 @@ async function start(): Promise<void> {
|
|||||||
pollerService.setSocketServer(io);
|
pollerService.setSocketServer(io);
|
||||||
setDevicesPoller(pollerService);
|
setDevicesPoller(pollerService);
|
||||||
setSystemPoller(pollerService);
|
setSystemPoller(pollerService);
|
||||||
|
setSharedPollerService(pollerService);
|
||||||
setBulkAddPollerService(pollerService);
|
setBulkAddPollerService(pollerService);
|
||||||
setTopologyPoller(pollerService);
|
setTopologyPoller(pollerService);
|
||||||
setClientsPoller(pollerService);
|
setClientsPoller(pollerService);
|
||||||
@@ -384,6 +386,12 @@ async function start(): Promise<void> {
|
|||||||
// hold up startup, and nothing depends on it having finished.
|
// hold up startup, and nothing depends on it having finished.
|
||||||
void pollerService.cleanupJobHistory().catch((e) =>
|
void pollerService.cleanupJobHistory().catch((e) =>
|
||||||
console.error('[Poller] Job history cleanup failed:', e));
|
console.error('[Poller] Job history cleanup failed:', e));
|
||||||
|
|
||||||
|
// Pending work accumulated before dedup existed is dropped too, not just
|
||||||
|
// finished-job history. An upgrade that leaves hundreds of thousands of stale
|
||||||
|
// polls queued has not actually fixed anything the operator can see (#114).
|
||||||
|
void pollerService.dropStalePending().catch((e) =>
|
||||||
|
console.error('[Poller] Stale-pending sweep failed:', e));
|
||||||
await startBulkAddWorker();
|
await startBulkAddWorker();
|
||||||
|
|
||||||
// NetFlow/IPFIX collector (binds its UDP socket only when netflow_enabled)
|
// NetFlow/IPFIX collector (binds its UDP socket only when netflow_enabled)
|
||||||
|
|||||||
@@ -152,6 +152,31 @@ router.get('/insights', async (_req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The poller's own health belongs here. A fleet outrunning its workers looks
|
||||||
|
// exactly like a fleet of broken devices — stale data everywhere and no
|
||||||
|
// explanation — and the operator who most needs to know is the one least
|
||||||
|
// likely to go reading an API endpoint (#114).
|
||||||
|
try {
|
||||||
|
const { pollerService } = await import('../services/pollerRef');
|
||||||
|
const health = pollerService ? await pollerService.getPollerHealth() : null;
|
||||||
|
if (health && health.status !== 'ok') {
|
||||||
|
const cap = health.capacity as Record<string, number | null>;
|
||||||
|
const backlog = cap.backlog ?? 0;
|
||||||
|
const saturated = health.status === 'saturated';
|
||||||
|
attention.push({
|
||||||
|
sev: saturated ? 'error' : 'warn',
|
||||||
|
category: 'poller',
|
||||||
|
title: saturated
|
||||||
|
? 'Polling cannot keep up with this fleet'
|
||||||
|
: `Polling is behind — ${backlog.toLocaleString()} jobs queued`,
|
||||||
|
body: saturated
|
||||||
|
? `Each cycle enqueues ${cap.arrival_per_sec}/s but the workers clear only ${cap.service_per_sec}/s, so devices will go stale. Raise POLLER_CONCURRENCY or lengthen the interval.`
|
||||||
|
: `Clearing at the current rate takes ${cap.drain_eta_sec == null ? 'longer than it is accumulating' : `about ${Math.round((cap.drain_eta_sec || 0) / 60)} min`}. Devices poll late until it clears.`,
|
||||||
|
action: 'Open poller health', path: '/settings?tab=poller',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch { /* health is advisory — never let it break the dashboard */ }
|
||||||
|
|
||||||
for (const d of devices) {
|
for (const d of devices) {
|
||||||
if (d.firmware_update_available) {
|
if (d.firmware_update_available) {
|
||||||
attention.push({
|
attention.push({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Router } from 'express';
|
|||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { requireAuth } from '../middleware/auth';
|
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||||
import { query } from '../config/database';
|
import { query } from '../config/database';
|
||||||
import type { PollerService } from '../services/PollerService';
|
import type { PollerService } from '../services/PollerService';
|
||||||
|
|
||||||
@@ -88,6 +88,26 @@ router.get('/poller', async (_req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/system/poller/drain — clear every queued poll now.
|
||||||
|
*
|
||||||
|
* The automatic sweep only discards jobs past their cadence, which still leaves
|
||||||
|
* a large backlog draining for hours. Nothing is lost: the scheduler re-enqueues
|
||||||
|
* whatever is still due on its next tick, so the cost is at most one interval of
|
||||||
|
* freshness.
|
||||||
|
*/
|
||||||
|
router.post('/poller/drain', requireWrite, async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
if (!pollerService) return res.status(503).json({ error: 'Poller not started' });
|
||||||
|
const drained = await pollerService.drainQueues();
|
||||||
|
const total = Object.values(drained).reduce((a, b) => a + b, 0);
|
||||||
|
res.json({ drained, total, message: `Cleared ${total} queued job(s). Polling resumes on the next cycle.` });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error draining poller queues:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to drain queues' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/version-check', async (_req: Request, res: Response) => {
|
router.get('/version-check', async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
// Check cache
|
// Check cache
|
||||||
|
|||||||
@@ -15,7 +15,23 @@ import type { GuardDevice } from './changeGuard/ChangeGuard';
|
|||||||
const JOB_RETENTION_SEC = Number(process.env.POLLER_JOB_RETENTION_SEC || 3600);
|
const JOB_RETENTION_SEC = Number(process.env.POLLER_JOB_RETENTION_SEC || 3600);
|
||||||
const JOB_RETENTION_COUNT = Number(process.env.POLLER_JOB_RETENTION_COUNT || 5000);
|
const JOB_RETENTION_COUNT = Number(process.env.POLLER_JOB_RETENTION_COUNT || 5000);
|
||||||
const CLEANUP_BATCH = 5000;
|
const CLEANUP_BATCH = 5000;
|
||||||
const CLEANUP_MAX_PASSES = 200;
|
const CLEANUP_MAX_PASSES = 400;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a *pending* poll may wait before it is worthless.
|
||||||
|
*
|
||||||
|
* A periodic job that has waited longer than its own cadence has already been
|
||||||
|
* superseded: running it produces a reading the next cycle would have taken
|
||||||
|
* anyway, while occupying a worker the fleet needs. Retention limits do not
|
||||||
|
* touch pending work, so a backlog accumulated before those limits existed sat
|
||||||
|
* untouched — one reporter upgraded and still had 752,318 jobs queued, roughly
|
||||||
|
* nine days of draining (#114).
|
||||||
|
*/
|
||||||
|
const STALE_WAIT_MS: Record<string, number> = {
|
||||||
|
'poll-fast': 120_000,
|
||||||
|
'poll-logs': 240_000,
|
||||||
|
'poll-slow': 900_000,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Worker concurrency, per queue.
|
* Worker concurrency, per queue.
|
||||||
@@ -147,12 +163,30 @@ export class PollerService {
|
|||||||
async scheduleDeviceSync(deviceId: number, type: PollJob['type'] = 'full'): Promise<void> {
|
async scheduleDeviceSync(deviceId: number, type: PollJob['type'] = 'full'): Promise<void> {
|
||||||
const jobData: PollJob = { deviceId, type };
|
const jobData: PollJob = { deviceId, type };
|
||||||
|
|
||||||
// A poll already queued or running for this device makes another pointless.
|
/**
|
||||||
// The TTL is generous relative to the job timeout so a crashed worker frees
|
* Deduplication is delegated to BullMQ rather than guarded by a lock.
|
||||||
// the device on the following cycle rather than stranding it.
|
*
|
||||||
if (type !== 'full' && !(await this.claimPoll(type, deviceId, Math.ceil(JOB_TIMEOUT_MS / 1000) * 3))) {
|
* The previous attempt used a Redis key with a TTL, which fails in exactly
|
||||||
return;
|
* the situation it exists for: with a large backlog a job waits hours, the
|
||||||
}
|
* TTL lapses long before it runs, and the scheduler enqueues another every
|
||||||
|
* cycle. A deterministic job id cannot lapse — while a job for this device
|
||||||
|
* and kind exists in any state, adding it again is a no-op.
|
||||||
|
*
|
||||||
|
* That requires finished jobs to be removed immediately, or the id would
|
||||||
|
* stay taken and the device would never be polled again. Losing that history
|
||||||
|
* costs nothing now: device_poll_stats records every attempt, its duration
|
||||||
|
* and its error, which is more useful than the job payload ever was.
|
||||||
|
*/
|
||||||
|
const periodic = {
|
||||||
|
// Hyphens, not colons: BullMQ reserves ':' for its own key structure and
|
||||||
|
// rejects a custom id containing one. Getting this wrong throws on every
|
||||||
|
// enqueue, which stops the scheduler dead — caught only by exercising the
|
||||||
|
// id directly rather than trusting the format.
|
||||||
|
jobId: `poll-${type}-${deviceId}`,
|
||||||
|
removeOnComplete: true,
|
||||||
|
removeOnFail: true,
|
||||||
|
attempts: 1,
|
||||||
|
};
|
||||||
|
|
||||||
if (type === 'full') {
|
if (type === 'full') {
|
||||||
await this.fastQueue.add('device-full-sync', jobData, {
|
await this.fastQueue.add('device-full-sync', jobData, {
|
||||||
@@ -167,21 +201,21 @@ export class PollerService {
|
|||||||
// also doubled the recorded failure count: 231k failed jobs on a four-device
|
// also doubled the recorded failure count: 231k failed jobs on a four-device
|
||||||
// fleet turned out to be ~1,000 hours of genuine downtime, counted twice.
|
// fleet turned out to be ~1,000 hours of genuine downtime, counted twice.
|
||||||
} else if (type === 'fast') {
|
} else if (type === 'fast') {
|
||||||
await this.fastQueue.add('device-fast-poll', jobData, { attempts: 1 });
|
await this.fastQueue.add('device-fast-poll', jobData, periodic);
|
||||||
} else if (type === 'slow') {
|
} else if (type === 'slow') {
|
||||||
await this.slowQueue.add('device-slow-poll', jobData, { attempts: 1 });
|
await this.slowQueue.add('device-slow-poll', jobData, periodic);
|
||||||
} else if (type === 'logs') {
|
} else if (type === 'logs') {
|
||||||
await this.logsQueue.add('device-logs-poll', jobData, { attempts: 1 });
|
await this.logsQueue.add('device-logs-poll', jobData, periodic);
|
||||||
} else if (type === 'macscan') {
|
} else if (type === 'macscan') {
|
||||||
await this.fastQueue.add('device-macscan', jobData, { attempts: 1 });
|
await this.fastQueue.add('device-macscan', jobData, periodic);
|
||||||
} else if (type === 'spectral') {
|
} else if (type === 'spectral') {
|
||||||
await this.slowQueue.add('device-spectral', jobData, { attempts: 1 });
|
await this.slowQueue.add('device-spectral', jobData, periodic);
|
||||||
} else if (type === 'apscan') {
|
} else if (type === 'apscan') {
|
||||||
await this.slowQueue.add('device-apscan', jobData, { attempts: 1 });
|
await this.slowQueue.add('device-apscan', jobData, periodic);
|
||||||
} else if (type === 'configsnap') {
|
} else if (type === 'configsnap') {
|
||||||
await this.slowQueue.add('device-configsnap', jobData, { attempts: 1 });
|
await this.slowQueue.add('device-configsnap', jobData, periodic);
|
||||||
} else if (type === 'confighealth') {
|
} else if (type === 'confighealth') {
|
||||||
await this.slowQueue.add('device-confighealth', jobData, { attempts: 1 });
|
await this.slowQueue.add('device-confighealth', jobData, periodic);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +350,17 @@ export class PollerService {
|
|||||||
.catch((e) => console.error('[Poller] Stale topology-link cleanup error:', e));
|
.catch((e) => console.error('[Poller] Stale topology-link cleanup error:', e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stale pending sweep — every 5 minutes. Cheap when there is nothing to do,
|
||||||
|
// and it means a backlog from any cause self-heals rather than requiring
|
||||||
|
// an operator to notice it.
|
||||||
|
const staleKey = 'task:stale_pending';
|
||||||
|
const lastStale = await this.getTimestamp(staleKey);
|
||||||
|
if (now - lastStale > 300_000) {
|
||||||
|
await this.setTimestamp(staleKey, now);
|
||||||
|
this.dropStalePending().catch((e) =>
|
||||||
|
console.error('[Poller] Stale-pending sweep error:', e));
|
||||||
|
}
|
||||||
|
|
||||||
// Firmware update check — runs once per day
|
// Firmware update check — runs once per day
|
||||||
const firmwareKey = 'task:firmware_check';
|
const firmwareKey = 'task:firmware_check';
|
||||||
const lastFirmware = await this.getTimestamp(firmwareKey);
|
const lastFirmware = await this.getTimestamp(firmwareKey);
|
||||||
@@ -577,19 +622,55 @@ export class PollerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Claim a device for one kind of poll, refusing if one is already outstanding.
|
* Discard pending polls that have waited longer than their own cadence.
|
||||||
*
|
*
|
||||||
* The scheduler runs on a fixed 30-second timer and used to enqueue
|
* Retention limits govern finished jobs only, so an installation that built a
|
||||||
* unconditionally, so whenever the workers could not keep up the queue grew by
|
* backlog before those limits existed keeps every queued job — and each one,
|
||||||
* the difference on every tick — forever. Devices at the back of it simply
|
* when it finally runs, produces a reading that the next cycle would have
|
||||||
* stopped being polled, which is what "last seen isn't refreshing at all"
|
* taken anyway. Executing them is worse than dropping them: they occupy the
|
||||||
* actually was (#114).
|
* workers the live fleet is waiting on.
|
||||||
*
|
*
|
||||||
* A claim is a Redis SET NX with a TTL, so a worker that dies mid-poll cannot
|
* Dropping queued work is safe precisely because it is periodic. The schedule
|
||||||
* wedge a device permanently: the claim lapses and the next cycle re-enqueues.
|
* re-enqueues within one cycle, so the cost of discarding a stale poll is at
|
||||||
* If Redis is unreachable we enqueue anyway — degrading to the old behaviour
|
* most one interval of freshness, against hours of backlog otherwise.
|
||||||
* beats not polling at all.
|
|
||||||
*/
|
*/
|
||||||
|
async dropStalePending(): Promise<number> {
|
||||||
|
let total = 0;
|
||||||
|
for (const { name, queue } of this.queues) {
|
||||||
|
const maxAge = STALE_WAIT_MS[name] ?? 300_000;
|
||||||
|
try {
|
||||||
|
for (let pass = 0; pass < CLEANUP_MAX_PASSES; pass++) {
|
||||||
|
const ids = await queue.clean(maxAge, CLEANUP_BATCH, 'wait');
|
||||||
|
total += ids.length;
|
||||||
|
if (ids.length < CLEANUP_BATCH) break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[Poller] Stale-pending sweep failed for ${name}:`, (e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (total > 0) console.log(`[Poller] Dropped ${total} stale pending job(s)`);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove every queued poll immediately, on request.
|
||||||
|
*
|
||||||
|
* The automatic sweep only drops jobs past their cadence, which still leaves an
|
||||||
|
* operator staring at a backlog that takes hours to clear. This is the "clear
|
||||||
|
* it now" they actually want; nothing is lost because the next scheduler tick
|
||||||
|
* re-enqueues whatever is still due.
|
||||||
|
*/
|
||||||
|
async drainQueues(): Promise<Record<string, number>> {
|
||||||
|
const drained: Record<string, number> = {};
|
||||||
|
for (const { name, queue } of this.queues) {
|
||||||
|
const before = await queue.getJobCounts('waiting', 'delayed');
|
||||||
|
await queue.drain(true);
|
||||||
|
drained[name] = (before.waiting || 0) + (before.delayed || 0);
|
||||||
|
}
|
||||||
|
console.log('[Poller] Queues drained on request:', drained);
|
||||||
|
return drained;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record what happened on a poll, per device and kind.
|
* Record what happened on a poll, per device and kind.
|
||||||
*
|
*
|
||||||
@@ -654,7 +735,29 @@ export class PollerService {
|
|||||||
const arrivalPerSec = deviceCount / (POLL_INTERVAL_MS / 1000);
|
const arrivalPerSec = deviceCount / (POLL_INTERVAL_MS / 1000);
|
||||||
const servicePerSec = avgSec > 0 ? POLL_CONCURRENCY / avgSec : null;
|
const servicePerSec = avgSec > 0 ? POLL_CONCURRENCY / avgSec : null;
|
||||||
|
|
||||||
|
const headroom = servicePerSec == null || arrivalPerSec === 0
|
||||||
|
? null
|
||||||
|
: Math.round((servicePerSec / arrivalPerSec) * 100) / 100;
|
||||||
|
|
||||||
|
// Backlog is reported alongside headroom because headroom alone lies.
|
||||||
|
// It describes flow — whether the workers can keep up from here — and says
|
||||||
|
// nothing about work already queued. An earlier version of this endpoint
|
||||||
|
// reported a comfortable 1.59 to an operator with 752,318 jobs waiting and
|
||||||
|
// nine stale devices, which is worse than no number at all (#114).
|
||||||
|
const backlog = queues.reduce((n, q) => n + (Number(q.waiting) || 0), 0);
|
||||||
|
const netDrainPerSec = servicePerSec == null ? null : servicePerSec - arrivalPerSec;
|
||||||
|
const drainEtaSec = backlog === 0 ? 0
|
||||||
|
: netDrainPerSec == null || netDrainPerSec <= 0 ? null
|
||||||
|
: Math.round(backlog / netDrainPerSec);
|
||||||
|
|
||||||
|
// A single verdict, so nobody has to interpret three numbers correctly.
|
||||||
|
const status =
|
||||||
|
headroom != null && headroom < 1 ? 'saturated'
|
||||||
|
: backlog > deviceCount * 3 ? 'draining'
|
||||||
|
: 'ok';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
status,
|
||||||
queues,
|
queues,
|
||||||
workers: {
|
workers: {
|
||||||
concurrency: POLL_CONCURRENCY,
|
concurrency: POLL_CONCURRENCY,
|
||||||
@@ -668,31 +771,19 @@ export class PollerService {
|
|||||||
arrival_per_sec: Math.round(arrivalPerSec * 100) / 100,
|
arrival_per_sec: Math.round(arrivalPerSec * 100) / 100,
|
||||||
service_per_sec: servicePerSec == null ? null : Math.round(servicePerSec * 100) / 100,
|
service_per_sec: servicePerSec == null ? null : Math.round(servicePerSec * 100) / 100,
|
||||||
// Below 1.0 the backlog grows every cycle and devices go stale.
|
// Below 1.0 the backlog grows every cycle and devices go stale.
|
||||||
headroom: servicePerSec == null || arrivalPerSec === 0
|
headroom,
|
||||||
? null
|
backlog,
|
||||||
: Math.round((servicePerSec / arrivalPerSec) * 100) / 100,
|
// null means the backlog is not shrinking at the current rate.
|
||||||
|
drain_eta_sec: drainEtaSec,
|
||||||
|
},
|
||||||
|
retention: {
|
||||||
|
max_age_sec: JOB_RETENTION_SEC,
|
||||||
|
max_count: JOB_RETENTION_COUNT,
|
||||||
|
stale_pending_ms: STALE_WAIT_MS,
|
||||||
},
|
},
|
||||||
retention: { max_age_sec: JOB_RETENTION_SEC, max_count: JOB_RETENTION_COUNT },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async claimPoll(kind: string, deviceId: number, ttlSec: number): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const { redis } = await import('../config/redis');
|
|
||||||
const res = await redis.set(`poll:inflight:${kind}:${deviceId}`, '1', 'EX', ttlSec, 'NX');
|
|
||||||
return res === 'OK';
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async releasePoll(kind: string, deviceId: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
const { redis } = await import('../config/redis');
|
|
||||||
await redis.del(`poll:inflight:${kind}:${deviceId}`);
|
|
||||||
} catch { /* claim will lapse on its own TTL */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async setTimestamp(key: string, ts: number, ttlSec = 600): Promise<void> {
|
private async setTimestamp(key: string, ts: number, ttlSec = 600): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { redis } = await import('../config/redis');
|
const { redis } = await import('../config/redis');
|
||||||
@@ -721,8 +812,6 @@ export class PollerService {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
await this.recordPollOutcome(job.data.deviceId, kind, Date.now() - started, (err as Error).message);
|
await this.recordPollOutcome(job.data.deviceId, kind, Date.now() - started, (err as Error).message);
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
|
||||||
await this.releasePoll(job.data.type, job.data.deviceId);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* A single place to reach the running PollerService.
|
||||||
|
*
|
||||||
|
* Routes receive it by injection from index.ts, which works well for a route
|
||||||
|
* module but not for code reached indirectly — the dashboard insights build in
|
||||||
|
* a helper that has no injection point of its own. A tiny module-level
|
||||||
|
* reference avoids threading the dependency through every caller, and stays
|
||||||
|
* null until the poller starts so nothing can accidentally depend on it early.
|
||||||
|
*/
|
||||||
|
import type { PollerService } from './PollerService';
|
||||||
|
|
||||||
|
export let pollerService: PollerService | null = null;
|
||||||
|
|
||||||
|
export function setSharedPollerService(p: PollerService): void {
|
||||||
|
pollerService = p;
|
||||||
|
}
|
||||||
+12
-7
@@ -85,16 +85,21 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
# A memory ceiling so a runaway producer can never take the host down. The
|
# A memory ceiling so a runaway producer can never take the host down, with
|
||||||
# eviction policy is deliberately volatile-lru rather than allkeys-*: BullMQ
|
# eviction disabled.
|
||||||
# job data carries no TTL and must never be evicted (that would silently
|
#
|
||||||
# destroy queued work), while the poller's caches and in-flight claims all
|
# volatile-lru looks tempting — it would evict only keys with an expiry, and
|
||||||
# set one and are safe to drop. If pressure ever exhausts the volatile keys
|
# BullMQ's job hashes have none. But its *control* keys do: job locks and
|
||||||
# Redis refuses writes instead of evicting queue state — loud, and survivable.
|
# per-queue stalled-check markers both carry TTLs, and losing one causes
|
||||||
|
# duplicate processing or a stalled-job storm. BullMQ warns about this on
|
||||||
|
# connect, and it is right. noeviction means Redis refuses writes when full
|
||||||
|
# rather than quietly corrupting queue state: the host stays up, and the
|
||||||
|
# failure is loud. Retention limits and the stale-pending sweep are what keep
|
||||||
|
# us away from the ceiling in the first place.
|
||||||
command: >
|
command: >
|
||||||
redis-server --appendonly yes
|
redis-server --appendonly yes
|
||||||
--maxmemory ${REDIS_MAXMEMORY:-512mb}
|
--maxmemory ${REDIS_MAXMEMORY:-512mb}
|
||||||
--maxmemory-policy volatile-lru
|
--maxmemory-policy noeviction
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-frontend",
|
"name": "mikrotik-manager-frontend",
|
||||||
"version": "0.23.18-beta",
|
"version": "0.23.19-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Activity, AlertTriangle, CheckCircle2, Clock, RefreshCw, Trash2 } from 'lucide-react';
|
||||||
|
import { pollerApi, type PollerHealth } from '../../services/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The poller's own health, in the UI rather than buried in an API response.
|
||||||
|
*
|
||||||
|
* A fleet that outruns its workers presents as stale data everywhere, which
|
||||||
|
* reads as a device problem rather than a capacity one. Making queue depth and
|
||||||
|
* headroom visible is what turns "polling frequency is set by guesswork" into an
|
||||||
|
* arithmetic question (#114).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STATUS: Record<PollerHealth['status'], { label: string; tone: string; icon: typeof Activity; blurb: string }> = {
|
||||||
|
ok: {
|
||||||
|
label: 'Keeping up', tone: 'text-green-700 bg-green-100 dark:text-green-300 dark:bg-green-900/40',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
blurb: 'Workers are clearing polls faster than the schedule creates them.',
|
||||||
|
},
|
||||||
|
draining: {
|
||||||
|
label: 'Working through a backlog', tone: 'text-amber-800 bg-amber-100 dark:text-amber-300 dark:bg-amber-900/40',
|
||||||
|
icon: Clock,
|
||||||
|
blurb: 'Capacity is sufficient, but queued work from earlier is still clearing. Devices poll late until it does.',
|
||||||
|
},
|
||||||
|
saturated: {
|
||||||
|
label: 'Cannot keep up', tone: 'text-red-700 bg-red-100 dark:text-red-300 dark:bg-red-900/40',
|
||||||
|
icon: AlertTriangle,
|
||||||
|
blurb: 'Polls are being created faster than they can run, so the backlog grows every cycle and devices will go stale.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtDuration = (sec: number | null): string => {
|
||||||
|
if (sec == null) return 'not shrinking';
|
||||||
|
if (sec < 60) return `${sec}s`;
|
||||||
|
if (sec < 3600) return `${Math.round(sec / 60)} min`;
|
||||||
|
return `${(sec / 3600).toFixed(1)} h`;
|
||||||
|
};
|
||||||
|
|
||||||
|
function Stat({ label, value, hint, alarm }: {
|
||||||
|
label: string; value: string; hint?: string; alarm?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-slate-400">{label}</div>
|
||||||
|
<div className={`text-lg font-semibold tabular-nums ${
|
||||||
|
alarm ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'}`}>{value}</div>
|
||||||
|
{hint && <div className="text-[11px] text-gray-400 dark:text-slate-500 mt-0.5">{hint}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PollerHealthCard() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [drainResult, setDrainResult] = useState('');
|
||||||
|
const [confirmDrain, setConfirmDrain] = useState(false);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['poller-health'],
|
||||||
|
queryFn: () => pollerApi.health().then(r => r.data),
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const drain = useMutation({
|
||||||
|
mutationFn: () => pollerApi.drain(),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
setDrainResult(res.data.message);
|
||||||
|
setConfirmDrain(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['poller-health'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !data) {
|
||||||
|
return <div className="text-sm text-gray-500 dark:text-slate-400">Loading poller health…</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = STATUS[data.status] ?? STATUS.ok;
|
||||||
|
const Icon = s.icon;
|
||||||
|
const cap = data.capacity;
|
||||||
|
const backlog = cap.backlog;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Icon className="w-5 h-5 mt-0.5 text-gray-400 dark:text-slate-500" />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">Polling</h3>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs font-medium ${s.tone}`}>{s.label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-slate-300 mt-0.5 max-w-2xl">{s.blurb}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => queryClient.invalidateQueries({ queryKey: ['poller-health'] })}
|
||||||
|
className="btn-secondary flex items-center gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Capacity. headroom and backlog are shown together deliberately: headroom
|
||||||
|
alone once reported "healthy" to an operator with 752k jobs queued. */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||||
|
<Stat label="Devices" value={String(cap.devices)} />
|
||||||
|
<Stat
|
||||||
|
label="Headroom"
|
||||||
|
value={cap.headroom == null ? '—' : `${cap.headroom}×`}
|
||||||
|
hint="capacity ÷ demand"
|
||||||
|
alarm={cap.headroom != null && cap.headroom < 1}
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Queued"
|
||||||
|
value={backlog.toLocaleString()}
|
||||||
|
hint={backlog > 0 ? `clears in ${fmtDuration(cap.drain_eta_sec)}` : 'nothing waiting'}
|
||||||
|
alarm={backlog > cap.devices * 3}
|
||||||
|
/>
|
||||||
|
<Stat label="Avg poll" value={cap.avg_fast_poll_ms == null ? '—' : `${cap.avg_fast_poll_ms} ms`} />
|
||||||
|
<Stat label="Slowest (p90)" value={cap.p90_fast_poll_ms == null ? '—' : `${cap.p90_fast_poll_ms} ms`} />
|
||||||
|
<Stat
|
||||||
|
label="Workers"
|
||||||
|
value={String(data.workers.concurrency)}
|
||||||
|
hint={`${data.workers.poll_interval_ms / 1000}s interval`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cap.headroom != null && cap.headroom < 1.5 && (
|
||||||
|
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs dark:border-amber-700/60 dark:bg-amber-900/20">
|
||||||
|
<div className="font-medium text-amber-900 dark:text-amber-200">Little margin left</div>
|
||||||
|
<p className="mt-0.5 text-amber-800 dark:text-amber-300">
|
||||||
|
Demand is {cap.arrival_per_sec}/s against {cap.service_per_sec ?? '—'}/s of capacity. Slow
|
||||||
|
devices push individual polls well past the average, so bursts will fall behind even while
|
||||||
|
the average looks fine. Raise <code>POLLER_CONCURRENCY</code> or lengthen the interval.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Queues */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs text-gray-500 dark:text-slate-400 border-b border-gray-200 dark:border-slate-700">
|
||||||
|
<th className="py-2 pr-4 font-medium">Queue</th>
|
||||||
|
<th className="py-2 pr-4 font-medium text-right">Waiting</th>
|
||||||
|
<th className="py-2 pr-4 font-medium text-right">Active</th>
|
||||||
|
<th className="py-2 pr-4 font-medium text-right">Failed</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.queues.map(q => (
|
||||||
|
<tr key={q.name} className="border-b border-gray-100 dark:border-slate-800 last:border-0">
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs text-gray-900 dark:text-white">{q.name}</td>
|
||||||
|
<td className={`py-2 pr-4 text-right tabular-nums ${
|
||||||
|
(q.waiting || 0) > 0 ? 'text-amber-700 dark:text-amber-400 font-medium'
|
||||||
|
: 'text-gray-600 dark:text-slate-300'}`}>
|
||||||
|
{(q.waiting ?? 0).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right tabular-nums text-gray-600 dark:text-slate-300">{q.active ?? 0}</td>
|
||||||
|
<td className="py-2 pr-4 text-right tabular-nums text-gray-600 dark:text-slate-300">{q.failed ?? 0}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clearing the queue */}
|
||||||
|
<div className="rounded-lg border border-gray-200 dark:border-slate-700 p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||||
|
<div className="text-xs text-gray-600 dark:text-slate-300 max-w-2xl">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">Clear the queue.</span>{' '}
|
||||||
|
Discards everything currently queued. Nothing is lost — the scheduler re-enqueues whatever
|
||||||
|
is still due on its next cycle, so the cost is at most one interval of freshness. Stale
|
||||||
|
polls are also dropped automatically once they are older than their own cadence.
|
||||||
|
</div>
|
||||||
|
{confirmDrain ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => drain.mutate()} disabled={drain.isPending}
|
||||||
|
className="btn-danger text-xs flex items-center gap-1.5">
|
||||||
|
{drain.isPending ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Trash2 className="w-3.5 h-3.5" />}
|
||||||
|
Confirm clear
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setConfirmDrain(false)} className="btn-secondary text-xs">Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setConfirmDrain(true)} disabled={backlog === 0}
|
||||||
|
className="btn-secondary text-xs flex items-center gap-1.5 disabled:opacity-50">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" /> Clear {backlog > 0 ? backlog.toLocaleString() : ''} queued
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{drainResult && <p className="mt-2 text-xs text-green-700 dark:text-green-400">{drainResult}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Which devices are actually being missed */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
Devices not polled recently {data.stale_devices.length > 0 && `(${data.stale_devices.length})`}
|
||||||
|
</h4>
|
||||||
|
{data.stale_devices.length === 0 ? (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-slate-400">
|
||||||
|
Every device has been polled successfully in the last few minutes.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{data.stale_devices.map(d => (
|
||||||
|
<li key={`${d.id}-${d.kind}`} className="text-xs flex flex-wrap gap-x-2 gap-y-0.5">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">{d.name}</span>
|
||||||
|
<span className="text-gray-500 dark:text-slate-400">
|
||||||
|
{d.last_success_at
|
||||||
|
? `last success ${fmtDuration(d.seconds_since_success)} ago`
|
||||||
|
: 'never polled successfully'}
|
||||||
|
</span>
|
||||||
|
{/* Attempt vs success is the distinction that says whether we
|
||||||
|
missed it or it did not answer. */}
|
||||||
|
{d.last_error
|
||||||
|
? <span className="text-red-600 dark:text-red-400">responded with: {d.last_error}</span>
|
||||||
|
: <span className="text-amber-700 dark:text-amber-400">not attempted recently</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,10 +3,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import {
|
import {
|
||||||
Settings, Users, Key, Plus, Trash2, CheckCircle, AlertCircle, Pencil, X,
|
Settings, Users, Key, Plus, Trash2, CheckCircle, AlertCircle, Pencil, X,
|
||||||
ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList, FileText, Zap, LogIn,
|
ShieldCheck, ShieldAlert, RefreshCw, Upload, Lock, Bell, Send, KeyRound, ClipboardList, FileText, Zap, LogIn,
|
||||||
|
Activity,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { settingsApi, authApi, certApi, alertsApi, auditLogApi, tagsApi, maintenanceApi, configTemplatesApi } from '../services/api';
|
import { settingsApi, authApi, certApi, alertsApi, auditLogApi, tagsApi, maintenanceApi, configTemplatesApi } from '../services/api';
|
||||||
import type { MaintenanceWindow, ConfigTemplate } from '../services/api';
|
import type { MaintenanceWindow, ConfigTemplate } from '../services/api';
|
||||||
import type { CertInfo, AlertRule, AlertChannel } from '../services/api';
|
import type { CertInfo, AlertRule, AlertChannel } from '../services/api';
|
||||||
|
import PollerHealthCard from '../components/system/PollerHealthCard';
|
||||||
import { useAuthStore } from '../store/authStore';
|
import { useAuthStore } from '../store/authStore';
|
||||||
import { useThemeStore } from '../store/themeStore';
|
import { useThemeStore } from '../store/themeStore';
|
||||||
import type { User, UserRole } from '../types';
|
import type { User, UserRole } from '../types';
|
||||||
@@ -49,7 +51,7 @@ export default function SettingsPage() {
|
|||||||
const { theme, setTheme } = useThemeStore();
|
const { theme, setTheme } = useThemeStore();
|
||||||
const isAdmin = user?.role === 'admin';
|
const isAdmin = user?.role === 'admin';
|
||||||
const canWrite = user?.role !== 'viewer';
|
const canWrite = user?.role !== 'viewer';
|
||||||
const [activeTab, setActiveTab] = useState<'general' | 'users' | 'sso' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance' | 'templates' | 'automation'>('general');
|
const [activeTab, setActiveTab] = useState<'general' | 'users' | 'sso' | 'credentials' | 'security' | 'certificate' | 'alerting' | 'audit' | 'tags' | 'maintenance' | 'templates' | 'automation' | 'poller'>('general');
|
||||||
const [auditSearch, setAuditSearch] = useState('');
|
const [auditSearch, setAuditSearch] = useState('');
|
||||||
const [auditPage, setAuditPage] = useState(1);
|
const [auditPage, setAuditPage] = useState(1);
|
||||||
const [newTagName, setNewTagName] = useState('');
|
const [newTagName, setNewTagName] = useState('');
|
||||||
@@ -477,6 +479,7 @@ export default function SettingsPage() {
|
|||||||
...(isAdmin ? [{ key: 'sso' as const, label: 'SSO / OIDC', icon: LogIn }] : []),
|
...(isAdmin ? [{ key: 'sso' as const, label: 'SSO / OIDC', icon: LogIn }] : []),
|
||||||
{ key: 'certificate' as const, label: 'Certificate', icon: Lock },
|
{ key: 'certificate' as const, label: 'Certificate', icon: Lock },
|
||||||
...(isAdmin ? [{ key: 'audit' as const, label: 'Audit Log', icon: ClipboardList }] : []),
|
...(isAdmin ? [{ key: 'audit' as const, label: 'Audit Log', icon: ClipboardList }] : []),
|
||||||
|
...(isAdmin ? [{ key: 'poller' as const, label: 'Polling', icon: Activity }] : []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -925,6 +928,12 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Users ── */}
|
{/* ── Users ── */}
|
||||||
|
{activeTab === 'poller' && (
|
||||||
|
<div className="card p-5">
|
||||||
|
<PollerHealthCard />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'users' && (
|
{activeTab === 'users' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Role legend */}
|
{/* Role legend */}
|
||||||
|
|||||||
@@ -301,6 +301,45 @@ export const lteApi = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Poller health (#114) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PollerQueue {
|
||||||
|
name: string; waiting?: number; active?: number; delayed?: number;
|
||||||
|
failed?: number; completed?: number; paused?: number; error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PollerHealth {
|
||||||
|
status: 'ok' | 'draining' | 'saturated';
|
||||||
|
queues: PollerQueue[];
|
||||||
|
workers: { concurrency: number; job_timeout_ms: number; poll_interval_ms: number };
|
||||||
|
capacity: {
|
||||||
|
devices: number;
|
||||||
|
avg_fast_poll_ms: number | null;
|
||||||
|
p90_fast_poll_ms: number | null;
|
||||||
|
arrival_per_sec: number;
|
||||||
|
service_per_sec: number | null;
|
||||||
|
/** Below 1.0 the backlog grows every cycle. */
|
||||||
|
headroom: number | null;
|
||||||
|
backlog: number;
|
||||||
|
/** null when the backlog is not shrinking at the current rate. */
|
||||||
|
drain_eta_sec: number | null;
|
||||||
|
};
|
||||||
|
retention: { max_age_sec: number; max_count: number; stale_pending_ms: Record<string, number> };
|
||||||
|
stale_devices: {
|
||||||
|
id: number; name: string; status: string; kind: string;
|
||||||
|
last_attempt_at: string | null; last_success_at: string | null;
|
||||||
|
last_duration_ms: number | null; last_error: string | null;
|
||||||
|
attempts: number; failures: number; seconds_since_success: number | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pollerApi = {
|
||||||
|
health: () => api.get<PollerHealth>('/system/poller'),
|
||||||
|
drain: () => api.post<{ drained: Record<string, number>; total: number; message: string }>(
|
||||||
|
'/system/poller/drain'),
|
||||||
|
};
|
||||||
|
|
||||||
export const devicesApi = {
|
export const devicesApi = {
|
||||||
list: () => api.get<Device[]>('/devices'),
|
list: () => api.get<Device[]>('/devices'),
|
||||||
discovered: () => api.get<DiscoveredDevice[]>('/devices/discovered'),
|
discovered: () => api.get<DiscoveredDevice[]>('/devices/discovered'),
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = 'v0.23.18 Beta';
|
export const APP_VERSION = 'v0.23.19 Beta';
|
||||||
|
|||||||
Reference in New Issue
Block a user