towerops/CLAUDE.md
Graham McIntire f44510bf47
docs: update user-facing changelog and document changelog maintenance
Updated priv/static/changelog.txt with all recent changes from CHANGELOG.txt
in concise, user-facing format. Enhanced CLAUDE.md to document the dual
changelog system (technical CHANGELOG.txt vs user-facing priv/static/changelog.txt)
with clear guidelines on what to include in each and when to update them.
2026-02-12 10:38:27 -06:00

17 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

CRITICAL: Read AGENTS.md First

Before starting any work in this repository, you MUST read AGENTS.md in the project root.

  • AGENTS.md contains comprehensive Phoenix/LiveView/Elixir guidelines that are mandatory for this project
  • These guidelines take precedence over general development practices when there's a conflict
  • Always read AGENTS.md at the start of a new conversation or when resuming work
  • Follow the project-specific patterns, conventions, and constraints documented there

Project Overview

Towerops is a Phoenix 1.8 web application built with Elixir, using Ecto for database operations (PostgreSQL), LiveView for real-time interactions, and Tailwind CSS v4 for styling.

Data Model Relationships

User (Accounts) → owns/member of → Organization (Organizations)
                                    ├─ Site (Sites) → Equipment
                                    ├─ Equipment → SNMPDevice, MonitoringCheck, Alert
                                    └─ AgentToken → AgentAssignment → Equipment

Key Relationships:
- User can own/belong to multiple Organizations
- Organization has default_agent_token_id (optional)
- Equipment belongs to both Site and Organization (denormalized)
- Equipment can be assigned to one AgentToken via AgentAssignment
- Equipment has one SNMPDevice with Sensors and Interfaces
- Equipment has many MonitoringChecks (polling results) and Alerts
- AgentToken authenticates remote agents for local SNMP polling

Note: Update this diagram when making data model changes.

Essential Commands

