Files
Maintainarr/web/static/js/app.js

455 lines
14 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 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);
}
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) {
fetch(url, { headers: { Accept: "text/html" } })
.then((response) => {
if (!response.ok) {
throw new Error("failed");
}
return response.text();
})
.then((html) => {
dashboardNodes.innerHTML = html;
dashboardNodes.classList.add("is-loaded");
dashboardSearch?.dispatchEvent(new Event("input"));
})
.catch(() => {
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>
`;
});
}
}
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);
});
}
});