Commit graph

193 commits

Author SHA1 Message Date
Graham McInitre
02f6d816de fix: upgrade Nix PostgreSQL from 16 to 17
- Change postgresql_16 → postgresql_17 in shell.nix (17.10 available)
- Both postgis and timescaledb confirmed available for PG17
- Remove PG17-only transaction_timeout SET from structure.sql
  (dump was already PG17, now compatible with running server)
- Auto-reinit triggers on version file mismatch
2026-07-16 09:23:44 -05:00
6fa0b791f2 fix: M3, M6, M7, M12 — open redirect, overfetch, missing constraint, admin defense-in-depth
- M3: Replace blacklist-based valid_return_path? with whitelist of known app
  route prefixes, plus URI decoding to prevent %2f encoding bypasses
- M6: Replace full-org device/link load in get_node_detail with targeted
  join query filtering by discovered node fields
- M7: Add partial unique index on (organization_id, email) for pending
  invitations (WHERE accepted_at IS NULL)
- M12: Add on_mount superuser verification to all 7 admin LiveViews for
  defense-in-depth
2026-05-12 12:46:16 -05:00
7e6ec7098c fix: 10 high-severity bugs from code audit (H1-H5, H14-H16, H27, H30)
- H1: batch count queries in MobileController (org sites/devices/alerts, site
  device counts) — reduces 1+3N+2M queries to 4 + 2
- H2: batch Oban cancel for stop_device_checks — single ANY(?) query instead
  of one per check
- H3: resolve_active_alerts_for_device uses update_all + deduped PubSub
  broadcasts (was N updates + N broadcasts)
- H4: atomic ON CONFLICT upsert for auto-discovery checks; restore partial
  unique index dropped in 20260404000002 (dedup migration included)
- H5: wrap apply_agent_to_all_equipment delete+insert in transaction so a
  failed insert_all rolls back the prior delete
- H14: replace String.to_integer/1 on untrusted SNMP OID components with
  Integer.parse/1 + validation (neighbor_discovery, discovery storage index,
  printer supply index)
- H15: ActivityFeedLive whitelists activity types against @all_types instead
  of String.to_existing_atom/1
- H16: defensive page param parsing in user_settings and admin dashboard
  LiveViews
- H27: search sanitize/1 delegates to QueryHelpers.sanitize_like/1 which
  escapes backslash first
- H30: JobCleanupTask no longer cancels jobs in "executing" state so
  in-flight polls complete naturally
2026-05-12 10:55:01 -05:00
ecec8ba845 feat(mikrotik): webhook receiver for MikroTik RouterOS events
Receives webhooks from MikroTik RouterOS when devices come online,
extracts MAC/IP data, and reconciles with Gaiia inventory.
2026-05-11 18:02:21 -05:00
01d64a446e refactor(llm): split token tracking into a dedicated llm_usage table
Token-usage doesn't belong on the per-insight row — every consumer of
Towerops.LLM (insight enrichment today; billing-anomaly summaries,
monitoring narratives, fleet weekly reports tomorrow) needs to record
into one queryable place. Per-insight columns also made it impossible
to track LLM calls that aren't tied to an insight.

Schema: `llm_usage` keyed (organization_id, day, model, purpose) with a
unique index. organization_id is nullable for system-level analyses.
Each row carries running totals plus a request_count — callers UPSERT
and increment atomically.

API: `Towerops.LLM.Usage.record/1` for write, `summarize/2` for
read-side rollups. The worker now calls `Usage.record(%{... purpose:
"insight_enrichment"})` after each successful enrichment.

Removed: `llm_prompt_tokens` / `llm_completion_tokens` from
preseem_insights — same migration drops the columns and creates
llm_usage in one go. `apply_llm_enrichment` reverts to /3 (no usage
arg).
2026-05-10 13:13:52 -05:00
e18d877695 feat(insights): persist LLM prompt + completion token counts
Track DeepSeek token spend per enriched insight so cost can be rolled
up by SQL — `SELECT date_trunc('day', llm_enriched_at), organization_id,
SUM(llm_prompt_tokens), SUM(llm_completion_tokens) FROM preseem_insights
GROUP BY 1, 2`.

- Migration adds llm_prompt_tokens + llm_completion_tokens to
  preseem_insights
- DeepSeek client surfaces the API's `usage` block (prompt/completion/
  total tokens) on the success response
