Reduced file size by 62% while preserving all critical information: - Simplified data model diagram to hierarchical text format - Removed verbose code examples, keeping essential patterns - Condensed sections while retaining actionable information - Streamlined Custom Ecto Types, Browser Navigation, and Memory Leak sections The condensed version maintains all necessary guidance for AI assistants working on the project without overwhelming the context window.
16 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.mdcontains 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.mdat 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 assetsmix 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 testsmix test --failed- Re-run failed testsmix test --cover- Run with coverage (target: 90% minimum)mix precommit- Run before committing: compiles with warnings as errors, formats, runs testsmix dialyzer- Static type analysis
Database
mix ecto.create/migrate/reset- Database operationsmix 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_datetimefor all timestamps - Web server: Bandit adapter
- HTTP client:
:reqlibrary (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 RequestswithRetry-Afterheader - Disabled in test:
config :towerops, :rate_limiting_enabled, false
Asset Pipeline
- Tailwind CSS v4: Uses
@import "tailwindcss"inassets/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:
Towerops.EctoTypes.IpAddress- IPv4/IPv6 validation, struct with version/tupleTowerops.EctoTypes.EmailAddress- Normalized emails (Planned)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 tasksdiscovery(10) - SNMP discoverypollers(50) - SNMP polling (per-device)monitors(50) - Health checks (per-device)maintenance(5) - Periodic cleanup
Key Workers:
-
Self-Scheduling (per-device):
DeviceMonitorWorker- Health checks (60s default)DevicePollerWorker- SNMP data collection (60s default)- Auto-created/cancelled when device settings change
-
Oban Cron (cluster-wide):
NeighborCleanupWorker- Hourly stale data cleanupStaleAgentWorker- 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:
- Discovery (
Towerops.Snmp.Discovery) - One-time/manual, collects full device info - 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_KEYenv var
Key Files:
lib/towerops/vault.ex- Cloak vaultlib/towerops/ecto_types/encrypted_binary.ex- Encrypted field typelib/towerops/devices.ex- Config resolution, propagationpriv/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(usessnmptranslatecommand) - 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/1first, fallback toSnmpKit.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 precommitbefore 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/2in 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 formatafter 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
@doccomments and/docs/apitemplate 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 credentialstowerops-secrets- RELEASE_COOKIE, SECRET_KEY_BASE, CLOAK_KEYtowerops-db- PostgreSQL connectiontowerops-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:
- Every LiveView implements
handle_params/3- ONLY place to read URL params - Validate all params, provide defaults
- Never use
assignfor URL-backed state - usepush_patch - 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/3returns{:ok, value}or{:error, reason}snmp_adapter().walk/3returns{:ok, [%{oid: "...", value: ...}]}(list, not map)- Values already extracted (no type wrapper needed)
Common Pitfalls:
- ❌
walkreturning{:ok, %{}}instead of{:ok, []} - ❌ Returning type wrappers like
{:integer, 123} - ❌ Not matching OIDs in get expectations
Test Organization
DataCasefor database tests,ConnCasefor controllers/LiveViewasync: truewhen possible- Never open test coverage HTML files
- Never use npm - esbuild is built into Phoenix
- Run
cargo fmtbefore 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 loadwindow.addEventListener("phx:page-loading-start", ...)- Global app events
LiveView patch navigation doesn't reload page, so no duplicates.
Checklist
LiveView:
- Use
stream/3for large/frequently-updated lists - Use pagination for lists that could grow large
- PubSub auto-cleans (no action needed)
JavaScript Hooks:
- Every
addEventListenerhas matchingremoveEventListenerindestroyed() - Store event handler references (no anonymous functions)
- Clean up third-party instances in
destroyed() - Set hook properties to
nullindestroyed()
Changelog
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
Keep reverse chronological (newest at top). Never remove entries.