Commit graph

2498 commits

Author SHA1 Message Date
40dadededd fix(gaiia): wrap manufacturer select in form for phx-change to pick up name attr
A bare <select phx-change="..."> sends %{"value" => ...}, not the name
attribute. Wrapping in <form phx-change="..."> fixes this so the handler
receives %{"manufacturer_id" => value}.
2026-05-11 15:58:51 -05:00
8db9fb4fcd fix(gaiia): add error handling and logging for model fetch
- Handle empty manufacturer_id selection (select placeholder)
- Log and flash when model fetch fails or returns empty
- Add require Logger to reconciliation live view
2026-05-11 15:25:34 -05:00
2695e8a0eb refactor(gaiia): manufacturer/model dropdowns for inventory creation
Replaces auto-matching with explicit two-step selection:
1. Select manufacturer from Gaiia's inventory model library (dropdown)
2. Select model for that manufacturer (dropdown, filtered live)
3. Click Create

Manufacturers and models are fetched from Gaiia on demand and cached
in socket assigns. Only one device form is open at a time.
2026-05-11 15:12:52 -05:00
31231d9fad fix(insights): correct ngettext usage in auto-match flash
ngettext is a macro that already calls dngettext internally. The
previous code was calling dngettext a second time on the result string,
passing nil as the count — causing a FunctionClauseError.
2026-05-11 15:07:39 -05:00
3267bb134f feat(gaiia): create inventory items for unmatched devices
Adds `Towerops.Gaiia.InventoryCreator` which:
- Matches a device's SNMP-discovered manufacturer/model against Gaiia's
  inventory model library to find the right modelId
- Calls `startAddInventoryItemsJob` to create the item, assigned to the
  correct Gaiia network site with MAC address from SNMP interfaces

A "Create in Gaiia" button now appears next to each untracked device on
the reconciliation page.
2026-05-11 14:58:37 -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
485748d253 feat(gaiia): site-based auto-matching + automatic site mapping during sync
Two improvements to Gaiia reconciliation:

1. Auto-match now uses two-phase matching:
   - Phase 1: IP match (high confidence, same as before)
   - Phase 2: Site match — if a device is at a Towerops site linked to a
     Gaiia network site, match it to unmapped inventory assigned to that site

2. Gaiia sync now automatically maps network sites to Towerops sites by
   name during sync. When a Gaiia network site name matches a Towerops
   site name, `site_id` is set on the `gaiia_network_sites` row. This
   enables the site-based matching in phase 2.

Flash message now shows breakdown: "Auto-matched 47 devices (34 by IP,
13 by site). 17 remaining."
2026-05-11 14:17:41 -05:00
c00be779f9 chore: remove stale renovate config 2026-05-11 13:53:32 -05:00
80837b8b26 feat(gaiia): one-click auto-match untracked devices by IP
Adds `Gaiia.auto_match_untracked/1` which finds unmapped Gaiia inventory
items whose IP matches an untracked Towerops device and links them.

Button on the reconciliation page's Untracked tab runs the match
inline and shows a flash with matched/remaining counts.
2026-05-11 13:51:13 -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
e6e1326689 feat(insights): allow AI observations to link directly to devices
The LLM can now copy device_id from the snapshot into its observation
output. The worker wires it into the insight's device_id FK so the UI
can link directly to the referenced device.
2026-05-11 10:40:59 -05:00
f0058a4d77 feat(insights): feed AP radio/frequency/interference data to AI network insight
Adds a wireless section to the NetworkSnapshot with per-AP radio state
(channel, frequency, channel width, noise floor, RF/QoE scores), per-channel
occupancy (own-fleet + foreign neighbor counts), and APs with the worst
foreign interference. Updates the system prompt to describe this data and
removes device_id from the expected output schema — the LLM now references
devices by name.
2026-05-11 10:35:35 -05:00
9a10e01a24 fix(llm): switch to deepseek-chat (standard model, no reasoning overhead)
deepseek-v4-flash is still a reasoning variant and burns tokens on
chain-of-thought before producing content. deepseek-chat produces output
directly — 82 tokens all went to content in testing, vs 85-102 tokens
that produced empty observations on flash.
2026-05-11 10:23:18 -05:00
4bca41a700 fix(insights): add info logging to AI network insight worker
Info-level logs for: org count at start, observation count per org,
successful persistence, and dedup. Makes the worker observable without
needing database access.
2026-05-11 10:11:16 -05:00
4d91e691ad fix(llm): switch default model to deepseek-v4-flash
deepseek-v4-pro is a reasoning model that burns tokens on chain-of-thought
before producing content. The flash variant is faster, cheaper, and doesn't
starve the content output — a better fit for summarization and observation
generation tasks.
2026-05-11 09:53:37 -05:00
4cfb97a741 fix(llm): handle reasoning models, bump token limits to 20k
deepseek-v4-pro is a reasoning model that burns tokens on
reasoning_content before producing content. When max_tokens was too low
(400/1500), all tokens went to reasoning and content was empty.

