From 20f5f48372e7e4b96ad1f99d48186e8c052a7c6f Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 13 Feb 2026 14:52:49 -0600 Subject: [PATCH] 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 --- config/runtime.exs | 8 +- lib/towerops/alerts.ex | 13 + lib/towerops/dashboard.ex | 129 +++++ lib/towerops/gaiia.ex | 28 + lib/towerops/preseem/insight.ex | 14 +- lib/towerops/preseem/insights.ex | 70 +++ .../workers/device_health_insight_worker.ex | 57 ++ lib/towerops/workers/gaiia_insight_worker.ex | 105 ++++ lib/towerops/workers/system_insight_worker.ex | 91 ++++ lib/towerops_web/live/alert_live/index.ex | 33 +- .../live/alert_live/index.html.heex | 12 + lib/towerops_web/live/dashboard_live.ex | 141 ++++- .../live/dashboard_live.html.heex | 409 +++++++++++---- lib/towerops_web/live/device_live/index.ex | 13 + .../live/device_live/index.html.heex | 6 + lib/towerops_web/live/site_live/show.ex | 31 +- .../live/site_live/show.html.heex | 36 ++ ...3201303_add_source_to_preseem_insights.exs | 15 + test/towerops/dashboard_test.exs | 174 +++++++ test/towerops/gaiia_test.exs | 67 +++ test/towerops/preseem/insight_test.exs | 141 ++++- test/towerops/preseem/insights_test.exs | 202 ++++++++ .../device_health_insight_worker_test.exs | 85 +++ .../workers/gaiia_insight_worker_test.exs | 60 +++ .../workers/system_insight_worker_test.exs | 100 ++++ test/towerops_web/live/alert_live_test.exs | 47 ++ .../towerops_web/live/dashboard_live_test.exs | 487 +++++++++++++----- .../live/device_live/index_test.exs | 47 ++ test/towerops_web/live/site_live_test.exs | 100 ++++ 29 files changed, 2503 insertions(+), 218 deletions(-) create mode 100644 lib/towerops/dashboard.ex create mode 100644 lib/towerops/workers/device_health_insight_worker.ex create mode 100644 lib/towerops/workers/gaiia_insight_worker.ex create mode 100644 lib/towerops/workers/system_insight_worker.ex create mode 100644 priv/repo/migrations/20260213201303_add_source_to_preseem_insights.exs create mode 100644 test/towerops/dashboard_test.exs create mode 100644 test/towerops/workers/device_health_insight_worker_test.exs create mode 100644 test/towerops/workers/gaiia_insight_worker_test.exs create mode 100644 test/towerops/workers/system_insight_worker_test.exs diff --git a/config/runtime.exs b/config/runtime.exs index 9962a63b..6814c384 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -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}, diff --git a/lib/towerops/alerts.ex b/lib/towerops/alerts.ex index 89dc366d..1d9c5d86 100644 --- a/lib/towerops/alerts.ex +++ b/lib/towerops/alerts.ex @@ -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. diff --git a/lib/towerops/dashboard.ex b/lib/towerops/dashboard.ex new file mode 100644 index 00000000..1c9df5ef --- /dev/null +++ b/lib/towerops/dashboard.ex @@ -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 diff --git a/lib/towerops/gaiia.ex b/lib/towerops/gaiia.ex index 0bdc1389..dc8696f1 100644 --- a/lib/towerops/gaiia.ex +++ b/lib/towerops/gaiia.ex @@ -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 diff --git a/lib/towerops/preseem/insight.ex b/lib/towerops/preseem/insight.ex index 215f8adf..d62e9e81 100644 --- a/lib/towerops/preseem/insight.ex +++ b/lib/towerops/preseem/insight.ex @@ -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 diff --git a/lib/towerops/preseem/insights.ex b/lib/towerops/preseem/insights.ex index bfe056d0..c742a60a 100644 --- a/lib/towerops/preseem/insights.ex +++ b/lib/towerops/preseem/insights.ex @@ -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." diff --git a/lib/towerops/workers/device_health_insight_worker.ex b/lib/towerops/workers/device_health_insight_worker.ex new file mode 100644 index 00000000..36466747 --- /dev/null +++ b/lib/towerops/workers/device_health_insight_worker.ex @@ -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 diff --git a/lib/towerops/workers/gaiia_insight_worker.ex b/lib/towerops/workers/gaiia_insight_worker.ex new file mode 100644 index 00000000..f26fef1e --- /dev/null +++ b/lib/towerops/workers/gaiia_insight_worker.ex @@ -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 diff --git a/lib/towerops/workers/system_insight_worker.ex b/lib/towerops/workers/system_insight_worker.ex new file mode 100644 index 00000000..46d74783 --- /dev/null +++ b/lib/towerops/workers/system_insight_worker.ex @@ -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 diff --git a/lib/towerops_web/live/alert_live/index.ex b/lib/towerops_web/live/alert_live/index.ex index 10b46bbb..72f5a80c 100644 --- a/lib/towerops_web/live/alert_live/index.ex +++ b/lib/towerops_web/live/alert_live/index.ex @@ -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 diff --git a/lib/towerops_web/live/alert_live/index.html.heex b/lib/towerops_web/live/alert_live/index.html.heex index 81d7b442..264e19bc 100644 --- a/lib/towerops_web/live/alert_live/index.html.heex +++ b/lib/towerops_web/live/alert_live/index.html.heex @@ -153,6 +153,18 @@ > {alert.device.site.name} + <%= if @site_subscribers[alert.device.site_id] do %> + · + + {@site_subscribers[alert.device.site_id].account_count} subscribers + + <%= if @site_subscribers[alert.device.site_id].total_mrr do %> + · + + {format_mrr(@site_subscribers[alert.device.site_id].total_mrr)}/mo MRR + + <% end %> + <% end %> diff --git a/lib/towerops_web/live/dashboard_live.ex b/lib/towerops_web/live/dashboard_live.ex index 9fdf3e66..db086332 100644 --- a/lib/towerops_web/live/dashboard_live.ex +++ b/lib/towerops_web/live/dashboard_live.ex @@ -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 diff --git a/lib/towerops_web/live/dashboard_live.html.heex b/lib/towerops_web/live/dashboard_live.html.heex index d4f21380..c8f8f5a8 100644 --- a/lib/towerops_web/live/dashboard_live.html.heex +++ b/lib/towerops_web/live/dashboard_live.html.heex @@ -4,7 +4,7 @@ active_page="dashboard" > <.header> - Dashboard + Command Center <:subtitle>Welcome to {@current_scope.organization.name} @@ -86,119 +86,354 @@ <% else %> -
- <%= 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" - > -

