- Replace gray->cool-steel, blue/indigo->cerulean, red->sweet-salmon, yellow/amber->wheat - Dark sidebar: sidebar/footer use cool-steel-800 bg, text lightened for contrast - ~11,600 color replacements across ~99 files - Update tests for new color class names
305 lines
10 KiB
Elixir
305 lines
10 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
|
|
case parse_activity_type(type_str) do
|
|
nil ->
|
|
{:noreply, socket}
|
|
|
|
type ->
|
|
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
|
|
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 ---
|
|
|
|
# Lookup against the static @all_types whitelist. Never use
|
|
# `String.to_existing_atom/1` on user-controlled input — an unknown atom
|
|
# raises ArgumentError and crashes the LiveView.
|
|
defp parse_activity_type(type_str) when is_binary(type_str) do
|
|
Enum.find(@all_types, fn t -> Atom.to_string(t) == type_str end)
|
|
end
|
|
|
|
defp parse_activity_type(_), do: nil
|
|
|
|
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-sweet-salmon-100 text-sweet-salmon-800 dark:bg-sweet-salmon-900/30 dark:text-sweet-salmon-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-cerulean-100 text-cerulean-800 dark:bg-cerulean-900/30 dark:text-cerulean-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-cool-steel-200 text-cool-steel-700 dark:bg-cool-steel-700 dark:text-cool-steel-300"
|
|
|
|
@doc false
|
|
def severity_dot_color(:critical, _), do: "bg-sweet-salmon-500"
|
|
def severity_dot_color(:warning, _), do: "bg-wheat-500"
|
|
def severity_dot_color(:info, :alert_resolved), do: "bg-green-500"
|
|
def severity_dot_color(:info, :sync), do: "bg-cerulean-500"
|
|
def severity_dot_color(:info, :device_added), do: "bg-cyan-500"
|
|
def severity_dot_color(:info, _), do: "bg-cool-steel-400"
|
|
def severity_dot_color(_, _), do: "bg-cool-steel-400"
|
|
|
|
@doc false
|
|
def severity_icon_color(:critical, _), do: "text-sweet-salmon-500 dark:text-sweet-salmon-400"
|
|
def severity_icon_color(:warning, _), do: "text-wheat-500 dark:text-wheat-400"
|
|
def severity_icon_color(:info, :alert_resolved), do: "text-green-500 dark:text-green-400"
|
|
def severity_icon_color(:info, :sync), do: "text-cerulean-500 dark:text-cerulean-400"
|
|
def severity_icon_color(:info, :device_added), do: "text-cyan-500 dark:text-cyan-400"
|
|
def severity_icon_color(_, _), do: "text-cool-steel-400 dark:text-cool-steel-500"
|
|
|
|
@doc false
|
|
def severity_text_color(:critical, _), do: "text-sweet-salmon-700 dark:text-sweet-salmon-400"
|
|
def severity_text_color(:warning, _), do: "text-wheat-700 dark:text-wheat-400"
|
|
def severity_text_color(:info, :alert_resolved), do: "text-green-700 dark:text-green-400"
|
|
def severity_text_color(_, _), do: "text-cool-steel-900 dark:text-white"
|
|
|
|
@doc false
|
|
def type_badge_class(:config_change), do: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
|
|
|
|
def type_badge_class(:alert_fired),
|
|
do: "bg-sweet-salmon-100 text-sweet-salmon-700 dark:bg-sweet-salmon-900/30 dark:text-sweet-salmon-400"
|
|
|
|
def type_badge_class(:alert_resolved), do: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
|
def type_badge_class(:device_event), do: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
|
|
def type_badge_class(:sync), do: "bg-cerulean-100 text-cerulean-700 dark:bg-cerulean-900/30 dark:text-cerulean-400"
|
|
def type_badge_class(:device_added), do: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400"
|
|
def type_badge_class(_), do: "bg-cool-steel-100 text-cool-steel-600 dark:bg-cool-steel-800 dark:text-cool-steel-400"
|
|
|
|
@doc false
|
|
def type_label(:config_change), do: "Config"
|
|
def type_label(:alert_fired), do: "Alert"
|
|
def type_label(:alert_resolved), do: "Resolved"
|
|
def type_label(:device_event), do: "Event"
|
|
def type_label(:sync), do: "Sync"
|
|
def type_label(:device_added), do: "Device"
|
|
def 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
|