Commit graph

696 commits

Author SHA1 Message Date
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
52cde2cbc5 fix: H21 — stagger per-integration sync jobs across maintenance queue
Sonar, Splynx, VISP, NetBox, and Gaiia sync workers all followed the same
"cron runs, then Enum.map syncs every integration in the same job" shape.
A single slow tenant — anything from an upstream rate limit to a stuck
GraphQL call — would hold the maintenance queue slot for ten minutes
while every other integration waited in line.

Each worker now uses the dispatcher/per-integration pattern that
UispSyncWorker already had:

- the cron `perform(%Oban.Job{args: %{}})` fans out one job per eligible
  integration via `PollingOffset.calculate_offset/2`, staggered across a
  5- to 10-minute window with `unique: [period: window_seconds]`
- `perform(%Oban.Job{args: %{"integration_id" => id}})` does the actual
  sync, classifying failures via `Towerops.Workers.SyncErrors.transient?/1`
  so permanent errors (auth, 4xx) don't get retried forever

Worker-specific defaults preserved:
- gaiia / netbox: 30/600s window, longer interval
- sonar / splynx / visp: 10/300s window

Tests updated to match the two-stage dispatcher pattern — `perform_job/2`
with empty args now asserts the per-integration jobs got enqueued and
the actual sync is invoked via a separate `perform_job/2` call with the
integration_id arg.
2026-05-12 12:12:11 -05:00
8379664cb0 fix: 3 more bugs (M17, M23, L9)
- M17: default SNMPv3 auth protocol on the four SNMP executors
  (interface/storage/processor/sensor) is now SHA-256 instead of MD5.
  Devices that explicitly set MD5 still use it; only the fallback for
  unconfigured devices changes.
- M23: permissions check now logs a warning when an org's memberships
  aren't preloaded so a silent denial is visible at the call site
  instead of looking identical to a real authz failure.
- L9: extracted the duplicated should_sync?/1 logic from gaiia, sonar,
  splynx, visp, and netbox sync workers into
  Towerops.Integrations.due_for_sync?/2. Each worker now passes its
  own default interval (10 or 30 minutes) to a single shared helper.
  Tests updated to cover the helper directly.
2026-05-12 11:45:22 -05:00
abfd44fd75 fix: 3 more high-severity bugs (H19, H20, H25)
- H19: /api/v1/mobile/auth/qr/verify no longer returns user_email. Knowing
  a QR token now only tells the caller the token is valid; the email is
  only revealed by /complete which consumes the token.
- H20: CoverageWorker max_attempts dropped from 3 to 1. The fail/2 path
  already returns :ok and writes the failure onto the coverage record,
  so the 3-attempt retry policy was never reachable and was misleading.
- H25: ChecksController.create rejects device_ids that don't belong to
  the caller's organization (via ScopedResource.fetch). Without this an
  API token could attach service checks to devices in another tenant.

Also strip bug ID references from in-code comments (per feedback that
H/M/L numbers don't survive past their bugs.md removal); commit history
keeps the audit trail.
2026-05-12 11:32:20 -05:00
97232117f5 fix: H12 cookie hardening + 5 low/medium bugs (L2, L5, L6, L8, L10, L11)
- 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).
2026-05-12 11:22:47 -05:00
810dafbf5e remove all fixed critical items from bugs.md, fix flaky MIB cache test 2026-05-12 10:35:52 -05:00
ca7bb75472 fix: 11 critical security/correctness bugs from code audit
- C1: Replace innerHTML template literals with DOM textContent in sites_map.ts
- C2: Derive user_id from current_scope instead of client params in policy consent
- C3: Fix broken halt() in GDPR data export (orphaned _ = ... halt())
- C4: Add owner/admin authorization check to MembersController update/delete
- C5: Require HMAC signature on agent release webhook (was optional)
- C6: Fix Repo.all_by/2 (not a real Ecto function) → Repo.all with query
- C7: Move auth exemption to before_send callback in BruteForceProtection
- C8: Block </style>/<script injection in status page custom_css validation
- C9: Create secrets.example.yaml with placeholders; secrets.yaml already gitignored
- C10: Load Stripe key from STRIPE_SECRET_KEY env var instead of hardcoded value
- C13: Remove credentials from PubSub backup broadcast; channel resolves them