Sites

-

{@sites_count}

-

Total sites

- - <% 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" - > -

Device

-

{@device_count}

-
-
- - {@device_up} Up -
-
- - {@device_down} Down -
-
- - {@device_unknown} Unknown -
-
- + <%!-- Section A: Health Overview --%> +
+ <%!-- Health Score --%> +
+

Health Score

+

+ {@summary.health_score} +

+
+ <%!-- 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" >

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}

-

Requires attention

-
-

System Status

-
- <%= if @device_down == 0 do %> - <.icon name="hero-check-circle" class="h-5 w-5" /> - All Systems Operational - <% else %> - <.icon name="hero-exclamation-triangle" class="h-5 w-5" /> - {@device_down} Device Down + <%!-- 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" + > +

Devices

+

{@device_count}

+
+ + + {@device_up} Up + + + + {@device_down} Down + + + + {@device_unknown} Unknown + +
+ + + <%!-- Subscribers (only when Gaiia data exists) --%> + <%= if @has_subscribers do %> +
+

Subscribers

+

+ {format_number(@summary.subscribers.total)} +

+

+ {format_mrr(@summary.subscribers.total_mrr)}/mo MRR +

+
+ <% 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" + > +

Sites

+

{@sites_count}

+ + <% end %> + + <%!-- Insights --%> +
+

Insights

+
+

+ {@summary.insights.critical + @summary.insights.warning + @summary.insights.info} +

+
+
+ <%= if @summary.insights.critical > 0 do %> + + {@summary.insights.critical} critical + + <% end %> + <%= if @summary.insights.warning > 0 do %> + + {@summary.insights.warning} warning + + <% end %> + <%= if @summary.insights.info > 0 do %> + + {@summary.insights.info} info + <% end %>
- <%= if length(@active_alerts) > 0 do %> -
+ <%!-- Section B+C: Two-column layout --%> +
+ <%!-- Left: Alert Feed (3 cols) --%> +
-

