Add dashboard UI updates and settings modal

This commit is contained in:
Aaron
2025-12-10 18:51:31 -05:00
commit c85df728b7
54 changed files with 7151 additions and 0 deletions

89
pikit-web/assets/api.js Normal file
View File

@@ -0,0 +1,89 @@
// Lightweight fetch wrapper for the Pi-Kit API endpoints exposed by the mock server
// and on-device Python API. All helpers below return parsed JSON or throw the
// JSON error body when the response is not 2xx.
const headers = { "Content-Type": "application/json" };
export async function api(path, opts = {}) {
// When running `npm run dev` without the backend, allow mock JSON from /data/
const isMock = import.meta?.env?.MODE === 'development' && path.startsWith('/api');
const target = isMock
? path.replace('/api/status', '/data/mock-status.json').replace('/api/updates/config', '/data/mock-updates.json')
: path;
const res = await fetch(target, { headers, ...opts });
// If mock files are missing, surface a clear error instead of JSON parse of HTML
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch (e) {
throw new Error(`Expected JSON from ${target}, got: ${text.slice(0, 120)}...`);
}
if (!res.ok) throw data;
return data;
}
export const getStatus = () => api("/api/status");
export const toggleUpdates = (enable) =>
api("/api/updates/auto", {
method: "POST",
body: JSON.stringify({ enable }),
});
export const getUpdateConfig = () => api("/api/updates/config");
export const saveUpdateConfig = (config) =>
api("/api/updates/config", {
method: "POST",
body: JSON.stringify(config),
});
export const triggerReset = (confirm) =>
api("/api/reset", {
method: "POST",
body: JSON.stringify({ confirm }),
});
export const addService = ({
name,
port,
scheme,
path,
notice,
notice_link,
self_signed,
}) =>
api("/api/services/add", {
method: "POST",
body: JSON.stringify({ name, port, scheme, path, notice, notice_link, self_signed }),
});
export const updateService = ({
port,
name,
new_port,
scheme,
path,
notice,
notice_link,
self_signed,
}) =>
api("/api/services/update", {
method: "POST",
body: JSON.stringify({
port,
name,
new_port,
scheme,
path,
notice,
notice_link,
self_signed,
}),
});
export const removeService = ({ port }) =>
api("/api/services/remove", {
method: "POST",
body: JSON.stringify({ port }),
});

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,101 @@
This package includes open fonts licensed under the SIL Open Font License, Version 1.1.
Fonts covered (used by Pi-Kit):
- Red Hat Display
- Red Hat Text
- Space Grotesk
- Manrope
- DM Sans
- Sora
- Chivo
- Atkinson Hyperlegible
- IBM Plex Sans
Full license text:
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

635
pikit-web/assets/main.js Normal file
View File

@@ -0,0 +1,635 @@
// Entry point for the dashboard: wires UI events, pulls status, and initializes
// feature modules (services, settings, stats).
import { getStatus, triggerReset } 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 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 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 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.",
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);
}
}
} 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");
}
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");
});
}
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();
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();

View File