- apply_llm_enrichment/4 takes a usage map and persists the two int
  columns
- Worker plumbs the usage from the response through to the context
2026-05-10 11:50:54 -05:00
f41f5fa1aa feat(insights): AP frequency-change recommendation rule
When a wireless AP has a strong same-channel neighbor (RSSI >= -75 dBm)
and a measurably cleaner alternative channel exists in the same band,
the system now emits an ap_frequency_change insight with a specific
recommended channel and the top interfering BSSIDs as evidence.

- New schema wireless_neighbor_scans (append-only) storing observed
  neighboring APs per device: bssid, ssid, channel, frequency_mhz,
  channel_width_mhz, rssi_dbm, is_own_fleet, seen_at
- New columns on snmp_devices: current_channel, current_frequency_mhz,
  current_channel_width_mhz, last_radio_seen_at — populated by vendor
  SNMP profiles for APs that expose wireless OIDs
- Towerops.Recommendations.Rules.FrequencyChange — pure rule that scores
  each candidate channel using sum-of-linear-power over the last 24h of
  neighbor scans, recommends the cleanest if it beats the current by at
  least 6 dB. Critical urgency when current score >= 100.
- Towerops.Workers.RecommendationsRunWorker — hourly Oban cron, iterates
  organizations and runs all rules, uses insert_insight_if_new/1 for
  idempotent upsert
- LiveView /insights renders a purple frequency-change card with current
  vs recommended channel, dB-cleaner badge, and a top-offender list
  (BSSID, SSID, RSSI, own-fleet flag)
- LLM enrichment automatically applies to the new insight type

Also fixes two pre-existing flaky tests that surfaced in CI:

- test/towerops_web/live/agent_live_test.exs:567 — now sets an explicit
  Req.Test transport_error stub for ReleaseChecker and uses Req.Test.allow
  so the LiveView process inherits it. Previously relied on absence of a
  stub, which leaked across tests and produced different behavior under
  full-suite parallel runs.
- test/towerops_web/telemetry_test.exs:140 — relaxes the "no log" check
  to refute the specific messages the function under test would emit,
  rather than asserting an empty capture_log/1 buffer (which catches
  stray logs from concurrent tests, e.g. Postgrex sandbox disconnects).
2026-05-09 16:56:19 -05:00
03f364a956 feat(insights): LLM-powered insight enrichment
Adds an optional plain-language summary and recommended action to every
active insight. A new Oban cron worker runs every 5 minutes, picks up
unenriched insights, and asks the configured LLM to restate the finding
and suggest one specific action grounded in the structured metadata.

- Migration: adds llm_summary, recommended_action, llm_model,
  llm_enriched_at columns to preseem_insights
- Towerops.LLM context with swappable behaviour; real client uses Req
  with the standard Req.Test plug for test isolation
- MockClient + InsightPrompt module (builds chat messages, parses JSON
  with code-fence stripping and a raw-text fallback)
- Worker logs and skips on rate limits / missing key / parse errors so
  insights still render without an AI summary when the LLM is unavailable
- Optional towerops-llm k8s secret with example template; deployment.yaml
  references it as optional in both init and main containers
- UI renders summary + recommended action callout on /insights and on
  the org-level insights page
2026-05-09 16:41:48 -05:00
ce9bd744c7 feat(coverage): LOS/NLOS dual compute + buildings clutter + MS importer
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.
2026-05-06 16:50:05 -05:00
434f5782d0 feat(coverage): multi-SM-height compute + cnHeat-style height slider
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.
2026-05-06 16:38:07 -05:00
f77c493fa3 feat(lidar): nationwide USGS NED fallback (the actual code)
Earlier commit a9aa8bf9 had the message but only the test tweak made
it through pre-commit stashing. This is the implementation:

- lib/towerops/lidar/sources/usgs_ned.ex
- lib/towerops/lidar.ex (mosaic_from_ned + get_elevation_from_ned
  fallback paths)
- lib/towerops/coverages/building.ex + migration scaffolding
- lib/towerops/workers/coverage_worker.ex error message
- test_helper.exs and the lidar test changes
2026-05-06 16:21:50 -05:00
84d4fccb29 feat: optionally attach a coverage to a device at the chosen site
After picking a site in the coverage form, a new optional dropdown
lets the user attach the coverage to one of the devices at that site.
The dropdown reloads on every site change. If the site has no devices
yet, a hint replaces it.

