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:
parent
30f0c0b54a
commit
2362d0ae39
24 changed files with 3308 additions and 52 deletions
130
internal/webui/assets/app.js
Normal file
130
internal/webui/assets/app.js
Normal 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();
|
||||
});
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue