Add dashboard UI updates and settings modal
This commit is contained in:
89
pikit-web/assets/api.js
Normal file
89
pikit-web/assets/api.js
Normal 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 }),
|
||||
});
|
||||
BIN
pikit-web/assets/fonts/Atkinson-Bold.woff2
Normal file
BIN
pikit-web/assets/fonts/Atkinson-Bold.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/Atkinson-Regular.woff2
Normal file
BIN
pikit-web/assets/fonts/Atkinson-Regular.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/Chivo-Bold.woff2
Normal file
BIN
pikit-web/assets/fonts/Chivo-Bold.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/Chivo-Regular.woff2
Normal file
BIN
pikit-web/assets/fonts/Chivo-Regular.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/DMSans-Bold.woff2
Normal file
BIN
pikit-web/assets/fonts/DMSans-Bold.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/DMSans-Medium.woff2
Normal file
BIN
pikit-web/assets/fonts/DMSans-Medium.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/DMSans-Regular.woff2
Normal file
BIN
pikit-web/assets/fonts/DMSans-Regular.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/Manrope-Regular.woff2
Normal file
BIN
pikit-web/assets/fonts/Manrope-Regular.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/Manrope-SemiBold.woff2
Normal file
BIN
pikit-web/assets/fonts/Manrope-SemiBold.woff2
Normal file
Binary file not shown.
101
pikit-web/assets/fonts/OFL.txt
Normal file
101
pikit-web/assets/fonts/OFL.txt
Normal 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.
|
||||
BIN
pikit-web/assets/fonts/PlexSans-Regular.woff2
Normal file
BIN
pikit-web/assets/fonts/PlexSans-Regular.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/PlexSans-SemiBold.woff2
Normal file
BIN
pikit-web/assets/fonts/PlexSans-SemiBold.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/RedHatDisplay-Bold.woff2
Normal file
BIN
pikit-web/assets/fonts/RedHatDisplay-Bold.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/RedHatDisplay-SemiBold.woff2
Normal file
BIN
pikit-web/assets/fonts/RedHatDisplay-SemiBold.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/RedHatText-Medium.woff2
Normal file
BIN
pikit-web/assets/fonts/RedHatText-Medium.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/RedHatText-Regular.woff2
Normal file
BIN
pikit-web/assets/fonts/RedHatText-Regular.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/Sora-Regular.woff2
Normal file
BIN
pikit-web/assets/fonts/Sora-Regular.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/Sora-SemiBold.woff2
Normal file
BIN
pikit-web/assets/fonts/Sora-SemiBold.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/SpaceGrotesk-Medium.woff2
Normal file
BIN
pikit-web/assets/fonts/SpaceGrotesk-Medium.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/SpaceGrotesk-Regular.woff2
Normal file
BIN
pikit-web/assets/fonts/SpaceGrotesk-Regular.woff2
Normal file
Binary file not shown.
BIN
pikit-web/assets/fonts/SpaceGrotesk-SemiBold.woff2
Normal file
BIN
pikit-web/assets/fonts/SpaceGrotesk-SemiBold.woff2
Normal file
Binary file not shown.
635
pikit-web/assets/main.js
Normal file
635
pikit-web/assets/main.js
Normal 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();
|
||||
361
pikit-web/assets/services.js
Normal file
361
pikit-web/assets/services.js
Normal 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">×</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();
|
||||
}
|
||||
});
|
||||
}
|
||||
130
pikit-web/assets/settings.js
Normal file
130
pikit-web/assets/settings.js
Normal 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 };
|
||||
}
|
||||
77
pikit-web/assets/status.js
Normal file
77
pikit-web/assets/status.js
Normal 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
1574
pikit-web/assets/style.css
Normal file
File diff suppressed because it is too large
Load Diff
344
pikit-web/assets/update-settings.js
Normal file
344
pikit-web/assets/update-settings.js
Normal 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;
|
||||
Reference in New Issue
Block a user