Schema: nullable device_id FK on coverages (on_delete: nilify_all so
deleting a device leaves the coverage standing). Filtered by both
site_id and organization_id when loading the dropdown options.

Show page lists the attached device by name (or IP) when set.
2026-05-06 14:49:32 -05:00
40db913170 feat: end-to-end /coverage compute pipeline + antenna catalog + cnHeat-style form
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.
2026-05-06 14:32:38 -05:00
5a9381f91a feat: add /coverage RF prediction feature scaffold
End-to-end CRUD scaffold for per-site, per-antenna RF coverage
prediction. Schema, context, antenna registry, Oban worker stub, and
three LiveViews are wired up; the actual ITM + LIDAR + buildings
compute pipeline lands in a follow-up.

- Migration + Coverage schema with full validation (RF params, pixel-
  budget cap, antenna_slug existence). organization_id excluded from
  cast (programmatic-only).
- Coverages context: org-scoped CRUD, validate_site_in_organization
  rejects cross-tenant site_id refs at insert/update, queue_compute
  with PubSub broadcast on per-coverage and per-org topics.
- MSI Planet .ant parser + persistent_term registry loaded at boot
  from priv/antennas/. Two synthetic .ant fixtures (omni + 90deg
  sector) bundled.
- CoverageWorker on new :coverage Oban queue (concurrency 2). Job
  args carry organization_id; worker refuses to run on org mismatch.
  Stub flips status to "failed" with "compute not yet implemented"
  so the UI flow is exercisable.
- LiveViews: index (org-wide, live status updates), form (antenna
  picker grouped by manufacturer), show (status banners, params
  panel, map placeholder). Sidebar nav link added.
- Routes /coverage, /coverage/new, /coverage/:id, /coverage/:id/edit
  under :require_authenticated_user_with_default_org.
- 41 new tests covering schema validation, .ant parsing, context
  CRUD with cross-org guards, Oban job enqueue.
2026-05-06 13:44:14 -05:00
158ee9d323 feat: add Texas LIDAR DEM catalog with on-demand elevation reads
Catalog-only system for LIDAR-derived 1m DEMs covering Texas. We do not
mirror raster data — the DB stores tile metadata (URL + footprint
geometry) and elevation values are streamed on-demand from public USGS
3DEP / TNRIS Cloud-Optimized GeoTIFFs via GDAL /vsicurl/ HTTP byte-range
reads. Used for future tower coverage line-of-sight calculations.