C11 (no force_ssl): by design — Cloudflared terminates TLS
C12 (device quota race): false positive — FOR UPDATE serializes correctly
2026-05-12 10:20:52 -05:00
39e588c686 fix: API token access control, admin form crash, MIB validation, CSP dedup
- Add organization membership check before API token creation
- Fix admin security allowlist form reading current_user instead of current_scope.user
- Use recursive File.ls instead of Path.wildcard to include hidden files in MIB validation
- Add upload size check before ZIP extraction in MIB controller
- Centralize CSP in SecurityHeaders plug with per-request nonces instead of 'unsafe-inline'
2026-05-12 09:08:57 -05:00
8338d0af1e fix: remaining bugs.md findings — Repo calls, process dict, CSS, rate limits, Oban, debug route
- Extract Repo calls from LiveViews into Agents.get_agent_token_for_org/2
  and Accounts.verify_new_totp_device/2 context functions
- Remove Process dictionary usage for cookie consent; use explicit assigns
- Reject url() references in status page custom CSS changeset
- Add per-organization rate limit check alongside per-IP
- Document Oban flush as emergency-only with audit logging
- Gate /admin/headers behind dev_routes (debug endpoint)
2026-05-11 19:34:18 -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
1f9fe2ee29 chore: add gaiia library dep, remove stale honeybadger/tidewave/cbor
- Add vendored Gaiia library (path: vendor/gaiia) with auto-generated GraphQL
  queries and mutations from the Gaiia API schema
- Add startAddInventoryItemsJob + updateInventoryItem mutations
- Remove `:cbor` (was unused — no references anywhere)
- Remove stale Honeybadger filter module, tests, router plug, and config
- Remove stale Tidewave plug from endpoint (dev-only, dep already gone)
- Unlock leftover deps from lock file
2026-05-11 14:46:27 -05:00
6f46915eeb fix(insights): raise device overheating thresholds for outdoor equipment
Warning: 65°C → 80°C, Critical: 75°C → 90°C. Outdoor gear in Texas
routinely runs at 70-75°C ambient. The old thresholds generated useless
AI insights like "Verona to Altoga at 72°C may be overheating" — that's
normal for an enclosure in summer, not actionable.
2026-05-11 12:13:09 -05:00
3b0961dfc0 refactor(webhooks): enqueue Oban jobs immediately, ACK fast
All 4 webhook controllers now verify the signature, insert an Oban job,
and return immediately. Previously, Stripe and PagerDuty did DB queries +
writes inline; Gaiia ran upserts; Agent Release broadcast to all connected
WebSockets — all in the request path.

New workers:
- StripeWebhookWorker — delegates to WebhookProcessor.process/1
- GaiiaWebhookWorker — delegates to Webhooks.process_event/3
- PagerdutyWebhookWorker — resolve/acknowledge alerts by dedup key
- AgentReleaseWebhookWorker — broadcast mass update to agents

Also fix: log error when resolve_alert fails in agent_channel (was silently
discarded, causing "Resolving stuck device_down alert" to repeat in logs).
2026-05-10 17:18:23 -05:00
fd235d05b7 feat(insights): AI surfaces its own observations from full network state
Adds a parallel LLM path that reviews the whole org snapshot — Preseem
APs, SNMP signals, backhaul, agents, and the insight types the rules
already covered — and emits novel observations as ai_observation
insights (source=ai). Existing rule-based insights and per-insight
enrichment continue unchanged.

- lib/towerops/llm/network_snapshot.ex builds the bounded snapshot
- lib/towerops/llm/network_insight_prompt.ex builds the prompt and
  parses the LLM's JSON observations array
- lib/towerops/workers/ai_network_insight_worker.ex runs nightly at
  03:30 UTC (after the rule pass + per-insight enrichment) and on
  the superuser regenerate button
- preseem/insight.ex extends @valid_types with "ai_observation" and
  @valid_sources with "ai"
- insights_live/index.ex includes the new worker in the regen list
  and adds an "AI" source badge style

Bumps the SnmpKit Manager timeout-options test threshold from 500ms
to 3000ms (was flaking on loaded dev machines), and scopes
list_unenriched_insights/1 assertion in insights_test:573 to the
test's org so cross-suite leaks don't fail the count.
2026-05-10 14:29:56 -05:00
6338556945 fix(snmp): scale agent-polled temperatures + quiet two info logs
- ToweropsWeb.AgentChannel now runs sensor readings through
  Towerops.Snmp.SensorScale.normalize/2 in both resolve_sensor_value/2
  and the GraphQL publisher. Without this, MikroTik mtxrHl* deci-degree
  readings (e.g. Culleoka reporting 294 -> 29.4°C) were stored and
  alerted on at 294°C. The DevicePollerWorker path already did this;
  only the agent path was missing it.
- Demote "Received SNMP result from agent" and "Processing polling
  result for X" from info to debug — they fire every poll cycle on
  every device and were dominating prod logs.
2026-05-10 14:00:18 -05:00
bdfb20efdf feat(insights): superuser regenerate button + dialyzer cleanup
- /insights now shows a "Regenerate insights" button for superusers
  that enqueues all seven insight workers (Preseem baselines,
  capacity, device-health, Gaiia, system, wireless, LLM enrichment).
  Non-superusers neither see the button nor can trigger the event.
- Dialyzer is clean: added :tools to plt_add_apps, gave
  Preseem.Insight a @type t, pinned a few unmatched returns, and
  suppressed a known MapSet opaque false-positive in Towerops.Unused.
- e2e: NODE_OPTIONS=--disable-warning=DEP0205 silences the Node 25
  deprecation noise from Playwright's internal ESM loader.
2026-05-10 13:41:25 -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
fd3c9691f2 feat(insights): auto-resolve stale insights after 7 days
If a rule keeps re-firing the underlying condition, the dedup query
suppresses duplicate inserts but `inserted_at` of the live row stays
old — so a real 6-month-old issue and a one-off blip both linger
forever. Auto-resolving any active insight older than 7 days clears
the dashboard. Still-real issues will be re-created by the next rule
pass (dedup only checks `status: "active"`), so the operator sees
fresh evidence rather than stale text.

- Towerops.Workers.InsightExpiryWorker (queue: :maintenance) flips
  `status` to "resolved" on rows where `inserted_at < now - 7d`.
- Window is configurable via `:max_age_days` for ops who want a
  different cadence per env.
- Oban cron: 04:00 UTC nightly, after the 02:00 rule pass and 03:00
  LLM enrichment.
2026-05-10 13:06:35 -05:00
dbd4183f37 fix(llm): drop model name from footer, harden JSON parser
UI: the AI-generated footer dropped " · deepseek-v4-pro" — model
identity is irrelevant to operators reviewing the recommendation.

Parser: stop letting raw JSON leak into the summary text when the LLM
returns an almost-but-not-quite-valid response.

- Strip ```json fences (already done) PLUS try a balanced-brace
  extraction so a "Here's the JSON: {…}" preamble doesn't break
  Jason.decode
- When all decode paths fail, regex-extract `"summary"` and `"action"`
  fields from the text rather than dumping the whole blob with braces
  intact
- Last-ditch sanitiser strips remaining brace/quote noise so the
  rendered summary never shows literal `"summary"` keys

Maintenance: `Towerops.Maintenance.RepairLlmSummaries.run()` clears
`llm_enriched_at` on already-stored rows whose summaries contain JSON
artifacts so the next worker cycle re-enriches them with the new
parser.

Test: insights_live_events_test now asserts the AI-generated label is
present *and* that the model name does not leak into the rendered HTML.
2026-05-10 13:03:25 -05:00
c545672efe fix(snmp): auto-rescale implausible temperature readings
Some MikroTik models report `mtxrGaugeValue` (mtxrGaugeUnit=1, °C) in
deci-degrees, while others — like the CRS317 in upstream test data —
return whole degrees. The MIB doesn't disclose which, so the YAML
profile can't pick one literal divisor. Result: a sensor that should
read 56.92 °C was being stored and alerted on as 5,692 °C.

- Towerops.Snmp.SensorScale.normalize/2: divide-by-10 a temperature
  reading until it falls below 150 °C, capped at 3 iterations. Other
  sensor types pass through untouched.
- DevicePollerWorker.poll_simple_sensor wires the normaliser in after
  the YAML divisor is applied, so live polling stops storing bogus
  values immediately.
- DeviceOverheating rule now bounds the query at 150 °C as defense in
  depth so a future scaling glitch never produces a 5,000 °C alert.
- mix towerops.fix_mikrotik_temperature_scaling backfills any rows
  already in the DB by re-running normalize/2 over them.
- routeros.yaml profile gets a comment pointing at the runtime
  normaliser instead of a wrong unconditional divisor.
2026-05-10 12:21:24 -05:00
f6cd35d2d3 feat(insights): add 'AI' source filter for LLM-enriched insights
Adds an "ai" pill to the source-filter strip on /insights and a small
blue "AI" badge on every card whose `llm_enriched_at` is set. Selecting
the pill returns every LLM-enriched insight regardless of underlying
source (preseem / snmp / gaiia / system).

`source: "ai"` is a virtual filter — `list_insights/2` translates it to
`WHERE llm_enriched_at IS NOT NULL` rather than equality on the source
column, so existing real source semantics stay intact.
2026-05-10 12:11:45 -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
67d8be6b89 test: in-process Redis fake instead of skipping Redis-backed tests
Replaces the previous :requires_redis skip with a real test double so
the suite stays self-contained — no external Redis service needed in
CI, but the call paths through RedisHealthCheck and FourOhFourTracker
still get exercised end-to-end.

- Towerops.Redix.Fake: GenServer that speaks PING / SADD / SCARD /
  SISMEMBER / SMEMBERS / EXPIRE / DEL / FLUSHDB. start_link refuses
  anything other than localhost:6379 so failure-path tests still fail
  the way they did against real Redix.
- Application supervisor starts the fake under name Towerops.Redix in
  test env (instead of the dummy Agent stub).
- RedisHealthCheck and FourOhFourTracker now route Redix calls through
  Application.get_env(:towerops, :redix_module, Redix). Test config
  points it at the fake.
- Drops the test_helper :requires_redis exclusion and the @moduletag
  on the two test files; FourOhFourTrackerTest setup just resets the
  fake between runs.
2026-05-10 11:32:50 -05:00
670516f641 test: skip Redis-dependent tests in CI when Redis isn't reachable
The Forgejo CI test gate has no Redis service, so FourOhFourTracker
and RedisHealthCheck tests fail with Redix.ConnectionError. They've
always been broken in CI but the failures got noticed now.

Mirrors the existing :requires_gdal pattern: probe localhost:6379 in
test_helper.exs and add :requires_redis to extra_excludes when nothing
answers. Locally (where direnv runs Redis) the tag isn't excluded so
the tests still run.
2026-05-10 11:14:47 -05:00
4937570c2a feat(devices): auto-resolve active alerts when monitoring is disabled
When a user flips monitoring_enabled true → false on a device, any
unresolved alerts on that device are now resolved silently — the
operator has deliberately stopped monitoring it, so leaving stale
"device down" pages around is noise.

- Alerts.resolve_active_alerts_for_device/1: bulk-resolves via
  resolve_alert_silent so PagerDuty/email don't fire
- Devices.handle_monitoring_changes hooks it on the disable transition
2026-05-10 11:04:55 -05:00
b1c803e1d2 test: cut runtime on slow tests, fix time-sensitive flake
Performance:
- HealthController: honor `:timeout` from `:redis` config (was hardcoded
  2s) so the 503-when-unreachable test exits immediately
- DevicePollerBranches: drop `collect_events` window 1500ms → 100ms
  (PubSub broadcasts are synchronous from `run_perform`)
- Towerops.Unused: cache xref analysis in `:persistent_term` keyed by
  BEAM mtimes so repeated `mix unused` invocations skip re-analysis
- DiscoveryWorker: make wait_loop interval configurable via
  `:discovery_wait_interval_ms` (25ms in tests vs 500ms in prod)
- ProfileWatcher: configurable `:profile_reloader` so tests can stub
  the YAML reload instead of paying the full disk-load cost
- PingExecutor: tag the loopback test `:network` (it shells out to
  `ping`); coverage already provided by 4 other `:network`-tagged tests

Flake fix:
- FrequencyChange tests used hardcoded `~U[2026-05-09 12:00:00Z]`,
  which now falls outside the rule's 24h lookback window. Switched to
  `DateTime.utc_now()` so scans stay inside the window regardless of
  clock progression.
2026-05-10 10:39:36 -05:00
b6513ec301 feat(insights): OpticalRxLow rule for fiber transceivers
Fiber operators care a lot about optical Rx power because connectors
get dirty, splices degrade, and patch panels develop bend-radius
problems. The data is already in snmp_transceiver_readings — this
rule turns it into actionable insights.

- Towerops.Recommendations.Rules.OpticalRxLow — single query selects
  the latest reading per transceiver (last hour), filters to those
  with rx_power_dbm <= -28, joins back to the device. Critical at
  <= -32 dBm. One insight per (device, port) via metadata.dedup_key.
- Insight.@valid_types extended with optical_rx_low.
- LLM prompt hint walks the operator through the standard ladder:
  clean SC/LC connector with a one-click cleaner, inspect bend
  radius, verify splices, then replace the transceiver.
- UI: cyan card with port, Rx power, Tx power, transceiver type, and
  wavelength.
- Wired into RecommendationsRunWorker.

8 new tests.
2026-05-09 17:56:38 -05:00
4804dfea5b feat(insights): UpstreamDegradation multi-AP correlation rule
When 50%+ of the access points at one site simultaneously have
Preseem QoE score <60, root cause is almost always a shared upstream
problem (backhaul, transit, DNS, DHCP) — not per-AP RF. This rule
emits one site-level insight that points the operator at the right
investigation layer instead of letting them chase per-AP RF
symptoms across many APs at the same site.

- Towerops.Recommendations.Rules.UpstreamDegradation — single query
  pulls all matched Preseem APs grouped by site_id. Critical when
  100% of APs at the site are degraded; warning otherwise. Lists
  every degraded AP with its QoE score in metadata.
- Insight.@valid_types extended with upstream_degradation.
- LLM prompt hint tells the model to instruct the operator to
  investigate upstream FIRST and to NOT chase per-AP RF symptoms.
- UI: indigo card with degraded/total AP counts, an "Investigate
  upstream first" badge, and a clickable list of affected APs.
- Wired into RecommendationsRunWorker FIRST in the @rules list so
  the upstream signal surfaces before per-AP RF rules.

Also fixes two flakes:
- device_live/show_test.exs is now async: false. It inserts to
  InterfaceStat which is a TimescaleDB hypertable; concurrent inserts
  deadlock on chunk-table locks under parallel SQL-sandbox tests
  (ERROR 40P01 deadlock_detected in setup).
- Cleared an unused-variable warning in device_overheating_test.exs.

8 new tests for UpstreamDegradation.
2026-05-09 17:51:41 -05:00
5eb1acf9d4 feat(insights): device-overheating recommendation
Operators frequently misdiagnose thermal throttling as RF problems
and waste truck rolls. This rule reads the hottest temperature
sensor per device and, when the device is linked to a Preseem AP
with degraded QoE, makes the thermal-correlation explicit so the
fix is ventilation/shading rather than antenna alignment.

- Towerops.Recommendations.Rules.DeviceOverheating — single query
  pulls the hottest temperature sensor per device above 65 °C; one
  insight per device. Critical at 75 °C+. Includes the linked
  Preseem qoe_score in metadata when present.
- Insight.@valid_types extended with device_overheating.
- LLM prompt hint tells the model to recommend ventilation/fan/
  shading inspection BEFORE any RF action when QoE is also low.
- UI: red card with sensor name, temperature, linked QoE score,
  and an "Above manufacturer spec" badge for >=75 °C.

Wired into RecommendationsRunWorker. 8 new tests.
2026-05-09 17:40:22 -05:00
727a743c11 feat(llm): per-insight-type prompt hints + flake fixes
Generic prompts produce generic advice. This commit adds per-type
expert hints appended to the base system prompt, listing the metadata
field names the rule provides plus short domain heuristics so the
LLM has the context it needs to write a tight, specific recommendation.

Hints cover: ap_frequency_change, own_fleet_channel_conflict,
sector_overload, cpe_realign, wireless_signal_weak, wireless_snr_low,
backhaul_over_capacity, backhaul_near_capacity, qoe_degradation,
capacity_saturation. Unknown types fall back to the base prompt.

No change to rules, schema, or worker — this lifts every existing
insight's enrichment quality on the next cron run.

Also fixes two recurring flaky tests:

- agent_live_test.exs:567 now mirrors the companion show_test.exs
  pattern of accepting any of the valid update-flow flashes (Failed
  / Update command sent / No binary available). The previous
  Req.Test stub-and-allow approach was itself flaky because both
  test files stub the same module name and ownership tracking can
  leak under full-suite parallelism.
- telemetry_test.exs is now async: false. Its handle_endpoint_stop
  describe block mutates global Logger.level which races with other
  async tests; running this single module sync removes the race
  without sacrificing the per-test setup/restore pattern.
2026-05-09 17:36:07 -05:00
b735adc067 feat(insights): RF-aware channel-conflict — multi-source noise + Preseem
Same-channel conflicts at one site are not all equal. A pair of APs
sharing a channel in a clean RF environment is a planning issue;
the same pair in a contaminated environment is an outage waiting to
happen. This commit makes the rule reason across sources before
deciding urgency.

- Towerops.Snmp.RfHealth — cross-source helper that combines:
  * Most recent noise-floor / noise sensor on the AP (dBm)
  * Preseem rf_score and qoe_score from the linked AccessPoint row
  * Average CPE SNR and client count from wireless_clients
  Returns a snapshot map and an interference_severity/1 classifier
  (:critical | :warning | :ok). Critical when noise floor >= -80 dBm
  OR Preseem rf_score < 4.0; warning when avg CPE SNR < 15 with
  active clients.
- OwnFleetChannelConflict now enriches each conflicting AP with its
  noise floor, rf_score, qoe_score, avg CPE SNR, CPE count, and a
  per-AP rf_severity. Urgency promotes to critical when 3+ APs share
  a channel OR any conflicting AP shows critical RF health.
- UI: amber card now renders an evidence table with width, noise,
  rf_score, CPE SNR, and CPE count per AP, plus a "Critical RF
  environment detected" badge when has_critical_rf is true.

Also strengthens the previous flake fix on agent_live_test.exs:567
by switching to Req.Test shared mode (the test is sync), so the
ReleaseChecker stub is visible to the LiveView process regardless of
when it spawns relative to setup. The earlier Req.Test.allow approach
relied on stub timing that didn't always hold under full-suite load.

11 new tests across RfHealth + the enriched rule. LLM enrichment
automatically picks up the richer metadata via the existing Phase 1
worker.
2026-05-09 17:25:26 -05:00
18f62022f5 feat(snmp): canonical AP radio state + own-fleet channel conflict rule
Adds a vendor-agnostic radio-state extractor that lets the frequency-
aware rules actually fire on production data without per-vendor
plumbing for each profile.

- Towerops.Snmp.RadioState — pure module that maps a frequency in MHz
  to the corresponding 802.11 channel across 2.4 GHz, 5 GHz, and 6 GHz
  bands; reads the freshest "frequency" sensor (already emitted by
  Cambium/MikroTik/AirOS/Mimosa/Exalt/etc. profiles) and persists the
  AP-level current_frequency_mhz, current_channel, last_radio_seen_at
  fields onto snmp_devices.
- DevicePollerWorker now calls RadioState.update_from_sensors/1 after
  each sensor poll so frequency-aware rules read a single canonical
  source instead of joining sensors at query time.
- Towerops.Recommendations.Rules.OwnFleetChannelConflict — new rule
  that needs no neighbor-scan collection: detects when 2+ of our own
  APs at the same site share a channel (self-interference). One
  insight per (site, channel) with a clickable list of conflicting
  APs and their channel widths. Critical when 3+ APs share a channel.
- Insight.@valid_types extended with own_fleet_channel_conflict.
- UI: amber card showing channel, frequency, AP count, and the linked
  list of conflicting devices.

Wired into the existing hourly RecommendationsRunWorker. LLM
enrichment automatically applies via the Phase 1 worker.
2026-05-09 17:14:48 -05:00
91f23416e4 feat(insights): SectorOverload + CpeRealign recommendation rules
Two new multi-source rules using data we already collect, both wired
into the existing hourly RecommendationsRunWorker.

SectorOverload — fires when a Preseem-monitored AP has <25% free
airtime AND active subscribers. Critical urgency when free airtime
drops below 15%, OR when free airtime <25% AND QoE <50. Reuses the
airtime/subscriber/QoE data already pulled by PreseemSyncWorker so
no new collection is needed. UI: orange evidence card with free
airtime, subscriber count, QoE score, and AP model.

CpeRealign — fires when a wireless client has BOTH signal_strength
<=-78 dBm AND SNR <=18 dB seen in the last 2 hours. This is the
classic alignment / obstruction signature, distinct from the
existing single-metric wireless_signal_weak / wireless_snr_low
alerts produced by WirelessInsightWorker. One insight per (AP, CPE)
pair via a metadata.dedup_key. Critical when signal <=-88 OR SNR
<=10. UI: rose evidence card with signal, SNR, TX/RX rate, distance,
hostname.

Insights.insert_insight_if_new/1 dedup logic now prefers a
metadata.dedup_key over device_id when explicitly set, allowing
per-CPE insights without collapsing multiple CPEs on the same AP
into a single insight. Existing rules without dedup_key are
unaffected.

Insight.@valid_types extended with sector_overload and cpe_realign.

LLM enrichment automatically applies to both new types via the
existing Phase 1 worker — no extra wiring.

Also adds k8s/secrets.yaml to .gitignore so operators can drop a
local Secret manifest with real values, kubectl apply manually, and
never accidentally commit it. Documented in k8s/README.md.
2026-05-09 17:07:15 -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
5893a4398e update 2026-05-09 15:24:00 -05:00
ee76c568d1 test: GraphQL.Resolvers.Schedule and Device coverage 2026-05-09 14:16:21 -05:00
b71b1615eb test: GraphQL.Resolvers.Schedule queries, mutations, and auth branches 2026-05-09 14:06:17 -05:00
b995954795 test: Topology.Lldp v3 credentials, mgmt addresses, per-walk error fallbacks 2026-05-09 13:40:58 -05:00
20413d2050 test(agent_channel): cover assignment-disabled, missing-check, wrong-org branches 2026-05-09 13:38:35 -05:00
c7479292d4 test: stabilize DeviceLive.Index health-color tests; drop deadlock-prone latency cases 2026-05-09 13:09:00 -05:00
7eb63a0176 test: GaiiaWebhookController auth/signature error branches 2026-05-09 13:07:41 -05:00
6481801447 test: Uisp.ConfigSnapshot.sync_configs success and error branches 2026-05-09 12:51:11 -05:00
dc72689aa9 test: DataLoaders DB-backed paths for agent_info, snmp_data, neighbor lookups 2026-05-09 12:49:53 -05:00
50d3b43c4f test: BruteForceProtection plug skip branches for auth and socket paths 2026-05-09 12:47:48 -05:00
429ca86f5d test: Org.SettingsLive billing tab paid plan and past-due rendering 2026-05-09 12:45:26 -05:00
514dac9ba9 test: Profiles.Base ENTITY-SENSOR-MIB type/unit/status mapping 2026-05-09 12:44:41 -05:00
e5c6b32f32 test: CloudflareClient unblock error + transport-failure branches 2026-05-09 12:43:39 -05:00
29c4bf5582 test: CoverageLive.Map probe_point malformed input + coverage_status broadcast 2026-05-09 12:41:15 -05:00
08cf3a67ff test: DevicePollerWorker error branches and state-change broadcasting 2026-05-09 12:39:26 -05:00