e47819e232e064bec2751d7259ffd9edc0fc79e7
118
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e47819e232 |
fix(topology): read the spanning-tree root instead of guessing it (v0.23.35-beta)
Closes #131. Reported as the wrong device being marked root: an RB4011 at priority 4096 says "root bridge: yes", two APs at 8192 say they are not and name the RB4011's MAC, and the map crowned an AP anyway — consistently, always the same one. The detection had never worked at all. It looked for a device with no port whose STP role was 'root', but RouterOS reports the role as 'root-port'. Nothing ever matched, so the set of devices-with-a-root-port was permanently empty and the lookup returned the first device in the list. It was not choosing badly; it was not choosing. The same mismatch broke link state. Blocking was derived from a role of 'alternate' or 'backup' against values that arrive as 'alternate-port' and 'backup-port', so blocked links were drawn as forwarding — which is the "traffic connection path" half of the report, since a blocked link shown as live yields a path that does not exist. Roles are now normalised on write so both ends of the system share one vocabulary, and the root is no longer inferred. RouterOS answers it directly: /interface/bridge/monitor reports whether a bridge is the root and names the root it sees, which is exactly what the reporter's own screenshots show the device knowing. That is collected into device_bridges and resolved in utils/stpRoot.ts with 13 tests. Two properties of real networks shaped the resolution, both observed on the development fleet rather than reasoned about: * **A root belongs to a spanning tree, not to a fleet.** Two of four devices here legitimately report root at once, being separate bridge domains, so roots are resolved per connected component. * **The root may not be ours.** One switch points at priority 8192 on a MAC belonging to no device in the inventory. Naming a local device in that case is precisely the original bug, so the crown is withheld and the foreign bridge id reported instead. Verified against that fleet: the managed root resolves to the device that claims it, a self-rooted bridge keeps its own crown, and the switch with an external root now reports "not ours" where the old logic confidently crowned it — the reported symptom, reproduced and fixed. Also adds collectStp to the full sync. It previously ran only on the five-minute slow poll, so pressing Sync after rewiring did not refresh the map. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
273a0da737 |
fix(deploy): restore NetFlow to the published compose file, and generate it (v0.23.34-beta)
Closes #121. The Quick Deploy file was missing NETFLOW_BIND_PORT and the 2055/udp mapping, so the collector bound inside the container and nothing reached it. NetFlow did nothing, silently, with an empty traffic page and no error — for everyone following the path the README tells new users to take. The report was accurate and understated. docker-compose.ghcr.yml had drifted for seven weeks, and three closed issues had never reached it: * NETFLOW_BIND_PORT and the UDP port — as reported * all five POLLER_* tunables — #114, whose fix was "pass the tunables through" * the Redis memory ceiling and eviction policy — #116 * TZ on the postgres service, which nobody had noticed at all The middle one is the uncomfortable part. Four days ago #114 was closed by forwarding poller settings that were documented but inert, with a comment saying setting them "did precisely nothing". On Quick Deploy that remained true, because one compose file was fixed and the other was not — including the advice given to that reporter about which knob to turn. So patching the four gaps would have been treating the symptom. The two files describe the same system and differ only in where images come from; maintaining both by hand meant remembering every change twice, and the one forgotten was always the file no maintainer runs. docker-compose.ghcr.yml is now generated from docker-compose.yml by scripts/generate-ghcr-compose.mjs, which performs the two build-to-image swaps and nothing else. Substitution is textual rather than parse-and-reserialise so comments survive — the reasoning about Redis eviction is worth more than the two lines it explains. The file stays committed and browsable, because the README tells people to curl it directly. A --check mode runs as the first preflight gate and as its own CI job, both of which fail when the committed file does not match what the generator produces. Verified by deleting the NetFlow line and watching the guard reject it, with the reason and the fix in the failure message. Reported by @mamb1329, on their first issue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6c5b8ac21a |
feat(lte): time-on-band and time-on-cell statistics (v0.23.33-beta)
Closes #120. The Tower and band changes panel listed every handover, newest first, growing without bound. On a link that hands over several times a day it reads as a wall of near-identical rows — it answers what happened, but not what the pattern is, which is the question an operator actually has. Dwell totals now sit above it: how long the modem has spent on each band and on each cell, over 24 hours, 7 days or 30 days, ordered by time spent so the answer is the first row rather than something to be worked out. Visit counts sit alongside, because six brief stays and one long one are different situations that share a total. No new collection. History is already written on every change, so dwell is the interval between consecutive events — the same reconstruction used for WiFi roaming sessions. Three decisions worth recording. **The state in force when the window opened is included.** Without it, a link that changed once yesterday would report a few minutes of dwell across seven days, having silently dropped everything before the first change in range. The last event *before* the window is fetched specifically to anchor that first stretch. **Aggregated carriers each count for the whole stretch.** A period on B1 and B3 together credits both with the full duration, so band shares describe how much of the window each band was in use rather than dividing it between them. Totals can exceed 100%, which is the honest description of carrier aggregation rather than a rounding artefact. **Absence is reported as absence.** A segment whose cell was never reported is excluded rather than attributed to "unknown", a device with no history reads as "no changes recorded" rather than an empty panel, and a window longer than our records says so instead of implying the quiet period was real. Logic lives in utils/lteDwell.ts with 17 tests. Verified end to end against seeded history matching the reporter's own pattern: a 24-hour window covered exactly 24 hours, B3 correctly showed 19 of them against a five-hour stretch without it, cell totals summed to the window, and a 30-day request over 9 days of records reported itself truncated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
33ca7cc651 |
chore: clear the dependency backlog, and fix a log-injection finding (v0.23.32-beta)
Two pieces of housekeeping that had gone quiet. **A code-scanning alert nobody had looked at.** CodeQL flagged js/log-injection in the preflight handler: a request value interpolated straight into a log line. Log entries are newline-delimited, so a value containing a line break forges additional entries — and an audit trail its own subject can write into is worse than none, because it is believed. The interesting part is that the risk had already been recognised. Two sites in the clients routes strip carriage returns inline, ad hoc, and the third was simply missed. That is what a shared helper is for, so there is now a `logSafe` utility with tests, used at all three. It replaces control characters with a visible marker rather than stripping them, so a tampered value reads as tampered instead of being quietly tidied, and truncates absurd input so one field cannot flood the log. **Twelve Dependabot pull requests, the oldest six weeks old.** Nine had passing checks, but those runs were from July against a main that has moved a long way, so rather than merging stale branches serially the eight safe ones are applied here as one change and verified together: bullmq, pg, helmet, @types/supertest, tailwind-merge, vite, actions/setup-node and github/codeql-action. Deliberately excluded, and left open: * bcryptjs 2.4.3 to 3.0.3 (#79) — a major on the password-hashing path. Dependabot groups it with routine bumps; it is not one, and it deserves its own verification rather than riding along in a batch. * typescript 5.9 to 7.0 (#81), tailwindcss 3.4 to 4.3 (#76) and eslint-plugin-react-hooks (#78) — all three already fail their own checks. Verified beyond the gates: the application was rebuilt and exercised on the new dependencies — authentication, the BullMQ-backed poller, Postgres reads and helmet's headers all behave. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8474f8e789 |
fix(firmware): download and verify before rebooting, and prove the reboot (v0.23.31-beta)
Reported as rollouts failing with "Device rebooted but still reports 7.24.1 — the update did not apply", intermittently and on ad-hoc updates too. Three separate faults, found by watching one rollout that succeeded on an access point and failed on a switch in the same run. **The device had never rebooted.** Its uptime was one week. The old sequence issued `/system/package/update/install`, slept a fixed 25 seconds, reconnected, read the version and — finding it unchanged — declared the upgrade failed. It could not tell a device that restarted unchanged from one that never restarted at all, and 25 seconds is nowhere near long enough: the access point that *worked* took 245 seconds. So a device still busy downloading was routinely reported as broken, and with halt-on-failure that aborted the rest of the fleet. **`install` was the wrong command.** It bundles download and reboot into one fire-and-forget call whose progress cannot be observed. On the CCR2216 it accepted the command and then neither downloaded nor rebooted, reporting nothing; an explicit `download` on the same device worked immediately. The sequence is now download → confirm the device reports the image as downloaded → reboot, which makes success verifiable *before* anything restarts and means a device is never rebooted for an image it does not have. **A successful reboot was being read as a failure.** Rebooting necessarily kills the session that asked for it, so the transport error that follows is the expected outcome. Unhandled, it surfaced as "Update failed before reboot: Connection closed" on a device that had in fact rebooted and upgraded correctly. The reboot is now proven by comparing uptime across it rather than assumed from elapsed time, so "never restarted" is reported as itself — with the device left running and reachable — instead of being mistaken for a failed upgrade. Download failures now carry the device's free space, because that is the usual cause and "did not finish in ten minutes" otherwise sends someone hunting the network. A CRS309 with 1.06 MB free now says so instead of timing out silently. Verified on hardware: the CCR2216 that produced the original report upgraded 7.24.1 to 7.24.2 cleanly, and the CRS309 that genuinely cannot fit the image failed with an accurate message and was left untouched — same version, uptime unbroken, never rebooted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2eda3d47ae |
ci: cut a release when the version changes (v0.23.30-beta)
The dashboard sends people to the releases page to see what changed. That page held a single entry from five months earlier while the product shipped several times a day, which tells a visitor the project is abandoned — raised on #119, and the more useful half of that thread. A release is now cut whenever the shipped version changes, tagged to match the container image so the thing you are running and the notes you are reading carry the same number. Notes are the commit body rather than a generated list of subjects, because the commit messages here already explain what changed and why. Betas are marked pre-release so nobody is pointed at one by accident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
324ceba676 |
fix: place log events in real time, reset the data cap on send, export runs (v0.23.29-beta)
Three reports from this morning. **Event timestamps were not the times the events happened.** RouterOS writes log times in the device's own timezone and never says so, and it uses more than one format. The parser handled `sep/03 08:37:38` and `08:37:38` but labelled both UTC, shifting every event by the device's offset — reported as an event log seven hours out from the device page. It did not handle `2026-09-03 08:37:38` at all, which is what current RouterOS actually returns over the API, so those fell through to the collection time instead: plausible on a live poll, badly wrong for a backlog, where a thousand historical lines all arrive stamped "now". Devices now report their clock, and timestamps are converted from device-local to UTC through it, with the numeric gmt-offset as a fallback and daylight saving handled by the runtime rather than a fixed number. Where the zone is unknown the parser returns null and the caller falls back to collection time, deliberately — a confidently wrong timestamp is worse than an openly approximate one. Verified on hardware: 18 of 18 events in a steady-state window now store exactly seven hours ahead of what the device printed, which is the offset the device itself reports. Existing rows are left alone; they were written by three different code paths and cannot be corrected uniformly. **The data-cap counter kept climbing after the SMS was sent.** Reported from the field, and correct: the message is what zeroes the carrier's allowance — it is the alternative to waiting for midnight — so ours has to follow. Left as it was, two things went wrong: the bar counted against an allowance that no longer applied, and usage stayed above the threshold, so the rule would re-fire on every cooldown for the rest of the day. Both send paths now restart the count. **Command runs export to CSV**, asked for on #118 for archiving and audit. Output is quoted so multi-line device responses survive, and timestamps are ISO rather than the driver's default string form, which no spreadsheet parses. The download goes through the authenticated client rather than a plain link, because a link cannot carry a Bearer header and would simply 401. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
98a72e1f7a |
docs: versioned documentation site, and the four pages that were missing (v0.23.28-beta)
Closes #112. Source stays `docs/*.md`, rendered by MkDocs-Material and published to GitHub Pages by Actions. Same repository on purpose: documentation drifts, and co-location is the only thing that reliably prevents it — the pull request that changes behaviour changes the description, and review sees both. A separate repo would double the ceremony and guarantee divergence. The markdown remains readable when browsing the repository, so the site renders those files rather than replacing them. **Versioned from the start**, using mike. Images are published to GHCR and this project ships betas most days, so readers are routinely running something other than the newest build; a single "latest" would confidently describe a release they do not have. Versions follow the product rather than the docs, so the site and the running image carry the same number. **Content came first, and it had to.** The docs were eleven days and sixteen releases stale — SSH keys, bulk commands, the data-cap SMS, poller health and the timezone setting appeared in exactly zero files between them. Publishing that would have given documentation missing five of the most consequential features a polished, authoritative home, and a well-presented wrong answer is worse than an obviously rough one. Four pages now cover them: - cellular.md — signal grading, carrier aggregation, the uplink anchor, observed bands, and the data-cap SMS including why it fires early and how to calibrate the margin - ssh-keys.md — leading with what installing a key costs, because RouterOS disables password SSH for that user and the database becomes load-bearing - commands.md — why waves exist, and why reading output matters when RouterOS reports failure in text rather than an exit code - scaling.md — headroom, why it is insufficient on its own, and every tunable Pull requests build the site with `--strict`, which turns warnings into errors. That is the point of running it there at all, and it earned itself immediately: the first build failed on two links to the project README, which sits outside the docs tree. They are absolute now, so they resolve both in the rendered site and when reading the markdown on GitHub. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0e364b1558 |
feat(commands): run one command across a fleet, in waves (v0.23.27-beta)
Closes #118. Unblocked by #110, which is what makes SSH on sixty devices practical. Arbitrary RouterOS console commands over SSH, because `:put`, `:foreach` and `/interface print` are console syntax that the binary API cannot express, and because a curated command library cannot cover "add a channel to LACP" or "rewrite a WireGuard endpoint" — the things people actually asked for. **Waves are the feature, not the safety rail around it.** A shell loop already runs a command on many hosts, costs nothing, and prints errors as they happen. What it cannot do is stop. Devices run together within a wave, waves run strictly in sequence, and halt-on-failure ends the run before a mistake becomes sixty mistakes. Without that, this would be a worse bash. Proved on hardware rather than argued: a bad command on two devices, one per wave, failed on the first and left the second `skipped`. RouterOS reports most errors in output text rather than an exit code, so output is read for failure markers — otherwise a rejected command and a successful one are indistinguishable and the run would sail on. Conservatively: only unambiguous markers count, since misreading ordinary output would halt a run that was working. Change Guard wraps each device by default, so one that stops answering after the command restores itself. It is one click to turn off, because a sharp tool that refuses to cut is not a tool — the requester's argument, and a fair one. The combination that gets an explicit acknowledgement is a command that can sever management *with the guard disabled*; that is the only case where nothing catches a mistake. A preview names devices, wave assignment, and the reasons a command looks capable of cutting management access. It warns, never blocks. Devices with no SSH credential are listed before the run rather than producing a wave of identical authentication failures. Per-device output is captured and kept, because "it failed" without the device's own words is not actionable, and because some commands exist purely for output. Verified end to end against real hardware: a read-only command across two devices, halt-on-failure stopping a bad command after one device, and a genuine write applied under Change Guard, confirmed, then reverted — with both devices returned to their original configuration afterwards. Also adds services/sshExec.ts, shared with the key work: it selects a verified key or a password, and throws rather than swallowing. That last part is deliberate — a swallowed error is exactly how the data-cap feature spent a week doing nothing while looking healthy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
19e2d914f5 |
fix(lte): data-cap usage was never counted (v0.23.26-beta)
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 <noreply@anthropic.com> |
||
|
|
864ca40427 |
fix(ssh): state what installing a key actually costs, and ask before doing it (v0.23.25-beta)
v0.23.24 shipped the SSH key feature with a warning that undersold it and a confirmation flow that was the wrong way round. Five gaps, all in how the change was presented rather than in what it does. **Installing was one click; revoking asked for confirmation.** Backwards. Revoking restores password SSH, while installing takes it away — the consequential action was the unguarded one. Installation now requires an explicit acknowledgement naming the account that will lose password access. **The warning disappeared once a key existed.** Whoever arrives at a device later and finds their password refused over SSH is exactly the person who needs the explanation, and they were the one person not shown it. The notice is now permanent, worded for the current state rather than only the impending one. **It said "this user" rather than naming the account**, which is the difference between a caution and a fact someone can act on. **It never mentioned losing the database.** That is the serious one. Password SSH is already refused once a key is bound, so a manager that loses its database or encryption key leaves SSH recoverable only through the API or WinBox. That consequence outlives the session, the operator, and quite possibly the person who clicked the button, and it went unstated. **Fleet-wide deployment had no guard whatsoever.** One request would disable password SSH on every device it touched, while the single-device path asked for an acknowledgement — an endpoint that can do a thing sixty times should not be easier to trigger than the one that does it once. It now refuses without an explicit confirm and explains why in the refusal. The notice also spells out what was previously left implicit: colleagues, scripts and other tools that SSH in with a password will stop working. On a shared fleet that is a change to announce, not to discover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1ab2e6547e |
feat(ssh): per-device SSH keys, deployed and proved (v0.23.24-beta)
Closes #110. Also the prerequisite for #118 — arbitrary console commands need SSH on every device, and stored passwords do not scale to sixty of them. Each device gets its own ed25519 keypair, generated on the manager, pushed over the existing password session, imported, and then *proved* by opening a fresh connection and authenticating with it. Nothing trusts a key until that last step succeeds. Backups and config export use a verified key where one exists. Verified end to end on hardware: deploy, rotate twice leaving exactly one authorised key each time, verify, and a 4.6 KB config backup pulled over key-only SSH with no password fallback. **The design note on the issue was wrong about the central safety property**, and testing on a switch is what proved it. I claimed the stored password remained as a fallback. It does not: RouterOS binds a key to a user and then refuses password authentication for that user entirely. Removing the key restored password login immediately, which settles the causation. That mistake compounded with a second one. Deployment wrote the new private key to the database before proving it, so a rotation that failed halfway left the database holding a key the device had never authorised while the device demanded one we no longer had — SSH lost on the test switch, recoverable only through the binary API. Three changes follow: * Rotation authenticates with the *existing* key, since the password is no longer an option once a key is installed. * Nothing is written until a fresh connection has authenticated with the new key. A deployment that fails verification removes the key it just installed rather than leaving the device demanding a credential nobody holds. * The binary API, unaffected by any of this, is the removal path — and is therefore the real recovery route, not the password. Two further faults came out of live testing rather than reasoning. Every key for a device shared one comment, so removing "the old key" during a rotation removed the new one too; comments are now unique per key. And RouterOS stores that comment in `info`, not `key-owner` — matching the wrong field silently matched nothing and left every rotation's old key behind, which is why the switch accumulated keys that later blocked it. Revoke now clears *every* key this manager installed for a device, not just the one on record. Orphans from a half-failed rotation otherwise keep password SSH disabled with no way back through the product, which is exactly the state the test switch got stuck in. When deployment fails with an authentication error and orphans are present, the message says so instead of leaving someone guessing. The UI states the trade before it is made: installing a key disables password SSH for that user, the API and WinBox are unaffected, and revoking restores it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a06ebd4d71 |
feat(lte): daily data-cap tracking and carrier reset SMS (v0.23.23-beta)
Requested in discussion #85. Several European carriers sell "unlimited" mobile data that is throttled hard past a daily allowance, and lift the throttle when the subscriber texts a short code — 10 GB then 64 kbps until you text 1237, in the reporter's case. Doing that by hand every evening is exactly the sort of thing this should handle. Two facts from the contributor's hardware shaped the whole design. **Interface byte counters do not survive a reboot.** So there is no running total to read; usage has to be accumulated from poll to poll on our side. That makes our figure a permanent undercount — traffic during a reboot, before the device was adopted, or between a missed poll and the next is invisible. An undercount fires late, and late means the user is already throttled, so the trigger deliberately fires below the configured allowance (5% by default, and adjustable). Firing early costs one SMS; firing late costs the evening. **The carrier's own counter resets at local midnight.** That anchors the period and, more usefully, bounds the error: both counters reset together, so our drift cannot accumulate past a single day. Periods are keyed by local date string rather than timestamp, which detects the rollover without converting local wall-clock back to UTC — where daylight saving usually goes wrong. A counter that reads *lower* than the previous sample is treated as a reboot and the new value taken as the delta, rather than as negative usage that would corrupt the running total for the rest of the day. The manual send is deliberately independent of everything else: not gated on the rule being enabled, on a threshold, or on our usage estimate. It is the half of this feature that is trustworthy — one known action, for a reason the operator already has — and it is what makes the automatic half optional rather than load-bearing. The automatic trigger carries a cooldown because usage sits *at* the cap once reached rather than above it; without one, a link parked on its limit would text the carrier on every poll. Sending twice is a nuisance, forty times is an incident. Every send is recorded with the counter reading at the time, so a misfire is explainable rather than mysterious. Accounting logic lives in utils/dataCap.ts with 18 tests, covering the reboot case, the local-midnight rollover across zones and a non-midnight reset, the margin, and the cooldown. Failures in tracking are logged and swallowed: a convenience feature must never break the collection it rides on. Also fixes an error path found while testing. DeviceCollector decrypts the stored credential in its constructor, which sat outside the route's try block, so an unreadable credential escaped as a raw 500 carrying an internal message and left no record of the attempt. It now returns 502, says the credentials could not be read rather than quoting a cipher error at someone hunting a modem fault, and logs the failure like any other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4ff8eec200 |
fix(topology): stop breadth translating into width (v0.23.22-beta)
Closes #115. The map gave every leaf its own column, so a subtree's width was simply its leaf count. That is reasonable for a deep branching network and useless for a real one: a core switch with fifty-eight access switches under it produced a single row fifty-eight slots wide — over ten thousand pixels, which is exactly how it was reported. Real networks are shallow and wide, so breadth had to stop translating directly into horizontal distance. Large groups of sibling leaves are now laid out as a block rather than a row, trading width for height and turning linear growth into roughly square-root. The reported shape goes from 58 slots to 10, six rows deep. Children that have subtrees of their own keep the classic tidy-tree treatment, because there the hierarchy is the information and flattening it would destroy meaning. Only leaf groups — where the sibling order carries nothing — are packed. Block proportions are deliberately wider than tall: screens are landscape, and a column of forty reads worse than a band four rows deep. The layout moved to utils/topologyLayout.ts in slot and row units, with pixels left to the renderer, and 12 tests. That split is what makes the fix checkable at all — the old arithmetic was only observable by looking at a rendered graph of a network none of us has. The tests pin the reported case and, as importantly, the shapes that must not regress: a three-node row stays a row, a branching tree keeps its levels, a deep chain stays one slot wide, and no two nodes land on the same coordinate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7d02bedc5f |
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> |
||
|
|
dd71271473 |
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> |
||
|
|
745a7cda93 |
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>
|
||
|
|
deb71363c9 |
fix(poller): cap Redis memory, and stop retrying periodic polls (v0.23.18-beta)
The two follow-ups left open by v0.23.17. One is a real fix; the other turned out to need explaining rather than fixing. **Redis had no memory ceiling.** v0.23.17 stopped the poller from growing Redis without bound, but nothing stopped anything else from doing so, and the host had no protection either way. Redis now runs with a 512 MB cap (REDIS_MAXMEMORY). The eviction policy is the part worth care. `allkeys-lru` would be actively dangerous here: BullMQ job data carries no TTL, so an LRU sweep across all keys could discard queued work with no trace. `volatile-lru` evicts only keys that set an expiry — which is exactly the poller's caches and in-flight claims, all of which are safe to lose because losing one merely causes an earlier re-poll. Verified rather than assumed: sampled BullMQ job keys report ttl=-1 and poller cache keys 528-3408s. If pressure ever exhausts the volatile keys Redis refuses writes instead of evicting queue state, which is loud and survivable. **The 14% fast-poll failure rate is not a bug.** Current rate across all devices and job kinds is 0% over 1,462 attempts. The historical 230,707 failures were genuine device downtime: device_availability records 324 outages totalling ~1,003 hours across four lab devices, which at a 30-second cadence and two attempts per cycle predicts ~240,700 failures — within 4% of what was recorded. Nothing to fix; retention had simply been hoarding an accurate count. It did expose real waste, though. Periodic polls no longer retry in-job, because the schedule already is the retry: a device that did not answer is unlikely to answer a second later, and each extra attempt occupies a worker for up to the job timeout. On a fleet with several devices down that doubled the capacity they consumed — the same capacity the rest of the fleet was short of in #114. It also doubled the recorded failure count, which is how ~1,000 hours of downtime came to look like 231k distinct faults. User-initiated full syncs keep their retries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
38d23cde02 |
fix(poller): stop the queue outrunning the workers, and cap job retention (v0.23.17-beta)
Addresses #114, where a ~60 device fleet saw devices stop reporting entirely while others crawled, new devices never started polling, and a manual Sync worked exactly once. All of it is one failure with two compounding causes. **The queue grew faster than it drained.** Worker concurrency was hardcoded to 3 with no per-job timeout, and the scheduler enqueued a fast poll per device every 30 seconds regardless of whether the previous one had finished. Measured against a live fleet a fast poll took ~1s at the median but 13s at p90, so three workers sustained about 0.87 jobs/sec against an arrival rate of 2.0/sec at sixty devices. The difference — roughly 98,000 jobs a day — accumulated in an unbounded FIFO, and devices at the back of it were simply never polled. That is why Sync appeared to work once and then stop: the Sync route runs inline and bypasses the queue entirely, so it was the only thing still reaching those devices. Three changes: concurrency is configurable and defaults to 12; every job runs under a timeout so one unreachable device cannot hold a worker while the fleet starves; and a device with a poll already queued or running is not enqueued again. That last one is the important one — it makes a backlog impossible rather than merely less likely. The claim is a Redis key with a TTL, so a worker that dies frees its device on the next cycle instead of wedging it forever. **Finished jobs were never removed.** BullMQ keeps them unless told otherwise. A four-device development instance had accumulated 1,412,935 completed and 230,707 failed jobs across the three queues — 2,570,474 Redis keys and 1.16 GB, against a Redis with no maxmemory configured. Left alone it grows until the host dies. Retention is now bounded by age and count, and a background pass on startup trims history from before the limits existed. The two faults fed each other, which is measurable: after trimming 2.16M jobs the mean fast poll on the same fleet fell from 3,448ms to 602ms and the slowest from 16,095ms to 1,205ms. Multi-million-element sorted sets were slowing every queue operation, which reduced throughput, which grew the backlog further. **The reporter's follow-up is the real lesson** and is addressed too: there was no way to tell whether a device had been missed, was unresponsive, or was merely slow, and no view of queue depth or per-poll cost — so polling frequency was being chosen by guesswork. device_poll_stats now records attempt and success separately per device and kind, with duration and last error; their difference is precisely the missing distinction. GET /api/system/poller reports queue depths, worker settings, measured poll cost, and a headroom figure that states whether the configured cadence is achievable at the current fleet size. Below 1.0 the backlog grows every cycle. Verified on the live instance: 2,570,474 keys reduced to 2,336, memory 1.16 GB to 32.25 MB, queues drained, per-device stats recording, no stranded in-flight claims, and headroom reported at 149x for four devices — the same arithmetic that would have read 0.43 for sixty devices before this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1332f7384d |
fix(firmware): make the per-device update button actually install (v0.23.16-beta)
The "Install update" button on a device's Config tab reported success and did
nothing. Staged rollouts were unaffected, which is why this survived: the two
paths do the same job through different code, and only one of them was right.
RouterOS installs the version discovered by `check-for-updates` **in the same
API session**. Issued alone, `/system/package/update/install` replies !done and
takes no action — no download, no reboot, no error. The route issued it alone.
The rollout orchestrator calls the check first, so it always worked.
Confirmed by A/B on one switch, two minutes apart. Through the button: HTTP 200,
"Update installation initiated. Device will reboot", and ninety seconds later
the device still reported 7.24 with "New version is available" and had not
rebooted. Through a rollout: reboot within fourteen seconds, upgraded to 7.24.1.
Two further faults in the same handler, both of which made the first one harder
to see:
* `firmware_update_available` was cleared the moment the command was sent, so
every screen showed the device as current while it sat unchanged. The next
poll flipped it back, which reads as an update that "keeps coming back".
* Success was reported on submit rather than on outcome, so a device that was
already up to date and a device that had genuinely started an upgrade
produced identical messages.
The route now checks first, distinguishes "installing 7.24.1" from "already
running 7.24.1 — nothing to install", and leaves the flag alone: an upgrade
takes minutes and a reboot, neither of which an HTTP request can wait for.
Ownership of that flag moves to the version read itself. collectSystemInfo now
clears it when the observed version matches the known latest, because the
dedicated firmware check runs only once a day — without this, not clearing
optimistically would leave a device flagged for up to 24 hours after it was
upgraded. It also fixes the same staleness when a device is upgraded outside the
platform entirely.
install-routerboard had the identical shape and gets the identical treatment.
It matters more there: `/system/routerboard/upgrade` on a current bootloader is
a no-op, but the code rebooted the device afterwards regardless — an outage in
exchange for nothing.
Verified against the live fleet: the fixed button upgraded 2GT-NW-BigSwitch
7.24 -> 7.24.1; an already-current device answered "nothing to install" instead
of claiming a reboot; the flag stayed set during the upgrade and cleared once
the new version was observed; and a device genuinely behind stayed flagged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f011a40748 |
fix(dashboard): render the snooze button, and cap the things-to-handle list (v0.23.15-beta)
Three faults in the same card, all layout. **Snooze rendered as an unreadable sliver.** The row is a three-column grid — severity bar, text, action — but carried four children once the snooze button was added in v0.23.10. The fourth wrapped onto an implicit second row and landed in column one, which is 3px wide, so the button was squeezed to nothing. The two buttons now share the third column in a flex wrapper, which is what the grid always assumed. Only the rendering was broken: the dismiss round-trip was verified end to end against the running instance — POST returns 201, the item leaves the list, the snoozed count rises, and DELETE restores it. **A long backlog pushed the rest of the dashboard off the page.** The list grew without bound, so a fleet with real problems displaced everything below it. It now caps at min(52vh, 460px) and scrolls, keeping the card a fixed part of the page. The snoozed-count footer stays outside the scroll area so it remains visible. **The card rendered at 57% width with dead space beside it.** It was the only child of a two-column grid left behind when quick actions moved to the header (#104) — the comment above it already claimed full width, which it had not been since. The empty column and its wrapper are gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7a548abfd8 |
fix(clients): filter the row the table shows, not the rows behind it (v0.23.14-beta)
Closes #100. The clients list deduplicates by MAC: a client seen by several devices collapses to one row, and DISTINCT ON resolves the collision in favour of the wireless sighting. The filters, however, ran against the raw rows before that happened, so a filter and the column beside it could disagree. The wired/wireless filter showed it plainly. A wireless client's MAC is also learned by the switch its AP uplinks to, so it holds both a wireless row and a wired one — on the test fleet every wireless client does. Filtering by "wired" therefore returned all 176 clients rather than 163, including the 13 the table was labelling wireless. Filtering by signal had the same flaw in a subtler form: a client seen by two APs at different strengths could appear in a band its displayed row was not in. Filters now sit on the correct side of deduplication according to what they mean. Device and active-state choose which sightings to consider, so they still run before. Type, VLAN and signal describe what the table displays, so they run after. The count query deduplicates identically, so totals and rows agree. Verified against the live fleet by running the same predicates as SQL and through the API in the same moment: 176 total, 162 wired, 14 wireless, 29 on VLAN 1, 13 at -67 dBm or stronger — matching on every one, with the wired and wireless halves now summing to the total. Also closes four issues that were implemented in v0.23.9 and v0.23.10 but stayed open: their commit messages listed several numbers behind a single "Closes" keyword, which GitHub applies only to the first. #97 — location selector on the Channel Usage panel #102 — snoozeable "Things to handle" items #107 — rack and location filters on the device table #108 — topic, device and severity filters on the events table Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ec873ab8bd |
fix(wireless): keep the 2.4 GHz channel map inside its card (v0.23.13-beta)
The 2.4 GHz row of the Channel Usage panel painted over the edge of the card. The cause is the band table, not the layout. A 40 MHz channel 1 occupies 2392-2432 MHz, but the drawn 2.4 GHz band started at 2400, so that carrier resolved to `left: -8%` and an absolutely positioned bar at a negative offset renders outside its container. 2.4 GHz is the only band where this can happen: channel 1 is the lowest channel in the plan and the only one whose 40 MHz form reaches below the round number the axis started on. The band now starts at 2390, which is also more honest — the radio really does reach below 2400, and clamping alone would have drawn it as though it did not. Geometry moves to utils/spectrumGeometry.ts with tests, and keeps a clamp even though the range now covers channel 1: band ranges are static assumptions, and region-specific channels and widths they do not anticipate will keep appearing. No bar can leave the card again regardless of what the backend reports. An earlier attempt at this fixed a real but different bug in the same row: ticks at 2400 and 2480 fall outside the channel plan, so an axis labelled by channel fell back to printing raw frequencies, and the 2400 label sat at exactly 0% with a centring transform and hung half its width outside the card. That fix stands; it simply was not the whole of what the user was seeing. The verification script used at the time filtered radios to those starting inside their band, which silently discarded the very row that was overflowing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2625fea8de |
feat(lte): cellular telemetry, band details and roaming history (v0.23.12-beta)
Adds LTE support, requested in discussion #85 by a contributor running a MikroTik ATL 18 on a fixed antenna. Cellular was previously a blank page: the only occurrence of "lte" in the tree excluded LTE interfaces from DHCP binding. Built against two real field captures rather than documentation, which disagreed with the hardware in several places — the modem reports `data-class` not `access-technology`, `iccid` not `uicc`, `status: running` not `connected`, embeds units inside values (`-97dBm`, `17dB`), and omits `registration-status`, `pin-status` and `lac`/`tac` entirely. What an LTE interface reports depends on the modem chipset, not on RouterOS, so every field is optional by construction. Fixes a latent bug in the API client while it was in reach. A sentence carrying the same attribute twice kept only the last value, and a Cat-18 modem reports one `ca-band` per aggregated carrier. On the contributor's 4-carrier capture that yielded B3 + B20 — 30 MHz — on a device actually running 70 MHz, because the narrowest carrier happens to arrive last. Repeats are now preserved alongside the flat value, so existing callers are byte-for-byte unaffected. Signal quality is graded rather than merely displayed. RSRP alone misleads: the capture shows −97 dBm, nominally "fair", on a link running 256QAM at CQI 15 with two spatial streams. SINR leads the grade and RSRP moderates it. Surfaces the uplink anchor, which no MikroTik UI shows. Left on automatic a modem anchors to whichever band it hears loudest, often a narrow one, and aggregates wider bands for downlink only — but uplink rides the primary carrier alone, so upload stays capped. This is the contributor's actual reason for locking bands, and it reframes band selection as anchor control rather than coverage. Band changes, tower handovers and re-registrations are reconstructed by comparing consecutive polls, since scanning interrupts service and finds nothing at a site served by a single base station. Observed bands accumulate as the evidence a future band lock will be checked against. Band selection itself is deliberately absent: it needs Change Guard plus a free-space check, as the target hardware has 1.1 MB free of 16 MB flash. Also fixes an unrelated display bug in the RF Health channel map. The 2.4 GHz axis is labelled by channel, but ticks at 2400 and 2480 fall outside the channel plan and fell back to printing raw frequencies — and the 2400 label sat at exactly 0% with a centring transform, hanging half its width outside the card. 2.4 GHz is the only band with a tick at an edge. Unlabelled ticks are now dropped and edge labels anchor inward. - backend/src/utils/lte.ts — parsing, quality, change detection (59 tests) - RouterOSClient — repeated attributes preserved (6 tests) - lte_interfaces, lte_cell_history, lte_observed_bands - GET /devices/:id/lte, /lte/history, /lte/metrics - LteTab, gated on has_lte from the interface list - scripts/seed-lte-fixture.sql — a cellular device for local development Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
57d650c159 |
feat(wireless): reconstruct per-client roaming history from AP logs (v0.23.11-beta)
Closes #105. RouterOS narrates the whole life of an association in its log — association, every band or AP change, and the disconnect — each line carrying the signal at that moment. Nothing else in the platform can see any of it: roaming happens between polls, so a poll only ever observes where a client ended up, never that it moved. Sessions are now rebuilt from those lines and shown on the client detail page: the path across radios, signal at each step, duration, and RouterOS's own disconnect reason. Sessions averaging six or more roams an hour are flagged, since a client bouncing between radios is usually a roaming-threshold problem rather than a faulty client. No new collection and no new storage. It reads the events already gathered, so it works retroactively over whatever log history exists. Two things came out of testing against real logs rather than the samples in the issue, and both were wrong in ways the samples could not have shown: Ordering. RouterOS log timestamps are second-granular, so a reassociation emits its disconnect and reconnect inside the same logged second, and collection assigns sub-second values in whichever order they were read — observed on a live AP as both `.849/.849` and `.096/.097` for the same kind of pair. Sorting on the raw timestamp attached the disconnect to the session the connect had just opened, so every reassociation reconstructed as a 0-second session while the real one disappeared. On one IoT client that was 255 phantom sessions out of 422. Events are now ordered by whole second and ties broken by meaning: a disconnect closes what came before, a connect opens what comes after. The same client now shows its real behaviour — 900s, 840s and 930s sessions, reassociating every 15 minutes. Signal sanity. Real logs contain `signal strength 91`. dBm is negative, so a positive reading is not a measurement; recording it would draw a client with an impossibly perfect signal. Treated as unknown. 21 tests, including the full ping-pong trace quoted in the issue — which the reconstruction correctly identifies as flapping — plus both timestamp-collision shapes seen in production. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
37887d38c2 |
feat: RouterBOOT in staged rollouts, and snoozeable insights (v0.23.10-beta)
Closes #113, #102. #113 — RouterBOOT as a follow-on rollout step Firmware rollouts upgraded RouterOS and stopped, leaving the bootloader to be done by hand on every device — the exact task staged rollouts exist to remove. The data was already collected and the collector already had the two methods; the orchestrator simply never called them. An optional "Then RouterBOOT" step now runs per device after the RouterOS upgrade has been verified, rides out the second reboot, and confirms the firmware version actually moved. Ordering is deliberate: the bootloader ships inside the RouterOS package, so upgrading it first would apply the old one. It runs only once the OS half is confirmed good, and off by default, because it is a second flash and a second reboot per device. A device with nothing pending is a success rather than a failure — most of a fleet is already current, and treating "nothing to do" as an error would halt rollouts for no reason. When RouterBOOT does fail after a successful RouterOS upgrade the error says so explicitly, rather than reporting the device as simply failed and sending someone to re-run an upgrade it already has. #102 — snooze an item in "Things to handle" Insights are recomputed from live state on every request and have no id, so a dismissal is keyed on a fingerprint of category, target and title. That survives a reworded title without swallowing a different device's version of the same issue. Dismissals expire, defaulting to 24 hours. "Handled" and "not now" look identical to the code, and an item that is genuinely resolved stops being generated anyway — so a permanent dismissal could only ever hide something still true. Expired rows are cleared on read, so a lapse needs no scheduled job, and the panel reports how many items are currently hidden rather than quietly shrinking. Verified against the fleet: snoozing took the list from 8 items to 7 with dismissedCount 1, and removing the dismissal restored it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
60d7d13bd3 |
feat(ui): filters on the clients, devices and events tables (v0.23.9-beta)
Closes #99, #100, #107, #108. Four reports with one root complaint: the tables sort but do not filter, which is fine at four devices and painful at forty. #100 / #99 — clients VLAN and signal-range filters, both server-side so counts and pagination stay correct across the whole dataset rather than the page in hand. Filter state lives in the URL alongside the existing wired/wireless choice, which makes a filtered view shareable and gives the AP Deployment Density chart somewhere to point. Clicking a density bubble now opens the clients list filtered to wireless clients in that signal zone (#99). The zone, not the 2 dB drawing bin — the bin is a rendering detail and the zone is the question being asked. Verified against the fleet: the four zones partition all 17 wireless clients exactly. VLAN options are built from VLANs actually present, so the list never offers a choice that returns nothing. Category filtering is deliberately absent. The effective category is a user override where set and otherwise a fingerprint computed in JS from vendor and hostname, so there is no column to filter on, and filtering after pagination would report wrong totals. Persisting the fingerprint would fix it and is noted in the route, along with the cost: a stored value goes stale whenever the rules change. #107 — devices Rack and location selectors, populated from what is actually recorded so neither offers an empty choice. Free-text search now also covers location, rack and slot, so "datacenter" or "rack-4" finds devices without touching a dropdown. A RACK column joins the table, sortable like the rest. #108 — events RouterOS reports topics as a comma-separated list, so the useful unit of filtering is the token rather than the string. A new endpoint splits and counts them across the whole table — not merely the loaded page — and the free-text topic box becomes a populated selector. Topic filtering also changed from ILIKE '%token%' to exact array membership. The badges present topics as discrete tags, so `error` must not quietly also match a hypothetical `no-error`. On this fleet the endpoint reports 537,346 `info`, 535,439 `system` and 535,362 `account` — which makes the reporter's aside about login noise dominating the events table concretely true, and worth its own issue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5071e1f512 |
chore(dev): seedable CAPsMAN fixture so managed-wireless paths can be tested (v0.23.8-beta)
Two bugs shipped in the last three releases because CAPsMAN code was written and
"verified" against a fleet that contains no CAPsMAN. Neither was reachable by unit
tests, because both lived in SQL: a join that could never match, and a WHERE clause
that filtered out every managed radio before the analysis ran. Both were found by
the reporter, in production, after release.
The fix is not another test of the pure functions — those were already right. It is
to stop the local environment from being unrepresentative.
scripts/seed-capsman-fixture.sql builds a deployment shaped like a real one:
- the controller is a ROUTER with no radios of its own, so anything keyed on
device_type = 'wireless_ap' misses it
- CAP interfaces carry NULL frequency, SSID and band, because the controller owns
the configuration — the exact condition that removed them from Channel Usage
- the controller mirrors every CAP interface locally, so a CAP's radio MAC
legitimately appears on two devices — the condition behind the misattribution
- channels exist only as "<freq>/<phy>/<control-positions>", never a plain number
- the radios are arranged to produce one partial overlap and one co-channel pair,
so interference classification has something real to get wrong
Running it against v0.23.7 confirms the fix shipped blind two commits ago actually
works: six managed radios now group under the controller, the controller appears in
the location selector, the standalone group holds only the genuinely standalone AP,
5680/ax/eCee/D resolves to 80 MHz, client counts come from the controller where the
CAP rows read zero, and the controller's mirror interfaces do not double-count.
Documented in docs/architecture.md, including the point that when a report arrives
with device output attached, that output is the most valuable thing in it and
belongs in the fixture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
4a00b675d9 |
fix(wireless): read CAPsMAN radio channels from the controller (v0.23.7-beta)
Follow-ups on #97 and #106, both reported against the releases they shipped in. #97 — managed radios were missing from Channel Usage entirely The query required wireless_interfaces.frequency > 0. A CAPsMAN-managed access point stores no frequency of its own: the controller owns the channel, and the AP's own rows carry nulls. Every managed radio was therefore filtered out before analysis ran, which is why the reporter saw all access points collapse into "Standalone" and a controller vanish from the selector — the group had no surviving radios to name it. The channel now comes from capsman_radios.current_channel where present, parsed from RouterOS's `<freq>/<phy>/<control-positions>` form. The control letters give a precise operating width — Ce is 40 MHz, Ceee is 80 — rather than the capability list a width field reports. Client counts prefer the controller's per-radio figure for the same reason. Verified only by unit test here, since we run no CAPsMAN. The local fleet still reports its two standalone radios unchanged. #106 — the setting had no way to change it maps_enabled shipped without a control, which made it a setting in name only. Added under Settings, with the reasoning stated: tiles and geocoding are outbound requests to a third party, and the address entered is sent to them. Also removed the "map display is off" placeholder. The reporter pointed out it can never appear in the environment it was written for — with maps disabled from the start no coordinates are ever geocoded, so the block was dead code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dcd89fe86b |
feat(ui): quieten the dashboard — mute checks, hide app noise, honest sparkline (v0.23.6-beta)
Closes #101, #103, #104, #106, #111. Partially addresses #96. A batch of signal-to-noise fixes reported together. Individually small; together they are the difference between a dashboard someone reads and one they learn to ignore. #101 — security heuristics can be muted A warning you disagree with and cannot dismiss discredits every other finding beside it. Cleartext API is the reported case: on a fleet reachable only across WireGuard, TLS adds nothing to the threat model, and the check is permanent noise. Missing input-chain firewall is the same on a switch behind a router — the message even says "confirm that is the case", while offering no way to record that you did. Checks can now be muted per device or fleet-wide. Muted findings are still listed, greyed and labelled, and excluded from the score — not hidden. Hiding them would make the posture unauditable, which is a worse failure than noise. #103 — the app's own requests are hidden from Recent activity Audit entries for writes made through the UI are noise from an operations point of view. Off by default now, with a toggle that names how many are hidden, remembered per browser since it is a viewing preference rather than fleet configuration. #104 — quick actions moved into the header strip Matching the Wireless page. "Things to handle" takes the full width it was already overflowing. #106 — map lookups can be turned off Geocoding hits nominatim.openstreetmap.org and the map pulls OSM tiles. On an isolated network those hang; everywhere else they disclose where your devices are to a third party, which the report understates. A maps_enabled setting stops both requests; stored coordinates are still shown as plain text so nothing is lost. #111 — the CPU sparkline says something It was drawn in one colour and auto-scaled to each device's own peak, so an idle device and a saturated one produced an identical picture. Now plotted against a fixed 0-100 scale, coloured by the band its peak falls into — a flat line low in the box is an idle device and looks like one. #96 — groundwork TX-retry figures come from CCQ, which only the legacy `wireless` driver reports, so the panel is permanently empty on a RouterOS 7 fleet. The wireless package each device runs is now recorded, and a capabilities endpoint answers the fleet-wide question. The panel is dropped and the density chart takes the full width when nothing can supply the data — and shown when nothing has been polled yet, rather than hiding a panel that may populate a minute later. Verified against the fleet: muting cleartext API moved a device's score from 87 to 90 with the finding still listed and marked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4cc8c84ea6 |
fix(wireless): model channel usage as spectrum, not channel numbers (v0.23.5-beta)
Closes #98, #97. The Channel Usage panel treated a radio as occupying a channel *number* and coloured a channel amber when two radios shared it. Both halves are wrong, and the result is not merely incomplete — it gives actively bad planning advice. Channels are not slots. In 2.4 GHz the centres are 5 MHz apart while a channel is 20 MHz wide, so an AP on channel 1 occupies about 2402-2422 MHz and pollutes channels 1 through 5. Showing 2 through 5 as free is why 1/6/11 exists as the only three-way non-overlapping plan, and a model built on channel indices cannot express that. Sharing a channel is also not the problem. Two radios on exactly the same channel hear each other and take turns; CSMA/CA does its job and the cost is airtime. Two radios partially overlapping cannot decode each other, transmit over one another, and the cost is corruption. Partial overlap has to rank above co-channel, and it was not represented at all. Occupancy is now modelled in megahertz and overlap classified by how two ranges relate. On the local fleet, a 40 MHz radio on channel 1 is correctly shown bleeding 5 MHz into two channel-6 radios — which are themselves correctly identified as co-channel with each other rather than as the worst problem in the band. The model lives in the backend (utils/rfSpectrum.ts) with 26 tests rather than in the browser, because it is correctness-critical and the frontend has no test runner. Tests pin the arithmetic that is easy to get wrong: 1/6/11 is clear, 1/3/5 is three partial overlaps, channels 4 and 8 abut exactly rather than overlapping, a 20 MHz radio nested inside an 80 MHz one is partial rather than co-channel, and a lone radio is clear rather than co-channel with itself. The panel now draws real spectrum to scale, stacking overlapping radios so each stays visible, with the clash list on hover naming the other radio and how many megahertz they share. Width is taken as the widest entry of a RouterOS capability list such as 20/40/80mhz, since that is the spectrum the radio may occupy and therefore what a planner must treat as unavailable. Per-location scoping (#97): analysis runs per CAPsMAN controller, with a selector, because two APs on channel 6 in different buildings are not a conflict. Radios with no controller are analysed together as standalone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e10f7ba568 |
docs: split reference material into docs/, keeping the README a shop window (v0.23.4-beta)
Documentation only — no functional change.
The README was carrying two jobs for two different readers. Its Reference section was
the single largest block at 154 lines, and nobody deciding whether to try the project
reads an InfluxDB environment variable table — while the people who need it were
scrolling past the pitch to reach it.
Split along that seam: anything you would read before installing stays in the README,
anything you would read after installing moves to docs/.
README.md 430 lines sell, prove, install, feature overview
docs/change-guard.md 193 safety system, settings, guard states
docs/architecture.md 116 data stores, polling model, module layout
docs/capsman.md 84 role model, radio matching, current limits
docs/alerting.md 81 rules, channels, webhooks, maintenance windows
docs/api.md 77 scoped tokens, preflight, scripting
docs/configuration.md 76 environment, secrets, rotation, TLS
docs/sso-oidc.md 71 identity provider setup and behaviour
docs/README.md 20 index
In-repo rather than a documentation site, deliberately. A change to Change Guard's
semantics can edit its documentation in the same commit as the code — which, given how
often that behaviour has been refined recently, is the property that actually matters.
A static site generator can point at docs/ later without moving a file.
The Change Guard pitch stays on the front page: the problem framing, the verified
recovery timeline and the quoted prediction warning are what make someone care, and a
reference page is the wrong home for them.
Three sections are new rather than relocated, written because the split exposed the gaps:
- architecture.md did not exist. It now records why there are three data stores, the
polling cadences, and which modules are pure functions over a snapshot and why.
- Scoped API tokens shipped without usage documentation. api.md shows preflighting a
change from a script and reading the guard block afterwards.
- change-guard.md records why ip-on-bridge-port is informational rather than critical
on RouterOS 7 — reasoning that previously existed only in a commit message.
Every internal anchor and cross-file link verified to resolve, including anchors into
other documents. No content dropped, checked by keyword comparison against the previous
single file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
2201759aff |
docs: restructure README around what the project actually does differently (v0.23.3-beta)
Documentation only — no functional change. The previous README opened with eighty lines of screenshots before saying what the software was, then listed twenty-five feature sections in no particular order. Change Guard — the one capability nothing else in this space has — sat fifth, under a heading that gave no hint of it. Reordered around the questions a new visitor actually asks: what is it → why is it different → show me → how do I run it → full features → reference "What makes it different" now leads with the problem rather than the feature: that RouterOS applies every command immediately, with no transaction and no cross-object validation, and that MikroTik's own documented advice for VLAN filtering is to have a serial console ready. Change Guard, lockout prediction and Config Health follow as answers to it, with the verified auto-revert timeline and a real prediction warning quoted rather than described. The twenty-five flat sections are grouped into seven, safety first. Reference material — environment variables, Change Guard settings, secret management, OIDC setup, tech stack, project layout — is consolidated at the end instead of being interleaved with features, and the deepest auto-revert mechanics move into a collapsible block: present for anyone relying on it, out of the way of a skim. Added a table of contents; the document was seven hundred lines with no navigation. Headings that anchors point at use "and" rather than an ampersand, because GitHub slugifies an entity into a double hyphen and six links would have broken silently. No content dropped: every feature, table and screenshot from the previous version is still present, verified by keyword and image-path comparison. 713 lines to 573, entirely from removing repetition. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9128573801 |
chore: stop tracking .vscode editor settings (v0.23.2-beta)
Personal editor preferences — font size and colour theme — were swept into the previous commit by git add -A. Committing them would override every other contributor's editor configuration. Removed from tracking and .vscode/ added to .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0258ee47dc |
fix(wireless): attribute CAP radios to the access point, not the controller (v0.23.1-beta)
Third round of #94 field testing. The placement bug survived v0.23.0 because the previous fix addressed the wrong mechanism. Cause A CAPsMAN controller mirrors every CAP interface locally, so a CAP's radio MAC legitimately appears on two devices: the CAP that owns the hardware, and the controller that mirrors it. The MAC index was single-valued and first-wins over an unordered UNION, so each radio landed on whichever row the database happened to return first. On the reporter's fleet six radios attached to a router with no radios at all, while their siblings attached correctly — the same access point appearing in two places, and liable to move between polls. v0.23.0 excluded the controller from *prefix* matches only. These are exact matches, so the exclusion never applied. Consecutive MACs on one physical AP resolving to different devices is what gave it away. Fix The index is multi-valued: a MAC maps to every device carrying it. A radio the controller reports as local:false lives on a CAP by definition, so the controller is rejected as a candidate at exact strength as well as prefix — an exact match against it only ever means the mirror. Attribution now runs through matchRadiosToDevices, which was previously tested but unused while the collector kept its own copy of the logic. With no radios left attributed to it, a controller that has none of its own stops appearing in the access point list, which was the reporter's follow-up point. SSID on provisioned interfaces A CAP holds no configuration, so its interface rows carry a generic name and a null SSID. The controller knows it, on its mirror of the same interface. Radios now record the provisioned SSID from there, and the table is driven by the controller's radio list rather than the CAPs' own rows — the controller is the only side that knows the SSID, channel and client count together. Band and frequency columns removed Both were empty while the channel column beside them read 5680/ax/eCee/D. The channel string already carries the band and the extension layout; parsing it properly is its own task, and half-parsing it produced empty columns next to populated ones. Requested by the reporter. 75 tests across the CAPsMAN and topology modules, including fixtures built from the reporter's MAC set: both radios of one AP resolve to that AP, order-independently, with a genuinely local radio still landing on the controller and an unknown CAP left unattributed rather than absorbed. Reproduced the mirror scenario against the local fleet before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
82efcaa371 |
fix(wireless,topology): CAPsMAN write guards, MAC neighbour resolution, unified panel (v0.23.0-beta)
Closes #95. Addresses the second round of #94 feedback. Includes a regression introduced in v0.22.0. Wireless write paths were not CAPsMAN-aware (regression) v0.22.0 widened getAP() and the bulk-SSID selector to include cap/controller roles so a controller — normally tagged a router — would stop being invisible to the wireless section. Those same helpers gate the write endpoints, so the change also allowed writes to devices whose radios a controller owns. Reads needed widening; writes needed a guard, and did not get one. Five paths now refuse or skip provisioned radios: bulk SSID deployment, single interface create, interface edit, interface delete, and guest network setup. Delete was the worst of them — RouterOS will not remove a provisioned interface, but the cached row was deleted anyway, so it vanished from the UI until the next poll brought it back. The check is shared: partitionRadios() splits physical radios into locally controllable and controller-owned, assertNotCapsmanManaged() refuses an edit to a provisioned interface and names the controller. Neighbours identified only by MAC (#95) Topology resolved a neighbour to a managed device by IP address. A trunk port has no IP, so LLDP across one reports a MAC and an identity but no address — and those links stayed unresolved, drawing an external node beside the very device it already had on the map. Two directly connected devices appeared four times. Resolution by MAC now runs first, before address matching, because a MAC is hardware identity and unique fleet-wide where an address is not. This is the same join the CAPsMAN radio matching uses. CAPsMAN panel, from the reporter's testing - Radios and provisioned interfaces were two tables describing the same things from different angles; they are now one, per interface, with the access point, SSID, band, frequency, channel, clients and status. - Provisioning rules sit inside the configuration they apply. Rules referencing a configuration we did not collect are listed separately rather than dropped, since that usually means a stale reference. - The Wireless header reported zero clients while the Clients page reported 32: it summed the CAP-local count, which is empty under central forwarding. It now takes the greater of that and the controller's per-radio counts, so a controller that is also a CAP is not double-counted. - A remote radio could be attributed to the controller by the five-octet prefix fallback when the two shared a MAC block. Prefix matches now refuse the controller for a non-local radio; exact matches are still honoured. Radios tab followed device_type device_type is user-supplied tagging, and a router with radios had no way to display them. The tab now follows the detected wifi_role, which is a fact about the hardware. 48 tests on the CAPsMAN module, 22 on topology, including a fixture built from the device output in #95. Verified against the fleet: topology output unchanged, so MAC resolution merged nothing it should not have; guards correctly permissive on a standalone AP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e15556b601 |
fix(wireless): CAPsMAN panel corrections from field testing (v0.22.1-beta)
All five points raised on #94 after the reporter tested v0.22.0 against a live CAPsMAN deployment. Two were regressions in that release. Operating channel instead of a capability list (mine) current_channel was stored from /interface/wifi/radio's current-channels, which is every channel the radio supports rather than the one it is using. Measured on a wAP ax: 535 characters on the 2.4 GHz radio and 3037 on the 5 GHz one — the "two kilobytes of text" reported. /interface/wifi/monitor gives the operating channel (5500/ax/Ceeeeeee/D), and carries state, peer counts and TX power too, all now shown on the radio chip. Client counts that were zero everywhere (mine) The panel read registered_clients from the CAP's own wireless_interfaces rows, which are empty when traffic is processed centrally by the controller. Monitor's registered-peers is not the fix either, and testing caught that before it shipped: clients register on the virtual AP carrying the SSID, not on the physical radio, so monitor on a physical radio reports zero on a busy access point. On the test AP, wifi1 and wifi2 both read 0 while wifi3-wifi6 held all ten clients. Counts now come from the registration table, with each entry attributed to its radio by following master-interface up to the interface owning a radio-mac. That field requires /interface/wifi/print detail — it is absent from the plain listing. Verified against real hardware: ten registrations, attributed 8 and 2 across the two radios, none dropped. "no network" on remote interfaces The Network column resolved from the AP's own bridge ports. A CAPsMAN-provisioned interface is not a local bridge port, so nothing matched and the UI asserted the interface had no network at all. It now falls back to the datapath — inline fields or a named/.id reference — and labels the source. Where the datapath names no bridge it says "via controller" rather than claiming no network. Status labels "Serving Clients" claimed something the underlying field does not say: running means the radio is up, not that clients are connected. Now "Running" and "Enabled, not running". Table title "SSIDs / Wireless Interfaces" conflated two different things; the table lists interfaces, virtual and physical. Now "Wireless Interfaces". Guest WiFi on CAPsMAN-provisioned radios Raised by the reporter as an aside, and it was a real hazard already shipping. setupGuestNetwork created virtual APs locally on whatever physical radios it found, with no awareness that a controller might own them — the SSID would be ignored or overwritten on the next provision while the UI reported success. Provisioned radios are now skipped with an explanation, and if every radio is provisioned the setup is refused, pointing at the controller instead. 37 unit tests on the CAPsMAN module. No regression on a fleet without a controller: local bridge-port resolution unchanged, roles still correct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
14bab24c7e |
feat(wireless): CAPsMAN awareness and read-only controller view (v0.22.0-beta)
Addresses #94, phases A and B. Write operations are deliberately out of scope. An access point provisioned by CAPsMAN holds none of its own wireless configuration, so /interface/wifi/print returns rows with the configuration.* and security.* fields absent. normalizeWifiInterface mapped nothing, stored nulls, and the UI rendered an AP with no SSID, security or band — indistinguishable from one that is simply broken. Phase A — stop showing blanks Devices are classified standalone / cap / controller / controller_cap from /interface/wifi/capsman and /interface/wifi/cap. Both toggles can be on: MikroTik supports a controller that also runs its own radios under itself, and the issue calls that case out, so it gets its own role. Provisioned interfaces are marked and labelled in the Radios tab with the controller's MAC and an explanation that the blank fields live on the controller. SSID, mode and channel are recovered from RouterOS's status line where present, so the AP is described rather than blank. Role is the signal, not missing data: an AP whose SSID is empty because the controller owns it looks identical to an unconfigured one, and only the toggle distinguishes them. A CAP can still hold locally-configured interfaces alongside provisioned ones, so per-interface state is checked rather than assuming the whole device. Phase B — read-only controller view Controllers now collect the radios they manage, named configurations, and provisioning rules, surfaced as a CAPsMAN panel on the Wireless page grouped by the access point each radio physically lives on. CAPs are matched to managed devices by radio MAC from the controller's own /interface/wifi/radio list, not by resolving addresses through neighbours as the issue proposed. A MAC is hardware identity and globally unique, so unlike an address it cannot mean two devices on two segments — the failure fixed in v0.20.1. A radio MAC often differs from the interface MAC in the final octet, so lookup falls back to a five-octet prefix while keeping the OUI exact. A CAP that is not a managed device is surfaced rather than hidden; that is usually the reason an AP appears to be missing from the fleet. The wireless section required device_type = 'wireless_ap' in five places, which hid a dedicated controller — normally classified as a router — from every wireless view. Collection was gated the same way. Both now include CAPsMAN roles, and the collector probes an unknown device once, records the role, and skips cheaply forever after. Not included Provisioning, config edits and interface enable/disable. A CAPsMAN configuration applies to every AP bound to it simultaneously, which is the fleet-wide version of the failure Change Guard exists to prevent. It needs the same prediction and revert treatment rather than being bolted on. Legacy /caps-man (pre-RouterOS 7) is a separate API tree and is not covered. Verified: API shapes probed against RouterOS 7.23 rather than taken from docs. 24 unit tests on the pure parsing and matching. No CAPsMAN hardware here, so the read path was exercised against a seeded controller with a local radio, a matched two-radio CAP and an unmanaged CAP; grouping, provisioning and SSID resolution all came out correct. Confirmed no regression on a fleet with no controller: roles detected correctly, no interface falsely flagged, wireless views unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
19617e8357 |
feat(alerts): ntfy.sh notification channel (v0.21.0-beta)
Closes #93. Routing push notifications through email ingestion was a workaround. ntfy is now a first-class channel alongside Slack, Discord, Telegram and email, using the same alert_channels row and the same dispatch switch. Built to cover the whole plausible configuration space rather than waiting on the reporter's answers, so no config migration is needed once they arrive: - server_url is operator-supplied and defaults to https://ntfy.sh, so a self-hosted instance works — the likely case for infrastructure alerting - both of ntfy's auth schemes: an access token (preferred; scopable and revocable) or username/password, with none required for an open topic - optional click_url deep-links each notification to the device it refers to Published as JSON to the server root rather than POST /{topic} with header metadata: the header form is ASCII-only and these titles carry per-event emoji. Severity maps onto ntfy's priority scale so the channel stays worth being woken by — 4 (breaks through do-not-disturb) for device_offline, log_error and CPU/memory pressure; 2 for recoveries and discoveries; 3 for everything else. The event type is attached as a tag so a client can be filtered to wake only for chosen events without needing a channel per event. Secrets follow the existing pattern: token and password are added to SENSITIVE_KEYS, so they are masked on read and the mask placeholder is restored to the stored value on write rather than overwriting it. The type column is a CHECK constraint rather than a lookup table, so widening it means dropping and recreating the constraint; done idempotently for existing installs. Note on trust: server_url lets an admin point the channel at an internal address. That is the same exposure the existing Slack and Discord webhook URLs already have, and only an admin can configure any of them. Restricting it would break the self-hosted case this feature is mostly for. Verified against the real ntfy.sh — a test alert arrived with the correct title, emoji, body, priority, tag and click URL; masking verified to hide the token on read while preserving it across a save that did not retype it. 13 unit tests cover priority mapping, both auth schemes, URL normalisation, and rejection of a missing topic or non-http server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b03ea87f11 |
fix(topology,ui): spurious cross-segment links, CPU rounding, serial column (v0.20.1-beta)
Closes #90, #91, #92. #90 — Spurious topology links Topology treated IP addresses and system identities as unique across the whole fleet. They are unique only within a broadcast domain, so an installation with physically separate segments that each reuse 10.2.0.0/24 saw devices in one segment joined to devices in another. The reporter pointed at shared-segment detection, and that is one of four sites with the same flaw — but not the first. Step 2 resolves a neighbour IP to a managed device from a global first-wins map, so a reused address silently resolves to whichever device was loaded first; the union-find in step 6 then merges on the resulting device id, a correct-looking key derived from bad data. Fixing only step 6 would have left the reported link in place. Addresses and identities are now scoped to the device that reported them wherever they name a neighbour, so they still collapse one device's duplicate sightings but can never join two devices. Resolution and LLDP-coverage suppression additionally require the address to be unambiguous fleet-wide. MAC addresses and resolved device ids are unaffected — they identify hardware, and genuinely shared segments still merge on them. Ambiguity detection deliberately ignores MAC divergence. A switch assigns a MAC per port, so a device with two uplinks is reported under several MACs; an earlier version of this fix treated that as a conflict and refused to resolve ordinary multi-port hardware. Caught on a live fleet, where three sightings of one CRS510 disagreed on MAC but agreed on identity. The signals used instead are an address claimed by two managed devices, or sightings that disagree about the neighbour's identity. The graph builder is extracted from the route into a pure module with 16 tests, including a fixture of the reporter's five-device, three-segment network. It had no test coverage before, which is why a flaw this systemic went unnoticed. Verified against a live fleet: graph output is identical before and after, and the ambiguity list is empty when addressing is clean. #91 — CPU load not rounded The devices table rendered the raw value from a 5-minute Influx mean, so a device idling between 1% and 2% displayed as 1.3333333333333333%. Rounded for display; the sparkline keeps the unrounded series so its curve stays smooth. #92 — Serial number in the device table Serials were already collected, stored, returned by the API and matched by global search, so this is presentation only: a sortable SERIAL column, and serial added to the table's filter predicate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
93ff1fbc53 |
feat(safety): Config Health audit, verified auto-revert, VLAN cache fixes (v0.20.0-beta)
Completes the lockout-prevention work: a standing audit for configurations
RouterOS accepts but that do not work, an empirical answer on /system history
undo, and corrections to the VLAN cache the editors are built on.
Config Health (new)
Eleven rules over a live snapshot, encoding MikroTik's documented Layer2
misconfigurations plus the latent lockout cases: IP or VLAN interface on a
bridge slave port, a bridged VLAN interface, a bond slave that is also a
bridge port, MTU above L2MTU, a multi-VLAN entry with untagged ports,
bridges competing for hardware offload, a port in no VLAN on a filtering
bridge, a PVID that frame-type=admit-only-vlan-tagged makes inert, a VLAN
interface whose VLAN the bridge is not tagged in, duplicate addresses, and
management that survives only on a dynamic entry. Each finding names the
consequence, the fix, how long it has been present, and the vendor doc.
Runs hourly, surfaces on the Security tab and the dashboard.
Calibrated against hardware: RouterOS 7 relocates an address off a bridge
slave port onto the bridge (actual-interface), so the stock defconf layout
works. Reporting that as critical would have fired on a default install; it
is informational unless RouterOS did not relocate it.
Change Guard: severed-connection handling
Proven on a CRS by deleting its own management address — the switch locked
itself out and self-restored unattended in ~3 minutes, twice.
That test exposed a real defect. withSafeApply treated any exception from
the apply step as "the change failed", disarming the guard and rethrowing.
But the most dangerous changes sever the connection carrying them, so the
call times out even though the change applied. The guard was stripping the
safety net off a device that had just gone silent, and reporting HTTP 500
"Read timeout" instead of telling the user the device was restoring itself.
Only luck saved it: the disarm needed the same dead connection.
Reachability now decides, not the exception. Unreachable leaves the revert
armed and returns auto_reverting; still reachable means a genuine failure,
disarmed so nothing reboots for nothing. committed_at is no longer stamped
on failed or reverted guards; resolved_at records completion instead.
/system history undo probe
probeUndoCapability tests the device rather than assuming. On 7.23.x over
the binary API: /system/history/print works, /system/history/undo does not
exist, /undo numbers= rejects the parameter, bare /undo works. Undo is
therefore untargeted, and cannot replace auto-revert since a dead-man switch
must fire after contact is lost. Binary restore stays the default.
VLAN cache correctness
- vlans keyed on (device_id, bridge, vlan_id); the old key collapsed the
same VID on two bridges
- vlan-ids ranges expanded instead of parseInt truncating "10-20" to 10
- both VLAN tables pruned, but only when the device returned an
authoritative list, so a failed read leaves the cache stale not empty
- bridge_vlan_entries.vlan_ids written and .tagged derived rather than
hardcoded false, which had left the port editor unable to see a trunk
- removeBridgeVlan matched by expanded VIDs; the ?vlan-ids= server filter
compares raw strings, so deleting one VID of a range silently no-opped
while reporting success. Multi-VLAN entries are refused in the route,
before the guard arms, since a mid-apply failure would cost a reboot
- interfaces.vlan_filtering promoted to a column
Membership is deliberately the STATIC tagged/untagged, never current-*.
These tables back the editors and updateBridgeVlan is remove-then-add, so
whatever is shown is written back; the effective view baked RouterOS's
dynamic PVID entries into saved configuration on every save. Analysis that
needs the effective view reads the device live. Extracted to pure functions
with tests, because that path was previously untestable inline.
Verified on four devices; 195 backend tests; all CI gates green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
d67698bac9 |
feat(safety): wire the lockout verdict into every guarded UI surface (v0.19.2-beta)
The verdict dialog only reached the VLANs tab, so a predicted lockout on the other guarded paths still surfaced as a generic "failed" message with the reasoning lost. Every surface that can push a guarded change now explains the refusal: - SwitchPortDiagram — port VLAN (PVID / tagged / untagged, the shape of the real incident), bond create and destroy, and the bridge vlan-filtering toggle. - RoutingTab — static route add and delete. - SystemConfigTab — IP address add and remove. - SecurityTab — /ip/service enable/disable. This route also has an older hard refusal that returns 409 with a reason but no verdict; that text is now shown instead of a generic error, where previously it was swallowed entirely. Supporting changes: confirm_lockout threaded through the affected API methods (DELETE routes send it as a request body), and deviceName threaded down to SwitchPortDiagram, RoutingTab and SecurityTab so the typed confirmation can ask for the actual device name. Verified the shipped bundle contains the dialog and the typed-confirm prompt. Full CI preflight green; 152 backend tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
118dd9ee8f |
feat(safety): explain predicted lockouts in the UI with typed confirmation (v0.19.1-beta)
The backend already refused a predicted lockout with 409 + a verdict, but the
frontend surfaced it as a generic "failed" message, so the reasoning never reached
the user.
- LockoutVerdictDialog renders the verdict properly: each violated invariant with
its plain-English consequence, and the resolved management path ("how the manager
reaches this device") so the user can verify the claim rather than take it on
trust. Critical verdicts require typing the device name to override; warnings
need only an acknowledgement, per the agreed friction levels.
- lockoutVerdictOf() extracts the 409 payload, mirroring the existing lockoutOf()
helper used by the firewall guard.
- VLAN add/update/delete now capture the verdict and re-send with confirm_lockout
when the user accepts, instead of showing an error banner.
Verified end to end on the production switch: changing the ingress port's PVID off
the management VLAN returns severity=critical with the ingress-port-pvid violation
and the hop chain (192.168.0.40/24 -> bridge -> sfp28-1), and the shipped bundle
contains the new dialog. Full CI preflight green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
518063e64f |
feat(safety): predict management lockouts before applying a change (v0.19.0-beta)
Phase 2a of the change-safety work. Change Guard could already rescue a device after a bad change; this predicts the break beforehand and refuses it. Rather than blocklisting "risky operations" — which is how deleting an *unused* bond and a routine VLAN edit both slipped through — the engine derives the invariants that must hold for the manager to keep reaching the device, simulates the planned change against LIVE device state, and reports any invariant that flips from satisfied to violated. Novel change shapes are covered for free, and each invariant explains itself, so the warning names the real mechanism. - pathModel.ts: captures a live snapshot over the API (no SSH) and resolves the management path — which address, interface, bridge, VLAN and physical ingress port the session depends on, each with the reason it is load-bearing. - invariants.ts: nine checks drawn from MikroTik's documented behaviour, notably that the bridge IS the CPU port and must be a member of the management VLAN. - analyzeChange.ts: simulates the change and diffs invariants, returning safe / warning / critical plus the path used to reach the verdict. - withGuardedChange now runs the analysis first and returns 409 with the verdict unless the caller passes confirm_lockout; analysis failures never block a change. - New GET /:id/management-path and POST /:id/preflight (analyse without applying). Three bugs were found by testing against the live switch rather than fixtures: - Invariants read vlan-filtering from the frozen path instead of the snapshot under evaluation, so "enable vlan-filtering" — the single most important case — was invisible. - VLAN membership is spread across BOTH a static row and a dynamic "added by pvid" row. Simulating only the first match hid the danger; membership now tracks static and effective separately, and a bridge left in the VLAN only by the dynamic row is reported as a warning rather than silently safe. - Requiring the ingress port to be UNTAGGED in the management VLAN flagged a healthy tagged trunk. Because a pre-existing violation is (correctly) not reported as new, that false positive silently suppressed real detections. Verdicts now also list pre-existing violations so this can't hide again. Verified on the production 100G switch: changing the ingress port's PVID off the management VLAN is refused with 409 and the switch is left untouched (pvid still 1); removing the bridge from VLAN 1 is correctly reported as a warning because a dynamic PVID row would still cover it; unrelated VLAN edits stay safe. 29 changeGuard tests, 152 backend tests, full CI preflight green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
78e1c114e5 |
feat(safety): extend Change Guard to VLAN, IP and route changes (v0.18.2-beta)
Change Guard shipped covering only 4 routes (service toggle, bond create/delete, bridge vlan-filtering). The most recent real incident — a VLAN change that removed a switch from management — went through PUT /:id/ports/:name/vlan, which was NOT guarded. Neither were the bridge-VLAN CRUD routes, IP address changes, or route changes, all of which can sever the manager's own path. Guarded routes go from 4 to 12, adding: - port.vlan PUT /:id/ports/:name/vlan (PVID + tagged/untagged membership) - vlan.add POST /:id/vlans - vlan.update PUT /:id/vlans/:vlanDbId - vlan.delete DELETE /:id/vlans/:vlanDbId - ip.add POST /:id/ip-addresses - ip.remove DELETE /:id/ip-addresses/:addrId - route.add POST /:id/routing - route.remove DELETE /:id/routing/:routeId Each now takes a device-side restore point and arms a self-restore before applying, disarming only once the device is proven still reachable. Two correctness details while converting: - vlan.delete removes the local DB row in afterConfirmed, so a change that auto-reverts no longer leaves the UI claiming a VLAN was deleted when the device restored it. - The VLAN CRUD routes previously returned a bare array, which would have been spread into the guard response object. They now return a message; the frontend ignores these bodies and re-queries, so no client change is needed. Verified live against the production 100G switch: a guarded VLAN add and delete both returned protected/confirmed, were recorded in device_change_guards, and the switch was left in its original state. 133 backend tests pass; full CI preflight green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
d44a0ea8a7 |
fix(deps): patch socket.io-parser advisory; add local CI preflight gate
Security Audit failed on a newly-published advisory, GHSA-2m8v-j782-fhvr (socket.io-parser "Zero-attachment Memory Exhaustion", high). It affected both projects transitively via socket.io / socket.io-client. Patched to 4.2.7 in both lockfiles; no direct dependency versions changed. Also adds the gate that should have caught the previous two CI failures before they reached GitHub: - scripts/ci-preflight.sh runs every gate from .github/workflows/ci.yml (frontend and backend lint/type-check/build, plus both prod-dependency audits with the same audit-ci flags and allowlist) and additionally the backend Jest suite, which CI does not currently run. It runs all gates rather than failing fast, so one invocation surfaces every problem. ~35s. - scripts/claude-push-gate.sh wires that into a Claude Code PreToolUse hook so a `git push` is refused while any gate fails, with the failure detail returned inline. Non-push commands and --dry-run pass straight through. Verified: with the fix reverted, the gate correctly denied the push and reported "backend: lint FAIL"; with it restored, all 9 gates pass and both audit-ci invocations exit 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
23a45703a5 |
fix(ci): attach cause when rethrowing bridge-restore failure (v0.18.1-beta)
The backend Lint job failed on the new createBond error path: ESLint's
preserve-caught-error rule (in effect since the eslint 10.7 bump) requires a
rethrown error to carry the original. Pass `{ cause: err }`, which also makes the
underlying RouterOS failure visible in the error chain instead of only inside the
message string.
No behavior change beyond the richer error. Verified with the exact CI commands:
`npm run lint` and `npm run build` both exit 0, 133 backend tests pass, and the
redeployed build still guards correctly (guarded change commits, disabling the
management service is refused with 409).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
3a18cb1d91 |
feat(safety): Change Guard — device-side auto-revert for risky config changes (v0.18.0-beta)
A user change can sever the manager's own path to a device with no warning and no
way back. Most recently, deleting an *unused* bond on a switch took the whole
switch offline. This adds a safety net, and fixes the bug that caused that
specific incident.
Root cause of the bond incident (DeviceCollector.deleteBond):
- Slaves were re-added to the bridge ONLY when the bond itself was a bridge
member. For an unbridged bond the restore loop never ran, so the member ports
were released from the bond and left in no bridge at all — the ports stopped
forwarding and the switch dropped off the network.
- The restore in both deleteBond and createBond was wrapped in `.catch(() => {})`,
so a failed restore was swallowed and the route still returned 200 OK.
- Restores also dropped PVID, silently returning tagged ports to VLAN 1.
Now: port settings (PVID, frame-types, ingress-filtering, edge, horizon) are
captured and carried over, and restore failures are reported instead of hidden.
Change Guard (services/changeGuard/ChangeGuard.ts):
Before a risky change the device saves a restore point and arms a scheduler that
restores it after N seconds. The change is applied, then reachability is proven
on a NEW connection; success disarms the scheduler, failure leaves the device to
restore itself and come back. Because the restore point is created before the
scheduler, a binary restore rolls back to a config containing neither the
scheduler nor the change — self-cleaning, with no revert loop.
- Works entirely over the RouterOS API. This matters: every recovery artifact
today requires SSH, so devices without SSH credentials had no rollback target
at all (cf. #86).
- Modes: `binary` (exact restore incl. deleted interfaces, reboots) or `script`
(/import, no reboot but additive). Configurable via change_guard_* settings.
- Per-device Redis lock, `device_change_guards` audit table, and startup
reconciliation for guards left pending by a manager restart.
- Degrades gracefully: if protection can't be armed the change still runs and the
response says it was unprotected.
Wired into bond create/delete, bridge vlan-filtering, and /ip/service. Disabling
the service the manager is connected through is now refused outright (409) — the
mgmt-service signal already existed but was confined to the read-only posture
endpoint.
Also: replaces the bare confirm() on "Destroy Bond" with a ChangeGuardDialog that
explains the risk and the protection, and reports the outcome (including "the
device is restoring itself"). Fixes getAppSettings omitting
config_snapshot_enabled/interval, which made the v0.17.4 snapshot toggle a no-op.
Verified on real hardware (hAP-class switch): capability probe confirmed
/system/backup/save and scheduler add/remove work over the API; a guarded change
armed, applied, verified and committed leaving zero schedulers and zero files on
the device; disabling the api service was refused with 409. 133 backend tests
pass, including 7 guard state-machine tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
5895e905b7 |
fix(netflow): bound template cache, throttle UDP ingress, canonical IPv6 (v0.17.7-beta)
Follow-up to the decoder DoS fix (
|
||
|
|
7e11115a11 |
fix(netflow): reject zero-length template fields to stop decoder DoS (v0.17.6-beta)
A NetFlow v9/IPFIX template declaring a field of length 0 produced a template with minLength 0. parseDataSet's loop condition (off + minLength <= setEnd) then stayed true while the field pass advanced `off` by 0 — an infinite loop. Impact: the collector socket is UDP and unauthenticated, so any host able to reach the NetFlow port could hang the entire backend with a single 36-byte packet — Node is single-threaded, so the event loop never yields again and the API, poller, sockets and health checks all stop. Only reachable while NetFlow is enabled (off by default). Two layers of defense in decoder.ts: - parseTemplates rejects any field with length 0 (meaningless in v9/IPFIX) and refuses to cache a template whose computed minLength is 0. - parseDataSet asserts forward progress: if a full field pass consumes no bytes, break instead of spinning. Guards any future zero-width path. Adds 5 regression tests covering the hang case, a zero-length field among valid ones, a variable-length field whose length byte is 0, absurd/truncated field lengths, and that valid v9/IPFIX traffic still decodes afterwards. Verified: the exact packet that previously hung the process now returns immediately with no template cached; 20 attack packets fired at the live collector left the backend healthy and serving, and real exporter flows kept decoding (102 -> 152 flows across the test). 119 backend tests pass. Found via an adversarial review; the report identified the infinite loop but attributed it to the variable-length branch, which provably terminates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |