Files
tailscale/tstest/natlab/vmtest/assets/index.html
T
Brad Fitzpatrick b9eac14ef9 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>
2026-04-28 07:46:04 -07:00

112 lines
3.9 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>VMTest: {{.TestName}}</title>
<script src="https://unpkg.com/htmx.org@2.0.4"
integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"
crossorigin="anonymous"></script>
<script src="https://unpkg.com/htmx-ext-ws@2.0.2"
integrity="sha384-932iIqjARv+Gy0+r6RTGrfCkCKS5MsF539Iqf6Vt8L4YmbnnWI2DSFoMD90bvXd0"
crossorigin="anonymous"></script>
<link rel="stylesheet" href="style.css">
</head>
<body hx-ext="ws" ws-connect="ws">
<h1>VMTest: {{.TestName}} <span class="test-status test-{{.TestStatus.State}}" id="test-status">{{.TestStatus.State}} ({{formatDuration .TestStatus.Elapsed}})</span></h1>
<div class="steps">
<h2>Progress</h2>
{{range .Steps}}
<div class="step step-{{.Status}}" id="step-{{.Index}}">
<span class="step-icon">{{.Status.Icon}}</span>
<span class="step-name">{{.Name}}</span>
<span class="step-time">{{if ne .Status.String "pending"}}{{formatDuration .Elapsed}}{{end}}</span>
</div>
{{end}}
</div>
<div class="vm-grid">
{{range $node := .Nodes}}
<div class="vm-card" id="vm-{{$node.Name}}">
<div class="vm-header">
<span class="vm-name">{{$node.Name}}</span>
<span class="vm-os">{{$node.OS}}</span>
</div>
<div class="vm-status">
{{range $i, $nic := $node.NICs}}
<div class="vm-status-line">
<span class="vm-status-label">DHCP{{if gt (len $node.NICs) 1}} ({{$nic.NetName}}){{end}}:</span>
<span class="vm-status-value" id="dhcp-{{$node.Name}}-{{$i}}">{{$nic.DHCP}}</span>
</div>
{{end}}
{{if $node.JoinsTailnet}}
<div class="vm-status-line">
<span class="vm-status-label">Tailscale:</span>
<span class="vm-status-value" id="ts-{{$node.Name}}">{{$node.Tailscale}}</span>
</div>
{{end}}
</div>
<div class="console" id="console-{{$node.Name}}">{{range $node.Console}}{{ansi .}}
{{end}}</div>
</div>
{{end}}
</div>
<div class="event-log">
<h2>Events</h2>
<div class="events" id="events"></div>
</div>
<script>
// Tick the elapsed time on the test status badge while the test is running.
(function() {
var startTime = {{.TestStatus.StartUnixMilli}};
var el = document.getElementById("test-status");
var timer = setInterval(function() {
if (!el || !el.classList.contains("test-Running")) {
clearInterval(timer);
return;
}
var elapsed = Date.now() - startTime;
var secs = elapsed / 1000;
var text;
if (secs < 1) {
text = Math.round(elapsed) + "ms";
} else {
text = secs.toFixed(1) + "s";
}
el.textContent = "Running (" + text + ")";
}, 100);
})();
// Auto-scroll console divs to bottom unless user has scrolled up.
// Re-enable auto-scroll when user scrolls back to the bottom.
(function() {
var consoles = document.querySelectorAll(".console");
consoles.forEach(function(el) {
el._autoScroll = true;
el.addEventListener("scroll", function() {
// At bottom if scrollTop + clientHeight >= scrollHeight - small threshold
var atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 5;
el._autoScroll = atBottom;
});
});
// Use MutationObserver to detect when content is added to console divs.
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(m) {
var el = m.target;
if (el.classList && el.classList.contains("console") && el._autoScroll) {
el.scrollTop = el.scrollHeight;
}
});
});
consoles.forEach(function(el) {
observer.observe(el, { childList: true, characterData: true, subtree: true });
});
})();
</script>
</body>
</html>