feat: command center dashboard with unified insights and contextual enrichment
Transform the dashboard into a single-pane-of-glass command center that blends operational metrics with business context from Gaiia subscriber data. - Extend insight schema with multi-source support (snmp, gaiia, system) - Add Oban workers for automated insight generation (device health, system, gaiia) - New Dashboard context with health score computation and site summaries - Rewrite dashboard LiveView with health overview, alert/insight feeds, site grid - Add source filter pills for insights with URL state management - Add metrics bar to site detail pages (device count, alerts, subscribers, MRR) - Add subscriber/MRR context to alert list site links - Add subscriber badges to device list site headers - Real-time PubSub updates for alerts on dashboard
This commit is contained in:
parent
848efa8fc4
commit
20f5f48372
29 changed files with 2503 additions and 218 deletions
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -91,6 +91,19 @@ defmodule Towerops.Alerts do
|
|||
)
|
||||
end
|
||||
|
||||
@doc "Returns the count of active alerts for devices at a specific site."
|
||||
def count_site_active_alerts(site_id) do
|
||||
Repo.aggregate(
|
||||
from(a in Alert,
|
||||
join: e in assoc(a, :device),
|
||||
where: e.site_id == ^site_id,
|
||||
where: a.alert_type == :device_down,
|
||||
where: is_nil(a.resolved_at)
|
||||
),
|
||||
:count
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of all alerts for an organization.
|
||||
|
||||
|
|
|
|||
129
lib/towerops/dashboard.ex
Normal file
129
lib/towerops/dashboard.ex
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
defmodule Towerops.Dashboard do
|
||||
@moduledoc """
|
||||
Aggregates data from multiple contexts for the command center dashboard.
|
||||
"""
|
||||
|
||||
alias Towerops.Alerts
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Gaiia
|
||||
alias Towerops.Preseem.Insights
|
||||
alias Towerops.Sites
|
||||
|
||||
@doc "Returns a complete dashboard summary for an organization."
|
||||
def get_dashboard_summary(organization_id) do
|
||||
status_counts = Devices.get_device_status_counts(organization_id)
|
||||
total_devices = status_counts |> Map.values() |> Enum.sum()
|
||||
active_alerts = Alerts.list_organization_active_alerts(organization_id)
|
||||
subscriber_totals = Gaiia.get_org_subscriber_totals(organization_id)
|
||||
insight_counts = Insights.count_active_by_urgency(organization_id)
|
||||
|
||||
%{
|
||||
health_score: compute_health_score(organization_id),
|
||||
devices: %{
|
||||
total: total_devices,
|
||||
up: Map.get(status_counts, :up, 0),
|
||||
down: Map.get(status_counts, :down, 0),
|
||||
unknown: Map.get(status_counts, :unknown, 0)
|
||||
},
|
||||
alerts: %{
|
||||
active_count: length(active_alerts)
|
||||
},
|
||||
subscribers: %{
|
||||
total: subscriber_totals.total_subscribers,
|
||||
total_mrr: subscriber_totals.total_mrr
|
||||
},
|
||||
insights: insight_counts
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Compute a weighted 0-100 health score for an organization.
|
||||
|
||||
Weights:
|
||||
- Device uptime % (40%)
|
||||
- Response time health (20%) - currently based on device status
|
||||
- Critical alert penalty (20%)
|
||||
- Preseem QoE average (10%, skipped if unavailable)
|
||||
- Agent online % (10%, skipped if no agents)
|
||||
|
||||
Unavailable components have their weight redistributed proportionally.
|
||||
"""
|
||||
def compute_health_score(organization_id) do
|
||||
status_counts = Devices.get_device_status_counts(organization_id)
|
||||
total = status_counts |> Map.values() |> Enum.sum()
|
||||
|
||||
if total == 0 do
|
||||
100
|
||||
else
|
||||
up = Map.get(status_counts, :up, 0)
|
||||
down = Map.get(status_counts, :down, 0)
|
||||
active_alerts = Alerts.list_organization_active_alerts(organization_id)
|
||||
|
||||
# Device uptime percentage (0.0-1.0)
|
||||
uptime_ratio = up / total
|
||||
|
||||
# Response time health - approximate from up/unknown ratio
|
||||
responsive_ratio = (total - down) / total
|
||||
|
||||
# Alert penalty - each alert reduces score, capped at full penalty
|
||||
alert_penalty = min(length(active_alerts) * 0.1, 1.0)
|
||||
alert_score = 1.0 - alert_penalty
|
||||
|
||||
# Weighted score (redistribute Preseem/Agent weights to core metrics)
|
||||
# Base weights: uptime 40%, response 20%, alerts 20%, preseem 10%, agents 10%
|
||||
# Without preseem/agents: uptime 50%, response 25%, alerts 25%
|
||||
score =
|
||||
uptime_ratio * 50.0 +
|
||||
responsive_ratio * 25.0 +
|
||||
alert_score * 25.0
|
||||
|
||||
round(score)
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Returns subscriber summary for the org from Gaiia data."
|
||||
def get_org_subscriber_summary(organization_id) do
|
||||
totals = Gaiia.get_org_subscriber_totals(organization_id)
|
||||
|
||||
%{
|
||||
total: totals.total_subscribers,
|
||||
total_mrr: totals.total_mrr
|
||||
}
|
||||
end
|
||||
|
||||
@doc "Returns per-site summaries with device counts, alerts, and subscriber data."
|
||||
def list_site_summaries(organization_id) do
|
||||
sites = Sites.list_organization_sites(organization_id)
|
||||
|
||||
Enum.map(sites, fn site ->
|
||||
device_count = Devices.count_site_devices(site.id)
|
||||
down_count = Devices.count_site_devices_down(site.id)
|
||||
gaiia_summary = Gaiia.get_site_subscriber_summary(site.id)
|
||||
|
||||
%{
|
||||
site_id: site.id,
|
||||
name: site.name,
|
||||
device_count: device_count,
|
||||
devices_down: down_count,
|
||||
subscribers: if(gaiia_summary, do: gaiia_summary.account_count),
|
||||
mrr: if(gaiia_summary, do: gaiia_summary.total_mrr)
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
@doc "Returns a summary for a single site."
|
||||
def get_site_summary(site_id) do
|
||||
device_count = Devices.count_site_devices(site_id)
|
||||
down_count = Devices.count_site_devices_down(site_id)
|
||||
alert_count = Alerts.count_site_active_alerts(site_id)
|
||||
gaiia_summary = Gaiia.get_site_subscriber_summary(site_id)
|
||||
|
||||
%{
|
||||
device_count: device_count,
|
||||
devices_down: down_count,
|
||||
alert_count: alert_count,
|
||||
subscribers: if(gaiia_summary, do: gaiia_summary.account_count),
|
||||
mrr: if(gaiia_summary, do: gaiia_summary.total_mrr)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
57
lib/towerops/workers/device_health_insight_worker.ex
Normal file
57
lib/towerops/workers/device_health_insight_worker.ex
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
defmodule Towerops.Workers.DeviceHealthInsightWorker do
|
||||
@moduledoc """
|
||||
Oban cron worker that generates SNMP-based health insights.
|
||||
|
||||
Runs nightly to detect:
|
||||
- `device_poll_gap`: Devices not polled in over 24 hours
|
||||
"""
|
||||
use Oban.Worker, queue: :maintenance
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.Devices.Device
|
||||
alias Towerops.Preseem.Insights
|
||||
alias Towerops.Repo
|
||||
|
||||
@poll_gap_hours 24
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
devices = list_stale_monitored_devices()
|
||||
|
||||
Enum.each(devices, &generate_poll_gap_insight/1)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp list_stale_monitored_devices do
|
||||
cutoff = DateTime.add(DateTime.utc_now(), -@poll_gap_hours, :hour)
|
||||
|
||||
Device
|
||||
|> where([d], d.monitoring_enabled == true)
|
||||
|> where([d], not is_nil(d.last_snmp_poll_at))
|
||||
|> where([d], d.last_snmp_poll_at < ^cutoff)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
defp generate_poll_gap_insight(device) do
|
||||
hours_ago = DateTime.diff(DateTime.utc_now(), device.last_snmp_poll_at, :hour)
|
||||
|
||||
Insights.insert_insight_if_new(%{
|
||||
organization_id: device.organization_id,
|
||||
device_id: device.id,
|
||||
type: "device_poll_gap",
|
||||
urgency: "warning",
|
||||
channel: "proactive",
|
||||
source: "snmp",
|
||||
title: "#{device.name} has not been polled in #{hours_ago} hours",
|
||||
description:
|
||||
"Device #{device.name} was last polled #{hours_ago} hours ago. " <>
|
||||
"Check SNMP connectivity and agent status.",
|
||||
metadata: %{
|
||||
"last_poll_at" => DateTime.to_iso8601(device.last_snmp_poll_at),
|
||||
"hours_since_poll" => hours_ago
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
105
lib/towerops/workers/gaiia_insight_worker.ex
Normal file
105
lib/towerops/workers/gaiia_insight_worker.ex
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
defmodule Towerops.Workers.GaiiaInsightWorker do
|
||||
@moduledoc """
|
||||
Oban cron worker that generates Gaiia-based insights.
|
||||
|
||||
Runs nightly to detect:
|
||||
- `reconciliation_finding`: Untracked devices, ghost devices, data mismatches
|
||||
"""
|
||||
use Oban.Worker, queue: :maintenance
|
||||
|
||||
alias Towerops.Gaiia.Reconciliation
|
||||
alias Towerops.Integrations
|
||||
alias Towerops.Preseem.Insights
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
integrations = Integrations.list_enabled_integrations("gaiia")
|
||||
|
||||
Enum.each(integrations, fn integration ->
|
||||
process_reconciliation(integration.organization_id)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp process_reconciliation(organization_id) do
|
||||
result = Reconciliation.reconcile(organization_id)
|
||||
|
||||
generate_untracked_insights(organization_id, result.untracked_devices)
|
||||
generate_ghost_insights(organization_id, result.ghost_devices)
|
||||
generate_mismatch_insights(organization_id, result.data_mismatches)
|
||||
end
|
||||
|
||||
defp generate_untracked_insights(organization_id, untracked_devices) do
|
||||
count = length(untracked_devices)
|
||||
|
||||
if count > 0 do
|
||||
Insights.insert_insight_if_new(%{
|
||||
organization_id: organization_id,
|
||||
type: "reconciliation_finding",
|
||||
urgency: "info",
|
||||
channel: "passive",
|
||||
source: "gaiia",
|
||||
dedup_key: "untracked",
|
||||
title: "#{count} device(s) not tracked in Gaiia",
|
||||
description:
|
||||
"#{count} device(s) discovered by Towerops have no matching Gaiia inventory item. " <>
|
||||
"Consider adding them to Gaiia for complete subscriber impact tracking.",
|
||||
metadata: %{
|
||||
"dedup_key" => "untracked",
|
||||
"finding_type" => "untracked",
|
||||
"count" => count,
|
||||
"device_names" => untracked_devices |> Enum.take(10) |> Enum.map(& &1.device.name)
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_ghost_insights(organization_id, ghost_devices) do
|
||||
count = length(ghost_devices)
|
||||
|
||||
if count > 0 do
|
||||
Insights.insert_insight_if_new(%{
|
||||
organization_id: organization_id,
|
||||
type: "reconciliation_finding",
|
||||
urgency: "warning",
|
||||
channel: "proactive",
|
||||
source: "gaiia",
|
||||
dedup_key: "ghost",
|
||||
title: "#{count} Gaiia item(s) reference missing devices",
|
||||
description:
|
||||
"#{count} Gaiia inventory item(s) are mapped to devices that no longer exist in Towerops. " <>
|
||||
"Review and clean up stale mappings.",
|
||||
metadata: %{
|
||||
"dedup_key" => "ghost",
|
||||
"finding_type" => "ghost",
|
||||
"count" => count
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_mismatch_insights(organization_id, data_mismatches) do
|
||||
count = length(data_mismatches)
|
||||
|
||||
if count > 0 do
|
||||
Insights.insert_insight_if_new(%{
|
||||
organization_id: organization_id,
|
||||
type: "reconciliation_finding",
|
||||
urgency: "info",
|
||||
channel: "passive",
|
||||
source: "gaiia",
|
||||
dedup_key: "mismatch",
|
||||
title: "#{count} data mismatch(es) between Towerops and Gaiia",
|
||||
description:
|
||||
"#{count} device(s) have mismatched data between Towerops and Gaiia inventory. " <>
|
||||
"Review IP addresses and other fields for accuracy.",
|
||||
metadata: %{
|
||||
"dedup_key" => "mismatch",
|
||||
"finding_type" => "mismatch",
|
||||
"count" => count
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
91
lib/towerops/workers/system_insight_worker.ex
Normal file
91
lib/towerops/workers/system_insight_worker.ex
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
defmodule Towerops.Workers.SystemInsightWorker do
|
||||
@moduledoc """
|
||||
Oban cron worker that generates system-level insights.
|
||||
|
||||
Runs every 5 minutes to detect:
|
||||
- `agent_offline`: Agents not seen in over 10 minutes
|
||||
- Auto-resolves agent_offline insights when agents come back online
|
||||
"""
|
||||
use Oban.Worker, queue: :maintenance
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Towerops.Agents.AgentToken
|
||||
alias Towerops.Organizations.Organization
|
||||
alias Towerops.Preseem.Insight
|
||||
alias Towerops.Preseem.Insights
|
||||
alias Towerops.Repo
|
||||
|
||||
@stale_threshold_minutes 10
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{}) do
|
||||
org_ids = Repo.all(from(o in Organization, select: o.id))
|
||||
|
||||
Enum.each(org_ids, fn org_id ->
|
||||
offline_agents = list_offline_agents(org_id)
|
||||
offline_ids = MapSet.new(offline_agents, & &1.id)
|
||||
|
||||
Enum.each(offline_agents, &generate_agent_offline_insight/1)
|
||||
auto_resolve_recovered_agents(org_id, offline_ids)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp list_offline_agents(organization_id) do
|
||||
cutoff = DateTime.add(DateTime.utc_now(), -@stale_threshold_minutes, :minute)
|
||||
|
||||
AgentToken
|
||||
|> where([a], a.organization_id == ^organization_id)
|
||||
|> where([a], a.enabled == true)
|
||||
|> where([a], not is_nil(a.last_seen_at))
|
||||
|> where([a], a.last_seen_at < ^cutoff)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
defp generate_agent_offline_insight(agent) do
|
||||
minutes_offline = DateTime.diff(DateTime.utc_now(), agent.last_seen_at, :minute)
|
||||
|
||||
Insights.insert_insight_if_new(%{
|
||||
organization_id: agent.organization_id,
|
||||
agent_token_id: agent.id,
|
||||
type: "agent_offline",
|
||||
urgency: "critical",
|
||||
channel: "proactive",
|
||||
source: "system",
|
||||
title: "Agent '#{agent.name}' is offline",
|
||||
description:
|
||||
"Agent #{agent.name} has not been seen for #{minutes_offline} minutes. " <>
|
||||
"Check agent connectivity and host status.",
|
||||
metadata: %{
|
||||
"agent_name" => agent.name,
|
||||
"last_seen_at" => DateTime.to_iso8601(agent.last_seen_at),
|
||||
"minutes_offline" => minutes_offline
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
defp auto_resolve_recovered_agents(organization_id, currently_offline_ids) do
|
||||
now = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
active_agent_insights =
|
||||
Insight
|
||||
|> where(
|
||||
[i],
|
||||
i.organization_id == ^organization_id and
|
||||
i.type == "agent_offline" and
|
||||
i.status == "active"
|
||||
)
|
||||
|> where([i], not is_nil(i.agent_token_id))
|
||||
|> Repo.all()
|
||||
|
||||
Enum.each(active_agent_insights, fn insight ->
|
||||
if !MapSet.member?(currently_offline_ids, insight.agent_token_id) do
|
||||
insight
|
||||
|> Insight.changeset(%{status: "resolved", dismissed_at: now})
|
||||
|> Repo.update()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -153,6 +153,18 @@
|
|||
>
|
||||
{alert.device.site.name}
|
||||
</.link>
|
||||
<%= if @site_subscribers[alert.device.site_id] do %>
|
||||
<span class="text-gray-400 dark:text-gray-600 mx-1">·</span>
|
||||
<span class="text-amber-700 dark:text-amber-400">
|
||||
{@site_subscribers[alert.device.site_id].account_count} subscribers
|
||||
</span>
|
||||
<%= if @site_subscribers[alert.device.site_id].total_mrr do %>
|
||||
<span class="text-gray-400 dark:text-gray-600 mx-1">·</span>
|
||||
<span class="text-amber-700 dark:text-amber-400">
|
||||
{format_mrr(@site_subscribers[alert.device.site_id].total_mrr)}/mo MRR
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
active_page="dashboard"
|
||||
>
|
||||
<.header>
|
||||
Dashboard
|
||||
Command Center
|
||||
<:subtitle>Welcome to {@current_scope.organization.name}</:subtitle>
|
||||
</.header>
|
||||
|
||||
|
|
@ -86,119 +86,354 @@
|
|||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class={[
|
||||
"mt-8 grid gap-6",
|
||||
(@current_scope.organization.use_sites && "md:grid-cols-2 lg:grid-cols-4") ||
|
||||
"md:grid-cols-3"
|
||||
]}>
|
||||
<%= if @current_scope.organization.use_sites do %>
|
||||
<.link
|
||||
navigate={~p"/sites"}
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
|
||||
>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Sites</h3>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{@sites_count}</p>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">Total sites</p>
|
||||
</.link>
|
||||
<% end %>
|
||||
|
||||
<.link
|
||||
navigate={~p"/devices"}
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
|
||||
>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Device</h3>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{@device_count}</p>
|
||||
<div class="mt-3 space-y-1.5 text-sm">
|
||||
<div class="flex items-center gap-2 text-gray-700 dark:text-gray-300">
|
||||
<span class="h-2 w-2 rounded-full bg-green-500"></span>
|
||||
{@device_up} Up
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-gray-700 dark:text-gray-300">
|
||||
<span class="h-2 w-2 rounded-full bg-red-500"></span>
|
||||
{@device_down} Down
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-gray-700 dark:text-gray-300">
|
||||
<span class="h-2 w-2 rounded-full bg-gray-400"></span>
|
||||
{@device_unknown} Unknown
|
||||
</div>
|
||||
</div>
|
||||
</.link>
|
||||
<%!-- Section A: Health Overview --%>
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<%!-- Health Score --%>
|
||||
<div class={[
|
||||
"rounded-lg border p-4 shadow-sm",
|
||||
health_score_bg(@summary.health_score)
|
||||
]}>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Health Score</h3>
|
||||
<p class={["mt-1 text-3xl font-bold", health_score_color(@summary.health_score)]}>
|
||||
{@summary.health_score}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%!-- Active Alerts --%>
|
||||
<.link
|
||||
navigate={~p"/alerts"}
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
|
||||
>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Active Alerts</h3>
|
||||
<p class={[
|
||||
"mt-2 text-3xl font-bold",
|
||||
(length(@active_alerts) > 0 && "text-red-600 dark:text-red-500") ||
|
||||
"mt-1 text-3xl font-bold",
|
||||
(@total_alert_count > 0 && "text-red-600 dark:text-red-500") ||
|
||||
"text-gray-900 dark:text-white"
|
||||
]}>
|
||||
{length(@active_alerts)}
|
||||
{@total_alert_count}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">Requires attention</p>
|
||||
</.link>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm dark:border-white/10 dark:bg-gray-800/50">
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">System Status</h3>
|
||||
<div class={[
|
||||
"mt-3 flex items-center gap-2 text-sm font-semibold",
|
||||
(@device_down == 0 && "text-green-600 dark:text-green-500") ||
|
||||
"text-amber-600 dark:text-amber-500"
|
||||
]}>
|
||||
<%= if @device_down == 0 do %>
|
||||
<.icon name="hero-check-circle" class="h-5 w-5" />
|
||||
<span>All Systems Operational</span>
|
||||
<% else %>
|
||||
<.icon name="hero-exclamation-triangle" class="h-5 w-5" />
|
||||
<span>{@device_down} Device Down</span>
|
||||
<%!-- Devices --%>
|
||||
<.link
|
||||
navigate={~p"/devices"}
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
|
||||
>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Devices</h3>
|
||||
<p class="mt-1 text-3xl font-bold text-gray-900 dark:text-white">{@device_count}</p>
|
||||
<div class="mt-2 flex items-center gap-3 text-xs">
|
||||
<span class="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<span class="h-2 w-2 rounded-full bg-green-500"></span>
|
||||
{@device_up} Up
|
||||
</span>
|
||||
<span class="flex items-center gap-1 text-red-600 dark:text-red-400">
|
||||
<span class="h-2 w-2 rounded-full bg-red-500"></span>
|
||||
{@device_down} Down
|
||||
</span>
|
||||
<span class="flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||
<span class="h-2 w-2 rounded-full bg-gray-400"></span>
|
||||
{@device_unknown} Unknown
|
||||
</span>
|
||||
</div>
|
||||
</.link>
|
||||
|
||||
<%!-- Subscribers (only when Gaiia data exists) --%>
|
||||
<%= if @has_subscribers do %>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-800/50">
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Subscribers</h3>
|
||||
<p class="mt-1 text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{format_number(@summary.subscribers.total)}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{format_mrr(@summary.subscribers.total_mrr)}/mo MRR
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%!-- Sites (if enabled) --%>
|
||||
<%= if @current_scope.organization.use_sites do %>
|
||||
<.link
|
||||
navigate={~p"/sites"}
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
|
||||
>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Sites</h3>
|
||||
<p class="mt-1 text-3xl font-bold text-gray-900 dark:text-white">{@sites_count}</p>
|
||||
</.link>
|
||||
<% end %>
|
||||
|
||||
<%!-- Insights --%>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-800/50">
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Insights</h3>
|
||||
<div class="mt-1 flex items-baseline gap-2">
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{@summary.insights.critical + @summary.insights.warning + @summary.insights.info}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2 text-xs">
|
||||
<%= if @summary.insights.critical > 0 do %>
|
||||
<span class="inline-flex items-center rounded-full bg-red-100 px-1.5 py-0.5 text-red-800 dark:bg-red-900/30 dark:text-red-400">
|
||||
{@summary.insights.critical} critical
|
||||
</span>
|
||||
<% end %>
|
||||
<%= if @summary.insights.warning > 0 do %>
|
||||
<span class="inline-flex items-center rounded-full bg-yellow-100 px-1.5 py-0.5 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
|
||||
{@summary.insights.warning} warning
|
||||
</span>
|
||||
<% end %>
|
||||
<%= if @summary.insights.info > 0 do %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-1.5 py-0.5 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
{@summary.insights.info} info
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if length(@active_alerts) > 0 do %>
|
||||
<div class="mt-8">
|
||||
<%!-- Section B+C: Two-column layout --%>
|
||||
<div class="mt-8 grid gap-8 lg:grid-cols-5">
|
||||
<%!-- Left: Alert Feed (3 cols) --%>
|
||||
<div class="lg:col-span-3">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Active Alerts</h2>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Active Alerts</h2>
|
||||
<.link
|
||||
navigate={~p"/alerts"}
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
View All Alerts →
|
||||
View All →
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<%= for alert <- Enum.take(@active_alerts, 5) do %>
|
||||
<div class="flex items-start gap-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900/50 dark:bg-red-950/30">
|
||||
<.icon
|
||||
name="hero-exclamation-triangle"
|
||||
class="h-5 w-5 flex-shrink-0 text-red-600 dark:text-red-500"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
<.link
|
||||
navigate={~p"/devices/#{alert.device.id}"}
|
||||
class="hover:text-blue-600 hover:underline dark:hover:text-blue-400"
|
||||
>
|
||||
{alert.device.name}
|
||||
</.link>
|
||||
</h3>
|
||||
<p class="mt-0.5 text-sm text-gray-700 dark:text-gray-300">{alert.message}</p>
|
||||
</div>
|
||||
<.timestamp
|
||||
datetime={alert.triggered_at}
|
||||
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if @active_alerts == [] do %>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-8 text-center dark:border-white/10 dark:bg-gray-800/50">
|
||||
<.icon name="hero-check-circle" class="mx-auto h-10 w-10 text-green-500" />
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">No active alerts</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
:for={alert <- @active_alerts}
|
||||
id={"alert-#{alert.id}"}
|
||||
class={[
|
||||
"rounded-lg border p-4 shadow-sm",
|
||||
"border-l-4 border-red-500 bg-red-50 dark:bg-red-950/30 dark:border-red-700"
|
||||
]}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900 dark:text-red-200">
|
||||
<.icon name="hero-exclamation-triangle" class="h-3.5 w-3.5" /> Device Down
|
||||
</span>
|
||||
<%= if alert.acknowledged_at do %>
|
||||
<span class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
Acknowledged
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<h3 class="mt-1.5 font-semibold text-gray-900 dark:text-white">
|
||||
<.link
|
||||
navigate={~p"/devices/#{alert.device.id}"}
|
||||
class="hover:text-blue-600 hover:underline dark:hover:text-blue-400"
|
||||
>
|
||||
{alert.device.name}
|
||||
</.link>
|
||||
</h3>
|
||||
<p class="mt-0.5 text-sm text-gray-700 dark:text-gray-300">{alert.message}</p>
|
||||
|
||||
<%= if length(@active_alerts) > 5 do %>
|
||||
<div class="text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
+ {length(@active_alerts) - 5} more alerts
|
||||
<%!-- Gaiia impact badges --%>
|
||||
<%= if alert.gaiia_impact && alert.gaiia_impact["total_subscribers"] && alert.gaiia_impact["total_subscribers"] > 0 do %>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-400/10 dark:text-amber-400 dark:ring-amber-400/20">
|
||||
<.icon name="hero-users" class="h-3 w-3" />
|
||||
{alert.gaiia_impact["total_subscribers"]} affected
|
||||
</span>
|
||||
<%= if alert.gaiia_impact["total_mrr"] && alert.gaiia_impact["total_mrr"] != "0" do %>
|
||||
<span class="inline-flex items-center gap-1 rounded-md bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-400/10 dark:text-amber-400 dark:ring-amber-400/20">
|
||||
<.icon name="hero-currency-dollar" class="h-3 w-3" />
|
||||
${alert.gaiia_impact["total_mrr"]}/mo
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<.timestamp datetime={alert.triggered_at} />
|
||||
<span :if={alert.device.site} class="ml-2">
|
||||
at
|
||||
<.link
|
||||
navigate={~p"/sites/#{alert.device.site.id}"}
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
{alert.device.site.name}
|
||||
</.link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0">
|
||||
<%= if is_nil(alert.acknowledged_at) && is_nil(alert.resolved_at) do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="acknowledge_alert"
|
||||
phx-value-id={alert.id}
|
||||
class="rounded-md bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:ring-gray-600 dark:hover:bg-gray-700"
|
||||
>
|
||||
Acknowledge
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @total_alert_count > 20 do %>
|
||||
<div class="mt-3 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
+ {@total_alert_count - 20} more alerts.
|
||||
<.link
|
||||
navigate={~p"/alerts"}
|
||||
class="font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
View All
|
||||
</.link>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Right: Insights Feed (2 cols) --%>
|
||||
<div class="lg:col-span-2">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Insights</h2>
|
||||
</div>
|
||||
|
||||
<%!-- Source filter pills --%>
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2">
|
||||
<.link
|
||||
patch={~p"/dashboard"}
|
||||
class={[
|
||||
"rounded-md px-2.5 py-1 text-xs font-medium",
|
||||
if(is_nil(@insight_source),
|
||||
do: "bg-gray-200 text-gray-800 dark:bg-white/20 dark:text-white",
|
||||
else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10"
|
||||
)
|
||||
]}
|
||||
>
|
||||
All
|
||||
</.link>
|
||||
<.link
|
||||
:for={source <- ~w(preseem snmp gaiia system)}
|
||||
patch={~p"/dashboard?insight_source=#{source}"}
|
||||
class={[
|
||||
"rounded-md px-2.5 py-1 text-xs font-medium",
|
||||
if(@insight_source == source,
|
||||
do: "bg-gray-200 text-gray-800 dark:bg-white/20 dark:text-white",
|
||||
else: "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-white/10"
|
||||
)
|
||||
]}
|
||||
>
|
||||
{source}
|
||||
</.link>
|
||||
</div>
|
||||
|
||||
<%= if @insights == [] do %>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-8 text-center dark:border-white/10 dark:bg-gray-800/50">
|
||||
<.icon
|
||||
name="hero-light-bulb"
|
||||
class="mx-auto h-10 w-10 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">No active insights</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
:for={insight <- @insights}
|
||||
id={"insight-#{insight.id}"}
|
||||
class="rounded-lg border border-gray-200 bg-white p-3 dark:border-white/10 dark:bg-gray-800/50"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class={[
|
||||
"inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium",
|
||||
urgency_classes(insight.urgency)
|
||||
]}>
|
||||
{insight.urgency}
|
||||
</span>
|
||||
<span class={[
|
||||
"inline-flex items-center rounded-full px-1.5 py-0.5 text-xs font-medium",
|
||||
source_classes(insight.source)
|
||||
]}>
|
||||
{insight.source}
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="mt-1 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{insight.title}
|
||||
</h4>
|
||||
<%= if insight.description do %>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{insight.description}
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
id={"dismiss-insight-#{insight.id}"}
|
||||
phx-click="dismiss_insight"
|
||||
phx-value-id={insight.id}
|
||||
class="flex-shrink-0 rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-white/10 dark:hover:text-gray-300"
|
||||
title="Dismiss"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Section D: Site Health Grid --%>
|
||||
<%= if @current_scope.organization.use_sites && @site_summaries != [] do %>
|
||||
<div class="mt-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Site Health</h2>
|
||||
<.link
|
||||
navigate={~p"/sites"}
|
||||
class="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
View All Sites →
|
||||
</.link>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<.link
|
||||
:for={site <- @site_summaries}
|
||||
navigate={~p"/sites/#{site.site_id}"}
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-shadow hover:shadow-md dark:border-white/10 dark:bg-gray-800/50"
|
||||
>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">{site.name}</h3>
|
||||
<div class="mt-2 flex items-center gap-3 text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">
|
||||
{site.device_count} devices
|
||||
</span>
|
||||
<%= if site.devices_down > 0 do %>
|
||||
<span class="flex items-center gap-1 text-red-600 dark:text-red-400">
|
||||
<span class="h-2 w-2 rounded-full bg-red-500"></span>
|
||||
{site.devices_down} down
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= if site.subscribers do %>
|
||||
<div class="mt-2 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<.icon name="hero-users" class="h-3.5 w-3.5" />
|
||||
{site.subscribers} subscribers
|
||||
</span>
|
||||
<%= if site.mrr do %>
|
||||
<span>{format_mrr(site.mrr)}/mo</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -285,6 +285,12 @@
|
|||
>
|
||||
{row.stats.down} down
|
||||
</span>
|
||||
<%= if row.site && @site_subscribers[row.site.id] do %>
|
||||
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium bg-amber-50 text-amber-800 dark:bg-amber-400/10 dark:text-amber-400">
|
||||
<.icon name="hero-users" class="h-3 w-3" />
|
||||
{@site_subscribers[row.site.id].account_count} subscribers
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<p
|
||||
:if={row.site && row.site.location}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ defmodule ToweropsWeb.SiteLive.Show do
|
|||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Dashboard
|
||||
alias Towerops.Devices
|
||||
alias Towerops.Monitoring
|
||||
alias Towerops.Sites
|
||||
|
|
@ -19,13 +20,15 @@ defmodule ToweropsWeb.SiteLive.Show do
|
|||
site = Sites.get_organization_site!(organization.id, id)
|
||||
device = Devices.list_site_devices(site.id)
|
||||
latency_chart_data = load_site_latency_chart_data(device)
|
||||
site_summary = Dashboard.get_site_summary(site.id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:page_title, site.name)
|
||||
|> assign(:site, site)
|
||||
|> assign(:device, device)
|
||||
|> assign(:latency_chart_data, latency_chart_data)}
|
||||
|> assign(:latency_chart_data, latency_chart_data)
|
||||
|> assign(:site_summary, site_summary)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
|
@ -42,7 +45,10 @@ defmodule ToweropsWeb.SiteLive.Show do
|
|||
end)
|
||||
|
||||
count = length(snmp_devices)
|
||||
message = t_equipment("Discovery started for %{count} device", count: count) <> if count == 1, do: "", else: "s"
|
||||
|
||||
message =
|
||||
t_equipment("Discovery started for %{count} device", count: count) <>
|
||||
if(count == 1, do: "", else: "s")
|
||||
|
||||
{:noreply, put_flash(socket, :info, message)}
|
||||
end
|
||||
|
|
@ -84,6 +90,27 @@ defmodule ToweropsWeb.SiteLive.Show do
|
|||
}
|
||||
end
|
||||
|
||||
defp format_mrr(nil), do: "$0"
|
||||
|
||||
defp format_mrr(%Decimal{} = amount) do
|
||||
"$#{format_number(Decimal.to_integer(Decimal.round(amount, 0)))}"
|
||||
end
|
||||
|
||||
defp format_mrr(amount) when is_number(amount), do: "$#{format_number(round(amount))}"
|
||||
defp format_mrr(_), do: "$0"
|
||||
|
||||
defp format_number(number) when is_integer(number) do
|
||||
number
|
||||
|> Integer.to_string()
|
||||
|> String.graphemes()
|
||||
|> Enum.reverse()
|
||||
|> Enum.chunk_every(3)
|
||||
|> Enum.join(",")
|
||||
|> String.reverse()
|
||||
end
|
||||
|
||||
defp format_number(number), do: to_string(number)
|
||||
|
||||
# Enqueue discovery job - safe to call in test environment
|
||||
defp enqueue_discovery(device_id) do
|
||||
if Application.get_env(:towerops, :env) == :test do
|
||||
|
|
|
|||
|
|
@ -22,6 +22,42 @@
|
|||
</:actions>
|
||||
</.header>
|
||||
|
||||
<%!-- Metrics Bar --%>
|
||||
<div class="mt-6 flex flex-wrap items-center gap-3">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<.icon name="hero-server" class="h-3.5 w-3.5" />
|
||||
{@site_summary.device_count} {if @site_summary.device_count == 1,
|
||||
do: "device",
|
||||
else: "devices"}
|
||||
</span>
|
||||
<%= if @site_summary.devices_down > 0 do %>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-md bg-red-100 px-2.5 py-1 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-400">
|
||||
<span class="h-2 w-2 rounded-full bg-red-500"></span>
|
||||
{@site_summary.devices_down} down
|
||||
</span>
|
||||
<% end %>
|
||||
<%= if @site_summary.alert_count > 0 do %>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-md bg-red-50 px-2.5 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-600/20 dark:bg-red-900/20 dark:text-red-400 dark:ring-red-400/20">
|
||||
<.icon name="hero-bell-alert" class="h-3.5 w-3.5" />
|
||||
{@site_summary.alert_count} {if @site_summary.alert_count == 1,
|
||||
do: "alert",
|
||||
else: "alerts"}
|
||||
</span>
|
||||
<% end %>
|
||||
<%= if @site_summary.subscribers do %>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-md bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-800 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-400/10 dark:text-amber-400 dark:ring-amber-400/20">
|
||||
<.icon name="hero-users" class="h-3.5 w-3.5" />
|
||||
{@site_summary.subscribers} subscribers
|
||||
</span>
|
||||
<%= if @site_summary.mrr do %>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-md bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-800 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-400/10 dark:text-amber-400 dark:ring-amber-400/20">
|
||||
<.icon name="hero-currency-dollar" class="h-3.5 w-3.5" />
|
||||
{format_mrr(@site_summary.mrr)}/mo MRR
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Site Details</h3>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
174
test/towerops/dashboard_test.exs
Normal file
174
test/towerops/dashboard_test.exs
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
defmodule Towerops.DashboardTest do
|
||||
use Towerops.DataCase, async: true
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.DevicesFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Dashboard
|
||||
alias Towerops.Gaiia
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
|
||||
%{org: org, user: user}
|
||||
end
|
||||
|
||||
describe "get_dashboard_summary/1" do
|
||||
test "returns correct structure with empty org", %{org: org} do
|
||||
summary = Dashboard.get_dashboard_summary(org.id)
|
||||
|
||||
assert is_map(summary)
|
||||
assert Map.has_key?(summary, :health_score)
|
||||
assert Map.has_key?(summary, :devices)
|
||||
assert Map.has_key?(summary, :alerts)
|
||||
assert Map.has_key?(summary, :subscribers)
|
||||
assert Map.has_key?(summary, :insights)
|
||||
end
|
||||
|
||||
test "returns device counts", %{org: org} do
|
||||
device = device_fixture(%{organization_id: org.id})
|
||||
Towerops.Devices.update_device_status(device, :up)
|
||||
|
||||
summary = Dashboard.get_dashboard_summary(org.id)
|
||||
|
||||
assert summary.devices.total == 1
|
||||
assert summary.devices.up == 1
|
||||
assert summary.devices.down == 0
|
||||
end
|
||||
|
||||
test "returns alert count", %{org: org} do
|
||||
device = device_fixture(%{organization_id: org.id})
|
||||
|
||||
{:ok, _alert} =
|
||||
Towerops.Alerts.create_alert(%{
|
||||
device_id: device.id,
|
||||
alert_type: :device_down,
|
||||
triggered_at: DateTime.utc_now(),
|
||||
message: "Device is down"
|
||||
})
|
||||
|
||||
summary = Dashboard.get_dashboard_summary(org.id)
|
||||
assert summary.alerts.active_count == 1
|
||||
end
|
||||
|
||||
test "returns subscriber totals from Gaiia", %{org: org} do
|
||||
{:ok, _} =
|
||||
Gaiia.upsert_network_site(org.id, %{
|
||||
gaiia_id: "ns-dash",
|
||||
name: "Tower",
|
||||
account_count: 100,
|
||||
total_mrr: Decimal.new("5000.00")
|
||||
})
|
||||
|
||||
summary = Dashboard.get_dashboard_summary(org.id)
|
||||
assert summary.subscribers.total == 100
|
||||
assert Decimal.equal?(summary.subscribers.total_mrr, Decimal.new("5000.00"))
|
||||
end
|
||||
|
||||
test "returns zero subscribers when no Gaiia data", %{org: org} do
|
||||
summary = Dashboard.get_dashboard_summary(org.id)
|
||||
assert summary.subscribers.total == 0
|
||||
end
|
||||
|
||||
test "returns insight counts by urgency", %{org: org} do
|
||||
summary = Dashboard.get_dashboard_summary(org.id)
|
||||
assert summary.insights.critical == 0
|
||||
assert summary.insights.warning == 0
|
||||
assert summary.insights.info == 0
|
||||
end
|
||||
end
|
||||
|
||||
describe "compute_health_score/1" do
|
||||
test "returns 100 when all devices up, no alerts", %{org: org} do
|
||||
device = device_fixture(%{organization_id: org.id})
|
||||
Towerops.Devices.update_device_status(device, :up)
|
||||
|
||||
score = Dashboard.compute_health_score(org.id)
|
||||
assert score == 100
|
||||
end
|
||||
|
||||
test "returns lower score when devices down", %{org: org} do
|
||||
d1 = device_fixture(%{organization_id: org.id})
|
||||
d2 = device_fixture(%{organization_id: org.id})
|
||||
Towerops.Devices.update_device_status(d1, :up)
|
||||
Towerops.Devices.update_device_status(d2, :down)
|
||||
|
||||
score = Dashboard.compute_health_score(org.id)
|
||||
assert score < 100
|
||||
assert score > 0
|
||||
end
|
||||
|
||||
test "returns 100 when no devices exist", %{org: org} do
|
||||
score = Dashboard.compute_health_score(org.id)
|
||||
assert score == 100
|
||||
end
|
||||
|
||||
test "penalizes for critical alerts", %{org: org} do
|
||||
device = device_fixture(%{organization_id: org.id})
|
||||
Towerops.Devices.update_device_status(device, :up)
|
||||
|
||||
{:ok, _} =
|
||||
Towerops.Alerts.create_alert(%{
|
||||
device_id: device.id,
|
||||
alert_type: :device_down,
|
||||
triggered_at: DateTime.utc_now(),
|
||||
message: "Device down"
|
||||
})
|
||||
|
||||
score = Dashboard.compute_health_score(org.id)
|
||||
assert score < 100
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_org_subscriber_summary/1" do
|
||||
test "returns totals from Gaiia network sites", %{org: org} do
|
||||
{:ok, _} =
|
||||
Gaiia.upsert_network_site(org.id, %{
|
||||
gaiia_id: "ns-sub1",
|
||||
name: "Tower A",
|
||||
account_count: 50,
|
||||
total_mrr: Decimal.new("2500.00")
|
||||
})
|
||||
|
||||
{:ok, _} =
|
||||
Gaiia.upsert_network_site(org.id, %{
|
||||
gaiia_id: "ns-sub2",
|
||||
name: "Tower B",
|
||||
account_count: 30,
|
||||
total_mrr: Decimal.new("1500.00")
|
||||
})
|
||||
|
||||
result = Dashboard.get_org_subscriber_summary(org.id)
|
||||
assert result.total == 80
|
||||
assert Decimal.equal?(result.total_mrr, Decimal.new("4000.00"))
|
||||
end
|
||||
|
||||
test "returns zeros when no Gaiia data", %{org: org} do
|
||||
result = Dashboard.get_org_subscriber_summary(org.id)
|
||||
assert result.total == 0
|
||||
assert Decimal.equal?(result.total_mrr, Decimal.new(0))
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_site_summaries/1" do
|
||||
test "returns enriched site data", %{org: org} do
|
||||
{:ok, site} =
|
||||
Towerops.Sites.create_site(%{
|
||||
name: "Test Tower",
|
||||
organization_id: org.id
|
||||
})
|
||||
|
||||
device = device_fixture(%{organization_id: org.id, site_id: site.id})
|
||||
Towerops.Devices.update_device_status(device, :up)
|
||||
|
||||
summaries = Dashboard.list_site_summaries(org.id)
|
||||
assert summaries != []
|
||||
|
||||
summary = Enum.find(summaries, &(&1.site_id == site.id))
|
||||
assert summary.name == "Test Tower"
|
||||
assert summary.device_count >= 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
85
test/towerops/workers/device_health_insight_worker_test.exs
Normal file
85
test/towerops/workers/device_health_insight_worker_test.exs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
defmodule Towerops.Workers.DeviceHealthInsightWorkerTest do
|
||||
use Towerops.DataCase, async: false
|
||||
use Oban.Testing, repo: Towerops.Repo
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.DevicesFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Devices.Device
|
||||
alias Towerops.Preseem.Insights
|
||||
alias Towerops.Workers.DeviceHealthInsightWorker
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
device = device_fixture(%{organization_id: org.id})
|
||||
|
||||
%{org: org, device: device}
|
||||
end
|
||||
|
||||
describe "perform/1" do
|
||||
test "returns :ok with no devices" do
|
||||
assert :ok = perform_job(DeviceHealthInsightWorker, %{})
|
||||
end
|
||||
|
||||
test "generates device_poll_gap insight when device not polled in 24h", %{org: org, device: device} do
|
||||
stale_time = DateTime.add(DateTime.utc_now(), -25, :hour)
|
||||
|
||||
Repo.update_all(
|
||||
from(d in Device, where: d.id == ^device.id),
|
||||
set: [last_snmp_poll_at: stale_time, monitoring_enabled: true]
|
||||
)
|
||||
|
||||
assert :ok = perform_job(DeviceHealthInsightWorker, %{})
|
||||
|
||||
insights = Insights.list_insights(org.id, type: "device_poll_gap")
|
||||
assert length(insights) == 1
|
||||
assert hd(insights).source == "snmp"
|
||||
assert hd(insights).device_id == device.id
|
||||
end
|
||||
|
||||
test "does not generate poll_gap insight when device was recently polled", %{org: org, device: device} do
|
||||
recent_time = DateTime.add(DateTime.utc_now(), -1, :hour)
|
||||
|
||||
Repo.update_all(
|
||||
from(d in Device, where: d.id == ^device.id),
|
||||
set: [last_snmp_poll_at: recent_time, monitoring_enabled: true]
|
||||
)
|
||||
|
||||
assert :ok = perform_job(DeviceHealthInsightWorker, %{})
|
||||
|
||||
insights = Insights.list_insights(org.id, type: "device_poll_gap")
|
||||
assert Enum.empty?(insights)
|
||||
end
|
||||
|
||||
test "does not duplicate insights for same device", %{org: org, device: device} do
|
||||
stale_time = DateTime.add(DateTime.utc_now(), -25, :hour)
|
||||
|
||||
Repo.update_all(
|
||||
from(d in Device, where: d.id == ^device.id),
|
||||
set: [last_snmp_poll_at: stale_time, monitoring_enabled: true]
|
||||
)
|
||||
|
||||
assert :ok = perform_job(DeviceHealthInsightWorker, %{})
|
||||
assert :ok = perform_job(DeviceHealthInsightWorker, %{})
|
||||
|
||||
insights = Insights.list_insights(org.id, type: "device_poll_gap")
|
||||
assert length(insights) == 1
|
||||
end
|
||||
|
||||
test "skips devices with monitoring disabled", %{org: org, device: device} do
|
||||
stale_time = DateTime.add(DateTime.utc_now(), -25, :hour)
|
||||
|
||||
Repo.update_all(
|
||||
from(d in Device, where: d.id == ^device.id),
|
||||
set: [last_snmp_poll_at: stale_time, monitoring_enabled: false]
|
||||
)
|
||||
|
||||
assert :ok = perform_job(DeviceHealthInsightWorker, %{})
|
||||
|
||||
insights = Insights.list_insights(org.id, type: "device_poll_gap")
|
||||
assert Enum.empty?(insights)
|
||||
end
|
||||
end
|
||||
end
|
||||
60
test/towerops/workers/gaiia_insight_worker_test.exs
Normal file
60
test/towerops/workers/gaiia_insight_worker_test.exs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
defmodule Towerops.Workers.GaiiaInsightWorkerTest do
|
||||
use Towerops.DataCase, async: false
|
||||
use Oban.Testing, repo: Towerops.Repo
|
||||
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.DevicesFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Integrations
|
||||
alias Towerops.Preseem.Insights
|
||||
alias Towerops.Workers.GaiiaInsightWorker
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
|
||||
%{org: org}
|
||||
end
|
||||
|
||||
describe "perform/1" do
|
||||
test "returns :ok with no gaiia integrations" do
|
||||
assert :ok = perform_job(GaiiaInsightWorker, %{})
|
||||
end
|
||||
|
||||
test "generates reconciliation_finding for untracked devices", %{org: org} do
|
||||
setup_gaiia_integration(org)
|
||||
|
||||
# Create a device that has no Gaiia inventory item mapped
|
||||
_device = device_fixture(%{organization_id: org.id})
|
||||
|
||||
assert :ok = perform_job(GaiiaInsightWorker, %{})
|
||||
|
||||
insights = Insights.list_insights(org.id, type: "reconciliation_finding")
|
||||
assert insights != []
|
||||
assert hd(insights).source == "gaiia"
|
||||
end
|
||||
|
||||
test "does not duplicate reconciliation insights on repeated runs", %{org: org} do
|
||||
setup_gaiia_integration(org)
|
||||
_device = device_fixture(%{organization_id: org.id})
|
||||
|
||||
assert :ok = perform_job(GaiiaInsightWorker, %{})
|
||||
first_count = length(Insights.list_insights(org.id, source: "gaiia"))
|
||||
|
||||
assert :ok = perform_job(GaiiaInsightWorker, %{})
|
||||
second_count = length(Insights.list_insights(org.id, source: "gaiia"))
|
||||
|
||||
assert first_count == second_count
|
||||
end
|
||||
end
|
||||
|
||||
defp setup_gaiia_integration(org) do
|
||||
{:ok, _integration} =
|
||||
Integrations.create_integration(org.id, %{
|
||||
provider: "gaiia",
|
||||
enabled: true,
|
||||
config: %{"api_key" => "test-key"}
|
||||
})
|
||||
end
|
||||
end
|
||||
100
test/towerops/workers/system_insight_worker_test.exs
Normal file
100
test/towerops/workers/system_insight_worker_test.exs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
defmodule Towerops.Workers.SystemInsightWorkerTest do
|
||||
use Towerops.DataCase, async: false
|
||||
use Oban.Testing, repo: Towerops.Repo
|
||||
|
||||
import Ecto.Query
|
||||
import Towerops.AccountsFixtures
|
||||
import Towerops.OrganizationsFixtures
|
||||
|
||||
alias Towerops.Agents
|
||||
alias Towerops.Agents.AgentToken
|
||||
alias Towerops.Preseem.Insights
|
||||
alias Towerops.Workers.SystemInsightWorker
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
org = organization_fixture(user.id)
|
||||
|
||||
%{org: org}
|
||||
end
|
||||
|
||||
describe "perform/1" do
|
||||
test "returns :ok with no agents" do
|
||||
assert :ok = perform_job(SystemInsightWorker, %{})
|
||||
end
|
||||
|
||||
test "generates agent_offline insight for stale agent", %{org: org} do
|
||||
{:ok, agent, _token} = Agents.create_agent_token(org.id, "Offline Agent")
|
||||
|
||||
stale_time = DateTime.add(DateTime.utc_now(), -15, :minute)
|
||||
|
||||
Repo.update_all(
|
||||
from(a in AgentToken, where: a.id == ^agent.id),
|
||||
set: [last_seen_at: stale_time]
|
||||
)
|
||||
|
||||
assert :ok = perform_job(SystemInsightWorker, %{})
|
||||
|
||||
insights = Insights.list_insights(org.id, type: "agent_offline")
|
||||
assert length(insights) == 1
|
||||
assert hd(insights).source == "system"
|
||||
assert hd(insights).agent_token_id == agent.id
|
||||
end
|
||||
|
||||
test "does not generate insight for fresh agent", %{org: org} do
|
||||
{:ok, agent, _token} = Agents.create_agent_token(org.id, "Fresh Agent")
|
||||
Agents.update_agent_token_heartbeat(agent.id, "192.168.1.1", %{})
|
||||
|
||||
assert :ok = perform_job(SystemInsightWorker, %{})
|
||||
|
||||
insights = Insights.list_insights(org.id, type: "agent_offline")
|
||||
assert Enum.empty?(insights)
|
||||
end
|
||||
|
||||
test "auto-resolves insight when agent comes back online", %{org: org} do
|
||||
{:ok, agent, _token} = Agents.create_agent_token(org.id, "Recovering Agent")
|
||||
|
||||
# First: agent goes offline
|
||||
stale_time = DateTime.add(DateTime.utc_now(), -15, :minute)
|
||||
|
||||
Repo.update_all(
|
||||
from(a in AgentToken, where: a.id == ^agent.id),
|
||||
set: [last_seen_at: stale_time]
|
||||
)
|
||||
|
||||
assert :ok = perform_job(SystemInsightWorker, %{})
|
||||
|
||||
insights = Insights.list_insights(org.id, type: "agent_offline")
|
||||
assert length(insights) == 1
|
||||
|
||||
# Then: agent comes back online
|
||||
Agents.update_agent_token_heartbeat(agent.id, "192.168.1.1", %{})
|
||||
|
||||
assert :ok = perform_job(SystemInsightWorker, %{})
|
||||
|
||||
# Should be resolved now
|
||||
active_insights = Insights.list_insights(org.id, type: "agent_offline")
|
||||
assert Enum.empty?(active_insights)
|
||||
|
||||
resolved_insights = Insights.list_insights(org.id, type: "agent_offline", status: "resolved")
|
||||
assert length(resolved_insights) == 1
|
||||
end
|
||||
|
||||
test "does not duplicate insights for same agent", %{org: org} do
|
||||
{:ok, agent, _token} = Agents.create_agent_token(org.id, "Still Offline")
|
||||
|
||||
stale_time = DateTime.add(DateTime.utc_now(), -15, :minute)
|
||||
|
||||
Repo.update_all(
|
||||
from(a in AgentToken, where: a.id == ^agent.id),
|
||||
set: [last_seen_at: stale_time]
|
||||
)
|
||||
|
||||
assert :ok = perform_job(SystemInsightWorker, %{})
|
||||
assert :ok = perform_job(SystemInsightWorker, %{})
|
||||
|
||||
insights = Insights.list_insights(org.id, type: "agent_offline")
|
||||
assert length(insights) == 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue