From 19e2d914f5eccbea265a54431f60f9eca7d926ee Mon Sep 17 00:00:00 2001 From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:31:52 -0700 Subject: [PATCH] fix(lte): data-cap usage was never counted (v0.23.26-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported from the field: manual SMS send works, but no traffic is accumulated. Correct on both counts, and the cause is a single wrong call shape made invisible by a swallowed error. The interface filter was passed as a parameter rather than a RouterOS query word, so the request went out as `=?name=lte1` and the device answered "unknown parameter ?name". That threw on every poll, a `.catch(() => [])` turned it into an empty result, and the empty result made the tracker return early — every time, in silence. The rule looked configured, the usage bar sat at zero, and nothing anywhere said why. Proved against hardware rather than reasoned about: as-shipped -> THREW: unknown parameter ?name query-word -> rows: 1 ether1 rx=4997765728 The filter now goes through the client's query-word argument, falls back to an unfiltered read if a filtered one comes back empty, and — the part that matters more — logs instead of discarding. A counter read that fails, or an interface that is not in the result, now says so. The tracker also warns when it has no counters, because a rule that exists and never counts is precisely the failure the user experiences. Accumulation verified end to end on real hardware afterwards: first poll establishes a baseline and counts nothing, second and third accumulate 118 KB then 144 KB, with the period keyed to the configured local date. Worth recording why this shipped. The pure accounting logic has eighteen tests and all of them pass — reboot handling, midnight rollover across zones, margin, cooldown. None of it ran, because the one integration point those tests cannot reach was wrong. Verifying the API paths and the configuration flow, as was done before release, exercised everything except the thing the feature is for. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- backend/package.json | 2 +- .../src/services/mikrotik/DeviceCollector.ts | 42 +++++++++++++++---- frontend/package.json | 2 +- frontend/src/version.ts | 2 +- 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 932eed8..b86f0e8 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

- Version + Version License Docker TypeScript diff --git a/backend/package.json b/backend/package.json index be0c8f4..e349ba8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.23.25-beta", + "version": "0.23.26-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts index 0192bdf..eed8ab4 100644 --- a/backend/src/services/mikrotik/DeviceCollector.ts +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -2865,13 +2865,36 @@ export class DeviceCollector { }); } - /** Current byte counters for one interface, for data-cap accounting. */ + /** + * Current byte counters for one interface, for data-cap accounting. + * + * The filter is a RouterOS *query word* and has to be passed as one. Smuggled + * in as a parameter it becomes `=?name=lte1`, which the device rejects with + * "unknown parameter ?name" — and because that error was being swallowed, the + * data-cap counter silently read nothing on every poll while the rest of the + * feature looked healthy. Errors here are logged rather than discarded for + * exactly that reason. + */ async getInterfaceCounters(name: string): Promise<{ rxBytes: number; txBytes: number } | null> { - const rows = await this.client - .execute('/interface/print', { stats: '', '?name': name }) - .catch(() => [] as Record[]); - const row = rows.find((r) => r['name'] === name) || rows[0]; - if (!row) return null; + let rows: Record[] | null = await this.client + .execute('/interface/print', { stats: '' }, [`?name=${name}`]) + .catch((err) => { + console.error(`[${this.device.name}] counter read for ${name} failed:`, (err as Error).message); + return null; + }); + + // Some versions answer a filtered print with nothing at all; falling back to + // the full list costs one call and removes a whole class of silent zero. + if (rows && rows.length === 0) { + rows = await this.client.execute('/interface/print', { stats: '' }).catch(() => null); + } + if (!rows) return null; + + const row = rows.find((r) => r['name'] === name); + if (!row) { + console.warn(`[${this.device.name}] interface ${name} not present in counter read`); + return null; + } return { rxBytes: parseInt(row['rx-byte'] || '0', 10), txBytes: parseInt(row['tx-byte'] || '0', 10), @@ -3034,7 +3057,12 @@ export class DeviceCollector { if (!rule) return; const counters = await this.getInterfaceCounters(interfaceName); - if (!counters) return; + if (!counters) { + // A rule that exists but never counts is the failure the user actually + // sees, so it must not be silent. + console.warn(`[DataCap] ${this.device.name}/${interfaceName}: no counters available; usage not updated`); + return; + } const now = new Date(); const key = periodKey(now, rule.reset_hour, rule.reset_minute, rule.timezone); diff --git a/frontend/package.json b/frontend/package.json index 387bb92..b84ede0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.23.25-beta", + "version": "0.23.26-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 28ccace..4b75adb 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.23.25 Beta'; +export const APP_VERSION = 'v0.23.26 Beta';