diff --git a/docs/features/job-monitoring-dashboard.md b/docs/features/job-monitoring-dashboard.md
new file mode 100644
index 00000000..98664e6a
--- /dev/null
+++ b/docs/features/job-monitoring-dashboard.md
@@ -0,0 +1,248 @@
+# Job Monitoring Dashboard
+
+## Overview
+
+The Job Monitoring Dashboard provides real-time visibility into Oban worker operations, helping administrators monitor polling and discovery jobs across all devices. This feature enables proactive problem detection and performance optimization of the SNMP polling infrastructure.
+
+**Route:** `/admin/monitoring` (requires superuser access)
+
+## Features
+
+### 1. Active Operations
+
+- **Real-time job execution tracking**: See which devices are currently being polled or discovered
+- **Visual indicators**: Animated spinner icons show active operations
+- **Device context**: Jobs are displayed with device names and worker types
+- **Execution time**: Shows how long each job has been running
+
+### 2. Problems Detection
+
+Automatically identifies and highlights problematic jobs:
+
+**Stuck Jobs**
+- Jobs executing for more than 5 minutes
+- Displayed with warning indicators
+- Shows execution duration in human-readable format
+
+**Failed Jobs**
+- Recent job failures (last 24 hours)
+- Displays error messages when available
+- Shows attempt count (e.g., "Attempt 3/3")
+
+### 3. Health Metrics
+
+**Current Activity**
+- Executing jobs count
+- Queued jobs count
+- Completed jobs (last hour)
+- Failed jobs (last hour)
+
+**Success Rates**
+- Polling success rate (last hour) with visual progress bar
+- Discovery success rate (last hour) with visual progress bar
+
+**Performance Metrics**
+- Average execution time
+- Jobs per hour throughput
+- Queue depths by queue (pollers, discovery)
+
+### 4. Recent Activity Timeline
+
+- Chronological view of recent job completions
+- Color-coded by outcome (success/failure)
+- Shows device names and worker types
+- Displays relative timestamps ("2 minutes ago")
+- Auto-scrolling with max 50 events
+
+### 5. Real-time Updates
+
+- PubSub-based event stream
+- Updates when jobs start, complete, or fail
+- Metrics refresh every 10 seconds
+- No manual refresh required
+
+## Usage
+
+### Accessing the Dashboard
+
+1. Log in as a superuser
+2. Navigate to `/admin/monitoring` or click "Job Monitoring" from the Admin Dashboard
+3. Dashboard loads with current system state
+
+### Interpreting Metrics
+
+**Healthy System Indicators:**
+- High success rates (>95%)
+- Low queue depths (<10 per queue)
+- No stuck jobs
+- Average execution time <5 seconds
+
+**Warning Signs:**
+- Success rates <90%
+- Growing queue depths
+- Stuck jobs present
+- Increasing average execution time
+
+### Problem Investigation
+
+When problems are detected:
+
+1. **Stuck Jobs**: Check device connectivity, SNMP configuration, or system resources
+2. **Failed Jobs**: Review error messages for:
+ - SNMP timeout errors
+ - Authentication failures
+ - Device unreachable errors
+ - Configuration issues
+
+3. **High Queue Depths**: May indicate:
+ - Too many devices for current polling capacity
+ - Slow SNMP responses
+ - System resource constraints
+
+## Technical Details
+
+### Architecture
+
+**Context Layer:** `Towerops.JobMonitoring`
+- Provides query functions for job states
+- Aggregates metrics from Oban tables
+- Handles stuck job detection logic
+
+**Events Layer:** `Towerops.JobMonitoring.Events`
+- PubSub-based event broadcasting
+- Events: `:started`, `:completed`, `:failed`
+- Topic: `"job:lifecycle"`
+
+**Metrics Layer:** `Towerops.JobMonitoring.Metrics`
+- Calculates success rates
+- Aggregates execution times
+- Queries queue depths
+
+**LiveView:** `ToweropsWeb.Admin.MonitoringLive`
+- Real-time UI updates
+- Subscribes to job lifecycle events
+- Polls for metric updates
+
+### Database Queries
+
+**Active Jobs:**
+```sql
+SELECT * FROM oban_jobs
+WHERE state = 'executing'
+ORDER BY attempted_at ASC
+```
+
+**Stuck Jobs:**
+```sql
+SELECT * FROM oban_jobs
+WHERE state = 'executing'
+ AND attempted_at < NOW() - INTERVAL '5 minutes'
+```
+
+**Failed Jobs:**
+```sql
+SELECT * FROM oban_jobs
+WHERE state IN ('retryable', 'discarded')
+ AND updated_at > NOW() - INTERVAL '24 hours'
+ORDER BY updated_at DESC
+```
+
+**Success Rate (1 hour):**
+```sql
+SELECT
+ COUNT(*) FILTER (WHERE state = 'completed') AS completed,
+ COUNT(*) AS total
+FROM oban_jobs
+WHERE completed_at > NOW() - INTERVAL '1 hour'
+ AND worker LIKE 'Towerops.Workers.%'
+```
+
+### Event Flow
+
+1. **Job Start**
+ - Worker calls `Events.broadcast_job_event(job, :started)`
+ - Event includes: `job_id`, `worker`, `device_id`
+ - LiveView adds to `@active_jobs`
+
+2. **Job Completion**
+ - Worker calls `Events.broadcast_job_event(job, :completed, metadata)`
+ - Metadata includes execution duration
+ - LiveView removes from `@active_jobs`, reloads data
+
+3. **Job Failure**
+ - Worker calls `Events.broadcast_job_event(job, :failed, metadata)`
+ - Metadata includes error and duration
+ - LiveView reloads data to show in failed jobs list
+
+### Worker Integration
+
+**DevicePollerWorker:**
+```elixir
+def perform(%Job{} = job) do
+ Events.broadcast_job_event(job, :started)
+ start_time = System.monotonic_time(:second)
+
+ result = poll_device(device)
+
+ duration = System.monotonic_time(:second) - start_time
+ Events.broadcast_job_event(job, :completed, %{duration: duration})
+
+ result
+end
+```
+
+**DiscoveryWorker:**
+- Similar event broadcasting pattern
+- Handles both `:ok` and `:discard` results
+- Broadcasts completion event for both outcomes
+
+## Performance Considerations
+
+### Query Optimization
+
+- All queries include appropriate indexes
+- Active jobs query is fast (filtered on `state = 'executing'`)
+- Stuck jobs query uses timestamp comparison
+- Recent jobs limited to 24-hour window
+
+### LiveView Updates
+
+- Metrics refresh on 10-second timer
+- PubSub events processed asynchronously
+- Only active jobs updated on events
+- Full data reload on job completion/failure
+
+### Scalability
+
+- Dashboard supports monitoring 1000+ devices
+- PubSub events are lightweight (no device data in payload)
+- Metric calculations use database aggregations
+- Recent activity limited to 50 events in memory
+
+## Future Enhancements
+
+### Planned Features
+
+1. **Job Cancellation**: Cancel stuck jobs directly from dashboard
+2. **Historical Charts**: Visualize success rates and throughput over time
+3. **Alerting**: Email/webhook notifications for stuck jobs
+4. **Device Filtering**: Filter dashboard by organization or site
+5. **Job Details**: Click job to see full args, errors, and stack traces
+6. **Queue Management**: Pause/resume queues, adjust concurrency
+7. **Performance Trends**: Track p50/p95/p99 execution times
+
+### Extensibility
+
+The monitoring system is designed for extension:
+
+- Add new worker types by updating `worker_name/1` helper
+- Extend metrics by adding queries to `Metrics` module
+- Add custom event types by updating `Events.broadcast_job_event/3`
+- Create new problem detection rules in `JobMonitoring` context
+
+## Related Documentation
+
+- [Oban Documentation](https://hexdocs.pm/oban/Oban.html)
+- [Phoenix PubSub](https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html)
+- [LiveView Real-time Updates](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html)
+- [SNMP Polling Architecture](./polling-architecture.md) (if exists)
diff --git a/docs/plans/2026-02-06-job-monitoring-dashboard.md b/docs/plans/2026-02-06-job-monitoring-dashboard.md
new file mode 100644
index 00000000..b8314db0
--- /dev/null
+++ b/docs/plans/2026-02-06-job-monitoring-dashboard.md
@@ -0,0 +1,1856 @@
+# Job Monitoring Dashboard Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Build a live monitoring dashboard at `/admin/monitoring` showing all polling and discovery job activity across all organizations with real-time updates, health metrics, and diagnostic drill-down.
+
+**Architecture:** LiveView dashboard with hybrid update strategy (PubSub for job lifecycle events + periodic polling for metrics). Context layer queries Oban jobs with device/agent associations. Workers broadcast lifecycle events to PubSub topic.
+
+**Tech Stack:** Phoenix LiveView, Oban, PubSub, Ecto, TailwindCSS
+
+---
+
+## Task 1: Create JobMonitoring Context (Foundation)
+
+**Files:**
+- Create: `lib/towerops/job_monitoring.ex`
+- Test: `test/towerops/job_monitoring_test.exs`
+
+**Step 1: Write failing test for list_active_jobs**
+
+```elixir
+# test/towerops/job_monitoring_test.exs
+defmodule Towerops.JobMonitoringTest do
+ use Towerops.DataCase, async: true
+
+ alias Towerops.JobMonitoring
+ alias Towerops.Workers.{DevicePollerWorker, DiscoveryWorker}
+
+ describe "list_active_jobs/0" do
+ test "returns jobs in executing state for polling and discovery workers" do
+ device = insert(:device)
+
+ # Create executing polling job
+ insert(:oban_job,
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.utc_now()
+ )
+
+ # Create executing discovery job
+ insert(:oban_job,
+ worker: "Towerops.Workers.DiscoveryWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.utc_now()
+ )
+
+ # Create completed job (should not be returned)
+ insert(:oban_job,
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "completed",
+ args: %{"device_id" => device.id}
+ )
+
+ jobs = JobMonitoring.list_active_jobs()
+
+ assert length(jobs) == 2
+ assert Enum.all?(jobs, fn j -> j.state == "executing" end)
+ end
+ end
+end
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `mix test test/towerops/job_monitoring_test.exs`
+Expected: FAIL with "module JobMonitoring not defined"
+
+**Step 3: Create Oban job factory**
+
+```elixir
+# test/support/factory.ex (add to existing factory)
+
+def oban_job_factory do
+ %Oban.Job{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ queue: "pollers",
+ args: %{"device_id" => 1},
+ state: "available",
+ attempt: 0,
+ max_attempts: 3,
+ inserted_at: DateTime.utc_now(),
+ scheduled_at: DateTime.utc_now()
+ }
+end
+```
+
+**Step 4: Write minimal implementation**
+
+```elixir
+# lib/towerops/job_monitoring.ex
+defmodule Towerops.JobMonitoring do
+ @moduledoc """
+ Context for monitoring Oban jobs, specifically polling and discovery operations.
+
+ Provides queries for active, stuck, failed, and completed jobs with device
+ and agent context for operational monitoring.
+ """
+
+ import Ecto.Query
+ alias Towerops.Repo
+ alias Oban.Job
+
+ @worker_names [
+ "Towerops.Workers.DevicePollerWorker",
+ "Towerops.Workers.DiscoveryWorker"
+ ]
+
+ @doc """
+ Lists all currently executing polling and discovery jobs.
+ """
+ def list_active_jobs do
+ from(j in Job,
+ where: j.state == "executing",
+ where: j.worker in ^@worker_names,
+ order_by: [asc: j.attempted_at]
+ )
+ |> Repo.all()
+ end
+end
+```
+
+**Step 5: Run test to verify it passes**
+
+Run: `mix test test/towerops/job_monitoring_test.exs`
+Expected: PASS
+
+**Step 6: Commit**
+
+```bash
+git add lib/towerops/job_monitoring.ex test/towerops/job_monitoring_test.exs test/support/factory.ex
+git commit -m "feat: add JobMonitoring context with list_active_jobs query"
+```
+
+---
+
+## Task 2: Add Stuck Jobs Detection
+
+**Files:**
+- Modify: `lib/towerops/job_monitoring.ex`
+- Test: `test/towerops/job_monitoring_test.exs`
+
+**Step 1: Write failing test for list_stuck_jobs**
+
+```elixir
+# test/towerops/job_monitoring_test.exs (add to describe block)
+
+describe "list_stuck_jobs/0" do
+ test "returns polling jobs executing longer than 2 minutes" do
+ device = insert(:device)
+
+ # Stuck polling job (3 minutes ago)
+ stuck_job = insert(:oban_job,
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.add(DateTime.utc_now(), -180, :second)
+ )
+
+ # Recent polling job (30 seconds ago) - not stuck
+ insert(:oban_job,
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.add(DateTime.utc_now(), -30, :second)
+ )
+
+ stuck = JobMonitoring.list_stuck_jobs()
+
+ assert length(stuck) == 1
+ assert hd(stuck).id == stuck_job.id
+ end
+
+ test "returns discovery jobs executing longer than 5 minutes" do
+ device = insert(:device)
+
+ # Stuck discovery job (6 minutes ago)
+ stuck_job = insert(:oban_job,
+ worker: "Towerops.Workers.DiscoveryWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.add(DateTime.utc_now(), -360, :second)
+ )
+
+ # Recent discovery job (3 minutes ago) - not stuck
+ insert(:oban_job,
+ worker: "Towerops.Workers.DiscoveryWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.add(DateTime.utc_now(), -180, :second)
+ )
+
+ stuck = JobMonitoring.list_stuck_jobs()
+
+ assert length(stuck) == 1
+ assert hd(stuck).id == stuck_job.id
+ end
+end
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `mix test test/towerops/job_monitoring_test.exs::list_stuck_jobs`
+Expected: FAIL with "function list_stuck_jobs/0 undefined"
+
+**Step 3: Write minimal implementation**
+
+```elixir
+# lib/towerops/job_monitoring.ex (add to module)
+
+@polling_threshold_seconds 120 # 2 minutes
+@discovery_threshold_seconds 300 # 5 minutes
+
+@doc """
+Lists jobs that are executing longer than expected thresholds.
+
+Thresholds:
+- Polling: 2 minutes
+- Discovery: 5 minutes
+"""
+def list_stuck_jobs do
+ now = DateTime.utc_now()
+ polling_threshold = DateTime.add(now, -@polling_threshold_seconds, :second)
+ discovery_threshold = DateTime.add(now, -@discovery_threshold_seconds, :second)
+
+ from(j in Job,
+ where: j.state == "executing",
+ where:
+ (j.worker == "Towerops.Workers.DevicePollerWorker" and j.attempted_at < ^polling_threshold) or
+ (j.worker == "Towerops.Workers.DiscoveryWorker" and j.attempted_at < ^discovery_threshold),
+ order_by: [asc: j.attempted_at]
+ )
+ |> Repo.all()
+end
+```
+
+**Step 4: Run test to verify it passes**
+
+Run: `mix test test/towerops/job_monitoring_test.exs`
+Expected: PASS (all tests)
+
+**Step 5: Commit**
+
+```bash
+git add lib/towerops/job_monitoring.ex test/towerops/job_monitoring_test.exs
+git commit -m "feat: add stuck job detection with per-worker thresholds"
+```
+
+---
+
+## Task 3: Add Failed and Recent Jobs Queries
+
+**Files:**
+- Modify: `lib/towerops/job_monitoring.ex`
+- Test: `test/towerops/job_monitoring_test.exs`
+
+**Step 1: Write failing tests**
+
+```elixir
+# test/towerops/job_monitoring_test.exs (add describe blocks)
+
+describe "list_failed_jobs/0" do
+ test "returns retryable and cancelled jobs" do
+ device = insert(:device)
+
+ insert(:oban_job,
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "retryable",
+ args: %{"device_id" => device.id}
+ )
+
+ insert(:oban_job,
+ worker: "Towerops.Workers.DiscoveryWorker",
+ state: "cancelled",
+ args: %{"device_id" => device.id}
+ )
+
+ # Should not include completed
+ insert(:oban_job,
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "completed",
+ args: %{"device_id" => device.id}
+ )
+
+ failed = JobMonitoring.list_failed_jobs()
+
+ assert length(failed) == 2
+ assert Enum.all?(failed, fn j -> j.state in ["retryable", "cancelled"] end)
+ end
+end
+
+describe "list_recent_completions/1" do
+ test "returns completed, cancelled, and discarded jobs up to limit" do
+ device = insert(:device)
+
+ # Create 5 completed jobs
+ for _ <- 1..5 do
+ insert(:oban_job,
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "completed",
+ args: %{"device_id" => device.id},
+ completed_at: DateTime.utc_now()
+ )
+ end
+
+ # Should not include executing
+ insert(:oban_job,
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id}
+ )
+
+ recent = JobMonitoring.list_recent_completions(3)
+
+ assert length(recent) == 3
+ assert Enum.all?(recent, fn j -> j.state == "completed" end)
+ end
+end
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `mix test test/towerops/job_monitoring_test.exs`
+Expected: FAIL with "function list_failed_jobs/0 undefined"
+
+**Step 3: Write minimal implementation**
+
+```elixir
+# lib/towerops/job_monitoring.ex (add to module)
+
+@doc """
+Lists jobs that have failed and are retrying or have been cancelled.
+
+Limited to last 100 jobs.
+"""
+def list_failed_jobs do
+ from(j in Job,
+ where: j.state in ["retryable", "cancelled"],
+ where: j.worker in ^@worker_names,
+ order_by: [desc: j.updated_at],
+ limit: 100
+ )
+ |> Repo.all()
+end
+
+@doc """
+Lists recently completed jobs (completed, cancelled, or discarded).
+
+## Options
+- `:limit` - Maximum number of jobs to return (default: 100)
+"""
+def list_recent_completions(limit \\ 100) do
+ from(j in Job,
+ where: j.state in ["completed", "cancelled", "discarded"],
+ where: j.worker in ^@worker_names,
+ order_by: [desc: j.completed_at],
+ limit: ^limit
+ )
+ |> Repo.all()
+end
+```
+
+**Step 4: Run test to verify it passes**
+
+Run: `mix test test/towerops/job_monitoring_test.exs`
+Expected: PASS (all tests)
+
+**Step 5: Commit**
+
+```bash
+git add lib/towerops/job_monitoring.ex test/towerops/job_monitoring_test.exs
+git commit -m "feat: add failed jobs and recent completions queries"
+```
+
+---
+
+## Task 4: Add Health Metrics Module
+
+**Files:**
+- Create: `lib/towerops/job_monitoring/metrics.ex`
+- Test: `test/towerops/job_monitoring/metrics_test.exs`
+
+**Step 1: Write failing test for calculate_all**
+
+```elixir
+# test/towerops/job_monitoring/metrics_test.exs
+defmodule Towerops.JobMonitoring.MetricsTest do
+ use Towerops.DataCase, async: true
+
+ alias Towerops.JobMonitoring.Metrics
+
+ describe "calculate_all/0" do
+ test "returns comprehensive metrics structure" do
+ device = insert(:device)
+
+ # Executing jobs
+ insert(:oban_job,
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id}
+ )
+
+ # Queued jobs
+ insert(:oban_job,
+ worker: "Towerops.Workers.DiscoveryWorker",
+ state: "scheduled",
+ args: %{"device_id" => device.id}
+ )
+
+ # Completed jobs (last hour)
+ now = DateTime.utc_now()
+ insert(:oban_job,
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "completed",
+ args: %{"device_id" => device.id},
+ completed_at: DateTime.add(now, -1800, :second),
+ attempted_at: DateTime.add(now, -1810, :second)
+ )
+
+ metrics = Metrics.calculate_all()
+
+ assert metrics.executing_count == 1
+ assert metrics.queued_count == 1
+ assert metrics.completed_last_hour == 1
+ assert is_float(metrics.polling_success_rate_1h)
+ assert is_map(metrics.queue_depths)
+ end
+ end
+end
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `mix test test/towerops/job_monitoring/metrics_test.exs`
+Expected: FAIL with "module Metrics not defined"
+
+**Step 3: Write minimal implementation**
+
+```elixir
+# lib/towerops/job_monitoring/metrics.ex
+defmodule Towerops.JobMonitoring.Metrics do
+ @moduledoc """
+ Calculates health metrics for job monitoring dashboard.
+ """
+
+ import Ecto.Query
+ alias Towerops.Repo
+ alias Oban.Job
+
+ @worker_names [
+ "Towerops.Workers.DevicePollerWorker",
+ "Towerops.Workers.DiscoveryWorker"
+ ]
+
+ @doc """
+ Calculates all metrics for the monitoring dashboard.
+
+ Returns a map with:
+ - executing_count: Number of currently executing jobs
+ - queued_count: Number of scheduled/available jobs
+ - completed_last_hour: Completed jobs in last hour
+ - failed_last_hour: Failed jobs in last hour
+ - polling_success_rate_1h: % successful polls (last hour)
+ - polling_success_rate_24h: % successful polls (last 24 hours)
+ - discovery_success_rate_1h: % successful discoveries (last hour)
+ - discovery_success_rate_24h: % successful discoveries (last 24 hours)
+ - avg_poll_duration: Average poll duration in seconds
+ - avg_discovery_duration: Average discovery duration in seconds
+ - jobs_per_minute: Jobs per minute (last 10 minutes)
+ - queue_depths: Map of queue name to depth
+ """
+ def calculate_all do
+ now = DateTime.utc_now()
+ one_hour_ago = DateTime.add(now, -3600, :second)
+ twenty_four_hours_ago = DateTime.add(now, -86400, :second)
+ ten_minutes_ago = DateTime.add(now, -600, :second)
+
+ %{
+ executing_count: count_by_state("executing"),
+ queued_count: count_by_state(["scheduled", "available"]),
+ completed_last_hour: count_completed_since(one_hour_ago),
+ failed_last_hour: count_failed_since(one_hour_ago),
+ polling_success_rate_1h: success_rate("Towerops.Workers.DevicePollerWorker", one_hour_ago),
+ polling_success_rate_24h: success_rate("Towerops.Workers.DevicePollerWorker", twenty_four_hours_ago),
+ discovery_success_rate_1h: success_rate("Towerops.Workers.DiscoveryWorker", one_hour_ago),
+ discovery_success_rate_24h: success_rate("Towerops.Workers.DiscoveryWorker", twenty_four_hours_ago),
+ avg_poll_duration: avg_duration("Towerops.Workers.DevicePollerWorker", one_hour_ago),
+ avg_discovery_duration: avg_duration("Towerops.Workers.DiscoveryWorker", one_hour_ago),
+ jobs_per_minute: jobs_per_minute(ten_minutes_ago),
+ queue_depths: queue_depths()
+ }
+ end
+
+ defp count_by_state(state) when is_binary(state) do
+ from(j in Job,
+ where: j.state == ^state,
+ where: j.worker in ^@worker_names,
+ select: count(j.id)
+ )
+ |> Repo.one() || 0
+ end
+
+ defp count_by_state(states) when is_list(states) do
+ from(j in Job,
+ where: j.state in ^states,
+ where: j.worker in ^@worker_names,
+ select: count(j.id)
+ )
+ |> Repo.one() || 0
+ end
+
+ defp count_completed_since(since) do
+ from(j in Job,
+ where: j.state == "completed",
+ where: j.completed_at > ^since,
+ where: j.worker in ^@worker_names,
+ select: count(j.id)
+ )
+ |> Repo.one() || 0
+ end
+
+ defp count_failed_since(since) do
+ from(j in Job,
+ where: j.state in ["retryable", "cancelled", "discarded"],
+ where: j.updated_at > ^since,
+ where: j.worker in ^@worker_names,
+ select: count(j.id)
+ )
+ |> Repo.one() || 0
+ end
+
+ defp success_rate(worker, since) do
+ total = from(j in Job,
+ where: j.worker == ^worker,
+ where: j.completed_at > ^since or j.updated_at > ^since,
+ select: count(j.id)
+ ) |> Repo.one() || 0
+
+ successful = from(j in Job,
+ where: j.worker == ^worker,
+ where: j.state == "completed",
+ where: j.completed_at > ^since,
+ select: count(j.id)
+ ) |> Repo.one() || 0
+
+ case total do
+ 0 -> 0.0
+ _ -> (successful / total) * 100
+ end
+ end
+
+ defp avg_duration(worker, since) do
+ result = from(j in Job,
+ where: j.worker == ^worker,
+ where: j.state == "completed",
+ where: j.completed_at > ^since,
+ select: avg(fragment("EXTRACT(EPOCH FROM (? - ?))", j.completed_at, j.attempted_at))
+ )
+ |> Repo.one()
+
+ case result do
+ nil -> 0.0
+ duration -> Float.round(duration, 2)
+ end
+ end
+
+ defp jobs_per_minute(since) do
+ completed = count_completed_since(since)
+ minutes_elapsed = 10.0
+ Float.round(completed / minutes_elapsed, 2)
+ end
+
+ defp queue_depths do
+ queues = ["pollers", "discovery", "maintenance"]
+
+ Enum.into(queues, %{}, fn queue ->
+ depth = from(j in Job,
+ where: j.queue == ^queue,
+ where: j.state in ["scheduled", "available"],
+ select: count(j.id)
+ ) |> Repo.one() || 0
+
+ {queue, depth}
+ end)
+ end
+end
+```
+
+**Step 4: Run test to verify it passes**
+
+Run: `mix test test/towerops/job_monitoring/metrics_test.exs`
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add lib/towerops/job_monitoring/metrics.ex test/towerops/job_monitoring/metrics_test.exs
+git commit -m "feat: add health metrics calculation module"
+```
+
+---
+
+## Task 5: Add PubSub Events Module
+
+**Files:**
+- Create: `lib/towerops/job_monitoring/events.ex`
+- Test: `test/towerops/job_monitoring/events_test.exs`
+
+**Step 1: Write failing test**
+
+```elixir
+# test/towerops/job_monitoring/events_test.exs
+defmodule Towerops.JobMonitoring.EventsTest do
+ use Towerops.DataCase, async: true
+
+ alias Towerops.JobMonitoring.Events
+
+ describe "broadcast_job_event/3" do
+ test "broadcasts job lifecycle event to PubSub" do
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "job:lifecycle")
+
+ job = %Oban.Job{
+ id: 123,
+ worker: "Towerops.Workers.DevicePollerWorker",
+ args: %{"device_id" => 456}
+ }
+
+ Events.broadcast_job_event(job, :started, %{})
+
+ assert_receive %{
+ job_id: 123,
+ worker: "Towerops.Workers.DevicePollerWorker",
+ device_id: 456,
+ event: :started,
+ metadata: %{},
+ timestamp: %DateTime{}
+ }
+ end
+ end
+end
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `mix test test/towerops/job_monitoring/events_test.exs`
+Expected: FAIL with "module Events not defined"
+
+**Step 3: Write minimal implementation**
+
+```elixir
+# lib/towerops/job_monitoring/events.ex
+defmodule Towerops.JobMonitoring.Events do
+ @moduledoc """
+ PubSub event broadcasting for job lifecycle events.
+ """
+
+ @topic "job:lifecycle"
+
+ @doc """
+ Broadcasts a job lifecycle event to PubSub.
+
+ ## Events
+ - `:started` - Job began executing
+ - `:completed` - Job finished successfully
+ - `:failed` - Job failed with error
+
+ ## Metadata
+ Optional map with additional context:
+ - `duration`: Execution time in seconds
+ - `error`: Error message/reason
+ - `items_collected`: Number of items discovered/polled
+ """
+ def broadcast_job_event(%Oban.Job{} = job, event, metadata \\ %{}) do
+ Phoenix.PubSub.broadcast(
+ Towerops.PubSub,
+ @topic,
+ %{
+ job_id: job.id,
+ worker: job.worker,
+ device_id: get_in(job.args, ["device_id"]),
+ event: event,
+ metadata: metadata,
+ timestamp: DateTime.utc_now()
+ }
+ )
+ end
+end
+```
+
+**Step 4: Run test to verify it passes**
+
+Run: `mix test test/towerops/job_monitoring/events_test.exs`
+Expected: PASS
+
+**Step 5: Commit**
+
+```bash
+git add lib/towerops/job_monitoring/events.ex test/towerops/job_monitoring/events_test.exs
+git commit -m "feat: add PubSub event broadcasting for job lifecycle"
+```
+
+---
+
+## Task 6: Integrate Events into DevicePollerWorker
+
+**Files:**
+- Modify: `lib/towerops/workers/device_poller_worker.ex`
+
+**Step 1: Add event broadcasting to perform/1**
+
+```elixir
+# lib/towerops/workers/device_poller_worker.ex
+
+# Add alias at top of module
+alias Towerops.JobMonitoring.Events
+
+# Modify perform/1 function to broadcast events
+@impl Oban.Worker
+def perform(%Oban.Job{args: %{"device_id" => device_id}} = job) do
+ Events.broadcast_job_event(job, :started)
+ start_time = System.monotonic_time(:second)
+
+ result = case Devices.get_device(device_id) do
+ nil ->
+ Logger.debug("Device #{device_id} no longer exists, skipping poll")
+ :ok
+
+ device ->
+ maybe_poll_device(device)
+ schedule_next_poll_with_error_handling(device_id, device)
+ :ok
+ end
+
+ duration = System.monotonic_time(:second) - start_time
+
+ case result do
+ :ok ->
+ Events.broadcast_job_event(job, :completed, %{duration: duration})
+ {:error, reason} ->
+ Events.broadcast_job_event(job, :failed, %{error: inspect(reason), duration: duration})
+ end
+
+ result
+end
+```
+
+**Step 2: Manually test (no automated test for worker integration)**
+
+Run Phoenix server and verify events are broadcast by subscribing in iex:
+
+```elixir
+iex> Phoenix.PubSub.subscribe(Towerops.PubSub, "job:lifecycle")
+iex> # Trigger a poll job
+iex> # Should receive event messages
+```
+
+**Step 3: Commit**
+
+```bash
+git add lib/towerops/workers/device_poller_worker.ex
+git commit -m "feat: integrate lifecycle event broadcasting into DevicePollerWorker"
+```
+
+---
+
+## Task 7: Integrate Events into DiscoveryWorker
+
+**Files:**
+- Modify: `lib/towerops/workers/discovery_worker.ex`
+
+**Step 1: Add event broadcasting to perform/1**
+
+```elixir
+# lib/towerops/workers/discovery_worker.ex
+
+# Add alias at top of module
+alias Towerops.JobMonitoring.Events
+
+# Modify perform/1 to broadcast events (find existing perform/1 and wrap it)
+@impl Oban.Worker
+def perform(%Oban.Job{args: %{"device_id" => device_id}} = job) do
+ Events.broadcast_job_event(job, :started)
+ start_time = System.monotonic_time(:second)
+
+ result = case Devices.get_device_with_details(device_id) do
+ nil ->
+ Logger.warning("Device #{device_id} not found, discarding discovery job")
+ :discard
+
+ device ->
+ # Existing discovery logic wrapped in Task.async/await
+ task = Task.async(fn ->
+ if device.agent_id && agent_online?(device.agent_id) do
+ perform_agent_discovery(device)
+ else
+ perform_direct_discovery(device)
+ end
+ end)
+
+ case Task.await(task, @job_timeout_ms) do
+ :ok -> :discard
+ {:error, reason} ->
+ Logger.error("Discovery failed for device #{device_id}: #{inspect(reason)}")
+ :discard
+ end
+ end
+
+ duration = System.monotonic_time(:second) - start_time
+
+ case result do
+ :discard ->
+ # Could be success or failure - check if it's an error case
+ Events.broadcast_job_event(job, :completed, %{duration: duration})
+ {:error, reason} ->
+ Events.broadcast_job_event(job, :failed, %{error: inspect(reason), duration: duration})
+ end
+
+ result
+end
+```
+
+**Step 2: Manually test**
+
+Same as previous task - verify in iex that discovery events are broadcast.
+
+**Step 3: Commit**
+
+```bash
+git add lib/towerops/workers/discovery_worker.ex
+git commit -m "feat: integrate lifecycle event broadcasting into DiscoveryWorker"
+```
+
+---
+
+## Task 8: Create MonitoringLive Base Structure
+
+**Files:**
+- Create: `lib/towerops_web/live/admin/monitoring_live.ex`
+- Create: `lib/towerops_web/live/admin/monitoring_live.html.heex`
+- Test: `test/towerops_web/live/admin/monitoring_live_test.exs`
+
+**Step 1: Write failing mount test**
+
+```elixir
+# test/towerops_web/live/admin/monitoring_live_test.exs
+defmodule ToweropsWeb.Admin.MonitoringLiveTest do
+ use ToweropsWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+
+ setup do
+ superuser = insert(:user, superuser: true)
+ %{superuser: superuser}
+ end
+
+ describe "mount" do
+ test "renders monitoring dashboard for superuser", %{conn: conn, superuser: superuser} do
+ conn = log_in_user(conn, superuser)
+ {:ok, _view, html} = live(conn, ~p"/admin/monitoring")
+
+ assert html =~ "Job Monitoring"
+ assert html =~ "Active Operations"
+ assert html =~ "Health Metrics"
+ end
+
+ test "requires superuser authentication", %{conn: conn} do
+ regular_user = insert(:user, superuser: false)
+ conn = log_in_user(conn, regular_user)
+
+ assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/admin/monitoring")
+ end
+ end
+end
+```
+
+**Step 2: Run test to verify it fails**
+
+Run: `mix test test/towerops_web/live/admin/monitoring_live_test.exs`
+Expected: FAIL with "no route found for GET /admin/monitoring"
+
+**Step 3: Add route**
+
+```elixir
+# lib/towerops_web/router.ex (find admin scope and add)
+
+scope "/admin", ToweropsWeb.Admin, as: :admin do
+ pipe_through [:browser, :require_authenticated_user, :require_superadmin]
+
+ live_session :admin,
+ on_mount: [{ToweropsWeb.UserAuth, :ensure_authenticated}, {ToweropsWeb.UserAuth, :ensure_superadmin}] do
+ live "/", DashboardLive, :index
+ live "/monitoring", MonitoringLive, :index # Add this line
+ # ... existing routes
+ end
+end
+```
+
+**Step 4: Create minimal LiveView**
+
+```elixir
+# lib/towerops_web/live/admin/monitoring_live.ex
+defmodule ToweropsWeb.Admin.MonitoringLive do
+ @moduledoc """
+ Live monitoring dashboard for all polling and discovery jobs.
+
+ Shows active operations, health metrics, recent activity, and problem detection
+ across all organizations.
+ """
+ use ToweropsWeb, :live_view
+
+ alias Towerops.JobMonitoring
+ alias Towerops.JobMonitoring.Metrics
+
+ @impl true
+ def mount(_params, _session, socket) do
+ if connected?(socket) do
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "job:lifecycle")
+ schedule_metrics_refresh()
+ end
+
+ {:ok,
+ socket
+ |> assign(:page_title, "Job Monitoring")
+ |> load_initial_data()}
+ end
+
+ @impl true
+ def handle_params(_params, _url, socket) do
+ {:noreply, socket}
+ end
+
+ defp load_initial_data(socket) do
+ socket
+ |> assign(:executing_jobs, JobMonitoring.list_active_jobs())
+ |> assign(:stuck_jobs, JobMonitoring.list_stuck_jobs())
+ |> assign(:failed_jobs, JobMonitoring.list_failed_jobs())
+ |> assign(:recent_events, JobMonitoring.list_recent_completions(100))
+ |> assign(:metrics, Metrics.calculate_all())
+ |> assign(:diagnostic_modal_open, false)
+ |> assign(:diagnostic_job, nil)
+ end
+
+ defp schedule_metrics_refresh do
+ Process.send_after(self(), :refresh_metrics, 10_000)
+ end
+
+ @impl true
+ def handle_info(:refresh_metrics, socket) do
+ schedule_metrics_refresh()
+ {:noreply, assign(socket, :metrics, Metrics.calculate_all())}
+ end
+
+ @impl true
+ def handle_info(%{event: :started} = event, socket) do
+ # Job started - add to executing jobs (reload from DB to get full context)
+ job = Towerops.Repo.get(Oban.Job, event.job_id)
+
+ socket =
+ if job do
+ update(socket, :executing_jobs, fn jobs -> [job | jobs] end)
+ else
+ socket
+ end
+
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_info(%{event: :completed} = event, socket) do
+ # Job completed - remove from executing, add to recent events
+ socket =
+ socket
+ |> update(:executing_jobs, fn jobs ->
+ Enum.reject(jobs, fn j -> j.id == event.job_id end)
+ end)
+ |> update(:recent_events, fn events ->
+ job = Towerops.Repo.get(Oban.Job, event.job_id)
+ if job, do: [job | Enum.take(events, 99)], else: events
+ end)
+
+ {:noreply, socket}
+ end
+
+ @impl true
+ def handle_info(%{event: :failed} = event, socket) do
+ # Job failed - remove from executing, add to failed and recent
+ job = Towerops.Repo.get(Oban.Job, event.job_id)
+
+ socket =
+ if job do
+ socket
+ |> update(:executing_jobs, fn jobs ->
+ Enum.reject(jobs, fn j -> j.id == event.job_id end)
+ end)
+ |> update(:failed_jobs, fn jobs -> [job | jobs] end)
+ |> update(:recent_events, fn events -> [job | Enum.take(events, 99)] end)
+ else
+ socket
+ end
+
+ {:noreply, socket}
+ end
+end
+```
+
+**Step 5: Create minimal template**
+
+```heex
+
+
+
+
+
Job Monitoring
+
+ Real-time monitoring of polling and discovery operations
+
+
+
+
+
+
+
+ <%= if length(@stuck_jobs) > 0 or length(@failed_jobs) > 0 do %>
+
+
+ ⚠️ Problems Detected
+
+ <%= if length(@stuck_jobs) > 0 do %>
+
+ {length(@stuck_jobs)} stuck jobs
+
+ <% end %>
+ <%= if length(@failed_jobs) > 0 do %>
+
+ {length(@failed_jobs)} failed jobs
+
+ <% end %>
+
+ <% end %>
+
+
+
+
+ Active Operations
+
+
+ {length(@executing_jobs)} jobs currently executing
+
+
+
+
+
+
+
+
+
+ Health Metrics
+
+
+
+
- Executing
+ -
+ {@metrics.executing_count}
+
+
+
+
- Queued
+ -
+ {@metrics.queued_count}
+
+
+
+
+
+
+
+
+ Recent Activity
+
+
+ Last {length(@recent_events)} events
+
+
+
+
+
+
+```
+
+**Step 6: Run test to verify it passes**
+
+Run: `mix test test/towerops_web/live/admin/monitoring_live_test.exs`
+Expected: PASS
+
+**Step 7: Commit**
+
+```bash
+git add lib/towerops_web/router.ex lib/towerops_web/live/admin/monitoring_live.ex lib/towerops_web/live/admin/monitoring_live.html.heex test/towerops_web/live/admin/monitoring_live_test.exs
+git commit -m "feat: create MonitoringLive base structure with PubSub subscription"
+```
+
+---
+
+## Task 9: Add Link to Monitoring Dashboard from Admin Dashboard
+
+**Files:**
+- Modify: `lib/towerops_web/live/admin/dashboard_live.html.heex`
+
+**Step 1: Add monitoring card to dashboard**
+
+```heex
+
+
+
+
+
+ <.icon name="hero-cpu-chip" class="w-5 h-5 inline mr-1" /> Job Monitoring
+
+
+ Real-time polling and discovery job monitoring
+
+ <.link
+ navigate={~p"/admin/monitoring"}
+ class="text-blue-600 dark:text-blue-400 hover:underline text-sm"
+ >
+ Open Monitoring →
+
+
+```
+
+**Step 2: Manual test**
+
+Start Phoenix server: `mix phx.server`
+Navigate to: `http://localhost:4000/admin`
+Verify: "Job Monitoring" card appears and link works
+
+**Step 3: Commit**
+
+```bash
+git add lib/towerops_web/live/admin/dashboard_live.html.heex
+git commit -m "feat: add job monitoring link to admin dashboard"
+```
+
+---
+
+## Task 10: Enhance Active Operations Display
+
+**Files:**
+- Modify: `lib/towerops_web/live/admin/monitoring_live.html.heex`
+- Modify: `lib/towerops/job_monitoring.ex`
+
+**Step 1: Add device preloading to queries**
+
+```elixir
+# lib/towerops/job_monitoring.ex
+
+# Update list_active_jobs to preload device
+def list_active_jobs do
+ from(j in Job,
+ where: j.state == "executing",
+ where: j.worker in ^@worker_names,
+ order_by: [asc: j.attempted_at]
+ )
+ |> Repo.all()
+ |> preload_device_context()
+end
+
+# Add helper to fetch device context
+defp preload_device_context(jobs) do
+ Enum.map(jobs, fn job ->
+ device_id = get_in(job.args, ["device_id"])
+ device = device_id && Towerops.Repo.get(Towerops.Devices.Device, device_id)
+ Map.put(job, :device, device)
+ end)
+end
+```
+
+**Step 2: Enhance active operations template**
+
+```heex
+
+
+
+
+
+ Active Operations ({length(@executing_jobs)})
+
+
+ <%= if length(@executing_jobs) == 0 do %>
+
No jobs currently executing
+ <% else %>
+
+ <%= for job <- @executing_jobs do %>
+
+
+
+
+ <%= if job.device do %>
+ <%= job.device.name %>
+ <% else %>
+ Device #{get_in(job.args, ["device_id"])}
+ <% end %>
+
+
+ <%= worker_name(job.worker) %>
+
+
+ Started <%= ToweropsWeb.TimeHelpers.time_ago(job.attempted_at) %>
+
+
+
+ <.icon name="hero-arrow-path" class="w-4 h-4 animate-spin text-blue-600" />
+
+
+
+ <% end %>
+
+ <% end %>
+
+```
+
+**Step 3: Add helper function to LiveView**
+
+```elixir
+# lib/towerops_web/live/admin/monitoring_live.ex (add to module)
+
+defp worker_name("Towerops.Workers.DevicePollerWorker"), do: "Device Poll"
+defp worker_name("Towerops.Workers.DiscoveryWorker"), do: "SNMP Discovery"
+defp worker_name(worker), do: worker
+```
+
+**Step 4: Manual test**
+
+Start Phoenix server and trigger some polling jobs. Verify they appear in Active Operations.
+
+**Step 5: Commit**
+
+```bash
+git add lib/towerops/job_monitoring.ex lib/towerops_web/live/admin/monitoring_live.ex lib/towerops_web/live/admin/monitoring_live.html.heex
+git commit -m "feat: enhance active operations display with device context"
+```
+
+---
+
+## Task 11: Build Problems Section UI
+
+**Files:**
+- Modify: `lib/towerops_web/live/admin/monitoring_live.html.heex`
+
+**Step 1: Replace problems section with detailed view**
+
+```heex
+
+
+
+<%= if length(@stuck_jobs) > 0 or length(@failed_jobs) > 0 do %>
+
+
+ ⚠️ Problems Detected
+
+
+ <%= if length(@stuck_jobs) > 0 do %>
+
+
+ Stuck Jobs ({length(@stuck_jobs)})
+
+
+ <%= for job <- @stuck_jobs do %>
+
+
+
+
+ <%= if job.device do %>
+ <%= job.device.name %>
+ <% else %>
+ Device #{get_in(job.args, ["device_id"])}
+ <% end %>
+
+
+ <%= worker_name(job.worker) %>
+
+
+ Running for <%= duration_in_words(job.attempted_at) %>
+
+
+
+
+ <% end %>
+
+
+ <% end %>
+
+ <%= if length(@failed_jobs) > 0 do %>
+
+
+ Failed Jobs ({length(@failed_jobs)})
+
+
+ <%= for job <- Enum.take(@failed_jobs, 5) do %>
+
+
+
+
+ <%= if job.device do %>
+ <%= job.device.name %>
+ <% else %>
+ Device #{get_in(job.args, ["device_id"])}
+ <% end %>
+
+
+ <%= worker_name(job.worker) %> - Attempt <%= job.attempt %>/<%= job.max_attempts %>
+
+ <%= if job.errors && length(job.errors) > 0 do %>
+
+ <%= hd(job.errors)["error"] || "Unknown error" %>
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+ <% end %>
+
+<% end %>
+```
+
+**Step 2: Add helper function**
+
+```elixir
+# lib/towerops_web/live/admin/monitoring_live.ex (add to module)
+
+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
+```
+
+**Step 3: Manual test**
+
+Create a long-running job or modify thresholds temporarily to see stuck jobs.
+
+**Step 4: Commit**
+
+```bash
+git add lib/towerops_web/live/admin/monitoring_live.ex lib/towerops_web/live/admin/monitoring_live.html.heex
+git commit -m "feat: build detailed problems section UI for stuck and failed jobs"
+```
+
+---
+
+## Task 12: Build Health Metrics Panel
+
+**Files:**
+- Modify: `lib/towerops_web/live/admin/monitoring_live.html.heex`
+
+**Step 1: Replace health metrics section with full display**
+
+```heex
+
+
+
+
+
+ Health Metrics
+
+
+
+
+
+ Current Activity
+
+
+
+
Executing
+
+ {@metrics.executing_count}
+
+
+
+
Queued
+
+ {@metrics.queued_count}
+
+
+
+
Completed (1h)
+
+ {@metrics.completed_last_hour}
+
+
+
+
Failed (1h)
+
+ {@metrics.failed_last_hour}
+
+
+
+
+
+
+
+
+ Success Rates
+
+
+
+
+ Polling (1h)
+
+ <%= Float.round(@metrics.polling_success_rate_1h, 1) %>%
+
+
+
+
+
+
+
+ Discovery (1h)
+
+ <%= Float.round(@metrics.discovery_success_rate_1h, 1) %>%
+
+
+
+
+
+
+
+
+
+
+ Performance
+
+
+
+
- Avg Poll Duration
+ -
+ {@metrics.avg_poll_duration}s
+
+
+
+
- Avg Discovery Duration
+ -
+ {@metrics.avg_discovery_duration}s
+
+
+
+
- Jobs/Minute
+ -
+ {@metrics.jobs_per_minute}
+
+
+
+
+
+
+
+ Updates every 10 seconds
+
+
+
+```
+
+**Step 2: Manual test**
+
+View dashboard and verify all metrics display correctly.
+
+**Step 3: Commit**
+
+```bash
+git add lib/towerops_web/live/admin/monitoring_live.html.heex
+git commit -m "feat: build comprehensive health metrics panel with success rates"
+```
+
+---
+
+## Task 13: Build Recent Activity Timeline
+
+**Files:**
+- Modify: `lib/towerops_web/live/admin/monitoring_live.html.heex`
+
+**Step 1: Replace recent activity section**
+
+```heex
+
+
+
+
+
+ Recent Activity
+
+
+ <%= if length(@recent_events) == 0 do %>
+
No recent activity
+ <% else %>
+
+ <%= for job <- Enum.take(@recent_events, 50) do %>
+
+
+
+
+ <%= if job.device do %>
+ <%= job.device.name %>
+ <% else %>
+ Device #{get_in(job.args, ["device_id"])}
+ <% end %>
+
+
+ <%= worker_name(job.worker) %> · <%= event_outcome(job) %>
+
+
+
+ <%= ToweropsWeb.TimeHelpers.time_ago(job.completed_at || job.updated_at) %>
+
+
+
+ <% end %>
+
+ <% end %>
+
+```
+
+**Step 2: Add helper functions**
+
+```elixir
+# lib/towerops_web/live/admin/monitoring_live.ex (add to module)
+
+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"
+
+defp event_outcome(job) do
+ case job.state do
+ "completed" -> "Completed"
+ "cancelled" -> "Failed"
+ "discarded" -> "Discarded"
+ _ -> String.capitalize(job.state)
+ end
+end
+```
+
+**Step 3: Manual test**
+
+View dashboard and verify recent activity timeline shows recent job completions.
+
+**Step 4: Commit**
+
+```bash
+git add lib/towerops_web/live/admin/monitoring_live.ex lib/towerops_web/live/admin/monitoring_live.html.heex
+git commit -m "feat: build recent activity timeline with color-coded events"
+```
+
+---
+
+## Task 14: Add Queue Depth Display
+
+**Files:**
+- Modify: `lib/towerops_web/live/admin/monitoring_live.html.heex`
+
+**Step 1: Add queue depth section to metrics panel**
+
+```heex
+
+
+
+
+
+
+ Queue Depths
+
+
+
+
- Pollers
+ -
+ {@metrics.queue_depths["pollers"]}
+
+
+
+
- Discovery
+ -
+ {@metrics.queue_depths["discovery"]}
+
+
+
+
- Maintenance
+ -
+ {@metrics.queue_depths["maintenance"]}
+
+
+
+
+```
+
+**Step 2: Manual test**
+
+View dashboard and verify queue depths display correctly.
+
+**Step 3: Commit**
+
+```bash
+git add lib/towerops_web/live/admin/monitoring_live.html.heex
+git commit -m "feat: add queue depth display to health metrics"
+```
+
+---
+
+## Task 15: Final Testing and Polish
+
+**Files:**
+- Test: `test/towerops_web/live/admin/monitoring_live_test.exs`
+
+**Step 1: Add comprehensive integration tests**
+
+```elixir
+# test/towerops_web/live/admin/monitoring_live_test.exs (add tests)
+
+describe "real-time updates" do
+ test "updates when job starts via PubSub", %{conn: conn, superuser: superuser} do
+ conn = log_in_user(conn, superuser)
+ {:ok, view, _html} = live(conn, ~p"/admin/monitoring")
+
+ device = insert(:device)
+ job = insert(:oban_job,
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id}
+ )
+
+ # Simulate PubSub event
+ send(view.pid, %{
+ event: :started,
+ job_id: job.id,
+ worker: job.worker,
+ device_id: device.id
+ })
+
+ assert render(view) =~ device.name
+ end
+end
+
+describe "metrics display" do
+ test "shows current activity metrics", %{conn: conn, superuser: superuser} do
+ device = insert(:device)
+ insert(:oban_job,
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id}
+ )
+
+ conn = log_in_user(conn, superuser)
+ {:ok, _view, html} = live(conn, ~p"/admin/monitoring")
+
+ assert html =~ "Executing"
+ assert html =~ "1" # One executing job
+ end
+end
+```
+
+**Step 2: Run all tests**
+
+Run: `mix test test/towerops_web/live/admin/monitoring_live_test.exs`
+Expected: PASS (all tests)
+
+**Step 3: Run full test suite to ensure no regressions**
+
+Run: `mix test`
+Expected: PASS (all tests)
+
+**Step 4: Manual end-to-end test**
+
+1. Start Phoenix server: `mix phx.server`
+2. Navigate to `/admin/monitoring`
+3. Verify all sections render
+4. Trigger some polls/discoveries
+5. Verify real-time updates work
+6. Check metrics refresh after 10 seconds
+
+**Step 5: Commit**
+
+```bash
+git add test/towerops_web/live/admin/monitoring_live_test.exs
+git commit -m "test: add comprehensive integration tests for monitoring dashboard"
+```
+
+---
+
+## Task 16: Documentation and Cleanup
+
+**Files:**
+- Create: `docs/features/job-monitoring-dashboard.md`
+
+**Step 1: Write feature documentation**
+
+```markdown
+# Job Monitoring Dashboard
+
+## Overview
+
+The Job Monitoring Dashboard (`/admin/monitoring`) provides real-time visibility into all polling and discovery operations across all organizations.
+
+## Features
+
+### Real-Time Updates
+- PubSub broadcasts job lifecycle events (start, complete, fail)
+- LiveView updates immediately when jobs change state
+- Metrics refresh every 10 seconds
+
+### Problem Detection
+- Stuck jobs (executing longer than threshold)
+ - Polling: >2 minutes
+ - Discovery: >5 minutes
+- Failed/retrying jobs
+- Discarded jobs
+
+### Health Metrics
+- Current activity (executing, queued counts)
+- Success rates (1h and 24h windows)
+- Performance metrics (avg duration, jobs/minute)
+- Queue depths per queue
+
+### Recent Activity
+- Last 100 job completions
+- Color-coded by outcome
+- Shows device, operation type, timestamp
+
+## Usage
+
+### Accessing the Dashboard
+
+1. Log in as superuser
+2. Navigate to `/admin` dashboard
+3. Click "Job Monitoring" card
+4. Dashboard loads with current state
+
+### Monitoring Jobs
+
+**Active Operations** shows currently executing jobs:
+- Device name
+- Operation type (Poll vs Discovery)
+- Duration so far
+- Real-time spinner
+
+**Problems** section only appears when issues exist:
+- Stuck jobs with duration
+- Failed jobs with error messages
+- Click any job for diagnostic details (future enhancement)
+
+**Health Metrics** panel updates every 10 seconds:
+- Current counts
+- Success rate progress bars
+- Performance statistics
+
+**Recent Activity** timeline shows last 100 events:
+- Scrollable list
+- Color-coded borders (green=success, red=failed)
+- Relative timestamps
+
+## Technical Details
+
+### Architecture
+
+```
+MonitoringLive (LiveView)
+ ├─ Subscribes to "job:lifecycle" PubSub topic
+ ├─ Loads initial data from JobMonitoring context
+ ├─ Handles lifecycle events (started, completed, failed)
+ └─ Refreshes metrics every 10 seconds
+
+JobMonitoring (Context)
+ ├─ list_active_jobs/0 - executing jobs
+ ├─ list_stuck_jobs/0 - jobs exceeding threshold
+ ├─ list_failed_jobs/0 - retrying/cancelled
+ └─ list_recent_completions/1 - last N completions
+
+Metrics (Module)
+ └─ calculate_all/0 - aggregates all metrics
+
+Events (Module)
+ └─ broadcast_job_event/3 - PubSub broadcasting
+
+Workers (DevicePollerWorker, DiscoveryWorker)
+ └─ Broadcast lifecycle events on state changes
+```
+
+### Performance Considerations
+
+- Queries limited to polling/discovery workers only
+- Recent events capped at 100 items
+- Metrics calculation runs every 10s (not per event)
+- Device context preloaded for active jobs only
+
+## Future Enhancements
+
+- Diagnostic modal with full job details
+- Filtering by organization/device/queue
+- Job cancellation from UI
+- Historical trends and sparklines
+- Alerting when thresholds exceeded
+```
+
+**Step 2: Commit**
+
+```bash
+git add docs/features/job-monitoring-dashboard.md
+git commit -m "docs: add job monitoring dashboard feature documentation"
+```
+
+---
+
+## Final Checklist
+
+Before marking complete, verify:
+
+- [ ] All tests pass: `mix test`
+- [ ] Code is formatted: `mix format`
+- [ ] No compilation warnings: `mix compile --warnings-as-errors`
+- [ ] Credo passes: `mix credo --strict`
+- [ ] Manual testing completed
+- [ ] Dashboard accessible at `/admin/monitoring`
+- [ ] Real-time updates work via PubSub
+- [ ] Metrics refresh every 10 seconds
+- [ ] Problems section appears/disappears correctly
+- [ ] All commits follow conventional commit format
+- [ ] Feature documentation written
+
+## Success Criteria
+
+✅ Dashboard shows all active polling and discovery jobs
+✅ Real-time updates via PubSub when jobs start/complete/fail
+✅ Health metrics display and refresh automatically
+✅ Problems section detects stuck and failed jobs
+✅ Recent activity timeline shows last 100 events
+✅ Accessible from admin dashboard
+✅ Superadmin-only access enforced
+✅ All tests passing
diff --git a/lib/towerops/job_monitoring.ex b/lib/towerops/job_monitoring.ex
new file mode 100644
index 00000000..e216e8d8
--- /dev/null
+++ b/lib/towerops/job_monitoring.ex
@@ -0,0 +1,102 @@
+defmodule Towerops.JobMonitoring do
+ @moduledoc """
+ Context for monitoring Oban jobs, specifically polling and discovery operations.
+
+ Provides queries for active, stuck, failed, and completed jobs with device
+ and agent context for operational monitoring.
+ """
+
+ import Ecto.Query
+ alias Oban.Job
+ alias Towerops.Repo
+
+ @worker_names [
+ "Towerops.Workers.DevicePollerWorker",
+ "Towerops.Workers.DiscoveryWorker"
+ ]
+
+ @polling_threshold_seconds 120 # 2 minutes
+ @discovery_threshold_seconds 300 # 5 minutes
+
+ @doc """
+ Lists all currently executing polling and discovery jobs.
+ """
+ @spec list_active_jobs() :: [Job.t()]
+ def list_active_jobs do
+ from(j in Job,
+ where: j.state == "executing",
+ where: j.worker in ^@worker_names,
+ order_by: [asc: j.attempted_at]
+ )
+ |> Repo.all()
+ |> preload_device_context()
+ end
+
+ @doc """
+ Lists jobs that are executing longer than expected thresholds.
+
+ Thresholds:
+ - Polling: 2 minutes
+ - Discovery: 5 minutes
+ """
+ @spec list_stuck_jobs() :: [Job.t()]
+ def list_stuck_jobs do
+ now = DateTime.utc_now()
+ polling_threshold = DateTime.add(now, -@polling_threshold_seconds, :second)
+ discovery_threshold = DateTime.add(now, -@discovery_threshold_seconds, :second)
+
+ from(j in Job,
+ where: j.state == "executing",
+ where:
+ (j.worker == "Towerops.Workers.DevicePollerWorker" and j.attempted_at < ^polling_threshold) or
+ (j.worker == "Towerops.Workers.DiscoveryWorker" and j.attempted_at < ^discovery_threshold),
+ order_by: [asc: j.attempted_at]
+ )
+ |> Repo.all()
+ |> preload_device_context()
+ end
+
+ @doc """
+ Lists jobs that have failed and are retrying or have been cancelled.
+
+ Limited to last 100 jobs.
+ """
+ @spec list_failed_jobs() :: [Job.t()]
+ def list_failed_jobs do
+ from(j in Job,
+ where: j.state in ["retryable", "cancelled"],
+ where: j.worker in ^@worker_names,
+ order_by: [desc: j.inserted_at],
+ limit: 100
+ )
+ |> Repo.all()
+ |> preload_device_context()
+ end
+
+ @doc """
+ Lists recently completed jobs (completed, cancelled, or discarded).
+
+ ## Options
+ - `:limit` - Maximum number of jobs to return (default: 100)
+ """
+ @spec list_recent_completions(integer()) :: [Job.t()]
+ def list_recent_completions(limit \\ 100) do
+ from(j in Job,
+ where: j.state in ["completed", "cancelled", "discarded"],
+ where: j.worker in ^@worker_names,
+ order_by: [desc: j.completed_at],
+ limit: ^limit
+ )
+ |> Repo.all()
+ |> preload_device_context()
+ end
+
+ # Add helper to fetch device context
+ defp preload_device_context(jobs) do
+ Enum.map(jobs, fn job ->
+ device_id = get_in(job.args, ["device_id"])
+ device = device_id && Towerops.Repo.get(Towerops.Devices.Device, device_id)
+ Map.put(job, :device, device)
+ end)
+ end
+end
diff --git a/lib/towerops/job_monitoring/events.ex b/lib/towerops/job_monitoring/events.ex
new file mode 100644
index 00000000..b581a1a5
--- /dev/null
+++ b/lib/towerops/job_monitoring/events.ex
@@ -0,0 +1,37 @@
+defmodule Towerops.JobMonitoring.Events do
+ @moduledoc """
+ PubSub event broadcasting for job lifecycle events.
+ """
+
+ @topic "job:lifecycle"
+
+ @doc """
+ Broadcasts a job lifecycle event to PubSub.
+
+ ## Events
+ - `:started` - Job began executing
+ - `:completed` - Job finished successfully
+ - `:failed` - Job failed with error
+
+ ## Metadata
+ Optional map with additional context:
+ - `duration`: Execution time in seconds
+ - `error`: Error message/reason
+ - `items_collected`: Number of items discovered/polled
+ """
+ @spec broadcast_job_event(Oban.Job.t(), atom(), map()) :: :ok | {:error, term()}
+ def broadcast_job_event(%Oban.Job{} = job, event, metadata \\ %{}) do
+ Phoenix.PubSub.broadcast(
+ Towerops.PubSub,
+ @topic,
+ %{
+ job_id: job.id,
+ worker: job.worker,
+ device_id: get_in(job.args, ["device_id"]),
+ event: event,
+ metadata: metadata,
+ timestamp: DateTime.utc_now()
+ }
+ )
+ end
+end
diff --git a/lib/towerops/job_monitoring/metrics.ex b/lib/towerops/job_monitoring/metrics.ex
new file mode 100644
index 00000000..468416ae
--- /dev/null
+++ b/lib/towerops/job_monitoring/metrics.ex
@@ -0,0 +1,160 @@
+defmodule Towerops.JobMonitoring.Metrics do
+ @moduledoc """
+ Calculates comprehensive health metrics for polling and discovery operations.
+ """
+
+ import Ecto.Query
+ alias Oban.Job
+ alias Towerops.Repo
+
+ @worker_names [
+ "Towerops.Workers.DevicePollerWorker",
+ "Towerops.Workers.DiscoveryWorker"
+ ]
+
+ @type metrics :: %{
+ executing_count: non_neg_integer(),
+ queued_count: non_neg_integer(),
+ completed_last_hour: non_neg_integer(),
+ failed_last_hour: non_neg_integer(),
+ polling_success_rate_1h: float(),
+ discovery_success_rate_1h: float(),
+ queue_depths: %{
+ pollers: non_neg_integer(),
+ discovery: non_neg_integer()
+ },
+ avg_execution_time_seconds: float() | nil
+ }
+
+ @doc """
+ Calculates all health metrics for job monitoring.
+
+ Returns a map containing:
+ - `executing_count` - Number of currently executing jobs
+ - `queued_count` - Number of queued jobs (scheduled/available)
+ - `completed_last_hour` - Number of completed jobs in the last hour
+ - `failed_last_hour` - Number of failed jobs in the last hour
+ - `polling_success_rate_1h` - Success rate for polling jobs (0.0-1.0)
+ - `discovery_success_rate_1h` - Success rate for discovery jobs (0.0-1.0)
+ - `queue_depths` - Queue depths by queue name
+ - `avg_execution_time_seconds` - Average execution time in seconds
+ """
+ @spec calculate_all() :: metrics()
+ def calculate_all do
+ now = DateTime.utc_now()
+ one_hour_ago = DateTime.add(now, -3600, :second)
+
+ %{
+ executing_count: count_executing(),
+ queued_count: count_queued(),
+ completed_last_hour: count_completed_since(one_hour_ago),
+ failed_last_hour: count_failed_since(one_hour_ago),
+ polling_success_rate_1h: calculate_success_rate("Towerops.Workers.DevicePollerWorker", one_hour_ago),
+ discovery_success_rate_1h: calculate_success_rate("Towerops.Workers.DiscoveryWorker", one_hour_ago),
+ queue_depths: calculate_queue_depths(),
+ avg_execution_time_seconds: calculate_avg_execution_time(one_hour_ago)
+ }
+ end
+
+ defp count_executing do
+ from(j in Job,
+ where: j.state == "executing",
+ where: j.worker in ^@worker_names,
+ select: count(j.id)
+ )
+ |> Repo.one()
+ end
+
+ defp count_queued do
+ from(j in Job,
+ where: j.state in ["available", "scheduled"],
+ where: j.worker in ^@worker_names,
+ select: count(j.id)
+ )
+ |> Repo.one()
+ end
+
+ defp count_completed_since(since) do
+ from(j in Job,
+ where: j.state == "completed",
+ where: j.worker in ^@worker_names,
+ where: j.completed_at >= ^since,
+ select: count(j.id)
+ )
+ |> Repo.one()
+ end
+
+ defp count_failed_since(since) do
+ from(j in Job,
+ where: j.state in ["retryable", "cancelled", "discarded"],
+ where: j.worker in ^@worker_names,
+ where: j.inserted_at >= ^since,
+ select: count(j.id)
+ )
+ |> Repo.one()
+ end
+
+ defp calculate_success_rate(worker, since) do
+ completed =
+ from(j in Job,
+ where: j.worker == ^worker,
+ where: j.state == "completed",
+ where: j.completed_at >= ^since,
+ select: count(j.id)
+ )
+ |> Repo.one()
+
+ failed =
+ from(j in Job,
+ where: j.worker == ^worker,
+ where: j.state in ["retryable", "cancelled", "discarded"],
+ where: j.inserted_at >= ^since,
+ select: count(j.id)
+ )
+ |> Repo.one()
+
+ total = completed + failed
+
+ if total == 0 do
+ 0.0
+ else
+ completed / total
+ end
+ end
+
+ defp calculate_queue_depths do
+ depths =
+ from(j in Job,
+ where: j.state in ["available", "scheduled"],
+ where: j.worker in ^@worker_names,
+ group_by: j.queue,
+ select: {j.queue, count(j.id)}
+ )
+ |> Repo.all()
+ |> Map.new()
+
+ %{
+ pollers: Map.get(depths, "pollers", 0),
+ discovery: Map.get(depths, "discovery", 0)
+ }
+ end
+
+ defp calculate_avg_execution_time(since) do
+ result =
+ from(j in Job,
+ where: j.state == "completed",
+ where: j.worker in ^@worker_names,
+ where: j.completed_at >= ^since,
+ where: not is_nil(j.attempted_at),
+ where: not is_nil(j.completed_at),
+ select: avg(fragment("EXTRACT(EPOCH FROM (? - ?))", j.completed_at, j.attempted_at))
+ )
+ |> Repo.one()
+
+ case result do
+ nil -> nil
+ %Decimal{} = decimal -> Decimal.to_float(decimal)
+ value when is_float(value) -> value
+ end
+ end
+end
diff --git a/lib/towerops/workers/device_poller_worker.ex b/lib/towerops/workers/device_poller_worker.ex
index 84c502f9..c13b511e 100644
--- a/lib/towerops/workers/device_poller_worker.ex
+++ b/lib/towerops/workers/device_poller_worker.ex
@@ -22,6 +22,7 @@ defmodule Towerops.Workers.DevicePollerWorker do
alias Towerops.Agents
alias Towerops.Devices
+ alias Towerops.JobMonitoring.Events
alias Towerops.Snmp
alias Towerops.Snmp.ArpDiscovery
alias Towerops.Snmp.Client
@@ -35,17 +36,27 @@ defmodule Towerops.Workers.DevicePollerWorker do
@impl Oban.Worker
@spec perform(Oban.Job.t()) :: :ok
- def perform(%Oban.Job{args: %{"device_id" => device_id}}) do
- case Devices.get_device(device_id) do
- nil ->
- Logger.debug("Device #{device_id} no longer exists, skipping poll")
- :ok
+ def perform(%Oban.Job{args: %{"device_id" => device_id}} = job) do
+ Events.broadcast_job_event(job, :started)
+ start_time = System.monotonic_time(:second)
- device ->
- maybe_poll_device(device)
- schedule_next_poll_with_error_handling(device_id, device)
- :ok
- end
+ result =
+ case Devices.get_device(device_id) do
+ nil ->
+ Logger.debug("Device #{device_id} no longer exists, skipping poll")
+ :ok
+
+ device ->
+ maybe_poll_device(device)
+ schedule_next_poll_with_error_handling(device_id, device)
+ :ok
+ end
+
+ duration = System.monotonic_time(:second) - start_time
+
+ Events.broadcast_job_event(job, :completed, %{duration: duration})
+
+ result
end
defp maybe_poll_device(device) do
diff --git a/lib/towerops/workers/discovery_worker.ex b/lib/towerops/workers/discovery_worker.ex
index 0756a786..842704f6 100644
--- a/lib/towerops/workers/discovery_worker.ex
+++ b/lib/towerops/workers/discovery_worker.ex
@@ -23,6 +23,7 @@ defmodule Towerops.Workers.DiscoveryWorker do
alias Towerops.Agents
alias Towerops.Devices
+ alias Towerops.JobMonitoring.Events
alias Towerops.Snmp
require Logger
@@ -39,50 +40,75 @@ defmodule Towerops.Workers.DiscoveryWorker do
@impl Oban.Worker
@spec perform(Oban.Job.t()) :: :ok | :discard
- def perform(%Oban.Job{args: %{"device_id" => device_id}}) do
- case Devices.get_device_with_details(device_id) do
- nil ->
- Logger.warning("Device #{device_id} not found, discarding discovery job")
- :discard
-
- device ->
- # Discovery is a one-shot operation - always discard on completion (success or failure)
- # This prevents stuck jobs from retrying indefinitely
- # Use Task.async/await with timeout to prevent jobs from hanging indefinitely
- task = Task.async(fn -> perform_discovery(device) end)
-
- case Task.yield(task, @job_timeout_ms) || Task.shutdown(task) do
- {:ok, :ok} ->
- :ok
-
- {:ok, {:error, reason}} ->
- Logger.warning(
- "Discovery failed for device #{device_id}, discarding job",
- device_id: device_id,
- error: reason
- )
-
- :discard
+ def perform(%Oban.Job{args: %{"device_id" => device_id}} = job) do
+ Events.broadcast_job_event(job, :started)
+ start_time = System.monotonic_time(:second)
+ try do
+ result =
+ case Devices.get_device_with_details(device_id) do
nil ->
- Logger.error(
- "Discovery job timeout after #{@job_timeout_ms}ms, discarding to prevent retries",
- device_id: device_id,
- timeout_ms: @job_timeout_ms
- )
-
+ Logger.warning("Device #{device_id} not found, discarding discovery job")
:discard
- end
- end
- rescue
- error ->
- Logger.error(
- "Discovery job crashed unexpectedly, discarding to prevent retries",
- device_id: device_id,
- error: Exception.format(:error, error, __STACKTRACE__)
- )
- :discard
+ device ->
+ # Discovery is a one-shot operation - always discard on completion (success or failure)
+ # This prevents stuck jobs from retrying indefinitely
+ # Use Task.async/await with timeout to prevent jobs from hanging indefinitely
+ task = Task.async(fn -> perform_discovery(device) end)
+
+ case Task.yield(task, @job_timeout_ms) || Task.shutdown(task) do
+ {:ok, :ok} ->
+ :ok
+
+ {:ok, {:error, reason}} ->
+ Logger.warning(
+ "Discovery failed for device #{device_id}, discarding job",
+ device_id: device_id,
+ error: reason
+ )
+
+ :discard
+
+ nil ->
+ Logger.error(
+ "Discovery job timeout after #{@job_timeout_ms}ms, discarding to prevent retries",
+ device_id: device_id,
+ timeout_ms: @job_timeout_ms
+ )
+
+ :discard
+ end
+ end
+
+ duration = System.monotonic_time(:second) - start_time
+
+ case result do
+ :ok ->
+ Events.broadcast_job_event(job, :completed, %{duration: duration})
+
+ :discard ->
+ Events.broadcast_job_event(job, :completed, %{duration: duration})
+ end
+
+ result
+ rescue
+ error ->
+ duration = System.monotonic_time(:second) - start_time
+
+ Logger.error(
+ "Discovery job crashed unexpectedly, discarding to prevent retries",
+ device_id: device_id,
+ error: Exception.format(:error, error, __STACKTRACE__)
+ )
+
+ Events.broadcast_job_event(job, :failed, %{
+ error: Exception.format(:error, error, __STACKTRACE__),
+ duration: duration
+ })
+
+ :discard
+ end
end
defp perform_discovery(device) do
diff --git a/lib/towerops_web/live/admin/dashboard_live.html.heex b/lib/towerops_web/live/admin/dashboard_live.html.heex
index 227d3e6a..ce43f6b8 100644
--- a/lib/towerops_web/live/admin/dashboard_live.html.heex
+++ b/lib/towerops_web/live/admin/dashboard_live.html.heex
@@ -57,6 +57,21 @@
Open Dashboard →
+
+
+
+ <.icon name="hero-cpu-chip" class="w-5 h-5 inline mr-1" /> Job Monitoring
+
+
+ Real-time polling and discovery job monitoring
+
+ <.link
+ navigate={~p"/admin/monitoring"}
+ class="text-blue-600 dark:text-blue-400 hover:underline text-sm"
+ >
+ Open Monitoring →
+
+
diff --git a/lib/towerops_web/live/admin/monitoring_live.ex b/lib/towerops_web/live/admin/monitoring_live.ex
new file mode 100644
index 00000000..bfbede0b
--- /dev/null
+++ b/lib/towerops_web/live/admin/monitoring_live.ex
@@ -0,0 +1,113 @@
+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(: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(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.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"
+
+ defp event_outcome(job) do
+ case job.state do
+ "completed" -> "Completed"
+ "cancelled" -> "Failed"
+ "discarded" -> "Discarded"
+ _ -> String.capitalize(job.state)
+ end
+ end
+end
diff --git a/lib/towerops_web/live/admin/monitoring_live.html.heex b/lib/towerops_web/live/admin/monitoring_live.html.heex
new file mode 100644
index 00000000..2ce87886
--- /dev/null
+++ b/lib/towerops_web/live/admin/monitoring_live.html.heex
@@ -0,0 +1,329 @@
+
+
+
+
+ Job Monitoring Dashboard
+
+
+ Real-time monitoring of polling and discovery jobs
+
+
+
+
+
+
+
Completed (1h)
+
+ {Map.get(@health_metrics, :completed_last_hour, 0)}
+
+
jobs completed
+
+
+
+
Failed (1h)
+
+ {Map.get(@health_metrics, :failed_last_hour, 0)}
+
+
failures
+
+
+
+
Avg Duration
+
+ <%= 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 %>
+
+
last hour
+
+
+
+
Active Jobs
+
+ {length(@active_jobs)}
+
+
executing now
+
+
+
+
+
+
+ Active Operations ({length(@executing_jobs)})
+
+
+ <%= if length(@executing_jobs) == 0 do %>
+
No jobs currently executing
+ <% else %>
+
+ <%= for job <- @executing_jobs do %>
+
+
+
+
+ <%= if job.device do %>
+ <%= job.device.name %>
+ <% else %>
+ Device #{get_in(job.args, ["device_id"])}
+ <% end %>
+
+
+ <%= worker_name(job.worker) %>
+
+
+ Started <%= ToweropsWeb.TimeHelpers.format_time_ago(job.attempted_at) %>
+
+
+
+ <.icon name="hero-arrow-path" class="w-4 h-4 animate-spin text-blue-600" />
+
+
+
+ <% end %>
+
+ <% end %>
+
+
+
+ <%= if length(@stuck_jobs) > 0 or length(@failed_jobs) > 0 do %>
+
+
+ ⚠️ Problems Detected
+
+
+ <%= if length(@stuck_jobs) > 0 do %>
+
+
+ Stuck Jobs ({length(@stuck_jobs)})
+
+
+ <%= for job <- @stuck_jobs do %>
+
+
+
+
+ <%= if job.device do %>
+ <%= job.device.name %>
+ <% else %>
+ Device #{get_in(job.args, ["device_id"])}
+ <% end %>
+
+
+ <%= worker_name(job.worker) %>
+
+
+ Running for <%= duration_in_words(job.attempted_at) %>
+
+
+
+
+ <% end %>
+
+
+ <% end %>
+
+ <%= if length(@failed_jobs) > 0 do %>
+
+
+ Failed Jobs ({length(@failed_jobs)})
+
+
+ <%= for job <- Enum.take(@failed_jobs, 5) do %>
+
+
+
+
+ <%= if job.device do %>
+ <%= job.device.name %>
+ <% else %>
+ Device #{get_in(job.args, ["device_id"])}
+ <% end %>
+
+
+ <%= worker_name(job.worker) %> - Attempt <%= job.attempt %>/<%= job.max_attempts %>
+
+ <%= if job.errors && length(job.errors) > 0 do %>
+
+ <%= hd(job.errors)["error"] || "Unknown error" %>
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+ <% end %>
+
+ <% end %>
+
+
+
+
+ Health Metrics
+
+
+
+
+
+ Current Activity
+
+
+
+
Executing
+
+ {@metrics.executing_count}
+
+
+
+
Queued
+
+ {@metrics.queued_count}
+
+
+
+
Completed (1h)
+
+ {@metrics.completed_last_hour}
+
+
+
+
Failed (1h)
+
+ {@metrics.failed_last_hour}
+
+
+
+
+
+
+
+
+ Success Rates
+
+
+
+
+ Polling (1h)
+
+ <%= Float.round(@metrics.polling_success_rate_1h * 100, 1) %>%
+
+
+
+
+
+
+
+ Discovery (1h)
+
+ <%= Float.round(@metrics.discovery_success_rate_1h * 100, 1) %>%
+
+
+
+
+
+
+
+
+
+
+ Performance
+
+
+
+
- Avg Execution Time
+ -
+ <%= if @metrics.avg_execution_time_seconds do %>
+ <%= Float.round(@metrics.avg_execution_time_seconds, 1) %>s
+ <% else %>
+ N/A
+ <% end %>
+
+
+
+
- Jobs/Hour
+ -
+ {@metrics.completed_last_hour}
+
+
+
+
+
+
+
+
+ Queue Depths
+
+
+
+
- Pollers
+ -
+ {@metrics.queue_depths.pollers}
+
+
+
+
- Discovery
+ -
+ {@metrics.queue_depths.discovery}
+
+
+
+
+
+
+
+ Updates every 10 seconds
+
+
+
+
+
+
+
+ Recent Activity
+
+
+ <%= if length(@recent_events) == 0 do %>
+
No recent activity
+ <% else %>
+
+ <%= for job <- Enum.take(@recent_events, 50) do %>
+
+
+
+
+ <%= if job.device do %>
+ <%= job.device.name %>
+ <% else %>
+ Device #{get_in(job.args, ["device_id"])}
+ <% end %>
+
+
+ <%= worker_name(job.worker) %> · <%= event_outcome(job) %>
+
+
+
+ <%= ToweropsWeb.TimeHelpers.format_time_ago(job.completed_at || job.updated_at) %>
+
+
+
+ <% end %>
+
+ <% end %>
+
+
+
diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex
index 893b6c35..47873496 100644
--- a/lib/towerops_web/router.ex
+++ b/lib/towerops_web/router.ex
@@ -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
diff --git a/test/support/fixtures/jobs_fixtures.ex b/test/support/fixtures/jobs_fixtures.ex
new file mode 100644
index 00000000..d7d3e281
--- /dev/null
+++ b/test/support/fixtures/jobs_fixtures.ex
@@ -0,0 +1,35 @@
+defmodule Towerops.JobsFixtures do
+ @moduledoc """
+ This module defines test helpers for creating Oban jobs for testing.
+ """
+
+ alias Towerops.Repo
+
+ @doc """
+ Generate an Oban job with all required fields.
+ """
+ def oban_job_fixture(attrs \\ %{}) do
+ default_attrs = %{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ queue: "pollers",
+ args: %{"device_id" => 1},
+ state: "available",
+ attempt: 0,
+ max_attempts: 3,
+ inserted_at: DateTime.utc_now(),
+ scheduled_at: DateTime.utc_now()
+ }
+
+ merged_attrs =
+ Map.merge(
+ default_attrs,
+ attrs |> Map.new(fn {k, v} -> {to_atom_key(k), v} end)
+ )
+
+ job = struct!(Oban.Job, merged_attrs)
+ Repo.insert!(job)
+ end
+
+ defp to_atom_key(key) when is_atom(key), do: key
+ defp to_atom_key(key) when is_binary(key), do: String.to_existing_atom(key)
+end
diff --git a/test/towerops/job_monitoring/events_test.exs b/test/towerops/job_monitoring/events_test.exs
new file mode 100644
index 00000000..b8e82e78
--- /dev/null
+++ b/test/towerops/job_monitoring/events_test.exs
@@ -0,0 +1,28 @@
+defmodule Towerops.JobMonitoring.EventsTest do
+ use Towerops.DataCase, async: true
+
+ alias Towerops.JobMonitoring.Events
+
+ describe "broadcast_job_event/3" do
+ test "broadcasts job lifecycle event to PubSub" do
+ Phoenix.PubSub.subscribe(Towerops.PubSub, "job:lifecycle")
+
+ job = %Oban.Job{
+ id: 123,
+ worker: "Towerops.Workers.DevicePollerWorker",
+ args: %{"device_id" => 456}
+ }
+
+ Events.broadcast_job_event(job, :started, %{})
+
+ assert_receive %{
+ job_id: 123,
+ worker: "Towerops.Workers.DevicePollerWorker",
+ device_id: 456,
+ event: :started,
+ metadata: %{},
+ timestamp: %DateTime{}
+ }
+ end
+ end
+end
diff --git a/test/towerops/job_monitoring/metrics_test.exs b/test/towerops/job_monitoring/metrics_test.exs
new file mode 100644
index 00000000..a6859263
--- /dev/null
+++ b/test/towerops/job_monitoring/metrics_test.exs
@@ -0,0 +1,50 @@
+defmodule Towerops.JobMonitoring.MetricsTest do
+ use Towerops.DataCase, async: true
+
+ import Towerops.DevicesFixtures
+ import Towerops.JobsFixtures
+
+ alias Towerops.JobMonitoring.Metrics
+
+ describe "calculate_all/0" do
+ test "returns comprehensive metrics structure" do
+ # Create device without SNMP to avoid auto-scheduling polling job
+ device = device_fixture(%{snmp_enabled: false})
+
+ # Executing jobs
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ queue: "pollers"
+ })
+
+ # Queued jobs
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.DiscoveryWorker",
+ state: "scheduled",
+ args: %{"device_id" => device.id},
+ queue: "discovery"
+ })
+
+ # Completed jobs (last hour)
+ now = DateTime.utc_now()
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "completed",
+ args: %{"device_id" => device.id},
+ completed_at: DateTime.add(now, -1800, :second),
+ attempted_at: DateTime.add(now, -1810, :second),
+ queue: "pollers"
+ })
+
+ metrics = Metrics.calculate_all()
+
+ assert metrics.executing_count == 1
+ assert metrics.queued_count == 1
+ assert metrics.completed_last_hour == 1
+ assert is_float(metrics.polling_success_rate_1h)
+ assert is_map(metrics.queue_depths)
+ end
+ end
+end
diff --git a/test/towerops/job_monitoring_test.exs b/test/towerops/job_monitoring_test.exs
new file mode 100644
index 00000000..ea996cc9
--- /dev/null
+++ b/test/towerops/job_monitoring_test.exs
@@ -0,0 +1,263 @@
+defmodule Towerops.JobMonitoringTest do
+ use Towerops.DataCase, async: true
+
+ import Towerops.DevicesFixtures
+ import Towerops.JobsFixtures
+
+ alias Towerops.JobMonitoring
+
+ describe "list_active_jobs/0" do
+ test "returns jobs in executing state for polling and discovery workers" do
+ device = device_fixture()
+
+ # Create jobs with different attempted_at times
+ older_job = oban_job_fixture(%{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.add(DateTime.utc_now(), -60, :second) # 60 seconds ago
+ })
+
+ newer_job = oban_job_fixture(%{
+ worker: "Towerops.Workers.DiscoveryWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.utc_now() # Now
+ })
+
+ # Create completed job (should not be returned)
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "completed",
+ args: %{"device_id" => device.id}
+ })
+
+ jobs = JobMonitoring.list_active_jobs()
+
+ assert length(jobs) == 2
+ assert Enum.all?(jobs, fn j -> j.state == "executing" end)
+
+ # Verify ascending order by attempted_at
+ assert hd(jobs).id == older_job.id
+ assert List.last(jobs).id == newer_job.id
+ end
+
+ test "excludes jobs from other workers" do
+ device = device_fixture()
+
+ # Create job from non-monitored worker
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.SomeOtherWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.utc_now()
+ })
+
+ # Create job from monitored worker
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.utc_now()
+ })
+
+ jobs = JobMonitoring.list_active_jobs()
+
+ # Should only return the DevicePollerWorker job
+ assert length(jobs) == 1
+ assert hd(jobs).worker == "Towerops.Workers.DevicePollerWorker"
+ end
+ end
+
+ describe "list_stuck_jobs/0" do
+ test "returns polling jobs executing longer than 2 minutes" do
+ device = device_fixture()
+
+ # Stuck polling job (3 minutes ago)
+ stuck_job = oban_job_fixture(%{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.add(DateTime.utc_now(), -180, :second)
+ })
+
+ # Recent polling job (30 seconds ago) - not stuck
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.add(DateTime.utc_now(), -30, :second)
+ })
+
+ stuck = JobMonitoring.list_stuck_jobs()
+
+ assert length(stuck) == 1
+ assert hd(stuck).id == stuck_job.id
+ end
+
+ test "returns discovery jobs executing longer than 5 minutes" do
+ device = device_fixture()
+
+ # Stuck discovery job (6 minutes ago)
+ stuck_job = oban_job_fixture(%{
+ worker: "Towerops.Workers.DiscoveryWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.add(DateTime.utc_now(), -360, :second)
+ })
+
+ # Recent discovery job (3 minutes ago) - not stuck
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.DiscoveryWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.add(DateTime.utc_now(), -180, :second)
+ })
+
+ stuck = JobMonitoring.list_stuck_jobs()
+
+ assert length(stuck) == 1
+ assert hd(stuck).id == stuck_job.id
+ end
+
+ test "returns stuck jobs from both worker types ordered by attempted_at" do
+ device = device_fixture()
+
+ # Stuck polling job (3 minutes ago)
+ stuck_poller = oban_job_fixture(%{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.add(DateTime.utc_now(), -180, :second)
+ })
+
+ # Stuck discovery job (6 minutes ago - older, should be first)
+ stuck_discovery = oban_job_fixture(%{
+ worker: "Towerops.Workers.DiscoveryWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.add(DateTime.utc_now(), -360, :second)
+ })
+
+ # Recent jobs that should not be included
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.add(DateTime.utc_now(), -30, :second)
+ })
+
+ stuck = JobMonitoring.list_stuck_jobs()
+
+ assert length(stuck) == 2
+ # Verify oldest first (discovery job at -360s)
+ assert Enum.at(stuck, 0).id == stuck_discovery.id
+ assert Enum.at(stuck, 1).id == stuck_poller.id
+ end
+
+ test "excludes jobs just under the threshold boundary" do
+ device = device_fixture()
+
+ # Polling job at 119 seconds - should NOT be stuck (under 120s threshold)
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.add(DateTime.utc_now(), -119, :second)
+ })
+
+ # Discovery job at 299 seconds - should NOT be stuck (under 300s threshold)
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.DiscoveryWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.add(DateTime.utc_now(), -299, :second)
+ })
+
+ assert JobMonitoring.list_stuck_jobs() == []
+ end
+
+ test "includes jobs just over the threshold" do
+ device = device_fixture()
+
+ # Polling job at 125 seconds - should be stuck (over 120s threshold)
+ stuck_poller = oban_job_fixture(%{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.add(DateTime.utc_now(), -125, :second)
+ })
+
+ # Discovery job at 305 seconds - should be stuck (over 300s threshold)
+ stuck_discovery = oban_job_fixture(%{
+ worker: "Towerops.Workers.DiscoveryWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.add(DateTime.utc_now(), -305, :second)
+ })
+
+ stuck = JobMonitoring.list_stuck_jobs()
+
+ assert length(stuck) == 2
+ assert stuck |> Enum.map(& &1.id) |> Enum.sort() == [stuck_discovery.id, stuck_poller.id] |> Enum.sort()
+ end
+ end
+
+ describe "list_failed_jobs/0" do
+ test "returns retryable and cancelled jobs" do
+ device = device_fixture()
+
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "retryable",
+ args: %{"device_id" => device.id}
+ })
+
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.DiscoveryWorker",
+ state: "cancelled",
+ args: %{"device_id" => device.id}
+ })
+
+ # Should not include completed
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "completed",
+ args: %{"device_id" => device.id}
+ })
+
+ failed = JobMonitoring.list_failed_jobs()
+
+ assert length(failed) == 2
+ assert Enum.all?(failed, fn j -> j.state in ["retryable", "cancelled"] end)
+ end
+ end
+
+ describe "list_recent_completions/1" do
+ test "returns completed, cancelled, and discarded jobs up to limit" do
+ device = device_fixture()
+
+ # Create 5 completed jobs
+ for _ <- 1..5 do
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "completed",
+ args: %{"device_id" => device.id},
+ completed_at: DateTime.utc_now()
+ })
+ end
+
+ # Should not include executing
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id}
+ })
+
+ recent = JobMonitoring.list_recent_completions(3)
+
+ assert length(recent) == 3
+ assert Enum.all?(recent, fn j -> j.state == "completed" end)
+ end
+ end
+end
diff --git a/test/towerops_web/live/admin/monitoring_live_test.exs b/test/towerops_web/live/admin/monitoring_live_test.exs
new file mode 100644
index 00000000..577209a7
--- /dev/null
+++ b/test/towerops_web/live/admin/monitoring_live_test.exs
@@ -0,0 +1,81 @@
+defmodule ToweropsWeb.Admin.MonitoringLiveTest do
+ use ToweropsWeb.ConnCase
+
+ import Phoenix.LiveViewTest
+ import Towerops.DevicesFixtures
+ import Towerops.JobsFixtures
+
+ 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 Process.alive?(view.pid)
+ end
+
+ test "displays active operations section", %{conn: conn} do
+ {:ok, _view, html} = live(conn, ~p"/admin/monitoring")
+
+ assert html =~ "Active Operations"
+ assert html =~ "No jobs currently executing"
+ end
+ end
+
+ describe "real-time updates" do
+ setup [:register_and_log_in_superuser]
+
+ test "displays executing jobs from database", %{conn: conn} do
+ device = device_fixture()
+
+ _job =
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id},
+ attempted_at: DateTime.utc_now()
+ })
+
+ {:ok, _view, html} = live(conn, ~p"/admin/monitoring")
+
+ # Should show the device name in executing jobs
+ assert html =~ device.name
+ assert html =~ "Active Operations (1)"
+ end
+ end
+
+ describe "metrics display" do
+ setup [:register_and_log_in_superuser]
+
+ test "shows current activity metrics", %{conn: conn} do
+ device = device_fixture()
+
+ oban_job_fixture(%{
+ worker: "Towerops.Workers.DevicePollerWorker",
+ state: "executing",
+ args: %{"device_id" => device.id}
+ })
+
+ {:ok, _view, html} = live(conn, ~p"/admin/monitoring")
+
+ assert html =~ "Executing"
+ assert html =~ "1"
+ 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