towerops/lib/towerops/workers/system_insight_worker.ex
Graham McIntire 20f5f48372
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
2026-02-13 14:52:49 -06:00

91 lines
2.7 KiB
Elixir

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