feat: add device list health indicators

- Health status dot (green/yellow/red/gray) from worst-case check state
- Relative 'last seen' with color-coded staleness thresholds
- Response time badge from latest monitoring check
- Subscriber count badge from Gaiia inventory items
- Batch queries to avoid N+1: Monitoring.get_device_health_summary/1,
  Monitoring.get_device_latest_response_times/1,
  Gaiia.get_device_subscriber_counts/1
This commit is contained in:
Graham McIntire 2026-02-13 18:14:29 -06:00
parent 5a3ede1f71
commit c512f30e5a
14 changed files with 1521 additions and 13 deletions

View file

@ -1,4 +1,13 @@
2026-02-13
feat: device list health indicators
- Added health status dot (green/yellow/red/gray) per device row from worst-case check state
- Added relative "last seen" indicator with color-coded staleness
- Added response time badge from latest monitoring check
- Added subscriber count badge from Gaiia inventory items
- New batch queries: Monitoring.get_device_health_summary/1,
Monitoring.get_device_latest_response_times/1, Gaiia.get_device_subscriber_counts/1
- All queries batched to avoid N+1 on device list page
feat: config change → performance correlation system (Roadmap #2)
- New `config_change_events` table + migration linking backup diffs to time windows
- Schema: device_id, organization_id, backup_before_id, backup_after_id, changed_at,

256
PRODUCT_IDEAS.md Normal file
View file

@ -0,0 +1,256 @@
# TowerOps Product Ideas & UX Improvements
> The tools WISP operators use today were built by networking people for networking people.
> TowerOps should show **business outcomes**: subscriber impact, revenue risk, crew deployment decisions, capacity investment timing.
> The technical detail is still there (operators are engineers, they want it), but it's **contextualized**.
---
## A Day in the Life of a WISP Operator
It's 6:30 AM. Your phone buzzes — three customers emailed overnight saying "internet is slow." You pull up Preseem, check QoE. Looks like Tower 4 sector 2 has degraded. You SSH into the MikroTik, look at the wireless registration table. 47 clients, CCQ is garbage. You open LibreNMS to check if something changed. Then you open Gaiia to see which customers are on that AP and whether any are on premium plans. Then you pull up your Google Sheet where you track tower capacity because nothing else does it. Then you check WeatherUnderground because there was an ice storm warning and you're wondering if a dish shifted.
That's **six tools** before your first cup of coffee. And none of them talk to each other.
---
## Quick Wins (Low Effort, High Impact)
### 1. Device List Health-at-a-Glance
The device index has no visual health indicator before clicking in. Network engineers want to scan a list and instantly spot problems.
**Add:**
- Color-coded status dot (green/yellow/red) based on latest check status
- Mini sparkline for latency or throughput (last 1h, tiny inline chart)
- "Last seen" relative time (they care about this obsessively)
- Subscriber count + MRR badge if Gaiia is linked (the "so what" for each device)
### 2. In-App Changelog / What's New
`priv/static/changelog.txt` exists but isn't surfaced in the UI. Operators love knowing what changed. A small "What's New" badge on the help nav item or a dedicated changelog page builds trust and engagement. Trivially easy — the data already exists.
### 3. Insights Severity Triage
The Insights page is a flat list. Operators need:
- Severity grouping (critical → warning → info)
- "Impact" column showing subscriber/MRR numbers
- Quick filters: "show me only insights that affect >$1k MRR"
- Insight trends: "you're getting more QoE degradation insights this week than last"
---
## Medium Effort Improvements
### 4. Enrich the Site Page
`SiteLive.Show` only shows a latency chart and device list. For a WISP operator, a site = a tower = the most important unit. Should show:
- Aggregate QoE score (from Preseem APs at this site)
- Total subscribers / MRR at this site
- Capacity utilization (how full are the APs)
- Recent config changes across all devices at this site
- Mini network topology for just this site's devices
- Weather conditions at the tower location (wind/ice matter for fixed wireless)
### 5. Alert Context & Impact
Alerts show device + type but don't answer "so what?" Network engineers need:
- Impact badge: "affects 47 subscribers, $3,200 MRR at risk"
- Related alerts grouped (if 3 devices at one site are down, that's one incident, not three)
- Suggested cause: recent config change? Correlated QoE drop? Upstream device also down?
- One-click "show me the config diff from right before this alert"
### 6. Device Detail Progressive Disclosure
The device show page has 10+ tabs. That's a lot. Consider:
- Collapse secondary tabs (ARP, MAC, VLANs, IPs) into a "Layer 2/3" sub-section
- Show Preseem QoE and Gaiia subscriber info *inline on overview* instead of separate tabs
- Progressive disclosure: collapsed sensor sections that expand on click
### 7. Network Map Polish
The topology map exists but still has the beaker (experimental) icon. For it to be useful:
- Click a node → side panel with quick device stats (not full page nav)
- Color nodes by health status
- Show link utilization on edges (traffic data exists in interface stats)
- Filter by site
- "Trace path" between two devices
### 8. Global Search (Cmd+K)
There's no way to quickly find a device by IP, MAC, hostname, or subscriber name. WISP operators constantly get calls like "customer at 10.22.15.7 is slow" and need to trace that to a device → AP → tower → QoE instantly. A command-palette style search would be huge.
---
## New Features — High Value
### 9. Subscriber Trace / "Help Desk Mode"
**The killer workflow for WISPs:** customer calls in → search by name/IP/account → see their AP → see AP health, QoE score, latency, packet loss → see if there's a known issue → resolve or escalate.
This is THE workflow that would sell TowerOps to helpdesk teams at WISPs. Dedicated `/trace` or `/lookup` page.
Not just subscriber count — show names, plan tiers, how long they've been a customer (churn risk), and whether they've already submitted a ticket in Gaiia.
### 10. "Who's Actually Affected Right Now?"
When something breaks, the first question is never "what's the SNMP status" — it's **"who's calling us about this?"** and **"how many more people are about to call?"**
A real-time **"Affected Customers" view** that automatically populates when a device degrades. This is the view the person answering the phone needs open all day.
### 11. Org-Wide Activity Feed / Network Ops Journal
The #1 cause of outages at WISPs isn't equipment failure — it's **someone changed something**. New firewall rule. Moved a VLAN. Changed QoS queues. Upgraded firmware at 2 AM.
A unified timeline of everything that happened across the org: config changes, firmware updates, new subscribers provisioned, Preseem policy changes, manual reboots, maintenance windows. When something breaks, you scroll back and go "oh, Dave pushed a firewall change to three routers at 2:14 AM."
### 12. Remote vs. Site Visit Diagnostic
Tower climbs cost $500-2,000 per truck roll. The decision to send someone up is huge.
A **diagnostic page** per device that aggregates:
- Has a reboot been tried? Did it help?
- Are signal levels within normal range? (Compare to historical baseline)
- Is this weather-correlated? (Check conditions at tower location)
- Are neighboring devices on the same tower also affected? (Physical issue vs. config)
- Has the device been rebooted suspiciously often lately? (hardware degradation)
- What's the uptime trend? (Intermittent restarts = failing power supply, not config)
Give a recommendation: "This looks like a physical issue — signal levels dropped 8dB from baseline with no config change and clear weather. Likely dish misalignment or cable issue."
### 13. Smart Alert Triage
Small WISPs have 2-5 people. The overnight on-call person gets an alert, panics, and either ignores it or wakes the owner.
When an alert fires, automatically add context:
- "This device has gone down 3 times this month. Last two times it recovered on its own within 10 minutes. **Suggest: wait 15 minutes before escalating.**"
- "This is the upstream router for 12 other devices. If those also drop in the next 2 minutes, this is a power issue at Tower 7. **Suggest: check with power company.**"
- "This device's last config backup was 6 hours ago and shows a firmware update. **Suggest: this may be firmware-related, contact the person who updated it.**"
Give the on-call person the institutional knowledge that normally only lives in the owner's head.
### 14. Weather Correlation
Huge for WISPs and nobody does it. Fixed wireless performance is directly affected by weather — rain fade, ice on dishes, wind causing dish movement, lightning damage.
Pull weather data for tower GPS coordinates and correlate:
- "QoE at Tower 4 drops every time wind exceeds 30mph" → dish mounting issue
- "This link degrades in rain" → classic rain fade, might need to lower modulation or add a second radio
- "3 devices at Tower 7 went offline during a lightning storm and haven't come back" → probably got zapped
Saves thousands of dollars because the operator can proactively fix a dish mount before the next storm instead of reactively sending a crew after customer complaints.
### 15. Fleet Firmware Report & Upgrade Planning
WISP operators run dozens of equipment models from 5+ vendors. Tracking firmware versions is chaos.
TowerOps already detects MikroTik firmware versions. Extend this:
- **Fleet firmware report:** "You have 47 MikroTik devices. 12 are on 7.14 (EOL), 28 on 7.20, 7 on 7.21 (current)."
- **Known issues database:** When a RouterOS version has a known bug, flag devices running it.
- **Upgrade planning:** "These 12 devices need updating. They serve 340 subscribers. Best maintenance window based on traffic patterns: Tuesday 3-5 AM."
### 16. Auto-Generated Network Health Reports
Nobody has time to do trend analysis. When WISP operators go to the bank for a loan or justify new equipment, they have nothing.
**Monthly/weekly network health report** — auto-generated, PDF-exportable:
- Overall QoE trend (improving/declining)
- Worst-performing sites ranked
- Subscriber growth vs. capacity utilization
- Outage summary (total downtime minutes, MTTR, most impacted sites)
- Config change activity
- "Things that got better this month" / "Things that got worse"
This is the thing they forward to their business partner or show at a board meeting.
### 17. Maintenance Windows
No concept of planned downtime. When an operator upgrades firmware, they don't want alerts.
- Schedule maintenance per device, site, or org
- Suppress alerts during window
- Track "was QoE impacted by this maintenance?" (ties into config correlation)
### 18. Customer Self-Service Status Page
Every WISP gets hammered with "is the internet down?" calls during outages. Most run a basic status page that they manually update.
Auto-generated **public status page** per organization:
- Auto-detects outages and posts them
- Shows affected areas (abstracted to regions/towers, not device names)
- Estimated resolution time based on historical MTTR for similar incidents
- "Subscribe to updates" via email/SMS
- Reduces support call volume by 40-60% during outages
### 19. Dashboard Customization
The impact dashboard is one-size-fits-all. Even simple stuff like "choose which 4 cards show at the top" or "pin favorite sites" would help different operators focus on what matters to them.
### 20. Mobile-First Field Experience
WISP operators are often in the field at tower sites on their phones. Key mobile-first needs:
- Device quick-status view
- Alert acknowledgment
- QR code login already exists — make sure the post-login experience is mobile-friendly
- Offline-capable device status cache (for when you're at a remote tower with spotty signal)
---
## Integration Opportunities
The play isn't just "we integrate with these" — it's **"we're the glue layer."**
When a Cambium radio reports a client disconnect, TowerOps correlates it with the Gaiia subscriber, checks if the MikroTik config changed, looks at Preseem QoE, and posts a single enriched message to Slack:
> "Customer John Smith (Premium plan, $89/mo) disconnected from Tower 4 AP2. QoE was degrading for 2 hours before disconnect. No config changes detected. Signal was -72dBm (normal -65dBm). Possible physical issue."
That one message replaces 20 minutes of manual investigation.
### Platforms to integrate:
| Platform | What it does | Integration value |
|----------|-------------|-------------------|
| **Cambium cnMaestro** | Radio management for most popular WISP radios | Client signal levels, radio stats, firmware management |
| **UISP** | Ubiquiti management platform | Device inventory, signal levels, firmware |
| **Splynx** | Billing (alternative to Gaiia) | Subscriber data, service plans, tickets |
| **Powercode** | Billing (alternative to Gaiia) | Subscriber data, service plans |
| **Visp.net** | Billing (alternative to Gaiia) | Subscriber data, service plans |
| **Sonar** | Billing + NMS | Subscriber data, network inventory |
| **PagerDuty / OpsGenie** | Alerting/escalation | Push enriched alerts with impact context |
| **Slack / Teams / Discord** | Team communication | Enriched incident notifications |
| **Grafana** | Dashboarding | Data source plugin for custom dashboards |
---
## What NOT to Build
- **Don't build a billing system** — stay in the ops intelligence lane
- **Don't build a full NMS replacement** — no trap receiver, no MIB browser, stay at the operational intelligence layer
- **Don't over-dashboard** — one great dashboard > five mediocre ones
- **Don't try to replace vendor management tools** — integrate with cnMaestro/UISP, don't compete
---
## Recommended Priority
**Tier 1 — Build Now (highest ROI):**
1. Subscriber Trace / Help Desk Mode (#9) — unique differentiator, sells to entire helpdesk teams
2. Device list health indicators (#1) — low effort, massive daily-use improvement
3. Site page enrichment (#4) — towers are how WISPs think
4. In-app changelog (#2) — trivial effort, builds trust
**Tier 2 — Build Next:**
5. Smart alert triage (#13) — reduces noise, empowers on-call staff
6. Org-wide activity feed (#11) — "who changed what" is always the first question
7. Global search (#8) — speed is everything in incident response
8. Weather correlation (#14) — unique, high-value, no one else does this
**Tier 3 — Build for Growth:**
9. Auto-generated health reports (#16) — sells to management/owners, not just engineers
10. Customer status page (#18) — reduces support load, visible to end customers
11. Capacity planning with projections (Roadmap #5) — the spreadsheet killer
12. Fleet firmware report (#15) — saves hours of manual tracking
**Tier 4 — Build for Scale:**
13. Maintenance windows (#17)
14. Dashboard customization (#19)
15. Integration expansion (Cambium, UISP, Splynx, etc.)
16. Mobile-first field experience (#20)

View file

@ -104,6 +104,20 @@ defmodule Towerops.Alerts do
)
end
@doc "Returns active (unresolved) alerts for devices at a specific site."
def list_site_active_alerts(site_id) do
Repo.all(
from(a in Alert,
join: e in assoc(a, :device),
where: e.site_id == ^site_id,
where: a.alert_type == :device_down,
where: is_nil(a.resolved_at),
order_by: [desc: a.triggered_at],
preload: [device: e]
)
)
end
@doc """
Returns the list of all alerts for an organization.

View file

@ -113,6 +113,31 @@ defmodule Towerops.ConfigChanges do
|> Repo.preload(preloads)
end
@doc """
Lists config change events for devices at a specific site, most recent first.
Supports `:after`, `:before`, `:limit`, and `:preload` options.
"""
def list_site_changes(site_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 20)
preloads = Keyword.get(opts, :preload, [:device])
device_ids =
Towerops.Devices.list_site_devices(site_id)
|> Enum.map(& &1.id)
if device_ids == [] do
[]
else
ConfigChangeEvent
|> where([e], e.device_id in ^device_ids)
|> apply_date_filters(opts)
|> order_by(desc: :changed_at)
|> limit(^limit)
|> Repo.all()
|> Repo.preload(preloads)
end
end
@doc """
Gets a single config change event by ID.
"""

View file

@ -239,6 +239,33 @@ defmodule Towerops.Gaiia do
}
end
@doc """
Returns subscriber counts and MRR for multiple devices in a single batch query.
Joins through `gaiia_inventory_items` (which map devices to Gaiia accounts)
then counts distinct accounts and sums active MRR from `gaiia_billing_subscriptions`.
Returns `%{device_id => %{subscriber_count: integer, mrr: Decimal.t()}}`.
"""
def get_device_subscriber_counts(device_ids) when is_list(device_ids) do
if device_ids == [] do
%{}
else
# Count inventory items with assigned accounts per device as subscriber proxy
InventoryItem
|> where([ii], ii.device_id in ^device_ids and not is_nil(ii.assigned_account_gaiia_id))
|> group_by([ii], ii.device_id)
|> select([ii], %{
device_id: ii.device_id,
subscriber_count: count(ii.id, :distinct)
})
|> Repo.all()
|> Map.new(fn row ->
{row.device_id, %{subscriber_count: row.subscriber_count, mrr: nil}}
end)
end
end
@doc "Get subscriber count and MRR for a specific Towerops site via its mapped Gaiia network site."
def get_site_subscriber_summary(site_id) do
NetworkSite

View file

@ -311,6 +311,57 @@ defmodule Towerops.Monitoring do
|> Repo.insert()
end
@doc """
Returns a health summary for multiple devices in a single batch query.
Queries the `checks` table to find the worst-case `current_state` and
latest response time across all enabled checks for each device.
Returns `%{device_id => %{worst_status: 0..3, latest_response_ms: float | nil, last_check_at: DateTime.t() | nil}}`.
Devices with no checks will not appear in the map.
"""
def get_device_health_summary(device_ids) when is_list(device_ids) do
if device_ids == [] do
%{}
else
Check
|> where([c], c.device_id in ^device_ids and c.enabled == true)
|> group_by([c], c.device_id)
|> select([c], %{
device_id: c.device_id,
worst_status: max(c.current_state),
last_check_at: max(c.last_check_at)
})
|> Repo.all()
|> Map.new(fn row -> {row.device_id, row} end)
end
end
@doc """
Returns latest response times for multiple devices from monitoring_checks.
Fetches the most recent monitoring_check per device.
Returns `%{device_id => %{response_time_ms: float | nil, checked_at: DateTime.t()}}`.
"""
def get_device_latest_response_times(device_ids) when is_list(device_ids) do
if device_ids == [] do
%{}
else
# Use a lateral join / DISTINCT ON to get latest per device
MonitoringCheck
|> where([mc], mc.device_id in ^device_ids)
|> distinct([mc], [mc.device_id])
|> order_by([mc], [mc.device_id, desc: mc.checked_at])
|> select([mc], %{
device_id: mc.device_id,
response_time_ms: mc.response_time_ms,
checked_at: mc.checked_at
})
|> Repo.all()
|> Map.new(fn row -> {row.device_id, row} end)
end
end
## Legacy ping-based monitoring compatibility (stub)
@doc false

View file

@ -78,6 +78,68 @@ defmodule Towerops.Preseem do
end
end
@doc """
Returns an aggregate QoE summary for all APs at a site.
Returns a map with average QoE, latency, capacity info, and per-AP breakdowns.
"""
def get_site_qoe_summary(site_id) do
alias Towerops.Devices
device_ids =
site_id
|> Devices.list_site_devices()
|> Enum.map(& &1.id)
if device_ids == [] do
%{
avg_qoe: nil,
avg_capacity: nil,
total_subscribers: 0,
aps: [],
aps_with_issues: 0,
total_aps: 0
}
else
aps =
AccessPoint
|> where([ap], ap.device_id in ^device_ids)
|> Repo.all()
qoe_scores = aps |> Enum.map(& &1.qoe_score) |> Enum.reject(&is_nil/1)
capacity_scores = aps |> Enum.map(& &1.capacity_score) |> Enum.reject(&is_nil/1)
avg_qoe =
if qoe_scores != [],
do: Float.round(Enum.sum(qoe_scores) / length(qoe_scores), 1),
else: nil
avg_capacity =
if capacity_scores != [],
do: Float.round(Enum.sum(capacity_scores) / length(capacity_scores), 1),
else: nil
total_subscribers =
aps |> Enum.map(& &1.subscriber_count) |> Enum.reject(&is_nil/1) |> Enum.sum()
# APs with issues: QoE < 6.0 (on 1-10 scale) or capacity < 50
aps_with_issues =
Enum.count(aps, fn ap ->
(ap.qoe_score && ap.qoe_score < 6.0) ||
(ap.capacity_score && ap.capacity_score < 50.0)
end)
%{
avg_qoe: avg_qoe,
avg_capacity: avg_capacity,
total_subscribers: total_subscribers,
aps: aps,
aps_with_issues: aps_with_issues,
total_aps: length(aps)
}
end
end
# --- Insight delegations ---
defdelegate list_insights(organization_id, opts \\ []), to: Insights

401
lib/towerops/trace.ex Normal file
View file

@ -0,0 +1,401 @@
defmodule Towerops.Trace do
@moduledoc """
Context for the Subscriber Trace / Help Desk Mode feature.
Provides unified search across Gaiia accounts, inventory items, devices,
and Preseem access points, then assembles a full "patient chart" view
of a subscriber's network status.
"""
import Ecto.Query
alias Towerops.Alerts.Alert
alias Towerops.ConfigChanges.ConfigChangeEvent
alias Towerops.Devices.Device
alias Towerops.Gaiia.Account
alias Towerops.Gaiia.BillingSubscription
alias Towerops.Gaiia.InventoryItem
alias Towerops.Preseem.AccessPoint
alias Towerops.Preseem.Insight
alias Towerops.Preseem.SubscriberMetric
alias Towerops.Repo
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
@doc """
Search across multiple data sources for a given query string.
Returns a list of `%{type, id, label, sublabel, data}` maps.
"""
def search(organization_id, query) when is_binary(query) do
query = String.trim(query)
if String.length(query) < 2 do
[]
else
pattern = "%#{sanitize_like(query)}%"
accounts = search_accounts(organization_id, pattern)
inventory = search_inventory(organization_id, pattern)
devices = search_devices(organization_id, pattern)
access_points = search_access_points(organization_id, pattern)
(accounts ++ inventory ++ devices ++ access_points)
|> Enum.take(25)
end
end
defp search_accounts(organization_id, pattern) do
Account
|> where(organization_id: ^organization_id)
|> where([a], ilike(a.name, ^pattern) or ilike(a.readable_id, ^pattern) or ilike(a.gaiia_id, ^pattern))
|> limit(10)
|> Repo.all()
|> Enum.map(fn a ->
address_str = format_address(a.address)
sublabel = Enum.join(Enum.reject([a.readable_id, a.status, address_str], &is_nil/1), " · ")
%{type: :account, id: a.id, gaiia_id: a.gaiia_id, label: a.name || a.gaiia_id, sublabel: sublabel, data: a}
end)
end
defp search_inventory(organization_id, pattern) do
InventoryItem
|> where(organization_id: ^organization_id)
|> where([i], ilike(i.ip_address, ^pattern) or ilike(i.serial_number, ^pattern) or ilike(i.name, ^pattern) or ilike(i.mac_address, ^pattern))
|> limit(10)
|> preload(:device)
|> Repo.all()
|> Enum.map(fn i ->
sublabel = Enum.join(Enum.reject([i.ip_address, i.model_name, i.serial_number], &is_nil/1), " · ")
%{type: :inventory_item, id: i.id, label: i.name || i.ip_address || i.gaiia_id, sublabel: sublabel, data: i}
end)
end
defp search_devices(organization_id, pattern) do
Device
|> where(organization_id: ^organization_id)
|> where([d], ilike(d.ip_address, ^pattern) or ilike(d.name, ^pattern))
|> limit(10)
|> preload(:site)
|> Repo.all()
|> Enum.map(fn d ->
site_name = if d.site && d.site.name, do: d.site.name
sublabel = Enum.join(Enum.reject([d.ip_address, site_name, to_string(d.status)], &is_nil/1), " · ")
%{type: :device, id: d.id, label: d.name || d.ip_address, sublabel: sublabel, data: d}
end)
end
defp search_access_points(organization_id, pattern) do
AccessPoint
|> where(organization_id: ^organization_id)
|> where([ap], ilike(ap.name, ^pattern) or ilike(ap.ip_address, ^pattern))
|> limit(10)
|> preload(:device)
|> Repo.all()
|> Enum.map(fn ap ->
sublabel = Enum.join(Enum.reject([ap.ip_address, ap.model, "#{ap.subscriber_count || 0} subscribers"], &is_nil/1), " · ")
%{type: :access_point, id: ap.id, label: ap.name, sublabel: sublabel, data: ap}
end)
end
# ---------------------------------------------------------------------------
# Trace Assembly
# ---------------------------------------------------------------------------
@doc """
Given a search result, assemble the full trace view.
Returns a map with all sections of the "patient chart".
"""
def assemble_trace(organization_id, type, id) do
case type do
:account -> trace_from_account(organization_id, id)
:inventory_item -> trace_from_inventory(organization_id, id)
:device -> trace_from_device(organization_id, id)
:access_point -> trace_from_access_point(organization_id, id)
_ -> nil
end
end
defp trace_from_account(organization_id, account_id) do
account = Repo.get_by(Account, id: account_id, organization_id: organization_id)
unless account, do: throw(:not_found)
subscriptions = list_account_subscriptions(organization_id, account.gaiia_id)
inventory_items = list_account_inventory(organization_id, account.gaiia_id)
# Find connected device through inventory items
{device, access_point} = find_device_and_ap(inventory_items)
build_trace(account, subscriptions, inventory_items, device, access_point, organization_id)
catch
:not_found -> nil
end
defp trace_from_inventory(organization_id, item_id) do
item =
InventoryItem
|> where(id: ^item_id, organization_id: ^organization_id)
|> preload(:device)
|> Repo.one()
unless item, do: throw(:not_found)
# Find account if linked
account =
if item.assigned_account_gaiia_id do
Repo.get_by(Account, organization_id: organization_id, gaiia_id: item.assigned_account_gaiia_id)
end
subscriptions = if account, do: list_account_subscriptions(organization_id, account.gaiia_id), else: []
inventory_items = if account, do: list_account_inventory(organization_id, account.gaiia_id), else: [item]
device = if item.device_id, do: Repo.get(Device, item.device_id) |> Repo.preload(:site)
access_point = if device, do: Repo.get_by(AccessPoint, device_id: device.id)
build_trace(account, subscriptions, inventory_items, device, access_point, organization_id)
catch
:not_found -> nil
end
defp trace_from_device(organization_id, device_id) do
device =
Device
|> where(id: ^device_id, organization_id: ^organization_id)
|> preload(:site)
|> Repo.one()
unless device, do: throw(:not_found)
access_point = Repo.get_by(AccessPoint, device_id: device.id)
# Find accounts served by this device through inventory items
inventory_items =
InventoryItem
|> where(organization_id: ^organization_id, device_id: ^device_id)
|> Repo.all()
account =
case inventory_items do
[first | _] when not is_nil(first.assigned_account_gaiia_id) ->
Repo.get_by(Account, organization_id: organization_id, gaiia_id: first.assigned_account_gaiia_id)
_ -> nil
end
subscriptions = if account, do: list_account_subscriptions(organization_id, account.gaiia_id), else: []
build_trace(account, subscriptions, inventory_items, device, access_point, organization_id)
catch
:not_found -> nil
end
defp trace_from_access_point(organization_id, ap_id) do
access_point =
AccessPoint
|> where(id: ^ap_id, organization_id: ^organization_id)
|> preload(:device)
|> Repo.one()
unless access_point, do: throw(:not_found)
device = if access_point.device_id, do: access_point.device |> Repo.preload(:site)
build_trace(nil, [], [], device, access_point, organization_id)
catch
:not_found -> nil
end
# ---------------------------------------------------------------------------
# Build the trace map
# ---------------------------------------------------------------------------
defp build_trace(account, subscriptions, inventory_items, device, access_point, organization_id) do
device_id = if device, do: device.id
%{
subscriber: build_subscriber_info(account, subscriptions),
inventory_items: inventory_items,
device: device,
access_point: access_point,
qoe_metrics: build_qoe_metrics(access_point),
peer_impact: build_peer_impact(access_point, organization_id),
alerts: load_device_alerts(device_id),
config_changes: load_recent_config_changes(device_id),
insights: load_device_insights(device_id, organization_id)
}
end
defp build_subscriber_info(nil, _subscriptions), do: nil
defp build_subscriber_info(account, subscriptions) do
primary_sub = List.first(subscriptions)
%{
name: account.name,
account_id: account.readable_id || account.gaiia_id,
status: account.status,
address: format_address(account.address),
mrr: account.mrr,
customer_since: account.inserted_at,
plan: if(primary_sub, do: primary_sub.product_name),
plan_mrr: if(primary_sub, do: primary_sub.mrr_amount),
speed_download: if(primary_sub, do: primary_sub.speed_download),
speed_upload: if(primary_sub, do: primary_sub.speed_upload),
subscription_count: length(subscriptions),
subscriptions: subscriptions
}
end
defp build_qoe_metrics(nil), do: nil
defp build_qoe_metrics(access_point) do
latest_metric =
SubscriberMetric
|> where(preseem_access_point_id: ^access_point.id)
|> order_by(desc: :recorded_at)
|> limit(1)
|> Repo.one()
%{
qoe_score: access_point.qoe_score,
capacity_score: access_point.capacity_score,
rf_score: access_point.rf_score,
subscriber_count: access_point.subscriber_count,
airtime_utilization: access_point.airtime_utilization,
latest_metric: latest_metric
}
end
defp build_peer_impact(nil, _org_id), do: nil
defp build_peer_impact(access_point, organization_id) do
# Count subscribers on same AP
subscriber_count = access_point.subscriber_count || 0
# Find other APs at the same device (same tower)
peer_aps =
if access_point.device_id do
AccessPoint
|> where(organization_id: ^organization_id)
|> where(device_id: ^access_point.device_id)
|> where([ap], ap.id != ^access_point.id)
|> Repo.all()
else
[]
end
# Check for active alerts on the device
device_alerts =
if access_point.device_id do
Alert
|> where(device_id: ^access_point.device_id)
|> where([a], is_nil(a.resolved_at))
|> Repo.aggregate(:count)
else
0
end
%{
subscriber_count: subscriber_count,
peer_ap_count: length(peer_aps),
peer_aps: peer_aps,
active_alert_count: device_alerts
}
end
defp load_device_alerts(nil), do: []
defp load_device_alerts(device_id) do
Alert
|> where(device_id: ^device_id)
|> order_by(desc: :triggered_at)
|> limit(10)
|> preload([:device, :acknowledged_by])
|> Repo.all()
end
defp load_recent_config_changes(nil), do: []
defp load_recent_config_changes(device_id) do
since = DateTime.utc_now() |> DateTime.add(-24 * 3600, :second)
ConfigChangeEvent
|> where(device_id: ^device_id)
|> where([c], c.detected_at >= ^since)
|> order_by(desc: :detected_at)
|> limit(10)
|> Repo.all()
end
defp load_device_insights(nil, _org_id), do: []
defp load_device_insights(device_id, _organization_id) do
Insight
|> where(device_id: ^device_id)
|> where(status: "active")
|> order_by(desc: :inserted_at)
|> limit(10)
|> Repo.all()
end
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
defp find_device_and_ap(inventory_items) do
# Find the first inventory item that has a linked device
item_with_device = Enum.find(inventory_items, & &1.device_id)
if item_with_device do
device = Repo.get(Device, item_with_device.device_id) |> Repo.preload(:site)
access_point = Repo.get_by(AccessPoint, device_id: device.id)
{device, access_point}
else
{nil, nil}
end
end
defp list_account_subscriptions(organization_id, account_gaiia_id) do
BillingSubscription
|> where(organization_id: ^organization_id, account_gaiia_id: ^account_gaiia_id)
|> order_by(:product_name)
|> Repo.all()
end
defp list_account_inventory(organization_id, account_gaiia_id) do
InventoryItem
|> where(organization_id: ^organization_id, assigned_account_gaiia_id: ^account_gaiia_id)
|> preload(:device)
|> Repo.all()
end
defp format_address(nil), do: nil
defp format_address(addr) when is_map(addr) do
parts =
[
addr["street1"] || addr["line1"],
addr["street2"] || addr["line2"],
addr["city"],
addr["state"] || addr["province"],
addr["zip"] || addr["postal_code"]
]
|> Enum.reject(&is_nil/1)
|> Enum.reject(&(String.trim(&1) == ""))
if parts == [], do: nil, else: Enum.join(parts, ", ")
end
defp format_address(_), do: nil
defp sanitize_like(query) do
query
|> String.replace("\\", "\\\\")
|> String.replace("%", "\\%")
|> String.replace("_", "\\_")
end
end

View file

@ -4,6 +4,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
alias Towerops.Devices
alias Towerops.Gaiia
alias Towerops.Monitoring
alias Towerops.Organizations.SubscriptionLimits
alias Towerops.Sites
alias Towerops.Snmp
@ -314,6 +315,11 @@ defmodule ToweropsWeb.DeviceLive.Index do
end
defp build_device_rows(devices) do
device_ids = Enum.map(devices, & &1.id)
health_data = Monitoring.get_device_health_summary(device_ids)
response_data = Monitoring.get_device_latest_response_times(device_ids)
subscriber_data = Gaiia.get_device_subscriber_counts(device_ids)
devices
|> group_devices_by_site()
|> Enum.flat_map(fn {site, site_devices, stats} ->
@ -334,7 +340,10 @@ defmodule ToweropsWeb.DeviceLive.Index do
type: :device,
device: device,
site: site,
device_index: idx
device_index: idx,
health: Map.get(health_data, device.id),
response: Map.get(response_data, device.id),
subscribers: Map.get(subscriber_data, device.id)
}
end)
@ -390,6 +399,67 @@ defmodule ToweropsWeb.DeviceLive.Index do
|> Map.new(fn site_id -> {site_id, Gaiia.get_site_subscriber_summary(site_id)} end)
end
# Health indicator helpers
defp health_dot_color(nil), do: "bg-gray-400"
defp health_dot_color(%{worst_status: 0}), do: "bg-green-500"
defp health_dot_color(%{worst_status: 1}), do: "bg-yellow-500"
defp health_dot_color(%{worst_status: 2}), do: "bg-red-500"
defp health_dot_color(%{worst_status: 3}), do: "bg-gray-400"
defp health_dot_color(_), do: "bg-gray-400"
defp health_dot_title(nil), do: "No check data"
defp health_dot_title(%{worst_status: 0}), do: "All checks passing"
defp health_dot_title(%{worst_status: 1}), do: "Warning"
defp health_dot_title(%{worst_status: 2}), do: "Critical"
defp health_dot_title(%{worst_status: 3}), do: "Unknown"
defp health_dot_title(_), do: "No check data"
defp last_seen_text(nil), do: {"Never", "text-gray-400 dark:text-gray-600"}
defp last_seen_text(datetime) do
diff_seconds = DateTime.diff(DateTime.utc_now(), datetime, :second)
cond do
diff_seconds < 0 -> {"just now", "text-green-600 dark:text-green-400"}
diff_seconds < 60 -> {"#{diff_seconds}s ago", "text-green-600 dark:text-green-400"}
diff_seconds < 600 -> {"#{div(diff_seconds, 60)}m ago", "text-green-600 dark:text-green-400"}
diff_seconds < 3600 -> {"#{div(diff_seconds, 60)}m ago", "text-yellow-600 dark:text-yellow-400"}
diff_seconds < 86400 -> {"#{div(diff_seconds, 3600)}h ago", "text-red-600 dark:text-red-400"}
true -> {"#{div(diff_seconds, 86400)}d ago", "text-red-600 dark:text-red-400"}
end
end
defp response_time_badge(nil), do: {"-", "text-gray-400"}
defp response_time_badge(%{response_time_ms: nil}), do: {"-", "text-gray-400"}
defp response_time_badge(%{response_time_ms: ms}) do
formatted =
cond do
ms < 1 -> "<1ms"
ms < 1000 -> "#{round(ms)}ms"
true -> "#{Float.round(ms / 1000, 1)}s"
end
color =
cond do
ms < 20 -> "text-green-600 dark:text-green-400"
ms <= 100 -> "text-yellow-600 dark:text-yellow-400"
true -> "text-red-600 dark:text-red-400"
end
{formatted, color}
end
defp format_subscriber_count(nil), do: nil
defp format_subscriber_count(%{subscriber_count: count}) when count > 0 do
"#{count} subs"
end
defp format_subscriber_count(_), do: nil
# Generate pagination range with ellipsis for large page counts
# Shows: [1] ... [4] [5] [6] ... [20] when on page 5 of 20
defp pagination_range(current_page, total_pages) do

View file

@ -215,7 +215,19 @@
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
>
Last Checked
Last Seen
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
>
Response
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-white"
>
Subscribers
</th>
</tr>
</thead>
@ -245,7 +257,7 @@
</td>
<th
scope="colgroup"
colspan="4"
colspan="6"
class={[
"bg-gray-50 py-2 pr-3 text-left dark:bg-gray-800/50",
!@reorder_mode && "pl-4 sm:pl-3"
@ -329,11 +341,15 @@
<.link
navigate={~p"/devices/#{row.device.id}"}
class={[
"block py-4 pr-3 text-inherit no-underline",
"flex items-center gap-2 py-4 pr-3 text-inherit no-underline",
@reorder_mode && "pl-2",
!@reorder_mode && "pl-4 sm:pl-3"
]}
>
<span
class={"inline-block h-2.5 w-2.5 rounded-full flex-shrink-0 #{health_dot_color(row.health)}"}
title={health_dot_title(row.health)}
/>
{row.device.name}
</.link>
</td>
@ -363,15 +379,37 @@
</span>
</.link>
</td>
<td class="p-0 text-sm whitespace-nowrap text-gray-500 dark:text-gray-400">
<td class="p-0 text-sm whitespace-nowrap">
<.link
navigate={~p"/devices/#{row.device.id}"}
class="block px-3 py-4 text-inherit no-underline"
>
<.timestamp
datetime={row.device.last_checked_at}
timezone={@timezone}
/>
<% {text, color} = last_seen_text(row.device.last_seen_at) %>
<span class={"text-xs font-medium #{color}"}>{text}</span>
</.link>
</td>
<td class="p-0 text-sm whitespace-nowrap">
<.link
navigate={~p"/devices/#{row.device.id}"}
class="block px-3 py-4 text-inherit no-underline"
>
<% {rt_text, rt_color} = response_time_badge(row.response) %>
<span class={"text-xs font-mono #{rt_color}"}>{rt_text}</span>
</.link>
</td>
<td class="p-0 text-sm whitespace-nowrap">
<.link
navigate={~p"/devices/#{row.device.id}"}
class="block px-3 py-4 text-inherit no-underline"
>
<%= if sub_text = format_subscriber_count(row.subscribers) do %>
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
<.icon name="hero-users" class="h-3 w-3" />
{sub_text}
</span>
<% else %>
<span class="text-xs text-gray-400">-</span>
<% end %>
</.link>
</td>
</tr>

View file

@ -2,9 +2,12 @@ defmodule ToweropsWeb.SiteLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Alerts
alias Towerops.ConfigChanges
alias Towerops.Dashboard
alias Towerops.Devices
alias Towerops.Monitoring
alias Towerops.Preseem
alias Towerops.Sites
alias Towerops.Snmp
alias Towerops.Workers.DiscoveryWorker
@ -18,17 +21,39 @@ defmodule ToweropsWeb.SiteLive.Show do
def handle_params(%{"id" => id}, _, socket) do
organization = socket.assigns.current_scope.organization
site = Sites.get_organization_site!(organization.id, id)
device = Devices.list_site_devices(site.id)
latency_chart_data = load_site_latency_chart_data(device)
devices = Devices.list_site_devices(site.id)
device_ids = Enum.map(devices, & &1.id)
latency_chart_data = load_site_latency_chart_data(devices)
site_summary = Dashboard.get_site_summary(site.id)
# QoE summary from Preseem
qoe_summary = Preseem.get_site_qoe_summary(site.id)
# Config changes in last 30 days
thirty_days_ago = DateTime.add(DateTime.utc_now(), -30, :day)
config_changes = ConfigChanges.list_site_changes(site.id, after: thirty_days_ago, limit: 10)
# Active alerts
active_alerts = Alerts.list_site_active_alerts(site.id)
# Insights
insights = Preseem.Insights.list_site_insights(site.id, limit: 10)
# Device response times for health grid
response_times = Monitoring.get_device_latest_response_times(device_ids)
{:noreply,
socket
|> assign(:page_title, site.name)
|> assign(:site, site)
|> assign(:device, device)
|> assign(:device, devices)
|> assign(:latency_chart_data, latency_chart_data)
|> assign(:site_summary, site_summary)}
|> assign(:site_summary, site_summary)
|> assign(:qoe_summary, qoe_summary)
|> assign(:config_changes, config_changes)
|> assign(:active_alerts, active_alerts)
|> assign(:insights, insights)
|> assign(:response_times, response_times)}
end
@impl true
@ -111,6 +136,58 @@ defmodule ToweropsWeb.SiteLive.Show do
defp format_number(number), do: to_string(number)
defp qoe_color(nil), do: "text-gray-400 dark:text-gray-500"
defp qoe_color(score) when score >= 8.0, do: "text-green-600 dark:text-green-400"
defp qoe_color(score) when score >= 6.0, do: "text-yellow-600 dark:text-yellow-400"
defp qoe_color(_), do: "text-red-600 dark:text-red-400"
defp qoe_bg(nil), do: "bg-gray-50 border-gray-200 dark:bg-gray-800/50 dark:border-white/10"
defp qoe_bg(score) when score >= 8.0, do: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800/30"
defp qoe_bg(score) when score >= 6.0, do: "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800/30"
defp qoe_bg(_), do: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800/30"
defp capacity_bar_color(nil), do: "bg-gray-300"
defp capacity_bar_color(score) when score >= 70, do: "bg-green-500"
defp capacity_bar_color(score) when score >= 40, do: "bg-yellow-500"
defp capacity_bar_color(_), do: "bg-red-500"
defp status_dot_class(:up), do: "bg-green-500"
defp status_dot_class(:down), do: "bg-red-500"
defp status_dot_class(_), do: "bg-gray-400"
defp time_ago(nil), do: ""
defp time_ago(datetime) do
seconds = DateTime.diff(DateTime.utc_now(), datetime, :second)
cond do
seconds < 60 -> "#{seconds}s ago"
seconds < 3600 -> "#{div(seconds, 60)}m ago"
seconds < 86400 -> "#{div(seconds, 3600)}h ago"
true -> "#{div(seconds, 86400)}d ago"
end
end
defp format_response_time(nil), do: ""
defp format_response_time(ms) when ms < 1, do: "<1ms"
defp format_response_time(ms), do: "#{round(ms)}ms"
defp change_size_label(size) when size < 100, do: "Small"
defp change_size_label(size) when size < 500, do: "Medium"
defp change_size_label(_), do: "Large"
defp change_size_color(size) when size < 100, do: "badge-ghost"
defp change_size_color(size) when size < 500, do: "badge-warning"
defp change_size_color(_), do: "badge-error"
defp insight_urgency_class("critical"), do: "text-red-600 dark:text-red-400"
defp insight_urgency_class("warning"), do: "text-yellow-600 dark:text-yellow-400"
defp insight_urgency_class(_), do: "text-blue-600 dark:text-blue-400"
defp insight_urgency_icon("critical"), do: "hero-exclamation-triangle"
defp insight_urgency_icon("warning"), do: "hero-exclamation-circle"
defp insight_urgency_icon(_), do: "hero-information-circle"
# Enqueue discovery job - safe to call in test environment
defp enqueue_discovery(device_id) do
if Application.get_env(:towerops, :env) == :test do

View file

@ -0,0 +1,90 @@
defmodule ToweropsWeb.TraceLive.Index do
@moduledoc """
LiveView for the Subscriber Trace / Help Desk Mode.
Provides a unified search across subscribers, devices, and access points,
then assembles a full "patient chart" trace view showing all relevant
network health information at a glance.
"""
use ToweropsWeb, :live_view
alias Towerops.Trace
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Trace")
|> assign(:query, "")
|> assign(:results, [])
|> assign(:trace, nil)
|> assign(:trace_type, nil)
|> assign(:trace_id, nil)
|> assign(:searching, false)}
end
@impl true
def handle_params(params, _url, socket) do
organization = socket.assigns.current_scope.organization
socket =
case params do
%{"type" => type, "id" => id} ->
type_atom = safe_type(type)
trace = Trace.assemble_trace(organization.id, type_atom, id)
socket
|> assign(:trace, trace)
|> assign(:trace_type, type_atom)
|> assign(:trace_id, id)
_ ->
socket
end
{:noreply, socket}
end
@impl true
def handle_event("search", %{"query" => query}, socket) do
organization = socket.assigns.current_scope.organization
results =
if String.trim(query) != "" do
Trace.search(organization.id, query)
else
[]
end
{:noreply,
socket
|> assign(:query, query)
|> assign(:results, results)
|> assign(:trace, nil)
|> assign(:trace_type, nil)
|> assign(:trace_id, nil)}
end
@impl true
def handle_event("select_result", %{"type" => type, "id" => id}, socket) do
{:noreply, push_patch(socket, to: ~p"/trace?type=#{type}&id=#{id}")}
end
@impl true
def handle_event("clear", _params, socket) do
{:noreply,
socket
|> assign(:query, "")
|> assign(:results, [])
|> assign(:trace, nil)
|> assign(:trace_type, nil)
|> assign(:trace_id, nil)
|> push_patch(to: ~p"/trace")}
end
defp safe_type("account"), do: :account
defp safe_type("inventory_item"), do: :inventory_item
defp safe_type("device"), do: :device
defp safe_type("access_point"), do: :access_point
defp safe_type(_), do: :account
end

View file

@ -0,0 +1,380 @@
<Layouts.authenticated
flash={@flash}
current_scope={@current_scope}
active_page="trace"
>
<div class="space-y-6">
<%!-- Search Bar --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<div class="flex items-center gap-3">
<.icon name="hero-magnifying-glass" class="h-6 w-6 text-gray-400 flex-shrink-0" />
<form phx-change="search" phx-submit="search" class="flex-1 flex gap-2">
<input
type="text"
name="query"
value={@query}
placeholder="Search by customer name, IP address, account ID, serial number, or device name..."
class="input input-bordered w-full dark:bg-gray-800 dark:border-white/10 dark:text-white"
phx-debounce="300"
autofocus
/>
</form>
<button
:if={@query != "" || @trace}
phx-click="clear"
class="btn btn-ghost btn-sm"
>
<.icon name="hero-x-mark" class="h-5 w-5" />
Clear
</button>
</div>
</div>
</div>
<%!-- Search Results --%>
<div :if={@results != [] && !@trace} class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-0">
<div class="divide-y divide-base-200 dark:divide-white/10">
<button
:for={result <- @results}
phx-click="select_result"
phx-value-type={result.type}
phx-value-id={result.id}
class="w-full flex items-center gap-3 px-4 sm:px-6 py-3 hover:bg-base-200/50 dark:hover:bg-white/5 text-left transition-colors"
>
<span class={["badge badge-sm font-mono", type_badge_class(result.type)]}>
{type_badge_label(result.type)}
</span>
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 dark:text-white truncate">{result.label}</div>
<div :if={result.sublabel} class="text-sm text-gray-500 dark:text-gray-400 truncate">{result.sublabel}</div>
</div>
<.icon name="hero-chevron-right" class="h-4 w-4 text-gray-400 flex-shrink-0" />
</button>
</div>
</div>
</div>
<%!-- No Results --%>
<div :if={@query != "" && @results == [] && !@trace} class="text-center py-12 text-gray-500 dark:text-gray-400">
<.icon name="hero-magnifying-glass" class="h-12 w-12 mx-auto mb-3 opacity-50" />
<p class="text-lg font-medium">No results found</p>
<p class="text-sm">Try searching by customer name, IP address, account ID, or device name</p>
</div>
<%!-- Trace View --%>
<div :if={@trace} class="space-y-4">
<%!-- Known Issues Callout --%>
<.issues_callout trace={@trace} />
<%!-- Subscriber + Connection --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<%!-- Subscriber Info --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<h3 class="font-semibold text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
<.icon name="hero-user" class="h-4 w-4 inline" /> Subscriber
</h3>
<%= if @trace.subscriber do %>
<div class="space-y-2">
<div class="text-xl font-bold text-gray-900 dark:text-white">{@trace.subscriber.name}</div>
<div :if={@trace.subscriber.plan} class="text-sm text-gray-600 dark:text-gray-300">
{String.capitalize(@trace.subscriber.plan)}
<span :if={@trace.subscriber.plan_mrr} class="font-medium">
— ${Decimal.round(@trace.subscriber.plan_mrr, 2)}/mo
</span>
</div>
<div :if={@trace.subscriber.speed_download} class="text-sm text-gray-500 dark:text-gray-400">
↓ {format_speed(@trace.subscriber.speed_download)} / ↑ {format_speed(@trace.subscriber.speed_upload)}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
Account #: {@trace.subscriber.account_id}
</div>
<div :if={@trace.subscriber.address} class="text-sm text-gray-500 dark:text-gray-400">
<.icon name="hero-map-pin" class="h-3.5 w-3.5 inline" /> {@trace.subscriber.address}
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
Customer since: {Calendar.strftime(@trace.subscriber.customer_since, "%b %Y")}
</div>
<div class="flex items-center gap-2 mt-1">
<span class={["badge badge-sm", status_badge_class(@trace.subscriber.status)]}>
{@trace.subscriber.status || "unknown"}
</span>
<span :if={@trace.subscriber.mrr} class="text-sm font-medium text-gray-700 dark:text-gray-300">
MRR: ${Decimal.round(@trace.subscriber.mrr, 2)}
</span>
</div>
</div>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400 italic">No subscriber info available</p>
<% end %>
</div>
</div>
<%!-- Connection / Device Info --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<h3 class="font-semibold text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
<.icon name="hero-signal" class="h-4 w-4 inline" /> Connection
</h3>
<%= if @trace.device do %>
<div class="space-y-2">
<div class="text-xl font-bold text-gray-900 dark:text-white">
<.link navigate={~p"/devices/#{@trace.device.id}"} class="hover:text-blue-600 dark:hover:text-blue-400">
{@trace.device.name || @trace.device.ip_address}
</.link>
</div>
<div :if={@trace.device.site} class="text-sm text-gray-600 dark:text-gray-300">
<.link navigate={~p"/sites/#{@trace.device.site.id}"} class="hover:text-blue-600">
{@trace.device.site.name}
</.link>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500 dark:text-gray-400">Status:</span>
<span class={["inline-flex items-center gap-1 text-sm font-medium", device_status_color(@trace.device.status)]}>
<span class={["inline-block h-2.5 w-2.5 rounded-full", device_status_dot(@trace.device.status)]}></span>
{String.capitalize(to_string(@trace.device.status))}
</span>
</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
IP: {@trace.device.ip_address}
</div>
<div :if={@trace.device.last_checked_at} class="text-sm text-gray-500 dark:text-gray-400">
Last checked: {format_relative_time(@trace.device.last_checked_at)}
</div>
<div :if={@trace.access_point} class="mt-2 pt-2 border-t border-base-200 dark:border-white/10">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-1">Preseem AP</div>
<div class="text-sm text-gray-700 dark:text-gray-300">{@trace.access_point.name}</div>
<div :if={@trace.access_point.model} class="text-xs text-gray-500">{@trace.access_point.model}</div>
</div>
</div>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400 italic">No device linked</p>
<% end %>
</div>
</div>
</div>
<%!-- QoE Metrics + Peer Impact --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<%!-- QoE Metrics --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<h3 class="font-semibold text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
<.icon name="hero-chart-bar" class="h-4 w-4 inline" /> QoE Metrics
<span class="text-xs font-normal normal-case">(from Preseem)</span>
</h3>
<%= if @trace.qoe_metrics do %>
<div class="grid grid-cols-2 gap-3">
<.metric_card label="QoE Score" value={format_score(@trace.qoe_metrics.qoe_score)} quality={score_quality(@trace.qoe_metrics.qoe_score)} />
<.metric_card label="Capacity" value={format_score(@trace.qoe_metrics.capacity_score)} quality={score_quality(@trace.qoe_metrics.capacity_score)} />
<.metric_card label="RF Score" value={format_score(@trace.qoe_metrics.rf_score)} quality={score_quality(@trace.qoe_metrics.rf_score)} />
<.metric_card label="Airtime" value={format_pct(@trace.qoe_metrics.airtime_utilization)} quality={airtime_quality(@trace.qoe_metrics.airtime_utilization)} />
</div>
<%= if @trace.qoe_metrics.latest_metric do %>
<div class="mt-4 pt-3 border-t border-base-200 dark:border-white/10">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2">Latest Metrics</div>
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Latency</span>
<span class={["font-medium", latency_color(@trace.qoe_metrics.latest_metric.avg_latency)]}>{format_ms(@trace.qoe_metrics.latest_metric.avg_latency)}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Jitter</span>
<span class={["font-medium", jitter_color(@trace.qoe_metrics.latest_metric.avg_jitter)]}>{format_ms(@trace.qoe_metrics.latest_metric.avg_jitter)}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Loss</span>
<span class={["font-medium", loss_color(@trace.qoe_metrics.latest_metric.avg_loss)]}>{format_pct(@trace.qoe_metrics.latest_metric.avg_loss)}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">Throughput</span>
<span class="font-medium text-gray-900 dark:text-white">{format_throughput(@trace.qoe_metrics.latest_metric.avg_throughput)}</span>
</div>
</div>
</div>
<% end %>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400 italic">No Preseem data available</p>
<% end %>
</div>
</div>
<%!-- Peer Impact --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<h3 class="font-semibold text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
<.icon name="hero-users" class="h-4 w-4 inline" /> Peer Impact
</h3>
<%= if @trace.peer_impact do %>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500 dark:text-gray-400">Subscribers on this AP</span>
<span class="text-2xl font-bold text-gray-900 dark:text-white">{@trace.peer_impact.subscriber_count}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500 dark:text-gray-400">Other APs on device</span>
<span class="text-lg font-semibold text-gray-700 dark:text-gray-300">{@trace.peer_impact.peer_ap_count}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500 dark:text-gray-400">Active alerts on device</span>
<span class={["text-lg font-semibold", if(@trace.peer_impact.active_alert_count > 0, do: "text-red-600", else: "text-green-600")]}>
{@trace.peer_impact.active_alert_count}
</span>
</div>
<div :if={@trace.peer_impact.peer_aps != []} class="mt-3 pt-3 border-t border-base-200 dark:border-white/10">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 mb-2">Peer APs</div>
<div class="space-y-1">
<div :for={ap <- @trace.peer_impact.peer_aps} class="flex items-center justify-between text-sm">
<span class="text-gray-700 dark:text-gray-300 truncate">{ap.name}</span>
<div class="flex items-center gap-2">
<span class="text-gray-500">{ap.subscriber_count || 0} subs</span>
<span :if={ap.qoe_score} class={["font-medium", score_color(ap.qoe_score)]}>{format_score(ap.qoe_score)}</span>
</div>
</div>
</div>
</div>
</div>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400 italic">No peer data available</p>
<% end %>
</div>
</div>
</div>
<%!-- Active Alerts --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<h3 class="font-semibold text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
<.icon name="hero-bell-alert" class="h-4 w-4 inline" /> Recent Alerts
</h3>
<%= if @trace.alerts == [] do %>
<div class="flex items-center gap-2 text-green-600 dark:text-green-400">
<.icon name="hero-check-circle" class="h-5 w-5" />
<span class="text-sm font-medium">No alerts — all clear</span>
</div>
<% else %>
<div class="divide-y divide-base-200 dark:divide-white/10">
<div :for={alert <- @trace.alerts} class="py-2 first:pt-0 last:pb-0 flex items-center gap-3">
<span class={["inline-block h-2.5 w-2.5 rounded-full flex-shrink-0", alert_dot(alert)]}>
</span>
<div class="flex-1 min-w-0">
<span class="text-sm font-medium text-gray-900 dark:text-white">
{format_alert_type(alert.alert_type)}
</span>
<span :if={alert.message} class="text-sm text-gray-500 dark:text-gray-400 ml-1">
— {alert.message}
</span>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
{format_relative_time(alert.triggered_at)}
</div>
<span :if={alert.resolved_at} class="badge badge-xs badge-success">resolved</span>
<span :if={is_nil(alert.resolved_at)} class="badge badge-xs badge-error">active</span>
</div>
</div>
<% end %>
</div>
</div>
<%!-- Recent Activity: Config Changes + Insights --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<%!-- Config Changes --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<h3 class="font-semibold text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
<.icon name="hero-document-text" class="h-4 w-4 inline" /> Config Changes (24h)
</h3>
<%= if @trace.config_changes == [] do %>
<div class="flex items-center gap-2 text-green-600 dark:text-green-400">
<.icon name="hero-check-circle" class="h-5 w-5" />
<span class="text-sm font-medium">No config changes in last 24h</span>
</div>
<% else %>
<div class="space-y-2">
<div :for={change <- @trace.config_changes} class="text-sm">
<div class="flex items-center justify-between">
<span class="font-medium text-gray-900 dark:text-white">
{Map.get(change, :changed_sections, []) |> Enum.join(", ")}
</span>
<span class="text-xs text-gray-500">{format_relative_time(change.detected_at)}</span>
</div>
<div :if={Map.get(change, :diff_summary)} class="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-mono truncate">
{change.diff_summary}
</div>
</div>
</div>
<% end %>
</div>
</div>
<%!-- Active Insights --%>
<div class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<h3 class="font-semibold text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
<.icon name="hero-light-bulb" class="h-4 w-4 inline" /> Active Insights
</h3>
<%= if @trace.insights == [] do %>
<div class="flex items-center gap-2 text-green-600 dark:text-green-400">
<.icon name="hero-check-circle" class="h-5 w-5" />
<span class="text-sm font-medium">No active insights</span>
</div>
<% else %>
<div class="space-y-2">
<div :for={insight <- @trace.insights} class="flex items-start gap-2">
<span class={["mt-0.5 inline-block h-2.5 w-2.5 rounded-full flex-shrink-0", urgency_dot(insight.urgency)]}>
</span>
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-white">{insight.title}</div>
<div :if={insight.description} class="text-xs text-gray-500 dark:text-gray-400 line-clamp-2">{insight.description}</div>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>
<%!-- Inventory Items --%>
<div :if={@trace.inventory_items != []} class="card bg-base-100 shadow-sm border border-base-200 dark:border-white/10 dark:bg-gray-900">
<div class="card-body p-4 sm:p-6">
<h3 class="font-semibold text-sm uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-3">
<.icon name="hero-cube" class="h-4 w-4 inline" /> Equipment (from Gaiia)
</h3>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr class="text-xs uppercase text-gray-500">
<th>Name</th>
<th>IP</th>
<th>Model</th>
<th>Serial</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr :for={item <- @trace.inventory_items} class="hover">
<td class="font-medium text-gray-900 dark:text-white">{item.name}</td>
<td class="font-mono text-sm">{item.ip_address}</td>
<td>{item.model_name}</td>
<td class="font-mono text-xs">{item.serial_number}</td>
<td><span class={["badge badge-xs", status_badge_class(item.status)]}>{item.status}</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<%!-- Empty State --%>
<div :if={@query == "" && !@trace} class="text-center py-16">
<.icon name="hero-magnifying-glass-circle" class="h-16 w-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Subscriber Trace</h2>
<p class="text-gray-500 dark:text-gray-400 max-w-md mx-auto">
Search for a customer by name, IP address, account ID, or device name to see their full network status at a glance.
</p>
</div>
</div>
</Layouts.authenticated>

View file

@ -1,3 +1,11 @@
2026-02-13 — Device List Health Indicators
* Device list now shows a color-coded health dot next to each device name
- Green = all checks passing, Yellow = warnings, Red = critical, Gray = no data
* "Last Seen" column shows relative time with color coding (green = recent, yellow = stale, red = likely down)
* Response time badge shows latest ping/check latency at a glance
* Subscriber count badge appears for devices linked to Gaiia inventory items
* All data loads efficiently in batch — no impact on page load speed
2026-02-13 — Config Change Performance Correlation
* New Config Timeline page for each device — see config changes overlaid with performance metrics
* Automatically detects when a config change may have impacted network performance