Setup and Development

  • mix setup - Install dependencies, create/migrate database, build assets
  • mix phx.server - Start Phoenix server (http://localhost:4000)
  • iex -S mix phx.server - Start server with IEx shell

Testing and Quality

  • mix test - Run all tests
  • mix test --failed - Re-run failed tests
  • mix test --cover - Run with coverage (target: 90% minimum)
  • mix precommit - Run before committing: compiles with warnings as errors, formats, runs tests
  • mix dialyzer - Static type analysis

Database

  • mix ecto.create/migrate/reset - Database operations
  • mix ecto.gen.migration name_using_underscores - Generate migration

Assets

  • mix assets.build/deploy - Build CSS/JS assets (auto-rebuilds on save in dev)

Architecture

Application Structure

Standard Phoenix conventions: business logic in lib/towerops/, web interface in lib/towerops_web/.

Key Configuration:

  • Binary IDs: UUID primary keys by default (binary_id: true)
  • Timestamps: :utc_datetime for all timestamps
  • Web server: Bandit adapter
  • HTTP client: :req library (Req module) - ONLY approved client
  • Ecto repos: [Towerops.Repo]

Web Layer

All LiveViews get these imports via html_helpers/0:

  • ToweropsWeb.CoreComponents - Core UI (<.button>, <.input>, <.form>)
  • ToweropsWeb.Layouts, Phoenix.LiveView.JS, Gettext, verified routes (~p)

Rate Limiting

Uses Hammer (ETS-backed):

  • Auth endpoints: 10 req/min per IP (/users/log-in, /users/register, TOTP)
  • API v1: 1000 req/min per IP (/api/v1/*)
  • Admin API: Not rate limited (superuser only)
  • Returns 429 Too Many Requests with Retry-After header
  • Disabled in test: config :towerops, :rate_limiting_enabled, false

Asset Pipeline

  • Tailwind CSS v4: Uses @import "tailwindcss" in assets/css/app.css (no config file)
  • esbuild: Bundles assets/js/app.js
  • Import all vendor assets into app.js/app.css - no external src/href in layouts
  • No inline <script> tags - use LiveView hooks

Custom Ecto Types

Avoid "primitive obsession" by using custom types:

Available Types:

  1. Towerops.EctoTypes.IpAddress - IPv4/IPv6 validation, struct with version/tuple
  2. Towerops.EctoTypes.EmailAddress - Normalized emails (Planned)
  3. Towerops.EctoTypes.MacAddress - MAC address normalization (Planned)

Pattern: Implement Ecto.Type with type/0, cast/1, load/1, dump/1.

When to use:

  • Domain concepts with validation rules (emails, IPs, phone numbers)
  • Values needing normalization (case-insensitive emails, MAC addresses)
  • Multiple representations (IP strings vs tuples)
  • Simple strings, primitives, one-off validations

Migration: No database changes needed - column stays primitive, Ecto handles conversion.

Background Jobs (Oban)

PostgreSQL-backed with cluster-wide coordination.

Queues:

  • default (10) - General tasks
  • discovery (10) - SNMP discovery
  • pollers (50) - SNMP polling (per-device)
  • monitors (50) - Health checks (per-device)
  • maintenance (5) - Periodic cleanup

Key Workers:

  1. Self-Scheduling (per-device):

    • DeviceMonitorWorker - Health checks (60s default)
    • DevicePollerWorker - SNMP data collection (60s default)
    • Auto-created/cancelled when device settings change
  2. Oban Cron (cluster-wide):

    • NeighborCleanupWorker - Hourly stale data cleanup
    • StaleAgentWorker - Detect agents offline 10+ minutes (every minute)
    • AgentLatencyEvaluator - Latency-based reassignment (every 5 minutes)
    • JobHealthCheckWorker - Recover missing jobs (every 10 minutes)

Resilience: Oban Cron uses PostgreSQL locking, self-scheduling recovered every 10 minutes. Dashboard: /admin/oban (superuser), /dev/dashboard → Oban tab (dev)

SNMP Polling

Two mechanisms:

  1. Discovery (Towerops.Snmp.Discovery) - One-time/manual, collects full device info
  2. Polling (DevicePollerWorker) - Continuous (60s default), time-series data

Load Distribution: Staggered polling using hash-based offsets:

  • offset = :erlang.phash2(device_id) |> rem(interval_seconds)
  • Prevents thundering herd, stable across restarts
  • Implementation: lib/towerops/workers/polling_offset.ex

MikroTik API Integration

RouterOS API access alongside SNMP.

Architecture:

  • 3-tier credential cascade: Organization → Site → Device (like SNMP)
  • Encryption: Passwords encrypted at rest (Cloak AES-256-GCM)
  • Detection: Auto-detected from SNMP manufacturer field
  • Transport: API-SSL (8729, default) or plain API (8728, insecure)
  • Security: Plain API blocked for cloud pollers, passwords require CLOAK_KEY env var

Key Files:

  • lib/towerops/vault.ex - Cloak vault
  • lib/towerops/ecto_types/encrypted_binary.ex - Encrypted field type
  • lib/towerops/devices.ex - Config resolution, propagation
  • priv/proto/agent.proto - MikrotikDevice protobuf messages

Credential Resolution: Each field resolves independently up the hierarchy.

MIB Name Resolution (Rust NIF)

Uses Rust NIF via Rustler for fast MIB resolution (replaces unreliable Erlang SNMP).

  • NIF Module: lib/towerops_native.ex
  • Rust: native/towerops_native/src/mib.rs (uses snmptranslate command)
  • MIB Files: priv/mibs/ (570+ vendor and standard MIBs)
  • Dependencies: brew install net-snmp (macOS), apt-get install libsnmp-dev snmp-mibs-downloader (Docker)
  • Usage: Try ToweropsNative.resolve_oid/1 first, fallback to SnmpKit.resolve/1

Note: SnmpKit still used for SNMP protocol operations (get, walk, etc.).

Admin Features

User Impersonation

  • Location: /admin/users (superuser only)
  • Superusers CAN impersonate other superusers (feature, not bug)
  • All events logged to audit_logs
  • Stop link appears in user menu when active

GeoIP Database

  • Import MaxMind GeoLite2-City for IP-based country/city detection (GDPR cookies)
  • Local: make geoip-import DIR=~/Downloads/GeoLite2-City-CSV_20260127/
  • Production: make geoip-import-prod DIR=... (requires TOWEROPS_KEY)
  • API: POST /admin/api/geoip/import (superuser only)

Project-Specific Constraints

From AGENTS.md (see that file for details):

  • Use mix precommit before committing
  • Use :req (Req) for HTTP - never :httpoison, :tesla, :httpc
  • Never use daisyUI - custom Tailwind components
  • LiveView templates start with <Layouts.app flash={@flash}>
  • Use <.icon name="hero-x-mark"> for icons
  • Use LiveView streams for large collections
  • Forms: to_form/2 in LiveView, <.form for={@form}> in templates
  • Never access changesets directly in templates - use form assign
  • Tailwind v4: new import syntax, never @apply
  • Always run mix format after Elixir changes

Database Schema Critical Notes

Binary UUID Primary Keys: All tables MUST use :binary_id, not bigint:

create table(:table_name, primary_key: false) do
  add :id, :binary_id, primary_key: true
end

SNMP Binary Data: Convert to printable strings before saving. Non-printable → colon-separated hex. See lib/towerops/snmp/neighbor_discovery.ex:sanitize_string_field/1.

API Documentation

  • Available at /docs/api (Tailwind UI Protocol template)
  • Update controller @doc comments and /docs/api template when changing endpoints
  • Wrap examples in <%= raw(~S"""...""") %> to prevent HEEx parsing

Kubernetes Deployment

Prerequisites: cert-manager, Traefik, MetalLB, FluxCD, GitLab Agent, NFS Provisioner

Secrets (1Password, towerops namespace):

  • gitlab-registry - Docker credentials
  • towerops-secrets - RELEASE_COOKIE, SECRET_KEY_BASE, CLOAK_KEY
  • towerops-db - PostgreSQL connection
  • towerops-aws - AWS credentials

Create CLOAK_KEY:

# Generate and store in 1Password first
CLOAK_KEY=$(openssl rand -base64 32)

# Create secret
kubectl create secret generic towerops-secrets \
  --from-literal=RELEASE_COOKIE=$(openssl rand -base64 32) \
  --from-literal=SECRET_KEY_BASE=$(mix phx.gen.secret) \
  --from-literal=CLOAK_KEY="$CLOAK_KEY" \
  -n towerops

# Or patch existing
kubectl patch secret towerops-secrets -n towerops \
  --type='json' \
  -p="[{'op': 'add', 'path': '/data/CLOAK_KEY', 'value': '$(echo -n "$CLOAK_KEY" | base64)'}]"

# Restart pods
kubectl rollout restart deployment/towerops -n towerops

Important: Losing CLOAK_KEY makes encrypted data unrecoverable.

Deployment: FluxCD auto-applies k8s/ manifests. Manual: kubectl apply -k k8s/

Common Patterns

Pagination in LiveView

Use <.pagination> component from CoreComponents:

def handle_params(params, _url, socket) do
  page = params |> Map.get("page", "1") |> String.to_integer()
  per_page = 20

  all_items = MyContext.list_items(org_id)
  total_count = length(all_items)
  total_pages = ceil(total_count / per_page)
  page = max(1, min(page, max(1, total_pages)))

  offset = (page - 1) * per_page
  items = Enum.slice(all_items, offset, per_page)

  {:noreply, socket
   |> assign(:items, items)
   |> assign(:pagination, %{page: page, per_page: per_page,
                             total_count: total_count, total_pages: total_pages})}
end

Template:

<.pagination meta={@pagination} path={~p"/devices"} params={%{"tab" => @tab}} />

For large datasets (>10K), use database-level limit/offset instead of in-memory slicing.

Browser Navigation and URL State

Critical: All LiveViews MUST sync UI state to URL for browser back/forward buttons.

Core Principle: URL is single source of truth for visible state (tabs, modals, filters, pagination).

Pattern: Use push_patch/2 and handle_params/3:

# Tab navigation
def handle_params(params, _url, socket) do
  tab = case params["tab"] do
    tab when tab in ~w(overview sensors) -> tab
    _ -> "overview"  # Safe default
  end
  {:noreply, assign(socket, :active_tab, tab) |> load_tab_data(tab)}
end

def handle_event("select_tab", %{"tab" => tab}, socket) do
  {:noreply, push_patch(socket, to: ~p"/devices/#{socket.assigns.device.id}?tab=#{tab}")}
end

Template:

<.link patch={~p"/devices/#{@device.id}?tab=overview"}
       class={if @active_tab == "overview", do: "active"}>Overview</.link>

Modal state:

def handle_params(params, _url, socket) do
  modal = params["modal"]
  {:noreply, assign(socket, show_modal: modal != nil, modal_name: modal)}
end

def handle_event("show_modal", _, socket) do
  {:noreply, push_patch(socket, to: ~p"/settings?modal=add_token")}
end

# Close modal by removing ?modal param
{:noreply, push_patch(socket, to: ~p"/settings")}

Rules:

  1. Every LiveView implements handle_params/3 - ONLY place to read URL params
  2. Validate all params, provide defaults
  3. Never use assign for URL-backed state - use push_patch
  4. Test browser back/forward buttons

Achieves: Browser navigation, bookmarkable URLs, deep linking, refresh preservation.

Reference: docs/plans/2026-02-12-browser-navigation-fix-design.md

Testing Patterns

SNMP Mocking with Mox

  • snmp_adapter().get/3 returns {:ok, value} or {:error, reason}
  • snmp_adapter().walk/3 returns {:ok, [%{oid: "...", value: ...}]} (list, not map)
  • Values already extracted (no type wrapper needed)

Common Pitfalls:

  • walk returning {:ok, %{}} instead of {:ok, []}
  • Returning type wrappers like {:integer, 123}
  • Not matching OIDs in get expectations

Test Organization

  • DataCase for database tests, ConnCase for controllers/LiveView
  • async: true when possible
  • Never open test coverage HTML files
  • Never use npm - esbuild is built into Phoenix
  • Run cargo fmt before committing Rust changes

Dialyzer

  • mix dialyzer - Run analysis (builds PLT first time)
  • mix dialyzer --format dialyzer - Detailed error locations
  • Start with broad types, narrow gradually
  • Never suppress valid warnings - fix root cause
  • PLT files in priv/plts/ - don't commit

Memory Leak Prevention

JavaScript Hook Cleanup

Always remove event listeners in destroyed():

const MyHook = {
  handleClick: null as ((e: Event) => void) | null,

  mounted(this: any) {
    this.handleClick = (e: Event) => { /* logic */ }
    this.el.addEventListener("click", this.handleClick)
  },

  destroyed(this: any) {
    if (this.handleClick) {
      this.el.removeEventListener("click", this.handleClick)
      this.handleClick = null
    }
  }
}

