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.
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.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
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