feat: create MonitoringLive base structure with PubSub subscription

Add LiveView for real-time job monitoring dashboard:
- Subscribe to job:lifecycle PubSub topic for live updates
- Display health metrics (completed, failed, avg duration, active jobs)
- Show active operations with real-time updates
- Display problems section (stuck/failed job counts)
- Add router entry under /admin/monitoring

Tests verify PubSub subscription and basic rendering.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Graham McIntire 2026-02-06 18:27:40 -06:00 committed by Graham McIntire
parent be073fa4b7
commit bb9e6f0b0c
No known key found for this signature in database
4 changed files with 233 additions and 0 deletions

View file

@ -0,0 +1,73 @@
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 Towerops.JobMonitoring
alias Towerops.JobMonitoring.Metrics
@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, "Job Monitoring")
|> assign(:active_jobs, [])
|> assign(:stuck_jobs_count, 0)
|> assign(:failed_jobs_count, 0)
|> assign(:recent_jobs, [])
|> 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(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
defp load_monitoring_data(socket) do
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(:stuck_jobs_count, length(stuck_jobs))
|> assign(:failed_jobs_count, length(failed_jobs))
|> assign(:recent_jobs, recent_jobs)
|> assign(:health_metrics, metrics)
end
end

View file

@ -0,0 +1,121 @@
<Layouts.admin flash={@flash} timezone={@timezone}>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Job Monitoring Dashboard
</h1>
<p class="text-gray-600 dark:text-gray-400">
Real-time monitoring of polling and discovery jobs
</p>
</div>
<!-- Health Metrics Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-6">
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">Completed (1h)</h3>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">
{Map.get(@health_metrics, :completed_last_hour, 0)}
</p>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">jobs completed</p>
</div>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-6">
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">Failed (1h)</h3>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">
{Map.get(@health_metrics, :failed_last_hour, 0)}
</p>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">failures</p>
</div>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-6">
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">Avg Duration</h3>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">
<%= if Map.get(@health_metrics, :avg_execution_time_seconds) do %>
{Map.get(@health_metrics, :avg_execution_time_seconds) |> Float.round(1)}s
<% else %>
N/A
<% end %>
</p>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">last hour</p>
</div>
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10 p-6">
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">Active Jobs</h3>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">
{length(@active_jobs)}
</p>
<p class="text-sm text-gray-500 dark:text-gray-500 mt-1">executing now</p>
</div>
</div>
<!-- Active Operations -->
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Active Operations</h2>
</div>
<div class="p-4">
<%= if @active_jobs == [] do %>
<p class="text-gray-500 dark:text-gray-400 text-center py-8">
No active jobs
</p>
<% else %>
<div class="space-y-2">
<%= for job <- @active_jobs do %>
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-900/50 rounded">
<div>
<span class="font-mono text-sm text-gray-900 dark:text-white">
{job.worker |> String.split(".") |> List.last()}
</span>
<span class="text-sm text-gray-500 dark:text-gray-400 ml-2">
Device ID: {job.device_id}
</span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">
Started: {Calendar.strftime(job.timestamp, "%H:%M:%S")}
</span>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
<!-- Problems -->
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Problems</h2>
<div class="flex gap-4">
<span class="text-sm text-orange-600 dark:text-orange-400">
{if @stuck_jobs_count > 0, do: "#{@stuck_jobs_count} stuck", else: ""}
</span>
<span class="text-sm text-red-600 dark:text-red-400">
{if @failed_jobs_count > 0, do: "#{@failed_jobs_count} failed", else: ""}
</span>
</div>
</div>
<div class="p-4">
<%= if @stuck_jobs_count == 0 and @failed_jobs_count == 0 do %>
<p class="text-gray-500 dark:text-gray-400 text-center py-8">
No problems detected
</p>
<% else %>
<p class="text-sm text-gray-600 dark:text-gray-400">
Problems section coming soon
</p>
<% end %>
</div>
</div>
<!-- Health Metrics -->
<div class="bg-white dark:bg-gray-800/50 rounded-lg border border-gray-200 dark:border-white/10">
<div class="px-4 py-3 border-b border-gray-200 dark:border-white/10">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Health Metrics</h2>
</div>
<div class="p-4">
<p class="text-sm text-gray-600 dark:text-gray-400">
Detailed metrics coming soon
</p>
</div>
</div>
</div>
</Layouts.admin>

View file

@ -231,6 +231,7 @@ defmodule ToweropsWeb.Router do
live "/users", UserLive.Index, :index
live "/organizations", OrgLive.Index, :index
live "/audit", AuditLive.Index, :index
live "/monitoring", MonitoringLive, :index
end
end

View file

@ -0,0 +1,38 @@
defmodule ToweropsWeb.Admin.MonitoringLiveTest do
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
describe "MonitoringLive" do
setup [:register_and_log_in_superuser]
test "renders monitoring dashboard", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/monitoring")
assert html =~ "Job Monitoring Dashboard"
assert html =~ "Active Operations"
assert html =~ "Problems"
assert html =~ "Health Metrics"
end
test "subscribes to job lifecycle events", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/admin/monitoring")
# Verify PubSub subscription by checking it's alive
assert view.pid |> Process.alive?()
end
test "displays active operations section", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/admin/monitoring")
assert html =~ "Active Operations"
assert html =~ "No active jobs"
end
end
defp register_and_log_in_superuser(%{conn: conn}) do
user = Towerops.AccountsFixtures.user_fixture()
user = Towerops.Repo.update!(Ecto.Changeset.change(user, is_superuser: true))
%{conn: log_in_user(conn, user), user: user}
end
end