Clean up third-party instances (Chart.js, Cytoscape) in destroyed().

LiveView Patterns

Use streams for:

  • Lists with hundreds/thousands of items
  • Lists with frequent PubSub updates
  • When only adding/updating/removing individual items

Example:

# Mount
{:ok, stream(socket, :alerts, load_all_alerts())}

# Add item
stream_insert(socket, :alerts, alert, at: 0)

# Remove item
stream_delete(socket, :alerts, alert)

Template:

<div id="alerts" phx-update="stream">
  <%= for {id, alert} <- @streams.alerts do %>
    <div id={id}>{alert.message}</div>
  <% end %>
</div>

Use pagination for: Audit logs, historical alerts, any list that could grow to hundreds of items.

LiveView Form Data Access

Problem: form.params is empty until phx-change="validate" fires.

Solution: Access current values from changeset:

def handle_event("test_connection", _params, socket) do
  changeset = socket.assigns.form.source
  form_data = Ecto.Changeset.apply_changes(changeset)  # Returns struct with all values

  # Convert to map if needed
  device_map = %{"ip_address" => form_data.ip_address, ...}
end

Combines: DB values + default values + user changes.

Phoenix.PubSub

Phoenix automatically unsubscribes when LiveView terminates - no manual cleanup needed.

Global Event Listeners

