- M3: Replace blacklist-based valid_return_path? with whitelist of known app route prefixes, plus URI decoding to prevent %2f encoding bypasses - M6: Replace full-org device/link load in get_node_detail with targeted join query filtering by discovered node fields - M7: Add partial unique index on (organization_id, email) for pending invitations (WHERE accepted_at IS NULL) - M12: Add on_mount superuser verification to all 7 admin LiveViews for defense-in-depth
117 lines
3.5 KiB
Elixir
117 lines
3.5 KiB
Elixir
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(:stuck_jobs_count, 0)
|
|
|> assign(:failed_jobs, [])
|
|
|> assign(:failed_jobs_count, 0)
|
|
|> assign(:recent_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(:stuck_jobs_count, length(stuck_jobs))
|
|
|> assign(:failed_jobs, failed_jobs)
|
|
|> assign(:failed_jobs_count, length(failed_jobs))
|
|
|> assign(:recent_jobs, recent_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
|