- parse_response now falls back to reasoning_content when content is blank
- max_tokens raised to 20_000 across all call sites and default
- improved parse logging shows raw/cleaned byte sizes
2026-05-11 09:49:30 -05:00
d90d24198c chore(deps): update absinthe, honeybadger, phoenix, swoosh
absinthe 1.10.1 → 1.10.2
honeybadger 0.26.0 → 0.27.0
phoenix 1.8.6 → 1.8.7
swoosh 1.25.1 → 1.25.2
+ transitive: decimal 3.1.0, mint 1.8.0, telemetry 1.4.2
2026-05-11 09:36:45 -05:00
fc60e4ece8 fix(llm): increase receive_timeout to 120s and allow per-call override
The network insight prompt is large and max_tokens is 1500 — DeepSeek
takes longer than 30s. Also plumb opts[:receive_timeout] through so
workers can tune it.
2026-05-11 09:30:15 -05:00
a05907b76d fix(insights): add logging to diagnose why AI network observations never persist
The parse function silently returned {:ok, []} on any decode failure or
observation rejection. Log warnings for both cases so we can see what the
LLM is actually returning.
2026-05-11 09:18:09 -05:00
78f7745c3c e2e tests 2026-05-10 17:53: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
36edf54e7c fix(insights): don't hold DB transaction across LLM API calls
The Repo.transaction wrapping Repo.stream() held a checkout for the
entire iteration, including the LLM HTTP call. When the LLM took >15s,
Postgrex killed the connection, cascading to all subsequent orgs.
Collect org IDs first, then process each independently.
2026-05-10 16:54:01 -05:00
a8f119261e fix(insights): surface all LLM/insight errors instead of silently swallowing them
- Regenerate button now logs Oban.insert failures and shows per-worker counts
- AI network insight worker: log errors for LLM failures, insert failures,
  Usage.record failures, and transaction failures (was discarding all)
- Enrichment worker: handle apply_llm_enrichment errors instead of crashing,
  log LLM and Usage.record failures
- Wire up build_gaiia in network snapshot to actually query reconciliation
  finding counts instead of hardcoding zeros
2026-05-10 15:31:26 -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
f8c3b00ba0 refactor: move temperature backfill to a release-safe module
Mix tasks aren't usable from the prod release image — `/app/bin/towerops`
ships without `mix`, and `Mix.shell()` raises at runtime. Move the work
to `Towerops.Maintenance.FixMikrotikTemperatureScaling` (plain module
with `run/0`, IO.puts + Logger.info) and keep the Mix.Task as a thin
dev-only delegator.

Run in prod via:

    /app/bin/towerops eval 'Towerops.Maintenance.FixMikrotikTemperatureScaling.run()'
2026-05-10 12:50:35 -05:00
d051968ae6 fix(insights): render acronym sources uppercase (AI, SNMP)
The CSS `capitalize` class only capitalises the first letter, so the
source filter pills and per-card badges showed "Ai" and "Snmp". Switch
to a `source_label/1` helper that returns "AI" / "SNMP" verbatim and
falls back to `String.capitalize/1` for the rest (Preseem, Gaiia,
System).
2026-05-10 12:41:57 -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
c9202744a5 fix(insights): UI contrast + DeepSeek empty-string env-var fallback
UI:
- Type-specific evidence cards (red/amber/orange/rose/purple/cyan/
  indigo/blue) had `dark:text-{color}-300` labels and
  `dark:bg-{color}-800 dark:text-{color}-100` badges that washed out on
  the dark background. Bumped 31 label shades to -200 and switched the
  three problem badges (Above manufacturer spec, Investigate upstream
  first, own fleet, crit) to solid -600 with white text.
- upstream_degradation card got brighter device-link text and a
  hover state in dark mode.

DeepSeek:
- Empty-string DEEPSEEK_MODEL / DEEPSEEK_BASE_URL env vars were not
  treated as missing — `||` only short-circuits on nil/false — so a
  blank secret would override the @default_model and the API would
  reject `model: ""`. Both runtime.exs and the client itself now treat
  blank values as missing.
2026-05-10 12:07:53 -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
8f45057ba5 chore(cron): move recommendation + LLM enrichment to overnight
DeepSeek API spend isn't justified by minute-by-minute freshness when
ops review insights in the morning. Run rules at 02:00 UTC and the
enrichment pass at 03:00 UTC so summaries are ready by 9 AM local.

- RecommendationsRunWorker: hourly → 02:00 UTC daily
- InsightLlmEnrichmentWorker: every 5 min → 03:00 UTC daily
2026-05-10 10:46:47 -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
6a8ee862bd chore(k8s): align secrets template with prod secret schema
The towerops-db secret in prod has both DATABASE_URL and individual
POSTGRES_* fields; towerops-redis uses HOST/PORT/PASSWORD instead of
a single REDIS_URL. Update the committed template to match so a fresh
copy of k8s/secrets.example.yaml mirrors what's actually in the
cluster.
2026-05-09 18:08:52 -05:00
32c9575952 chore(k8s): unify secrets template into one k8s/secrets.example.yaml
Replace the single-secret towerops-llm template with one
multi-document YAML covering every Secret the Deployment references:
towerops-secrets, towerops-db, towerops-aws, towerops-redis,
towerops-billing, towerops-llm.

Workflow: copy k8s/secrets.example.yaml to k8s/secrets.yaml (which is
gitignored), fill in real values, kubectl apply. Real values never land
in git.
2026-05-09 18:01:09 -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