Safe patterns (already correct):

  • document.addEventListener('DOMContentLoaded', ...) - Once per page load
  • window.addEventListener("phx:page-loading-start", ...) - Global app events

LiveView patch navigation doesn't reload page, so no duplicates.

Checklist

LiveView:

  • Use stream/3 for large/frequently-updated lists
  • Use pagination for lists that could grow large
  • PubSub auto-cleans (no action needed)

JavaScript Hooks:

  • Every addEventListener has matching removeEventListener in destroyed()
  • Store event handler references (no anonymous functions)
  • Clean up third-party instances in destroyed()
  • Set hook properties to null in destroyed()

Changelog

Two changelog files to maintain:

CHANGELOG.txt (Technical/Internal)

After every code change, append to CHANGELOG.txt:

  • Date (YYYY-MM-DD)
  • Short description (e.g. "fix: update last_snmp_poll_at for agent-polled devices")
  • Files changed and brief explanation
  • Technical details about implementation

Keep reverse chronological (newest at top). Never remove entries.

priv/static/changelog.txt (User-Facing)

After significant changes, update priv/static/changelog.txt:

  • Group changes by date (YYYY-MM-DD)
  • Brief bullet points without code specifics
  • Generic descriptions (no function names, modules, file paths, or language details)
  • Focus on what changed for the user, not how it was implemented
  • Follow existing pattern: "* Feature description" or "* Bug fix: brief description"

Example conversions:

  • "fix: update last_snmp_poll_at in agent_channel.ex"

  • "Poll time tracking improvements"

  • "feat: add discover_wireless_sensors/1 to MikroTik vendor module"

  • "Enhanced wireless monitoring for MikroTik devices"

When to update user-facing changelog:

  • User-visible features or improvements
  • Bug fixes affecting user experience
  • New vendor/device support
  • Performance improvements
  • Security improvements
  • Skip: Test-only changes, internal refactoring, documentation updates