defmodule ToweropsWeb.Admin.MonitoringLive do @moduledoc """ Real-time job monitoring dashboard for Oban workers. Displays: - Active operations (currently executing jobs) - Problems (stuck/failed jobs) - Health metrics (throughput, errors, latency) - Recent activity timeline """ use ToweropsWeb, :live_view alias Phoenix.LiveView.Socket alias Towerops.JobMonitoring alias Towerops.JobMonitoring.Metrics on_mount {ToweropsWeb.UserAuth, :require_superuser} @topic "job:lifecycle" @impl Phoenix.LiveView def mount(_params, _session, socket) do _ = if connected?(socket) do _ = Phoenix.PubSub.subscribe(Towerops.PubSub, @topic) end socket = socket |> assign(:page_title, t("Job Monitoring")) |> assign(:active_jobs, []) |> assign(:executing_jobs, []) |> assign(:stuck_jobs, []) |> assign(:failed_jobs, []) |> assign(:recent_events, []) |> assign(:metrics, %{}) |> assign(:health_metrics, %{}) |> load_monitoring_data() {:ok, socket} end @impl Phoenix.LiveView def handle_info(%{event: :started} = event, socket) do # Job started - add to active jobs list active_jobs = [event | socket.assigns.active_jobs] {:noreply, assign(socket, :active_jobs, active_jobs)} end def handle_info(%{event: event} = job_event, socket) when event in [:completed, :failed] do # Job finished - remove from active jobs, reload data active_jobs = Enum.reject(socket.assigns.active_jobs, fn job -> job.job_id == job_event.job_id end) socket = socket |> assign(:active_jobs, active_jobs) |> load_monitoring_data() {:noreply, socket} end def handle_info(_event, socket) do {:noreply, socket} end @spec load_monitoring_data(Socket.t()) :: Socket.t() defp load_monitoring_data(socket) do executing_jobs = JobMonitoring.list_active_jobs() stuck_jobs = JobMonitoring.list_stuck_jobs() failed_jobs = JobMonitoring.list_failed_jobs() recent_jobs = JobMonitoring.list_recent_completions(20) metrics = Metrics.calculate_all() socket |> assign(:executing_jobs, executing_jobs) |> assign(:stuck_jobs, stuck_jobs) |> assign(:failed_jobs, failed_jobs) |> assign(:recent_events, recent_jobs) |> assign(:metrics, metrics) |> assign(:health_metrics, metrics) end defp worker_name("Towerops.Workers.DevicePollerWorker"), do: "Device Poll" defp worker_name("Towerops.Workers.DiscoveryWorker"), do: "SNMP Discovery" defp worker_name(worker), do: worker defp duration_in_words(started_at) do seconds = DateTime.diff(DateTime.utc_now(), started_at) minutes = div(seconds, 60) cond do minutes < 1 -> "#{seconds}s" minutes < 60 -> "#{minutes}m" true -> "#{div(minutes, 60)}h #{rem(minutes, 60)}m" end end defp event_border_color("completed"), do: "border-green-500" defp event_border_color("cancelled"), do: "border-red-500" defp event_border_color("discarded"), do: "border-gray-400" defp event_border_color(_), do: "border-gray-300 dark:border-gray-600" defp event_outcome(job) do case job.state do "completed" -> "Completed" "cancelled" -> "Failed" "discarded" -> "Discarded" _ -> String.capitalize(job.state) end end end