§1The shape of the repo
Three deployable units in one monorepo, plus the packaging that ships them
Everything lives under apps/. The backend owns all domain logic and is the only
thing that touches the database; the frontend is a pure API consumer; the Go agent is an
optional edge process that runs on machines you want first-party telemetry from.
| Path | What it is | Stack |
|---|---|---|
| apps/backend | API, workers, integrations, migrations. 57 router modules, 82 service modules, 80+ ORM models. | Python 3.12 · FastAPI · SQLAlchemy 2 · Alembic · Pydantic v2 |
| apps/frontend | Single-page app. 29 pages, 164 components, 27 custom hooks — half of which are stream consumers. | React · Vite · React Flow · sigma + graphology · d3-force · Plotly · i18next |
| apps/agent | Edge collector: host facts, network scoping, remote probes. Ships its own updater and spool. | Go 1.25 · Noise IK (flynn/noise) · gorilla/websocket · miekg/dns |
| docker/ · deploy/ | Single-image runtime: supervisord unit files, nginx config, Postgres bootstrap, migration and OOBE hooks. | Dockerfile.mono · supervisord · nginx · pgbouncer |
| specs/ · docs/ | Release-gate evidence, ADRs, security suppression ledger, and the user-facing MkDocs site. | Markdown · JSON policy files checked in CI |
The one rule that shapes the backend
Routers decode and authorize; services do the work. An API module under app/api/
resolves the caller, validates a Pydantic schema, and calls into app/services/.
No SQL and no domain branching live in the route layer, which is what makes the same logic
reusable from the six background workers — they import services directly and never call
the HTTP API.
§2One image, one process tree
Postgres, NATS, Redis, the API, six workers and nginx under a single supervisor
The deployment target is somebody's homelab, so the whole stack ships as one container with a
read-only root filesystem and a single writable /data volume. Supervisord runs the
process tree; the API binds only to loopback and nginx is the only thing listening on a public
port. Postgres, NATS and Redis are embedded by default and each can be pointed at an external
instance through an env var instead.
§3What happens to one request
Seven middleware layers, then authorization, then a service, then an audit entry
Every call from the SPA carries a HttpOnly session cookie and, on writes, a double-submit CSRF
token. Starlette wraps middleware in reverse registration order, so the last one added in
main.py is the first one executed — the diagram below is in execution
order, which is the order that actually matters when you are debugging a 403.
endpoint_inventory.json and diffed against endpoint_policy.json in CI:
adding a route without an auth dependency fails the build unless it is explicitly listed as an
allowed exception. Authorization is a tested property of the app, not a convention.
§4Telemetry: poll, queue, batch, cache
Device polling is decoupled from database writes by a durable JetStream queue
The telemetry worker polls every enabled device on its own interval — Redfish for iDRAC and iLO, SNMP for UPS units and switches, the Proxmox API for hypervisors. Credentials are pulled from the Fernet vault at poll time and never held in plaintext at rest. The naive version of this writes a row per poll; instead each sample is published to a JetStream subject and a separate pull consumer batches up to 50 rows per commit.
§5Real-time fan-out
One bus, six transports, one hook per stream on the client
Anything that changes state publishes a message; nothing polls the database to find out what
happened. Subjects are namespaced <domain>.<entity>.<event> and
declared as constants in a single module, so the set of things the system can announce is
greppable in one file. Each subject family surfaces on exactly one transport.
§6The monitoring engine
One clock, two work queues, deliberately separated
Uptime checks run on a single scheduler guarded by a Postgres advisory lock — many replicas may
be running, exactly one enqueues. Each tick atomically claims due items and advances their
next_due_at in the same statement, so all scheduling state lives in the database and
a restart resumes cleanly with nothing wedged.
The design decision worth pointing at is the second queue. Checks executed by a remote agent go to their own JetStream work queue, never onto the server's. An agent that stops draining backs up its own queue and nothing else.
§7The edge agent link
Noise-encrypted, spool-backed, and written to assume the network lies
The Go agent connects outbound over WSS and authenticates with a Noise IK handshake — there is no session cookie and no bearer token on this path; the handshake is the authentication, and the agent's static key is its identity from enrollment onward. Both endpoints bypass session middleware entirely, which is why they get their own nginx locations and their own tests.
Most of the interesting engineering is in failure handling. A severed network is often not a
closed socket: a firewall DROP, a container detach, or a stale NAT entry produces a
black hole where writes keep succeeding into nothing. Both sides therefore run a 60 s read
deadline against a 20 s heartbeat — a silent peer is a disconnect, and a disconnect is what hands
the outage to the disk spool.
§8Discovery never writes to your inventory
Eight probe sources land in a staging table; a human promotes them
Scans fan out across whatever the deployment can reach — port sweeps, mDNS, the kernel ARP cache,
DHCP leases, SNMP and LLDP neighbor tables, the Docker socket, the Proxmox API, OPNsense. Results
are fingerprinted against a vendor catalog and a device knowledge base, then written to
scan_results, which nothing else reads from.
Promoting a result into real hardware, service or network
rows requires an explicit merge. This is a product decision with an architectural consequence:
the discovery pipeline is append-only and can be re-run, dropped, or replaced without any risk to
the inventory a user has curated.
The centerpiece
§9The map engine
Every other data path in this document exists to put something on this screen
This is the component the whole product is for, and the one that took the most hours to get right — not because the graph is hard to draw, but because it has to stay beautiful and correct while eight independent data sources mutate it underneath the user's cursor. Telemetry pushes a health change, a discovery finishes, a monitor flips a node red, someone drags forty nodes at once, and the layout has to survive all of it without losing an edge or throwing away a position somebody spent an afternoon arranging.
orchestrating 10 hooks
all pure functions
React Flow & Sigma/WebGL
11 relation kinds
its own membership
The one idea that makes it work
The graph and its picture are two different things, fetched separately and stored
separately. The server owns the semantic graph — which hardware hosts which VM,
which service depends on which storage — derived from the inventory tables by
build_topology_graph and returned with stable prefixed IDs like hw-12
and svc-4. The client owns the visual graph — positions, boundary shapes,
freehand labels, drawn lines, per-edge routing overrides — persisted as one JSON blob per map.
That split is what lets everything else be simple. Deleting a service removes a node without disturbing anyone's layout. Re-running discovery adds nodes that auto-place into free space rather than triggering a full re-layout. And a layout engine can be swapped, rewritten, or added without a migration, because no server code has an opinion about geometry.
Four decisions worth defending
Layouts are pure functions
All eleven take (nodes, edges, viewport) and return positions. Nothing about
them touches the network, so switching engines is instant, undoable, and testable without a
DOM. Adding a twelfth is one file and one entry in a list.
Two renderers, chosen by scale
React Flow gives real DOM nodes — rich custom cards, telemetry rings, port handles, drag-to-arrange. It also stops being pleasant somewhere in the hundreds. Sigma over WebGL takes the same graph when the estate outgrows the DOM.
Edges are derived, not drawn
A link between two nodes means a real relation exists in the database —
hosts, runs, on_network, depends_on. You
can add ad-hoc edges, and they render dashed in a different colour precisely so nobody
mistakes decoration for fact.
One hook per concern
Data load, layout, mutations, edge interaction, boundary interaction, visual lines, drag snapping, real-time merge, timers, and tab state are ten separate hooks. Every timer lives in one of them, so unmount cleanup is a single path instead of a leak hunt.
The bug that cost the most hours
Multi-select drag could silently drop edges. React Flow emits a batch of change events during a
drag, and a race between the position update and the edge re-snap could commit a node array whose
edges no longer resolved — connections vanished from the screen and, if an autosave landed first,
from the database. The fix is useEdgeIntegrityGuard: snapshot the edge ID set at drag
start, diff it against every candidate commit, and restore anything that went missing. It is
fifty lines that exist entirely to make a feature feel trustworthy.
A topology map is judged in the first three seconds — whether it looks considered or looks like a graph library's default output. Most of the refinement time went into things nobody will ever name: edge routing that picks handle sides by relative position, boundary shapes that sit behind nodes without eating clicks, auto-placement that finds free space instead of stacking, and a viewport fit that frames the estate rather than centring the bounding box.
What it also does
Beyond drawing: infer parent links from device role and gateway heuristics when nothing declares them, collapse network members into cloud groups, auto-group Docker containers into boundaries, compute per-edge bandwidth from uplink speeds with type caps for wireless and tunnels, review LLDP neighbour findings, create a monitor from a right-click, and keep up to ten separate maps with independent membership over the same inventory.
§10Security posture
The controls that shaped the architecture rather than sitting on top of it
This is infrastructure tooling holding IPMI credentials and hypervisor API keys for someone's entire estate. Several of the design choices above exist because of that, not despite it.
Route policy gate
All 435 routes are enumerated into a checked-in inventory and diffed against an explicit policy file in CI. A new endpoint without an auth dependency fails the build.
Fernet secrets vault
Every credential is encrypted at rest with a key that lives outside the database. The vault starts uninitialized and refuses to generate an ephemeral key at import time — a wrong key is a loud failure, never silent data loss.
Hash-chained audit log
Each entry stores SHA-256(payload + prev_hash) under a write lock, so concurrent
appends cannot fork the chain. Verification walks the chain and reports the first break.
Mid-stream revalidation
Long-lived SSE and WebSocket connections re-check session validity every 15 seconds. Revoking a session ends the stream instead of waiting for the client to reconnect.
Least-privilege container
Read-only root filesystem, cap_drop: ALL with seven capabilities added back for
named reasons, no-new-privileges, and supervisor-dropped UIDs per service.
SSRF and egress control
Integration targets pass a URL validator and a network ACL before any request leaves the process; outbound traffic can be pinned to a proxy, and an air-gap mode disables it entirely.
The project is pre-1.0 and says so in its own README: not fully audited, LAN-only until the
release gates pass. The gates themselves — the suppression ledger, the endpoint policy, the
evidence files under specs/ — are checked into the repo and run in CI.
§11Five things worth saying out loud
The decisions a reviewer will ask about, and the answers
- Why one container instead of a compose stack? The users are homelabbers, and every additional service is a support burden. Bundling Postgres, NATS and Redis under supervisord means the install is one command and one volume — and because each is reached through a URL from config, any of them can be pointed at an external instance when someone outgrows the default. The convenience is the default, not the ceiling.
- Why a message bus in a single-node app? Not for scale — for decoupling and durability. It is what lets the collector stop caring whether the database is slow, lets a worker restart mid-upgrade without losing samples, and lets six independent processes fan work out without any of them knowing the others exist. Every path that uses it also has a degraded mode that works without it.
- Why does the agent exist at all when SNMP already works? Vantage point. An agent sees what an out-of-band controller cannot — process-level host facts, the kernel neighbor table, and checks executed from inside a network segment the server cannot route to. It is optional, and the system is fully functional without it.
- What's the hardest bug you fixed here? The silent-link failure in the agent. Writes to a black-holed socket keep succeeding, so the agent believed a dead link was healthy and an entire outage's samples went into the void instead of the spool. The fix — symmetric read deadlines on both ends, tied to a heartbeat interval both sides agree on — is three lines and a long comment explaining why they are load-bearing.
-
What would you change?
main.pyis 2,100 lines of router registration and lifespan wiring; that wants to be a declarative router manifest.MapPage.jsxis 3,000 lines even after ten hooks were extracted from it — the remaining orchestration should become a reducer. The frontend is JSX with JSDoc types rather than TypeScript, which the stream-heavy hooks would benefit from most. And the 80-model single ORM module should be a package split by bounded context — there is a proposal for it already in the tree.
§12The whole system, connected
Every piece from §2 through §9 on one canvas, following one loop
Read it left to right and the story is simple: the estate is polled and scanned by workers, everything they learn is announced on a bus, the API turns that into inventory and pushes live changes down open streams, and the map draws it. Read the bottom bar and the loop closes — what the user does on the map re-enters the system as new work.