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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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.
- 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>
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>
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>
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>
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.
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>
- 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>
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.
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.
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
* 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>
* 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>
Squash-merge of PR #28 from @denis-ev. All three review blockers addressed: encryption key management documented, string truncation replaced with skip+warn validation, and undefined return on duplicate merge fixed with proper 500. Local test stack verified against cloned prod DB — migrations, new features, and backend all clean.