@@ -0,0 +1,361 @@
import { addService, updateService, removeService } from "./api.js";
// Renders service cards and wires UI controls for add/edit/remove operations.
// All mutations round-trip through the API then invoke onChange to refresh data.
let noticeModalRefs = null;
const DEFAULT_SELF_SIGNED_MSG =
"This service uses a self-signed certificate. Expect a browser warning; proceed only if you trust this device.";
function isValidLink(str) {
if (!str) return true; // empty is allowed
try {
const u = new URL(str);
return u.protocol === "http:" || u.protocol === "https:";
} catch (e) {
return false;
}
}
function normalizePath(path) {
if (!path) return "";
const trimmed = path.trim();
if (!trimmed) return "";
if (/\s/.test(trimmed)) return null; // no spaces or tabs in paths
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return null;
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
function validateServiceFields({ name, port, path, notice, notice_link }, setMsg, toast) {
const fail = (m) => {
setMsg("");
toast?.(m, "error");
return false;
};
if (!name || name.trim().length < 2) return fail("Name must be at least 2 characters.");
if (name.length > 48) return fail("Name is too long (max 48 chars).");
if (!Number.isInteger(port) || port < 1 || port > 65535) return fail("Port must be 1-65535.");
if (path === null) return fail("Path must be relative (e.g. /admin) or blank.");
if (path.length > 200) return fail("Path is too long (max 200 chars).");
if (notice && notice.length > 180) return fail("Notice text too long (max 180 chars).");
if (notice_link && notice_link.length > 200) return fail("Notice link too long (max 200 chars).");
if (!isValidLink(notice_link)) return fail("Enter a valid URL (http/https) or leave blank.");
return true;
}
function ensureNoticeModal() {
if (noticeModalRefs) return noticeModalRefs;
const modal = document.createElement("div");
modal.className = "modal hidden";
modal.id = "noticeModal";
modal.innerHTML = `
<div class="modal-card">
<div class="panel-header">
<div>
<p class="eyebrow">Service notice</p>
<h3 id="noticeTitle"></h3>
</div>
<button class="ghost icon-btn close-btn" id="noticeClose" title="Close">&times;</button>
</div>
<div class="modal-body">
<p id="noticeText" class="hint"></p>
<a id="noticeLink" class="notice-link" target="_blank" rel="noopener"></a>
</div>
</div>
`;
document.body.appendChild(modal);
const title = modal.querySelector("#noticeTitle");
const text = modal.querySelector("#noticeText");
const link = modal.querySelector("#noticeLink");
const closeBtn = modal.querySelector("#noticeClose");
const close = () => modal.classList.add("hidden");
closeBtn.addEventListener("click", close);
modal.addEventListener("click", (e) => {
if (e.target === modal) close();
});
noticeModalRefs = { modal, title, text, link, close };
return noticeModalRefs;
}
export function renderServices(gridEl, services = [], { openAddService } = {}) {
if (!gridEl) return;
gridEl.innerHTML = "";
if (!services.length) {
gridEl.classList.add("empty");
gridEl.innerHTML = `
<div class="empty-state">
<p>No web services detected yet.</p>
<button id="addSvcCta">Add a service</button>
</div>`;
const cta = gridEl.querySelector("#addSvcCta");
cta?.addEventListener("click", () => {
if (typeof window !== "undefined" && window.__pikitOpenAddService) {
window.__pikitOpenAddService();
} else if (typeof openAddService === "function") {
openAddService();
}
});
return;
}
gridEl.classList.remove("empty");
services.forEach((svc) => {
const card = document.createElement("div");
const scheme = svc.scheme === "https" ? "https" : "http";
const path = normalizePath(svc.path) || "";
const url = svc.url || `${scheme}://pikit:${svc.port}${path}`;
const nameRaw = svc.name || svc.process || "service";
const name = nameRaw.slice(0, 32);
const isSelfSigned = !!svc.self_signed;
const hasCustomNotice = !!(svc.notice && svc.notice.trim());
const noticeText = hasCustomNotice ? svc.notice.trim() : "";
const noticeLink = hasCustomNotice ? svc.notice_link || "" : "";
card.className = `card clickable ${svc.online ? "" : "offline"}`.trim();
card.dataset.url = url;
card.dataset.path = path;
card.tabIndex = 0;
card.innerHTML = `
<div class="service-header">
<div class="status-dot ${svc.online ? "on" : "off"}"></div>
<div class="pill" title="${nameRaw}">${name}</div>
</div>
<div class="service-url">${url}</div>
<p class="hint">Port ${svc.port}</p>
${
isSelfSigned
? `<span class="notice-pill self-signed-pill" title="${DEFAULT_SELF_SIGNED_MSG}">Self-signed</span>`
: ""
}
<div class="service-menu">
${
hasCustomNotice
? `<button class="ghost info-btn" title="Notice" data-notice="${encodeURIComponent(
noticeText,
)}" data-link="${encodeURIComponent(noticeLink)}"></button>`
: ""
}
<button class="ghost menu-btn" title="Service actions" data-port="${svc.port}" data-name="${svc.name || ""}" data-scheme="${scheme}" data-path="${encodeURIComponent(
path,
)}" data-notice="${encodeURIComponent(svc.notice || "")}" data-notice-link="${encodeURIComponent(
svc.notice_link || "",
)}" data-self-signed="${svc.self_signed ? "1" : "0"}">⋮</button>
</div>
`;
gridEl.appendChild(card);
});
}
export function initServiceControls({ gridEl, menu, addForm, onChange, overlay, toast, openAddService }) {
if (!gridEl) return;
// Tracks which card was opened in the context menu
let menuContext = null;
const showBusy = overlay?.show || (() => {});
const hideBusy = overlay?.hide || (() => {});
const {
modal,
title,
subtitle,
renameInput,
portInput,
pathInput,
schemeSelect,
saveBtn,
cancelBtn,
removeBtn,
msg,
noticeInput,
noticeLinkInput,
selfSignedInput,
} = menu;
const {
nameInput,
portInput: addPortInput,
pathInput: addPathInput,
schemeSelect: addSchemeSelect,
addBtn,
msg: addMsg,
noticeInput: addNoticeInput,
noticeLinkInput: addNoticeLinkInput,
selfSignedInput: addSelfSignedInput,
} = addForm;
function enforceTlsCheckbox(selectEl, checkbox) {
if (!selectEl || !checkbox) return;
const update = () => {
const isHttps = selectEl.value === "https";
checkbox.disabled = !isHttps;
if (!isHttps) checkbox.checked = false;
};
selectEl.addEventListener("change", update);
update();
}
gridEl.addEventListener("click", (e) => {
const btn = e.target.closest(".menu-btn");
if (!btn) return;
const port = Number(btn.dataset.port);
const name = btn.dataset.name || "";
const schemeRaw = btn.dataset.scheme || "http";
const scheme = schemeRaw === "https" ? "https" : "http";
const path = decodeURIComponent(btn.dataset.path || "").trim();
const notice = decodeURIComponent(btn.dataset.notice || "").trim();
const notice_link = decodeURIComponent(btn.dataset.noticeLink || "").trim();
const self_signed = btn.dataset.selfSigned === "1";
menuContext = { port, name, scheme, path };
if (title) title.textContent = `${name || "Service"} (${scheme}://${port})`;
if (subtitle) {
const p = normalizePath(path) || "";
subtitle.textContent = `Current link: ${scheme}://pikit:${port}${p}`;
}
if (renameInput) renameInput.value = name;
if (portInput) portInput.value = port;
if (pathInput) pathInput.value = path;
if (schemeSelect) schemeSelect.value = scheme;
if (noticeInput) noticeInput.value = notice || "";
if (noticeLinkInput) noticeLinkInput.value = notice_link || "";
if (selfSignedInput) selfSignedInput.checked = !!self_signed;
if (msg) msg.textContent = "";
modal?.classList.remove("hidden");
});
gridEl.addEventListener("click", (e) => {
if (e.target.closest(".menu-btn") || e.target.closest(".info-btn") || e.target.closest("a")) return;
const card = e.target.closest(".card.clickable");
if (!card) return;
const url = card.dataset.url;
if (url) window.open(url, "_blank", "noopener");
});
gridEl.addEventListener("keydown", (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
const card = e.target.closest(".card.clickable");
if (!card || e.target.closest(".menu-btn")) return;
e.preventDefault();
const url = card.dataset.url;
if (url) window.open(url, "_blank", "noopener");
});
gridEl.addEventListener("click", (e) => {
const infoBtn = e.target.closest(".info-btn");
if (!infoBtn) return;
e.stopPropagation();
const text = decodeURIComponent(infoBtn.dataset.notice || "").trim();
const link = decodeURIComponent(infoBtn.dataset.link || "").trim();
const { modal, title, text: textEl, link: linkEl } = ensureNoticeModal();
title.textContent = infoBtn.getAttribute("title") || "Notice";
textEl.textContent = text || "No additional info.";
if (link) {
linkEl.textContent = "More info";
linkEl.href = link;
linkEl.classList.remove("hidden");
} else {
linkEl.textContent = "";
linkEl.href = "";
linkEl.classList.add("hidden");
}
modal.classList.remove("hidden");
});
enforceTlsCheckbox(schemeSelect, selfSignedInput);
enforceTlsCheckbox(addSchemeSelect, addSelfSignedInput);
async function menuAction(action, body = {}) {
if (!menuContext) return;
msg.textContent = "";
try {
const isRemove = action === "remove";
const isSave = action === "save";
if (isRemove) showBusy("Removing service", "Updating firewall rules…");
if (isSave) showBusy("Saving service", "Opening/closing firewall rules as needed…");
if (action === "remove") {
await removeService({ port: menuContext.port });
} else {
await updateService({
port: menuContext.port,
name: body.name,
new_port: body.new_port,
scheme: body.scheme,
path: body.path,
notice: body.notice,
notice_link: body.notice_link,
self_signed: body.self_signed,
});
}
msg.textContent = "";
toast?.(isRemove ? "Service removed" : "Service saved", "success");
modal?.classList.add("hidden");
menuContext = null;
await onChange?.();
} catch (e) {
const err = e.error || "Action failed.";
msg.textContent = "";
toast?.(err, "error");
} finally {
hideBusy();
}
}
saveBtn?.addEventListener("click", () => {
if (!menuContext) return;
const name = (renameInput?.value || "").trim();
const new_port = Number(portInput?.value);
const scheme = schemeSelect?.value === "https" ? "https" : "http";
const pathRaw = pathInput?.value ?? "";
const path = normalizePath(pathRaw);
const notice = (noticeInput?.value || "").trim();
const notice_link = (noticeLinkInput?.value || "").trim();
const self_signed = !!selfSignedInput?.checked;
if (
!validateServiceFields(
{ name, port: new_port, path, notice, notice_link },
() => {},
toast,
)
)
return;
menuAction("save", { name, new_port, scheme, path, notice, notice_link, self_signed });
});
cancelBtn?.addEventListener("click", () => {
modal?.classList.add("hidden");
msg.textContent = "";
menuContext = null;
});
removeBtn?.addEventListener("click", () => menuAction("remove"));
addBtn?.addEventListener("click", async () => {
addMsg.textContent = "";
const name = (nameInput?.value || "").trim();
const port = Number(addPortInput?.value);
const scheme = addSchemeSelect?.value === "https" ? "https" : "http";
const pathRaw = addPathInput?.value ?? "";
const path = normalizePath(pathRaw);
const notice = (addNoticeInput?.value || "").trim();
const notice_link = (addNoticeLinkInput?.value || "").trim();
const self_signed = !!addSelfSignedInput?.checked;
if (
!validateServiceFields(
{ name, port, path, notice, notice_link },
() => {},
toast,
)
)
return;
addBtn.disabled = true;
try {
showBusy("Adding service", "Opening firewall rules…");
await addService({ name, port, scheme, path, notice, notice_link, self_signed });
addMsg.textContent = "";
toast?.("Service added", "success");
await onChange?.();
} catch (e) {
const err = e.error || "Failed to add.";
addMsg.textContent = "";
toast?.(err, "error");
} finally {
addBtn.disabled = false;
hideBusy();
}
});
}

