- Remove daisyUI plugins and theme from app.css
- Define 5 custom color palettes: sweet-salmon, desert-sand, wheat, cool-steel, cerulean
- Add @utility card, badge, btn classes to replace daisyUI components
- Replace all daisyUI classes across 16+ template files
- Update tests for new class return values
- Set light mode as default theme
- L3: Normalize IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) to IPv4 tuples
when matching against IP whitelist entries
- L7: Verify interface belongs to current org before set/clear capacity
- L12: Guard window.liveSocket behind NODE_ENV check for production safety
- L13: Replace Alert.changeset with Ecto.Changeset.change for simple field
updates (resolved_at, acknowledged_at, gaiia_impact) to prevent
accidental overwrites from the 17-field cast
- M18: Add validate_snmp_device/1 with 255-byte limits on community,
v3_auth_password, and v3_priv_password to prevent memory exhaustion
- M13: Add client-side mime_type allowlist to phx:download handler
- M14: Replace Process.sleep loop in WeatherSyncWorker with Task.async_stream
to avoid blocking the Oban queue slot
- M15: Chunk device list in CloudLatencyProbeWorker PubSub broadcasts
(100 devices per message) to prevent megabyte-sized messages
- M2: get_user_by_email/1 uses lower(email) = lower(?) so User@Example.com
and user@example.com resolve to the same account; closes a lookalike-
registration / password-reset confusion risk.
- M8: webhook auth plug no longer echoes "Webhook authentication not
configured" — the misconfiguration is logged server-side and the caller
gets a generic Internal server error so endpoints can't be probed.
- M9: coverages controller logs the underlying KMZ build error and
returns a generic "Failed to build KMZ" string — filesystem paths and
zip internals no longer leak.
- M10: account-data controller no longer crashes with MatchError when an
org has zero or multiple memberships; defaults to "member" and treats
any owner membership as owner.
- M11: ActivityController switches to a hard whitelist
(Atom.to_string/1 comparison) instead of String.to_existing_atom/1;
unknown filter types are dropped silently and the atom table can't be
grown from API input.
- M16: title-tracking MutationObserver is stored on `this` so destroyed()
actually disconnects it — fixes a memory leak per LV navigation.
- H12: session and remember-me cookies get http_only + secure (prod-only).
Cookie can no longer be read via document.cookie (XSS exfil defense)
and the Secure flag is set in production via config/prod.exs.
- L2: 404 tracker uses EXPIRE … NX so a sustained probe can't keep
refreshing the 60s window and dodge the threshold ban.
- L5: vault only reads CLOAK_KEY when :env == :prod — a developer with
a prod env var set in their shell won't accidentally encrypt local
data with the production key.
- L6: health endpoint no longer leaks the app version.
- L8: Preseem.dismiss_insight/2 + InsightsLive uses it — dismissing now
requires the insight to belong to the user's org (closes IDOR).
- L10: SidebarCollapse JS hook stores its click handler and removes it
in destroyed(); listeners no longer accumulate across LV navigation.
- L11: WebMCP navigate tool rejects anything that isn't a same-origin
absolute path (blocks javascript:, data:, off-site URLs).
- H23: introduce Towerops.Workers.SyncErrors.transient?/1 classifier;
uisp/preseem/cn_maestro sync workers now retry transient failures (5xx,
timeouts, rate limits) via Oban while still discarding permanent errors
(401/403/404) as :ok. Stale monitoring data is the bigger risk than an
extra retry on a known-bad credential.
- H26: add Monitoring.get_latency_data_for_devices/2 batch counterpart
(currently a stub map but called from SiteLive.Show so any future ping
implementation lands in a single query, not one-per-device).
- H28: Nominatim search popups in the coverage map now bind via a text
node instead of an HTML string, so a compromised/poisoned response
can't execute as JS in the popup.
- H29: yaml_profiles dot-boundary OID prefix match (handles optional
trailing dot from YAML profile keys). Stops 1.3.6.1.4.1.9 from
incorrectly matching 1.3.6.1.4.1.99.
Rewire the single-coverage show page to mount MultiCoverageMap (the
same hook the org map uses) with one coverage in the array, and lift
the cnHeat-style overlay panels — LOS/NLOS toggle + height slider on
the left, opacity + RSSI filter on the bottom, distance probe on
click. Parameters move into a togglable right-side drawer so the map
dominates the viewport.
Show LiveView gains toggle_params / probe_point / close_probe /
set_probe_units handlers and delegates the formatters to
CoverageLive.Map. Site is now preloaded so coverages_payload/1 can
read site.name without an Ecto.AssociationNotLoaded crash that was
silently nuking the data-coverages JSON encode.
Also fixes a real bug in coverage_hooks.ts: pngForCoverage was
returning before the antenna-marker creation block, leaving the
marker code as unreachable dead code — towers never rendered. Moved
the marker creation into addCoverageLayer where it belongs and added
a no-PNG guard so a missing png_path logs instead of silently
failing the L.imageOverlay constructor.
ConfigTimelineLive's chart panel was rendering a bare 'Loading chart...'
placeholder forever — the phx-hook="ConfigTimelineChart" had no JS
implementation registered anywhere, so the whole point of the page
(correlating QoE regressions with config edits) was lost.
Add a focused lazy hook in hooks/config_timeline_chart.ts that:
* Plots Preseem latency / throughput / loss across three Y-axes.
* Overlays vertical dashed markers at each config-change timestamp
with a small badge showing how many lines changed.
* Drops a red triangle on the time axis for every failed check.
* Reuses the Chart.js vendor chunk (esbuild --splitting factors it
into a shared chunk with sensor_chart, so visiting either page
primes the other).
Bundle delta:
chunk-RSDHLN2L.js (Chart.js, shared) 314.8 KB
config_timeline_chart 7.2 KB
sensor_chart (was 325 KB standalone) 10.6 KB after dedup
DeviceListReorder, SortableList, MikrotikPortSync are each used on a
single page (devices index, escalation/schedule show, device edit
form respectively) — push them out of app.js into their own chunks
via lazyHook. Also extract MikrotikPortSync into its own file under
hooks/ for consistency.
Compress CopyToClipboard's flash feedback by collapsing the duplicated
success/error inline-SVG/setTimeout blocks into one helper, drop the
debug console.logs, and remove the bulk Phoenix-template boilerplate
comment block that was just generated noise.
After-bundle (minified):
app.js 287.6 KB (-2 KB on top of prior split)
device_list_reorder 4.6 KB loaded on /devices only
sortable_list 3.1 KB loaded on escalation+schedule pages
mikrotik_port_sync 1.3 KB loaded on device edit form only
app.js was bundling Chart.js (~200 KB), Cytoscape (~500 KB) and four
heavy hooks (SensorChart, NetworkMap, WeathermapViewer, SitesMap)
into a single 1.25 MB blob that every page paid for.
Extract each into its own module under assets/js/hooks/ and reference
them through the existing lazyHook() wrapper so the implementation is
fetched only when a hook actually mounts. esbuild --splitting then
factors the shared cytoscape vendor blob into a chunk that's only
loaded on topology / weathermap pages.
After-bundle layout (minified):
app.js 297.8 KB (was ~1250 KB; -76%)
chunk-LJGWK7RH (cyto) 555.1 KB loaded on topology pages only
sensor_chart 325.3 KB loaded on sensor pages only
network_map 24.4 KB topology hook glue
weathermap 13.0 KB weathermap hook glue
sites_map 4.1 KB site map hook glue
coverage_hooks 25.0 KB coverage hook glue
LeafletMap also moved to sites_map.ts and now uses ensureLeaflet
instead of polling for window.L with setTimeout.
Closes the cnHeat-parity gap on modes and clutter:
Backend
- Towerops.Coverages.Buildings.for_bbox/1 — PostGIS ST_Intersects
query that returns each building polygon as a compact
%{height_m, coords} map ready for in-memory point-in-polygon
testing, so the per-pixel hot loop never round-trips to
Postgres.
- Towerops.Coverages.Profile.sample/5 — accepts an optional
:clutter list and adds the building rooftop height to terrain
whenever a sample point falls inside a polygon. Pure ray-cast
PIP for portability.
- Towerops.Workers.CoverageWorker — fetches buildings once per
job and computes BOTH LOS and NLOS variants per SM-height
tier (12 PNGs total). LOS passes clutter: [] (height-above-
clutter view), NLOS passes the prefetched list (height-above-
ground view with rooftop diffraction). Pixel-loop signature
bundled into a ctx map to keep the function arity within
Credo's max-8 limit.
- Towerops.Workers.MsBuildingsImportWorker — streams Microsoft
GlobalMLBuildingFootprints GeoJSONSeq exports line-by-line,
upserts into coverage_buildings in 500-row batches via
insert_all + ON CONFLICT (source, ms_footprint_id). Handles
Polygon and MultiPolygon geometries; stable-hashes the bbox
when an explicit feature id is missing so re-imports are
idempotent. Public import_file/2 lets ops drive a state at
a time from IEx.
- Schema: nlos_tiers JSONB column on coverages, status_changeset
whitelist, payload exposed via list_ready_for_organization.
Frontend
- LOS / NLOS toggle pills above the height slider (left panel).
Selecting NLOS swaps each L.imageOverlay's URL via setUrl to
the matching nlos_tiers PNG, drops the cached pixel data, and
re-decodes for the RSSI filter; falls back to the other
mode's tiers when one is empty so older coverages still
render. coverage_hooks chunk: 22 KB → 25 KB.
Vertical 'Height above clutter' slider on the left of the map now
matches cnHeat's experience: pick an SM install height, the heatmap
re-renders to show only the coverage achievable from that height.
Backend
- New JSONB height_tiers column on coverages keyed by string metres
→ relative PNG path.
- CoverageWorker runs compute_pixels once per tier in
[1.83, 3.05, 4.57, 6.10, 9.14, 12.19] m (~6/10/15/20/30/40 ft)
by varying receiver_height_m on a copied struct. Each tier
writes its own raster + PNG via Raster.write/4 with a per-height
filename suffix; the default 3.05 m tier also populates the
legacy raster_path / png_path so existing show pages keep
working.
Frontend
- Vertical range slider (writing-mode: vertical-lr) anchored
centre-left. Step picks one of HEIGHT_TIERS_FT; label updates
to '6 ft'..'40 ft'.
- pngForCoverage/2 picks the closest available tier from the
payload's height_tiers map, falling back to the original png
for coverages not yet recomputed.
- On slider change the JS swaps each L.imageOverlay's URL via
setUrl, drops the cached pixel data, and re-decodes so the RSSI
filter still applies. coverage_hooks chunk: 20 KB → 22 KB.
Adds the cnHeat-style horizontal 'Min RSSI' slider beside the opacity
control. Pixels weaker than the threshold turn fully transparent,
client-side, with no server round-trip — same compute output now
powers an interactive 'where can I get -75 dBm or better' exploration.
- Slider range -100..-40 dBm step 1; -100 disables filtering.
- addCoverageLayer fetches each PNG into a hidden canvas and caches
its ImageData. crossOrigin=anonymous keeps reads tainted-free.
- Each pixel is classified by nearest palette colour (-50/-65/-75/
-85/-95 dBm centres matching Raster.write/3) and dropped if its
dBm is below the threshold. Result re-encoded via toDataURL and
swapped onto the L.imageOverlay via setUrl.
- destroyed() revokes blob URLs and clears the filter cache to
avoid leaks during LiveView nav. Bundle: coverage_hooks chunk
16 KB → 20 KB; main bundle unchanged at 1.2 MB.
Switch esbuild to ESM with --splitting and load app.js as type=module.
Heavy hooks now ship in their own chunk and only download when their
LiveView mounts.
- assets/js/lib/leaflet.ts: extract ensureLeaflet (vendored Leaflet
loader) into a shared module with a typed window.L declaration.
- assets/js/hooks/coverage_hooks.ts: extract CoverageMap,
MultiCoverageMap, and CoverageLocationPicker (~510 lines) with
proper interface types for hook context and tightened typing on
payloads / event handlers (CoveragePayload is now a real type).
- app.ts: replace those three inline hook bodies with lazyHook
stubs that call import('./hooks/coverage_hooks') on first mount,
forwarding lifecycle hooks once the chunk resolves. Also adds a
scoped `declare const L: any` so the remaining Leaflet hooks
type-check.
- root.html.heex: switch the script tag to type=module so the
emitted ESM bundle can use dynamic import().
- esbuild config: add --splitting --format=esm.
Build output now produces:
app.js 1.2 MB
coverage_hooks-<hash>.js 16 KB (lazy)
chunk-<hash>.js 2.4 KB (shared, lazy)
- /coverage now renders a full-bleed Leaflet map of every ready
coverage in the org as one composite heatmap; the prior table view
moves to /coverage/list.
- Top-right tower toggle list with Show All / Hide All. Disabled
towers grey out and their overlay/marker drop from the map.
- Bottom-center opacity slider controls all overlays in unison; the
cnHeat dBm legend renders next to it.
- Top-left base-layer toggle (OSM streets / Esri satellite) and a
Nominatim address search bar that drops a marker.
- Click anywhere on the map → Distance Tool side panel with each
tower sorted by distance and the predicted RSSI at that point
(read from each coverage's GeoTIFF via gdallocationinfo). ft/m
unit toggle, NA for out-of-bbox, color-coded by signal quality.
- Backend additions: Coverages.list_ready_for_organization/1,
Coverages.query_point/3, Raster.query_rssi/3 (gdallocationinfo
wrapper with bbox guard, NoData sentinel handling).
- New JS hook MultiCoverageMap manages the layer set, toolbar,
search, opacity, click-to-probe, and live updates via PubSub
(coverage:enabled-changed and coverage:list-changed).
Sourced 198 vendor-published MSI Planet pattern files via the
wireless-planning.com aggregator (data1.mlinkplanner.com). Real
patterns now ship for: Cambium (6), Ubiquiti (154), RF Elements
(27), MARS (5), MTI Wireless (1), Tarana (2), Ruckus (1), RFE (2).
Files dropped into priv/antennas/<vendor>-<part>.msi; the existing
boot loader picks them up and they override the synthesized catalog
entries by slug where the slugs match.
Parser improvements to handle real-world MSI file quirks:
- File-extension filter now accepts .ant/.msi (case-insensitive).
- Slug normalisation: spaces, +, and other non-alnum collapse to
hyphens.
- Header parser splits on whitespace runs (tab or space) — different
vendor tools produce different delimiters.
- New parse_frequency_mhz/1 handles "5500", "5 GHz", "5GHz",
"5.6GHz", "5500 MHz", "5150-5850" (centre frequency), and
"5.45 - 5.85 GHz" formats. Replaces the old integer-only parse.
- 2 MARS files with non-360 angular resolution and 1 with
angle-less pattern lines were excluded; the rest parse cleanly.
Total registry now 305 antennas: 198 real vendor patterns + 107
synthesized catalog entries (used as fallback for SKUs without
public .msi files: Cambium ePMP/PMP variants, Tarana G1/G2,
ALPHA, ITELITE, KP Performance, RADWIN, Mimosa, etc.).
Leaflet auto-load fix:
- The location-picker map wasn't appearing because the form page
didn't have the inline <script> tag the existing site map uses
to load Leaflet. AGENTS.md forbids inline <script> in templates,
so I added an ensureLeaflet() helper in app.ts that lazy-loads
/vendor/leaflet/leaflet.{js,css} on first hook mount and caches
the promise. SitesMap, CoverageMap, and CoverageLocationPicker
all use it now — no inline scripts needed.
Adds a "Radio location" section to the coverage form with an embedded
Leaflet map. The marker starts at the parent site's coordinates and
the user can drag it to the actual radio location. A "Use site
location" button clears the override.
- New CoverageLocationPicker hook in assets/js/app.ts: creates the
map, draggable marker, and pushes "location_picked" {lat, lon} on
dragend. Re-creates the marker when the user picks a site that
initially had no coords.
- form.html.heex: location section with map div (phx-hook), readout
("lat, lon - override" if set), and reset button. Hidden inputs for
latitude_override / longitude_override so the form's params always
carry the chosen coordinates.
- form.ex: handle_event "location_picked" updates the override fields
in the changeset; "use_site_location" clears them. Helper functions
radio_marker_lat/lon walk the form values, falling back to the
selected site.
CI test gate fix:
- Worker test now requires GDAL CLI tools to write GeoTIFF + PNG.
test_helper.exs detects whether gdal_translate / gdaldem are on
PATH; when missing (CI's lean test image), :requires_gdal tagged
tests are excluded. Locally with brew install gdal they still run.
The compute pipeline now actually runs: per-pixel path loss over LIDAR
terrain, antenna pattern lookup, real GeoTIFF + colored PNG output,
Leaflet imageOverlay rendering with opacity slider and dBm legend.
Propagation:
- Towerops.Coverages.Propagation: Friis FSPL + Bullington single-knife-edge
diffraction (ITU-R P.526). Pure Elixir, closed-form, no NIF dependency.
Clean signature so a future ITM/Longley-Rice backend can drop in.
- Towerops.Coverages.Profile: AAIGrid sampler with elevation_at, sample
along great circle, haversine distance.
- Towerops.Coverages.Raster: Float32 GeoTIFF via gdal_translate (with VRT
wrapper) and colored PNG via gdaldem color-relief. Outputs to
priv/static/coverage/<org>/<id>/, served by existing Plug.Static.
Worker:
- Towerops.Workers.CoverageWorker now does the full pipeline: bbox compute
-> Towerops.Lidar.get_elevation_grid -> per-pixel Task.async_stream
(parallel = schedulers) running antenna pattern lookup + propagation +
RSSI threshold check, write rasters. Configurable terrain source via
:coverage_terrain_module application env so tests can stub. Friendly
failure messages for :no_tile, :grid_too_large, :nodata,
:unknown_antenna, :missing_location.
Bundled antenna catalog (~95 entries):
- AntennaCatalog with compact specs for every antenna in the request list:
Cambium, ALPHA, Antel, ITELITE, KP Performance, L-com, MARS, MikroTik,
Mimosa, MTI, RADWIN, RF Elements, Ruckus, Tarana, Simulate, Ubiquiti.
- Antenna.from_spec/1 synthesizes 360+360 attenuation patterns from
gain+beamwidth (cosine-squared main lobe, smooth transition,
front-to-back floor with mild ripple). Real .ant files in priv/antennas/
override the catalog by slug. Test fixtures moved to test/support.
Schema (migration add_radio_fields_to_coverages):
- tx_power_dbm replaces eirp_dbm; EIRP is now derived as
tx_power + antenna.gain - cable_loss in Coverage.eirp_dbm/2 and shown
live in the form header.
- cable_loss_db, sm_gain_dbi, latitude_override, longitude_override,
height_above_rooftop_m, tx_clearance_m, foliage_tuning (0-100 slider).
- Coverage.location/1 returns {lat, lon} from override or parent site.
UI:
- form.html.heex: cnHeat-style grouped form (Identity / Location /
Mounting / RF / Coverage extent), foliage tuning range slider,
computed EIRP badge in the header.
- show.html.heex: Leaflet map area with L.imageOverlay heatmap,
opacity slider, dBm color legend, antenna marker with directional
wedge for azimuth.
- assets/js/app.ts: CoverageMap hook registered.
Tests: 24 new (propagation reference values, profile sampling,
worker end-to-end with stubbed terrain that writes real GeoTIFF + PNG,
org-mismatch job-cancel guard). Full suite: 10815 tests.
Three deferred followups from the chunk-4 plan, all together:
A. Restrictions editor
- Resolver: time_of_week support (weekday set + time-of-day window),
in addition to the existing time_of_day.
- New OnCall.update_layer_restrictions/2 + clear_layer_restrictions/1.
- schedule_live/show: per-layer Restrict on-call shifts form
(Always on call / Time of day / Time of week with Mon-Sun checkboxes),
changes patched live and persisted via the resolver.
B. Drag-and-drop reorder
- New OnCall.reorder_layers/2 + reorder_escalation_rules/2 (atomic
transaction; validates id count + membership).
- assets/js/sortable_list.ts: minimal HTML5 drag-and-drop hook (no
external dep; mirrors the existing DeviceListReorder pattern),
registered as SortableList in app.ts.
- schedule_live/show + escalation_policy_live/show: layer/level cards
now carry data-id + draggable + a visible drag handle. Up/down arrow
buttons remain as a fallback for users on assistive tech.
C. Unified new-schedule UX
- schedule_live/show pre-opens the Add Layer form when the schedule
has zero layers, so the new-schedule -> add-layers flow lands ready
to act on. Two existing tests updated to seed a layer first; two
new tests verify the auto-open behaviour both ways.
Full suite 10,231 / 0 failures. Format/dialyzer clean.
- Create WeathermapLive module at /weathermap with 60s auto-refresh
- Extend Topology.get_topology_for_weathermap with bandwidth utilization data
- Add WeathermapViewer JavaScript hook with color-coded edges:
* Green (0-30%) → Yellow (30-60%) → Orange (60-80%) → Red (80%+)
- Display throughput/capacity labels on edges (e.g., '847 Mbps / 1 Gbps')
- Add utilization stats dashboard with link count by usage level
- Support full-screen mode for NOC displays
- Integrate with existing dark mode and responsive design
- Add navigation links in sidebar and mobile menu
Technical implementation:
- Leverages existing Capacity module for interface utilization calculations
- Extends topology edges with utilization_level, utilization_pct, throughput_bps
- Builds on NetworkMapLive foundation with enhanced edge styling
- Auto-refresh via Phoenix PubSub and 60s timer
- Filter support for high/critical utilization links
Reviewed-on: graham/towerops-web#172
## Problem
On backhaul devices, the Y axis auto-scale includes capacity reference line values (e.g. 100 Mbps) alongside actual traffic (e.g. 5 Mbps). This makes the traffic data appear as a flat line near zero — effectively invisible.
## Fix
Exclude datasets with `Capacity` prefix from the max-value calculation used for symmetric Y axis scaling. The capacity dashed lines still render and clip at the chart edges as a visual ceiling reference.
## 1-line change
```js
if (dataset.label?.startsWith("Capacity")) return
```
Reviewed-on: graham/towerops-web#140
CRITICAL FIXES:
- Fix unsafe pattern matching in SNMP discovery that would crash on timeout
- Changed {:ok, _} = ... to proper case statements with error handling
- Extracted safe_discover/3 helper to reduce cyclomatic complexity
- Added fallback to empty lists and warning logging for timeouts
- Affects: discover_vlans, discover_ip_addresses, discover_processors, discover_storage
MEMORY LEAK FIXES (JS Hooks):
- GlobalSearchTrigger: Added destroyed() cleanup for click listener
- SidebarCollapse: Added destroyed() cleanup for click listener
- ThemeSelector: Added destroyed() cleanup for phx:set-theme window listener
- NetworkMap: Added destroyed() cleanup for 3 button listeners (zoom in/out/fit)
- All hooks now properly store handler references and remove listeners on unmount
LIVEVIEW FIXES:
- Added phx-update="ignore" to 5 SensorChart hooks to prevent chart re-initialization
- Added catch-all handle_info(_msg, socket) to 5 LiveViews to prevent crashes on unexpected messages
- device_live/show.ex, device_live/index.ex, alert_live/index.ex, activity_feed_live.ex, agent_live/index.ex
N+1 QUERY FIXES:
- Created Gaiia.get_site_subscriber_summaries/1 batch function
- Refactored alert_live and device_live to use batch query instead of looping
- Reduces queries from N to 1 when loading site subscriber data
Impact:
- Prevents discovery worker crashes during SNMP timeouts
- Eliminates memory accumulation from leaked event handlers
- Prevents chart state loss on LiveView updates
- Prevents LiveView crashes from unexpected PubSub messages
- Improves database performance by eliminating N+1 queries in alert/device views
- Passes credo --strict with 0 issues
Reviewed-on: graham/towerops-web#132
- Add missing flex-1/text-right classes to Resolved IP row
- Add justify-end to Operating System flex container
- Fix duplicate time labels on 24h traffic chart by excluding boundary ticks
Reviewed-on: graham/towerops-web#117
Chart.js was showing integer-rounded values like '2' instead of '2.1 ms'. Uses toFixed(1) in tick callbacks for proper decimal display with units.
Reviewed-on: graham/towerops-web#76
DNS check graph now shows two datasets: Response Time (ms) on left y-axis and Status (Up/Down) on right y-axis with stepped green fill. Also adds space before units in chart labels.
Reviewed-on: graham/towerops-web#74
- Tower/device icons: role-based shapes (triangle for APs/wireless,
diamond for switches, round-rect for routers, hexagon for firewalls)
- RF link overlays: edges colored by signal health (green/yellow/red)
with bandwidth-proportional line thickness
- Enhanced hover tooltips: device-to-device name, SNR dB, bandwidth,
signal health indicator
- Wireless client count badge on AP nodes
- Filter bar: All Links | Degraded Only | Sites with Alerts | Search
- Search with zoom-to-match and highlight
- Geographic layout toggle (when sites have lat/lng)
- Enhanced detail panel: RF stats (client count, signal health, SNR),
View RF Links button for wireless devices
- Backend: compute_wireless_stats/compute_rf_link_stats enrich topology
- Site nodes include lat/lng for geographic layout
- Edge enrichment with RF signal health from SNR sensors
- Fix pre-existing compile error in notification_rate_limiter.ex
- Increase node font from 11px to 13px, compound labels to 14px
- Replace cose physics layout with deterministic grid preset layout to eliminate site group overlap
- Fix nil source_interface_id crash in topology.ex find_existing_link/1 (was using == nil instead of is_nil/1)
- Fix nil device label in device_to_node/1 fallback to IP or "Unknown"
LiveView DOM patching was stripping the client-side sidebar-collapsed
class from #sidebar-wrapper on every navigation. Fix: put the class
on document.documentElement which LiveView never touches.
- Sync script in <head> applies state before first paint (no flash)
- Hook click handler toggles on <html> + saves to localStorage
- CSS selectors updated to html.sidebar-collapsed
- Removed phx-click JS.toggle_class (hook handles toggle directly)
- Add updated() callback to SidebarCollapse hook so state is re-applied
after LiveView DOM patches (which strip client-side classes)
- Add inline script for synchronous state restore (no flash on nav)
- Sidebar now stays expanded/collapsed until manually toggled
Add an Appearance section to the Personal tab in user settings with
three clearly labelled options (Auto/Light/Dark). A ThemeSelector hook
reads localStorage to highlight the active choice and updates it when
selection changes. Remove the dropdown toggle from both the main nav
and admin nav.
The regex previously only stripped one leading emoji, so if emojis
stacked up, each update would only remove one, letting the stack grow.
Changed to use + quantifier to strip all leading emoji+space sequences.
Site compound nodes have IDs like "site_<uuid>" which can't be cast to
binary_id in Ecto queries. Add guard clause to get_node_detail/2 and
skip node_clicked events for site nodes in Cytoscape hook.
- fix bidirectional link queries to find links where device is source or target
- merge A→B and B→A edges into single link with combined confidence
- include interface speed in edge data for variable edge width
- infer discovered node roles from LLDP/CDP capabilities (router, switch, wireless, phone)
- store remote_capabilities in link metadata during evidence processing
- replace Cytoscape destroy-and-recreate with element diffing to preserve zoom/pan/positions
- add node detail slide-out panel with device info, connections, and confidence
- support ?node= URL param for deep-linking to selected nodes
- add site-based compound node grouping in Cytoscape
increase longPollFallbackMs from 2500 to 5000 to prevent premature
fallback on mobile networks, and add visibilitychange handler to
reconnect WebSocket when returning from a backgrounded tab
Fix JS regex in StatusTitle hook to use unicode flag so emoji are
properly matched and replaced instead of accumulating. Add page_title
to 8 LiveViews that were missing it, which caused "TowerOps | TowerOps"
duplication from the live_title suffix.
The SensorChart hook was creating charts successfully but never removing
the loading overlay, causing charts to appear stuck on 'Loading chart...'.
Now removes the [data-loading] element after the chart is created.
Connection counts (IPv4/IPv6, DHCP leases) were showing as floats
(e.g. 1270.0) in executor output, change detection messages, and
chart tooltips/axes. Now displayed as integers everywhere.