657 lines
22 KiB
JavaScript
657 lines
22 KiB
JavaScript
document.addEventListener("DOMContentLoaded", () => {
|
|
const output = document.getElementById("console");
|
|
const triggerType = document.getElementById("trigger_type");
|
|
const scheduleKind = document.getElementById("schedule_kind");
|
|
const addCommandButton = document.getElementById("add-command-step");
|
|
const commandSteps = document.getElementById("command-steps");
|
|
const nodeLive = document.querySelector(".node-live");
|
|
const dashboardNodes = document.getElementById("dashboard-nodes");
|
|
const dashboardSearch = document.getElementById("dashboard-search");
|
|
const authToggle = document.querySelector("[data-auth-toggle]");
|
|
const themeRadios = document.querySelectorAll('input[name="theme"]');
|
|
const themeModeInput = document.querySelector('input[name="mode"]');
|
|
const uptimeChart = document.getElementById("uptime-chart");
|
|
const commandLogModal = document.getElementById("commandLogModal");
|
|
const updatePackagesModal = document.getElementById("updatePackagesModal");
|
|
const editGroupModal = document.getElementById("editGroupModal");
|
|
const groupIconPickerModal = document.getElementById("groupIconPickerModal");
|
|
const groupIconSearch = document.querySelector("[data-group-icon-search]");
|
|
const groupIconGrid = document.querySelector("[data-group-icon-grid]");
|
|
let activeGroupIconInput = null;
|
|
let activeGroupIconPreview = "";
|
|
|
|
const setGroupIconPreview = (previewId, iconClass) => {
|
|
const preview = document.getElementById(previewId);
|
|
if (!(preview instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
preview.innerHTML = `<i class="${iconClass}"></i>`;
|
|
};
|
|
|
|
const highlightSelectedGroupIcon = (iconClass) => {
|
|
groupIconGrid?.querySelectorAll("[data-icon-option]").forEach((option) => {
|
|
if (option instanceof HTMLElement) {
|
|
option.classList.toggle("is-active", option.dataset.iconOption === iconClass);
|
|
}
|
|
});
|
|
};
|
|
|
|
const attachXtermConsole = (consoleOutput) => {
|
|
const wsPath = consoleOutput.dataset.ws;
|
|
if (!wsPath || typeof Terminal === "undefined") {
|
|
return;
|
|
}
|
|
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
convertEol: true,
|
|
fontFamily: '"Cascadia Code", "SFMono-Regular", Consolas, "Liberation Mono", monospace',
|
|
fontSize: 14,
|
|
lineHeight: 1.25,
|
|
theme: {
|
|
background: "#0b1220",
|
|
foreground: "#dbeafe",
|
|
cursor: "#93c5fd",
|
|
cursorAccent: "#0b1220",
|
|
selectionBackground: "rgba(147, 197, 253, 0.28)",
|
|
},
|
|
});
|
|
const fitAddon = typeof FitAddon !== "undefined" ? new FitAddon.FitAddon() : null;
|
|
if (fitAddon) {
|
|
term.loadAddon(fitAddon);
|
|
}
|
|
term.open(consoleOutput);
|
|
|
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const socket = new WebSocket(`${protocol}//${window.location.host}${wsPath}`);
|
|
const fit = () => {
|
|
fitAddon?.fit();
|
|
term.focus();
|
|
};
|
|
|
|
window.setTimeout(fit, 0);
|
|
window.addEventListener("resize", fit);
|
|
if (typeof ResizeObserver !== "undefined") {
|
|
const observer = new ResizeObserver(fit);
|
|
observer.observe(consoleOutput);
|
|
}
|
|
|
|
socket.addEventListener("message", (event) => {
|
|
term.write(event.data);
|
|
});
|
|
|
|
socket.addEventListener("open", () => {
|
|
fit();
|
|
});
|
|
|
|
socket.addEventListener("close", () => {
|
|
term.write("\r\n[session closed]\r\n");
|
|
});
|
|
|
|
socket.addEventListener("error", () => {
|
|
term.write("\r\n[connection error]\r\n");
|
|
});
|
|
|
|
term.onData((value) => {
|
|
if (socket.readyState === WebSocket.OPEN) {
|
|
socket.send(value);
|
|
}
|
|
});
|
|
};
|
|
|
|
if (output?.dataset.xterm === "true") {
|
|
attachXtermConsole(output);
|
|
}
|
|
|
|
if (commandLogModal instanceof HTMLElement) {
|
|
commandLogModal.addEventListener("show.bs.modal", (event) => {
|
|
const trigger = event.relatedTarget;
|
|
if (!(trigger instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const target = trigger.dataset.logTarget || "";
|
|
const time = trigger.dataset.logTime || "";
|
|
const status = trigger.dataset.logStatus || "";
|
|
const duration = trigger.dataset.logDuration || "";
|
|
const command = trigger.dataset.logCommand || "";
|
|
const outputText = trigger.dataset.logOutput || "";
|
|
|
|
const meta = commandLogModal.querySelector("[data-command-log-meta]");
|
|
const commandNode = commandLogModal.querySelector("[data-command-log-command]");
|
|
const outputNode = commandLogModal.querySelector("[data-command-log-output]");
|
|
|
|
if (meta) {
|
|
meta.textContent = [target, time, status, duration].filter(Boolean).join(" · ");
|
|
}
|
|
if (commandNode) {
|
|
commandNode.textContent = command || "-";
|
|
}
|
|
if (outputNode) {
|
|
outputNode.textContent = outputText || "No output";
|
|
}
|
|
});
|
|
}
|
|
|
|
if (updatePackagesModal instanceof HTMLElement) {
|
|
updatePackagesModal.addEventListener("show.bs.modal", (event) => {
|
|
const trigger = event.relatedTarget;
|
|
if (!(trigger instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const nodeName = trigger.dataset.nodeName || "";
|
|
const packageCount = trigger.dataset.packageCount || "0";
|
|
const rawPackages = trigger.dataset.packages || "[]";
|
|
const nodeLabel = updatePackagesModal.querySelector("[data-update-packages-node]");
|
|
const body = updatePackagesModal.querySelector("[data-update-packages-body]");
|
|
const countValue = Number.parseInt(packageCount, 10);
|
|
const countLabel = Number.isFinite(countValue) && countValue === 1 ? "1 pending package" : `${packageCount} pending packages`;
|
|
|
|
if (nodeLabel) {
|
|
nodeLabel.textContent = `${nodeName} · ${countLabel}`;
|
|
}
|
|
|
|
if (!(body instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
let packages = [];
|
|
try {
|
|
packages = JSON.parse(rawPackages);
|
|
} catch (_) {
|
|
packages = [];
|
|
}
|
|
|
|
if (!Array.isArray(packages) || packages.length === 0) {
|
|
body.innerHTML = `<tr><td colspan="4" class="text-body-secondary text-center py-4">No pending packages.</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
body.innerHTML = packages.map((pkg) => `
|
|
<tr>
|
|
<td class="fw-semibold">${pkg.Name || "-"}</td>
|
|
<td class="text-body-secondary">${pkg.Current || "-"}</td>
|
|
<td>${pkg.Available || "-"}</td>
|
|
<td class="text-body-secondary">${pkg.Architecture || "-"}</td>
|
|
</tr>
|
|
`).join("");
|
|
});
|
|
}
|
|
|
|
if (editGroupModal instanceof HTMLElement) {
|
|
editGroupModal.addEventListener("show.bs.modal", (event) => {
|
|
const trigger = event.relatedTarget;
|
|
if (!(trigger instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const groupId = trigger.dataset.groupId || "";
|
|
const groupName = trigger.dataset.groupName || "";
|
|
const groupDescription = trigger.dataset.groupDescription || "";
|
|
const groupIcon = trigger.dataset.groupIcon || "ti ti-stack-2";
|
|
const form = editGroupModal.querySelector("[data-edit-group-form]");
|
|
const nameInput = editGroupModal.querySelector("[data-edit-group-name]");
|
|
const descriptionInput = editGroupModal.querySelector("[data-edit-group-description]");
|
|
const iconInput = document.getElementById("editGroupIconValue");
|
|
const iconTrigger = editGroupModal.querySelector("[data-edit-group-icon-trigger]");
|
|
|
|
if (form instanceof HTMLFormElement) {
|
|
form.action = `/groups/${groupId}`;
|
|
}
|
|
if (nameInput instanceof HTMLInputElement) {
|
|
nameInput.value = groupName;
|
|
}
|
|
if (descriptionInput instanceof HTMLTextAreaElement) {
|
|
descriptionInput.value = groupDescription;
|
|
}
|
|
if (iconInput instanceof HTMLInputElement) {
|
|
iconInput.value = groupIcon;
|
|
}
|
|
setGroupIconPreview("editGroupIconPreview", groupIcon);
|
|
if (iconTrigger instanceof HTMLElement) {
|
|
iconTrigger.dataset.iconValue = groupIcon;
|
|
}
|
|
});
|
|
}
|
|
|
|
if (groupIconPickerModal instanceof HTMLElement) {
|
|
groupIconPickerModal.addEventListener("show.bs.modal", (event) => {
|
|
const trigger = event.relatedTarget;
|
|
if (!(trigger instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
activeGroupIconInput = document.getElementById(trigger.dataset.iconInput || "");
|
|
activeGroupIconPreview = trigger.dataset.iconPreview || "";
|
|
const currentValue = trigger.dataset.iconValue || (activeGroupIconInput instanceof HTMLInputElement ? activeGroupIconInput.value : "ti ti-stack-2");
|
|
|
|
if (groupIconSearch instanceof HTMLInputElement) {
|
|
groupIconSearch.value = "";
|
|
}
|
|
groupIconGrid?.querySelectorAll("[data-icon-option]").forEach((option) => {
|
|
if (option instanceof HTMLElement) {
|
|
option.classList.remove("d-none");
|
|
}
|
|
});
|
|
highlightSelectedGroupIcon(currentValue);
|
|
window.setTimeout(() => groupIconSearch?.focus(), 120);
|
|
});
|
|
|
|
groupIconGrid?.addEventListener("click", (event) => {
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) {
|
|
return;
|
|
}
|
|
const option = target.closest("[data-icon-option]");
|
|
if (!(option instanceof HTMLElement) || !(activeGroupIconInput instanceof HTMLInputElement)) {
|
|
return;
|
|
}
|
|
|
|
const iconClass = option.dataset.iconOption || "ti ti-stack-2";
|
|
activeGroupIconInput.value = iconClass;
|
|
if (activeGroupIconPreview) {
|
|
setGroupIconPreview(activeGroupIconPreview, iconClass);
|
|
}
|
|
document.querySelectorAll(`[data-icon-input="${activeGroupIconInput.id}"]`).forEach((trigger) => {
|
|
if (trigger instanceof HTMLElement) {
|
|
trigger.dataset.iconValue = iconClass;
|
|
}
|
|
});
|
|
highlightSelectedGroupIcon(iconClass);
|
|
bootstrap.Modal.getInstance(groupIconPickerModal)?.hide();
|
|
});
|
|
|
|
groupIconSearch?.addEventListener("input", () => {
|
|
const query = groupIconSearch.value.trim().toLowerCase();
|
|
groupIconGrid?.querySelectorAll("[data-icon-option]").forEach((option) => {
|
|
if (!(option instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
const label = option.dataset.iconOption || "";
|
|
option.classList.toggle("d-none", query !== "" && !label.toLowerCase().includes(query));
|
|
});
|
|
});
|
|
}
|
|
|
|
const syncScheduleUi = () => {
|
|
if (!triggerType || !scheduleKind) {
|
|
return;
|
|
}
|
|
|
|
const schedulePanel = document.querySelector(".schedule-panel");
|
|
const weeklyRows = document.querySelectorAll(".weekly-only");
|
|
const intervalRows = document.querySelectorAll(".interval-only");
|
|
const showSchedule = triggerType.value !== "triggered";
|
|
const showWeekly = showSchedule && scheduleKind.value === "weekly";
|
|
const showInterval = showSchedule && scheduleKind.value === "interval";
|
|
|
|
if (schedulePanel) {
|
|
schedulePanel.classList.toggle("opacity-50", !showSchedule);
|
|
}
|
|
weeklyRows.forEach((row) => row.classList.toggle("d-none", !showWeekly));
|
|
intervalRows.forEach((row) => row.classList.toggle("d-none", !showInterval));
|
|
};
|
|
|
|
triggerType?.addEventListener("change", syncScheduleUi);
|
|
scheduleKind?.addEventListener("change", syncScheduleUi);
|
|
syncScheduleUi();
|
|
|
|
addCommandButton?.addEventListener("click", () => {
|
|
if (!commandSteps) {
|
|
return;
|
|
}
|
|
|
|
const nextIndex = commandSteps.querySelectorAll(".command-step").length + 1;
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "input-group command-step";
|
|
wrapper.innerHTML = `
|
|
<span class="input-group-text">${nextIndex}</span>
|
|
<input type="text" class="form-control" name="command_steps[]" placeholder="command ${nextIndex}" required>
|
|
<button class="btn btn-outline-danger remove-command-step" type="button">Remove</button>
|
|
`;
|
|
commandSteps.appendChild(wrapper);
|
|
});
|
|
|
|
commandSteps?.addEventListener("click", (event) => {
|
|
const target = event.target;
|
|
if (!(target instanceof HTMLElement) || !target.classList.contains("remove-command-step")) {
|
|
return;
|
|
}
|
|
|
|
const steps = commandSteps.querySelectorAll(".command-step");
|
|
if (steps.length <= 1) {
|
|
return;
|
|
}
|
|
|
|
target.closest(".command-step")?.remove();
|
|
commandSteps.querySelectorAll(".command-step").forEach((step, index) => {
|
|
const badge = step.querySelector(".input-group-text");
|
|
if (badge) {
|
|
badge.textContent = String(index + 1);
|
|
}
|
|
});
|
|
});
|
|
|
|
if (nodeLive instanceof HTMLElement) {
|
|
const nodeId = nodeLive.dataset.nodeId;
|
|
const history = {
|
|
cpu: [],
|
|
ram: [],
|
|
disk: [],
|
|
uptime: [],
|
|
};
|
|
|
|
const uptimeLabel = (seconds) => {
|
|
const days = Math.floor(seconds / 86400);
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
|
|
return `${hours}h ${minutes}m`;
|
|
};
|
|
|
|
const renderSparkline = (values, key) => {
|
|
if (values.length === 0) {
|
|
return "";
|
|
}
|
|
|
|
let min = 0;
|
|
let max = 100;
|
|
|
|
if (key === "uptime") {
|
|
min = Math.min(...values);
|
|
max = Math.max(...values);
|
|
if (max-min < 5) {
|
|
max = min + 5;
|
|
}
|
|
}
|
|
|
|
const path = values.map((value, index) => {
|
|
const x = values.length === 1 ? 0 : (index / (values.length - 1)) * 100;
|
|
const normalized = (value - min) / Math.max(max - min, 1);
|
|
const y = 90 - normalized * 72;
|
|
return `${index === 0 ? "M" : "L"} ${x.toFixed(2)} ${y.toFixed(2)}`;
|
|
}).join(" ");
|
|
|
|
return `
|
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none">
|
|
<path d="${path}" fill="none" stroke="rgb(var(--color-primary-500))" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" vector-effect="non-scaling-stroke"></path>
|
|
</svg>
|
|
`;
|
|
};
|
|
|
|
const pushValue = (key, value) => {
|
|
history[key].push(value);
|
|
if (history[key].length > 60) {
|
|
history[key].shift();
|
|
}
|
|
const card = nodeLive.querySelector(`.kpi-card[data-kpi="${key}"] .kpi-graph`);
|
|
if (card) {
|
|
card.innerHTML = renderSparkline(history[key], key);
|
|
}
|
|
};
|
|
|
|
const updateNodeStats = async () => {
|
|
if (!nodeId) {
|
|
return;
|
|
}
|
|
try {
|
|
const response = await fetch(`/nodes/${nodeId}/stats`, { headers: { Accept: "application/json" } });
|
|
if (!response.ok) {
|
|
return;
|
|
}
|
|
const stats = await response.json();
|
|
const cpu = Number(stats.cpu_usage || 0);
|
|
const ram = Number(stats.ram_usage || 0);
|
|
const disk = Number(stats.disk_usage || 0);
|
|
const uptime = Number(stats.uptime_seconds || 0);
|
|
|
|
const cpuValue = nodeLive.querySelector('[data-kpi-value="cpu"]');
|
|
const ramValue = nodeLive.querySelector('[data-kpi-value="ram"]');
|
|
const diskValue = nodeLive.querySelector('[data-kpi-value="disk"]');
|
|
const uptimeValue = nodeLive.querySelector('[data-kpi-value="uptime"]');
|
|
|
|
if (cpuValue) cpuValue.textContent = `${cpu.toFixed(1)}%`;
|
|
if (ramValue) ramValue.textContent = `${ram.toFixed(1)}%`;
|
|
if (diskValue) diskValue.textContent = `${disk.toFixed(1)}%`;
|
|
if (uptimeValue) uptimeValue.textContent = uptimeLabel(uptime);
|
|
|
|
pushValue("cpu", cpu);
|
|
pushValue("ram", ram);
|
|
pushValue("disk", disk);
|
|
pushValue("uptime", Math.min(uptime / 3600, 100));
|
|
} catch (_) {
|
|
// Ignore transient poll failures.
|
|
}
|
|
};
|
|
|
|
updateNodeStats();
|
|
window.setInterval(updateNodeStats, 1000);
|
|
}
|
|
|
|
if (dashboardNodes instanceof HTMLElement) {
|
|
const url = dashboardNodes.dataset.dashboardNodesUrl;
|
|
if (url) {
|
|
const loadDashboardNodes = async (showErrorState) => {
|
|
try {
|
|
const response = await fetch(url, { headers: { Accept: "text/html" } });
|
|
if (!response.ok) {
|
|
throw new Error("failed");
|
|
}
|
|
const html = await response.text();
|
|
dashboardNodes.innerHTML = html;
|
|
dashboardNodes.classList.add("is-loaded");
|
|
dashboardSearch?.dispatchEvent(new Event("input"));
|
|
} catch (_) {
|
|
if (!showErrorState) {
|
|
return;
|
|
}
|
|
dashboardNodes.innerHTML = `
|
|
<div class="card border-0 shadow-sm">
|
|
<div class="card-body py-5 text-center text-body-secondary">
|
|
Failed to load nodes.
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
};
|
|
|
|
loadDashboardNodes(true);
|
|
window.setInterval(() => {
|
|
loadDashboardNodes(false);
|
|
}, 5000);
|
|
}
|
|
}
|
|
|
|
dashboardSearch?.addEventListener("input", () => {
|
|
const query = dashboardSearch.value.trim().toLowerCase();
|
|
document.querySelectorAll(".dashboard-node-card").forEach((card) => {
|
|
if (!(card instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
const haystack = card.dataset.nodeSearch || "";
|
|
const matches = query === "" || haystack.includes(query);
|
|
card.classList.toggle("d-none", !matches);
|
|
});
|
|
});
|
|
|
|
if (authToggle instanceof HTMLElement) {
|
|
const passwordRadio = document.getElementById("authModePassword");
|
|
const keyRadio = document.getElementById("authModeKey");
|
|
const passwordPanel = document.querySelector('[data-auth-panel="password"]');
|
|
const keyPanel = document.querySelector('[data-auth-panel="key"]');
|
|
const passwordInput = document.querySelector('[data-auth-input="password"]');
|
|
const keyInput = document.querySelector('[data-auth-input="key"]');
|
|
|
|
const syncAuthMode = () => {
|
|
const passwordMode = passwordRadio instanceof HTMLInputElement && passwordRadio.checked;
|
|
|
|
passwordPanel?.classList.toggle("d-none", !passwordMode);
|
|
keyPanel?.classList.toggle("d-none", passwordMode);
|
|
|
|
if (passwordInput instanceof HTMLInputElement) {
|
|
passwordInput.disabled = !passwordMode;
|
|
passwordInput.required = passwordMode;
|
|
if (!passwordMode) {
|
|
passwordInput.value = "";
|
|
}
|
|
}
|
|
|
|
if (keyInput instanceof HTMLInputElement) {
|
|
keyInput.disabled = passwordMode;
|
|
keyInput.required = !passwordMode;
|
|
if (passwordMode) {
|
|
keyInput.value = "";
|
|
}
|
|
}
|
|
};
|
|
|
|
passwordRadio?.addEventListener("change", syncAuthMode);
|
|
keyRadio?.addEventListener("change", syncAuthMode);
|
|
syncAuthMode();
|
|
}
|
|
|
|
if (themeModeInput instanceof HTMLInputElement) {
|
|
themeRadios.forEach((radio) => {
|
|
radio.addEventListener("change", () => {
|
|
if (radio instanceof HTMLInputElement && radio.checked) {
|
|
themeModeInput.value = radio.value;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
if (uptimeChart instanceof HTMLCanvasElement && typeof Chart !== "undefined") {
|
|
try {
|
|
const labels = JSON.parse(uptimeChart.dataset.labels || "[]");
|
|
const values = JSON.parse(uptimeChart.dataset.values || "[]");
|
|
const pointColors = JSON.parse(uptimeChart.dataset.pointColors || "[]");
|
|
const style = getComputedStyle(document.body);
|
|
const primaryRGB = style.getPropertyValue("--color-primary-500").trim() || "16 185 129";
|
|
const chartStroke = `rgb(${primaryRGB})`;
|
|
const chartFill = `rgba(${primaryRGB.replace(/\s+/g, ", ")}, 0.16)`;
|
|
const gridColor = style.getPropertyValue("--ma-border").trim() || "rgba(148, 163, 184, 0.16)";
|
|
const tickColor = style.getPropertyValue("--bs-secondary-color").trim() || "#94a3b8";
|
|
|
|
new Chart(uptimeChart, {
|
|
type: "line",
|
|
data: {
|
|
labels,
|
|
datasets: [{
|
|
data: values,
|
|
borderColor: chartStroke,
|
|
backgroundColor: chartFill,
|
|
fill: true,
|
|
borderWidth: 3,
|
|
tension: 0.35,
|
|
pointRadius: 3,
|
|
pointHoverRadius: 4,
|
|
pointBackgroundColor: pointColors,
|
|
pointBorderColor: pointColors,
|
|
pointBorderWidth: 0,
|
|
}],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: false,
|
|
plugins: {
|
|
legend: {
|
|
display: false,
|
|
},
|
|
tooltip: {
|
|
displayColors: false,
|
|
callbacks: {
|
|
label(context) {
|
|
return `${Number(context.parsed.y || 0).toFixed(0)}% availability`;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
interaction: {
|
|
intersect: false,
|
|
mode: "index",
|
|
},
|
|
scales: {
|
|
x: {
|
|
grid: {
|
|
display: false,
|
|
},
|
|
border: {
|
|
display: false,
|
|
},
|
|
ticks: {
|
|
color: tickColor,
|
|
maxRotation: 0,
|
|
autoSkip: true,
|
|
maxTicksLimit: 6,
|
|
},
|
|
},
|
|
y: {
|
|
min: 0,
|
|
max: 100,
|
|
border: {
|
|
display: false,
|
|
},
|
|
ticks: {
|
|
color: tickColor,
|
|
stepSize: 25,
|
|
callback(value) {
|
|
return `${value}%`;
|
|
},
|
|
},
|
|
grid: {
|
|
color: gridColor,
|
|
drawTicks: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
} catch (_) {
|
|
// Ignore malformed chart payloads.
|
|
}
|
|
}
|
|
|
|
document.addEventListener("click", async (event) => {
|
|
const target = event.target;
|
|
if (!(target instanceof Element)) {
|
|
return;
|
|
}
|
|
|
|
const copyTrigger = target.closest("[data-copy-value]");
|
|
if (!(copyTrigger instanceof HTMLElement)) {
|
|
return;
|
|
}
|
|
|
|
const value = (copyTrigger.dataset.copyValue || "").trim();
|
|
if (!value) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(value);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
|
|
if (typeof bootstrap !== "undefined") {
|
|
const tooltip = bootstrap.Tooltip.getOrCreateInstance(copyTrigger);
|
|
const previousTitle = copyTrigger.getAttribute("data-bs-title") || copyTrigger.getAttribute("title") || "Copy";
|
|
copyTrigger.setAttribute("data-bs-title", "Copied");
|
|
tooltip.setContent?.({ ".tooltip-inner": "Copied" });
|
|
tooltip.show();
|
|
window.setTimeout(() => {
|
|
copyTrigger.setAttribute("data-bs-title", previousTitle);
|
|
tooltip.setContent?.({ ".tooltip-inner": previousTitle });
|
|
}, 1200);
|
|
}
|
|
});
|
|
|
|
if (typeof bootstrap !== "undefined") {
|
|
document.querySelectorAll('[data-bs-toggle-tooltip="tooltip"]').forEach((element) => {
|
|
new bootstrap.Tooltip(element);
|
|
});
|
|
}
|
|
});
|