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:
committed by
Brad Fitzpatrick
parent
0ac09721df
commit
b9eac14ef9
@@ -0,0 +1,39 @@
|
||||
{{if eq .Type "test_status"}}
|
||||
<span class="test-status test-{{.Message}}" id="test-status" hx-swap-oob="outerHTML">{{.Message}} ({{.Detail}})</span>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Type "step_changed"}}
|
||||
<div class="step step-{{.Step.Status}}" id="step-{{.Step.Index}}" hx-swap-oob="outerHTML">
|
||||
<span class="step-icon">{{.Step.Status.Icon}}</span>
|
||||
<span class="step-name">{{.Step.Name}}</span>
|
||||
<span class="step-time">{{formatDuration .Step.Elapsed}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Type "console_output"}}
|
||||
<div id="console-{{.NodeName}}" hx-swap-oob="beforeend">{{ansi .Message}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Type "dhcp_discover"}}
|
||||
<span id="dhcp-{{.NodeName}}-{{.NIC}}" hx-swap-oob="innerHTML">Discover sent</span>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Type "dhcp_offer"}}
|
||||
<span id="dhcp-{{.NodeName}}-{{.NIC}}" hx-swap-oob="innerHTML">Offered {{.Detail}}</span>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Type "dhcp_request"}}
|
||||
<span id="dhcp-{{.NodeName}}-{{.NIC}}" hx-swap-oob="innerHTML">Requesting {{.Detail}}</span>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Type "dhcp_ack"}}
|
||||
<span id="dhcp-{{.NodeName}}-{{.NIC}}" hx-swap-oob="innerHTML">Got {{.Detail}}</span>
|
||||
{{end}}
|
||||
|
||||
{{if eq .Type "tailscale"}}
|
||||
<span id="ts-{{.NodeName}}" hx-swap-oob="innerHTML">{{.Detail}}</span>
|
||||
{{end}}
|
||||
|
||||
<div id="events" hx-swap-oob="beforeend"><div class="event event-{{.Type}}"><span class="event-time">{{.Time.Format "15:04:05.000"}}</span> {{if .NodeName}}<span class="event-node">[{{.NodeName}}]</span> {{end}}<span class="event-msg">{{.Message}}</span>{{if .Detail}} <span class="event-detail">{{.Detail}}</span>{{end}}</div>
|
||||
</div>
|
||||
@@ -0,0 +1,111 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,168 @@
|
||||
/* CSS reset */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
* { margin: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
line-height: 1.5;
|
||||
background: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.4em;
|
||||
margin-bottom: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.test-status {
|
||||
font-size: 0.7em;
|
||||
padding: 2px 10px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.test-Running { background: #2563eb; color: #fff; }
|
||||
.test-Passed { background: #16a34a; color: #fff; }
|
||||
.test-Failed { background: #dc2626; color: #fff; }
|
||||
|
||||
h2 {
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 8px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* Step progress panel */
|
||||
.steps {
|
||||
background: #16213e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.step-pending { color: #666; }
|
||||
.step-running { color: #4af; font-weight: bold; background: rgba(68, 170, 255, 0.1); }
|
||||
.step-done { color: #4a4; }
|
||||
.step-failed { color: #f44; font-weight: bold; background: rgba(255, 68, 68, 0.1); }
|
||||
|
||||
.step-icon { width: 1.2em; text-align: center; }
|
||||
.step-name { flex: 1; }
|
||||
.step-time { color: #666; font-size: 12px; min-width: 6em; text-align: right; }
|
||||
|
||||
/* VM card grid */
|
||||
.vm-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vm-card {
|
||||
background: #16213e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.vm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vm-name {
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.vm-os {
|
||||
font-size: 0.8em;
|
||||
background: #333;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.vm-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.vm-status-line {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vm-status-label {
|
||||
color: #888;
|
||||
min-width: 7em;
|
||||
}
|
||||
|
||||
.vm-status-value {
|
||||
color: #4af;
|
||||
}
|
||||
|
||||
/* Console output */
|
||||
.console {
|
||||
background: #0a0a0a;
|
||||
color: #ccc;
|
||||
font-family: "Cascadia Code", "Fira Code", "Consolas", monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #222;
|
||||
}
|
||||
|
||||
/* Event log */
|
||||
.event-log {
|
||||
background: #16213e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.events {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.event {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
padding: 1px 0;
|
||||
border-bottom: 1px solid #1a1a2e;
|
||||
}
|
||||
|
||||
.event-time { color: #666; }
|
||||
.event-node { color: #4af; font-weight: bold; }
|
||||
.event-msg { color: #ccc; }
|
||||
.event-detail { color: #888; }
|
||||
|
||||
.event-dhcp_discover .event-msg,
|
||||
.event-dhcp_request .event-msg { color: #fa4; }
|
||||
.event-dhcp_offer .event-msg,
|
||||
.event-dhcp_ack .event-msg { color: #4f4; }
|
||||
.event-step_changed .event-msg { color: #aaf; }
|
||||
Reference in New Issue
Block a user