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 ./...
130 lines
4.5 KiB
JavaScript
130 lines
4.5 KiB
JavaScript
(() => {
|
|
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();
|
|
});
|
|
})();
|