Skip to content

Web UI: management control plane, config editor, diagnostics, service control, and live stats#15

Merged
pgodwin merged 24 commits into
mainfrom
feat-webui
Jun 7, 2026
Merged

Web UI: management control plane, config editor, diagnostics, service control, and live stats#15
pgodwin merged 24 commits into
mainfrom
feat-webui

Conversation

@pgodwin

@pgodwin pgodwin commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a complete management plane and web UI for ClassicStack, plus the supporting runtime refactors (a Supervisor that owns the stack, a dynamic router, and daemon/Windows-service wrappers). This is the full feat-webui line: 16 commits from the initial control-plane scaffold through live per-port statistics.

What's included

Runtime / control plane

  • pkg/control — transport-agnostic management API (status, config stage/apply/save, per-service start/stop/restart, diagnostics) shared by every front-end.
  • internal/app.Supervisor — owns ports, the router (+ DDP service set), and the standalone hooks; exposes dependency-aware lifecycle control. main.go is reduced to flags + config + handoff.
  • router/ — runtime add/remove of ports and services so the UI can toggle a transport or service without a full restart.
  • pkg/status, pkg/metrics, pkg/logbuf, pkg/serialport — status registry, streaming-stats hub, log ring buffer, serial-port enumeration.

Web UI (service/webui, -tags webui)

  • HTTPS/JSON adapter over pkg/control with an embedded dependency-free vanilla-JS SPA (no build step).
  • Dashboard with per-unit status; per-service start/stop/restart for transport hooks (IPX/NetBEUI/NetBIOS/SMB) and the AppleTalk DDP subsystems (AFP/MacIP/IPXGW).
  • Config editor: scalar fields, AFP volumes / SMB shares, packet-dump + pcap capture, Bridge virtual interface, remaining MacIP options, MacIPX/IPXGW gateway, and the AFP extension-map (type/creator) editor.
  • Diagnostics (zones, ZIP/DDP enumerate, SMB browse, AEP echo, MacIP leases), live log viewer (SSE) with download, and live per-port rx/tx throughput on the dashboard for EtherTalk/LToUDP/TashTalk/IPX/NetBEUI.

Daemon / service

  • cmd/classicstack-svc (Windows service) and cmd/classicstackd (Unix/macOS daemon, macOS LaunchAgent) sharing the internal/app run-core.

Known follow-ups (not in this PR)

  • AFP/SMB active-connection gauges (needs session-table accessors).
  • IPX/NetBEUI already metered; AFP/SMB throughput gauge is the only remaining stats item.
  • NBT (:139) / SMB-Direct (:445) TCP listeners remain unimplemented.
  • Standalone diagnostic CLI tools and a macOS menu-bar app.

Testing

  • go build -tags all ./..., no-tags, and -tags webui all green.
  • go test -tags all ./... passes; new unit tests cover the DDP service hooks, per-port meters, transport bindings, extension-map round-trip, and control-plane delegates.
  • go vet -tags all ./... and gofmt clean; node --check on the SPA.

🤖 Generated with Claude Code

pgodwin and others added 24 commits June 4, 2026 15:16
webui: scaffold management control plane, config model, and web UI adapter

Introduce the foundation for an operator web UI gated behind a new `webui`
build tag (joined into `all`):

- config.Model: an in-memory, serialisable representation of the whole
  server.toml, with deep Clone for staged edits, ToTOML marshalling
  (go-toml/v2, now a direct dep), FromSource koanf loading, Defaults, and
  Save with numbered backups (server.toml.NNNN) + atomic write.
- pkg/status: concurrency-safe per-unit status registry (enabled/running,
  binding, hostnames/zones/shares, dependency edges) for the dashboard.
- pkg/metrics: streaming stats hub with fan-out sinks; expvar sink keeps
  counters visible. Per-second rate broadcasting lives in the control plane.
- pkg/control: transport-agnostic management API (status, config
  stage/apply/save/export with a dirty flag, service restart, interface and
  serial-port enumeration, SSE-style Subscribe, diagnostics facade). This is
  the single implementation every UI front-end shares, so a future
  text/telnet UI reuses it without HTTP.
- pkg/serialport: per-OS serial-port enumeration (Windows SERIALCOMM
  registry; Unix /dev/tty* globs) for the TashTalk dropdown.
