6c3d2aaa35999703798b95a589a484e5c181f081
53
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
df8f6dca44 |
feat(firmware): optional parallel upgrades within a wave (v0.24.3-beta)
Closes #135. The reporter's numbers: CRS switches take ~5 minutes each to reboot, so ten devices is an hour of mostly waiting. Devices in a wave can now upgrade concurrently, up to a per-rollout limit. Waves themselves stay strictly sequential — that is the decision point, and what makes wave 1 a canary. The default is 1, which is the behaviour every existing rollout was built around, and existing rows backfill to it. CommandRunner already runs a wave with Promise.all, but an upgrade is not a bulk command: it holds an API connection for minutes across a download, a reboot and a verification, and connection pressure is exactly where this system has been shown to break. So concurrency is bounded rather than unlimited, via mapWithConcurrency, capped at 20 and clamped server-side. The trade is stated rather than hidden. Halting is now evaluated at the end of a wave instead of the instant a device fails, so a wave of five can put a bad build on five devices before stopping. At the default of 1 those are the same moment. The UI says so plainly whenever the setting is above 1. An upstream warning covers the risk Change Guard cannot: several devices rebooting together is fine for independent routers and dangerous for a switch the others are reached through, and a device unreachable because its uplink rebooted has no config change to revert. The first implementation of that warning was wrong in an instructive way. It flagged any device another selected device linked to — and on the real four-device fleet it flagged all four, because discovered links are bidirectional and a trunk port often resolves to several neighbours at once. A warning that fires on everything is worse than none: it teaches people to dismiss it. It now requires the link to be the reporting device's STP root port *and* that port to resolve to exactly one neighbour, so it stays quiet when it cannot tell — which, on this fleet's ambiguous topology, it correctly does. Verified on hardware with devices already up to date, so the wave machinery ran end to end without flashing anything: - sequential: 04:20:21→:26, then :26→:31. No overlap, 12s. - concurrency 2: both started within 10ms of each other, 7s. - concurrency 5 across two waves: wave 2 started 3ms *after* wave 1 finished, never during it. Clamping checked at the API: 999 stored as 20, 0 and "abc" as 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
719563dd22 |
feat(backups): filter, preview, compare and bulk delete (v0.24.2-beta)
Closes #134. Backups are `/export compact` output — plain RouterOS script, not the binary `.backup` blob — which is what makes the reporter's "it's just a text, after all" correct and most of this cheap. - Filters: device, type, date range and free text over device name, filename and notes. Dates are inclusive at both ends, because a picker showing "to: 3 Sep" that stops at that day's first instant is a trap. - Preview in place, on row click. No round trip through the downloads folder to see what a backup contains. - Compare any two, reusing the lineDiff that Config History already uses rather than adding a second diff implementation. - Bulk delete, with selection and a confirmation that names what goes. Two deliberate choices. Bulk delete had to respect an existing invariant: a backup and its config snapshot are one artifact, joined by ON DELETE CASCADE so they cannot drift apart. Removing fifty in one click can therefore destroy restore points nobody looked at. The confirmation is built from a server-side preview that reports the backups per device *and* how many config snapshots will go with them, and it is capped at 200 per call. The action is also site-scoped, so it cannot reach into another site's history. The Filename column is gone from the table but the filename is not gone: it is the natural handle for a file — device and timestamp, the thing you would quote to a colleague — so it moved to the preview and diff headers where you are actually looking at that file, rather than being dropped. Diff direction is extracted and tested. Selection order in a table is whatever the user happened to click and carries no meaning, so the pair is ordered oldest-first; rendered the other way round every addition reads as a deletion, and nothing about the output looks wrong while it does. Incidentally fixed: 'pre-upgrade' backups were labelled "Manual" in the table because the badge only knew three types. Type wording now has one home. The list query's placeholder counter is replaced by a bind() helper deriving from params.length. The counter's final increment was dead, and whoever added the next filter would have inherited a stale index. Verified against 110 real backups: every filter and combination returns the expected counts and partitions cleanly, preview and diff read real .rsc with file_path withheld, and bulk delete removed three throwaway backups plus their files and left the other 110 untouched. Guards checked: empty, missing, non-numeric and injected ids, unknown ids, and the 200 cap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
63b5677522 |
fix(firmware,ssh,logs): five reported bugs (v0.24.1-beta)
Closes #136. Also fixes #133, #137, #138, #139 (GitHub closes only the first). None of these came from multi-site; two came from me. #136 — waves failed on the second device. A regression from v0.23.31, the firmware rewrite that replaced fire-and-forget `install` with `download` plus polling. `/system/package/update/download` blocks for a minute or more, and the client's read timeout is 30 seconds. The timeout path was the real damage: it nulled the pending read but left the socket open, so when the reply finally arrived processBuffer() queued it and the *next* command took it as its own answer. Every later call on that connection returned the previous one's data, which is why the status polls could never see "Downloaded". - a timed-out read now abandons the connection instead of silently desynchronising it, so the failure is loud rather than wrong - long-running commands can state their own budget; check-for-updates, download and /log/print now do - a download whose command times out is re-checked on a fresh connection rather than assumed failed, because the image may well have landed #133 — the SSH terminal could not use a deployed key. It read only ssh_password_encrypted and refused to connect without one, so retiring the password after deploying a key -- the workflow the feature exists for -- broke the console. It now uses the same resolver as every other SSH consumer. preferredAuth(), written to make exactly this decision, had never run outside its own tests, and disagreed with the resolver that did: it accepted a 'deployed' key where the live path required 'verified'. Unified on the stricter rule and wired in. #139 — the rollout was written before start() was attempted, so a rejected start left the rows behind: "already running", press again, another rollout banked each time. Devices already in an unfinished rollout are now refused by name, before anything is persisted. #138 — cancel worked but the API's explanation that the in-flight device is never interrupted was discarded, so the button looked dead. It is shown. #137 — log overfetching. /log/print returns the whole ring buffer, and the count was incremented per attempted insert rather than per accepted row, so a poll that stored nothing still reported "Collected 1000 new log entries" after a thousand round trips. Inserts are batched and counted by RETURNING, and the id watermark is the maximum over recent rows rather than the last one inserted, which could move backwards and make the whole buffer look new. One self-inflicted wound worth recording. The first cut of the desync guard discarded sentences whenever `connected` was false -- but `connected` is only set *after* login succeeds, so it threw away every login reply and took the whole fleet offline until it was caught. "Not yet connected" and "abandoned" are different states and now have different flags. Every existing test pre-set connected = true, so none of them could see it; the test added for it fails against the bad version. Verified on hardware: devices back online, check-update 5.2s (was a 30s failure), full sync completes, and log collection now reports 298 / 22 / 12 / 12 across successive polls -- real decreasing counts rather than a constant 1000 -- with no failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7b994ad935 |
feat(sites): one install, many networks (v0.24.0-beta)
Closes #130. Multi-site, not multi-tenancy: a site is a named collection of devices whose clients, events, topology and dashboards are viewed separately. Sites separate what you see, not who you are — the admin/manage/read-only roles are unchanged and every user sees every site. Per-site permissions would have multiplied the work for a need nobody stated. Two measurements decided the design. Every device-derived table hangs off devices(id) — 25 of 50 tables, 35 foreign keys — so a single site_id column on devices scopes clients, events, topology, backups and the rest transitively. No other table needed one. The frontend has 162 useQuery call sites across 57 files. Threading a site through each would be a large change that leaves a permanent trap: every future query another chance to forget. The site is injected in the existing axios interceptor instead, beside the auth token, so a new query is scoped by default. Only 23 collection routes needed filtering; the 131 per-device routes are already scoped by their device. Existing installs are untouched. Migration creates one "Default Site", adopts every device into it, and a single-site install never meets the concept. An absent X-Site-Id header means unscoped, so existing API tokens and scripts behave exactly as before. Polling stays fleet-wide. Sites are a view over data already gathered, not a change to how it is gathered — a device does not stop being monitored because you are looking elsewhere. Rogue-AP detection scopes the scans but keeps the "these are ours" BSSID set fleet-wide, or your own AP in another site reads as an evil twin. Bulk actions do obey the selector: an operation that silently spanned two customers is the blast radius the wave guards exist to contain. Four rounds of defects came out of testing the UI rather than the API, and the first is the one worth recording. Switching sites called queryClient.clear(), which empties the cache and reviews as correct. It isn't: clear() destroys each query but a *mounted* observer keeps its own reference and last result, so every rendered page went on showing the previous site's data. The QueryClient is now keyed on the site, which remounts the tree against an empty cache. Three tests pin the old behaviour down so the shortcut cannot return. The others: an unscoped fallback path behind the Connected Clients chart; Config Health, TX retries and the activity feed reading fleet-wide; and site_id added to the device list query but none of the other four that build a device payload, so the Site field could not read its own value and appeared to reject every change while the writes were landing. That last one is now structural — DEVICE_BASE_COLUMNS is shared by all five, with a test that fails when a device payload is hand-rolled, and which found two further instances while being written. Frontend tests were never run by CI despite vitest being configured and six suites existing; they now run in both the workflow and ci-preflight. Verified against a live four-device fleet: the migration left every figure identical, an empty site returns zero from every read endpoint, moving a device carries its history with it, and deleting a site with devices is refused. Minor bump rather than patch: sites change the shape of the product rather than adding to it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
28701cfc67 |
fix(security): harden 2FA setup, OIDC default role, and rate-limit fallback (v0.17.3-beta)
Address the actionable findings from an adversarial review: - 2FA enrollment now requires the current password (/totp/setup), so a hijacked session or XSS can't enable TOTP and lock the real user out. SSO-only accounts (no local password) are told to set one first. Frontend collects the password before starting setup. - OIDC config rejects default_role='admin': auto-provisioning unmatched users straight to admin is almost never intended — admin must come from an explicit group → role mapping. - Rate limiter no longer fails fully open on a Redis outage. Redis calls are bounded by an 800ms timeout (the client uses maxRetriesPerRequest:null, so commands would otherwise hang, not throw), and on failure a per-process in-memory sliding-window fallback keeps /login and other limited endpoints throttled. Verified with Redis stopped: attempts throttle (429) instead of hanging or passing unlimited. Findings intentionally not actioned: the "API-token auth is fire-and-forget" report was a false positive (Express awaits next(), which is called inside the promise), and the webhook DB-pool and JWT-revocation items are either overstated or a separate architectural decision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
c0f230bdf2 |
feat(auth): OIDC / SSO single sign-on (v0.17.0-beta)
Add OpenID Connect single sign-on so the platform can authenticate against any standards-compliant provider (Entra ID, Okta, Google Workspace, Keycloak, Authentik, …) alongside local accounts. Resolves the feature request in #73. Flow (backend-driven Authorization Code + PKCE): - GET /api/auth/oidc/login builds the authorize URL and stores the PKCE/nonce transaction in Redis (single-use, 10m TTL); the callback validates the ID token via openid-client (JWKS signature, iss/aud/exp/nonce), resolves the identity to a local user, and mints the platform's normal session JWT — handed to the SPA via URL fragment. No sessions/cookies introduced. Identity mapping (users table gains email/oidc_subject/oidc_issuer/ auth_provider; password_hash now nullable; partial unique indexes): - First sign-in auto-provisions a user, or links to an existing local account when the IdP email is verified and matches. - Roles derive from a configurable IdP group -> role map with a default fallback; existing users are never auto-demoted on a groupless login. Configuration (admin-only, no env/redeploy needed): - Stored as an app_settings row with the client secret encrypted at rest (utils/crypto); masked in responses and excluded from GET /api/settings. - New UI: Settings -> SSO / OIDC panel (issuer/client, claim + group->role mapping, provisioning toggles, discovery test, copyable redirect URI), a login-page SSO button, and an SSO badge in Users & Roles. Security: local break-glass login preserved; PKCE + single-use state; email linking requires email_verified; same-site-only post-login redirect. Adds openid-client@^5 (CommonJS). 17 new unit tests (claim/role mapping, username derivation, config secret round-trip/masking); 114 backend tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
27f155f778 |
feat(firmware): in-app MikroTik release notes + posture findings for pending updates (v0.16.6-beta)
- New GET /api/firmware/changelog/:version proxies MikroTik's official per-version release notes (download.mikrotik.com/routeros/<ver>/CHANGELOG) with strict version validation and process-lifetime caching - ChangelogModal: "What's new in X.Y.Z" on the device Config tab's update box and on the Firmware page's latest-version cells opens the changelog in-app, with an external mikrotik.com link and graceful fallback when the CDN is unreachable - Security posture: a pending RouterOS update is now a medium-severity finding and a pending RouterBOOT upgrade a low-severity one; titles are version-free so Common Findings aggregates the fleet into one row - chore(deps): npm audit fix bumps morgan (GHSA-4vj7-5mj6-jm8m); both workspaces audit clean Validation: backend tsc/lint/92 tests, frontend tsc/lint/build/11 tests, endpoint tested (valid/invalid/unknown versions), browser-verified on Firmware page, device Config tab, Security Center, and device Security tab; security review of the diff found no findings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9dfb8a5333 |
feat: Guest WiFi, network intelligence, firmware orchestration, platform automation, api-ssl (v0.16.3-beta)
Guest WiFi (Hotspot): one-click captive-portal wizard that creates the guest SSID (virtual AP per radio), VLAN/bridge segregation + L3 VLAN interface, pool, DHCP, portal, rate-limited guest profile, and masquerade; vouchers with limits + printable sheets; live guest sessions; walled garden. Network intelligence: per-client device fingerprinting (vendor/hostname → category) with a persisted per-client override on the detail page; rogue/neighbor AP detection from stored AP scans (evil-twin flagging); Mist-style anomaly insights (client-count/CPU vs same-hour 14-day baseline + error bursts) in the Operations engine. Firmware orchestration: staged wave-based RouterOS rollouts (canary first) with pre-upgrade backup, reboot-ride-out, version verification, halt-on-failure, and scheduling; live per-device progress + cancel. Platform & automation: scoped API tokens (mtm_, read/write, hashed at rest, Bearer auth in requireAuth); outbound webhooks (12 events, HMAC-signed) fired from the alert pipeline + firmware orchestrator; scheduled email reports. api-ssl: RouterOSClient negotiates TLS on port 8729 and accepts RouterOS's default self-signed certs (rejectUnauthorized:false), so SSL-secured API devices connect without manual cert trust. Fixes #57. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
6cfca387d8 |
feat(ops): operations insights engine + working quick actions; fix global search (v0.16.2-beta)
Global search: - Clicking/Enter on a client result now opens that client's detail page (/clients/<mac>) instead of dumping to the clients list. Operations dashboard (new backend route /api/operations): - GET /insights: server-side attention engine (offline, updates, CPU/memory pressure, missing backups, recent outages, weak WiFi clients, co-channel overlap, high TX retries, error events) + per-device capacity + merged activity feed (config changes, audit actions, alerts; login noise filtered). - POST /backup-all and /sync-all: fleet-wide actions over online devices. - Quick Actions now execute: Run discovery, Backup all, Sync config, and Open terminal (device picker -> live SSH TerminalModal); inline status. - New cards: Capacity & health meters, Security posture rollup (reuses per-device posture, cached), Recent activity feed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
4e6c9f481e |
feat(wireless+ports): RF Health panels + physical per-port clients (v0.16.1-beta)
Wireless RF Health (fleet Wireless page + per-AP Radios tab):
- Channel usage map across 2.4/5/6 GHz with co-channel overlap; operating
channel resolved live from /interface/wifi/monitor on the wifi package
(auto-channel radios report no frequency in /print).
- AP deployment density: client RSSI distribution on the -90..-30 dBm scale
with a coverage-gap warning.
- AP radio TX retries histogram (0%->35%+) with band selector, derived from
per-client CCQ (new wireless_radio_quality InfluxDB series).
- WiFi connectivity success funnel (Association/Auth/DHCP) derived from logs.
Per-port connected clients (Ports tab):
- New card lists clients physically connected to a selected port (host/MAC/
IP/VLAN/connection time, linked to client pages).
- Distinguishes physical clients from the raw MAC table: uplink/trunk ports
are auto-detected (topology neighbor, multi-VLAN MAC spread, or high MAC
count) and show an uplink explainer + "view MAC table" disclosure instead
of every reachable MAC.
- Added clients.first_seen ("connected since"); set once, preserved across polls.
|
||
|
|
b1dd62bd1f |
feat(traffic): protocol breakdown layout + inbound packets chart + flow source health (v0.15.0-beta)
- Traffic by Application: donut + legend side-by-side, larger donut with center total label, percentage in tooltip; card now sizes to its content - New Inbound Packets area chart below the throughput chart (packets field now summed per direction in /traffic/timeseries) - New Flow Sources & Data Health card: collector listening state, packets received, flows attributed, attribution rate, per-exporter flow/packet counts and last-seen; low-attribution hint for NAT-in-path exporters - /traffic/apps now returns per-app packets alongside bytes - Restructured bottom row: Top Talkers (left) | Application + Data Health stacked (right) for alignment - Tightened Top Talkers row spacing Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
d79eb13119 |
feat(ui): theme-aware logo, dashboard bar graph, server-side client sorting (v0.14.1-beta)
UI/UX refinements on top of v0.14.0-beta, all validated on the test server: - Logo: new theme-aware compass iconmark (light/dark SVGs swapped via the dark class), sized to 45px; favicon updated to match. Wordmark casing corrected to "MikroTik Manager" (font size unchanged). - Navigation icons enlarged 15 -> 17px. - Dashboard: "Connected clients" line chart converted to a bar chart with a category axis + light downsampling so bars stay readably thick across ranges. - Clients: sorting is now server-side across the whole dataset (was sorting only the current page, so Type/others appeared to do nothing on a uniform page); table uses a fixed column layout + keepPreviousData so columns no longer jump or blank out when sorting/paging. Backend clients route gains whitelisted sort/dir params (safe numeric IP sort, deterministic tiebreaker). - Connections tab: surfaces the live /ip/firewall/connection/tracking state (on/auto/off pill) and clearer empty-state copy explaining auto-mode. Lint/typecheck/build/jest green in both workspaces; npm audit --audit-level=high clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
ee9c9ff9f7 |
feat(security): Security Center — firewall, NAT, bandwidth, posture, connections (v0.14.0-beta)
A dedicated top-level Security section plus a Meraki/UniFi-grade firewall built
on the full RouterOS feature set.
Security Center (new /security page)
- Fleet hardening scores, per-device posture list, and a "Common Findings"
rollup aggregating identical issues across the fleet
- "Manage" deep-links into the device's Security tab (?tab= support added)
Firewall & NAT (per device + in Security Center)
- Friendly rule builder: Allow/Drop/Reject chips, Any/Address/Address-List
pickers, port presets, connection-state chips, logging, live plain-English
preview
- Address lists as reusable objects, referenced from rules
- Rule reordering (/ip/firewall/{filter,nat}/move); per-rule hit counters
(print stats) with dead-rule highlighting + reset
- NAT wizards: Port Forward / Masquerade / 1:1 NAT + Custom
- Safe-apply lockout guard: backend 409 on an unscoped input drop/reject that
would sever management; UI confirms before forcing (detectLockoutRisk, tested)
Access & security
- Security posture audit: graduated, de-duplicated scoring that never proposes
disabling the management service the platform connects through (no self-
lockout); switch-aware input-firewall severity; service hardening table
- Bandwidth: simple-queue CRUD + one-click "Limit this client" on client detail
- Active connections viewer
These L3 features are available on all managed RouterOS devices (routers,
switches, APs), not gated by device type.
Backend: DeviceCollector address-list/move/stats/connections/queues/service
methods; device routes for all of the above; firewallSafety + firewallSummary
utils with unit tests (19 new).
Security: bump nodemailer 8 -> 9.0.1 to clear GHSA-p6gq-j5cr-w38f (our
createTransport/sendMail usage is unaffected by the advisory's raw-option path).
Verified live against RouterOS devices; lint/typecheck/build/jest green in both
workspaces; npm audit --audit-level=high clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
b841eb6e7b |
feat: NetFlow/IPFIX traffic analytics — per-client usage, app breakdown, built-in collector (v0.13.0-beta)
Adds Meraki-style traffic analytics powered by MikroTik Traffic Flow: Backend - Built-in NetFlow v9 + IPFIX collector (UDP 2055) with hand-rolled decoder, template caching, enterprise-field and IPv6 support - Flow attribution: client IP → MAC via the clients table, upload/download direction, NAT-duplicate records dropped - App classification by protocol/port (~25 categories: HTTPS, QUIC, DNS, SSH, Email, WireGuard, ...) - Deduplication: per-client best-exporter-per-window so flows crossing two managed routers are never double-counted - NAT-tolerant ingest: flows from sources matching no managed device (NAT / Tailscale subnet routers in path) are accepted as per-source unidentified exporters instead of silently dropped (netflow_accept_unknown setting) - Storage: InfluxDB client_traffic time-series (bounded cardinality via top-N fold) + Postgres client_traffic_daily rollups; daily retention purge - One-toggle device config push via RouterOS API (/ip traffic-flow + target) - New /api/traffic/* analytics endpoints + /api/network-services/netflow - docker-compose: NETFLOW_PORT (default 2055/udp) published on the backend Frontend - New Traffic page: fleet up/down chart, top talkers with client click-through, app-category donut, 1h–30d ranges - New NetFlow config page: collector settings, live ingest stats, per-device export toggles with health badges, NAT-source banner - Client detail: App Traffic card (auto-hides without data) - Clients page: sortable "Data (today)" column - Settings: NetFlow retention controls Includes 17 unit tests (decoder fixtures, dedup, attribution, classifier); verified end-to-end against live RouterOS devices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
367034c2d6 |
feat: config history (diff/rollback) + friendly backup scheduling (v0.12.3-beta)
Configuration History: - Per-device snapshots from the full RouterOS /export (.rsc) — config-only, so counters/operational state no longer cause phantom changes; deduped by content hash - New Config History tab: snapshot timeline, readable line diff, on-demand Capture (with honest 'no changes' feedback), one-click rollback via the linked .rsc backup - Snapshot and its backup are one artifact: badged 'Config Snapshot' on the Backups page, FK ON DELETE CASCADE keeps them in lockstep; delete is gated by a warning modal - New config_drift alert event (off by default); periodic snapshot poll with configurable cadence/retention (config_snapshot_interval_min/_retention) - New endpoints under /api/config-history (list/get/diff/capture/rollback/delete) Scheduled Backups: - Replaced the raw cron textbox in Settings with Frequency (Daily/Weekly/Monthly) + time + day pickers; cron is generated/parsed behind the scenes - Fixed cronMatchesNow to evaluate day-of-month/month/day-of-week (it previously only checked minute/hour, so weekly/monthly schedules ran every day) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
b1e4944823 |
feat: version update check — notify when newer version is on GitHub (v0.11.8 Beta)
Backend: GET /api/system/version-check fetches frontend/package.json from the main branch on GitHub, caches result in app_settings for 24 hours, and compares semver against the running version. Fails silently (no banner) for air-gapped or firewalled deployments. Frontend: dismissible blue update banner in SecurityBanners (rendered between TopBar and main content) — only visible when a newer version is detected. Shows target version and docker compose update command. |
||
|
|
8d24b0b998 |
feat: bandwidth test auto-manages server on target managed device
Backend: accept target_device_id — connect to target, enable btest server, run test from source using target credentials, disable server in finally (even on failure). Manual IP mode preserved as fallback. Frontend: device picker dropdown (all managed devices except self) as default mode; toggle to Manual IP for non-managed targets with a warning that the server must be pre-enabled. Results show target IP in labels. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
63a7f4d785 |
feat: improve network topology — LLDP-authoritative, manual links, orphan handling (v0.11.6 Beta)
- Backend: 7-step pipeline — per-neighbor best-protocol dedup, bidirectional LLDP pair merging, LLDP-covered set suppression of noisy CDP/MNDP links, shared-segment heuristic limited to genuinely unresolved non-LLDP links - Backend: manual_topology_links table + POST/DELETE endpoints for user-drawn connections - Frontend: ManualEdge custom edge type (purple dashed + × delete button) - Frontend: Connect Mode toggle — blue crosshair handles, onConnect creates DB-persisted link - Frontend: orphan nodes in dedicated bottom row with orange warning banner - Frontend: legend updated, canvas height increased Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
64bb8b1b93 |
feat: packet capture, bandwidth test (v0.11.5 Beta)
- Packet capture: RouterOS sniffer + SFTP download to PCAP for Wireshark analysis - Bandwidth test: RouterOS /tool/bandwidth-test with TX/RX Mbps result display - Both exposed in the Tools tab on device detail pages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
a1ca4d0a44 |
feat: 2FA/TOTP, config templates (v0.11.4 Beta)
- 2FA: TOTP setup wizard in Settings, QR code enrollment, login second-step, password-confirmed disable - Config templates: reusable DNS/NTP/syslog config sets pushed to selected devices via RouterOS API Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
49ab1b002e |
feat: audit log, device tags, maintenance windows (v0.11.3 Beta)
- Audit log: middleware captures all write operations, paginated admin UI in Settings - Device tags: full CRUD, device assignment, filter bar and chips on Devices page - Maintenance windows: alert suppression during planned downtime, Settings management UI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
6a4ab1fc5f |
feat: scheduled backups, availability tracking, PoE power metrics (v0.11.2 Beta)
P1 — Scheduled Backups - BackupService.createBackup() now accepts a `type` param (manual/scheduled) - PollerService: cronMatchesNow() parser + runScheduledBackups() + Redis 1h gate - Settings page: backup schedule toggle + cron expression input with examples P2 — Uptime/Availability Tracking - DB: device_availability table (went_offline_at, came_back_online_at, duration_seconds) - PollerService: inserts outage rows on offline detection, closes them on recovery - GET /api/metrics/device/:id/availability?range=30d endpoint - /api/metrics/summary now includes fleet 30-day uptime % - DeviceDetailPage: 30-day availability card (uptime %, outage count, longest outage) - DashboardPage HealthBar: Uptime 30d mini-stat P3 — PoE Power Draw Metrics - DeviceCollector.collectPoePower() queries /interface/ethernet/poe/monitor for switches - Writes poe_power measurement to InfluxDB (tags: device_id, port; fields: watts, current_ma, voltage_v) - GET /api/metrics/device/:id/poe endpoint (current per-port + 1h history) - SwitchPortDiagram: amber watt badge on PoE-active ports - HardwareTab: PoE budget section showing active ports and total watts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
d3aa72c488 |
feat: Copy VLANs wizard + CPU display fix for hardware switches (v0.11.0 Beta)
Add 'Copy VLANs from Another Switch' — a 3-step modal available on the VLANs tab of any switch device. Step 1 selects a source switch and which VLAN IDs to copy (VLAN 1 deselected by default with a management risk warning). Step 2 lets the user assign interfaces on the target device per VLAN: clicking a port chip cycles it through None → Tagged → Untagged → None, with a live summary and bridge picker per VLAN. Conflict detection identifies VLANs already present on the target and offers per-VLAN Skip/Overwrite with the port grid disabling itself when Skip is active. Step 3 shows the full plan before a single apply call executes all operations over one device connection and reports per-VLAN results. Also fix CPU load display on the Devices page for hardware switches that report 0% CPU via ASIC offloading — the previous filter (v > 0) silently dropped all valid zero readings; now filters on field presence instead. |
||
|
|
e5f630ef2f |
feat: add Syslog configuration to Network Services (v0.10.8 Beta)
New Syslog page under Network Services exposes RouterOS logging actions and routing rules with full CRUD. Logging actions define destinations (remote syslog server, memory, disk, echo); logging rules define which topics route to which action. Single Device and All Devices modes are functionally identical — All Devices applies changes across every online device in parallel and shows per-entry coverage badges indicating which devices have each action or rule. Toggle enable/disable works in both modes. Remote actions display address:port with facility/severity in a single Destination column; memory/disk/echo rows show no irrelevant remote fields. Syslog KPI card added to the Network Services overview. |
||
|
|
68d88d0306 |
feat: RouterBOOT upgrade support, update UI overhaul, discovery fixes (v0.10.6 Beta)
RouterOS vs RouterBOOT distinction: - Rename "Firmware Updates" → "RouterOS Updates" everywhere (Config tab, dashboard banner, devices list tooltip) to accurately reflect what is being updated - Add RouterBOOT bootloader upgrade support as a distinct operation: new DeviceCollector methods checkRouterboardUpgrade() and installRouterboardUpgrade() using /system/routerboard/print and /system/routerboard/upgrade - New API endpoints: POST /api/devices/:id/check-routerboard and POST /api/devices/:id/install-routerboard - Daily poller now also checks RouterBOOT upgrade status while already connected during firmware check cycle, with first-discovery alerts Config tab update panel: - Combine RouterOS and RouterBOOT into a single "Software & Firmware Updates" card with two subsections separated by a divider - Pre-populate both sections from stored DB state on load — no longer requires a manual "Check" click to see a known pending update - Enforce update order: when both are pending, show a blue info banner explaining RouterOS must be installed first, and disable the RouterBOOT "Upgrade & Reboot" button until the RouterOS update clears - Fix GET /api/devices/:id to include firmware_update_available, latest_ros_version, routerboard_upgrade_available, upgrade_firmware_version (these were missing from the detail endpoint) Dashboard & devices list: - Combined amber banner now shows two distinct rows: RouterOS (N) with device links and RouterBOOT (N) with device links — one dismiss for both - Devices list: amber ArrowUpCircle = RouterOS update, blue Cpu icon = RouterBOOT upgrade, each with distinct tooltips - Fix GET /api/devices (list) to include routerboard_upgrade_available and upgrade_firmware_version (these were missing, causing RouterBOOT notifications to never appear on the dashboard) DB migration: - Add routerboard_upgrade_available BOOLEAN DEFAULT FALSE - Add upgrade_firmware_version VARCHAR(20) Discovery system fixes: - Fix duplicate entries: replace single-key (mac || ip || identity) dedup with three cross-referenced indexes so a neighbor seen via LLDP by one device and via MNDP by another always merges into one row - Fix offline devices persisting: handleDeviceFailure now deletes topology_links for the offline device (same pattern as clients) - Add periodic stale-link sweep every 15 min that removes topology_links rows older than 20 min as a safety net for devices that go offline without cleanly triggering handleDeviceFailure |
||
|
|
a336b0e207 |
security: trust proxy fix, login rate limiting, default secret banners (#33)
* fix(security): default-secret banners, login rate limiting, real-IP passthrough
- app.set('trust proxy', 1) so req.ip resolves to real client IP behind nginx
- loginRateLimit() middleware on POST /api/auth/login; limits read from
app_settings (login_rate_limit_max / login_rate_limit_window_sec) with
60-second cache; falls back to 10/60s when Redis/DB unavailable
- GET /api/auth/security-status returns warnings for default JWT_SECRET,
ENCRYPTION_KEY, and admin/admin credential — non-breaking for dev
- SecurityBanners component in AppLayout: yellow banner for default secrets,
red banner + change-password link for default admin password
- Login rate limit settings exposed in Settings > General tab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(security): rate-limit /security-status, suppress CodeQL false positive on /login
- Add allMethods option to rateLimitRedis so it can apply to GET routes
- Apply rateLimitRedis (10 req/60s per user) to GET /auth/security-status
to prevent bcrypt-based CPU exhaustion by authenticated users
- Add lgtm suppression comment on POST /login — CodeQL does not recognize
our custom loginRateLimit() middleware; rate limiting is in place
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Denis Evers <git@evers.sh>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
a12f52ebc7 |
feat: non-blocking bulk-add, CORS hardening, credential preset access control (#31)
* devices: hide discovered neighbors that are already managed Discovered Devices showed every unresolved MikroTik neighbor, even when the neighbor was obviously the same device a managed node is reporting through a different link / MAC / alias. Since we already know every managed device interface MAC, primary IP, and system identity, we can flag those rows. - /api/devices/discovered now annotates each row with duplicate_of_device_id and duplicate_of_device_name when the neighbor matches a managed device by interface MAC, primary IP, or system identity. - Add a Hide duplicates of managed devices toggle (default on) above the table, with a count of how many matches were found. When enabled, the list hides matched rows; when disabled, matches stay visible but are dimmed and tagged Already managed so the origin is clear. - Suppress the Add to Manager action for rows that are already a managed device, since adding them would create a second record for the same hardware. Co-authored-by: Denis <denis-ev@users.noreply.github.com> * devices: safer duplicate handling, try-all safeguards, and timezone/firmware fixes Co-authored-by: Denis <denis-ev@users.noreply.github.com> * Non-blocking follow-ups: parsePort util, tests, bulk-add queue, preset rate limit Co-authored-by: Denis <denis-ev@users.noreply.github.com> * fix(security): tighten CORS, Redis rate limits, and preset access controls Require explicit CORS origins in production, align Socket.IO CORS with HTTP, default Docker Compose to local HTTPS origins, and remove the in-memory mutation limiter in favor of Redis. Also add operator-use flags for credential presets in the UI and harden bulk-add job access and client error messages. Co-authored-by: Denis <denis-ev@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> |