towerops/lib/towerops_web/live/activity_feed_live.ex

267 lines
9.1 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
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
:timer.send_interval(@tick_interval_ms, self(), :tick)
end
{:ok,
socket
|> assign(:page_title, "Activity Feed")
|> assign(:active_types, @all_types)
|> assign(:limit, @default_limit)
|> assign(:has_more, true)
|> assign(:search, "")
|> assign(:now, DateTime.utc_now())
|> 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
# --- 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 < 86400 -> "#{div(diff, 3600)}h ago"
diff < 172_800 -> "yesterday"
diff < 604_800 -> "#{div(diff, 86400)}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