fix(poller): the health panel must not blank the page, and pass the tunables through (v0.23.20-beta)
Two faults reported against v0.23.19, one of which makes the other much worse. **POLLER_CONCURRENCY was never passed into the container.** It is read from the environment by the backend and documented in .env.example, but docker-compose did not forward it, so setting it did nothing at all. The same was true of the job timeout, interval and both retention limits. Every poller tunable shipped in v0.23.17 was inert for anyone running the compose stack — including the one knob the #114 reply told the reporter to reach for. All five are now forwarded. **The health panel crashed on an unexpected payload**, taking the whole page with it: `cap is undefined` from reading data.capacity without a guard. It also rendered "Loading…" indefinitely on a request error, so a failing endpoint and a slow one looked identical. Both are the wrong failure mode for this screen in particular. It is the page an operator opens precisely when monitoring already looks broken, and a blank white panel at that moment is worse than no panel: it suggests the whole tool is dead rather than that one query failed. Every field is now defaulted, so a backend a version behind or one that answered partially degrades the panel instead of destroying the page, and a failed request renders what actually went wrong with a retry. Verified against the four shapes that could produce the reported crash: capacity missing, an error body arriving with a 2xx, an older backend without the status field, and an empty object. The endpoint is hardened to match. Device count, timing and the stale-device listing are each guarded independently, so a partial failure returns partial numbers rather than a 500 — the same reasoning applied at the other end. I could not reproduce the original crash: at concurrency 48 the endpoint returns a complete payload here, there is no route shadowing, and the axios interceptor rejects errors correctly rather than passing them through as data. The defensive work stands on its own regardless of which of those shapes he actually hit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
745a7cda93
commit
dd71271473
@@ -6,7 +6,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/version-0.23.19_Beta-blue" alt="Version" />
|
||||
<img src="https://img.shields.io/badge/version-0.23.20_Beta-blue" alt="Version" />
|
||||
<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/TypeScript-5.3-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "0.23.19-beta",
|
||||
"version": "0.23.20-beta",
|
||||
"description": "Mikrotik Network Management Platform - Backend API",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -68,7 +68,10 @@ router.get('/poller', async (_req: Request, res: Response) => {
|
||||
|
||||
// Devices the poller has not managed to reach lately, worst first. This is
|
||||
// the list that answers "which ones is it actually missing?".
|
||||
const stale = await query(
|
||||
// Stale-device listing is a nicety; never let it take the whole payload down.
|
||||
let stale: unknown[] = [];
|
||||
try {
|
||||
stale = await query(
|
||||
`SELECT d.id, d.name, d.status, s.kind,
|
||||
s.last_attempt_at, s.last_success_at, s.last_duration_ms, s.last_error,
|
||||
s.attempts, s.failures,
|
||||
@@ -79,7 +82,10 @@ router.get('/poller', async (_req: Request, res: Response) => {
|
||||
AND (s.last_success_at IS NULL OR s.last_success_at < NOW() - INTERVAL '3 minutes')
|
||||
ORDER BY s.last_success_at ASC NULLS FIRST
|
||||
LIMIT 50`
|
||||
);
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Poller health: stale-device query failed:', (e as Error).message);
|
||||
}
|
||||
|
||||
res.json({ ...health, stale_devices: stale });
|
||||
} catch (error) {
|
||||
|
||||
@@ -721,14 +721,29 @@ export class PollerService {
|
||||
}
|
||||
}
|
||||
|
||||
const [{ count: deviceCount } = { count: 0 }] = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM devices WHERE status != 'disabled'`
|
||||
);
|
||||
const [timing] = await query<{ avg_ms: number | null; p90_ms: number | null }>(
|
||||
`SELECT AVG(last_duration_ms)::int AS avg_ms,
|
||||
(PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY last_duration_ms))::int AS p90_ms
|
||||
FROM device_poll_stats WHERE kind = 'fast' AND last_duration_ms IS NOT NULL`
|
||||
);
|
||||
// Each read is independently guarded. This endpoint is what an operator opens
|
||||
// when monitoring already looks broken, so it must degrade to partial numbers
|
||||
// rather than fail whole — a 500 here reads as "the tool is dead too".
|
||||
let deviceCount = 0;
|
||||
try {
|
||||
const [row] = await query<{ count: number }>(
|
||||
`SELECT COUNT(*)::int AS count FROM devices WHERE status != 'disabled'`
|
||||
);
|
||||
deviceCount = row?.count ?? 0;
|
||||
} catch (e) {
|
||||
console.error('[Poller] Health: device count failed:', (e as Error).message);
|
||||
}
|
||||
|
||||
let timing: { avg_ms: number | null; p90_ms: number | null } | undefined;
|
||||
try {
|
||||
[timing] = await query<{ avg_ms: number | null; p90_ms: number | null }>(
|
||||
`SELECT AVG(last_duration_ms)::int AS avg_ms,
|
||||
(PERCENTILE_CONT(0.9) WITHIN GROUP (ORDER BY last_duration_ms))::int AS p90_ms
|
||||
FROM device_poll_stats WHERE kind = 'fast' AND last_duration_ms IS NOT NULL`
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[Poller] Health: timing query failed:', (e as Error).message);
|
||||
}
|
||||
|
||||
// Can the workers keep up? Arrival is one fast poll per device per cycle.
|
||||
const avgSec = (timing?.avg_ms || 0) / 1000;
|
||||
|
||||
@@ -37,6 +37,13 @@ services:
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-https://localhost,http://localhost,https://127.0.0.1,http://127.0.0.1}
|
||||
PORT: 3001
|
||||
NETFLOW_BIND_PORT: 2055
|
||||
# Poller tuning. These were documented in .env.example but never passed
|
||||
# through, so setting them had no effect on the container at all.
|
||||
POLLER_CONCURRENCY: ${POLLER_CONCURRENCY:-12}
|
||||
POLLER_JOB_TIMEOUT_MS: ${POLLER_JOB_TIMEOUT_MS:-45000}
|
||||
POLLER_INTERVAL_MS: ${POLLER_INTERVAL_MS:-30000}
|
||||
POLLER_JOB_RETENTION_SEC: ${POLLER_JOB_RETENTION_SEC:-3600}
|
||||
POLLER_JOB_RETENTION_COUNT: ${POLLER_JOB_RETENTION_COUNT:-5000}
|
||||
ports:
|
||||
# NetFlow/IPFIX collector — UDP goes straight to the backend (nginx not involved).
|
||||
# Host port must match the netflow_collector_port app setting (default 2055).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.23.19-beta",
|
||||
"version": "0.23.20-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -50,12 +50,19 @@ function Stat({ label, value, hint, alarm }: {
|
||||
);
|
||||
}
|
||||
|
||||
/** Fallback capacity, so a partial or unexpected payload cannot blank the page. */
|
||||
const EMPTY_CAPACITY: PollerHealth['capacity'] = {
|
||||
devices: 0, avg_fast_poll_ms: null, p90_fast_poll_ms: null,
|
||||
arrival_per_sec: 0, service_per_sec: null, headroom: null,
|
||||
backlog: 0, drain_eta_sec: 0,
|
||||
};
|
||||
|
||||
export default function PollerHealthCard() {
|
||||
const queryClient = useQueryClient();
|
||||
const [drainResult, setDrainResult] = useState('');
|
||||
const [confirmDrain, setConfirmDrain] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['poller-health'],
|
||||
queryFn: () => pollerApi.health().then(r => r.data),
|
||||
refetchInterval: 15_000,
|
||||
@@ -70,14 +77,49 @@ export default function PollerHealthCard() {
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
if (isLoading) {
|
||||
return <div className="text-sm text-gray-500 dark:text-slate-400">Loading poller health…</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Failure has to be visible here of all places.
|
||||
*
|
||||
* This panel previously rendered "Loading…" forever on an error and threw
|
||||
* outright on an unexpected payload, blanking the whole page — the worst
|
||||
* possible behaviour for the one screen you open when monitoring looks broken.
|
||||
* Whatever comes back, say something useful about it.
|
||||
*/
|
||||
if (isError || !data || typeof data !== 'object') {
|
||||
const msg = (error as { response?: { data?: { error?: string } }; message?: string } | null);
|
||||
return (
|
||||
<div className="rounded-lg border border-red-300 bg-red-50 p-4 text-sm dark:border-red-700/60 dark:bg-red-900/20">
|
||||
<div className="flex items-center gap-2 font-medium text-red-800 dark:text-red-300">
|
||||
<AlertTriangle className="h-4 w-4" /> Could not read poller health
|
||||
</div>
|
||||
<p className="mt-1 text-red-700 dark:text-red-300">
|
||||
{msg?.response?.data?.error || msg?.message || 'The server returned an unexpected response.'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
Polling itself may still be running — this panel only reports on it. Check the backend logs
|
||||
if this persists.
|
||||
</p>
|
||||
<button onClick={() => queryClient.invalidateQueries({ queryKey: ['poller-health'] })}
|
||||
className="btn-secondary mt-3 flex items-center gap-2 text-xs">
|
||||
<RefreshCw className="h-3.5 w-3.5" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Every field below is defaulted. A backend a version behind, or one that
|
||||
// answered partially, degrades the panel rather than destroying the page.
|
||||
const s = STATUS[data.status] ?? STATUS.ok;
|
||||
const Icon = s.icon;
|
||||
const cap = data.capacity;
|
||||
const backlog = cap.backlog;
|
||||
const cap = data.capacity ?? EMPTY_CAPACITY;
|
||||
const backlog = cap.backlog ?? 0;
|
||||
const queues = Array.isArray(data.queues) ? data.queues : [];
|
||||
const staleDevices = Array.isArray(data.stale_devices) ? data.stale_devices : [];
|
||||
const workers = data.workers ?? { concurrency: 0, job_timeout_ms: 0, poll_interval_ms: 0 };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -103,7 +145,7 @@ export default function PollerHealthCard() {
|
||||
{/* 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="Devices" value={String(cap.devices ?? 0)} />
|
||||
<Stat
|
||||
label="Headroom"
|
||||
value={cap.headroom == null ? '—' : `${cap.headroom}×`}
|
||||
@@ -120,8 +162,8 @@ export default function PollerHealthCard() {
|
||||
<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`}
|
||||
value={String(workers.concurrency)}
|
||||
hint={`${(workers.poll_interval_ms || 0) / 1000}s interval`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -148,7 +190,7 @@ export default function PollerHealthCard() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.queues.map(q => (
|
||||
{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 ${
|
||||
@@ -195,15 +237,15 @@ export default function PollerHealthCard() {
|
||||
{/* 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})`}
|
||||
Devices not polled recently {staleDevices.length > 0 && `(${staleDevices.length})`}
|
||||
</h4>
|
||||
{data.stale_devices.length === 0 ? (
|
||||
{staleDevices.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 => (
|
||||
{staleDevices.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">
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.23.19 Beta';
|
||||
export const APP_VERSION = 'v0.23.20 Beta';
|
||||
|
||||
Reference in New Issue
Block a user