Active Alerts

+

Active Alerts

<.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 →
-
- <%= for alert <- Enum.take(@active_alerts, 5) do %> -
- <.icon - name="hero-exclamation-triangle" - class="h-5 w-5 flex-shrink-0 text-red-600 dark:text-red-500" - /> -
-

- <.link - navigate={~p"/devices/#{alert.device.id}"} - class="hover:text-blue-600 hover:underline dark:hover:text-blue-400" - > - {alert.device.name} - -

-

{alert.message}

-
- <.timestamp - datetime={alert.triggered_at} - class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap" - /> -
- <% end %> + <%= if @active_alerts == [] do %> +
+ <.icon name="hero-check-circle" class="mx-auto h-10 w-10 text-green-500" /> +

No active alerts

+
+ <% else %> +
+
+
+
+
+ + <.icon name="hero-exclamation-triangle" class="h-3.5 w-3.5" /> Device Down + + <%= if alert.acknowledged_at do %> + + Acknowledged + + <% end %> +
+

+ <.link + navigate={~p"/devices/#{alert.device.id}"} + class="hover:text-blue-600 hover:underline dark:hover:text-blue-400" + > + {alert.device.name} + +

+

{alert.message}

- <%= if length(@active_alerts) > 5 do %> -
- + {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 %> +
+ + <.icon name="hero-users" class="h-3 w-3" /> + {alert.gaiia_impact["total_subscribers"]} affected + + <%= if alert.gaiia_impact["total_mrr"] && alert.gaiia_impact["total_mrr"] != "0" do %> + + <.icon name="hero-currency-dollar" class="h-3 w-3" /> + ${alert.gaiia_impact["total_mrr"]}/mo + + <% end %> +
+ <% end %> + +
+ <.timestamp datetime={alert.triggered_at} /> + + 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} + + +
+
+ +
+ <%= if is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %> + + <% end %> +
+
+
+
+ + <%= if @total_alert_count > 20 do %> +
+ + {@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 +
<% end %> + <% end %> +
+ + <%!-- Right: Insights Feed (2 cols) --%> +
+
+

Insights

+
+ + <%!-- Source filter pills --%> +
+ <.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 + :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} + +
+ + <%= if @insights == [] do %> +
+ <.icon + name="hero-light-bulb" + class="mx-auto h-10 w-10 text-gray-400 dark:text-gray-500" + /> +

No active insights

+
+ <% else %> +
+
+
+
+
+ + {insight.urgency} + + + {insight.source} + +
+

+ {insight.title} +

+ <%= if insight.description do %> +

+ {insight.description} +

+ <% end %> +
+ +
+
+
+ <% end %> +
+
+ + <%!-- Section D: Site Health Grid --%> + <%= if @current_scope.organization.use_sites && @site_summaries != [] do %> +
+
+

Site Health

+ <.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 + :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" + > +

{site.name}

+
+ + {site.device_count} devices + + <%= if site.devices_down > 0 do %> + + + {site.devices_down} down + + <% end %> +
+ <%= if site.subscribers do %> +
+ + <.icon name="hero-users" class="h-3.5 w-3.5" /> + {site.subscribers} subscribers + + <%= if site.mrr do %> + {format_mrr(site.mrr)}/mo + <% end %> +
+ <% end %> +
<% end %> diff --git a/lib/towerops_web/live/device_live/index.ex b/lib/towerops_web/live/device_live/index.ex index f8fdbb91..654a1812 100644 --- a/lib/towerops_web/live/device_live/index.ex +++ b/lib/towerops_web/live/device_live/index.ex @@ -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 diff --git a/lib/towerops_web/live/device_live/index.html.heex b/lib/towerops_web/live/device_live/index.html.heex index bd3d23f1..8b3f88a6 100644 --- a/lib/towerops_web/live/device_live/index.html.heex +++ b/lib/towerops_web/live/device_live/index.html.heex @@ -285,6 +285,12 @@ > {row.stats.down} down + <%= if row.site && @site_subscribers[row.site.id] do %> + + <.icon name="hero-users" class="h-3 w-3" /> + {@site_subscribers[row.site.id].account_count} subscribers + + <% end %>

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 diff --git a/lib/towerops_web/live/site_live/show.html.heex b/lib/towerops_web/live/site_live/show.html.heex index d4552198..6b473ae7 100644 --- a/lib/towerops_web/live/site_live/show.html.heex +++ b/lib/towerops_web/live/site_live/show.html.heex @@ -22,6 +22,42 @@ + <%!-- Metrics Bar --%> +

