146 lines
7.4 KiB
JavaScript
146 lines
7.4 KiB
JavaScript
document.querySelectorAll(".menu-button:not([data-menu-bound])").forEach((button) => {
|
|
button.dataset.menuBound = "true";
|
|
button.addEventListener("click", () => {
|
|
const open = document.body.classList.toggle("nav-open");
|
|
button.setAttribute("aria-expanded", String(open));
|
|
});
|
|
});
|
|
|
|
const instanceSearch = document.querySelector("#instance-search");
|
|
if (instanceSearch) {
|
|
const cards = [...document.querySelectorAll("[data-instance-card]")];
|
|
const empty = document.querySelector("[data-search-empty]");
|
|
const filter = () => {
|
|
const query = instanceSearch.value.trim().toLocaleLowerCase();
|
|
let visible = 0;
|
|
cards.forEach((card) => {
|
|
const matches = card.dataset.search.toLocaleLowerCase().includes(query);
|
|
card.hidden = !matches;
|
|
visible += Number(matches);
|
|
});
|
|
empty.hidden = visible !== 0;
|
|
};
|
|
instanceSearch.addEventListener("input", filter);
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "/" && document.activeElement !== instanceSearch) {
|
|
event.preventDefault();
|
|
instanceSearch.focus();
|
|
}
|
|
});
|
|
}
|
|
|
|
const deploymentForm = document.querySelector("[data-deployment-form]");
|
|
if (deploymentForm) {
|
|
deploymentForm.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const submit = deploymentForm.querySelector("[data-deployment-submit]");
|
|
if (submit) {
|
|
submit.disabled = true;
|
|
submit.textContent = "…";
|
|
}
|
|
let progress = document.querySelector("[data-deployment-progress]");
|
|
if (!progress) {
|
|
progress = document.createElement("section");
|
|
progress.className = "panel deployment-progress";
|
|
progress.dataset.deploymentProgress = "";
|
|
deploymentForm.after(progress);
|
|
}
|
|
progress.replaceChildren();
|
|
const heading = document.createElement("h2");
|
|
heading.textContent = "Deployment progress";
|
|
const status = document.createElement("p");
|
|
status.dataset.operationStatus = "";
|
|
const list = document.createElement("ol");
|
|
list.dataset.operationSteps = "";
|
|
const error = document.createElement("p");
|
|
error.className = "error";
|
|
error.hidden = true;
|
|
const link = document.createElement("a");
|
|
link.hidden = true;
|
|
progress.append(heading, status, list, error, link);
|
|
fetch(deploymentForm.action || window.location.href, { method: "POST", body: new FormData(deploymentForm), credentials: "same-origin", headers: { Accept: "application/json" } }).then((response) => response.ok ? response.json() : Promise.reject(new Error("Deployment could not be queued."))).then(({ operation_id, instance_id }) => {
|
|
progress.hidden = false; deploymentForm.hidden = true;
|
|
const poll = () => fetch(`/api/v1/operations/${encodeURIComponent(operation_id)}`, { credentials: "same-origin" }).then((r) => r.ok ? r.json() : Promise.reject(new Error("Progress unavailable."))).then((operation) => {
|
|
status.textContent = operation.global_status; list.replaceChildren(...operation.steps.map((step) => { const item = document.createElement("li"); item.textContent = `${step.status === "success" ? "✓" : step.status === "failed" ? "✗" : step.status === "running" ? "▶" : "○"} ${step.id}`; return item; }));
|
|
if (operation.global_status === "failed") { error.hidden = false; error.textContent = `Deployment failed${operation.error_code ? `. Code: ${operation.error_code}` : "."}`; return; }
|
|
if (operation.global_status === "success") { link.hidden = false; link.href = `/instances/${encodeURIComponent(instance_id)}`; link.textContent = "Open instance"; return; }
|
|
window.setTimeout(poll, 800);
|
|
}).catch((reason) => { error.hidden = false; error.textContent = reason.message; }); poll();
|
|
}).catch((reason) => { if (submit) { submit.disabled = false; submit.textContent = "Go"; } alert(reason.message); });
|
|
});
|
|
}
|
|
|
|
const adminTabs = document.querySelectorAll('.app-main > nav.tabs a[href^="#"]');
|
|
if (adminTabs.length) {
|
|
const panels = [...document.querySelectorAll('.settings-section[id]')];
|
|
const selectAdminTab = (requested, updateHash) => {
|
|
const valid = panels.some((panel) => panel.id === requested);
|
|
const selected = valid ? requested : (panels[0]?.id || "");
|
|
panels.forEach((panel) => { panel.hidden = panel.id !== selected; });
|
|
adminTabs.forEach((tab) => {
|
|
const active = tab.getAttribute("href") === `#${selected}`;
|
|
tab.setAttribute("aria-selected", String(active));
|
|
tab.tabIndex = active ? 0 : -1;
|
|
});
|
|
if (updateHash && selected && window.location.hash !== `#${selected}`) history.replaceState(null, "", `#${selected}`);
|
|
};
|
|
adminTabs.forEach((tab, index) => {
|
|
tab.setAttribute("role", "tab");
|
|
tab.addEventListener("click", (event) => { event.preventDefault(); selectAdminTab(tab.hash.slice(1), true); });
|
|
tab.addEventListener("keydown", (event) => {
|
|
if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
|
|
event.preventDefault();
|
|
const next = (index + (event.key === "ArrowRight" ? 1 : -1) + adminTabs.length) % adminTabs.length;
|
|
adminTabs[next].focus(); selectAdminTab(adminTabs[next].hash.slice(1), true);
|
|
});
|
|
});
|
|
selectAdminTab(window.location.hash.slice(1), false);
|
|
window.addEventListener("hashchange", () => selectAdminTab(window.location.hash.slice(1), false));
|
|
}
|
|
|
|
// The backend, not a timer, is the source for this history. A 403 simply
|
|
// means the signed-in user is not an administrator and leaves no technical
|
|
// data in the DOM.
|
|
const detailMatch = window.location.pathname.match(/^\/instances\/([^/]+)$/);
|
|
if (detailMatch) {
|
|
fetch(`/api/v1/instances/${encodeURIComponent(detailMatch[1])}/diagnostics`, { credentials: "same-origin" })
|
|
.then((response) => response.ok ? response.json() : null)
|
|
.then((data) => {
|
|
if (!data || !data.operations || !data.operations.length) return;
|
|
const section = document.createElement("section");
|
|
section.className = "panel";
|
|
section.innerHTML = "<h2>Operation diagnostics</h2><p class=muted>Administrator-only technical history.</p>";
|
|
data.operations.forEach((operation) => {
|
|
const item = document.createElement("details");
|
|
const summary = document.createElement("summary");
|
|
summary.textContent = `${operation.created_at} · ${operation.kind} · ${operation.state}${operation.error_code ? ` · ${operation.error_code}` : ""} · ${operation.operation_id}`;
|
|
item.append(summary);
|
|
if (operation.diagnostic) { const detail = document.createElement("pre"); detail.textContent = operation.diagnostic.details; item.append(detail); }
|
|
section.append(item);
|
|
});
|
|
document.querySelector(".instance-detail-lower")?.append(section);
|
|
}).catch(() => {});
|
|
}
|
|
|
|
const catalogSearch = document.querySelector("#catalog-search");
|
|
if (catalogSearch) {
|
|
const cards = [...document.querySelectorAll("[data-catalog-card]")];
|
|
const empty = document.querySelector("[data-catalog-search-empty]");
|
|
const filter = () => {
|
|
const query = catalogSearch.value.trim().toLocaleLowerCase();
|
|
let visible = 0;
|
|
cards.forEach((card) => {
|
|
const matches = card.dataset.search.toLocaleLowerCase().includes(query);
|
|
card.hidden = !matches;
|
|
visible += Number(matches);
|
|
});
|
|
empty.hidden = visible !== 0;
|
|
};
|
|
catalogSearch.addEventListener("input", filter);
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "/" && document.activeElement !== catalogSearch) {
|
|
event.preventDefault(); catalogSearch.focus();
|
|
}
|
|
});
|
|
}
|