feat: command center dashboard with unified insights and contextual enrichment

Transform the dashboard into a single-pane-of-glass command center that
blends operational metrics with business context from Gaiia subscriber data.

- Extend insight schema with multi-source support (snmp, gaiia, system)
- Add Oban workers for automated insight generation (device health, system, gaiia)
- New Dashboard context with health score computation and site summaries
- Rewrite dashboard LiveView with health overview, alert/insight feeds, site grid
- Add source filter pills for insights with URL state management
- Add metrics bar to site detail pages (device count, alerts, subscribers, MRR)
- Add subscriber/MRR context to alert list site links
- Add subscriber badges to device list site headers
- Real-time PubSub updates for alerts on dashboard
This commit is contained in:
Graham McIntire 2026-02-13 14:52:49 -06:00
parent 848efa8fc4
commit 20f5f48372
No known key found for this signature in database
29 changed files with 2503 additions and 218 deletions

View file

@ -198,7 +198,13 @@ if config_env() == :prod do
# Compute Preseem baselines and fleet profiles nightly at 2:30 AM
{"30 2 * * *", Towerops.Workers.PreseemBaselineWorker},
# Sync Gaiia data every 15 minutes
{"*/15 * * * *", Towerops.Workers.GaiiaSyncWorker}
{"*/15 * * * *", Towerops.Workers.GaiiaSyncWorker},
# Device health insights nightly at 3:30 AM
{"30 3 * * *", Towerops.Workers.DeviceHealthInsightWorker},
# System insights (agent offline detection) every 5 minutes
{"*/5 * * * *", Towerops.Workers.SystemInsightWorker},
# Gaiia reconciliation insights nightly at 4:30 AM
{"30 4 * * *", Towerops.Workers.GaiiaInsightWorker}
]},
# Automatically delete completed jobs after 60 seconds
{Oban.Plugins.Pruner, max_age: 60},

View file

@ -91,6 +91,19 @@ defmodule Towerops.Alerts do
)
end
@doc "Returns the count of active alerts for devices at a specific site."
def count_site_active_alerts(site_id) do
Repo.aggregate(
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)
),
:count
)
end
@doc """
Returns the list of all alerts for an organization.

129
lib/towerops/dashboard.ex Normal file
View file

@ -0,0 +1,129 @@
defmodule Towerops.Dashboard do
@moduledoc """
Aggregates data from multiple contexts for the command center dashboard.
"""
alias Towerops.Alerts
alias Towerops.Devices
alias Towerops.Gaiia
alias Towerops.Preseem.Insights
alias Towerops.Sites
@doc "Returns a complete dashboard summary for an organization."
def get_dashboard_summary(organization_id) do
status_counts = Devices.get_device_status_counts(organization_id)
total_devices = status_counts |> Map.values() |> Enum.sum()
active_alerts = Alerts.list_organization_active_alerts(organization_id)
subscriber_totals = Gaiia.get_org_subscriber_totals(organization_id)
insight_counts = Insights.count_active_by_urgency(organization_id)
%{
health_score: compute_health_score(organization_id),
devices: %{
total: total_devices,
up: Map.get(status_counts, :up, 0),
down: Map.get(status_counts, :down, 0),
unknown: Map.get(status_counts, :unknown, 0)
},
alerts: %{
active_count: length(active_alerts)
},
subscribers: %{
total: subscriber_totals.total_subscribers,
total_mrr: subscriber_totals.total_mrr
},
insights: insight_counts
}
end
@doc """
Compute a weighted 0-100 health score for an organization.
Weights:
- Device uptime % (40%)
- Response time health (20%) - currently based on device status
- Critical alert penalty (20%)
- Preseem QoE average (10%, skipped if unavailable)
- Agent online % (10%, skipped if no agents)
Unavailable components have their weight redistributed proportionally.
"""
def compute_health_score(organization_id) do
status_counts = Devices.get_device_status_counts(organization_id)
total = status_counts |> Map.values() |> Enum.sum()
if total == 0 do
100
else
up = Map.get(status_counts, :up, 0)
down = Map.get(status_counts, :down, 0)
active_alerts = Alerts.list_organization_active_alerts(organization_id)
# Device uptime percentage (0.0-1.0)
uptime_ratio = up / total
# Response time health - approximate from up/unknown ratio
responsive_ratio = (total - down) / total
# Alert penalty - each alert reduces score, capped at full penalty
alert_penalty = min(length(active_alerts) * 0.1, 1.0)
alert_score = 1.0 - alert_penalty
# Weighted score (redistribute Preseem/Agent weights to core metrics)
# Base weights: uptime 40%, response 20%, alerts 20%, preseem 10%, agents 10%
# Without preseem/agents: uptime 50%, response 25%, alerts 25%
score =
uptime_ratio * 50.0 +
responsive_ratio * 25.0 +
alert_score * 25.0
round(score)
end
end
@doc "Returns subscriber summary for the org from Gaiia data."
def get_org_subscriber_summary(organization_id) do
totals = Gaiia.get_org_subscriber_totals(organization_id)
%{
total: totals.total_subscribers,
total_mrr: totals.total_mrr
}
end
@doc "Returns per-site summaries with device counts, alerts, and subscriber data."
def list_site_summaries(organization_id) do
sites = Sites.list_organization_sites(organization_id)
Enum.map(sites, fn site ->
device_count = Devices.count_site_devices(site.id)
down_count = Devices.count_site_devices_down(site.id)
gaiia_summary = Gaiia.get_site_subscriber_summary(site.id)
%{
site_id: site.id,
name: site.name,
device_count: device_count,
devices_down: down_count,
subscribers: if(gaiia_summary, do: gaiia_summary.account_count),
mrr: if(gaiia_summary, do: gaiia_summary.total_mrr)
}
end)
end
@doc "Returns a summary for a single site."
def get_site_summary(site_id) do
device_count = Devices.count_site_devices(site_id)
down_count = Devices.count_site_devices_down(site_id)
alert_count = Alerts.count_site_active_alerts(site_id)
gaiia_summary = Gaiia.get_site_subscriber_summary(site_id)
%{
device_count: device_count,
devices_down: down_count,
alert_count: alert_count,
subscribers: if(gaiia_summary, do: gaiia_summary.account_count),
mrr: if(gaiia_summary, do: gaiia_summary.total_mrr)
}
end
end

View file

@ -219,4 +219,32 @@ defmodule Towerops.Gaiia do
defp confidence_rank(:high), do: 0
defp confidence_rank(:medium), do: 1
defp confidence_rank(:low), do: 2
# --- Aggregate Queries ---
@doc "Sum subscriber count and MRR across all Gaiia network sites for an organization."
def get_org_subscriber_totals(organization_id) do
result =
NetworkSite
|> where(organization_id: ^organization_id)
|> select([ns], %{
total_subscribers: coalesce(sum(ns.account_count), 0),
total_mrr: coalesce(sum(ns.total_mrr), 0)
})
|> Repo.one()
%{
total_subscribers: result.total_subscribers,
total_mrr: result.total_mrr
}
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
|> where(site_id: ^site_id)
|> limit(1)
|> select([ns], %{account_count: ns.account_count, total_mrr: ns.total_mrr})
|> Repo.one()
end
end

View file

@ -10,10 +10,11 @@ defmodule Towerops.Preseem.Insight do
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@valid_types ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift)
@valid_types ~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap agent_offline firmware_mismatch reconciliation_finding subscriber_growth_trend)
@valid_urgencies ~w(critical warning info)
@valid_statuses ~w(active dismissed resolved)
@valid_channels ~w(proactive contextual passive)
@valid_sources ~w(preseem snmp gaiia system)
schema "preseem_insights" do
field :type, :string
@ -24,10 +25,13 @@ defmodule Towerops.Preseem.Insight do
field :description, :string
field :metadata, :map
field :dismissed_at, :utc_datetime
field :source, :string, default: "preseem"
belongs_to :organization, Towerops.Organizations.Organization
belongs_to :preseem_access_point, Towerops.Preseem.AccessPoint
belongs_to :device, Towerops.Devices.Device
belongs_to :site, Towerops.Sites.Site
belongs_to :agent_token, Towerops.Agents.AgentToken
timestamps(type: :utc_datetime)
end
@ -38,6 +42,8 @@ defmodule Towerops.Preseem.Insight do
:organization_id,
:preseem_access_point_id,
:device_id,
:site_id,
:agent_token_id,
:type,
:urgency,
:status,
@ -45,15 +51,19 @@ defmodule Towerops.Preseem.Insight do
:title,
:description,
:metadata,
:dismissed_at
:dismissed_at,
:source
])
|> validate_required([:organization_id, :type, :urgency, :channel, :title])
|> validate_inclusion(:type, @valid_types)
|> validate_inclusion(:urgency, @valid_urgencies)
|> validate_inclusion(:status, @valid_statuses)
|> validate_inclusion(:channel, @valid_channels)
|> validate_inclusion(:source, @valid_sources)
|> foreign_key_constraint(:organization_id)
|> foreign_key_constraint(:preseem_access_point_id)
|> foreign_key_constraint(:device_id)
|> foreign_key_constraint(:site_id)
|> foreign_key_constraint(:agent_token_id)
end
end

View file

@ -17,6 +17,7 @@ defmodule Towerops.Preseem.Insights do
status = Keyword.get(opts, :status, "active")
type = Keyword.get(opts, :type)
urgency = Keyword.get(opts, :urgency)
source = Keyword.get(opts, :source)
limit = Keyword.get(opts, :limit, 50)
query =
@ -28,10 +29,39 @@ defmodule Towerops.Preseem.Insights do
query = if type, do: where(query, type: ^type), else: query
query = if urgency, do: where(query, urgency: ^urgency), else: query
query = if source, do: where(query, source: ^source), else: query
Repo.all(query)
end
@doc "List active insights for a specific site."
def list_site_insights(site_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 50)
Insight
|> where(site_id: ^site_id, status: "active")
|> order_by_urgency()
|> limit(^limit)
|> Repo.all()
end
@doc "Count active insights grouped by urgency for an organization."
def count_active_by_urgency(organization_id) do
results =
Insight
|> where(organization_id: ^organization_id, status: "active")
|> group_by(:urgency)
|> select([i], {i.urgency, count(i.id)})
|> Repo.all()
|> Map.new()
%{
critical: Map.get(results, "critical", 0),
warning: Map.get(results, "warning", 0),
info: Map.get(results, "info", 0)
}
end
@doc "List active insights for a specific device."
def list_device_insights(device_id) do
Insight
@ -69,6 +99,46 @@ defmodule Towerops.Preseem.Insights do
{:ok, count}
end
@doc """
Insert an insight if no active insight with the same type exists for the same entity.
Deduplicates by: device_id + type, site_id + type, or agent_token_id + type.
Returns `{:ok, insight}` on insert, `{:ok, :duplicate}` if already exists.
"""
def insert_insight_if_new(attrs) do
dedup_query = build_dedup_query(attrs)
if dedup_query && Repo.exists?(dedup_query) do
{:ok, :duplicate}
else
case %Insight{} |> Insight.changeset(attrs) |> Repo.insert() do
{:ok, insight} -> {:ok, insight}
{:error, changeset} -> {:error, changeset}
end
end
end
defp build_dedup_query(%{device_id: device_id, type: type}) when not is_nil(device_id) do
where(Insight, device_id: ^device_id, type: ^type, status: "active")
end
defp build_dedup_query(%{site_id: site_id, type: type}) when not is_nil(site_id) do
where(Insight, site_id: ^site_id, type: ^type, status: "active")
end
defp build_dedup_query(%{agent_token_id: agent_token_id, type: type}) when not is_nil(agent_token_id) do
where(Insight, agent_token_id: ^agent_token_id, type: ^type, status: "active")
end
defp build_dedup_query(%{organization_id: org_id, type: type, dedup_key: dedup_key})
when not is_nil(org_id) and not is_nil(dedup_key) do
Insight
|> where(organization_id: ^org_id, type: ^type, status: "active")
|> where([i], fragment("?->>'dedup_key' = ?", i.metadata, ^dedup_key))
end
defp build_dedup_query(_attrs), do: nil
# --- Generation functions ---
@doc "Generate insights for all matched APs in an organization."

View file

@ -0,0 +1,57 @@
defmodule Towerops.Workers.DeviceHealthInsightWorker do
@moduledoc """
Oban cron worker that generates SNMP-based health insights.
Runs nightly to detect:
- `device_poll_gap`: Devices not polled in over 24 hours
"""
use Oban.Worker, queue: :maintenance
import Ecto.Query
alias Towerops.Devices.Device
alias Towerops.Preseem.Insights
alias Towerops.Repo
@poll_gap_hours 24
@impl Oban.Worker
def perform(%Oban.Job{}) do
devices = list_stale_monitored_devices()
Enum.each(devices, &generate_poll_gap_insight/1)
:ok
end
defp list_stale_monitored_devices do
cutoff = DateTime.add(DateTime.utc_now(), -@poll_gap_hours, :hour)
Device
|> where([d], d.monitoring_enabled == true)
|> where([d], not is_nil(d.last_snmp_poll_at))
|> where([d], d.last_snmp_poll_at < ^cutoff)
|> Repo.all()
end
defp generate_poll_gap_insight(device) do
hours_ago = DateTime.diff(DateTime.utc_now(), device.last_snmp_poll_at, :hour)
Insights.insert_insight_if_new(%{
organization_id: device.organization_id,
device_id: device.id,
type: "device_poll_gap",
urgency: "warning",
channel: "proactive",
source: "snmp",
title: "#{device.name} has not been polled in #{hours_ago} hours",
description:
"Device #{device.name} was last polled #{hours_ago} hours ago. " <>
"Check SNMP connectivity and agent status.",
metadata: %{
"last_poll_at" => DateTime.to_iso8601(device.last_snmp_poll_at),
"hours_since_poll" => hours_ago
}
})
end
end

View file

@ -0,0 +1,105 @@
defmodule Towerops.Workers.GaiiaInsightWorker do
@moduledoc """
Oban cron worker that generates Gaiia-based insights.
Runs nightly to detect:
- `reconciliation_finding`: Untracked devices, ghost devices, data mismatches
"""
use Oban.Worker, queue: :maintenance
alias Towerops.Gaiia.Reconciliation
alias Towerops.Integrations
alias Towerops.Preseem.Insights
@impl Oban.Worker
def perform(%Oban.Job{}) do
integrations = Integrations.list_enabled_integrations("gaiia")
Enum.each(integrations, fn integration ->
process_reconciliation(integration.organization_id)
end)
:ok
end
defp process_reconciliation(organization_id) do
result = Reconciliation.reconcile(organization_id)
generate_untracked_insights(organization_id, result.untracked_devices)
generate_ghost_insights(organization_id, result.ghost_devices)
generate_mismatch_insights(organization_id, result.data_mismatches)
end
defp generate_untracked_insights(organization_id, untracked_devices) do
count = length(untracked_devices)
if count > 0 do
Insights.insert_insight_if_new(%{
organization_id: organization_id,
type: "reconciliation_finding",
urgency: "info",
channel: "passive",
source: "gaiia",
dedup_key: "untracked",
title: "#{count} device(s) not tracked in Gaiia",
description:
"#{count} device(s) discovered by Towerops have no matching Gaiia inventory item. " <>
"Consider adding them to Gaiia for complete subscriber impact tracking.",
metadata: %{
"dedup_key" => "untracked",
"finding_type" => "untracked",
"count" => count,
"device_names" => untracked_devices |> Enum.take(10) |> Enum.map(& &1.device.name)
}
})
end
end
defp generate_ghost_insights(organization_id, ghost_devices) do
count = length(ghost_devices)
if count > 0 do
Insights.insert_insight_if_new(%{
organization_id: organization_id,
type: "reconciliation_finding",
urgency: "warning",
channel: "proactive",
source: "gaiia",
dedup_key: "ghost",
title: "#{count} Gaiia item(s) reference missing devices",
description:
"#{count} Gaiia inventory item(s) are mapped to devices that no longer exist in Towerops. " <>
"Review and clean up stale mappings.",
metadata: %{
"dedup_key" => "ghost",
"finding_type" => "ghost",
"count" => count
}
})
end
end
defp generate_mismatch_insights(organization_id, data_mismatches) do
count = length(data_mismatches)
if count > 0 do
Insights.insert_insight_if_new(%{
organization_id: organization_id,
type: "reconciliation_finding",
urgency: "info",
channel: "passive",
source: "gaiia",
dedup_key: "mismatch",
title: "#{count} data mismatch(es) between Towerops and Gaiia",
description:
"#{count} device(s) have mismatched data between Towerops and Gaiia inventory. " <>
"Review IP addresses and other fields for accuracy.",
metadata: %{
"dedup_key" => "mismatch",
"finding_type" => "mismatch",
"count" => count
}
})
end
end
end

View file

@ -0,0 +1,91 @@
defmodule Towerops.Workers.SystemInsightWorker do
@moduledoc """
Oban cron worker that generates system-level insights.
Runs every 5 minutes to detect:
- `agent_offline`: Agents not seen in over 10 minutes
- Auto-resolves agent_offline insights when agents come back online
"""
use Oban.Worker, queue: :maintenance
import Ecto.Query
alias Towerops.Agents.AgentToken
alias Towerops.Organizations.Organization
alias Towerops.Preseem.Insight
alias Towerops.Preseem.Insights
alias Towerops.Repo
@stale_threshold_minutes 10
@impl Oban.Worker
def perform(%Oban.Job{}) do
org_ids = Repo.all(from(o in Organization, select: o.id))
Enum.each(org_ids, fn org_id ->
offline_agents = list_offline_agents(org_id)
offline_ids = MapSet.new(offline_agents, & &1.id)
Enum.each(offline_agents, &generate_agent_offline_insight/1)
auto_resolve_recovered_agents(org_id, offline_ids)
end)
:ok
end
defp list_offline_agents(organization_id) do
cutoff = DateTime.add(DateTime.utc_now(), -@stale_threshold_minutes, :minute)
AgentToken
|> where([a], a.organization_id == ^organization_id)
|> where([a], a.enabled == true)
|> where([a], not is_nil(a.last_seen_at))
|> where([a], a.last_seen_at < ^cutoff)
|> Repo.all()
end
defp generate_agent_offline_insight(agent) do
minutes_offline = DateTime.diff(DateTime.utc_now(), agent.last_seen_at, :minute)
Insights.insert_insight_if_new(%{
organization_id: agent.organization_id,
agent_token_id: agent.id,
type: "agent_offline",
urgency: "critical",
channel: "proactive",
source: "system",
title: "Agent '#{agent.name}' is offline",
description:
"Agent #{agent.name} has not been seen for #{minutes_offline} minutes. " <>
"Check agent connectivity and host status.",
metadata: %{
"agent_name" => agent.name,
"last_seen_at" => DateTime.to_iso8601(agent.last_seen_at),
"minutes_offline" => minutes_offline
}
})
end
defp auto_resolve_recovered_agents(organization_id, currently_offline_ids) do
now = DateTime.truncate(DateTime.utc_now(), :second)
active_agent_insights =
Insight
|> where(
[i],
i.organization_id == ^organization_id and
i.type == "agent_offline" and
i.status == "active"
)
|> where([i], not is_nil(i.agent_token_id))
|> Repo.all()
Enum.each(active_agent_insights, fn insight ->
if !MapSet.member?(currently_offline_ids, insight.agent_token_id) do
insight
|> Insight.changeset(%{status: "resolved", dismissed_at: now})
|> Repo.update()
end
end)
end
end

View file

@ -3,6 +3,7 @@ defmodule ToweropsWeb.AlertLive.Index do
use ToweropsWeb, :live_view
alias Towerops.Alerts
alias Towerops.Gaiia
alias ToweropsWeb.Live.Helpers.AccessControl
@impl true
@ -83,6 +84,36 @@ defmodule ToweropsWeb.AlertLive.Index do
Alerts.list_organization_alerts(organization_id, 100)
end
assign(socket, :alerts, alerts)
site_subscribers = load_site_subscribers(alerts)
socket
|> assign(:alerts, alerts)
|> assign(:site_subscribers, site_subscribers)
end
defp load_site_subscribers(alerts) do
alerts
|> Enum.map(& &1.device.site_id)
|> Enum.uniq()
|> Enum.reject(&is_nil/1)
|> Map.new(fn site_id -> {site_id, Gaiia.get_site_subscriber_summary(site_id)} end)
end
defp format_number(number) when is_integer(number) do
number
|> Integer.to_string()
|> String.graphemes()
|> Enum.reverse()
|> Enum.chunk_every(3)
|> Enum.join(",")
|> String.reverse()
end
defp format_number(number), do: to_string(number)
defp format_mrr(%Decimal{} = amount) do
"$#{format_number(Decimal.to_integer(Decimal.round(amount, 0)))}"
end
defp format_mrr(_), do: nil
end

View file

@ -153,6 +153,18 @@
>
{alert.device.site.name}
</.link>
<%= if @site_subscribers[alert.device.site_id] do %>
<span class="text-gray-400 dark:text-gray-600 mx-1">&middot;</span>
<span class="text-amber-700 dark:text-amber-400">
{@site_subscribers[alert.device.site_id].account_count} subscribers
</span>
<%= if @site_subscribers[alert.device.site_id].total_mrr do %>
<span class="text-gray-400 dark:text-gray-600 mx-1">&middot;</span>
<span class="text-amber-700 dark:text-amber-400">
{format_mrr(@site_subscribers[alert.device.site_id].total_mrr)}/mo MRR
</span>
<% end %>
<% end %>
</div>
</div>
</div>

View file

@ -3,26 +3,80 @@ defmodule ToweropsWeb.DashboardLive do
use ToweropsWeb, :live_view
alias Towerops.Alerts
alias Towerops.Dashboard
alias Towerops.Devices
alias Towerops.Preseem
alias Towerops.Preseem.Insights
alias Towerops.Sites
alias ToweropsWeb.Live.Helpers.AccessControl
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
# Subscribe to real-time alert updates
_ =
if connected?(socket) do
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:new")
Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:resolved")
end
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:new")
Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:resolved")
end
status_counts = Devices.get_device_status_counts(organization.id)
device_count = status_counts |> Map.values() |> Enum.sum()
{:ok,
socket
|> assign(:page_title, organization.name)
|> assign(:device_count, device_count)
|> assign(:insight_source, nil)}
end
@impl true
def handle_params(params, _url, socket) do
organization = socket.assigns.current_scope.organization
insight_source = params["insight_source"]
{:noreply,
socket
|> assign(:insight_source, insight_source)
|> load_dashboard_data(organization.id)}
end
@impl true
def handle_event("dismiss_insight", %{"id" => insight_id}, socket) do
case Preseem.dismiss_insight(insight_id) do
{:ok, _} ->
{:noreply,
socket
|> load_insights(socket.assigns.current_scope.organization.id)
|> put_flash(:info, "Insight dismissed")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to dismiss insight")}
end
end
@impl true
def handle_event("acknowledge_alert", %{"id" => id}, socket) do
organization = socket.assigns.current_scope.organization
user_id = socket.assigns.current_scope.user.id
case AccessControl.verify_alert_access(id, organization.id) do
{:ok, alert} ->
case Alerts.acknowledge_alert(alert, user_id) do
{:ok, _} ->
{:noreply,
socket
|> load_dashboard_data(organization.id)
|> put_flash(:info, "Alert acknowledged")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Failed to acknowledge alert")}
end
{:error, _} ->
{:noreply, put_flash(socket, :error, "Alert not found")}
end
end
@impl true
def handle_info({:new_alert, _device_id, _alert_type}, socket) do
{:noreply, load_dashboard_data(socket, socket.assigns.current_scope.organization.id)}
@ -35,18 +89,89 @@ defmodule ToweropsWeb.DashboardLive do
defp load_dashboard_data(socket, organization_id) do
organization = socket.assigns.current_scope.organization
summary = Dashboard.get_dashboard_summary(organization_id)
active_alerts = Alerts.list_organization_active_alerts(organization_id)
sites_count = if organization.use_sites, do: Sites.count_organization_sites(organization_id), else: 0
status_counts = Devices.get_device_status_counts(organization_id)
status_counts = Devices.get_device_status_counts(organization_id)
device_count = status_counts |> Map.values() |> Enum.sum()
has_subscribers = summary.subscribers.total > 0
site_summaries =
if organization.use_sites do
Dashboard.list_site_summaries(organization_id)
else
[]
end
socket
|> assign(:active_alerts, active_alerts)
|> assign(:summary, summary)
|> assign(:active_alerts, Enum.take(active_alerts, 20))
|> assign(:total_alert_count, length(active_alerts))
|> assign(:sites_count, sites_count)
|> assign(:device_count, device_count)
|> assign(:device_up, Map.get(status_counts, :up, 0))
|> assign(:device_down, Map.get(status_counts, :down, 0))
|> assign(:device_unknown, Map.get(status_counts, :unknown, 0))
|> assign(:has_subscribers, has_subscribers)
|> assign(:site_summaries, site_summaries)
|> load_insights(organization_id)
end
defp load_insights(socket, organization_id) do
opts = [status: "active", limit: 15]
opts =
if socket.assigns.insight_source,
do: Keyword.put(opts, :source, socket.assigns.insight_source),
else: opts
insights = Insights.list_insights(organization_id, opts)
assign(socket, :insights, insights)
end
defp health_score_color(score) when score > 80, do: "text-green-600 dark:text-green-400"
defp health_score_color(score) when score > 50, do: "text-yellow-600 dark:text-yellow-400"
defp health_score_color(_score), do: "text-red-600 dark:text-red-400"
defp health_score_bg(score) when score > 80,
do: "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800"
defp health_score_bg(score) when score > 50,
do: "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800"
defp health_score_bg(_score), do: "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800"
defp urgency_classes("critical"), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
defp urgency_classes("warning"), do: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
defp urgency_classes("info"), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
defp urgency_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
defp source_classes("preseem"), do: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
defp source_classes("snmp"), do: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400"
defp source_classes("gaiia"), do: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400"
defp source_classes("system"), do: "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"
defp source_classes(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
defp format_mrr(nil), do: "$0"
defp format_mrr(%Decimal{} = amount) do
"$#{format_number(Decimal.to_integer(Decimal.round(amount, 0)))}"
end
defp format_mrr(amount) when is_number(amount), do: "$#{format_number(round(amount))}"
defp format_mrr(_), do: "$0"
defp format_number(number) when is_integer(number) do
number
|> Integer.to_string()
|> String.graphemes()
|> Enum.reverse()
|> Enum.chunk_every(3)
|> Enum.join(",")
|> String.reverse()
end
defp format_number(number), do: to_string(number)
end

View file

@ -4,7 +4,7 @@
active_page="dashboard"
>
<.header>
Dashboard
Command Center
<:subtitle>Welcome to {@current_scope.organization.name}</:subtitle>
</.header>
@ -86,119 +86,354 @@
</div>
</div>
<% else %>
<div class={[
"mt-8 grid gap-6",
(@current_scope.organization.use_sites && "md:grid-cols-2 lg:grid-cols-4") ||
"md:grid-cols-3"
]}>
<%= if @current_scope.organization.use_sites do %>
<.link
navigate={~p"/sites"}
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Sites</h3>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{@sites_count}</p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">Total sites</p>
</.link>
<% end %>
<.link
navigate={~p"/devices"}
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Device</h3>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{@device_count}</p>
<div class="mt-3 space-y-1.5 text-sm">
<div class="flex items-center gap-2 text-gray-700 dark:text-gray-300">
<span class="h-2 w-2 rounded-full bg-green-500"></span>
{@device_up} Up
</div>
<div class="flex items-center gap-2 text-gray-700 dark:text-gray-300">
<span class="h-2 w-2 rounded-full bg-red-500"></span>
{@device_down} Down
</div>
<div class="flex items-center gap-2 text-gray-700 dark:text-gray-300">
<span class="h-2 w-2 rounded-full bg-gray-400"></span>
{@device_unknown} Unknown
</div>
</div>
</.link>
<%!-- Section A: Health Overview --%>
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
<%!-- Health Score --%>
<div class={[
"rounded-lg border p-4 shadow-sm",
health_score_bg(@summary.health_score)
]}>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Health Score</h3>
<p class={["mt-1 text-3xl font-bold", health_score_color(@summary.health_score)]}>
{@summary.health_score}
</p>
</div>
<%!-- Active Alerts --%>
<.link
navigate={~p"/alerts"}
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Active Alerts</h3>
<p class={[
"mt-2 text-3xl font-bold",
(length(@active_alerts) > 0 && "text-red-600 dark:text-red-500") ||
"mt-1 text-3xl font-bold",
(@total_alert_count > 0 && "text-red-600 dark:text-red-500") ||
"text-gray-900 dark:text-white"
]}>
{length(@active_alerts)}
{@total_alert_count}
</p>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">Requires attention</p>
</.link>
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-gray-800/50">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">System Status</h3>
<div class={[
"mt-3 flex items-center gap-2 text-sm font-semibold",
(@device_down == 0 && "text-green-600 dark:text-green-500") ||
"text-amber-600 dark:text-amber-500"
]}>
<%= if @device_down == 0 do %>
<.icon name="hero-check-circle" class="h-5 w-5" />
<span>All Systems Operational</span>
<% else %>
<.icon name="hero-exclamation-triangle" class="h-5 w-5" />
<span>{@device_down} Device Down</span>
<%!-- Devices --%>
<.link
navigate={~p"/devices"}
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Devices</h3>
<p class="mt-1 text-3xl font-bold text-gray-900 dark:text-white">{@device_count}</p>
<div class="mt-2 flex items-center gap-3 text-xs">
<span class="flex items-center gap-1 text-green-600 dark:text-green-400">
<span class="h-2 w-2 rounded-full bg-green-500"></span>
{@device_up} Up
</span>
<span class="flex items-center gap-1 text-red-600 dark:text-red-400">
<span class="h-2 w-2 rounded-full bg-red-500"></span>
{@device_down} Down
</span>
<span class="flex items-center gap-1 text-gray-500 dark:text-gray-400">
<span class="h-2 w-2 rounded-full bg-gray-400"></span>
{@device_unknown} Unknown
</span>
</div>
</.link>
<%!-- Subscribers (only when Gaiia data exists) --%>
<%= if @has_subscribers do %>
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-800/50">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Subscribers</h3>
<p class="mt-1 text-3xl font-bold text-gray-900 dark:text-white">
{format_number(@summary.subscribers.total)}
</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{format_mrr(@summary.subscribers.total_mrr)}/mo MRR
</p>
</div>
<% end %>
<%!-- Sites (if enabled) --%>
<%= if @current_scope.organization.use_sites do %>
<.link
navigate={~p"/sites"}
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
>
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Sites</h3>
<p class="mt-1 text-3xl font-bold text-gray-900 dark:text-white">{@sites_count}</p>
</.link>
<% end %>
<%!-- Insights --%>
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-800/50">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Insights</h3>
<div class="mt-1 flex items-baseline gap-2">
<p class="text-3xl font-bold text-gray-900 dark:text-white">
{@summary.insights.critical + @summary.insights.warning + @summary.insights.info}
</p>
</div>
<div class="mt-2 flex items-center gap-2 text-xs">
<%= if @summary.insights.critical > 0 do %>
<span class="inline-flex items-center rounded-full bg-red-100 px-1.5 py-0.5 text-red-800 dark:bg-red-900/30 dark:text-red-400">
{@summary.insights.critical} critical
</span>
<% end %>
<%= if @summary.insights.warning > 0 do %>
<span class="inline-flex items-center rounded-full bg-yellow-100 px-1.5 py-0.5 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
{@summary.insights.warning} warning
</span>
<% end %>
<%= if @summary.insights.info > 0 do %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-1.5 py-0.5 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
{@summary.insights.info} info
</span>
<% end %>
</div>
</div>
</div>
<%= if length(@active_alerts) > 0 do %>
<div class="mt-8">
<%!-- Section B+C: Two-column layout --%>
<div class="mt-8 grid gap-8 lg:grid-cols-5">
<%!-- Left: Alert Feed (3 cols) --%>
<div class="lg:col-span-3">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Active Alerts</h2>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Active Alerts</h2>
<.link
navigate={~p"/alerts"}
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
>
View All Alerts →
View All →
</.link>
</div>
<div class="space-y-3">
<%= for alert <- Enum.take(@active_alerts, 5) do %>
<div class="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900/50 dark:bg-red-950/30">
<.icon
name="hero-exclamation-triangle"
class="h-5 w-5 flex-shrink-0 text-red-600 dark:text-red-500"
/>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-gray-900 dark:text-white">
<.link
navigate={~p"/devices/#{alert.device.id}"}
class="hover:text-blue-600 hover:underline dark:hover:text-blue-400"
>
{alert.device.name}
</.link>
</h3>
<p class="mt-0.5 text-sm text-gray-700 dark:text-gray-300">{alert.message}</p>
</div>
<.timestamp
datetime={alert.triggered_at}
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"
/>
</div>
<% end %>
<%= if @active_alerts == [] do %>
<div class="rounded-lg border border-gray-200 bg-white p-8 text-center dark:border-white/10 dark:bg-gray-800/50">
<.icon name="hero-check-circle" class="mx-auto h-10 w-10 text-green-500" />
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">No active alerts</p>
</div>
<% else %>
<div class="space-y-3">
<div
:for={alert <- @active_alerts}
id={"alert-#{alert.id}"}
class={[
"rounded-lg border p-4 shadow-sm",
"border-l-4 border-red-500 bg-red-50 dark:bg-red-950/30 dark:border-red-700"
]}
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900 dark:text-red-200">
<.icon name="hero-exclamation-triangle" class="h-3.5 w-3.5" /> Device Down
</span>
<%= if alert.acknowledged_at do %>
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Acknowledged
</span>
<% end %>
</div>
<h3 class="mt-1.5 font-semibold text-gray-900 dark:text-white">
<.link
navigate={~p"/devices/#{alert.device.id}"}
class="hover:text-blue-600 hover:underline dark:hover:text-blue-400"
>
{alert.device.name}
</.link>
</h3>
<p class="mt-0.5 text-sm text-gray-700 dark:text-gray-300">{alert.message}</p>
<%= if length(@active_alerts) > 5 do %>
<div class="text-center text-sm text-gray-500 dark:text-gray-400">
+ {length(@active_alerts) - 5} more alerts
<%!-- Gaiia impact badges --%>
<%= if alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] && alert.gaiia_impact["total_subscribers"] > 0 do %>
<div class="mt-2 flex items-center gap-2">
<span class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-400/10 dark:text-amber-400 dark:ring-amber-400/20">
<.icon name="hero-users" class="h-3 w-3" />
{alert.gaiia_impact["total_subscribers"]} affected
</span>
<%= if alert.gaiia_impact["total_mrr"] && alert.gaiia_impact["total_mrr"] != "0" do %>
<span class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-400/10 dark:text-amber-400 dark:ring-amber-400/20">
<.icon name="hero-currency-dollar" class="h-3 w-3" />
${alert.gaiia_impact["total_mrr"]}/mo
</span>
<% end %>
</div>
<% end %>
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<.timestamp datetime={alert.triggered_at} />
<span :if={alert.device.site} class="ml-2">
at
<.link
navigate={~p"/sites/#{alert.device.site.id}"}
class="text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
{alert.device.site.name}
</.link>
</span>
</div>
</div>
<div class="flex-shrink-0">
<%= if is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>
<button
type="button"
phx-click="acknowledge_alert"
phx-value-id={alert.id}
class="rounded-md bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:ring-gray-600 dark:hover:bg-gray-700"
>
Acknowledge
</button>
<% end %>
</div>
</div>
</div>
</div>
<%= if @total_alert_count > 20 do %>
<div class="mt-3 text-center text-sm text-gray-500 dark:text-gray-400">
+ {@total_alert_count - 20} more alerts.
<.link
navigate={~p"/alerts"}
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
>
View All
</.link>
</div>
<% end %>
<% end %>
</div>
<%!-- Right: Insights Feed (2 cols) --%>
<div class="lg:col-span-2">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Insights</h2>
</div>
<%!-- Source filter pills --%>
<div class="mb-4 flex flex-wrap items-center gap-2">
<.link
patch={~p"/dashboard"}
class={[
"rounded-md px-2.5 py-1 text-xs font-medium",
if(is_nil(@insight_source),
do: "bg-gray-200 text-gray-800 dark:bg-white/20 dark:text-white",
else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10"
)
]}
>
All
</.link>
<.link
:for={source <- ~w(preseem snmp gaiia system)}
patch={~p"/dashboard?insight_source=#{source}"}
class={[
"rounded-md px-2.5 py-1 text-xs font-medium",
if(@insight_source == source,
do: "bg-gray-200 text-gray-800 dark:bg-white/20 dark:text-white",
else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10"
)
]}
>
{source}
</.link>
</div>
<%= if @insights == [] do %>
<div class="rounded-lg border border-gray-200 bg-white p-8 text-center dark:border-white/10 dark:bg-gray-800/50">
<.icon
name="hero-light-bulb"
class="mx-auto h-10 w-10 text-gray-400 dark:text-gray-500"
/>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">No active insights</p>
</div>
<% else %>
<div class="space-y-3">
<div
:for={insight <- @insights}
id={"insight-#{insight.id}"}
class="rounded-lg border border-gray-200 bg-white p-3 dark:border-white/10 dark:bg-gray-800/50"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5 flex-wrap">
<span class={[
"inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium",
urgency_classes(insight.urgency)
]}>
{insight.urgency}
</span>
<span class={[
"inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium",
source_classes(insight.source)
]}>
{insight.source}
</span>
</div>
<h4 class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">
{insight.title}
</h4>
<%= if insight.description do %>
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
{insight.description}
</p>
<% end %>
</div>
<button
type="button"
id={"dismiss-insight-#{insight.id}"}
phx-click="dismiss_insight"
phx-value-id={insight.id}
class="flex-shrink-0 rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-white/10 dark:hover:text-gray-300"
title="Dismiss"
>
<.icon name="hero-x-mark" class="h-4 w-4" />
</button>
</div>
</div>
</div>
<% end %>
</div>
</div>
<%!-- Section D: Site Health Grid --%>
<%= if @current_scope.organization.use_sites && @site_summaries != [] do %>
<div class="mt-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Site Health</h2>
<.link
navigate={~p"/sites"}
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
>
View All Sites →
</.link>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<.link
:for={site <- @site_summaries}
navigate={~p"/sites/#{site.site_id}"}
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
>
<h3 class="font-semibold text-gray-900 dark:text-white">{site.name}</h3>
<div class="mt-2 flex items-center gap-3 text-sm">
<span class="text-gray-600 dark:text-gray-400">
{site.device_count} devices
</span>
<%= if site.devices_down > 0 do %>
<span class="flex items-center gap-1 text-red-600 dark:text-red-400">
<span class="h-2 w-2 rounded-full bg-red-500"></span>
{site.devices_down} down
</span>
<% end %>
</div>
<%= if site.subscribers do %>
<div class="mt-2 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1">
<.icon name="hero-users" class="h-3.5 w-3.5" />
{site.subscribers} subscribers
</span>
<%= if site.mrr do %>
<span>{format_mrr(site.mrr)}/mo</span>
<% end %>
</div>
<% end %>
</.link>
</div>
</div>
<% end %>

View file

@ -3,6 +3,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
use ToweropsWeb, :live_view
alias Towerops.Devices
alias Towerops.Gaiia
alias Towerops.Organizations.SubscriptionLimits
alias Towerops.Sites
alias Towerops.Snmp
@ -23,6 +24,8 @@ defmodule ToweropsWeb.DeviceLive.Index do
# Load device quota
{current, limit} = SubscriptionLimits.device_quota(organization)
site_subscribers = load_site_subscribers(devices)
{:ok,
socket
|> assign(:page_title, t_equipment("Devices"))
@ -33,6 +36,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
|> assign(:has_sites, sites != [])
|> assign(:reorder_mode, false)
|> assign(:device_quota, %{current: current, limit: limit})
|> assign(:site_subscribers, site_subscribers)
|> assign(:pending_reload_ref, nil)}
end
@ -287,6 +291,7 @@ defmodule ToweropsWeb.DeviceLive.Index do
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> assign(:has_devices, devices != [])
|> assign(:device_quota, %{current: current, limit: limit})
|> assign(:site_subscribers, load_site_subscribers(devices))
end
# Lightweight reload: stream only, skip quota query (for status/update changes)
@ -377,6 +382,14 @@ defmodule ToweropsWeb.DeviceLive.Index do
type |> to_string() |> String.capitalize()
end
defp load_site_subscribers(devices) do
devices
|> Enum.map(& &1.site_id)
|> Enum.uniq()
|> Enum.reject(&is_nil/1)
|> Map.new(fn site_id -> {site_id, Gaiia.get_site_subscriber_summary(site_id)} end)
end
# 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

@ -285,6 +285,12 @@
>
{row.stats.down} down
</span>
<%= if row.site && @site_subscribers[row.site.id] do %>
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-amber-50 text-amber-800 dark:bg-amber-400/10 dark:text-amber-400">
<.icon name="hero-users" class="h-3 w-3" />
{@site_subscribers[row.site.id].account_count} subscribers
</span>
<% end %>
</div>
<p
:if={row.site && row.site.location}

View file

@ -2,6 +2,7 @@ defmodule ToweropsWeb.SiteLive.Show do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Dashboard
alias Towerops.Devices
alias Towerops.Monitoring
alias Towerops.Sites
@ -19,13 +20,15 @@ defmodule ToweropsWeb.SiteLive.Show do
site = Sites.get_organization_site!(organization.id, id)
device = Devices.list_site_devices(site.id)
latency_chart_data = load_site_latency_chart_data(device)
site_summary = Dashboard.get_site_summary(site.id)
{:noreply,
socket
|> assign(:page_title, site.name)
|> assign(:site, site)
|> assign(:device, device)
|> assign(:latency_chart_data, latency_chart_data)}
|> assign(:latency_chart_data, latency_chart_data)
|> assign(:site_summary, site_summary)}
end
@impl true
@ -42,7 +45,10 @@ defmodule ToweropsWeb.SiteLive.Show do
end)
count = length(snmp_devices)
message = t_equipment("Discovery started for %{count} device", count: count) <> if count == 1, do: "", else: "s"
message =
t_equipment("Discovery started for %{count} device", count: count) <>
if(count == 1, do: "", else: "s")
{:noreply, put_flash(socket, :info, message)}
end
@ -84,6 +90,27 @@ defmodule ToweropsWeb.SiteLive.Show do
}
end
defp format_mrr(nil), do: "$0"
defp format_mrr(%Decimal{} = amount) do
"$#{format_number(Decimal.to_integer(Decimal.round(amount, 0)))}"
end
defp format_mrr(amount) when is_number(amount), do: "$#{format_number(round(amount))}"
defp format_mrr(_), do: "$0"
defp format_number(number) when is_integer(number) do
number
|> Integer.to_string()
|> String.graphemes()
|> Enum.reverse()
|> Enum.chunk_every(3)
|> Enum.join(",")
|> String.reverse()
end
defp format_number(number), do: to_string(number)
# 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

@ -22,6 +22,42 @@
</:actions>
</.header>
<%!-- Metrics Bar --%>
<div class="mt-6 flex flex-wrap items-center gap-3">
<span class="inline-flex items-center gap-1.5 rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300">
<.icon name="hero-server" class="h-3.5 w-3.5" />
{@site_summary.device_count} {if @site_summary.device_count == 1,
do: "device",
else: "devices"}
</span>
<%= if @site_summary.devices_down > 0 do %>
<span class="inline-flex items-center gap-1.5 rounded-md bg-red-100 px-2.5 py-1 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-400">
<span class="h-2 w-2 rounded-full bg-red-500"></span>
{@site_summary.devices_down} down
</span>
<% end %>
<%= if @site_summary.alert_count > 0 do %>
<span class="inline-flex items-center gap-1.5 rounded-md bg-red-50 px-2.5 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-600/20 dark:bg-red-900/20 dark:text-red-400 dark:ring-red-400/20">
<.icon name="hero-bell-alert" class="h-3.5 w-3.5" />
{@site_summary.alert_count} {if @site_summary.alert_count == 1,
do: "alert",
else: "alerts"}
</span>
<% end %>
<%= if @site_summary.subscribers do %>
<span class="inline-flex items-center gap-1.5 rounded-md bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-800 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-400/10 dark:text-amber-400 dark:ring-amber-400/20">
<.icon name="hero-users" class="h-3.5 w-3.5" />
{@site_summary.subscribers} subscribers
</span>
<%= if @site_summary.mrr do %>
<span class="inline-flex items-center gap-1.5 rounded-md bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-800 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-400/10 dark:text-amber-400 dark:ring-amber-400/20">
<.icon name="hero-currency-dollar" class="h-3.5 w-3.5" />
{format_mrr(@site_summary.mrr)}/mo MRR
</span>
<% end %>
<% end %>
</div>
<div class="mt-8 grid gap-6 md:grid-cols-2">
<div>
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Site Details</h3>

View file

@ -0,0 +1,15 @@
defmodule Towerops.Repo.Migrations.AddSourceToPreseemInsights do
use Ecto.Migration
def change do
alter table(:preseem_insights) do
add :source, :string, null: false, default: "preseem"
add :site_id, references(:sites, type: :binary_id, on_delete: :nilify_all)
add :agent_token_id, references(:agent_tokens, type: :binary_id, on_delete: :nilify_all)
end
create index(:preseem_insights, [:organization_id, :source, :status])
create index(:preseem_insights, [:site_id])
create index(:preseem_insights, [:agent_token_id])
end
end

View file

@ -0,0 +1,174 @@
defmodule Towerops.DashboardTest do
use Towerops.DataCase, async: true
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Dashboard
alias Towerops.Gaiia
setup do
user = user_fixture()
org = organization_fixture(user.id)
%{org: org, user: user}
end
describe "get_dashboard_summary/1" do
test "returns correct structure with empty org", %{org: org} do
summary = Dashboard.get_dashboard_summary(org.id)
assert is_map(summary)
assert Map.has_key?(summary, :health_score)
assert Map.has_key?(summary, :devices)
assert Map.has_key?(summary, :alerts)
assert Map.has_key?(summary, :subscribers)
assert Map.has_key?(summary, :insights)
end
test "returns device counts", %{org: org} do
device = device_fixture(%{organization_id: org.id})
Towerops.Devices.update_device_status(device, :up)
summary = Dashboard.get_dashboard_summary(org.id)
assert summary.devices.total == 1
assert summary.devices.up == 1
assert summary.devices.down == 0
end
test "returns alert count", %{org: org} do
device = device_fixture(%{organization_id: org.id})
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Device is down"
})
summary = Dashboard.get_dashboard_summary(org.id)
assert summary.alerts.active_count == 1
end
test "returns subscriber totals from Gaiia", %{org: org} do
{:ok, _} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "ns-dash",
name: "Tower",
account_count: 100,
total_mrr: Decimal.new("5000.00")
})
summary = Dashboard.get_dashboard_summary(org.id)
assert summary.subscribers.total == 100
assert Decimal.equal?(summary.subscribers.total_mrr, Decimal.new("5000.00"))
end
test "returns zero subscribers when no Gaiia data", %{org: org} do
summary = Dashboard.get_dashboard_summary(org.id)
assert summary.subscribers.total == 0
end
test "returns insight counts by urgency", %{org: org} do
summary = Dashboard.get_dashboard_summary(org.id)
assert summary.insights.critical == 0
assert summary.insights.warning == 0
assert summary.insights.info == 0
end
end
describe "compute_health_score/1" do
test "returns 100 when all devices up, no alerts", %{org: org} do
device = device_fixture(%{organization_id: org.id})
Towerops.Devices.update_device_status(device, :up)
score = Dashboard.compute_health_score(org.id)
assert score == 100
end
test "returns lower score when devices down", %{org: org} do
d1 = device_fixture(%{organization_id: org.id})
d2 = device_fixture(%{organization_id: org.id})
Towerops.Devices.update_device_status(d1, :up)
Towerops.Devices.update_device_status(d2, :down)
score = Dashboard.compute_health_score(org.id)
assert score < 100
assert score > 0
end
test "returns 100 when no devices exist", %{org: org} do
score = Dashboard.compute_health_score(org.id)
assert score == 100
end
test "penalizes for critical alerts", %{org: org} do
device = device_fixture(%{organization_id: org.id})
Towerops.Devices.update_device_status(device, :up)
{:ok, _} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Device down"
})
score = Dashboard.compute_health_score(org.id)
assert score < 100
end
end
describe "get_org_subscriber_summary/1" do
test "returns totals from Gaiia network sites", %{org: org} do
{:ok, _} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "ns-sub1",
name: "Tower A",
account_count: 50,
total_mrr: Decimal.new("2500.00")
})
{:ok, _} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "ns-sub2",
name: "Tower B",
account_count: 30,
total_mrr: Decimal.new("1500.00")
})
result = Dashboard.get_org_subscriber_summary(org.id)
assert result.total == 80
assert Decimal.equal?(result.total_mrr, Decimal.new("4000.00"))
end
test "returns zeros when no Gaiia data", %{org: org} do
result = Dashboard.get_org_subscriber_summary(org.id)
assert result.total == 0
assert Decimal.equal?(result.total_mrr, Decimal.new(0))
end
end
describe "list_site_summaries/1" do
test "returns enriched site data", %{org: org} do
{:ok, site} =
Towerops.Sites.create_site(%{
name: "Test Tower",
organization_id: org.id
})
device = device_fixture(%{organization_id: org.id, site_id: site.id})
Towerops.Devices.update_device_status(device, :up)
summaries = Dashboard.list_site_summaries(org.id)
assert summaries != []
summary = Enum.find(summaries, &(&1.site_id == site.id))
assert summary.name == "Test Tower"
assert summary.device_count >= 1
end
end
end

View file

@ -125,4 +125,71 @@ defmodule Towerops.GaiiaTest do
site
end
describe "get_org_subscriber_totals/1" do
test "returns zero totals when no network sites", %{org: org} do
assert %{total_subscribers: 0, total_mrr: total_mrr} = Gaiia.get_org_subscriber_totals(org.id)
assert Decimal.equal?(total_mrr, Decimal.new(0))
end
test "sums account_count and total_mrr from network sites", %{org: org} do
{:ok, _} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "ns-1",
name: "Tower A",
account_count: 50,
total_mrr: Decimal.new("2500.00")
})
{:ok, _} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "ns-2",
name: "Tower B",
account_count: 30,
total_mrr: Decimal.new("1500.00")
})
result = Gaiia.get_org_subscriber_totals(org.id)
assert result.total_subscribers == 80
assert Decimal.equal?(result.total_mrr, Decimal.new("4000.00"))
end
test "handles nil values gracefully", %{org: org} do
{:ok, _} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "ns-nil",
name: "Tower Nil",
account_count: nil,
total_mrr: nil
})
result = Gaiia.get_org_subscriber_totals(org.id)
assert result.total_subscribers == 0
assert Decimal.equal?(result.total_mrr, Decimal.new(0))
end
end
describe "get_site_subscriber_summary/1" do
test "returns subscriber data for a mapped site", %{org: org} do
site = create_site(org.id)
{:ok, _} =
Gaiia.upsert_network_site(org.id, %{
gaiia_id: "ns-mapped",
name: "Mapped Tower",
account_count: 42,
total_mrr: Decimal.new("3100.50"),
site_id: site.id
})
result = Gaiia.get_site_subscriber_summary(site.id)
assert result.account_count == 42
assert Decimal.equal?(result.total_mrr, Decimal.new("3100.50"))
end
test "returns nil for unmapped site", %{org: org} do
site = create_site(org.id)
assert is_nil(Gaiia.get_site_subscriber_summary(site.id))
end
end
end

View file

@ -88,7 +88,7 @@ defmodule Towerops.Preseem.InsightTest do
test "accepts all valid types" do
valid_types =
~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift)
~w(qoe_degradation capacity_saturation firmware_opportunity model_underperforming subscriber_growth config_drift snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap agent_offline firmware_mismatch reconciliation_finding subscriber_growth_trend)
for type <- valid_types do
attrs = %{
@ -231,5 +231,144 @@ defmodule Towerops.Preseem.InsightTest do
assert reloaded.description == "Detailed explanation"
assert reloaded.metadata == %{"current" => 60.0, "baseline_mean" => 85.0}
end
test "defaults source to preseem", %{organization: org} do
attrs = %{
organization_id: org.id,
type: "qoe_degradation",
urgency: "warning",
channel: "proactive",
title: "Test"
}
changeset = Insight.changeset(%Insight{}, attrs)
assert Ecto.Changeset.get_field(changeset, :source) == "preseem"
end
test "accepts all valid sources", %{organization: org} do
for source <- ~w(preseem snmp gaiia system) do
attrs = %{
organization_id: org.id,
type: "qoe_degradation",
urgency: "info",
channel: "passive",
title: "Test",
source: source
}
changeset = Insight.changeset(%Insight{}, attrs)
assert changeset.valid?, "Expected source '#{source}' to be valid"
end
end
test "rejects invalid source", %{organization: org} do
attrs = %{
organization_id: org.id,
type: "qoe_degradation",
urgency: "warning",
channel: "proactive",
title: "Test",
source: "invalid_source"
}
changeset = Insight.changeset(%Insight{}, attrs)
refute changeset.valid?
assert "is invalid" in errors_on(changeset).source
end
test "accepts new SNMP insight types", %{organization: org} do
snmp_types = ~w(snmp_cpu_high snmp_memory_high snmp_disk_high device_poll_gap)
for type <- snmp_types do
attrs = %{
organization_id: org.id,
type: type,
urgency: "warning",
channel: "proactive",
title: "Test",
source: "snmp"
}
changeset = Insight.changeset(%Insight{}, attrs)
assert changeset.valid?, "Expected type '#{type}' to be valid"
end
end
test "accepts new system insight types", %{organization: org} do
system_types = ~w(agent_offline firmware_mismatch)
for type <- system_types do
attrs = %{
organization_id: org.id,
type: type,
urgency: "warning",
channel: "proactive",
title: "Test",
source: "system"
}
changeset = Insight.changeset(%Insight{}, attrs)
assert changeset.valid?, "Expected type '#{type}' to be valid"
end
end
test "accepts new Gaiia insight types", %{organization: org} do
gaiia_types = ~w(reconciliation_finding subscriber_growth_trend)
for type <- gaiia_types do
attrs = %{
organization_id: org.id,
type: type,
urgency: "info",
channel: "passive",
title: "Test",
source: "gaiia"
}
changeset = Insight.changeset(%Insight{}, attrs)
assert changeset.valid?, "Expected type '#{type}' to be valid"
end
end
test "accepts site_id and agent_token_id fields", %{organization: org} do
site_id = Ecto.UUID.generate()
agent_token_id = Ecto.UUID.generate()
attrs = %{
organization_id: org.id,
type: "agent_offline",
urgency: "critical",
channel: "proactive",
title: "Agent offline",
source: "system",
site_id: site_id,
agent_token_id: agent_token_id
}
changeset = Insight.changeset(%Insight{}, attrs)
assert Ecto.Changeset.get_field(changeset, :site_id) == site_id
assert Ecto.Changeset.get_field(changeset, :agent_token_id) == agent_token_id
end
test "persists source and associations", %{organization: org} do
{:ok, site} =
Towerops.Sites.create_site(%{name: "Test Site", organization_id: org.id})
attrs = %{
organization_id: org.id,
type: "snmp_cpu_high",
urgency: "warning",
channel: "proactive",
title: "CPU high on site",
source: "snmp",
site_id: site.id
}
{:ok, insight} = %Insight{} |> Insight.changeset(attrs) |> Repo.insert()
reloaded = Repo.get!(Insight, insight.id)
assert reloaded.source == "snmp"
assert reloaded.site_id == site.id
end
end
end

View file

@ -334,6 +334,192 @@ defmodule Towerops.Preseem.InsightsTest do
end
end
describe "list_insights/2 source filter" do
test "filters by source", %{org: org, access_point: ap} do
insert_insight(org, ap, %{type: "qoe_degradation", urgency: "warning", title: "Preseem insight"})
insert_insight_with_source(org, %{
type: "snmp_cpu_high",
urgency: "warning",
title: "SNMP insight",
source: "snmp"
})
preseem_insights = Insights.list_insights(org.id, source: "preseem")
assert length(preseem_insights) == 1
assert hd(preseem_insights).title == "Preseem insight"
snmp_insights = Insights.list_insights(org.id, source: "snmp")
assert length(snmp_insights) == 1
assert hd(snmp_insights).title == "SNMP insight"
end
test "returns all sources when no source filter", %{org: org, access_point: ap} do
insert_insight(org, ap, %{type: "qoe_degradation", urgency: "warning", title: "Preseem"})
insert_insight_with_source(org, %{
type: "agent_offline",
urgency: "critical",
title: "System",
source: "system"
})
insights = Insights.list_insights(org.id)
assert length(insights) == 2
end
end
describe "list_site_insights/2" do
test "returns insights for a specific site", %{org: org} do
{:ok, site} = Towerops.Sites.create_site(%{name: "Test Site", organization_id: org.id})
{:ok, other_site} = Towerops.Sites.create_site(%{name: "Other Site", organization_id: org.id})
insert_insight_with_source(org, %{
type: "snmp_cpu_high",
urgency: "warning",
title: "Site insight",
source: "snmp",
site_id: site.id
})
insert_insight_with_source(org, %{
type: "snmp_memory_high",
urgency: "warning",
title: "Other site insight",
source: "snmp",
site_id: other_site.id
})
insights = Insights.list_site_insights(site.id)
assert length(insights) == 1
assert hd(insights).title == "Site insight"
end
test "respects opts like limit and source", %{org: org} do
{:ok, site} = Towerops.Sites.create_site(%{name: "Test Site", organization_id: org.id})
for i <- 1..5 do
insert_insight_with_source(org, %{
type: "snmp_cpu_high",
urgency: "warning",
title: "Insight #{i}",
source: "snmp",
site_id: site.id
})
end
insights = Insights.list_site_insights(site.id, limit: 3)
assert length(insights) == 3
end
end
describe "count_active_by_urgency/1" do
test "returns counts grouped by urgency", %{org: org, access_point: ap} do
insert_insight(org, ap, %{type: "qoe_degradation", urgency: "critical", title: "Crit 1"})
insert_insight(org, ap, %{type: "capacity_saturation", urgency: "critical", title: "Crit 2"})
insert_insight(org, ap, %{type: "model_underperforming", urgency: "warning", title: "Warn 1"})
insert_insight_with_source(org, %{type: "snmp_cpu_high", urgency: "info", title: "Info 1", source: "snmp"})
counts = Insights.count_active_by_urgency(org.id)
assert counts.critical == 2
assert counts.warning == 1
assert counts.info == 1
end
test "returns zeros when no insights", %{org: _org} do
user = user_fixture()
empty_org = organization_fixture(user.id)
counts = Insights.count_active_by_urgency(empty_org.id)
assert counts.critical == 0
assert counts.warning == 0
assert counts.info == 0
end
test "excludes dismissed insights", %{org: org, access_point: ap} do
insert_insight(org, ap, %{type: "qoe_degradation", urgency: "critical", title: "Active"})
insert_insight(org, ap, %{type: "capacity_saturation", urgency: "critical", title: "Dismissed", status: "dismissed"})
counts = Insights.count_active_by_urgency(org.id)
assert counts.critical == 1
end
end
describe "insert_insight_if_new/2 generalized dedup" do
test "deduplicates by device_id + type", %{org: org, device: device} do
attrs = %{
organization_id: org.id,
device_id: device.id,
type: "snmp_cpu_high",
urgency: "warning",
channel: "proactive",
title: "CPU high",
source: "snmp"
}
assert {:ok, _insight} = Insights.insert_insight_if_new(attrs)
assert {:ok, :duplicate} = Insights.insert_insight_if_new(attrs)
insights = Insights.list_insights(org.id, type: "snmp_cpu_high")
assert length(insights) == 1
end
test "deduplicates by site_id + type", %{org: org} do
{:ok, site} = Towerops.Sites.create_site(%{name: "Dedup Site", organization_id: org.id})
attrs = %{
organization_id: org.id,
site_id: site.id,
type: "snmp_cpu_high",
urgency: "warning",
channel: "proactive",
title: "CPU high at site",
source: "snmp"
}
assert {:ok, _insight} = Insights.insert_insight_if_new(attrs)
assert {:ok, :duplicate} = Insights.insert_insight_if_new(attrs)
end
test "deduplicates by agent_token_id + type", %{org: org} do
{:ok, token, _raw_token} = Towerops.Agents.create_agent_token(org.id, "Test Agent")
attrs = %{
organization_id: org.id,
agent_token_id: token.id,
type: "agent_offline",
urgency: "critical",
channel: "proactive",
title: "Agent offline",
source: "system"
}
assert {:ok, _insight} = Insights.insert_insight_if_new(attrs)
assert {:ok, :duplicate} = Insights.insert_insight_if_new(attrs)
end
test "allows same type for different devices", %{org: org, device: device} do
other_device = device_fixture(%{organization_id: org.id})
base_attrs = %{
organization_id: org.id,
type: "snmp_cpu_high",
urgency: "warning",
channel: "proactive",
title: "CPU high",
source: "snmp"
}
assert {:ok, _} = Insights.insert_insight_if_new(Map.put(base_attrs, :device_id, device.id))
assert {:ok, _} = Insights.insert_insight_if_new(Map.put(base_attrs, :device_id, other_device.id))
insights = Insights.list_insights(org.id, type: "snmp_cpu_high")
assert length(insights) == 2
end
end
# Helpers
defp insert_insight(org, ap, attrs) do
@ -371,6 +557,22 @@ defmodule Towerops.Preseem.InsightsTest do
|> Repo.insert!()
end
defp insert_insight_with_source(org, attrs) do
full_attrs =
Map.merge(
%{
organization_id: org.id,
channel: "proactive",
status: "active"
},
attrs
)
%Insight{}
|> Insight.changeset(full_attrs)
|> Repo.insert()
end
defp insert_fleet_profile(org, model, attrs) do
full_attrs =
Map.merge(

View file

@ -0,0 +1,85 @@
defmodule Towerops.Workers.DeviceHealthInsightWorkerTest do
use Towerops.DataCase, async: false
use Oban.Testing, repo: Towerops.Repo
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Devices.Device
alias Towerops.Preseem.Insights
alias Towerops.Workers.DeviceHealthInsightWorker
setup do
user = user_fixture()
org = organization_fixture(user.id)
device = device_fixture(%{organization_id: org.id})
%{org: org, device: device}
end
describe "perform/1" do
test "returns :ok with no devices" do
assert :ok = perform_job(DeviceHealthInsightWorker, %{})
end
test "generates device_poll_gap insight when device not polled in 24h", %{org: org, device: device} do
stale_time = DateTime.add(DateTime.utc_now(), -25, :hour)
Repo.update_all(
from(d in Device, where: d.id == ^device.id),
set: [last_snmp_poll_at: stale_time, monitoring_enabled: true]
)
assert :ok = perform_job(DeviceHealthInsightWorker, %{})
insights = Insights.list_insights(org.id, type: "device_poll_gap")
assert length(insights) == 1
assert hd(insights).source == "snmp"
assert hd(insights).device_id == device.id
end
test "does not generate poll_gap insight when device was recently polled", %{org: org, device: device} do
recent_time = DateTime.add(DateTime.utc_now(), -1, :hour)
Repo.update_all(
from(d in Device, where: d.id == ^device.id),
set: [last_snmp_poll_at: recent_time, monitoring_enabled: true]
)
assert :ok = perform_job(DeviceHealthInsightWorker, %{})
insights = Insights.list_insights(org.id, type: "device_poll_gap")
assert Enum.empty?(insights)
end
test "does not duplicate insights for same device", %{org: org, device: device} do
stale_time = DateTime.add(DateTime.utc_now(), -25, :hour)
Repo.update_all(
from(d in Device, where: d.id == ^device.id),
set: [last_snmp_poll_at: stale_time, monitoring_enabled: true]
)
assert :ok = perform_job(DeviceHealthInsightWorker, %{})
assert :ok = perform_job(DeviceHealthInsightWorker, %{})
insights = Insights.list_insights(org.id, type: "device_poll_gap")
assert length(insights) == 1
end
test "skips devices with monitoring disabled", %{org: org, device: device} do
stale_time = DateTime.add(DateTime.utc_now(), -25, :hour)
Repo.update_all(
from(d in Device, where: d.id == ^device.id),
set: [last_snmp_poll_at: stale_time, monitoring_enabled: false]
)
assert :ok = perform_job(DeviceHealthInsightWorker, %{})
insights = Insights.list_insights(org.id, type: "device_poll_gap")
assert Enum.empty?(insights)
end
end
end

View file

@ -0,0 +1,60 @@
defmodule Towerops.Workers.GaiiaInsightWorkerTest do
use Towerops.DataCase, async: false
use Oban.Testing, repo: Towerops.Repo
import Towerops.AccountsFixtures
import Towerops.DevicesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Integrations
alias Towerops.Preseem.Insights
alias Towerops.Workers.GaiiaInsightWorker
setup do
user = user_fixture()
org = organization_fixture(user.id)
%{org: org}
end
describe "perform/1" do
test "returns :ok with no gaiia integrations" do
assert :ok = perform_job(GaiiaInsightWorker, %{})
end
test "generates reconciliation_finding for untracked devices", %{org: org} do
setup_gaiia_integration(org)
# Create a device that has no Gaiia inventory item mapped
_device = device_fixture(%{organization_id: org.id})
assert :ok = perform_job(GaiiaInsightWorker, %{})
insights = Insights.list_insights(org.id, type: "reconciliation_finding")
assert insights != []
assert hd(insights).source == "gaiia"
end
test "does not duplicate reconciliation insights on repeated runs", %{org: org} do
setup_gaiia_integration(org)
_device = device_fixture(%{organization_id: org.id})
assert :ok = perform_job(GaiiaInsightWorker, %{})
first_count = length(Insights.list_insights(org.id, source: "gaiia"))
assert :ok = perform_job(GaiiaInsightWorker, %{})
second_count = length(Insights.list_insights(org.id, source: "gaiia"))
assert first_count == second_count
end
end
defp setup_gaiia_integration(org) do
{:ok, _integration} =
Integrations.create_integration(org.id, %{
provider: "gaiia",
enabled: true,
config: %{"api_key" => "test-key"}
})
end
end

View file

@ -0,0 +1,100 @@
defmodule Towerops.Workers.SystemInsightWorkerTest do
use Towerops.DataCase, async: false
use Oban.Testing, repo: Towerops.Repo
import Ecto.Query
import Towerops.AccountsFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Agents
alias Towerops.Agents.AgentToken
alias Towerops.Preseem.Insights
alias Towerops.Workers.SystemInsightWorker
setup do
user = user_fixture()
org = organization_fixture(user.id)
%{org: org}
end
describe "perform/1" do
test "returns :ok with no agents" do
assert :ok = perform_job(SystemInsightWorker, %{})
end
test "generates agent_offline insight for stale agent", %{org: org} do
{:ok, agent, _token} = Agents.create_agent_token(org.id, "Offline Agent")
stale_time = DateTime.add(DateTime.utc_now(), -15, :minute)
Repo.update_all(
from(a in AgentToken, where: a.id == ^agent.id),
set: [last_seen_at: stale_time]
)
assert :ok = perform_job(SystemInsightWorker, %{})
insights = Insights.list_insights(org.id, type: "agent_offline")
assert length(insights) == 1
assert hd(insights).source == "system"
assert hd(insights).agent_token_id == agent.id
end
test "does not generate insight for fresh agent", %{org: org} do
{:ok, agent, _token} = Agents.create_agent_token(org.id, "Fresh Agent")
Agents.update_agent_token_heartbeat(agent.id, "192.168.1.1", %{})
assert :ok = perform_job(SystemInsightWorker, %{})
insights = Insights.list_insights(org.id, type: "agent_offline")
assert Enum.empty?(insights)
end
test "auto-resolves insight when agent comes back online", %{org: org} do
{:ok, agent, _token} = Agents.create_agent_token(org.id, "Recovering Agent")
# First: agent goes offline
stale_time = DateTime.add(DateTime.utc_now(), -15, :minute)
Repo.update_all(
from(a in AgentToken, where: a.id == ^agent.id),
set: [last_seen_at: stale_time]
)
assert :ok = perform_job(SystemInsightWorker, %{})
insights = Insights.list_insights(org.id, type: "agent_offline")
assert length(insights) == 1
# Then: agent comes back online
Agents.update_agent_token_heartbeat(agent.id, "192.168.1.1", %{})
assert :ok = perform_job(SystemInsightWorker, %{})
# Should be resolved now
active_insights = Insights.list_insights(org.id, type: "agent_offline")
assert Enum.empty?(active_insights)
resolved_insights = Insights.list_insights(org.id, type: "agent_offline", status: "resolved")
assert length(resolved_insights) == 1
end
test "does not duplicate insights for same agent", %{org: org} do
{:ok, agent, _token} = Agents.create_agent_token(org.id, "Still Offline")
stale_time = DateTime.add(DateTime.utc_now(), -15, :minute)
Repo.update_all(
from(a in AgentToken, where: a.id == ^agent.id),
set: [last_seen_at: stale_time]
)
assert :ok = perform_job(SystemInsightWorker, %{})
assert :ok = perform_job(SystemInsightWorker, %{})
insights = Insights.list_insights(org.id, type: "agent_offline")
assert length(insights) == 1
end
end
end

View file

@ -174,5 +174,52 @@ defmodule ToweropsWeb.AlertLive.IndexTest do
assert {:redirect, %{to: path}} = redirect
assert path == ~p"/users/log-in"
end
test "shows site subscriber context when Gaiia mapped", %{
conn: conn,
organization: organization,
site: site,
device: device
} do
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Device is down"
})
{:ok, _} =
Towerops.Gaiia.upsert_network_site(organization.id, %{
gaiia_id: "ns-alert-test",
name: "Tower",
account_count: 75,
total_mrr: Decimal.new("3750.00"),
site_id: site.id
})
{:ok, _view, html} = live(conn, ~p"/alerts")
assert html =~ "75 subscribers"
assert html =~ "3,750"
end
test "hides site subscriber context when no Gaiia mapping", %{
conn: conn,
device: device
} do
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Device is down"
})
{:ok, _view, html} = live(conn, ~p"/alerts")
refute html =~ "subscribers"
refute html =~ "MRR"
end
end
end

View file

@ -3,10 +3,13 @@ defmodule ToweropsWeb.DashboardLiveTest do
import Phoenix.LiveViewTest
alias Towerops.Preseem.Insights
setup :register_and_log_in_user
setup %{conn: conn, user: user} do
{:ok, organization} = Towerops.Organizations.create_organization(%{name: "Test Org", use_sites: true}, user.id)
{:ok, organization} =
Towerops.Organizations.create_organization(%{name: "Test Org", use_sites: true}, user.id)
{:ok, site} =
Towerops.Sites.create_site(%{
@ -14,128 +17,17 @@ defmodule ToweropsWeb.DashboardLiveTest do
organization_id: organization.id
})
# Set the organization in session so dashboard can load
conn = Plug.Conn.put_session(conn, :current_organization_id, organization.id)
%{conn: conn, organization: organization, site: site}
end
describe "Dashboard" do
test "displays organization name and stats", %{conn: conn, organization: organization, site: site} do
# Create a device so the dashboard shows stats instead of empty state
{:ok, _device} =
Towerops.Devices.create_device(%{
name: "Test Device",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
describe "empty state" do
test "shows onboarding when no devices exist", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ organization.name
assert html =~ "Sites"
assert html =~ "Device"
assert html =~ "Active Alerts"
end
test "displays correct site count", %{conn: conn, organization: organization} do
# Create additional sites
{:ok, _site2} =
Towerops.Sites.create_site(%{
name: "Site 2",
organization_id: organization.id
})
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Sites"
end
test "displays correct device counts", %{conn: conn, organization: organization, site: site} do
# Create devices with different statuses
{:ok, device_up} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id,
monitoring_enabled: true
})
Towerops.Devices.update_device_status(device_up, :up)
{:ok, device_down} =
Towerops.Devices.create_device(%{
name: "Router 2",
ip_address: "192.168.1.2",
site_id: site.id,
organization_id: organization.id,
monitoring_enabled: true
})
Towerops.Devices.update_device_status(device_down, :down)
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Device"
assert html =~ "Up"
assert html =~ "Down"
end
test "displays active alerts count", %{conn: conn, organization: organization, site: site} do
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
# Create an active alert
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Device is down"
})
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Active Alerts"
end
test "updates in real-time when new alert is triggered", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router 1",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, view, _html} = live(conn, ~p"/dashboard")
# Trigger a new alert via org-scoped PubSub
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:org:#{organization.id}:new",
{:new_alert, device.id, :device_down}
)
# View should update
assert render(view)
end
test "stat cards are clickable links", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/dashboard")
# Check for navigation links
assert html =~ ~p"/sites"
assert html =~ ~p"/devices"
assert html =~ ~p"/alerts"
assert html =~ "Let&#39;s get started!"
assert html =~ "Create Your First Site"
end
test "requires authentication" do
@ -146,4 +38,367 @@ defmodule ToweropsWeb.DashboardLiveTest do
assert path == ~p"/users/log-in"
end
end
describe "health overview" do
test "displays health score", %{conn: conn, organization: organization, site: site} do
{:ok, device} = create_device(organization, site)
Towerops.Devices.update_device_status(device, :up)
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Health Score"
assert html =~ "100"
end
test "displays device counts with up/down/unknown", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, d1} = create_device(organization, site, "Router 1", "192.168.1.1")
{:ok, d2} = create_device(organization, site, "Router 2", "192.168.1.2")
Towerops.Devices.update_device_status(d1, :up)
Towerops.Devices.update_device_status(d2, :down)
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Devices"
assert html =~ "1 Up"
assert html =~ "1 Down"
end
test "displays active alert count", %{conn: conn, organization: organization, site: site} do
{:ok, device} = create_device(organization, site)
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Device is down"
})
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Active Alerts"
assert html =~ "1"
end
test "displays insight counts by urgency", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
{:ok, _} =
Insights.insert_insight_if_new(%{
organization_id: organization.id,
device_id: device.id,
type: "device_poll_gap",
source: "snmp",
urgency: "warning",
channel: "proactive",
title: "Device not polled"
})
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Insights"
end
test "hides subscriber section when no Gaiia integration", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, _device} = create_device(organization, site)
{:ok, _view, html} = live(conn, ~p"/dashboard")
refute html =~ "Subscribers"
refute html =~ "MRR"
end
test "shows subscriber section when Gaiia data exists", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, _device} = create_device(organization, site)
{:ok, _} =
Towerops.Gaiia.upsert_network_site(organization.id, %{
gaiia_id: "ns-dash",
name: "Tower",
account_count: 100,
total_mrr: Decimal.new("5000.00")
})
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Subscribers"
assert html =~ "100"
assert html =~ "5,000"
end
end
describe "alert feed" do
test "shows alerts with device links", %{conn: conn, organization: organization, site: site} do
{:ok, device} = create_device(organization, site, "Main Router", "10.0.0.1")
Towerops.Devices.update_device_status(device, :down)
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Device is unreachable"
})
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Main Router"
assert html =~ "Device is unreachable"
assert html =~ ~p"/devices/#{device.id}"
end
test "shows View All link when alerts exist", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Device is down"
})
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "View All"
end
test "shows acknowledge button for unacknowledged alerts", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Device is down"
})
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Acknowledge"
end
end
describe "insight feed" do
test "shows insights from multiple sources", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
{:ok, _} =
Insights.insert_insight_if_new(%{
organization_id: organization.id,
device_id: device.id,
type: "device_poll_gap",
source: "snmp",
urgency: "warning",
channel: "proactive",
title: "Device not polled in 24h"
})
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Device not polled in 24h"
assert html =~ "snmp"
end
test "dismiss insight removes it from feed", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
{:ok, insight} =
Insights.insert_insight_if_new(%{
organization_id: organization.id,
device_id: device.id,
type: "device_poll_gap",
source: "snmp",
urgency: "warning",
channel: "proactive",
title: "Poll gap detected"
})
{:ok, view, html} = live(conn, ~p"/dashboard")
assert html =~ "Poll gap detected"
view |> element("#dismiss-insight-#{insight.id}") |> render_click()
html = render(view)
refute html =~ "Poll gap detected"
end
test "source filter filters insights via URL", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
{:ok, _} =
Insights.insert_insight_if_new(%{
organization_id: organization.id,
device_id: device.id,
type: "device_poll_gap",
source: "snmp",
urgency: "warning",
channel: "proactive",
title: "SNMP insight"
})
{:ok, _} =
Insights.insert_insight_if_new(%{
organization_id: organization.id,
device_id: device.id,
type: "reconciliation_finding",
source: "gaiia",
urgency: "info",
channel: "proactive",
title: "Gaiia insight",
dedup_key: "test_dedup",
metadata: %{"dedup_key" => "test_dedup"}
})
{:ok, view, _html} = live(conn, ~p"/dashboard?insight_source=snmp")
html = render(view)
assert html =~ "SNMP insight"
refute html =~ "Gaiia insight"
end
end
describe "site health grid" do
test "shows site cards with device counts", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
Towerops.Devices.update_device_status(device, :up)
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "Test Site"
assert html =~ ~p"/sites/#{site.id}"
end
test "shows subscriber data on site cards when Gaiia mapped", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
Towerops.Devices.update_device_status(device, :up)
{:ok, _} =
Towerops.Gaiia.upsert_network_site(organization.id, %{
gaiia_id: "ns-site",
name: "Tower",
account_count: 42,
total_mrr: Decimal.new("2100.00"),
site_id: site.id
})
{:ok, _view, html} = live(conn, ~p"/dashboard")
assert html =~ "42"
end
end
describe "real-time updates" do
test "PubSub new alert event updates dashboard", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
{:ok, view, _html} = live(conn, ~p"/dashboard")
# Create alert and broadcast
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Device went down"
})
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:org:#{organization.id}:new",
{:new_alert, device.id, :device_down}
)
html = render(view)
assert html =~ "Device went down"
end
test "PubSub alert resolved event updates dashboard", %{
conn: conn,
organization: organization,
site: site
} do
{:ok, device} = create_device(organization, site)
{:ok, alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Device went down"
})
{:ok, view, _html} = live(conn, ~p"/dashboard")
# Resolve the alert
Towerops.Alerts.resolve_alert(alert)
Phoenix.PubSub.broadcast(
Towerops.PubSub,
"alerts:org:#{organization.id}:resolved",
{:alert_resolved, device.id, :device_down}
)
html = render(view)
refute html =~ "Device went down"
end
end
defp create_device(organization, site, name \\ "Test Device", ip \\ "192.168.1.1") do
Towerops.Devices.create_device(%{
name: name,
ip_address: ip,
site_id: site.id,
organization_id: organization.id
})
end
end

View file

@ -188,6 +188,53 @@ defmodule ToweropsWeb.DeviceLive.IndexTest do
end
end
describe "site header enrichment" do
test "shows subscriber count on site header when Gaiia mapped", %{
conn: conn,
site: site,
organization: organization
} do
{:ok, _device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _} =
Towerops.Gaiia.upsert_network_site(organization.id, %{
gaiia_id: "ns-dev-list",
name: "Tower",
account_count: 55,
total_mrr: Decimal.new("2750.00"),
site_id: site.id
})
{:ok, _view, html} = live(conn, ~p"/devices")
assert html =~ "55 subscribers"
end
test "hides subscriber count when no Gaiia mapping", %{
conn: conn,
site: site,
organization: organization
} do
{:ok, _device} =
Devices.create_device(%{
name: "Router",
ip_address: "192.168.1.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _view, html} = live(conn, ~p"/devices")
refute html =~ "subscribers"
end
end
describe "discovered tab" do
test "renders discovered devices tab", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/devices?tab=discovered")

View file

@ -146,6 +146,106 @@ defmodule ToweropsWeb.SiteLiveTest do
end
end
describe "Show - Metrics Bar" do
test "displays device count and status in metrics bar", %{
conn: conn,
organization: organization
} do
{:ok, site} =
Sites.create_site(%{name: "Tower Alpha", organization_id: organization.id})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router",
ip_address: "10.0.0.1",
site_id: site.id,
organization_id: organization.id
})
Towerops.Devices.update_device_status(device, :up)
{:ok, _view, html} = live(conn, ~p"/sites/#{site.id}")
assert html =~ "1 device"
end
test "displays alert count in metrics bar", %{conn: conn, organization: organization} do
{:ok, site} =
Sites.create_site(%{name: "Tower Beta", organization_id: organization.id})
{:ok, device} =
Towerops.Devices.create_device(%{
name: "Router",
ip_address: "10.0.0.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _alert} =
Towerops.Alerts.create_alert(%{
device_id: device.id,
alert_type: :device_down,
triggered_at: DateTime.utc_now(),
message: "Device down"
})
{:ok, _view, html} = live(conn, ~p"/sites/#{site.id}")
assert html =~ "1 alert"
end
test "shows subscriber badge when Gaiia network site mapped", %{
conn: conn,
organization: organization
} do
{:ok, site} =
Sites.create_site(%{name: "Tower Gamma", organization_id: organization.id})
{:ok, _} =
Towerops.Devices.create_device(%{
name: "Router",
ip_address: "10.0.0.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _} =
Towerops.Gaiia.upsert_network_site(organization.id, %{
gaiia_id: "ns-gamma",
name: "Tower Gamma",
account_count: 75,
total_mrr: Decimal.new("3750.00"),
site_id: site.id
})
{:ok, _view, html} = live(conn, ~p"/sites/#{site.id}")
assert html =~ "75 subscribers"
assert html =~ "$3,750"
end
test "hides subscriber badge when no Gaiia mapping", %{
conn: conn,
organization: organization
} do
{:ok, site} =
Sites.create_site(%{name: "Tower Delta", organization_id: organization.id})
{:ok, _} =
Towerops.Devices.create_device(%{
name: "Router",
ip_address: "10.0.0.1",
site_id: site.id,
organization_id: organization.id
})
{:ok, _view, html} = live(conn, ~p"/sites/#{site.id}")
refute html =~ "subscribers"
refute html =~ "MRR"
end
end
describe "Form - New" do
test "renders new site form", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/sites/new")