- Enable PostGIS extension; tile footprints indexed via GiST
- Catalog sync from USGS 3DEP (primary) and TNRIS (fallback for gaps)
- Tile dedup by ST_Contains so TNRIS doesn't duplicate 3DEP coverage
- Single-point reads via gdallocationinfo with tile fallthrough on nodata
- Grid retrieval via gdal_translate AAIGrid with multi-tile mosaicing
- Monthly Oban cron worker keeps catalog fresh
- gdal-bin added to runtime image; geo_postgis dep added
2026-05-04 13:03:28 -05:00
a99c327446 fix: apply Oban Pro 1.7 follow-up cleanup + filter transient DB noise
The 1.7 upgrade migration added the new oban_workflows tracking but left
behind legacy state from earlier versions that the upgrade guide
strongly recommends removing
(https://oban.pro/docs/pro/v1-7.html):

  - The uniq_key / partition_key GENERATED ALWAYS columns. The Smart
    engine no longer reads them; uniqueness and partition lookups now
    use expression indexes on meta. Keeping the columns imposes write
    overhead on every oban_jobs insert/update and was contributing to
    intermittent transaction-rollback errors.
  - The oban_jobs_*_old workflow/chain indexes. v1.7 renamed the
    originals and built expression-based replacements; the _old indexes
    are dead weight that still slow down writes.

Also extend ErrorTrackerIgnorer to drop transient
DBConnection.ConnectionError noise ("connection is closed",
"transaction rolling back"). These come from the Repo's intentional
queue-target / disconnect-on-error cycling that recovers from stale SSL
connections — Ecto retries automatically, so logging adds noise without
value.
2026-05-01 16:04:05 -05:00
9ca73b949a fix: rename agent_assignments unique index to match device_id column
The original equipment->device rename migration's ALTER INDEX RENAME was a
no-op (the index didn't exist by the legacy name at that point in the
migration sequence on existing databases), leaving the unique index named
agent_assignments_equipment_id_index. The schema's unique_constraint now
expects agent_assignments_device_id_index, causing a constraint violation
to surface as Ecto.ConstraintError instead of a changeset error.
2026-05-01 13:32:03 -05:00
6c05d45dd6 deps: upgrade oban_pro to 1.7.0
Bumps vendored Oban Pro from 1.6.13 to 1.7.0, which transitively bumps
oban core to 2.22.1 (requires migrations v14) and db_connection to 2.10.0.

- mix.exs constraint: ~> 1.6 → ~> 1.7
- vendor/oban_pro/ replaced with v1.7.0 hex package
- migration 20260430142200: Oban core v12 → v14 (must run first)
- migration 20260430142241: Oban Pro 1.7.0 schema additions

Also corrects a pre-existing constraint name in AgentAssignment that was
left stale by the equipment→devices rename migration (20260117190134).
The unique index is named agent_assignments_device_id_index, but the
schema's unique_constraint/3 still pointed at the old equipment_id name,
causing the test suite to surface Ecto.ConstraintError instead of a
changeset error. Required to get the suite green for this upgrade.
2026-04-30 10:26:18 -05:00
701ce12f08 perf+refactor: codebase-wide query and antipattern audit
Performance:
- schedule_live: preload page once instead of get_schedule!/1 per row
- alert_live + alerts: DB-side status filter; Repo.aggregate counts replace length/Enum.count over 500-row fetches on every event
- dashboard_live: drop duplicate get_device_status_counts; cap active alerts to 20 + use count_active_alerts/1
- maintenance.active_windows_for_device: 3 round-trips collapsed into one query (per-site-id branch keeps Postgres parameter types unambiguous)
- sites.build_site_tree: O(N^2) -> O(N) via group_by(parent_site_id)
- accounts.sole_owner_organizations: single group_by + having instead of per-org Repo.aggregate loop
- agents + agent_live (org + admin): count_assigned_devices_batch/1 + count_agent_polling_targets/1 (no preloads)
- alert_digest_worker: list_alerts_by_ids/1 batches digest fetch
- gaiia: distinct: true at DB; limit 50 on bidirectional ilike

Indexes:
- maintenance_windows(organization_id, starts_at, ends_at) WHERE suppress_alerts = true
- alerts(check_id) WHERE resolved_at IS NULL

Antipatterns:
- agents.delete_agent_token: PubSub.broadcast moved outside Repo.transaction so a rollback no longer leaves subscribers acting on a non-existent deletion
- integrations_controller.to_atom_keys: replaced String.to_existing_atom on user-controlled JSON keys with explicit allowlist
- 9 Task.start callsites converted to Task.Supervisor.start_child(Towerops.TaskSupervisor, ...) so background DB writes survive shutdown (test-mode discovery shims left as-is for sandbox semantics)
2026-04-28 16:58:51 -05:00
e822bc9986 chore: rate_limit rewrite + on-call redesign plan + handoff_notifications_mode 2026-04-28 12:17:29 -05:00
978a72ad41 db-performance-migrations (#234)
Reviewed-on: graham/towerops-web#234
2026-04-04 14:22:25 -05:00
7232ae6306 refactor/dry-improvements (#202)
Reviewed-on: graham/towerops-web#202
2026-03-28 10:56:34 -05:00
b902480361 fix: add suspended state to oban_job_state enum (#199)
Oban 2.18+ introduced the 'suspended' job state for pausing jobs.
Oban.Met.Reporter queries for jobs in this state, causing errors when
the database enum doesn't include it:

  ERROR 22P02 (invalid_text_representation) invalid input value
  for enum oban_job_state: "suspended"

This migration adds 'suspended' to the oban_job_state enum type.

Required by recent Oban dependency updates (commit 0399b30d).

Note: Uses @disable_ddl_transaction since ALTER TYPE ADD VALUE cannot
run inside a transaction in PostgreSQL.

Reviewed-on: graham/towerops-web#199
2026-03-27 18:14:20 -05:00
a9c2f8e5e5 fix/findings-medium-priority (#188)
Reviewed-on: graham/towerops-web#188
2026-03-27 12:31:53 -05:00
0bc6407c3f feature/entity-physical-inventory (#187)
Reviewed-on: graham/towerops-web#187
2026-03-27 10:48:50 -05:00
8a58c7c238 feat: implement mempools (memory pools) feature for SNMP monitoring (#184)
Add complete memory pool monitoring using HOST-RESOURCES-MIB.
Tracks RAM/swap usage with historical data and real-time updates.

- Add Mempool and MempoolReading schemas
- Integrate discovery and polling with concurrent Task.async_stream
- Add 7 context API functions for querying mempools
- 24 new tests, all 8754 tests passing (0 failures)

Implements C1 from findings_librenms.md

Reviewed-on: graham/towerops-web#184
2026-03-26 17:07:43 -05:00
6f6f1757ea fix: handle existing index in deduplicate_discovery_checks migration (#179)
- Drop index if exists before creating to handle manual creation
- Prevents duplicate_table error when index was created manually

Reviewed-on: graham/towerops-web#179
2026-03-26 11:01:53 -05:00
a9e82779e7 fix: make deduplicate_discovery_checks migration production-safe (#178)
- Add @disable_ddl_transaction and @disable_migration_lock for concurrent index
- Increase statement_timeout to 10min for DELETE operation on large tables
- Use concurrently: true on index creation to avoid table locks
- Refactor detect_airfiber_counter_overrides to fix credo nesting warning
- Prevents timeout errors in production deployment

Reviewed-on: graham/towerops-web#178
2026-03-26 10:39:09 -05:00
11f9c4450c done i think (#177)
Reviewed-on: graham/towerops-web#177
2026-03-26 10:23:26 -05:00
de455d1cf4 fix: improve bandwidth counter handling to match LibreNMS approach (#168)
- Clamp negative counter deltas to zero instead of attempting 32-bit wrap
  correction (matches LibreNMS behavior — wraps are indistinguishable from
  HC counter resets, so discard the ambiguous data point)
- Add rate sanity check: discard values exceeding 400 Gbps as counter anomalies
- Add 4-byte binary decoding for 32-bit SNMP counters in decode_snmp_value
- Track HC vs standard counter usage per interface stat (is_hc field)
- Add migration for is_hc column on snmp_interface_stats
- Update tests to match new clamping behavior

Reviewed-on: graham/towerops-web#168
2026-03-25 13:33:45 -05:00
d75eeb5389 fix: add missing foreign key indexes for performance and referential integrity (#167)
Missing indexes detected on 7 foreign key columns:
- on_call_overrides.user_id
- on_call_layer_members.user_id
- site_outages.site_id
- escalation_targets.user_id
- notification_digests.organization_id
- on_call_notifications.user_id
- alerts.site_outage_id (already existed, skipped)

Without indexes, PostgreSQL must perform sequential scans for:
- Referential integrity checks on DELETE/UPDATE of parent rows
- JOIN operations on these columns
- Can cause lock contention and slow queries

Indexes created with CONCURRENTLY option to avoid blocking production
tables during deployment.

Migration uses @disable_ddl_transaction and @disable_migration_lock
to ensure indexes are built without holding locks.

Reviewed-on: graham/towerops-web#167
2026-03-25 13:04:48 -05:00
3f0f6e7d5f feat: add per-organization data retention with 1-year default (#144)
Add data_retention_days field to organizations (default 365, range 30-730).
DataRetentionWorker runs nightly at 1 AM to purge expired time-series data
across 8 tables, respecting each org's retention setting.

TimescaleDB global retention bumped from 90 to 730 days as safety net.
Resolved alerts are cleaned up; unresolved alerts kept regardless of age.

Reviewed-on: graham/towerops-web#144
2026-03-24 15:30:43 -05:00
35b1c9e753 fix/test-failures (#112)
Reviewed-on: graham/towerops-web#112
2026-03-22 13:02:06 -05:00
7d65e9e4c5 fix: remove device_role_source from form params (#110)
- Removed device_role_source assignment in sanitize_device_role/1
- Updated structure.sql to reflect database schema without device_role_source column
- Fixes ERROR 42703 (undefined_column) in production workers

The form was still trying to set device_role_source in params even though
the database column was dropped, causing runtime errors in:
- AgentLatencyEvaluator
- CapacityInsightWorker
- WirelessInsightWorker

All device roles are now manually set with default value 'other'.

Reviewed-on: graham/towerops-web#110
2026-03-22 12:27:41 -05:00
5a6acf0d7c feat: simplify device type to manual-only with 6 options (#109)
- Reduce device type options from 10 to 6 (server, switch, router, access_point, backhaul, other)
- Change label from "Device Role" to "Device Type"
- Disable auto-detection - device type is now required and manual-only
- Add migration to map existing device roles to new simplified set
- Update REST API to include device_role and device_role_source fields
- Automatically set device_role_source to "manual" when device_role is provided
- Update wireless detection to include "backhaul" type
- Enhance display formatting for "Access Point"

Breaking Changes:
- Auto-inference of device type has been disabled
- device_role is now a required field in forms
- GraphQL/REST API consumers will see changed device_role values after migration

Files Changed:
- lib/towerops_web/live/device_live/form.html.heex
- lib/towerops/topology.ex
- lib/towerops/snmp/discovery.ex
- lib/towerops/devices/device.ex
- lib/towerops_web/live/device_live/index.ex
- lib/towerops_web/controllers/api/v1/devices_controller.ex
- priv/repo/migrations/20260322152809_migrate_device_roles_to_simplified_set.exs
- test/towerops/topology_test.exs
- test/towerops_web/live/device_live/form_test.exs

Reviewed-on: graham/towerops-web#109
2026-03-22 11:24:53 -05:00
c8a092196f add auto ICMP ping check for all devices (#68)
## Summary

- Every device now automatically gets an ICMP ping check (`source_type: "auto_discovery"`) when created via `Monitoring.ensure_default_ping_check/1`
- When a device IP changes, the ping check host config is synced automatically
- Backfill migration inserts ICMP Ping checks for all existing devices that don't have one
- Fixes checkbox alignment in the Add Service Check modal (verify SSL / follow redirects)

## Test plan

- [x] 4 new tests for `ensure_default_ping_check/1` (create, idempotent, IP update, no-op)
- [x] 3 new tests for device lifecycle (auto-create on device create, sync on IP change, no-op when IP unchanged)
- [x] 13 existing tests updated to account for auto-created ping check
- [x] Full test suite passes (`mix precommit`)
- [ ] Verify backfill migration on staging
- [ ] Create device in UI, confirm ping check appears in service checks list
- [ ] Verify checkbox alignment in Add Service Check modal

Reviewed-on: graham/towerops-web#68
2026-03-17 17:55:23 -05:00
42bfc8b9d2 paperclip/TOW-21-uisp-integration-phase1 (#31)
Reviewed-on: graham/towerops-web#31
2026-03-15 17:57:36 -05:00
acf70b3bee
fix: remove duplicate StormDetector child spec
The StormDetector was being added twice:
1. In the main children list (line 127)
2. In background_workers() (line 176)

This caused the supervisor to fail with 'duplicate_child_name' error.

Removed the duplicate from main children list since it's correctly
placed in background_workers() which also handles test env properly.
2026-03-15 16:05:30 -05:00
90af7ac0d1 feat: alert storm correlation & suppression (TOW-13)
- StormDetector GenServer: sliding-window per org, >10 alerts/min triggers
  suppression mode with single summary notification
- Site correlation: >3 devices at same site down within 120s creates
  single 'Site Outage' alert instead of N individual alerts
- Notification rate limiter: max 5 per user per 15min window, excess
  batched into digest via AlertDigestWorker
- PagerDuty client: retry with exponential backoff on 429, respects
  Retry-After header
- Batch maintenance checks: new Maintenance.devices_in_maintenance/1
  takes list of device_ids, returns MapSet (single query vs N queries)
- Migration: alert_storms, site_outages, notification_digests tables
  plus site_outage_id/storm_suppressed on alerts
- DeviceMonitorWorker integrates storm detection and site correlation
  before creating alerts
- Tests for StormDetector, SiteCorrelation, NotificationRateLimiter,
  and batch maintenance checks
2026-03-15 14:29:12 -05:00
37314bd154 Add alert routing setting (builtin/pagerduty/both)
- New 'alert_routing' field on organizations (default: builtin)
- AlertNotificationWorker respects the setting:
  - builtin: only built-in escalation policies
  - pagerduty: only PagerDuty events
  - both: fire both (useful during migration)
- Radio button UI in Settings → General with visual cards
- Warning shown when PagerDuty selected but not configured
- Migration, schema validation, 6 tests
2026-03-13 15:26:25 -05:00
0cd2ed3567
feat: add on-call schedules and escalation policies
Built-in PagerDuty-equivalent system for on-call scheduling and alert
escalation. Users can now manage schedules, rotation layers, overrides,
and escalation policies directly in the app alongside PagerDuty.

- On-call schedules with rotation layers (daily/weekly/custom), member
  management, and temporary overrides
- Escalation policies with ordered rules, timeout-based escalation,
  and user/schedule targets
- Automatic escalation via Oban worker with configurable repeat count
- Email notifications via Swoosh for on-call alerts
- Resolver computes who's on-call from layer stacking and overrides
- AlertNotificationWorker integration: starts escalation alongside
  PagerDuty, acknowledges/resolves incidents on alert state changes
- Device and organization schemas support escalation_policy_id
- Escalation policy picker on device form
- Schedules nav item with tabbed index (schedules + escalation policies)
- Full CRUD UI for schedules, layers, members, overrides, rules, targets
- 62 LiveView tests, 56 context/schema/resolver/escalation tests
- 26 E2E Playwright tests for smoke and critical path coverage
2026-03-11 12:32:54 -05:00
f09d1b296d feat: add historical wireless client tracking with TimescaleDB
- Create wireless_client_readings hypertable with compression and retention
- Add WirelessClientReading schema and batch insert function
- Update DevicePollerWorker to save historical readings
- Add signal_badge and snr_badge components to CoreComponents
2026-03-09 17:42:17 -05:00
90fb424f34
feat: create test organization in e2e seed script
After login, users without an organization are redirected to /orgs instead
of /dashboard. The e2e tests expect /dashboard, so create a test organization
for the user during seeding.

Also made the seed script idempotent by deleting existing test user before
creating a new one.

Tested locally and confirmed working.
2026-03-08 13:14:56 -05:00
97ba2b045b
fix: use correct signature for create_totp_device/3
Function signature is create_totp_device(user_id, device_name, secret) with
separate arguments, not create_totp_device(user, map).

Tested locally and confirmed working.
2026-03-08 12:42:17 -05:00
ae125f380c
fix: use create_totp_device/2 instead of create_totp_credential/2
The function is create_totp_device/2, not create_totp_credential/2.
2026-03-08 12:17:55 -05:00
9e236225b1
fix: use direct changeset to confirm test user email
Accounts.confirm_user_email/1 doesn't exist - the module only has confirm_user/1
which requires a token. For seeding, we can directly set confirmed_at timestamp
using Ecto.Changeset.change/2.
2026-03-08 11:53:47 -05:00
e7a25a5f2c
fix: add required consent fields to e2e test user registration
User registration requires terms_of_service_consent and privacy_policy_consent
to be true. The seed script was failing without these fields.
2026-03-08 11:20:14 -05:00
a102918455
feat: add e2e test user seeding for CI
Created seeds_e2e.exs to automatically create a test user with TOTP enabled
for Playwright e2e tests. This ensures tests can authenticate in CI without
requiring manual user setup.

Test credentials (for CI only):
- Email: test@example.com
- Password: TestPassword123!
- TOTP Secret: JBSWY3DPEHPK3PXP (fixed for reproducibility)

The workflow now:
1. Loads database structure
2. Seeds test user with TOTP
3. Passes credentials to e2e tests via environment variables
2026-03-08 10:57:18 -05:00
3913981094
feat: add backhaul capacity analysis foundation
Add per-interface capacity tracking with manual override and automatic
detection from SNMP sensors. Includes capacity resolver with priority
cascade (manual > sensor > if_speed), throughput calculation from
interface stats, and site/organization-level capacity summaries.

- Migration: add configured_capacity_bps and capacity_source to snmp_interfaces
- Capacity.Resolver: multi-source capacity detection with vendor unit normalization
- Capacity context: throughput calculation, utilization percentages, site/org summaries
- Snmp context: set_manual_capacity/2 and clear_manual_capacity/1 functions
2026-03-06 17:54:32 -06:00
448ed20401
feat: dynamic global billing defaults from ApplicationSettings 2026-03-06 13:44:03 -06:00