+ + <.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"} + + <%= if @site_summary.devices_down > 0 do %> + + + {@site_summary.devices_down} down + + <% end %> + <%= if @site_summary.alert_count > 0 do %> + + <.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"} + + <% end %> + <%= if @site_summary.subscribers do %> + + <.icon name="hero-users" class="h-3.5 w-3.5" /> + {@site_summary.subscribers} subscribers + + <%= if @site_summary.mrr do %> + + <.icon name="hero-currency-dollar" class="h-3.5 w-3.5" /> + {format_mrr(@site_summary.mrr)}/mo MRR + + <% end %> + <% end %> +
+

Site Details

diff --git a/priv/repo/migrations/20260213201303_add_source_to_preseem_insights.exs b/priv/repo/migrations/20260213201303_add_source_to_preseem_insights.exs new file mode 100644 index 00000000..55e1933f --- /dev/null +++ b/priv/repo/migrations/20260213201303_add_source_to_preseem_insights.exs @@ -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 diff --git a/test/towerops/dashboard_test.exs b/test/towerops/dashboard_test.exs new file mode 100644 index 00000000..83551d35 --- /dev/null +++ b/test/towerops/dashboard_test.exs @@ -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 diff --git a/test/towerops/gaiia_test.exs b/test/towerops/gaiia_test.exs index 91773164..1c951ac1 100644 --- a/test/towerops/gaiia_test.exs +++ b/test/towerops/gaiia_test.exs @@ -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 diff --git a/test/towerops/preseem/insight_test.exs b/test/towerops/preseem/insight_test.exs index 83d3eeb6..4b668468 100644 --- a/test/towerops/preseem/insight_test.exs +++ b/test/towerops/preseem/insight_test.exs @@ -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 diff --git a/test/towerops/preseem/insights_test.exs b/test/towerops/preseem/insights_test.exs index 6186b86e..434be0e7 100644 --- a/test/towerops/preseem/insights_test.exs +++ b/test/towerops/preseem/insights_test.exs @@ -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( diff --git a/test/towerops/workers/device_health_insight_worker_test.exs b/test/towerops/workers/device_health_insight_worker_test.exs new file mode 100644 index 00000000..809ddaa2 --- /dev/null +++ b/test/towerops/workers/device_health_insight_worker_test.exs @@ -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 diff --git a/test/towerops/workers/gaiia_insight_worker_test.exs b/test/towerops/workers/gaiia_insight_worker_test.exs new file mode 100644 index 00000000..e5029675 --- /dev/null +++ b/test/towerops/workers/gaiia_insight_worker_test.exs @@ -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 diff --git a/test/towerops/workers/system_insight_worker_test.exs b/test/towerops/workers/system_insight_worker_test.exs new file mode 100644 index 00000000..2ef59790 --- /dev/null +++ b/test/towerops/workers/system_insight_worker_test.exs @@ -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 diff --git a/test/towerops_web/live/alert_live_test.exs b/test/towerops_web/live/alert_live_test.exs index de6c3c75..25ea6e73 100644 --- a/test/towerops_web/live/alert_live_test.exs +++ b/test/towerops_web/live/alert_live_test.exs @@ -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 diff --git a/test/towerops_web/live/dashboard_live_test.exs b/test/towerops_web/live/dashboard_live_test.exs index 281e01d8..f4719700 100644 --- a/test/towerops_web/live/dashboard_live_test.exs +++ b/test/towerops_web/live/dashboard_live_test.exs @@ -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'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 diff --git a/test/towerops_web/live/device_live/index_test.exs b/test/towerops_web/live/device_live/index_test.exs index 1f0eee15..6425b721 100644 --- a/test/towerops_web/live/device_live/index_test.exs +++ b/test/towerops_web/live/device_live/index_test.exs @@ -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") diff --git a/test/towerops_web/live/site_live_test.exs b/test/towerops_web/live/site_live_test.exs index 90633136..5079980e 100644 --- a/test/towerops_web/live/site_live_test.exs +++ b/test/towerops_web/live/site_live_test.exs @@ -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")