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,130 @@
(() => {
const operationCard = document.querySelector("[data-operation-url]");
if (operationCard) {
const stageNode = document.getElementById("operation-stage");
const detailNode = document.getElementById("operation-detail");
const errorNode = document.getElementById("operation-error");
const logNode = document.getElementById("operation-log");
const statusUrl = operationCard.dataset.operationUrl;
const successUrl = operationCard.dataset.operationSuccess;
const poll = async () => {
const response = await fetch(statusUrl, { headers: { Accept: "application/json" } });
if (!response.ok) {
return;
}
const payload = await response.json();
const op = payload.operation || {};
if (stageNode) stageNode.textContent = op.stage || "queued";
if (detailNode) detailNode.textContent = op.detail || "";
if (errorNode) errorNode.textContent = op.error || "";
if (logNode && op.build_log_path) logNode.textContent = op.build_log_path;
if (op.done && op.success && successUrl) {
window.location.assign(successUrl);
return;
}
if (!op.done) {
window.setTimeout(poll, 1000);
}
};
window.setTimeout(poll, 800);
}
const copyButtons = document.querySelectorAll("[data-copy-text]");
copyButtons.forEach((button) => {
button.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(button.dataset.copyText || "");
button.textContent = "Copied";
window.setTimeout(() => { button.textContent = "Copy"; }, 1000);
} catch (_) {}
});
});
document.querySelectorAll("form[data-confirm]").forEach((form) => {
form.addEventListener("submit", (event) => {
const message = form.dataset.confirm || "Are you sure?";
if (!window.confirm(message)) {
event.preventDefault();
}
});
});
const logToggle = document.getElementById("log-auto-refresh");
if (logToggle) {
const schedule = () => {
if (!logToggle.checked) return;
window.setTimeout(() => {
if (logToggle.checked) {
window.location.reload();
}
}, 4000);
};
logToggle.addEventListener("change", schedule);
schedule();
}
const dialog = document.getElementById("path-picker");
if (!dialog) return;
const listNode = document.getElementById("picker-list");
const currentPathNode = document.getElementById("picker-current-path");
const closeButton = document.getElementById("picker-close");
const selectCurrentButton = document.getElementById("picker-select-current");
let currentInput = null;
let currentKind = "file";
let currentPath = "/";
const loadListing = async (path) => {
const response = await fetch(`/api/fs?path=${encodeURIComponent(path)}&kind=${encodeURIComponent(currentKind)}`, {
headers: { Accept: "application/json" }
});
if (!response.ok) return;
const payload = await response.json();
currentPath = payload.path;
currentPathNode.textContent = payload.path;
listNode.innerHTML = "";
payload.entries.forEach((entry) => {
const button = document.createElement("button");
button.type = "button";
button.className = "picker-entry";
button.dataset.kind = entry.kind;
button.dataset.path = entry.path;
button.innerHTML = `<span>${entry.name}</span><small>${entry.kind}</small>`;
button.addEventListener("click", () => {
if (entry.kind === "dir" || entry.kind === "up") {
loadListing(entry.path);
return;
}
if (currentInput) {
currentInput.value = entry.path;
dialog.close();
}
});
listNode.appendChild(button);
});
};
document.querySelectorAll("[data-picker-target]").forEach((button) => {
button.addEventListener("click", () => {
const fieldName = button.dataset.pickerTarget;
currentKind = button.dataset.pickerKind || "file";
currentInput = document.querySelector(`input[name="${fieldName}"]`);
if (!currentInput) return;
const initialPath = currentInput.value || "/";
dialog.showModal();
loadListing(initialPath);
});
});
document.querySelectorAll("[data-picker-root]").forEach((button) => {
button.addEventListener("click", () => loadListing(button.dataset.pickerRoot || "/"));
});
closeButton.addEventListener("click", () => dialog.close());
selectCurrentButton.addEventListener("click", () => {
if (!currentInput) return;
currentInput.value = currentPath;
dialog.close();
});
})();