diff --git a/README.md b/README.md index bd2c849..79cdd05 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@
-
+
diff --git a/backend/package.json b/backend/package.json
index 81a29d0..b2b6100 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -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": {
diff --git a/backend/src/routes/system.ts b/backend/src/routes/system.ts
index fd2c84e..e1440f5 100644
--- a/backend/src/routes/system.ts
+++ b/backend/src/routes/system.ts
@@ -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) {
diff --git a/backend/src/services/PollerService.ts b/backend/src/services/PollerService.ts
index 9fb54dc..d01d3e8 100644
--- a/backend/src/services/PollerService.ts
+++ b/backend/src/services/PollerService.ts
@@ -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;
diff --git a/docker-compose.yml b/docker-compose.yml
index a99bea3..eaaf4bb 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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).
diff --git a/frontend/package.json b/frontend/package.json
index c79c270..8cb0c1f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-frontend",
- "version": "0.23.19-beta",
+ "version": "0.23.20-beta",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/components/system/PollerHealthCard.tsx b/frontend/src/components/system/PollerHealthCard.tsx
index 81fe532..e83dad8 100644
--- a/frontend/src/components/system/PollerHealthCard.tsx
+++ b/frontend/src/components/system/PollerHealthCard.tsx
@@ -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
+ {msg?.response?.data?.error || msg?.message || 'The server returned an unexpected response.'} +
++ Polling itself may still be running — this panel only reports on it. Check the backend logs + if this persists. +
+ +Every device has been polled successfully in the last few minutes.
) : (