tstest/natlab/vmtest: add web UI for watching VM tests live

Add an optional --vmtest-web flag that starts an HTTP server showing a
live dashboard for vmtest runs. The dashboard includes:

- Step progress tracker showing all test phases (compile, image prep,
  QEMU launch, agent connect, tailscale up, test-specific steps)
  with status icons and elapsed times
- Per-VM "virtual monitor" cards showing serial console output
  streamed in realtime via WebSocket
- Per-NIC DHCP status (supporting multi-homed VMs like subnet routers)
- Per-node Tailscale status (hidden for non-tailnet VMs)
- Test status badge (Running/Passed/Failed) with live elapsed timer
- Event log showing all lifecycle events chronologically

Architecture follows the existing util/eventbus HTMX+WebSocket pattern:
the server pushes HTML fragments with hx-swap-oob attributes over a
WebSocket, and HTMX routes them to the correct DOM elements by ID.

Key components:
- vmstatus.go: Step tracker (Begin/End lifecycle), EventBus (pub/sub
  with history for late joiners), VMEvent types, NodeStatus tracking
- web.go: HTTP server, WebSocket handler, template loading, ANSI-to-HTML
  conversion via robert-nix/ansihtml, deterministic port selection
- assets/: HTML templates, CSS, HTMX library (copied from eventbus)
- vnet/vnet.go: DHCP event callback on Server for observing DHCP lifecycle
- qemu.go: Console log file tailing with manual offset-based reading

Usage:
  go test ./tstest/natlab/vmtest/ --run-vm-tests --vmtest-web=:0 -v

When using :0, a deterministic port based on the test name is tried
first so re-runs get the same URL, falling back to OS-assigned on
conflict.

Updates #13038

Change-Id: I45281347b3d7af78ed9f4ff896033984f84dcb4d
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2026-04-11 04:33:48 +00:00
committed by Brad Fitzpatrick
parent 0ac09721df
commit b9eac14ef9
14 changed files with 1322 additions and 39 deletions
+19
View File
@@ -751,6 +751,10 @@ type Server struct {
cloudInitData map[int]*CloudInitData // node num → cloud-init config
fileContents map[string][]byte // filename → file bytes
// onDHCPEvent, if non-nil, is called when DHCP messages are processed.
// Parameters are: source MAC, node number, DHCP message type, assigned IP.
onDHCPEvent func(nodeMAC MAC, nodeNum int, msgType layers.DHCPMsgType, assignedIP netip.Addr)
}
func (s *Server) logf(format string, args ...any) {
@@ -765,6 +769,13 @@ func (s *Server) SetLoggerForTest(logf func(format string, args ...any)) {
s.optLogf = logf
}
// SetDHCPCallback registers a function to be called when DHCP messages are
// processed. The callback receives the source MAC, node number, DHCP message
// type (Discover, Offer, Request, Ack), and the assigned IP address.
func (s *Server) SetDHCPCallback(fn func(MAC, int, layers.DHCPMsgType, netip.Addr)) {
s.onDHCPEvent = fn
}
var derpMap = &tailcfg.DERPMap{
Regions: map[int]*tailcfg.DERPRegion{
1: {
@@ -1990,6 +2001,10 @@ func (s *Server) createDHCPResponse(request gopacket.Packet) ([]byte, error) {
Length: 4,
},
)
if s.onDHCPEvent != nil {
s.onDHCPEvent(srcMAC, node.num, layers.DHCPMsgTypeDiscover, clientIP)
s.onDHCPEvent(srcMAC, node.num, layers.DHCPMsgTypeOffer, clientIP)
}
case layers.DHCPMsgTypeRequest:
response.Options = append(response.Options,
layers.DHCPOption{
@@ -2018,6 +2033,10 @@ func (s *Server) createDHCPResponse(request gopacket.Packet) ([]byte, error) {
Length: 4,
},
)
if s.onDHCPEvent != nil {
s.onDHCPEvent(srcMAC, node.num, layers.DHCPMsgTypeRequest, clientIP)
s.onDHCPEvent(srcMAC, node.num, layers.DHCPMsgTypeAck, clientIP)
}
}
eth := &layers.Ethernet{