View File

@@ -0,0 +1,130 @@
// Handles user-facing settings (theme, motion, refresh cadence) and persistence
// across reloads. Keeps side effects isolated so main.js simply wires callbacks.
const DEFAULT_REFRESH_SEC = 10;
const MIN_REFRESH_SEC = 5;
const MAX_REFRESH_SEC = 120;
const THEME_KEY = "pikit-theme";
const ANIM_KEY = "pikit-anim";
const REFRESH_KEY = "pikit-refresh-sec";
export function initSettings({
refreshHintMain,
refreshHintServices,
refreshFlagTop,
refreshIntervalInput,
refreshIntervalSave,
refreshIntervalMsg,
themeToggle,
themeToggleIcon,
animToggle,
onTick,
toast = null,
defaultIntervalSec = DEFAULT_REFRESH_SEC,
}) {
let refreshIntervalMs = defaultIntervalSec * 1000;
let refreshTimer = null;
const prefersReduce =
typeof window !== "undefined" &&
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
function updateRefreshHints(seconds) {
const text = `${seconds} second${seconds === 1 ? "" : "s"}`;
if (refreshHintMain) refreshHintMain.textContent = text;
if (refreshHintServices) refreshHintServices.textContent = text;
if (refreshFlagTop) refreshFlagTop.textContent = `Refresh: ${seconds}s`;
if (refreshIntervalInput) refreshIntervalInput.value = seconds;
}
function setRefreshInterval(seconds, { silent = false } = {}) {
const sec = Math.max(
MIN_REFRESH_SEC,
Math.min(MAX_REFRESH_SEC, Math.floor(seconds)),
);
// Clamp to safe bounds; store milliseconds for setInterval
refreshIntervalMs = sec * 1000;
if (refreshTimer) clearInterval(refreshTimer);
if (onTick) {
refreshTimer = setInterval(onTick, refreshIntervalMs);
}
updateRefreshHints(sec);
try {
localStorage.setItem(REFRESH_KEY, String(sec));
} catch (e) {
console.warn("Refresh persistence unavailable", e);
}
if (!silent && refreshIntervalMsg) {
refreshIntervalMsg.textContent = "";
}
}
refreshIntervalSave?.addEventListener("click", () => {
if (!refreshIntervalInput) return;
const sec = Number(refreshIntervalInput.value);
if (Number.isNaN(sec)) {
if (refreshIntervalMsg) refreshIntervalMsg.textContent = "";
toast?.("Enter seconds.", "error");
return;
}
setRefreshInterval(sec);
onTick?.(); // immediate refresh on change
if (refreshIntervalMsg) refreshIntervalMsg.textContent = "";
toast?.("Refresh interval updated", "success");
});
function applyTheme(mode) {
const theme = mode === "light" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", theme);
if (themeToggleIcon)
themeToggleIcon.textContent = theme === "light" ? "☀️" : "🌙";
try {
localStorage.setItem(THEME_KEY, theme);
} catch (e) {
console.warn("Theme persistence unavailable", e);
}
}
themeToggle?.addEventListener("click", () => {
const current =
document.documentElement.getAttribute("data-theme") || "dark";
applyTheme(current === "dark" ? "light" : "dark");
});
function applyAnim(enabled) {
const on = enabled !== false; // default true
// Using a data attribute lets CSS fully disable motion when off
document.documentElement.setAttribute("data-anim", on ? "on" : "off");
if (animToggle) animToggle.checked = on;
try {
localStorage.setItem(ANIM_KEY, on ? "on" : "off");
} catch (e) {
console.warn("Anim persistence unavailable", e);
}
}
animToggle?.addEventListener("change", () => {
applyAnim(animToggle.checked);
});
// Initialize defaults
let storedRefresh = defaultIntervalSec;
try {
const saved = localStorage.getItem(REFRESH_KEY);
if (saved) {
const n = Number(saved);
if (!Number.isNaN(n)) storedRefresh = n;
}
} catch (e) {
console.warn("Refresh persistence unavailable", e);
}
updateRefreshHints(storedRefresh);
setRefreshInterval(storedRefresh, { silent: true });
applyTheme(localStorage.getItem(THEME_KEY) || "dark");
const storedAnim = localStorage.getItem(ANIM_KEY);
const animDefault =
storedAnim === "on" || storedAnim === "off"
? storedAnim === "on"
: !prefersReduce; // respect system reduce-motion if no user choice
applyAnim(animDefault);
return { setRefreshInterval, applyTheme };
}

