806 lines
29 KiB
JavaScript
806 lines
29 KiB
JavaScript
// Entry point for the dashboard: wires UI events, pulls status, and initializes
|
|
// feature modules (services, settings, stats).
|
|
import {
|
|
getStatus,
|
|
triggerReset,
|
|
getReleaseStatus,
|
|
checkRelease,
|
|
applyRelease,
|
|
rollbackRelease,
|
|
setReleaseAutoCheck,
|
|
} from "./api.js";
|
|
import { placeholderStatus, renderStats } from "./status.js";
|
|
import { initServiceControls, renderServices } from "./services.js";
|
|
import { initSettings } from "./settings.js";
|
|
import { initUpdateSettings, isUpdatesDirty } from "./update-settings.js";
|
|
|
|
const servicesGrid = document.getElementById("servicesGrid");
|
|
const heroStats = document.getElementById("heroStats");
|
|
const refreshHintMain = document.getElementById("refreshHintMain");
|
|
const refreshHintServices = document.getElementById("refreshHintServices");
|
|
const refreshFlagTop = document.getElementById("refreshFlagTop");
|
|
const themeToggle = document.getElementById("themeToggle");
|
|
const themeToggleIcon = document.getElementById("themeToggleIcon");
|
|
const animToggle = document.getElementById("animToggle");
|
|
|
|
const resetConfirm = document.getElementById("resetConfirm");
|
|
const resetBtn = document.getElementById("resetBtn");
|
|
const updatesToggle = document.getElementById("updatesToggle");
|
|
const updatesStatus = document.getElementById("updatesStatus");
|
|
const updatesFlagTop = document.getElementById("updatesFlagTop");
|
|
const updatesNoteTop = document.getElementById("updatesNoteTop");
|
|
const tempFlagTop = document.getElementById("tempFlagTop");
|
|
const releaseFlagTop = document.getElementById("releaseFlagTop");
|
|
const refreshIntervalInput = document.getElementById("refreshIntervalInput");
|
|
const refreshIntervalSave = document.getElementById("refreshIntervalSave");
|
|
const refreshIntervalMsg = document.getElementById("refreshIntervalMsg");
|
|
const toastPosSelect = document.getElementById("toastPosSelect");
|
|
const toastAnimSelect = document.getElementById("toastAnimSelect");
|
|
const toastSpeedInput = document.getElementById("toastSpeedInput");
|
|
const toastDurationInput = document.getElementById("toastDurationInput");
|
|
const fontSelect = document.getElementById("fontSelect");
|
|
const updatesScope = document.getElementById("updatesScope");
|
|
const updateTimeInput = document.getElementById("updateTimeInput");
|
|
const upgradeTimeInput = document.getElementById("upgradeTimeInput");
|
|
const updatesCleanup = document.getElementById("updatesCleanup");
|
|
const updatesBandwidth = document.getElementById("updatesBandwidth");
|
|
const updatesRebootToggle = document.getElementById("updatesRebootToggle");
|
|
const updatesRebootTime = document.getElementById("updatesRebootTime");
|
|
const updatesRebootUsers = document.getElementById("updatesRebootUsers");
|
|
const updatesSaveBtn = document.getElementById("updatesSaveBtn");
|
|
const updatesMsg = document.getElementById("updatesMsg");
|
|
const updatesUnsavedNote = document.getElementById("updatesUnsavedNote");
|
|
const updatesSection = document.getElementById("updatesSection");
|
|
const svcName = document.getElementById("svcName");
|
|
const svcPort = document.getElementById("svcPort");
|
|
const svcPath = document.getElementById("svcPath");
|
|
const svcAddBtn = document.getElementById("svcAddBtn");
|
|
const svcMsg = document.getElementById("svcMsg");
|
|
const svcScheme = document.getElementById("svcScheme");
|
|
const svcNotice = document.getElementById("svcNotice");
|
|
const svcNoticeLink = document.getElementById("svcNoticeLink");
|
|
const svcSelfSigned = document.getElementById("svcSelfSigned");
|
|
const svcSelfSignedLabel = document.querySelector("label[for='svcSelfSigned']") || null;
|
|
const addServiceModal = document.getElementById("addServiceModal");
|
|
const addSvcClose = document.getElementById("addSvcClose");
|
|
const addServiceOpen = document.getElementById("addServiceOpen");
|
|
const menuModal = document.getElementById("menuModal");
|
|
const menuTitle = document.getElementById("menuTitle");
|
|
const menuSubtitle = document.getElementById("menuSubtitle");
|
|
const menuRename = document.getElementById("menuRename");
|
|
const menuPort = document.getElementById("menuPort");
|
|
const menuPath = document.getElementById("menuPath");
|
|
const menuScheme = document.getElementById("menuScheme");
|
|
const menuNotice = document.getElementById("menuNotice");
|
|
const menuNoticeLink = document.getElementById("menuNoticeLink");
|
|
const menuSelfSigned = document.getElementById("menuSelfSigned");
|
|
const menuSaveBtn = document.getElementById("menuSaveBtn");
|
|
const menuCancelBtn = document.getElementById("menuCancelBtn");
|
|
const menuRemoveBtn = document.getElementById("menuRemoveBtn");
|
|
const menuMsg = document.getElementById("menuMsg");
|
|
const menuClose = document.getElementById("menuClose");
|
|
|
|
const advBtn = document.getElementById("advBtn");
|
|
const advModal = document.getElementById("advModal");
|
|
const advClose = document.getElementById("advClose");
|
|
const releaseBtn = document.getElementById("releaseBtn");
|
|
const releaseModal = document.getElementById("releaseModal");
|
|
const releaseClose = document.getElementById("releaseClose");
|
|
const releaseCurrent = document.getElementById("releaseCurrent");
|
|
const releaseLatest = document.getElementById("releaseLatest");
|
|
const releaseStatusMsg = document.getElementById("releaseStatusMsg");
|
|
const releaseProgress = document.getElementById("releaseProgress");
|
|
const releaseCheckBtn = document.getElementById("releaseCheckBtn");
|
|
const releaseApplyBtn = document.getElementById("releaseApplyBtn");
|
|
const releaseRollbackBtn = document.getElementById("releaseRollbackBtn");
|
|
const releaseAutoCheck = document.getElementById("releaseAutoCheck");
|
|
|
|
const helpBtn = document.getElementById("helpBtn");
|
|
const helpModal = document.getElementById("helpModal");
|
|
const helpClose = document.getElementById("helpClose");
|
|
const aboutBtn = document.getElementById("aboutBtn");
|
|
const aboutModal = document.getElementById("aboutModal");
|
|
const aboutClose = document.getElementById("aboutClose");
|
|
const readyOverlay = document.getElementById("readyOverlay");
|
|
const busyOverlay = document.getElementById("busyOverlay");
|
|
const busyTitle = document.getElementById("busyTitle");
|
|
const busyText = document.getElementById("busyText");
|
|
const toastContainer = document.getElementById("toastContainer");
|
|
const readyOverlay = document.getElementById("readyOverlay");
|
|
|
|
const TOAST_POS_KEY = "pikit-toast-pos";
|
|
const TOAST_ANIM_KEY = "pikit-toast-anim";
|
|
const TOAST_SPEED_KEY = "pikit-toast-speed";
|
|
const TOAST_DURATION_KEY = "pikit-toast-duration";
|
|
const FONT_KEY = "pikit-font";
|
|
const ALLOWED_TOAST_POS = [
|
|
"bottom-center",
|
|
"bottom-right",
|
|
"bottom-left",
|
|
"top-right",
|
|
"top-left",
|
|
"top-center",
|
|
];
|
|
const ALLOWED_TOAST_ANIM = ["slide-in", "fade", "pop", "bounce", "drop", "grow"];
|
|
const ALLOWED_FONTS = ["redhat", "space", "manrope", "dmsans", "sora", "chivo", "atkinson", "plex"];
|
|
|
|
let toastPosition = "bottom-center";
|
|
let toastAnimation = "slide-in";
|
|
let toastDurationMs = 5000;
|
|
let toastSpeedMs = 300;
|
|
let fontChoice = "redhat";
|
|
|
|
function applyToastSettings() {
|
|
if (!toastContainer) return;
|
|
toastContainer.className = `toast-container pos-${toastPosition}`;
|
|
document.documentElement.style.setProperty("--toast-speed", `${toastSpeedMs}ms`);
|
|
const dir = toastPosition.startsWith("top") ? -1 : 1;
|
|
const isLeft = toastPosition.includes("left");
|
|
const isRight = toastPosition.includes("right");
|
|
const slideX = isLeft ? -26 : isRight ? 26 : 0;
|
|
const slideY = isLeft || isRight ? 0 : dir * 24;
|
|
document.documentElement.style.setProperty("--toast-slide-offset", `${dir * 24}px`);
|
|
document.documentElement.style.setProperty("--toast-dir", `${dir}`);
|
|
document.documentElement.style.setProperty("--toast-slide-x", `${slideX}px`);
|
|
document.documentElement.style.setProperty("--toast-slide-y", `${slideY}px`);
|
|
if (toastDurationInput) toastDurationInput.value = toastDurationMs;
|
|
}
|
|
|
|
function applyFontSetting() {
|
|
document.documentElement.setAttribute("data-font", fontChoice);
|
|
if (fontSelect) fontSelect.value = fontChoice;
|
|
}
|
|
|
|
function loadToastSettings() {
|
|
try {
|
|
const posSaved = localStorage.getItem(TOAST_POS_KEY);
|
|
if (ALLOWED_TOAST_POS.includes(posSaved)) toastPosition = posSaved;
|
|
const animSaved = localStorage.getItem(TOAST_ANIM_KEY);
|
|
const migrated =
|
|
animSaved === "slide-up" || animSaved === "slide-left" || animSaved === "slide-right"
|
|
? "slide-in"
|
|
: animSaved;
|
|
if (migrated && ALLOWED_TOAST_ANIM.includes(migrated)) toastAnimation = migrated;
|
|
const savedSpeed = Number(localStorage.getItem(TOAST_SPEED_KEY));
|
|
if (!Number.isNaN(savedSpeed) && savedSpeed >= 100 && savedSpeed <= 3000) {
|
|
toastSpeedMs = savedSpeed;
|
|
}
|
|
const savedDur = Number(localStorage.getItem(TOAST_DURATION_KEY));
|
|
if (!Number.isNaN(savedDur) && savedDur >= 1000 && savedDur <= 15000) {
|
|
toastDurationMs = savedDur;
|
|
}
|
|
const savedFont = localStorage.getItem(FONT_KEY);
|
|
if (ALLOWED_FONTS.includes(savedFont)) fontChoice = savedFont;
|
|
} catch (e) {
|
|
console.warn("Toast settings load failed", e);
|
|
}
|
|
if (toastPosSelect) toastPosSelect.value = toastPosition;
|
|
if (toastAnimSelect) toastAnimSelect.value = toastAnimation;
|
|
if (toastSpeedInput) toastSpeedInput.value = toastSpeedMs;
|
|
if (toastDurationInput) toastDurationInput.value = toastDurationMs;
|
|
if (fontSelect) fontSelect.value = fontChoice;
|
|
applyToastSettings();
|
|
applyFontSetting();
|
|
}
|
|
|
|
function persistToastSettings() {
|
|
try {
|
|
localStorage.setItem(TOAST_POS_KEY, toastPosition);
|
|
localStorage.setItem(TOAST_ANIM_KEY, toastAnimation);
|
|
localStorage.setItem(TOAST_SPEED_KEY, String(toastSpeedMs));
|
|
localStorage.setItem(TOAST_DURATION_KEY, String(toastDurationMs));
|
|
localStorage.setItem(FONT_KEY, fontChoice);
|
|
} catch (e) {
|
|
console.warn("Toast settings save failed", e);
|
|
}
|
|
}
|
|
|
|
function showToast(message, type = "info") {
|
|
if (!toastContainer || !message) return;
|
|
const t = document.createElement("div");
|
|
t.className = `toast ${type} anim-${toastAnimation}`;
|
|
t.textContent = message;
|
|
toastContainer.appendChild(t);
|
|
const animOn = document.documentElement.getAttribute("data-anim") !== "off";
|
|
if (!animOn) {
|
|
t.classList.add("show");
|
|
} else {
|
|
requestAnimationFrame(() => t.classList.add("show"));
|
|
}
|
|
const duration = toastDurationMs;
|
|
setTimeout(() => {
|
|
const all = Array.from(toastContainer.querySelectorAll(".toast"));
|
|
const others = all.filter((el) => el !== t && !el.classList.contains("leaving"));
|
|
const first = new Map(
|
|
others.map((el) => [el, el.getBoundingClientRect()]),
|
|
);
|
|
|
|
t.classList.add("leaving");
|
|
// force layout
|
|
void t.offsetHeight;
|
|
|
|
requestAnimationFrame(() => {
|
|
const second = new Map(
|
|
others.map((el) => [el, el.getBoundingClientRect()]),
|
|
);
|
|
others.forEach((el) => {
|
|
const dy = first.get(el).top - second.get(el).top;
|
|
if (Math.abs(dy) > 0.5) {
|
|
el.style.transition = "transform var(--toast-speed, 0.28s) ease";
|
|
el.style.transform = `translateY(${dy}px)`;
|
|
requestAnimationFrame(() => {
|
|
el.style.transform = "";
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
const removeDelay = animOn ? toastSpeedMs : 0;
|
|
setTimeout(() => {
|
|
t.classList.remove("show");
|
|
t.remove();
|
|
// clear transition styling
|
|
others.forEach((el) => (el.style.transition = ""));
|
|
}, removeDelay);
|
|
}, duration);
|
|
}
|
|
|
|
function applyTooltips() {
|
|
const tips = {
|
|
updatesFlagTop: "Auto updates status; configure in Settings → Automatic updates.",
|
|
refreshFlagTop: "Auto-refresh interval; change in Settings → Refresh interval.",
|
|
tempFlagTop: "CPU temperature status; see details in the hero stats below.",
|
|
releaseFlagTop: "Pi-Kit release status; open Settings → Updates to install.",
|
|
themeToggle: "Toggle light or dark theme",
|
|
helpBtn: "Open quick help",
|
|
advBtn: "Open settings",
|
|
animToggle: "Enable or disable dashboard animations",
|
|
refreshIntervalInput: "Seconds between automatic refreshes (5-120)",
|
|
refreshIntervalSave: "Save refresh interval",
|
|
svcName: "Display name for the service card",
|
|
svcPort: "Port number the service listens on",
|
|
svcPath: "Optional path like /admin",
|
|
svcScheme: "Choose HTTP or HTTPS link",
|
|
svcSelfSigned: "Mark service as using a self-signed certificate",
|
|
svcNotice: "Optional note shown on the service card",
|
|
svcNoticeLink: "Optional link for more info about the service",
|
|
svcAddBtn: "Add the service to the dashboard",
|
|
updatesToggle: "Turn unattended upgrades on or off",
|
|
updatesScope: "Select security-only or all updates",
|
|
updateTimeInput: "Time to download updates (24h)",
|
|
upgradeTimeInput: "Time to install updates (24h)",
|
|
updatesCleanup: "Remove unused dependencies after upgrades",
|
|
updatesBandwidth: "Limit download bandwidth in KB/s (0 = unlimited)",
|
|
updatesRebootToggle: "Auto-reboot if required by updates",
|
|
updatesRebootTime: "Scheduled reboot time when auto-reboot is on",
|
|
updatesRebootUsers: "Allow reboot even if users are logged in",
|
|
updatesSaveBtn: "Save unattended-upgrades settings",
|
|
resetConfirm: "Type YES to enable factory reset",
|
|
resetBtn: "Factory reset this Pi-Kit",
|
|
menuRename: "Change the service display name",
|
|
menuPort: "Change the service port",
|
|
menuPath: "Optional service path",
|
|
menuScheme: "Switch between HTTP and HTTPS",
|
|
menuSelfSigned: "Mark the service as self-signed",
|
|
menuNotice: "Edit the notice text shown on the card",
|
|
menuNoticeLink: "Optional link for the notice",
|
|
menuSaveBtn: "Save service changes",
|
|
menuCancelBtn: "Cancel changes",
|
|
menuRemoveBtn: "Remove this service",
|
|
};
|
|
Object.entries(tips).forEach(([id, text]) => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.title = text;
|
|
});
|
|
}
|
|
|
|
// Clamp name inputs to 30 chars
|
|
[svcName, menuRename].forEach((el) => {
|
|
if (!el) return;
|
|
el.setAttribute("maxlength", "32");
|
|
el.addEventListener("input", () => {
|
|
if (el.value.length > 32) el.value = el.value.slice(0, 32);
|
|
});
|
|
});
|
|
|
|
function setUpdatesUI(enabled) {
|
|
const on = !!enabled;
|
|
updatesToggle.checked = on;
|
|
updatesStatus.textContent = on ? "On" : "Off";
|
|
updatesStatus.classList.toggle("chip-on", on);
|
|
updatesStatus.classList.toggle("chip-off", !on);
|
|
}
|
|
|
|
async function loadStatus() {
|
|
try {
|
|
const data = await getStatus();
|
|
renderStats(heroStats, data);
|
|
renderServices(servicesGrid, data.services, { openAddService });
|
|
const updatesEnabled =
|
|
data?.auto_updates?.enabled ?? data.auto_updates_enabled;
|
|
if (updatesEnabled !== undefined && !isUpdatesDirty()) {
|
|
setUpdatesUI(updatesEnabled);
|
|
}
|
|
|
|
// Updates chip + reboot note
|
|
updatesFlagEl(
|
|
updatesEnabled === undefined ? null : updatesEnabled === true,
|
|
);
|
|
const cfg = data.updates_config || {};
|
|
const rebootReq = data.reboot_required;
|
|
setTempFlag(data.cpu_temp_c);
|
|
if (updatesNoteTop) {
|
|
updatesNoteTop.textContent = "";
|
|
updatesNoteTop.classList.remove("note-warn");
|
|
if (rebootReq) {
|
|
if (cfg.auto_reboot) {
|
|
updatesNoteTop.textContent = `Reboot scheduled at ${cfg.reboot_time || "02:00"}.`;
|
|
} else {
|
|
updatesNoteTop.textContent = "Reboot required. Please reboot when you can.";
|
|
updatesNoteTop.classList.add("note-warn");
|
|
}
|
|
}
|
|
}
|
|
if (readyOverlay) {
|
|
if (data.ready) {
|
|
readyOverlay.classList.add("hidden");
|
|
} else {
|
|
readyOverlay.classList.remove("hidden");
|
|
// When not ready, retry periodically until API reports ready
|
|
setTimeout(loadStatus, 3000);
|
|
}
|
|
}
|
|
// Pull Pi-Kit release status after core status
|
|
loadReleaseStatus();
|
|
} catch (e) {
|
|
console.error(e);
|
|
renderStats(heroStats, placeholderStatus);
|
|
}
|
|
}
|
|
|
|
function setTempFlag(tempC) {
|
|
if (!tempFlagTop) return;
|
|
const t = typeof tempC === "number" ? tempC : null;
|
|
let label = "Temp: n/a";
|
|
tempFlagTop.classList.remove("chip-on", "chip-warm", "chip-off");
|
|
if (t !== null) {
|
|
if (t < 55) {
|
|
label = "Temp: OK";
|
|
tempFlagTop.classList.add("chip-on");
|
|
} else if (t < 70) {
|
|
label = "Temp: Warm";
|
|
tempFlagTop.classList.add("chip-warm");
|
|
} else {
|
|
label = "Temp: Hot";
|
|
tempFlagTop.classList.add("chip-off");
|
|
}
|
|
}
|
|
tempFlagTop.textContent = label;
|
|
}
|
|
|
|
function updatesFlagEl(enabled) {
|
|
if (!updatesFlagTop) return;
|
|
updatesFlagTop.textContent = "Auto updates";
|
|
updatesFlagTop.classList.remove("chip-on", "chip-off");
|
|
if (enabled === true) updatesFlagTop.classList.add("chip-on");
|
|
else if (enabled === false) updatesFlagTop.classList.add("chip-off");
|
|
}
|
|
|
|
async function loadReleaseStatus() {
|
|
if (!releaseFlagTop) return;
|
|
setReleaseChip({ status: "checking" });
|
|
try {
|
|
const data = await getReleaseStatus();
|
|
const {
|
|
current_version = "n/a",
|
|
latest_version = "n/a",
|
|
status = "unknown",
|
|
message = "",
|
|
auto_check = false,
|
|
progress = null,
|
|
} = data || {};
|
|
window.__lastReleaseState = data;
|
|
setReleaseChip(data);
|
|
if (releaseCurrent) releaseCurrent.textContent = current_version;
|
|
if (releaseLatest) releaseLatest.textContent = latest_version;
|
|
if (releaseStatusMsg) {
|
|
releaseStatusMsg.textContent =
|
|
status === "update_available"
|
|
? message || "Update available"
|
|
: status === "up_to_date"
|
|
? "Up to date"
|
|
: message || status;
|
|
releaseStatusMsg.classList.toggle("error", status === "error");
|
|
}
|
|
if (releaseAutoCheck) releaseAutoCheck.checked = !!auto_check;
|
|
if (releaseProgress) {
|
|
releaseProgress.textContent = progress ? progress : "";
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to load release status", e);
|
|
setReleaseChip({ status: "error", message: "Failed to load" });
|
|
if (releaseStatusMsg) {
|
|
releaseStatusMsg.textContent = "Failed to load release status";
|
|
releaseStatusMsg.classList.add("error");
|
|
}
|
|
}
|
|
}
|
|
|
|
function setReleaseChip(state) {
|
|
if (!releaseFlagTop) return;
|
|
releaseFlagTop.textContent = "Pi-Kit: n/a";
|
|
releaseFlagTop.className = "status-chip quiet";
|
|
if (!state) return;
|
|
const { status, latest_version, current_version, message } = state;
|
|
const label =
|
|
status === "update_available"
|
|
? `Update → ${latest_version || "new"}`
|
|
: status === "up_to_date"
|
|
? `Pi-Kit: ${current_version || "latest"}`
|
|
: status === "checking"
|
|
? "Checking…"
|
|
: status === "error"
|
|
? "Update error"
|
|
: `Pi-Kit: ${current_version || "n/a"}`;
|
|
releaseFlagTop.textContent = label;
|
|
if (status === "update_available") releaseFlagTop.classList.add("chip-warm");
|
|
if (status === "error") releaseFlagTop.classList.add("chip-off");
|
|
releaseFlagTop.title = message || "Pi-Kit release status";
|
|
}
|
|
|
|
function wireModals() {
|
|
advBtn.onclick = () => advModal.classList.remove("hidden");
|
|
advClose.onclick = () => advModal.classList.add("hidden");
|
|
helpBtn.onclick = () => helpModal.classList.remove("hidden");
|
|
helpClose.onclick = () => helpModal.classList.add("hidden");
|
|
aboutBtn.onclick = () => aboutModal.classList.remove("hidden");
|
|
aboutClose.onclick = () => aboutModal.classList.add("hidden");
|
|
menuClose.onclick = () => menuModal.classList.add("hidden");
|
|
addServiceOpen?.addEventListener("click", openAddService);
|
|
addSvcClose?.addEventListener("click", () => addServiceModal?.classList.add("hidden"));
|
|
addServiceModal?.addEventListener("click", (e) => {
|
|
if (e.target === addServiceModal) addServiceModal.classList.add("hidden");
|
|
});
|
|
releaseBtn?.addEventListener("click", () => {
|
|
releaseModal?.classList.remove("hidden");
|
|
loadReleaseStatus();
|
|
});
|
|
releaseClose?.addEventListener("click", () => releaseModal?.classList.add("hidden"));
|
|
releaseModal?.addEventListener("click", (e) => {
|
|
if (e.target === releaseModal) releaseModal.classList.add("hidden");
|
|
});
|
|
}
|
|
|
|
function wireReleaseControls() {
|
|
releaseCheckBtn?.addEventListener("click", async () => {
|
|
try {
|
|
if (releaseProgress) releaseProgress.textContent = "Checking for updates…";
|
|
await checkRelease();
|
|
await loadReleaseStatus();
|
|
showToast("Checked for updates", "success");
|
|
} catch (e) {
|
|
showToast(e.error || "Check failed", "error");
|
|
} finally {
|
|
if (releaseProgress) releaseProgress.textContent = "";
|
|
}
|
|
});
|
|
|
|
releaseApplyBtn?.addEventListener("click", async () => {
|
|
try {
|
|
showBusy("Updating Pi-Kit…", "Applying release. This can take up to a minute.");
|
|
if (releaseProgress) releaseProgress.textContent = "Downloading and installing…";
|
|
await applyRelease();
|
|
pollReleaseStatus();
|
|
showToast("Update started", "success");
|
|
} catch (e) {
|
|
showToast(e.error || "Update failed", "error");
|
|
} finally {
|
|
if (releaseProgress) releaseProgress.textContent = "";
|
|
}
|
|
});
|
|
|
|
releaseRollbackBtn?.addEventListener("click", async () => {
|
|
try {
|
|
showBusy("Rolling back…", "Restoring previous backup.");
|
|
if (releaseProgress) releaseProgress.textContent = "Rolling back…";
|
|
await rollbackRelease();
|
|
pollReleaseStatus();
|
|
showToast("Rollback started", "success");
|
|
} catch (e) {
|
|
showToast(e.error || "Rollback failed", "error");
|
|
} finally {
|
|
if (releaseProgress) releaseProgress.textContent = "";
|
|
}
|
|
});
|
|
|
|
releaseAutoCheck?.addEventListener("change", async () => {
|
|
try {
|
|
await setReleaseAutoCheck(releaseAutoCheck.checked);
|
|
showToast("Auto-check preference saved", "success");
|
|
} catch (e) {
|
|
showToast(e.error || "Failed to save preference", "error");
|
|
releaseAutoCheck.checked = !releaseAutoCheck.checked;
|
|
}
|
|
});
|
|
}
|
|
|
|
function pollReleaseStatus() {
|
|
let attempts = 0;
|
|
const maxAttempts = 30; // ~1 min at 2s
|
|
const tick = async () => {
|
|
attempts += 1;
|
|
await loadReleaseStatus();
|
|
const state = window.__lastReleaseState || {};
|
|
if (state.status === "in_progress" && attempts < maxAttempts) {
|
|
setTimeout(tick, 2000);
|
|
} else {
|
|
hideBusy();
|
|
if (state.status === "up_to_date") {
|
|
showToast("Update complete", "success");
|
|
} else if (state.status === "error") {
|
|
showToast(state.message || "Update failed", "error");
|
|
}
|
|
}
|
|
};
|
|
tick();
|
|
}
|
|
|
|
function showBusy(title = "Working…", text = "This may take a few seconds.") {
|
|
if (!busyOverlay) return;
|
|
busyTitle.textContent = title;
|
|
busyText.textContent = text || "";
|
|
busyText.classList.toggle("hidden", !text);
|
|
busyOverlay.classList.remove("hidden");
|
|
}
|
|
|
|
function hideBusy() {
|
|
busyOverlay?.classList.add("hidden");
|
|
}
|
|
|
|
// Testing hook
|
|
if (typeof window !== "undefined") {
|
|
window.__pikitTest = window.__pikitTest || {};
|
|
window.__pikitTest.showBusy = showBusy;
|
|
window.__pikitTest.hideBusy = hideBusy;
|
|
}
|
|
|
|
function wireResetAndUpdates() {
|
|
resetBtn.onclick = async () => {
|
|
resetBtn.disabled = true;
|
|
try {
|
|
await triggerReset(resetConfirm.value.trim());
|
|
alert("Resetting now. The device will reboot.");
|
|
} catch (e) {
|
|
alert(e.error || "Reset failed");
|
|
} finally {
|
|
resetBtn.disabled = false;
|
|
}
|
|
};
|
|
|
|
resetConfirm.addEventListener("input", () => {
|
|
resetBtn.disabled = resetConfirm.value.trim() !== "YES";
|
|
});
|
|
}
|
|
|
|
function wireAccordions() {
|
|
const forceOpen = typeof window !== "undefined" && window.__pikitTest?.forceAccordionsOpen;
|
|
const accordions = document.querySelectorAll(".accordion");
|
|
if (forceOpen) {
|
|
accordions.forEach((a) => a.classList.add("open"));
|
|
return;
|
|
}
|
|
document.querySelectorAll(".accordion-toggle").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const acc = btn.closest(".accordion");
|
|
if (acc.classList.contains("open")) {
|
|
acc.classList.remove("open");
|
|
} else {
|
|
// Keep a single accordion expanded at a time for readability
|
|
accordions.forEach((a) => a.classList.remove("open"));
|
|
acc.classList.add("open");
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function collapseAccordions() {
|
|
document.querySelectorAll(".accordion").forEach((a) => a.classList.remove("open"));
|
|
}
|
|
|
|
function openAddService() {
|
|
if (addServiceModal) addServiceModal.classList.remove("hidden");
|
|
document.getElementById("svcName")?.focus();
|
|
}
|
|
if (typeof window !== "undefined") {
|
|
window.__pikitOpenAddService = openAddService;
|
|
}
|
|
|
|
function main() {
|
|
applyTooltips();
|
|
wireModals();
|
|
wireReleaseControls();
|
|
wireResetAndUpdates();
|
|
wireAccordions();
|
|
loadToastSettings();
|
|
|
|
if (advClose) {
|
|
advClose.onclick = () => {
|
|
advModal.classList.add("hidden");
|
|
collapseAccordions();
|
|
};
|
|
}
|
|
|
|
initServiceControls({
|
|
gridEl: servicesGrid,
|
|
onChange: loadStatus,
|
|
overlay: { show: showBusy, hide: hideBusy },
|
|
toast: showToast,
|
|
openAddService,
|
|
menu: {
|
|
modal: menuModal,
|
|
title: menuTitle,
|
|
subtitle: menuSubtitle,
|
|
renameInput: menuRename,
|
|
portInput: menuPort,
|
|
pathInput: menuPath,
|
|
schemeSelect: menuScheme,
|
|
saveBtn: menuSaveBtn,
|
|
cancelBtn: menuCancelBtn,
|
|
removeBtn: menuRemoveBtn,
|
|
msg: menuMsg,
|
|
noticeInput: menuNotice,
|
|
noticeLinkInput: menuNoticeLink,
|
|
selfSignedInput: menuSelfSigned,
|
|
},
|
|
addForm: {
|
|
nameInput: svcName,
|
|
portInput: svcPort,
|
|
pathInput: svcPath,
|
|
schemeSelect: svcScheme,
|
|
addBtn: svcAddBtn,
|
|
msg: svcMsg,
|
|
noticeInput: svcNotice,
|
|
noticeLinkInput: svcNoticeLink,
|
|
selfSignedInput: svcSelfSigned,
|
|
},
|
|
});
|
|
|
|
initSettings({
|
|
refreshHintMain,
|
|
refreshHintServices,
|
|
refreshFlagTop,
|
|
refreshIntervalInput,
|
|
refreshIntervalSave,
|
|
refreshIntervalMsg,
|
|
themeToggle,
|
|
themeToggleIcon,
|
|
animToggle,
|
|
onTick: loadStatus,
|
|
toast: showToast,
|
|
onThemeToggle: () => {
|
|
document.body.classList.add("theming");
|
|
setTimeout(() => document.body.classList.remove("theming"), 300);
|
|
},
|
|
});
|
|
|
|
// Toast controls
|
|
toastPosSelect?.addEventListener("change", () => {
|
|
const val = toastPosSelect.value;
|
|
if (ALLOWED_TOAST_POS.includes(val)) {
|
|
toastPosition = val;
|
|
applyToastSettings();
|
|
persistToastSettings();
|
|
} else {
|
|
toastPosSelect.value = toastPosition;
|
|
showToast("Invalid toast position", "error");
|
|
}
|
|
});
|
|
toastAnimSelect?.addEventListener("change", () => {
|
|
let val = toastAnimSelect.value;
|
|
if (val === "slide-up" || val === "slide-left" || val === "slide-right") val = "slide-in"; // migrate old label if cached
|
|
if (ALLOWED_TOAST_ANIM.includes(val)) {
|
|
toastAnimation = val;
|
|
persistToastSettings();
|
|
} else {
|
|
toastAnimSelect.value = toastAnimation;
|
|
showToast("Invalid toast animation", "error");
|
|
}
|
|
});
|
|
const clampSpeed = (val) => Math.min(3000, Math.max(100, val));
|
|
const clampDuration = (val) => Math.min(15000, Math.max(1000, val));
|
|
|
|
toastSpeedInput?.addEventListener("input", () => {
|
|
const raw = toastSpeedInput.value;
|
|
if (raw === "") return; // allow typing
|
|
const val = Number(raw);
|
|
if (Number.isNaN(val) || val < 100 || val > 3000) return; // wait until valid
|
|
toastSpeedMs = val;
|
|
applyToastSettings();
|
|
persistToastSettings();
|
|
});
|
|
toastSpeedInput?.addEventListener("blur", () => {
|
|
const raw = toastSpeedInput.value;
|
|
if (raw === "") {
|
|
toastSpeedInput.value = toastSpeedMs;
|
|
return;
|
|
}
|
|
const val = Number(raw);
|
|
if (Number.isNaN(val) || val < 100 || val > 3000) {
|
|
toastSpeedMs = clampSpeed(toastSpeedMs);
|
|
toastSpeedInput.value = toastSpeedMs;
|
|
showToast("Toast speed must be 100-3000 ms", "error");
|
|
return;
|
|
}
|
|
toastSpeedMs = val;
|
|
toastSpeedInput.value = toastSpeedMs;
|
|
applyToastSettings();
|
|
persistToastSettings();
|
|
});
|
|
|
|
toastDurationInput?.addEventListener("input", () => {
|
|
const raw = toastDurationInput.value;
|
|
if (raw === "") return; // allow typing
|
|
const val = Number(raw);
|
|
if (Number.isNaN(val) || val < 1000 || val > 15000) return; // wait until valid
|
|
toastDurationMs = val;
|
|
persistToastSettings();
|
|
});
|
|
toastDurationInput?.addEventListener("blur", () => {
|
|
const raw = toastDurationInput.value;
|
|
if (raw === "") {
|
|
toastDurationInput.value = toastDurationMs;
|
|
return;
|
|
}
|
|
const val = Number(raw);
|
|
if (Number.isNaN(val) || val < 1000 || val > 15000) {
|
|
toastDurationMs = clampDuration(toastDurationMs);
|
|
toastDurationInput.value = toastDurationMs;
|
|
showToast("Toast duration must be 1000-15000 ms", "error");
|
|
return;
|
|
}
|
|
toastDurationMs = val;
|
|
toastDurationInput.value = toastDurationMs;
|
|
persistToastSettings();
|
|
});
|
|
fontSelect?.addEventListener("change", () => {
|
|
const val = fontSelect.value;
|
|
if (!ALLOWED_FONTS.includes(val)) {
|
|
fontSelect.value = fontChoice;
|
|
showToast("Invalid font choice", "error");
|
|
return;
|
|
}
|
|
fontChoice = val;
|
|
applyFontSetting();
|
|
persistToastSettings();
|
|
});
|
|
toastTestBtn?.addEventListener("click", () => showToast("This is a test toast", "info"));
|
|
|
|
initUpdateSettings({
|
|
elements: {
|
|
updatesStatus,
|
|
updatesToggle,
|
|
scopeSelect: updatesScope,
|
|
updateTimeInput,
|
|
upgradeTimeInput,
|
|
cleanupToggle: updatesCleanup,
|
|
bandwidthInput: updatesBandwidth,
|
|
rebootToggle: updatesRebootToggle,
|
|
rebootTimeInput: updatesRebootTime,
|
|
rebootWithUsersToggle: updatesRebootUsers,
|
|
saveBtn: updatesSaveBtn,
|
|
msgEl: updatesMsg,
|
|
updatesUnsavedNote,
|
|
updatesSection,
|
|
},
|
|
onAfterSave: loadStatus,
|
|
overlay: { show: showBusy, hide: hideBusy },
|
|
toast: showToast,
|
|
});
|
|
|
|
// initial paint
|
|
renderStats(heroStats, placeholderStatus);
|
|
loadStatus();
|
|
}
|
|
|
|
main();
|