Critical Fixes (5): - Fix Task.yield_many race condition causing data corruption in DevicePollerWorker - Fix Enum.zip data corruption in SNMP Base Profile with length validation - Fix missing Alert schema fields for check alerts (check_id, severity, etc.) - Fix memory leaks from uncancelled LiveView timers in 4 components - Fix PubSub subscription leak in device form credential testing High Severity Fixes (3): - Fix clock skew bug in needs_discovery? check with DateTime.diff clamping - Fix nil crash in interface status display with proper nil handling - Fix migration index names after equipment→devices table rename Medium Severity Fixes (6): - Fix race condition in device monitor worker (duplicate maintenance checks) - Fix missing preload validation in devices.ex get_org_default_agent - Fix broad rescue clause in alerts.ex with specific error handling - Fix fire-and-forget notification tasks with try-catch error logging - Fix LiveView state bleeding between tabs (assign_new → assign) - Add catch-all handle_info callbacks to 3 LiveViews Infrastructure: - Silence health check endpoint logs (/health, /health/time) - Add migration to fix equipment index names missed in rename Files Changed: 16 files modified, 1 migration added All changes compile successfully and are backward-compatible.
279 lines
9.3 KiB
Elixir
279 lines
9.3 KiB
Elixir
defmodule ToweropsWeb.ActivityFeedLive do
|
|
@moduledoc """
|
|
Live view for the organization-wide activity feed / network ops journal.
|
|
Real-time NOC operations log with search, time grouping, and auto-refresh.
|
|
"""
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.ActivityFeed
|
|
|
|
@default_limit 50
|
|
@all_types ~w(config_change alert_fired alert_resolved device_event sync device_added)a
|
|
@tick_interval_ms 30_000
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
timer_ref =
|
|
if connected?(socket) do
|
|
# Subscribe to all relevant PubSub topics for real-time updates
|
|
Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:new")
|
|
Phoenix.PubSub.subscribe(Towerops.PubSub, "alerts:org:#{organization.id}:resolved")
|
|
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}")
|
|
Phoenix.PubSub.subscribe(Towerops.PubSub, "config:org:#{organization.id}")
|
|
Phoenix.PubSub.subscribe(Towerops.PubSub, "sync:org:#{organization.id}")
|
|
Phoenix.PubSub.subscribe(Towerops.PubSub, "device_events:org:#{organization.id}")
|
|
|
|
# Timer for relative timestamp refresh
|
|
{:ok, ref} = :timer.send_interval(@tick_interval_ms, self(), :tick)
|
|
ref
|
|
end
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, t("Activity Feed"))
|
|
|> assign(:active_types, @all_types)
|
|
|> assign(:limit, @default_limit)
|
|
|> assign(:has_more, true)
|
|
|> assign(:search, "")
|
|
|> assign(:now, DateTime.utc_now())
|
|
|> assign(:timer_ref, timer_ref)
|
|
|> load_activity()
|
|
|> load_counts()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(_params, _url, socket) do
|
|
{:noreply, socket}
|
|
end
|
|
|
|
# --- Events ---
|
|
|
|
@impl true
|
|
def handle_event("toggle_type", %{"type" => type_str}, socket) do
|
|
type = String.to_existing_atom(type_str)
|
|
active = socket.assigns.active_types
|
|
|
|
new_active =
|
|
if type in active do
|
|
List.delete(active, type)
|
|
else
|
|
[type | active]
|
|
end
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:active_types, new_active)
|
|
|> assign(:limit, @default_limit)
|
|
|> assign(:has_more, true)
|
|
|> load_activity()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("search", %{"search" => query}, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:search, query)
|
|
|> assign(:limit, @default_limit)
|
|
|> assign(:has_more, true)
|
|
|> load_activity()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("clear_search", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:search, "")
|
|
|> assign(:limit, @default_limit)
|
|
|> assign(:has_more, true)
|
|
|> load_activity()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("load_more", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> update(:limit, &(&1 + @default_limit))
|
|
|> load_activity()}
|
|
end
|
|
|
|
# --- PubSub handlers ---
|
|
|
|
@impl true
|
|
def handle_info({:new_alert, _device_id, _alert_type}, socket) do
|
|
{:noreply, socket |> load_activity() |> load_counts()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:alert_resolved, _device_id, _alert_type}, socket) do
|
|
{:noreply, socket |> load_activity() |> load_counts()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:device_added, _device_id}, socket) do
|
|
{:noreply, socket |> load_activity() |> load_counts()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:config_changed, _device_id}, socket) do
|
|
{:noreply, socket |> load_activity() |> load_counts()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:sync_completed, _status}, socket) do
|
|
{:noreply, socket |> load_activity() |> load_counts()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:device_event, _device_id, _event_type}, socket) do
|
|
{:noreply, socket |> load_activity() |> load_counts()}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(:tick, socket) do
|
|
{:noreply, assign(socket, :now, DateTime.utc_now())}
|
|
end
|
|
|
|
# Catch-all for unknown PubSub messages
|
|
@impl true
|
|
def handle_info(_msg, socket) do
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def terminate(_reason, socket) do
|
|
if timer_ref = socket.assigns[:timer_ref] do
|
|
:timer.cancel(timer_ref)
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
# --- Helpers ---
|
|
|
|
defp filter_options do
|
|
[
|
|
{:config_change, "Config", "hero-wrench-screwdriver"},
|
|
{:alert_fired, "Alerts", "hero-exclamation-triangle"},
|
|
{:alert_resolved, "Resolved", "hero-check-circle"},
|
|
{:device_event, "Events", "hero-bolt"},
|
|
{:sync, "Syncs", "hero-arrow-path"},
|
|
{:device_added, "Devices", "hero-plus-circle"}
|
|
]
|
|
end
|
|
|
|
defp filter_active_class(:config_change), do: "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400"
|
|
defp filter_active_class(:alert_fired), do: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
|
defp filter_active_class(:alert_resolved), do: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
|
defp filter_active_class(:device_event), do: "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400"
|
|
defp filter_active_class(:sync), do: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
|
|
defp filter_active_class(:device_added), do: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-400"
|
|
defp filter_active_class(_), do: "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300"
|
|
|
|
defp severity_dot_color(:critical, _), do: "bg-red-500"
|
|
defp severity_dot_color(:warning, _), do: "bg-yellow-500"
|
|
defp severity_dot_color(:info, :alert_resolved), do: "bg-green-500"
|
|
defp severity_dot_color(:info, :sync), do: "bg-blue-500"
|
|
defp severity_dot_color(:info, :device_added), do: "bg-cyan-500"
|
|
defp severity_dot_color(:info, _), do: "bg-gray-400"
|
|
defp severity_dot_color(_, _), do: "bg-gray-400"
|
|
|
|
defp severity_icon_color(:critical, _), do: "text-red-500 dark:text-red-400"
|
|
defp severity_icon_color(:warning, _), do: "text-yellow-500 dark:text-yellow-400"
|
|
defp severity_icon_color(:info, :alert_resolved), do: "text-green-500 dark:text-green-400"
|
|
defp severity_icon_color(:info, :sync), do: "text-blue-500 dark:text-blue-400"
|
|
defp severity_icon_color(:info, :device_added), do: "text-cyan-500 dark:text-cyan-400"
|
|
defp severity_icon_color(_, _), do: "text-gray-400 dark:text-gray-500"
|
|
|
|
defp severity_text_color(:critical, _), do: "text-red-700 dark:text-red-400"
|
|
defp severity_text_color(:warning, _), do: "text-yellow-700 dark:text-yellow-400"
|
|
defp severity_text_color(:info, :alert_resolved), do: "text-green-700 dark:text-green-400"
|
|
defp severity_text_color(_, _), do: "text-gray-900 dark:text-white"
|
|
|
|
defp type_badge_class(:config_change), do: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
|
|
defp type_badge_class(:alert_fired), do: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
|
defp type_badge_class(:alert_resolved), do: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
|
defp type_badge_class(:device_event), do: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
|
|
defp type_badge_class(:sync), do: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
|
defp type_badge_class(:device_added), do: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400"
|
|
defp type_badge_class(_), do: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
|
|
|
|
defp type_label(:config_change), do: "Config"
|
|
defp type_label(:alert_fired), do: "Alert"
|
|
defp type_label(:alert_resolved), do: "Resolved"
|
|
defp type_label(:device_event), do: "Event"
|
|
defp type_label(:sync), do: "Sync"
|
|
defp type_label(:device_added), do: "Device"
|
|
defp type_label(_), do: "Other"
|
|
|
|
defp load_activity(socket) do
|
|
org_id = socket.assigns.current_scope.organization.id
|
|
limit = socket.assigns.limit
|
|
types = socket.assigns.active_types
|
|
search = socket.assigns.search
|
|
|
|
search_opt = if search == "", do: nil, else: search
|
|
|
|
items =
|
|
ActivityFeed.list_org_activity(org_id,
|
|
limit: limit + 1,
|
|
types: types,
|
|
search: search_opt
|
|
)
|
|
|
|
has_more = length(items) > limit
|
|
trimmed = Enum.take(items, limit)
|
|
|
|
socket
|
|
|> assign(:items, trimmed)
|
|
|> assign(:grouped_items, group_by_period(trimmed))
|
|
|> assign(:has_more, has_more)
|
|
end
|
|
|
|
defp load_counts(socket) do
|
|
org_id = socket.assigns.current_scope.organization.id
|
|
counts = ActivityFeed.count_by_type(org_id)
|
|
assign(socket, :type_counts, counts)
|
|
end
|
|
|
|
@doc false
|
|
def relative_time(timestamp, now) do
|
|
diff = DateTime.diff(now, timestamp, :second)
|
|
|
|
cond do
|
|
diff < 5 -> "just now"
|
|
diff < 60 -> "#{diff}s ago"
|
|
diff < 3600 -> "#{div(diff, 60)}m ago"
|
|
diff < 86_400 -> "#{div(diff, 3600)}h ago"
|
|
diff < 172_800 -> "yesterday"
|
|
diff < 604_800 -> "#{div(diff, 86_400)}d ago"
|
|
true -> Calendar.strftime(timestamp, "%b %d, %Y")
|
|
end
|
|
end
|
|
|
|
defp group_by_period(items) do
|
|
today = Date.utc_today()
|
|
yesterday = Date.add(today, -1)
|
|
week_ago = Date.add(today, -7)
|
|
|
|
items
|
|
|> Enum.group_by(fn item ->
|
|
date = DateTime.to_date(item.timestamp)
|
|
|
|
cond do
|
|
date == today -> "Today"
|
|
date == yesterday -> "Yesterday"
|
|
Date.compare(date, week_ago) != :lt -> "This Week"
|
|
true -> "Earlier"
|
|
end
|
|
end)
|
|
|> then(fn groups ->
|
|
# Return in chronological order of periods
|
|
for period <- ["Today", "Yesterday", "This Week", "Earlier"],
|
|
items = Map.get(groups, period),
|
|
items != nil and items != [],
|
|
do: {period, items}
|
|
end)
|
|
end
|
|
end
|