View File

@@ -0,0 +1,77 @@
// Small helpers for rendering the status summary cards on the dashboard.
export const placeholderStatus = {
hostname: "Pi-Kit",
uptime_seconds: 0,
os_version: "Pi-Kit",
cpu_temp_c: null,
memory_mb: { total: 0, free: 0 },
disk_mb: { total: 0, free: 0 },
lan_ip: null,
};
function fmtUptime(sec) {
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
return `${h}h ${m}m`;
}
function fmtOs(text) {
const raw = text || "DietPi";
// If PRETTY_NAME looks like "Debian GNU/Linux 13 (trixie)"
const m = /Debian[^0-9]*?(\d+)(?:\s*\(([^)]+)\))?/i.exec(raw);
if (m) {
const version = m[1];
return `DietPi · Debian ${version}`;
}
// If already contains DietPi, keep a concise form
if (/dietpi/i.test(raw)) return raw.replace(/GNU\/Linux\s*/i, "").trim();
// Fallback: truncate to keep cards tidy
return raw.length > 30 ? `${raw.slice(0, 27)}` : raw;
}
function fmtSizeMb(mb) {
if (mb >= 1000) {
const gb = mb / 1024;
return `${gb.toFixed(gb >= 10 ? 0 : 1)} GB`;
}
return `${mb} MB`;
}
export function renderStats(container, data) {
if (!container) return;
container.innerHTML = "";
// Flatten the incoming status into label/value pairs before rendering cards
const stats = [
["Uptime", fmtUptime(data.uptime_seconds)],
["OS", fmtOs(data.os_version)],
["CPU Temp", data.cpu_temp_c ? `${data.cpu_temp_c.toFixed(1)} °C` : "n/a"],
[
"Memory",
`${fmtSizeMb(data.memory_mb.total - data.memory_mb.free)} / ${fmtSizeMb(data.memory_mb.total)}`,
],
[
"Disk",
`${fmtSizeMb(data.disk_mb.total - data.disk_mb.free)} / ${fmtSizeMb(data.disk_mb.total)}`,
],
[
"LAN / Host",
data.lan_ip
? `${data.lan_ip} (${data.hostname || "n/a"})`
: `n/a (${data.hostname || "n/a"})`,
],
];
stats.forEach(([label, value]) => {
const div = document.createElement("div");
div.className = "stat";
div.innerHTML = `<div class="label">${label}</div><div class="value">${value}</div>`;
if (label === "OS") {
const valEl = div.querySelector(".value");
if (valEl) {
valEl.style.whiteSpace = "normal";
valEl.style.wordBreak = "break-word";
valEl.style.lineHeight = "1.2";
}
}
container.appendChild(div);
});
}

