Sets sensor_unit for all sensors that are missing it, fixing sensors
created before the discovery code was updated to set units properly.
Updates:
- Temperature sensors: set to '°C'
- Voltage sensors: set to 'V'
- Current sensors: set to 'mA'
- Power sensors: set to 'W'
- dBm sensors: set to 'dBm'
This ensures the device detail page displays units correctly for all
sensor types (e.g., "27 °C" instead of "27" for temperature).
The discovery code already sets these units for new sensors, so this
migration only affects existing data.
Related to:
- lib/towerops/snmp/profiles/vendors/routeros.ex:690 (°C)
- lib/towerops/snmp/profiles/vendors/routeros.ex:709 (V)
- lib/towerops/snmp/profiles/base.ex:1473-1480 (sensor_type_to_unit)
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Adds test case for the real-world scenario where MikroTik devices
report incorrect mtxrGaugeUnit values (e.g., reporting unit type 1
for celsius/temperature when the sensor is actually a voltage sensor).
This test verifies that the name-based override logic in
maybe_override_sensor_type/2 correctly handles this case:
- Sensor name: "psu1-voltage" or "psu2-voltage"
- Reported unit type: 1 (celsius/temperature)
- Expected result: voltage sensor with divisor 10
The override code was added in commit f402dcb (Jan 22, 2026) and
works correctly for new discoveries. Existing sensors created before
this date need the migration from commit 68a4ebf to fix their values.
Related: 20260125182836_fix_misclassified_mikrotik_voltage_sensors.exs
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Fixes voltage sensors that were incorrectly classified as temperature
sensors due to MikroTik devices reporting wrong mtxrGaugeUnit values.
Problem:
- Sensors with names like "psu1-voltage" and "psu2-voltage" were
classified as temperature sensors with divisor=1
- This caused voltage readings to display as 469V instead of 46.9V
- The RouterOS profile already has name-based override logic for new
discoveries, but existing sensors needed fixing
Solution:
- Migration identifies sensors by:
- OID pattern: 1.3.6.1.4.1.14988.1.1.3.100.1.3.%
- Type mismatch: sensor_type = 'temperature' but name contains 'voltage'
- Incorrect divisor: sensor_divisor = 1
- Updates sensor metadata: type, unit, divisor
- Corrects historical sensor_reading values by dividing by 10
Future discoveries are already protected by the name-based override
logic in lib/towerops/snmp/profiles/vendors/routeros.ex:620-630
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Mikrotik/RouterOS voltage sensors were showing incorrect values (469.0
instead of 46.9) because existing sensors in the database have
sensor_divisor=1 instead of the correct value of 10.
The RouterOS profile code was fixed in a previous commit to set
divisor=10 for new sensors, but existing sensors need to be updated.
Migration changes:
- Updates sensor_divisor from 1 to 10 for Mikrotik voltage sensors
- Identifies Mikrotik sensors by OID prefix: 1.3.6.1.4.1.14988.1.1.3.100.1.3
- Divides all historical sensor_reading values by 10 to correct the data
- Both up and down migrations included for full reversibility
After this migration:
- Voltage will show as 46.9V instead of 469.0V
- Historical graphs will show correct voltage values
Cloud pollers (is_cloud_poller = true, organization_id = nil) were
being blocked from polling devices because verify_device_organization
was checking if the device's organization matched the agent's
organization.
Since cloud pollers don't belong to any organization (organization_id
is nil), they should be allowed to poll devices from any organization.
Changes:
- verify_device_organization now allows access if organization_id is nil
- This fixes "Device not in agent's organization" errors for cloud pollers
Error before:
Device b7e8dd99-0b19-496d-97ae-9f2601d16464 not in agent's organization
Reverted k8s/Dockerfile back to using official hexpm/elixir and debian
images directly instead of custom gmcintire/towerops-base images.
This restores the original Dockerfile behavior where all dependencies
are installed during each build. The base image work in k8s/base-image/
remains available but is not used by the main application build.
Production was crashing with CaseClauseError when viewing device edit
form for devices using the global default cloud poller.
Added missing :global case branch to the agent source case statement
in form.html.heex line 127.
Displays: 'Using global default cloud poller: <agent_name>'
Changed image naming scheme from separate builder/runtime images to a
single image name with different tags:
- docker.io/gmcintire/towerops-base:builder
- docker.io/gmcintire/towerops-base:runtime
- docker.io/gmcintire/towerops-base:latest (alias for runtime)
This simplifies the naming and makes it clearer that both images are
part of the same towerops-base image family.
Changes:
- build.sh: Use IMAGE_NAME with BUILDER_TAG/RUNTIME_TAG
- Makefile: Update all targets to use new naming
- k8s/Dockerfile: Point to gmcintire/towerops-base:builder and :runtime
Instead of installing system packages and tools on every deploy, we now
have two base images that pre-bake all the slow setup steps.
Base Images Created:
1. elixir-builder:latest - Builder stage base
- hexpm/elixir with build-essential and git pre-installed
- hex and rebar pre-installed
- Saves ~40 seconds per deploy
2. debian-runtime:latest - Runtime stage base
- Debian with all runtime packages pre-installed
- Locale pre-configured (en_US.UTF-8)
- /app directory pre-created
- Saves ~60 seconds per deploy
Main Dockerfile Changes:
- Use elixir-builder:latest instead of hexpm/elixir
- Use debian-runtime:latest instead of debian:trixie-slim
- Remove apt-get install steps (now in base images)
- Remove hex/rebar install (now in builder base)
- Remove locale setup (now in runtime base)
Build System:
- build.sh builds both images with podman/docker auto-detect
- Pushes to both GitLab registry and Docker Hub
- Makefile targets for build/test/push/clean
Total Time Saved Per Deploy: ~100 seconds (1m40s)
Benefits:
- Faster CI/CD builds (no repeated apt-get install)
- Faster local development builds
- Consistent build environment across all deploys
- Security updates centralized in base image rebuilds
The hexpm images install Erlang and Elixir in /usr/local instead of /usr/lib.
Changes:
- Copy from /usr/local/lib/erlang instead of /usr/lib/erlang
- Copy all Erlang/Elixir binaries from /usr/local/bin
- Remove manual symlink creation (binaries already exist)
- Binaries are symlinks to ../lib/erlang/bin and ../lib/elixir/bin
The Erlang Solutions APT repository was experiencing 504 Gateway Timeout
errors during builds, making the image build process unreliable.
Changes:
- Use official hexpm/elixir Docker image as builder stage
- More reliable (official Docker Hub images)
- Faster builds (pre-built binaries, no package installation)
- Still produces minimal runtime-only final image
- Updated documentation to reflect new approach
Benefits:
- No dependency on erlang-solutions CDN availability
- Faster build times (no apt-get install of large packages)
- Same minimal final image size (~150-200 MB)
- Easier to specify exact Erlang/Elixir versions
Enhanced base image build system with:
Container Engine Support:
- Auto-detect podman or docker (prefers podman if both installed)
- All scripts work with either engine seamlessly
- No configuration needed
Docker Hub Integration:
- Push to both GitLab and Docker Hub registries
- GitLab: registry.gitlab.com/towerops/towerops/elixir-runtime
- Docker Hub: docker.io/gmcintire/elixir-runtime
- Both versioned and :latest tags pushed to each
Build Script:
- Detect and use $CONTAINER_CMD for all operations
- Show which container engine is being used in output
- Tag and push to both registries automatically
Makefile:
- Add CONTAINER_CMD variable with auto-detection
- Add DOCKERHUB_REGISTRY configuration
- Update all targets to use detected container engine
- Add login-dockerhub target for Docker Hub authentication
- Update push target to push to both registries
Documentation:
- Document podman/docker auto-detection
- Update login instructions for both registries
- Update troubleshooting with examples for both engines
This makes the build system more flexible and accessible to users
who prefer podman over docker, while also publishing to the public
Docker Hub registry for easier access.
Two issues fixed:
1. Cloud pollers (organization_id = nil) were causing access denied errors
when superadmins tried to view them. Now allows access if user is
superadmin and agent is a cloud poller.
2. Ecto.NoResultsError was being raised without required :queryable option,
causing KeyError crash. Now properly raises with queryable parameter.
Error was:
KeyError: key :queryable not found in []
(ecto 3.13.5) lib/ecto/exceptions.ex:203: Ecto.NoResultsError.exception/1
(towerops) lib/towerops_web/live/agent_live/show.ex:16: mount/3
Created build system for minimal Debian 13 (Trixie) image with Erlang/OTP
and Elixir runtime pre-installed. This will speed up CI/CD builds by caching
the runtime environment.
Features:
- Multi-stage Dockerfile for minimal final image (~150-200 MB)
- Build script with automatic tagging (versioned, latest, dated)
- Update script for easy security patch rebuilds
- Makefile for common operations
- Comprehensive documentation (README + QUICKSTART)
Benefits:
- Faster CI/CD: Saves 30-60s per build (no apt-get install runtime deps)
- Smaller images: Only essential runtime packages included
- Security: Easy to rebuild weekly/monthly with latest patches
- Consistency: Same runtime across all environments
Usage:
cd k8s/base-image
make build # Build the image
make test # Verify it works
make push # Push to registry
Then update k8s/Dockerfile to use the custom base image.
This replaces the previous Dockerfile.base approach with a more
comprehensive and maintainable build system.
When Kubernetes performs rolling deployments, the old pod gracefully shuts down
and Oban's internal processes receive EXIT :shutdown signals. These are logged
as "unexpected" even though they're normal during shutdown.
Added custom logger filter to drop these messages in production:
- Oban.Queue.Watchman/Producer receiving EXIT :shutdown
- Oban plugin processes receiving EXIT :shutdown
- Reduces log noise during deployments without hiding real errors
The filter is production-only to preserve full debugging in development.
HTML checkboxes send value='on' when checked, not 'true'. Updated
parse_agent_params to accept both 'on' and 'true' values for the
is_cloud_poller field.
Added debug logging to help diagnose future form submission issues.
Redix expects host to be a string, not a charlist. The to_charlist/1
conversion was causing FunctionClauseError in Redix.StartOptions.__validate_host__/1.
This matches how Phoenix PubSub Redis and Exq are configured, which both
use the string host directly without conversion.
Added logging to identify which component is failing health checks:
- Log database and Redis status on each health check
- Log Redis errors with full error details
- This will help diagnose the production 503 errors
Fixed dialyzer warnings across multiple files by properly handling return
values from functions that are called for side effects.
Changes:
- lib/towerops/agents.ex: Explicitly match broadcast and if-expression returns
- lib/towerops/devices.ex: Match worker start function returns in if-expressions
- lib/towerops/workers/agent_latency_evaluator.ex: Match broadcast return
- lib/towerops/workers/device_monitor_worker.ex: Match perform_check and handle_status_change returns, handle schedule_next_check errors
- lib/towerops/workers/device_poller_worker.ex: Match all PubSub broadcast returns, handle schedule_next_poll errors, remove unreachable pattern
- lib/towerops/workers/stale_agent_worker.ex: Match process_stale_agents and broadcast returns
- lib/towerops_web/channels/agent_channel.ex: Match PubSub.subscribe return, improve get_addr pattern matching, remove unreachable format_ip clause
- lib/towerops_web/live/device_live/form.ex: Match enqueue_discovery return, replace compile-time @dev_env with runtime check
Pattern applied:
- PubSub broadcasts: `_ = Phoenix.PubSub.broadcast(...)`
- If-expressions with side effects: `_ = if condition do ... end`
- Critical operations: Added error logging with case statements
All 3625 tests passing. No dialyzer warnings remaining in lib/towerops or lib/towerops_web.
🤖 Generated with Claude Code
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Added .dialyzer_ignore.exs to suppress 94 dialyzer warnings:
Suppressions:
- All SnmpKit library warnings (third-party code in lib/snmpkit/)
- Unmatched return values in workers and contexts (intentional fire-and-forget)
- Pattern match coverage warnings (exhaustive patterns)
- Guard failures and unused functions in LiveView form helpers
Result: Dialyzer now passes cleanly (94 errors skipped).
These warnings are either false positives or intentional design choices
where the return value is deliberately ignored for async operations.
Changed discover_all/1 to enqueue individual Oban jobs instead of using
Task.async_stream for better error visibility and resilience.
Benefits:
- Errors are visible in Oban dashboard with full stack traces
- Automatic retries on failure via Oban
- Jobs persist across pod restarts
- No more silent failures from Task crashes
- Better concurrency control via Oban queue configuration
Changes:
- discover_all/1 now enqueues DiscoveryWorker jobs and returns immediately
- Return type changed from %{success, failed, errors} to %{enqueued, failed, errors}
- Updated tests to match new async behavior
- Added DiscoveryWorker alias to Discovery module
All tests passing (3,625 tests, 0 failures).
The health check endpoint was calling Towerops.RedisHealthCheck.check_health/2
but the module didn't exist, causing startup probes to fail with 503 errors.
Added RedisHealthCheck module with:
- check_health/2: Single PING command with timeout for health checks
- wait_for_redis/2: Exponential backoff retry logic for startup
This fixes the failing health checks that were blocking pod rollouts.
All tests passing.
Replaced GenServer-based periodic workers with Oban Cron jobs to improve
pod rollover resilience and simplify architecture.
Worker Changes:
- NeighborCleanupWorker: GenServer → Oban Cron (hourly)
- Cleans stale neighbors, ARP entries, and MAC addresses
- Runs every hour via Oban.Plugins.Cron
- StaleAgentWorker: GenServer → Oban Cron (every minute)
- Detects agents that haven't checked in for 10+ minutes
- Refactored to reduce nesting (extracted helper functions)
- Removed stateful tracking (now stateless, re-evaluates each run)
- AgentLatencyEvaluator: GenServer → Oban Cron (every 5 minutes)
- Latency-based agent reassignment with 20% threshold
- Removed trigger_evaluation/0 (no longer needed)
- JobHealthCheckWorker: NEW Oban Cron worker (every 10 minutes)
- Safety net to recover missing monitor/poller jobs
- Auto-creates jobs for devices with monitoring/SNMP enabled
Infrastructure Changes:
- Removed Monitoring.Supervisor (no longer needed)
- Updated application.ex to remove GenServer workers from supervision tree
- Added Oban.Plugins.Cron to dev.exs and runtime.exs
- All workers now run cluster-wide via PostgreSQL-backed coordination
Test Updates:
- Updated all worker tests to call perform(%Oban.Job{args: %{}})
- Removed GenServer lifecycle tests (start_link, send messages, etc.)
- Removed async sleep calls (no longer needed)
Benefits:
- Better pod rollover resilience (Cron jobs run cluster-wide)
- Simpler architecture (no GenServers for periodic tasks)
- Better observability (all jobs visible in Oban dashboard)
- Safety net for missing jobs (JobHealthCheckWorker)
- Stateless workers (easier to reason about and test)
Documentation:
- Updated CLAUDE.md with Background Job Architecture section
- Documented job types, queues, and resilience features
All tests passing (3,686 tests, 0 failures).
Changes:
- Add Oban configuration to config/dev.exs (was only in runtime.exs)
- Replace Exq telemetry metrics with Oban equivalents
- Update publish_exq_stats to publish_oban_stats using Oban.Job queries
- Track queue sizes, executing jobs, and available jobs
This fixes the startup error where Oban config was not available in dev.
Replaces etcd-based distributed locking with Oban's PostgreSQL-backed job queue.
This simplifies the architecture by eliminating the need for a separate etcd cluster
while providing better reliability and observability.
Changes:
- Add Oban dependency and migration (oban_jobs table)
- Create DevicePollerCoordinator and DeviceMonitorCoordinator Oban workers
- Remove EtcdCoordinator and EtcdLock modules
- Update application supervisor to start Oban
- Configure Oban with pollers (50 workers) and monitors (50 workers) queues
- Remove etcd StatefulSet from Kubernetes manifests
- Update monitoring supervisor documentation
Benefits:
- Simpler architecture (no etcd cluster to manage)
- PostgreSQL-based (uses existing database)
- Built-in uniqueness prevents duplicate jobs cluster-wide
- Better observability with Oban Web UI
- Automatic job rescue on node crashes
- Easier local development (no etcd required)
What was removed:
- etcd StatefulSet (3 pods)
- EtcdCoordinator module (320 lines)
- EtcdLock module (158 lines)
- eetcd dependency
All 3,686 tests passing.