- service/webui (//go:build webui || all): thin HTTPS adapter over
  pkg/control — JSON API, SSE stats stream, embedded vanilla-JS SPA
  (dashboard + config editor + diagnostics), and TLS with an in-memory
  self-signed certificate fallback.
- [WebUI] config section + -webui-* flags (enabled, bind, tls, cert/key).

The dynamic router, supervisor, and main.go wiring land in follow-up
commits; the web UI hook is not yet driven from main. Both the no-tags
(router) and -tags all variants build; config and control plane have unit
tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
router: support dynamic add/remove of ports and services at runtime

Make the router membership (ports, services, socket dispatch map)
mutable while running so the management plane can enable or disable a
transport or service without restarting the process.

- Guard Ports/Services/servicesBySAS with an RWMutex; the receive path
  (deliver) and lifecycle iteration (Start/Stop) take it for reading,
  mutators for writing, so dispatch never sees a half-updated map.
- AddService/RemoveService start/stop the service and register/unregister
  its socket; socket registration is extracted from New into
  registerServiceSocket/unregisterServiceSocket.
- AddPort/RemovePort start/stop the port and bind the LLAP link manager;
  bindLLAPManager is refactored into a per-port bindPortLLAP helper.
- RemovePort performs real route reconciliation via
  RoutingTable.RemoveEntriesForPort, withdrawing every route reachable
  through the port and dropping its zone associations (mirrors the cleanup
  SetPortRange/Age already do), so disabling e.g. LToUDP drops its
  networks instead of leaving stale routes.

Race-tested: concurrent deliver during AddService/RemoveService churn,
plus add/remove bookkeeping and route withdrawal on port removal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
@
cmd: extract Supervisor; reduce main.go to flags + config + run

main.go is now a thin entrypoint: parse flags / load TOML, project the
resolved config into a config.Model, build the Supervisor, construct the
management plane, wire the web UI, run, and tear down. All component
construction and lifecycle moved into the Supervisor.

- Supervisor owns ports, the AppleTalk router (and its DDP service set),
  and the standalone hooks (IPX/NetBEUI/NetBIOS/SMB/WebUI). It registers
  each as a named status unit and drives Start/Stop with the existing
  ordering (transports before the layers that consume them).
- Per-service lifecycle from the UI: StartService/StopService/
  RestartService act on a named hook and honour declared dependencies
  (stopping NetBIOS stops SMB; restarting brings dependents back around
  it).
- Control-plane integration: Apply performs an atomic whole-stack rebuild
  from the edited model (finer-grained application can layer on later via
  the dynamic-router primitives); RestartService and ListInterfaces
  satisfy control.Supervisor. config.Save is installed as the planes
control: add per-service start/stop from the UI

Expose StartService/StopService alongside RestartService on the control
plane and the supervisor interface, add /api/services/{name}/{start,stop,
restart} endpoints, and render Start/Stop/Restart buttons on each
controllable (hook) card in the dashboard.

Stops are dependency-aware: stopping NetBIOS cascades to SMB. Verified
end-to-end against a running binary — SMB stop/start toggles its running
state and stopping NetBIOS brings SMB down with it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
docs: document the web UI; add [WebUI] example and CI tag coverage

- server.toml.example: commented [WebUI] stanza (enabled/bind/tls/cert/key).
- scripts/ci/test.{sh,ps1}: add the webui tag set to the test matrix.
- README: a Web UI section covering config keys, flags, live start/stop, and
  the stage/apply/save/download flow.
- CLAUDE.md: package table entries for service/webui, pkg/control, pkg/status,
  pkg/metrics, pkg/serialport, config; note that the Supervisor owns the
  runtime and main.go is just flags + config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
webui: edit AFP volumes and SMB shares; source them from the config model

The config editor gains table editors to add, update, and remove AFP
volumes and SMB shares. They mutate the keyed maps in the config model
(AFP.Volumes / SMB.Volumes); the supervisor now builds AFP and SMB from the
editable model rather than re-reading the TOML file, so edits take effect on
Apply (the AFP config is self-contained, including its volumes).

- AFPFlagInputs.VolumeModels carries structured volumes from the model;
  applyAFPFlagsToConfig prefers them over the raw -afp-volume flag strings.
  smbSharesFromModel builds SMB shares from the model. The supervisor passes
  FromConfig:false for AFP and uses the model for SMB shares.
- AFP/SMB status units now report their advertised name(s) and share list.
- config.Model fields gain json tags mirroring their toml tags, so the API
  JSON keys match the lowercase TOML names the SPA editor binds to. This
  also fixes the scalar config editor, whose field paths previously did not
  match the PascalCase Go-default JSON.
- Apply preserves the running Web UI server across the atomic rebuild: it is
  detached before the stack stops and re-attached (already running) to the
  rebuilt stack, so the in-flight Apply request and the operator connection
  survive. Documented the remaining TIME_WAIT rebind limitation of the
  atomic rebuild for fixed-port services (AFP/DSI, SMB).

Verified end-to-end: adding an AFP volume via the API and applying rebuilds
AFP with the new volume (CNID db opened at the new path, volumes=1), the Web
UI stays up across Apply, and the volume appears in status and the
downloaded TOML.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
webui: add packet-dump and pcap capture options to the config editor

The config editor gains a Packet Dump & Capture panel: parse-packets,
log-traffic, parse-output file, and per-transport pcap capture paths
(localtalk/ethertalk/ipx/netbeui) plus snap length. These already live in the
config model, so editing and Apply rebuilds the stack with the new settings.

- Traffic logging (LogTraffic) is now wired by the Supervisor from config
  instead of once in main, so toggling it from the UI takes effect on Apply
  (disabling clears the netlog sink).
- The Router status unit reports parse_packets / log_traffic state and a
  summary of which transports have an active capture.

Verified end-to-end: toggling parse_packets on and adding an ethertalk
capture path via the API + Apply flips the Router status and attaches the
capture sink; the web UI stays up across Apply.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
Add a Logs tab to the management UI that replays recent log history on
open then streams new lines live, with a client-side level filter.

- pkg/logbuf: in-memory ring buffer that is both a slog.Handler and a
  fan-out broadcaster (mirrors the stats broadcaster); untagged so a
  future TUI can read logs too.
- pkg/logging: Options.Extra appends extra slog.Handlers to the fanout,
  letting the root logger tee records into the ring buffer.
- pkg/control: LogHistory()/SubscribeLogs() on the plane, Logs dep
  (defaults to logbuf.Default).
- service/webui: GET /api/logs (history) and GET /api/logs/stream (SSE),
  reusing the stats-stream SSE pattern; Logs tab in the SPA.
- cmd/classicstack: install logbuf.NewHandler on the root logger.

Builds clean for no-tags / webui / all; go vet -tags all and full test
suite pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…itor

Relocate the run-core from cmd/classicstack (package main) into internal/app
so the interactive binary, the Windows service wrapper (cmd/classicstack-svc),
and the Unix/macOS daemon (cmd/classicstackd) share one runtime.

Make UI-driven stop/start of pcap-backed protocols safe and restartable:
- rawlink: guard pcapLink with a closed flag + ErrClosed so calls after Close
  return an error instead of a use-after-free (0xC0000005) in pcap_compile.
- ports (IPX/NetBEUI/EtherTalk/MacIP): open a fresh link per Start via a
  LinkFactory and recreate the read-loop channels each cycle, fixing the
  "close of closed channel" panic on the second stop/start.
- ipx router: add UnregisterSocket; RIP/SAP/NetBIOS-over-IPX/SMB-direct now
  release their sockets on Stop, fixing "ipx: socket already registered".

Model NetBEUI/IPX as detachable NetBIOS transports rather than hard
dependencies: stopping one detaches just its binding while NetBIOS and SMB
keep serving. Add AddTransport/RemoveTransport/Transports to the NetBIOS
service and a transport-binding registry in the supervisor. Status summaries
are running-aware; remove SMB's phantom :139 (no TCP listener exists) in
favour of the real served transports.

Web UI: spinner + disabled buttons during start/stop/restart. Config editor:
group AFP volumes / SMB shares under their service panels; FS-type and
interface dropdowns; friendly interface labels (pcap description, GUID stored)
via /api/interfaces + /api/fs-types. Treat [Bridge] as a reusable virtual
interface (InterfaceModel) that protocols inherit or override with a per-
protocol [Section.Custom] interface; absent Custom keeps existing configs
working unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface the MacIP gateway's runtime state in the management UI:

Diagnostics: new read-only "MacIP Leases" probe lists current static
and DHCP-relayed leases (IP, AppleTalk net.node, source, last-seen)
via /api/diag/macip-leases. Backed by (*ipPool).snapshot() ->
Service.Leases() -> MacIPHook.Leases() -> routerDiagnostics.MacIPLeases,
returning ErrDiagUnavailable when MacIP is not built/enabled.

Dashboard: the MacIP status unit now reports gateway mode (nat/bridge),
DHCP-relay, zone, and live counts (active leases, pinned ASP sessions)
via Service.GatewayStats() -> MacIPHook.State() -> control.MacIPState.
A 5s status-refresh ticker (runStatusRefresh) re-publishes the live
counts while the stack runs; started in Start when MacIP is enabled,
stopped/nilled in Stop.

Types stay tag-clean: macip shapes live behind //go:build macip while
the hook/control layer uses neutral control.LeaseInfo/MacIPState, so
untagged diagnostics_impl.go/supervisor.go never import service/macip.

Tests: TestIPPoolStatsAndSnapshot covers stats()/snapshot() over mixed
static+dhcp leases and a pinned session; TestDiagnosticsFallback now
asserts MacIPLeases returns ErrDiagUnavailable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Log download: a "Download" button on the Logs tab fetches
/api/logs/download, which serves the retained ring-buffer history as a
plain-text attachment (one entry per line: timestamp LEVEL message,
filename classicstack-<ts>.log). Reuses Plane.LogHistory(); no new
control-plane surface.

Serial dropdown: the Windows enumerator now labels each port with its
COM name first (e.g. "COM3" or "COM3 (\Device\Serial0)") instead of
surfacing the raw driver device path as the option text, and sorts the
list numerically (COM1, COM2, COM10) rather than in registry order. The
stored value remains the bare COM name. Adds TestComNumber.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a raw-text editor for the Netatalk-style extension_map file on the
Configuration tab. The file is edited verbatim (comments and ordering
preserved) rather than parsed into a grid; the server validates it with
the existing parseAFPExtensionMap before writing and reports the
offending line on failure.

Plumbing:
- config.SaveBytes writes an arbitrary text file with the same
  numbered-backup + atomic-rename guarantees as config.Save.
- Supervisor.ReadExtMap/WriteExtMap resolve the configured
  AFP.ExtensionMap path (relative to the config dir, as AFP wiring
  does), read it, and validate+save edits. validateExtMap is behind
  //go:build afp (with a disabled stub) so untagged code stays clean.
- control.Plane.ExtMap/SaveExtMap delegate to the supervisor; webui
  serves them at GET/PUT /api/extmap.

The saved map loads on the next config Apply (WriteExtMap does not
restart AFP). A collapsible "Extension map" section lazy-loads the file
on first expand, with Reload and Save actions.

Tests: TestExtMapDelegates and TestExtMapWithoutSupervisor cover the
plane delegation and the no-supervisor guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MacIP gateway panel gains the previously-unexposed fields: NAT gateway IP,
lease file, nameserver, and BPF filter override, plus inline hints on the
existing zone/subnet/gateway/DHCP-relay fields.

Add an "IPX Gateway (MacIPX)" config panel (enabled toggle + editable
"Object:Zone" zone-binding list) backed by a new "stringlist" field type
and buildStringList() editor, with matching .stringlist CSS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The optional AppleTalk DDP subsystems (AFP, MacIP, the IPX gateway) used to
ride the AppleTalk router's initial service set, so their only lifecycle was
the whole-stack one and the dashboard showed no start/stop/restart controls
(markServiceRunning just force-toggled them with the router).

Wrap each subsystem's DDP services in a ddpServiceHook that drives the
router's runtime AddService/RemoveService primitives, register it as a
standalone hook, and promote its status unit to KindHook so the UI surfaces
lifecycle controls. buildServices now collects these groups instead of
appending them to the router's initial set; buildHooks wraps them once the
router exists (they depend only on the router, which starts first). Start
rolls back partially-added services on failure; Stop removes them in reverse.

Disabled subsystems contribute no services, so they register no hook and
remain uncontrollable, as before. markServiceRunning is removed.

Adds ddp_service_hook_test.go covering start/stop, start-rollback, the
empty-group nil hook, and KindService->KindHook promotion preserving detail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The metrics hub, SSE broadcaster, and expvar sink were all wired but nothing
pushed samples, so the dashboard cards never showed live stats. Add the first
producers: per-port rx/tx packet and byte counters for the AppleTalk ports
(EtherTalk, LToUDP, TashTalk).

Ports report traffic through a new optional port.TrafficMetered interface
(SetTrafficObserver) so no port implementation depends on pkg/metrics — the
data path only calls a plain observer func. The EtherTalk and LocalTalk ports
emit Rx on inbound DDP delivery and Tx in Unicast/Broadcast/Multicast. The
supervisor attaches a portMeter per port (carrying the status-unit name),
accumulates counts, and publishes them once a second from the refresh ticker
under "unit:<Name>:<metric>" so each sample attributes to exactly one card.

The earlier wrapper approach was abandoned: ports self-register into the
router's tables as their raw selves, so a wrapping port would have caught
inbound but missed almost all forwarded tx. The observer hook sits inside the
port where every send actually originates.

SPA: replace the fuzzy substring rate-matching with exact "unit:<Name>:"
prefix lookups, and render a per-card line — "↓ N pkt/s (bytes/s) · ↑ N
pkt/s (bytes/s)" plus any gauge value (e.g. sessions) — hidden until live
data arrives. Gauge frames are now consumed too.

Remaining for full coverage (deferred): IPX/NetBEUI port metering (different
frame type / observer point) and AFP/SMB active-connection gauges (needs
session-table accessors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extend the per-port traffic metering added for the AppleTalk ports to the
standalone IPX and NetBEUI ports so their dashboard cards also show live
rx/tx packet and byte rates.

The IPX/NetBEUI portImpls gain a SetTrafficObserver (an optional method, not
added to their Port interfaces, so the existing test fakes need no stub) and
emit Rx on inbound decode and Tx in Send. Their enabled hooks forward
SetTrafficObserver to the underlying port; the supervisor installs a portMeter
through that optional interface and publishes it on the same one-second ticker
as the AppleTalk ports. Disabled-protocol hooks and ports that do not meter
are skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The PR CI golangci-lint step (run with --build-tags=all on Linux) flagged:

- errcheck: unchecked os.Remove / tmp.Close / key.Close returns in
  config/save.go (atomicWrite) and pkg/serialport/serialport_windows.go.
- errorlint: direct == / != comparisons against sentinel errors in
  pkg/control/control_test.go and serialport_windows.go; switched to
  errors.Is.
- unlambda: redundant func() wrapper around over_tcp.NewTransport (which
  already returns netbios.Transport) in internal/app/netbios_enabled.go.
- ineffassign: dead final n += copy(...) in service/smb/command_fs_search.go.

No behaviour change; all builds and tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The PR CI govulncheck step flagged stdlib CVEs (net/textproto, crypto/x509 at
go1.23.4) and golang.org/x/net@v0.33.0 advisories (GO-2026-5025..5039). Bump
the go directive/toolchain to 1.25.11 and x/net to v0.55.0 (pulling x/crypto,
x/sys, x/sync, x/text forward via go mod tidy). govulncheck now reports no
called vulnerabilities.

The x/net bump updates goquery's transitive x/net/html parser, which changed
how the macgarden client tests' hand-written fixtures parsed and broke the
pagination-count assertions. Replace those fixtures with real HTML captured
from macintoshgarden.org (testdata/category_antivirus_page{1,5}.html) and a
loadCapturedPage helper that reroutes the captured root-relative links to the
test server. The two affected tests now assert the live site's real structure
(10 items/page, last page ?page=5 with 8 items, 58 total).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dings

Add scripts/ci/quality.sh — a single source for the CI Quality job's static
gates (vet + golangci-lint + govulncheck + gosec) that also runs locally via
`make quality`. The PR CI Quality job and the Makefile both call it, so local
development runs the exact same vulnerability scan as CI. govulncheck and gosec
self-install on demand, matching how CI bootstraps them; SKIP_LINT=1 lets CI
skip the script's lint pass since it runs golangci-lint via its own action.
The Makefile gains `quality`/`vet` targets and self-installing `vuln`/`gosec`.

Clear the gosec gate (it never completed before because govulncheck failed
first): exclude G115 (integer-overflow conversions on values already bounded
by the wire formats — pure noise here), and add justified #nosec annotations
to the 11 remaining intentional findings in the macgarden client (SHA-1 cache
filenames, relaxed TLS for the abandonware mirror, world-readable public
caches) and macip (DHCP xid via math/rand, operator-configured lease file).
gosec now reports 0 issues over the scanned packages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add port/router supervisor hooks, routing-table snapshots and dynamic
routing wiring, plus diagnostics and web UI surfaces (API, SSE stats,
SPA assets) to inspect the router state. Excludes screenshot images.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit adds the GNU General Public License version 3 to the project, ensuring that the software can be freely used, modified, and distributed under the terms of the license.
@pgodwin pgodwin merged commit 3e8248a into main Jun 7, 2026
13 checks passed
@pgodwin pgodwin deleted the feat-webui branch June 7, 2026 12:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant