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 %> -
{@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_count}
-+ {@summary.health_score} +
+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
-{@device_count}
++ {format_number(@summary.subscribers.total)} +
++ {format_mrr(@summary.subscribers.total_mrr)}/mo MRR +
+{@sites_count}
+ + <% end %> + + <%!-- Insights --%> ++ {@summary.insights.critical + @summary.insights.warning + @summary.insights.info} +
+{alert.message}
-No active alerts
+{alert.message}
- <%= if length(@active_alerts) > 5 do %> -No active insights
++ {insight.description} +
+ <% 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 --%> +