- Fix doctests for Accounts, Agents.Stats, Snmp to match actual behavior
- Fix dynamic_extra_test vendor post-processing tests to seed sensor data
- Fix activity_controller_test to seed devices for feed data
- Fix session_manager_test to create browser session for test
- Fix topology_test link creation for connection test
- Fix device_monitor/driver_worker tests for unique job constraints
- Fix accounts_test expired_tokens assertion (magic link token is expired)
- Fix happy_path_test and show_events_test to seed monitor data
- Fix admin user_live_test user.name -> user.email (no name field)
- Fix schema_test to seed activity data
- Fix mobile_qr_live_test to match actual template text
- Fix SnmpKit.MIB doctests and tests for enriched return values
- Fix onboarding_live, mobile_controller, mib_test weak assertions
- Remove dead code and fix credo warnings
All checks set to exit_status: 0 initially to avoid blocking on
pre-existing violations. Remove exit_status overrides as each
check category is cleaned up incrementally.
Dependency added to dev/test only.
All checks set to exit_status: 0 initially to avoid blocking on
pre-existing violations. Remove exit_status overrides as each
check category is cleaned up incrementally.
- Narrow @spec for NetworkInsightPrompt.build/1 and NetworkSnapshot.build/1
- Match/discard unmatched returns in monitoring.ex, alerts.ex, and
cloud_latency_probe_worker.ex
- Remove unreachable :rate_limited pattern in mikrotik_webhook_worker.ex
(resolve_error returns {:rate_limited, pos_integer()}, never bare atom)
- Fix gettext pluralisation in insights_live/index.ex: dngettext was
receiving a string where it expected a non_neg_integer count
Three changes targeting the root cause of production up/down alerting:
1. Skip BruteForceProtection for /health and /health/live paths
(removes unnecessary Repo.get_by(IpBlock) DB query from every
k8s probe, matching the existing /socket/agent exemption).
2. Relax k8s readiness probe: periodSeconds 5→10, timeoutSeconds
2→5, failureThreshold 2→3, successThreshold 3→2 so transient
DB/Redis blips don't cascade into full service unavailability.
3. Use the persistent Towerops.Redix connection for health checks
instead of opening a brand-new TCP connection (handshake→AUTH→
PING→close) on every probe. Towerops.Redix.Fake gains a
set_ping_response/2 toggle for testing failure paths.
The previous commit bumped CI from OTP 27.2/Elixir 1.18.1 to
OTP 28.5/Elixir 1.19.5. The broad mix cache restore-keys was
restoring a stale ~/.mix cache with hex compiled for the old
OTP, causing beam_load errors on new bs_add instructions.
Adding explicit mix local.hex --force ensures the correct hex
version for the running OTP is installed before deps.get.
Production code (lib/): all Process.sleep calls replaced with receive
after timeout - retry backoff, exponential backoff, batch delays,
poll intervals, and settle waits.
Test code: poll_until helpers across 4 agent channel test files now
use receive after instead of Process.sleep. Various standalone
Process.sleep(N) calls replaced with :sys.get_state sync barriers
or receive after. deferred_discovery test simulation sleeps reduced
500ms -> 30ms.
Test takes ~16s on this hardware to resolve/error on invalid
charlists. 1000ms timeout was causing CI pipeline failure, preventing
image build and deployment.
Backhaul radios (ePMP, AirOS, PMP, Cambium, MikroTik, RouterOS) now
monitor their connected client count with a low threshold of 1. When
the count drops to 0 the SnmpSensorExecutor marks it CRITICAL, which
triggers an alert through the existing check → alert pipeline.
- Adds field to sensor_def type in Vendor behaviour
- Sets on all clients-type wireless sensors in
backhaul-capable vendor profiles
- Adds default executor handling: any clients sensor at 0 → status 2
- 3 new tests for clients sensor behavior
Converts the 120KB PNG logo to 17KB WebP (86% smaller). Both logo instances in the marketing layout use <picture> with WebP source and PNG fallback for maximum browser compatibility.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Adds <meta name='robots'>, twitter:card/title/description meta tags, and WebP favicon reference to the root layout. Fixes title duplication (TowerOps | TowerOps -> Home | TowerOps) by passing page_title from the page controller.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
L2: Remove 2-second Process.sleep from post_startup — the try/catch
already handles transient noproc errors from the TaskSupervisor.
L3: Remove Process.sleep from delete_device — in-flight jobs already
handle the race condition via verify_polling_assignment_unchanged.
The blocking sleep was unnecessary and delayed web requests by 500ms.
The :env config key IS properly set in all environments:
- config/test.exs:112 sets :test
- config/dev.exs:216 sets :dev
- config/runtime.exs:461 sets :prod
Application.get_env(:towerops, :env) works correctly at runtime.
Now that the image serving /health/live is live, switch liveness and startup
probes to the shallow endpoint so transient db/redis blips can't kill or block
a healthy pod; readiness stays on the deep /health. Run 2 replicas so a single
pod restart no longer means full downtime.
Prod paired the Oban Pro Smart engine + unique workers with core
Oban.Plugins.Lifeline, whose rescue has no unique_violation handling. An
orphaned executing CheckExecutorWorker job rescued back to available collides
with its already-scheduled successor on oban_jobs_unique_index (23505),
crashing the plugin every 60s and leaving ~150 orphans stuck. Switch prod to
Oban.Pro.Plugins.DynamicLifeline, which repairs the conflict via
Smart.clear_uniq_violation. Dev keeps core Lifeline (Basic engine).
Add a shallow /health/live endpoint that does not touch db/redis. k8s liveness
and startup probes now target it so a transient dependency outage can't kill or
block boot of a healthy pod; readiness keeps the deep /health to gate the load
balancer. (deployment.yaml probe/replica change pushed separately, after the
image carrying /health/live is live.)
Also drop unused POSTGRES_* keys from the secrets example.
CheckExecutorWorker had no Oban `unique` constraint, so every insert path
created a fresh job row: self-scheduling, JobHealthCheckWorker, the check
form/API/GraphQL, and — the main amplifier — Monitoring.schedule_check/1
called unconditionally by snmp.ex discovery after create_check/1, which is
an upsert that returns {:ok, check} even when the check already exists.
Every SNMP discovery pass re-enqueued a job for every existing check, so
the check_executors queue grew to ~70k jobs for ~5.7k checks and stopped
draining.
Add `unique: [keys: [:check_id], states: [:available, :scheduled,
:retryable], period: :infinity]`. `:executing` is excluded so perform/1's
self-reschedule does not conflict with its own running job.
Also add the `checks` and `check_executors` queues to config/dev.exs —
they were missing from dev entirely (prod's runtime.exs already had them).
SyncErrors.transient?/1 only had explicit clauses for the {:http_error, ...}
shape, but vendor API clients return {:unexpected_status, status, body}. A 502
was retried only by accident via the catch-all, and a 4xx in that shape would
have been retried 20 times. Add explicit {:unexpected_status, ...} clauses: 5xx
is transient (Oban backs off and retries), 4xx is permanent.
The @session_options module attribute used Application.compile_env, which
baked compile-time placeholders into the endpoint while runtime.exs set
the real values from SESSION_SIGNING_SALT / SESSION_ENCRYPTION_SALT env
vars. Phoenix detected the mismatch and refused to start (failing migrate
Job in k8s).
- Remove hardcoded salts from config/config.exs (no compile-time binding)
- Add stable per-env salts in dev.exs / test.exs so local + CI don't need
the env vars
- Split static cookie opts (@static_session_options) from runtime-resolved
opts in endpoint.ex; expose session_options/0 as an MFA tuple in socket
connect_info so LiveView decodes sessions with the same runtime salts
- New ToweropsWeb.Plugs.RuntimeSession wraps Plug.Session, fetches salts
from app env on first request, and caches the initialized opts in
:persistent_term (zero per-request overhead after warm-up)
- M19: Add validate_mikrotik_device, validate_mikrotik_command, validate_check,
validate_snmp_query, validate_sensor, and validate_interface functions with
string length and list size validation, wired into all call sites
- L16: Document that Cloak Vault ETS table name collision risk is mitigated by
ETS tables being node-local (only a concern on same-BEAM-node, not a supported
production configuration)
- M20: Add check_no_hardlinks and check_no_special_files to MIB upload
archive extraction to prevent hard link, device node, and FIFO attacks
- M21: Change RateLimit ETS table from :public to :protected; route hit/get/reset
through GenServer.call instead of direct ETS access
- M22: Add 8-hour impersonation timeout — store impersonated_at in session and
auto-revoke impersonation when expired
- L14: Add default limit (500) and optional offset to list_checks for pagination
SESSION_SIGNING_SALT, SESSION_ENCRYPTION_SALT, and LIVE_VIEW_SIGNING_SALT
are now loaded from environment variables in production. Dev/test keep the
previous defaults via config.exs. k8s/secrets.yaml has placeholder entries;
the user fills in real values before applying.
Use Repo.get_by(schema, id: id, organization_id: organization_id) instead of
Repo.get + pattern match so that resources from wrong orgs return :not_found
instead of :forbidden, preventing org membership discovery.
- L3: Normalize IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) to IPv4 tuples
when matching against IP whitelist entries
- L7: Verify interface belongs to current org before set/clear capacity
- L12: Guard window.liveSocket behind NODE_ENV check for production safety
- L13: Replace Alert.changeset with Ecto.Changeset.change for simple field
updates (resolved_at, acknowledged_at, gaiia_impact) to prevent
accidental overwrites from the 17-field cast
- M18: Add validate_snmp_device/1 with 255-byte limits on community,
v3_auth_password, and v3_priv_password to prevent memory exhaustion
- M13: Add client-side mime_type allowlist to phx:download handler
- M14: Replace Process.sleep loop in WeatherSyncWorker with Task.async_stream
to avoid blocking the Oban queue slot
- M15: Chunk device list in CloudLatencyProbeWorker PubSub broadcasts
(100 devices per message) to prevent megabyte-sized messages
- M3: Replace blacklist-based valid_return_path? with whitelist of known app
route prefixes, plus URI decoding to prevent %2f encoding bypasses
- M6: Replace full-org device/link load in get_node_detail with targeted
join query filtering by discovered node fields
- M7: Add partial unique index on (organization_id, email) for pending
invitations (WHERE accepted_at IS NULL)
- M12: Add on_mount superuser verification to all 7 admin LiveViews for
defense-in-depth
Sonar, Splynx, VISP, NetBox, and Gaiia sync workers all followed the same
"cron runs, then Enum.map syncs every integration in the same job" shape.
A single slow tenant — anything from an upstream rate limit to a stuck
GraphQL call — would hold the maintenance queue slot for ten minutes
while every other integration waited in line.
Each worker now uses the dispatcher/per-integration pattern that
UispSyncWorker already had:
- the cron `perform(%Oban.Job{args: %{}})` fans out one job per eligible
integration via `PollingOffset.calculate_offset/2`, staggered across a
5- to 10-minute window with `unique: [period: window_seconds]`
- `perform(%Oban.Job{args: %{"integration_id" => id}})` does the actual
sync, classifying failures via `Towerops.Workers.SyncErrors.transient?/1`
so permanent errors (auth, 4xx) don't get retried forever
Worker-specific defaults preserved:
- gaiia / netbox: 30/600s window, longer interval
- sonar / splynx / visp: 10/300s window
Tests updated to match the two-stage dispatcher pattern — `perform_job/2`
with empty args now asserts the per-integration jobs got enqueued and
the actual sync is invoked via a separate `perform_job/2` call with the
integration_id arg.