Skip to content

Take the web UI for a spin

The web UI is the browser face of the same bitemporal engine the TUI drives. It trades the terminal for things a terminal can’t do well: a force-directed topology graph, shareable deep-links (/history?mac=…), and a click-through from a VM to the physical switchport it lives on. Eleven screens, one nav rail, and a time control that scrubs both axes of the bitemporal model at once.

It’s a two-tier split. A static Astro build (eleven pages, each mounting one React island) talks to a read-only FastAPI over /api/*. In production a single container serves the built frontend and the API, so the same image works with or without SSO — auth is entirely server-side.

The UI ships as a compose overlay that serves the built frontend and the read API as one service behind your reverse proxy:

Terminal window
docker compose -f docker-compose.yml -f docker-compose.web.yml up -d --build web

It comes up at the hostname in the service’s caddy label (override with L2TRACE_WEB_HOST); the API stays read-only, and Postgres/NATS stay off the shared network. Everything you see below was captured against the realistic seed — bring the same data up with make seed-realistic followed by make seed-web-extras (the second one adds the vSphere correlations, an unknown neighbor, and a quarantine burst that light up the last three screens).

For hot-reload hacking, run the API with uv run l2trace web and the Astro dev server with pnpm --dir web-ui dev — Vite proxies /api to the backend, so edits to either side reload on save.

The time circuits — bitemporality you can scrub

Section titled “The time circuits — bitemporality you can scrub”

Every data screen carries the same control at the top. Collapsed, it’s a one-line strip; click it and it unfolds into a dashboard modeled on the DeLorean’s time circuits, because it’s doing exactly what that prop implied — moving through two independent time axes at once.

the time-circuits panel — DESTINATION TIME (red, valid), PRESENT TIME (green, now), LAST TIME DEPARTED (amber, recording)

  • DESTINATION TIME (red) is valid timewhen a fact was true on the wire. “Travel to” 3pm last Tuesday and every screen answers as of the network’s state then.
  • PRESENT TIME (green) is a read-only live clock, ticking every second, so you always see where “now” sits relative to the two dials.
  • LAST TIME DEPARTED (amber) is recording timewhich belief snapshot you departed from. Set it to ask “what did the system believe last Tuesday,” before a late-arriving event revised the record.

A dial reading LIVE tracks now; set it and it flips to SET. Leaving both live is the default — you’re looking at current truth, current belief. This is the one control that makes the difference between “where is MAC X” and “where did we think MAC X was, as of a belief we held an hour ago” a matter of two clicks. The concept is spelled out in Querying past beliefs and Bitemporality.

The landing screen. Enter a source MAC, a destination MAC, and a VLAN.

the Trace screen — source/destination MAC + VLAN form, empty state

The MAC and IP fields (here, and on Diff and History) autocomplete on identifiers l2trace has actually observed: start typing and a dropdown offers matching MACs (hinted with their OUI vendor) and IPs (hinted with their bound MAC). Matching is format-insensitive, so 001b, 00:1b, and 00-1b all find the same prefix. Suggestions are a convenience, not a constraint: a MAC the fabric has never seen still submits, which matters when you are chasing something new.

Run it and the path renders as a vertical timeline, one card per hop: the device, the ingress port (where the frame entered) and egress port (where it leaves toward the next hop), the port role (access vs trunk), and the source that vouched for that CAM entry.

the Trace result — a 3-hop path across sw-access-1 → sw-core-1 → sw-access-2, REACHED

The badge up top is the termination reason — why the trace stopped. reached (green) is the happy path. floods, dead-end, loop, and too_many_hops each tell you where the reconstruction gave up rather than silently returning nothing. When the path crosses an MLAG pair, the core hop wears an mlag-N badge — the peer pair is collapsed to one logical node so a dual-homed link doesn’t read as a flap. See the traceroute algorithm and MLAG collapse.

Scrub the time circuits while a result is on screen and the trace re-runs at the new moment — no retyping.

Diff — did the path change between two times?

Section titled “Diff — did the path change between two times?”

Trace answers “where does this frame go now.” Diff runs the same reconstruction at two belief times and aligns them hop-by-hop, so you can ask “did anything move between 3pm and now, and where.” Enter the src and dst MAC, the VLAN, and two times — t1 (before) and t2 (after), each accepting ISO-8601 or a relative now-30h / now-2d.

the Diff screen — path-changed headline and a per-hop diff table

The headline says path unchanged or path changed at a glance; the table then breaks it down per step. Each hop is coloured by status — unchanged, changed (the port or device differs between t1 and t2), added (this hop only exists at t2), or removed (it was there at t1 and is gone). The before and after columns carry the hop as it stood at each time, so a shifted egress port or a re-homed uplink is right there in the row. The capture above is a path that came into existence between the two times — every hop added, because at t1 the source hadn’t appeared yet (no_ingress) and by t2 the full three-hop path reaches. A moved endpoint instead produces a changed row with both sides populated. This is the l2trace trace-diff CLI query with a form on top.

Where Diff targets one path, Changes is the fleet-wide review: pick a window (24h / 7d / 30d) and see everything the bitemporal log says happened in it, in four sections.

the Changes screen — Moved, New devices, Removed, and a flagged IP takeover

  • Moved — a MAC seen on a different (switch, port) than immediately before. A frame learned across the fabric’s trunks in one sweep registers a move on each hop it propagates through, so real churn on a busy fabric fills this table quickly.
  • New devices — MACs whose earliest-ever sighting lands in the window: something new plugged in.
  • Removed — MACs whose last sighting closed in the window with no open successor: something unplugged or decommissioned.
  • IP rebindings — an IP that swapped the MAC bound to it. Most are benign DHCP churn, but the one flagged vendor changed (red, pulled to the top) is the tell that matters: a DHCP renewal keeps the same MAC and vendor, so a different-vendor MAC claiming an address is what an ARP-spoof or takeover looks like. In the capture, 10.10.1.99 jumped from a Samsung MAC to an Apple one.

Every count is drawn from current-belief rows, so a correction that arrived late reads as the corrected reality, not the original mistake.

A force-directed map of devices (nodes) and resolved LLDP/CDP adjacencies (edges). Node shape and color encode kind; a double border marks an MLAG pair.

the Topology graph — sw-access-1/2 + sw-core-1/2 with adjacency edges

Endpoints (phones, APs, printers, servers) are hidden by default so the fabric reads clearly — the per-category toggles in the legend reveal them on demand, each refetching just its slice. Search a hostname, or click any node for a detail card: type, role, vendor, link count, instrumentation visibility, and MLAG group.

Topology with the sw-core-1 detail card open — Arista switch, 3 links, MLAG group 1

Only adjacencies whose peer port resolved become edges. Neighbors the graph can’t place yet don’t vanish silently — they surface on the Dangling screen.

Discovered — what’s plugged into which port

Section titled “Discovered — what’s plugged into which port”

Every endpoint forwarding on an access port right now, joined across CAM, ARP, the IEEE OUI registry, and vSphere placement. Uplinks and infrastructure are excluded, so this is the direct answer to “what is plugged into which port?”

the Discovered screen — endpoints grouped by switch, with VM badges, vendor, MAC, IP, shared-port counts

Grouped by switch, sorted by natural port order (Gi1/0/2 before Gi1/0/10). VMs get a badge; a ×N in the on port column flags a shared/trunk port carrying more than one MAC. This is the same feed the Nautobot SSoT app consumes to propose device shells — here it just answers the operator question directly.

Correlation — VM ↔ physical switchport

Section titled “Correlation — VM ↔ physical switchport”

The join vCenter can’t give you alone: a vSphere VM MAC that also shows up in an SNMP CAM table. Each row is one cross-confirmed MAC — the VM side (name, ESXi host, dvPortgroup VLAN) bridged to the physical side (switch, port, CAM-learned VLAN).

the Correlation screen — 3 of 3 VMs correlated, confirmed + investigate

The confidence badge is the point. confirmed means both sides agree. investigate (amber) means the VLANs differ — legitimate on a trunk, where vSphere reports the dvPortgroup VLAN and the switch reports what it learned, but worth a look. stale means one side has aged out. The “discrepancies only” toggle narrows to just the rows worth chasing; click any row to jump to that MAC’s history.

A pivot over every currently-observed MAC. Group by vendor, VLAN, or port role; each row is a proportion bar with the MAC count and the switch/VLAN spread.

the Inventory screen — grouped by vendor, proportion bars

Vendor families get a recognizable icon (printer, camera, phone, server…), but that’s presentation only — the authoritative axis is always the IEEE organization string, never the glyph.

The VLAN pivot lifted into its own screen: one card per VLAN, sortable by size (MAC count) or switch spread (how many switches carry it). A VLAN living on many switches is a broadcast domain worth understanding; one stuck on a single switch might be a leftover.

the VLANs screen — VLAN 10 and VLAN 100 cards with size + spread bars

Search a MAC — or an IP, which resolves to its MAC via ARP/ND and wireless bindings first — to see every port and VLAN it has lived on, newest first.

the History screen — alice-macbook's timeline, current tenancies pulsing, closed historical ranges

Open tenancies pulse and wear a current badge; closed ones show their [first seen → last seen] window. Note the time control here carries only the amber LAST TIME DEPARTED axis — history is inherently a walk through valid time, so the axis you’d scrub is belief: “what did we believe this MAC’s history was, as of last week?” The MLAG-heavy seed shows the same MAC as current on a core trunk and an access edge at once — that’s a frame traversing the fabric, not a contradiction.

Quarantine — events the reconciler refused

Section titled “Quarantine — events the reconciler refused”

The reconciler won’t ingest an event whose timestamps fail a trust check; instead of dropping it, it parks it on a NATS stream for review. This screen peeks that stream without consuming it — nothing here has touched the database. The landing view is one card per device.

the Quarantine landing — one card per device, device-skew vs queue-dwell

Two reasons, color-coded because they point at different culprits: device-skew (amber) means the device’s clock disagrees with the collector’s beyond budget — check NTP on the device. queue-dwell (blue) means the event sat too long between collector publish and reconciler ingest — check the pipeline, not the device. Click a device to drill in; the burst collapses to one row per distinct payload with a repeat count and a plain-English “why it was rejected.”

the Quarantine drill-in — 72 events collapsed to 3 rows, skew delta vs budget

See how to spot disagreements for the device-clock-vs-queue-dwell distinction in depth.

The other side of Topology: LLDP/CDP neighbors visible on the wire that never became a drawable link, split by why.

the Dangling screen — a resolution gap and an unknown Fortinet neighbor

Resolution gaps are neighbors whose advertised sysname matches an inventory hostname that just didn’t link — a naming mismatch to reconcile, not a mystery. Unknown devices aren’t in inventory at all; the one visible here is identified only by its chassis MAC’s OUI (Fortinet, Inc.) and the two core ports that see it — enough of a lead to go register it. Grouping is by neighbor identity, so one device seen from seventeen ports reads as one card with a sightings table, not seventeen cards. Why unresolved rows are excluded from the graph is covered in peer resolution.

The whole thing is mobile-first. The nav rail scrolls horizontally instead of collapsing into a hamburger (every destination stays one thumb-flick away), the trace form stacks, and the hop timeline reflows into a single column.

the Trace screen on a 390px phone viewport — stacked form, single-column hop timeline

The build is identical with or without SSO. When OIDC_ENABLED is set, the server installs an OpenID Connect gate (tested against Authentik) and a signed-in user chip appears top-right; when it isn’t, the app serves an anonymous shim and the chip renders nothing. Front it with your reverse proxy for TLS either way.

Every image on this page is captured by scripts/web_screenshots.py (Playwright) against a seeded stack. With the API and Astro dev server running:

Terminal window
make web-screenshots

It drives each screen, waits for the data to settle on the view’s accessibility landmark (not a blind timer), and writes PNGs into docs-site/public/screenshots/web/. Re-run it after a UI change to keep the docs honest.