Serve a local web UI from bangerd

Add a localhost-only web console so VM and image management no longer depends on the CLI for every inspection and lifecycle action.

Wire bangerd up to a configurable web listener, expose dashboard and async image-build state through the daemon, and serve CSRF-protected HTML pages with host-path picking, VM/image detail views, logs, ports, and progress polling for long-running operations.

Keep the browser path aligned with the existing sudo and host-owned artifact model: surface sudo readiness, print the web URL in daemon status, and document the new workflow. Polish the UI with resource usage cards, clearer clickable affordances, cancel paths, confirmation prompts, image-name links, and HTTP port links.

Validation: GOCACHE=/tmp/banger-gocache go test ./...
This commit is contained in:
Thales Maciel 2026-03-21 16:47:47 -03:00
parent 30f0c0b54a
commit 2362d0ae39
No known key found for this signature in database
GPG key ID: 33112E6833C34679
24 changed files with 3308 additions and 52 deletions

View file

@ -0,0 +1,124 @@
{{define "page"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} · banger</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div>
<p class="eyebrow">Local Control Plane</p>
<h1>banger</h1>
</div>
<nav class="nav">
<a href="/" class="{{if eq .Section "dashboard"}}active{{end}}">Dashboard</a>
<a href="/vms" class="{{if eq .Section "vms"}}active{{end}}">VMs</a>
<a href="/images" class="{{if eq .Section "images"}}active{{end}}">Images</a>
</nav>
</header>
{{if not .MutationAllowed}}
<section class="banner warning">
<strong>Mutating actions are paused.</strong>
<span>Run <code>{{.Summary.Sudo.Command}}</code> in a terminal and refresh this page. {{.Summary.Sudo.Error}}</span>
</section>
{{end}}
{{if .Flash}}
<section class="banner {{.Flash.Kind}}">
<span>{{.Flash.Message}}</span>
</section>
{{end}}
<section class="summary-grid">
<article class="summary-card resource-card cpu">
<div class="resource-head">
<h2>vCPU</h2>
<strong class="resource-ratio">{{.Summary.Banger.ConfiguredVCPUCount}} / {{.Summary.Host.CPUCount}}</strong>
</div>
<div class="resource-meter" aria-hidden="true">
<span class="resource-fill" style="width: {{percentOf .Summary.Banger.ConfiguredVCPUCount .Summary.Host.CPUCount}}%;"></span>
</div>
<div class="resource-foot">
<span>{{percentOf .Summary.Banger.ConfiguredVCPUCount .Summary.Host.CPUCount}}% allocated</span>
<span>{{.Summary.Banger.RunningVMCount}} running</span>
</div>
</article>
<article class="summary-card resource-card memory">
<div class="resource-head">
<h2>Memory</h2>
<strong class="resource-ratio">{{formatBytesCompact .Summary.Banger.ConfiguredMemoryBytes}} / {{formatBytesCompact .Summary.Host.TotalMemoryBytes}}</strong>
</div>
<div class="resource-meter" aria-hidden="true">
<span class="resource-fill" style="width: {{percentOf .Summary.Banger.ConfiguredMemoryBytes .Summary.Host.TotalMemoryBytes}}%;"></span>
</div>
<div class="resource-foot">
<span>{{percentOf .Summary.Banger.ConfiguredMemoryBytes .Summary.Host.TotalMemoryBytes}}% allocated</span>
<span>{{formatBytesCompact .Summary.Banger.RunningRSSBytes}} RSS live</span>
</div>
</article>
<article class="summary-card resource-card disk">
<div class="resource-head">
<h2>Disk</h2>
<strong class="resource-ratio">{{formatBytesCompact .Summary.Banger.ConfiguredDiskBytes}} / {{formatBytesCompact .Summary.Host.StateFilesystemTotalBytes}}</strong>
</div>
<div class="resource-meter" aria-hidden="true">
<span class="resource-fill" style="width: {{percentOf .Summary.Banger.ConfiguredDiskBytes .Summary.Host.StateFilesystemTotalBytes}}%;"></span>
</div>
<div class="resource-foot">
<span>{{formatBytesCompact .Summary.Host.StateFilesystemFreeBytes}} free</span>
<span>{{formatBytesCompact (sumInt64 .Summary.Banger.UsedSystemOverlayBytes .Summary.Banger.UsedWorkDiskBytes)}} actual</span>
</div>
</article>
</section>
<div class="summary-notes">
<span>{{.Summary.Banger.RunningVMCount}} / {{.Summary.Banger.VMCount}} running</span>
<span>{{.Summary.Banger.ImageCount}} images</span>
<span>{{.Summary.Banger.ManagedImageCount}} managed</span>
<span>{{formatPercent .Summary.Banger.RunningCPUPercent}} live CPU</span>
</div>
<main class="content-panel">
<div class="panel-head">
<div><h2>{{.Title}}</h2></div>
</div>
{{.BodyHTML}}
</main>
</div>
<dialog class="picker-dialog" id="path-picker">
<form method="dialog" class="picker-shell">
<div class="picker-sidebar">
<h3>Roots</h3>
<div class="picker-roots">
{{range .PickerRoots}}
<button type="button" class="picker-root" data-picker-root="{{.Path}}">{{.Label}}</button>
{{end}}
</div>
</div>
<div class="picker-main">
<div class="picker-bar">
<strong id="picker-current-path">/</strong>
<div class="picker-actions">
<button type="button" id="picker-select-current" class="secondary">Use current folder</button>
<button type="button" id="picker-close" class="secondary">Close</button>
</div>
</div>
<p class="picker-help">Choose a host path. Directories open in place; files select immediately.</p>
<div class="picker-list" id="picker-list"></div>
</div>
</form>
</dialog>
<script src="/static/app.js"></script>
</body>
</html>
{{end}}
{{define "csrf_field"}}
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
{{end}}

View file

@ -0,0 +1,65 @@
{{define "dashboard_content"}}
<section class="split-grid">
<div>
<div class="section-head">
<h3>Virtual Machines</h3>
<a class="button" href="/vms/new">Create VM</a>
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>State</th>
<th>IP</th>
<th>Spec</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{range .VMs}}
<tr>
<td><a class="table-link" href="/vms/{{.ID}}">{{.Name}}</a></td>
<td><span class="state-pill {{stateClass .State}}">{{.State}}</span></td>
<td>{{if .Runtime.GuestIP}}{{.Runtime.GuestIP}}{{else}}-{{end}}</td>
<td>{{.Spec.VCPUCount}} vCPU / {{.Spec.MemoryMiB}} MiB / {{formatBytes .Spec.WorkDiskSizeBytes}}</td>
<td>{{relativeTime .CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">No VMs yet.</td></tr>
{{end}}
</tbody>
</table>
</div>
<div>
<div class="section-head">
<h3>Images</h3>
<div class="stack-inline">
<a class="button secondary" href="/images/register">Register</a>
<a class="button" href="/images/build">Build</a>
</div>
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Managed</th>
<th>Rootfs</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{range .Images}}
<tr>
<td><a class="table-link" href="/images/{{.ID}}">{{.Name}}</a></td>
<td>{{formatBool .Managed}}</td>
<td><code>{{.RootfsPath}}</code></td>
<td>{{relativeTime .CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="4" class="muted">No images registered.</td></tr>
{{end}}
</tbody>
</table>
</div>
</section>
{{end}}

View file

@ -0,0 +1,3 @@
{{define "error_content"}}
<div class="inline-error">{{.ErrorMessage}}</div>
{{end}}

View file

@ -0,0 +1,182 @@
{{define "image_list_content"}}
<div class="section-head">
<p class="muted">Manage registered rootfs/kernel stacks and promote unmanaged experiments into daemon-owned artifacts.</p>
<div class="stack-inline">
<a class="button secondary" href="/images/register">Register Image</a>
<a class="button" href="/images/build">Build Image</a>
</div>
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Managed</th>
<th>Docker</th>
<th>Rootfs</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{range .Images}}
<tr>
<td><a class="table-link" href="/images/{{.ID}}">{{.Name}}</a></td>
<td>{{formatBool .Managed}}</td>
<td>{{formatBool .Docker}}</td>
<td><code>{{.RootfsPath}}</code></td>
<td>{{relativeTime .CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="5" class="muted">No images registered.</td></tr>
{{end}}
</tbody>
</table>
{{end}}
{{define "image_build_content"}}
<p class="muted">Build a managed image from a base rootfs, then redirect into the async build progress view.</p>
{{if .ErrorMessage}}
<div class="inline-error">{{.ErrorMessage}}</div>
{{end}}
<form method="post" action="/images/build" class="form-grid">
{{template "csrf_field" .}}
<label><span>Name</span><input type="text" name="name" value="{{.ImageBuildForm.Name}}" placeholder="generated when empty"></label>
<label class="picker-field">
<span>Base Rootfs</span>
<div class="picker-input">
<input type="text" name="base_rootfs" value="{{.ImageBuildForm.BaseRootfs}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="base_rootfs" data-picker-kind="file">Browse</button>
</div>
</label>
<label><span>Size Override</span><input type="text" name="size" value="{{.ImageBuildForm.Size}}" placeholder="optional"></label>
<label class="picker-field">
<span>Kernel Path</span>
<div class="picker-input">
<input type="text" name="kernel_path" value="{{.ImageBuildForm.KernelPath}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="kernel_path" data-picker-kind="file">Browse</button>
</div>
</label>
<label class="picker-field">
<span>Initrd Path</span>
<div class="picker-input">
<input type="text" name="initrd_path" value="{{.ImageBuildForm.InitrdPath}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="initrd_path" data-picker-kind="file">Browse</button>
</div>
</label>
<label class="picker-field">
<span>Modules Directory</span>
<div class="picker-input">
<input type="text" name="modules_dir" value="{{.ImageBuildForm.ModulesDir}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="modules_dir" data-picker-kind="dir">Browse</button>
</div>
</label>
<label class="checkbox">
<input type="checkbox" name="docker" {{if .ImageBuildForm.Docker}}checked{{end}}>
<span>Install Docker</span>
</label>
<div class="form-actions">
<a class="button secondary" href="/images">Cancel</a>
<button class="button" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Build Image</button>
</div>
</form>
{{end}}
{{define "image_register_content"}}
<p class="muted">Register an existing host-side image stack. Paths stay on the host; nothing is uploaded through the browser.</p>
{{if .ErrorMessage}}
<div class="inline-error">{{.ErrorMessage}}</div>
{{end}}
<form method="post" action="/images/register" class="form-grid">
{{template "csrf_field" .}}
<label><span>Name</span><input type="text" name="name" value="{{.ImageRegisterForm.Name}}"></label>
<label class="picker-field">
<span>Rootfs Path</span>
<div class="picker-input">
<input type="text" name="rootfs_path" value="{{.ImageRegisterForm.RootfsPath}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="rootfs_path" data-picker-kind="file">Browse</button>
</div>
</label>
<label class="picker-field">
<span>Work Seed Path</span>
<div class="picker-input">
<input type="text" name="work_seed_path" value="{{.ImageRegisterForm.WorkSeedPath}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="work_seed_path" data-picker-kind="file">Browse</button>
</div>
</label>
<label class="picker-field">
<span>Kernel Path</span>
<div class="picker-input">
<input type="text" name="kernel_path" value="{{.ImageRegisterForm.KernelPath}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="kernel_path" data-picker-kind="file">Browse</button>
</div>
</label>
<label class="picker-field">
<span>Initrd Path</span>
<div class="picker-input">
<input type="text" name="initrd_path" value="{{.ImageRegisterForm.InitrdPath}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="initrd_path" data-picker-kind="file">Browse</button>
</div>
</label>
<label class="picker-field">
<span>Modules Directory</span>
<div class="picker-input">
<input type="text" name="modules_dir" value="{{.ImageRegisterForm.ModulesDir}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="modules_dir" data-picker-kind="dir">Browse</button>
</div>
</label>
<label class="picker-field">
<span>Packages Manifest</span>
<div class="picker-input">
<input type="text" name="packages_path" value="{{.ImageRegisterForm.PackagesPath}}" data-picker-input>
<button type="button" class="button secondary" data-picker-target="packages_path" data-picker-kind="file">Browse</button>
</div>
</label>
<label class="checkbox">
<input type="checkbox" name="docker" {{if .ImageRegisterForm.Docker}}checked{{end}}>
<span>Mark image as Docker-ready</span>
</label>
<div class="form-actions">
<a class="button secondary" href="/images">Cancel</a>
<button class="button" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Register Image</button>
</div>
</form>
{{end}}
{{define "image_show_content"}}
<section class="detail-grid">
<article class="detail-card">
<h2>{{.Image.Name}}</h2>
<dl>
<dt>ID</dt><dd><code>{{.Image.ID}}</code></dd>
<dt>Managed</dt><dd>{{formatBool .Image.Managed}}</dd>
<dt>Docker</dt><dd>{{formatBool .Image.Docker}}</dd>
<dt>Used By</dt><dd>{{.ImageUsers}} VM(s)</dd>
</dl>
</article>
<article class="detail-card">
<h2>Artifacts</h2>
<dl>
<dt>Rootfs</dt><dd><code>{{.Image.RootfsPath}}</code></dd>
<dt>Work Seed</dt><dd>{{if .Image.WorkSeedPath}}<code>{{.Image.WorkSeedPath}}</code>{{else}}-{{end}}</dd>
<dt>Kernel</dt><dd><code>{{.Image.KernelPath}}</code></dd>
<dt>Initrd</dt><dd>{{if .Image.InitrdPath}}<code>{{.Image.InitrdPath}}</code>{{else}}-{{end}}</dd>
<dt>Modules</dt><dd>{{if .Image.ModulesDir}}<code>{{.Image.ModulesDir}}</code>{{else}}-{{end}}</dd>
</dl>
</article>
<article class="detail-card">
<h2>Lifecycle</h2>
<dl>
<dt>Created</dt><dd>{{relativeTime .Image.CreatedAt}}</dd>
<dt>Updated</dt><dd>{{relativeTime .Image.UpdatedAt}}</dd>
<dt>Packages</dt><dd>{{if .Image.PackagesPath}}<code>{{.Image.PackagesPath}}</code>{{else}}-{{end}}</dd>
<dt>Artifact Dir</dt><dd>{{if .Image.ArtifactDir}}<code>{{.Image.ArtifactDir}}</code>{{else}}-{{end}}</dd>
</dl>
</article>
</section>
<div class="stack-inline">
{{if not .Image.Managed}}
<form method="post" action="/images/{{.Image.ID}}/promote">{{template "csrf_field" .}}<button class="button" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Promote to Managed</button></form>
{{end}}
<form method="post" action="/images/{{.Image.ID}}/delete" data-confirm="Delete image {{.Image.Name}}?">{{template "csrf_field" .}}<button class="button danger" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Delete Image</button></form>
</div>
{{end}}

View file

@ -0,0 +1,20 @@
{{define "operation_content"}}
<section class="operation-card" data-operation-url="{{.OperationStatusURL}}" {{if .OperationSuccessURL}}data-operation-success="{{.OperationSuccessURL}}"{{end}}>
<h2>{{if eq .OperationKind "vm"}}VM readiness{{else}}Managed image build{{end}}</h2>
{{if .VMCreateOperation}}
<h3 id="operation-stage">{{.VMCreateOperation.Stage}}</h3>
<p id="operation-detail">{{.VMCreateOperation.Detail}}</p>
<p class="muted" id="operation-error">{{.VMCreateOperation.Error}}</p>
{{end}}
{{if .ImageBuildOperation}}
<h3 id="operation-stage">{{.ImageBuildOperation.Stage}}</h3>
<p id="operation-detail">{{.ImageBuildOperation.Detail}}</p>
<p class="muted" id="operation-error">{{.ImageBuildOperation.Error}}</p>
{{end}}
{{if .OperationLogPath}}
<p class="muted">Build log: <code id="operation-log">{{.OperationLogPath}}</code></p>
{{else}}
<p class="muted" id="operation-log"></p>
{{end}}
</section>
{{end}}

View file

@ -0,0 +1,191 @@
{{define "vm_list_content"}}
<div class="section-head">
<p class="muted">Inspect lifecycle, capacity, and reachability for every VM.</p>
<a class="button" href="/vms/new">Create VM</a>
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>State</th>
<th>Image</th>
<th>IP</th>
<th>vCPU</th>
<th>Memory</th>
<th>Disk</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{range .VMs}}
<tr>
<td><a class="table-link" href="/vms/{{.ID}}">{{.Name}}</a></td>
<td><span class="state-pill {{stateClass .State}}">{{.State}}</span></td>
<td>{{$image := findImage $.Images .ImageID}}{{if $image.ID}}<a class="table-link" href="/images/{{$image.ID}}">{{$image.Name}}</a>{{else}}<code>{{shortID .ImageID}}</code>{{end}}</td>
<td>{{if .Runtime.GuestIP}}{{.Runtime.GuestIP}}{{else}}-{{end}}</td>
<td>{{.Spec.VCPUCount}}</td>
<td>{{.Spec.MemoryMiB}} MiB</td>
<td>{{formatBytes .Spec.WorkDiskSizeBytes}}</td>
<td>{{relativeTime .CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="8" class="muted">No VMs registered.</td></tr>
{{end}}
</tbody>
</table>
{{end}}
{{define "vm_new_content"}}
<p class="muted">Create a VM and wait until the guest is fully ready. The browser will follow live create progress automatically.</p>
{{if .ErrorMessage}}
<div class="inline-error">{{.ErrorMessage}}</div>
{{end}}
<form method="post" action="/vms" class="form-grid">
{{template "csrf_field" .}}
<label>
<span>Name</span>
<input type="text" name="name" value="{{.VMCreateForm.Name}}" placeholder="generated when empty">
</label>
<label>
<span>Image</span>
<select name="image_name">
<option value="">Default image</option>
{{range .Images}}
<option value="{{.Name}}" {{if eq $.VMCreateForm.ImageName .Name}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</label>
<label>
<span>vCPU</span>
<input type="number" name="vcpu" min="1" value="{{.VMCreateForm.VCPU}}">
</label>
<label>
<span>Memory (MiB)</span>
<input type="number" name="memory" min="128" value="{{.VMCreateForm.Memory}}">
</label>
<label>
<span>System Overlay Size</span>
<input type="text" name="system_overlay_size" value="{{.VMCreateForm.SystemOverlaySize}}">
</label>
<label>
<span>Work Disk Size</span>
<input type="text" name="work_disk_size" value="{{.VMCreateForm.WorkDiskSize}}">
</label>
<label class="checkbox">
<input type="checkbox" name="nat_enabled" {{if .VMCreateForm.NATEnabled}}checked{{end}}>
<span>Enable NAT</span>
</label>
<label class="checkbox">
<input type="checkbox" name="no_start" {{if .VMCreateForm.NoStart}}checked{{end}}>
<span>Create without starting</span>
</label>
<div class="form-actions">
<a class="button secondary" href="/vms">Cancel</a>
<button class="button" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Create VM</button>
</div>
</form>
{{end}}
{{define "vm_show_content"}}
<section class="detail-grid">
<article class="detail-card">
<h2>{{.VM.Name}}</h2>
<dl>
<dt>ID</dt><dd><code>{{.VM.ID}}</code></dd>
<dt>Image</dt><dd>{{if .VMImage.ID}}<a class="table-link" href="/images/{{.VMImage.ID}}">{{.VMImage.Name}}</a>{{else}}<code>{{shortID .VM.ImageID}}</code>{{end}}</dd>
<dt>State</dt><dd><span class="state-pill {{stateClass .VM.State}}">{{.VM.State}}</span></dd>
<dt>Guest IP</dt><dd>{{if .VM.Runtime.GuestIP}}{{.VM.Runtime.GuestIP}}{{else}}-{{end}}</dd>
<dt>Created</dt><dd>{{relativeTime .VM.CreatedAt}}</dd>
</dl>
</article>
<article class="detail-card">
<h2>Configured Spec</h2>
<dl>
<dt>vCPU</dt><dd>{{.VM.Spec.VCPUCount}}</dd>
<dt>Memory</dt><dd>{{.VM.Spec.MemoryMiB}} MiB</dd>
<dt>Disk</dt><dd>{{formatBytes .VM.Spec.WorkDiskSizeBytes}}</dd>
<dt>NAT</dt><dd>{{formatBool .VM.Spec.NATEnabled}}</dd>
</dl>
</article>
<article class="detail-card">
<h2>Current Usage</h2>
<dl>
<dt>CPU</dt><dd>{{formatPercent .VMStats.CPUPercent}}</dd>
<dt>RSS</dt><dd>{{formatBytes .VMStats.RSSBytes}}</dd>
<dt>Overlay</dt><dd>{{formatBytes .VMStats.SystemOverlayBytes}}</dd>
<dt>Work Disk</dt><dd>{{formatBytes .VMStats.WorkDiskBytes}}</dd>
</dl>
</article>
</section>
<div class="section-head">
<h3>Actions</h3>
<a class="button secondary" href="/vms/{{.VM.ID}}/logs">Logs</a>
</div>
<div class="stack-inline">
{{if eq .VM.State "running"}}
<form method="post" action="/vms/{{.VM.ID}}/stop" data-confirm="Stop VM {{.VM.Name}}?">{{template "csrf_field" .}}<button class="button" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Stop</button></form>
<form method="post" action="/vms/{{.VM.ID}}/restart">{{template "csrf_field" .}}<button class="button secondary" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Restart</button></form>
{{else}}
<form method="post" action="/vms/{{.VM.ID}}/start">{{template "csrf_field" .}}<button class="button" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Start</button></form>
{{end}}
<form method="post" action="/vms/{{.VM.ID}}/delete" data-confirm="Delete VM {{.VM.Name}}?">{{template "csrf_field" .}}<button class="button danger" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Delete</button></form>
</div>
<section class="split-grid">
<div>
<div class="section-head"><h3>Listening Ports</h3></div>
{{if .VMPortsError}}
<p class="inline-error">{{.VMPortsError}}</p>
{{else}}
<table>
<thead>
<tr><th>Port</th><th>Process</th><th>Endpoint</th></tr>
</thead>
<tbody>
{{range .VMPorts.Ports}}
<tr>
<td>{{.Proto}}/{{.Port}}</td>
<td>{{if .Process}}{{.Process}}{{else}}-{{end}}</td>
<td>{{if .Endpoint}}{{if endpointHref .Endpoint}}<a class="table-link" href="{{endpointHref .Endpoint}}" target="_blank" rel="noreferrer">{{.Endpoint}}</a>{{else}}<code>{{.Endpoint}}</code>{{end}}{{else}}-{{end}}</td>
</tr>
{{else}}
<tr><td colspan="3" class="muted">No host-reachable listeners reported.</td></tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
<div>
<div class="section-head"><h3>Update Settings</h3></div>
<form method="post" action="/vms/{{.VM.ID}}/set" class="form-grid compact">
{{template "csrf_field" .}}
<label><span>vCPU</span><input type="number" name="vcpu" min="1" value="{{.VMSetForm.VCPU}}"></label>
<label><span>Memory (MiB)</span><input type="number" name="memory" min="128" value="{{.VMSetForm.Memory}}"></label>
<label><span>Work Disk Size</span><input type="text" name="work_disk_size" value="{{.VMSetForm.WorkDiskSize}}"></label>
<label>
<span>NAT</span>
<select name="nat_enabled">
<option value="true" {{if .VMSetForm.NATEnabled}}selected{{end}}>Enabled</option>
<option value="false" {{if not .VMSetForm.NATEnabled}}selected{{end}}>Disabled</option>
</select>
</label>
<div class="form-actions">
<a class="button secondary" href="/vms/{{.VM.ID}}">Cancel</a>
<button class="button" type="submit" {{if not .MutationAllowed}}disabled{{end}}>Save Settings</button>
</div>
</form>
</div>
</section>
{{end}}
{{define "vm_logs_content"}}
<div class="section-head">
<p class="muted">Showing the last 200 lines from the Firecracker log.</p>
<div class="stack-inline">
<label class="checkbox inline"><input type="checkbox" id="log-auto-refresh"><span>Auto refresh</span></label>
<a class="button secondary" href="/vms/{{.VM.ID}}/logs">Refresh</a>
</div>
</div>
<pre class="log-output">{{.LogText}}</pre>
{{end}}