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