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