Adds a dedicated test file that drives the small private mapping helpers
inside `Towerops.Topology` via their public surface:
- `role_to_type_atom/1`: every device_role string (router, core_router,
switch, distribution_switch, access_switch, access_point, cpe,
backhaul, backhaul_radio, server, firewall, ups, other, unknown
fallback) is exercised through `device_to_node` (via
`get_topology_for_map/2`) and again through `get_node_detail/2`.
- `link_type_priority/1`: each priority pair (lldp/cdp/mac_match/
arp_inference) is exercised via `merge_bidirectional_edges/1` by
upserting reciprocal links of differing types and asserting the
higher-priority `link_type` survives the merge.
- `evidence_type_to_link_type/1`: every evidence_type clause
(lldp_neighbor, cdp_neighbor, mac_on_interface, arp_entry,
wireless_registration, plus the catch-all) is hit via
`upsert_link/1` → `process_evidence_for_link/2`.
Coverage: 86.28% → 86.42% (+0.14%).
Adds focused tests across multiple modules to push toward the 87% threshold:
- CoreComponents: pagination/1 (multi-page, ellipsis, params),
list/1 (slot rendering), and signal_badge/1 across all RSSI bands.
- ApiDocs/GraphQLDocs controllers: bypass-the-router pattern so the
"scope-with-organization" branches are actually exercised (the public
routes are unscoped, so the org branch is unreachable via get/2).
- Mix.Tasks.Unused: text + JSON output, --only / --skip filters.
- Mix.Tasks.JobCleanupTask: prod path with phoenix snmp enabled vs
disabled (uses :job_cleanup_settle_ms = 0 to avoid the 1s sleep).
- Member resolver: success path for remove/update_role on non-owner
membership, and the changeset-error branch for an invalid role.
- Check resolver: unauthenticated fallback clauses for list / get /
create / update / delete.
- RfLinks: nil-signal sort ordering, :unknown classification for
nil/nil signal+snr, :degraded filter, capacity_utilization edge
cases (max_rate 0/missing, both rates nil).
- StatusHelpers: status_emoji + title_with_status across green / red
branches (warning is unreachable given the active-alerts query).
- ApiTokens: spawn_async_update/2 path with env != :test.
- ImpactAnalysis: to_json/1 across nil and populated levels.
- Maintenance: query helpers (default-arity wrappers, active range)
and Show LiveView delete event redirecting to /maintenance.
- Coverage map: probe_point event handler.
- InvitationQuery: default-arity pending/1.
- Remove Mox.set_mox_global() in DPW extra test; rely on $callers
instead so concurrent vendor SNMP tests stop intermittently failing
with VerificationError.
- Make ToweropsWeb.Plugs.GraphQLIntrospectionTest async: false. It
mutates Application.put_env(:towerops, :env, :prod), which polluted
every async vendor test that reads :env via
Towerops.Snmp.Client.phoenix_snmp_disabled/0.
- Bump test pool queue_target/queue_interval. AgentChannelTest spawns
many `:proc_lib` channel processes that hold sandbox connections;
the default 50ms queue caused intermittent
'could not checkout the connection' errors.
- Tag real-System.cmd("ping") tests as :network so they're excluded
by default. Saves ~20s on every full run.
- Make JobCleanupTask's internal 1s settle sleep configurable via
:job_cleanup_settle_ms; the test overrides it to 0.
Plus the pending CoverageLive.Form edit-save / EIRP recompute and
TraceLive deep-link tests from before the compact.
Add PromEx with Application/BEAM/Phoenix/LiveView/Ecto/Oban plugins plus
a custom plugin that bridges existing Towerops Oban/Redis telemetry events.
The metrics HTTP server runs isolated on port 9568 so scrape traffic never
traverses the public Traefik IngressRoute.
The pod template carries prometheus.io annotations (scrape, port, path, job)
so the external Prometheus on 10.0.15.31 discovers each pod through the
existing kubernetes-pods scrape job (apiserver-proxy).
- Add Codeberg Ansible collection link to API/GraphQL docs and Terraform guide
- Un-skip the previously-disabled DevicePollerWorker tests that still pass
- Expand MibController, CnMaestro.Sync, CheckWorker, ActivityFeed,
MobileController, JobCleanupTask tests
- New UploadMibs Mix task test covering arg validation and upload paths
- Set :mib_dir in test config so MibController upload/delete flows can
exercise the real on-disk paths against a writable tmp directory
The /docs/api and /docs/graphql pages were missing four resource
families that the API has shipped:
- Coverages (RF coverage prediction with async compute, recompute,
KMZ download)
- Maintenance Windows (one-off + recurring with RRULE)
- On-Call Schedules (top-level CRUD + layers, members, overrides,
on_call lookup)
- Escalation Policies (CRUD + rules + targets)
Both REST and GraphQL pages now have sidebar entries and example
requests for each.
The current ApiDocs/GraphQLDocs templates don't surface the sample_token
back into the rendered HTML, so the previous assertions were checking
for strings that don't exist. Drop them down to a 'page renders' check.
The controller branches that compute sample_token still get exercised.
Add focused tests across many modules to push overall coverage from
75.45% to ~77%.
New test files:
- test/towerops/snmp/topology_test.exs
- test/towerops/vault_test.exs
- test/towerops/workers/check_worker_test.exs
- test/towerops_web/graphql/resolvers/happy_path_test.exs
- test/towerops_web/graphql/schema_test.exs
- test/towerops_web/live/reports_live_test.exs
Expanded existing tests for: Airos vendor, ProfileWatcher, StormDetector,
LLDP, GpsSync, MikrotikBackupWorker, AdminController, MibController,
MobileController, GeoipController, OnboardingLive, UserResetPasswordLive,
SessionManager, Telemetry, CoverageLive.Show.
Also fix a pre-existing dead test in ApplicationSettingTest where the
schema default for value_type made the 'invalid without value_type' test
unreachable.
Apply Testing LiveView course patterns (live/2, has_element?,
render_click, form/3). Add tests for MobileQRLive, WeathermapLive,
StatusPageLive, controllers, plugs, and pure function modules.
Expand proto decode coverage to 85.24%.
Dialyzer:
* Add Lidar.Tile @type, narrow several @specs to match success-typing,
prefix discarded function returns with `_ =`, fix the broken
File.stream! call in the ms-buildings worker (it was causing dialyzer
to mark every private fun unreachable), broaden Antenna.spec key
types from `float()` to `number()` to match catalog int literals.
* Add :geo, :geo_postgis, :db_connection to plt_add_apps so the dep
PLT actually contains the modules we use.
* Result: 0 unsuppressed dialyzer warnings (was 50+ in project code).
Tree canopy:
* Replace the dead landfire.gov URL with the ETH Zurich Global
Canopy Height 10 m product. ETH publishes proper COGs with
overviews on libdrive.ethz.ch — confirmed working with
/vsicurl/ byte-range reads. The module now picks the 3°×3° tile
containing the bbox centroid, with both a `:metres` decoder
(ETH Byte raster, NoData=255) and a `:landfire_evh` decoder for
operators self-hosting LANDFIRE EVH COGs.
* Re-enable canopy by default now that there's a working source.
Tests (+105 tests, 0 failures):
* CoverageLive Index/Show/Form/Map — empty/loaded/auth states,
LiveView event handlers, formatters.
* Coverages.Building changeset + helpers.
* Coverages.TreeCanopy provider tile-URL builder and decoders.
* Workers.MsBuildingsImportWorker (full import flow incl. blank
lines, MultiPolygon, idempotency, hash fallback) — also fixed
the partial-index ON CONFLICT bug that prevented imports from
ever working: the unique index has WHERE ms_footprint_id IS
NOT NULL, so the worker now uses an :unsafe_fragment conflict
target that includes the predicate.
* GraphQLDocsController smoke test (was 0%).
Coverage rose from 73.19% → 74% with the major project modules I
touched all moved from 0% / <50% to >75%.
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.
Updates all docs, UI copy, release checker, proto go_package,
container image refs, and tests to point at
codeberg.org/towerops-agent/towerops-agent. Also deletes stray
empty package.json/package-lock.json (Phoenix uses esbuild — no
npm) and an unused test_encode_decode.exs scratch file.
Coverage rasters were being written to priv/static/coverage on the pod's
ephemeral filesystem — fine on a single-pod dev box, broken under k8s
where pods share NFS at /data and a restart wipes computed predictions.
* New :coverage_storage_dir config (defaults to priv/static/coverage for
dev/test, "/data/coverage" in production).
* Raster.output_dir/absolute_raster_path read this config and resolve
{otp_app, rel} or absolute paths.
* Endpoint now mounts a dedicated Plug.Static at /coverage from that
same config — replicas all serve the same NFS-backed file regardless
of which one ran the compute.
* Drop "coverage" from static_paths/0 since the dedicated plug owns
that prefix now.
`coverage` was missing from static_paths/0, so /coverage/<org>/<id>/rssi*.png
fell through to the router (or downstream CDN) instead of being served as a
static asset. In prod that meant the ingress's hotlink-protection page was
rendered as the Leaflet image overlay — the "Access blocked / Referer is
required by..." graphic the user saw on the map.
CoverageWorker was silent between progress updates, so a stuck
gdalwarp / runaway pixel loop showed up only as 'stuck at 15%' in
the UI with nothing in k8s logs. Now every stage logs with the
coverage id (terrain fetch, building load, per-tier LOS+NLOS, row
band progress every 10%, finalize) and the row stream uses a
10-minute timeout with on_timeout: :kill_task so a hang surfaces
as an explicit row-failed log line + NaN fill instead of a wedged
worker. Unexpected exceptions now log with stacktrace and fail the
coverage cleanly.