1574
pikit-web/assets/style.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,344 @@
// UI controller for unattended-upgrades settings.
// Fetches current config, mirrors it into the form, and saves changes.
import { getUpdateConfig, saveUpdateConfig } from "./api.js";
const TIME_RE = /^(\d{1,2}):(\d{2})$/;
const MAX_BANDWIDTH_KBPS = 1_000_000; // 1 GB/s cap to prevent typos
const fallback = (val, def) => (val === undefined || val === null ? def : val);
let updatesDirty = false;
function isValidTime(value) {
if (!value) return false;
const m = TIME_RE.exec(value.trim());
if (!m) return false;
const h = Number(m[1]);
const mi = Number(m[2]);
return h >= 0 && h < 24 && mi >= 0 && mi < 60;
}
function normalizeTime(value, def) {
return isValidTime(value) ? value.padStart(5, "0") : def;
}
export function initUpdateSettings({
elements,
onAfterSave,
overlay = { show: () => {}, hide: () => {} },
toast = null,
}) {
const {
updatesStatus,
updatesToggle,
scopeSelect,
updateTimeInput,
upgradeTimeInput,
cleanupToggle,
bandwidthInput,
rebootToggle,
rebootTimeInput,
rebootWithUsersToggle,
saveBtn,
msgEl,
updatesUnsavedNote,
updatesSection,
updatesControls,
} = elements;
let lastConfig = null;
let saving = false;
let dirty = false;
function normalizeConfig(cfg) {
if (!cfg) return null;
return {
enable:
cfg.enable !== undefined ? !!cfg.enable : cfg.enabled !== undefined ? !!cfg.enabled : false,
scope: cfg.scope || "all",
update_time: normalizeTime(cfg.update_time, "04:00"),
upgrade_time: normalizeTime(cfg.upgrade_time, "04:30"),
cleanup: !!cfg.cleanup,
bandwidth_limit_kbps:
cfg.bandwidth_limit_kbps === null || cfg.bandwidth_limit_kbps === undefined
? null
: Number(cfg.bandwidth_limit_kbps),
auto_reboot: !!cfg.auto_reboot,
reboot_time: normalizeTime(cfg.reboot_time, "04:30"),
reboot_with_users: !!cfg.reboot_with_users,
};
}
function setStatusChip(enabled) {
const on = !!enabled;
if (updatesToggle) updatesToggle.checked = on;
if (updatesStatus) {
updatesStatus.textContent = on ? "On" : "Off";
updatesStatus.classList.toggle("chip-on", on);
updatesStatus.classList.toggle("chip-off", !on);
}
}
function setControlsEnabled(on) {
const controls = [
scopeSelect,
updateTimeInput,
upgradeTimeInput,
cleanupToggle,
bandwidthInput,
rebootToggle,
rebootTimeInput,
rebootWithUsersToggle,
];
controls.forEach((el) => {
if (el) el.disabled = !on;
});
if (updatesControls) {
updatesControls.classList.toggle("is-disabled", !on);
}
// Reboot sub-controls follow their own toggle
if (rebootToggle) {
const allowReboot = on && rebootToggle.checked;
if (rebootTimeInput) rebootTimeInput.disabled = !allowReboot;
if (rebootWithUsersToggle) rebootWithUsersToggle.disabled = !allowReboot;
}
}
function setRebootControlsState(on) {
if (rebootTimeInput) rebootTimeInput.disabled = !on;
if (rebootWithUsersToggle) rebootWithUsersToggle.disabled = !on;
}
function showMessage(text, isError = false) {
if (!msgEl) return;
msgEl.textContent = text || "";
msgEl.classList.toggle("error", isError);
if (text) {
if (toast && msgEl.id !== "updatesMsg") toast(text, isError ? "error" : "success");
setTimeout(() => (msgEl.textContent = ""), 2500);
}
}
function currentConfigFromForm() {
try {
return normalizeConfig(buildPayload());
} catch (e) {
return null;
}
}
function setDirty(on) {
dirty = !!on;
updatesDirty = dirty;
if (saveBtn) saveBtn.disabled = !dirty;
if (updatesUnsavedNote) updatesUnsavedNote.classList.toggle("hidden", !dirty);
}
function populateForm(cfg) {
lastConfig = normalizeConfig(cfg);
setStatusChip(cfg?.enabled);
setControlsEnabled(cfg?.enabled);
if (scopeSelect) scopeSelect.value = cfg.scope || "all";
if (updateTimeInput)
updateTimeInput.value = normalizeTime(cfg.update_time, "04:00");
if (upgradeTimeInput)
upgradeTimeInput.value = normalizeTime(cfg.upgrade_time, "04:30");
if (cleanupToggle) cleanupToggle.checked = !!cfg.cleanup;
if (bandwidthInput) {
bandwidthInput.value =
cfg.bandwidth_limit_kbps === null || cfg.bandwidth_limit_kbps === undefined
? ""
: cfg.bandwidth_limit_kbps;
}
if (rebootToggle) rebootToggle.checked = !!cfg.auto_reboot;
if (rebootTimeInput)
rebootTimeInput.value = normalizeTime(cfg.reboot_time, "04:30");
if (rebootWithUsersToggle)
rebootWithUsersToggle.checked = !!cfg.reboot_with_users;
setRebootControlsState(rebootToggle?.checked);
setDirty(false);
}
function buildPayload() {
const enable = updatesToggle?.checked !== false;
const scope = scopeSelect?.value === "security" ? "security" : "all";
const updateTime = normalizeTime(updateTimeInput?.value, "04:00");
const upgradeTime = normalizeTime(
upgradeTimeInput?.value || updateTime,
"04:30",
);
if (!isValidTime(updateTime) || !isValidTime(upgradeTime)) {
throw new Error("Time must be HH:MM (24h).");
}
const bwRaw = bandwidthInput?.value?.trim();
let bw = null;
if (bwRaw) {
const n = Number(bwRaw);
if (Number.isNaN(n) || n < 0) throw new Error("Bandwidth must be >= 0.");
if (n > MAX_BANDWIDTH_KBPS) {
throw new Error(`Bandwidth too high (max ${MAX_BANDWIDTH_KBPS.toLocaleString()} KB/s).`);
}
bw = n === 0 ? null : n;
}
const autoReboot = !!rebootToggle?.checked;
const rebootTime = normalizeTime(
rebootTimeInput?.value || upgradeTime,
"04:30",
);
if (autoReboot && !isValidTime(rebootTime)) {
throw new Error("Reboot time must be HH:MM (24h).");
}
return {
enable: enable !== false,
scope,
update_time: updateTime,
upgrade_time: upgradeTime,
cleanup: !!cleanupToggle?.checked,
bandwidth_limit_kbps: bw,
auto_reboot: autoReboot,
reboot_time: rebootTime,
reboot_with_users: !!rebootWithUsersToggle?.checked,
};
}
async function loadConfig() {
try {
const cfg = await getUpdateConfig();
populateForm(cfg);
} catch (e) {
console.error("Failed to load update config", e);
showMessage("Could not load update settings", true);
}
}
async function persistConfig({ overrideEnable = null } = {}) {
if (saving) return;
saving = true;
showMessage("");
try {
const payload = buildPayload();
if (overrideEnable !== null) payload.enable = !!overrideEnable;
overlay.show?.("Saving updates", "Applying unattended-upgrades settings…");
const cfg = await saveUpdateConfig(payload);
populateForm(cfg);
showMessage("Update settings saved.");
toast?.("Updates saved", "success");
onAfterSave?.();
setDirty(false);
} catch (e) {
console.error(e);
if (overrideEnable !== null && lastConfig) {
// revert toggle on failure
setStatusChip(lastConfig.enabled);
setControlsEnabled(lastConfig.enabled);
}
showMessage(e?.error || e?.message || "Save failed", true);
} finally {
saving = false;
overlay.hide?.();
}
}
updatesToggle?.addEventListener("change", () => {
setStatusChip(updatesToggle.checked);
setControlsEnabled(updatesToggle.checked);
const cfgNow = currentConfigFromForm();
setDirty(!lastConfig || (cfgNow && JSON.stringify(cfgNow) !== JSON.stringify(lastConfig)));
});
saveBtn?.addEventListener("click", async () => {
await persistConfig();
});
rebootToggle?.addEventListener("change", () => {
setRebootControlsState(rebootToggle.checked);
const cfgNow = currentConfigFromForm();
setDirty(!lastConfig || (cfgNow && JSON.stringify(cfgNow) !== JSON.stringify(lastConfig)));
});
[
scopeSelect,
updateTimeInput,
upgradeTimeInput,
cleanupToggle,
bandwidthInput,
rebootTimeInput,
rebootWithUsersToggle,
]
.filter(Boolean)
.forEach((el) => {
el.addEventListener("input", () => {
const cfgNow = currentConfigFromForm();
setDirty(!lastConfig || (cfgNow && JSON.stringify(cfgNow) !== JSON.stringify(lastConfig)));
});
el.addEventListener("change", () => {
const cfgNow = currentConfigFromForm();
setDirty(!lastConfig || (cfgNow && JSON.stringify(cfgNow) !== JSON.stringify(lastConfig)));
});
});
loadConfig();
return { reload: loadConfig, isDirty: () => dirty };
}
export const isUpdatesDirty = () => updatesDirty;