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

21
pikit-web/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Pi-Kit contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

21
pikit-web/LICENSE.txt Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Pi-Kit contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

41
pikit-web/RESCUE.md Normal file
View File

@@ -0,0 +1,41 @@
# Pi-Kit quick rescue (offline note)
Keep this handy if the web dashboard is down. Youre already in via SSH, so heres what to check next.
## Where to find this note
- `/root/RESCUE.md`
- `/home/dietpi/RESCUE.md`
- `/var/www/pikit-web/RESCUE.md`
## Fast service reset
```bash
sudo systemctl status nginx pikit-api # check
sudo systemctl restart nginx pikit-api # restart both
```
## Logs to inspect
```bash
sudo tail -n 100 /var/log/nginx/error.log
sudo tail -n 100 /var/log/pikit-api.log
```
## Check system health
```bash
df -h # disk space
free -h # memory
sudo systemctl status unattended-upgrades # auto-update service
sudo systemctl status apt-daily.timer apt-daily-upgrade.timer
```
## If services wont start
```bash
sudo nginx -t # validate nginx config
sudo journalctl -u nginx -u pikit-api -n 100 # detailed service logs
```
## Licenses (for distribution)
- `/var/www/pikit-web/LICENSE` (MIT for Pi-Kit)
- `/var/www/pikit-web/THIRD-PARTY-LICENSES.md`
- `/var/www/pikit-web/assets/fonts/OFL.txt`
Tip: after any change, `sudo systemctl restart nginx pikit-api` then re-check logs above.

View File

@@ -0,0 +1,21 @@
# Third-Party Licenses
## Fonts (SIL Open Font License 1.1)
- Red Hat Display
- Red Hat Text
- Space Grotesk
- Manrope
- DM Sans
- Sora
- Chivo
- Atkinson Hyperlegible
- IBM Plex Sans
Full text: assets/fonts/OFL.txt
## Frontend tooling
- Vite — MIT License
- @fontsource packages — MIT License
## Test/dev
- Playwright — Apache License 2.0

View File

@@ -0,0 +1,21 @@
# Third-Party Licenses
## Fonts (SIL Open Font License 1.1)
- Red Hat Display
- Red Hat Text
- Space Grotesk
- Manrope
- DM Sans
- Sora
- Chivo
- Atkinson Hyperlegible
- IBM Plex Sans
Full text: assets/fonts/OFL.txt
## Frontend tooling
- Vite — MIT License
- @fontsource packages — MIT License
## Test/dev
- Playwright — Apache License 2.0

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;

View File

@@ -0,0 +1,11 @@
{
"enabled": true,
"scope": "all",
"cleanup": true,
"bandwidth_limit_kbps": 0,
"auto_reboot": false,
"reboot_time": "05:30",
"reboot_with_users": false,
"update_time": "04:00",
"upgrade_time": "04:30"
}

0
pikit-web/favicon.ico Normal file
View File

634
pikit-web/index.html Normal file
View File

@@ -0,0 +1,634 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pi-Kit Dashboard</title>
<link rel="stylesheet" href="assets/style.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<div
class="dot"
title="Dashboard status indicator (shows when the UI is loaded)"
aria-label="Dashboard status indicator"
></div>
<span>Pi-Kit</span>
</div>
<div class="top-indicators">
<span class="chip-label">Status</span>
<span id="updatesFlagTop" class="status-chip quiet">Auto updates</span>
<span id="updatesNoteTop" class="hint quiet"></span>
<span id="refreshFlagTop" class="status-chip quiet">Refresh: 10s</span>
<span id="tempFlagTop" class="status-chip quiet">Temp: OK</span>
</div>
<div class="top-actions">
<button
id="themeToggle"
class="ghost icon-btn"
title="Toggle theme"
aria-label="Toggle theme"
>
<span id="themeToggleIcon" aria-hidden="true">&#127769;</span>
</button>
<button id="aboutBtn" class="ghost" title="About Pi-Kit">About</button>
<button id="helpBtn" class="ghost" title="Open help">Help</button>
<button id="advBtn" class="ghost" title="Open settings">
Settings
</button>
</div>
</header>
<main class="layout">
<section class="hero">
<div>
<p class="eyebrow">All-in-one launcher</p>
<h1>Welcome to your Pi-Kit homebase</h1>
<p class="lede">
Launch services, view quick health, and handle essentials without
cracking open SSH.
</p>
</div>
<div class="hero-stats" id="heroStats"></div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<p class="eyebrow">Configured services</p>
<h2>Web interfaces</h2>
<p class="hint">
Shortcuts to the web UIs running on your Pi. Click a card to open it. Use the + button to add another service; use the ⋮ menu on a card to edit or remove it.
</p>
</div>
<div class="panel-actions">
<button id="addServiceOpen" class="ghost icon-btn" title="Add a service" aria-label="Add service">
+
</button>
</div>
</div>
<div id="servicesGrid" class="grid"></div>
</section>
</main>
<div id="readyOverlay" class="overlay hidden">
<div class="overlay-box">
<h3>Finishing setup</h3>
<p>
This only takes a couple of minutes. You'll see the dashboard once
Pi-Kit setup completes.
</p>
<div class="spinner"></div>
</div>
</div>
<div id="busyOverlay" class="overlay hidden">
<div class="overlay-box">
<h3 id="busyTitle">Working…</h3>
<p id="busyText" class="hint">This may take a few seconds.</p>
<div class="spinner"></div>
</div>
</div>
<div id="aboutModal" class="modal hidden">
<div class="modal-card wide">
<div class="panel-header sticky">
<div>
<p class="eyebrow">About</p>
<h3>About Pi-Kit</h3>
</div>
<button id="aboutClose" class="ghost icon-btn close-btn" title="Close about">
&times;
</button>
</div>
<div class="help-body">
<h4>What is this?</h4>
<p>
Pi-Kit is a self-hosted dashboard for managing services, monitoring your Pi,
and handling unattended updates, all through a single UI.
</p>
<h4>Font licensing</h4>
<p>
The following fonts are included under the SIL Open Font License 1.1 (no attribution required):
</p>
<ul>
<li>Red Hat Display / Red Hat Text</li>
<li>Space Grotesk</li>
<li>Manrope</li>
<li>DM Sans</li>
<li>Sora</li>
<li>Chivo</li>
<li>Atkinson Hyperlegible</li>
<li>IBM Plex Sans</li>
</ul>
<p class="hint">OFL license text is bundled at assets/fonts/OFL.txt.</p>
<h4>Other licenses</h4>
<p>
Pi-Kit code is MIT licensed. Third-party tooling (Vite, @fontsource) is MIT; Playwright (dev/test) is Apache 2.0.
See <a href="/THIRD-PARTY-LICENSES.txt" target="_blank">THIRD-PARTY-LICENSES.txt</a> and <a href="/LICENSE.txt" target="_blank">LICENSE.txt</a> for details, and <a href="/assets/fonts/OFL.txt" target="_blank">OFL.txt</a> for font licensing.
</p>
</div>
</div>
</div>
<div id="addServiceModal" class="modal hidden">
<div class="modal-card wide">
<div class="panel-header sticky">
<div>
<p class="eyebrow">Add service</p>
<h3>Register a web interface</h3>
<p class="hint">
Adds a local service by name and port. Choose HTTP or HTTPS to match how it serves traffic.
</p>
</div>
<button id="addSvcClose" class="ghost icon-btn close-btn" title="Close add service">
&times;
</button>
</div>
<div class="controls column">
<div class="control-actions column">
<input
type="text"
id="svcName"
placeholder="Service name"
maxlength="32"
/>
<p class="hint quiet">Service name: max 32 characters.</p>
<input
type="number"
id="svcPort"
placeholder="Port (e.g. 8080)"
min="1"
max="65535"
/>
<input
type="text"
id="svcPath"
placeholder="Optional path (e.g. /admin)"
/>
<div class="control-row split">
<label class="checkbox-row">
<span>Protocol</span>
<select id="svcScheme">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</label>
<label class="checkbox-row inline tight nowrap">
<input type="checkbox" id="svcSelfSigned" />
<span>Self-signed TLS</span>
</label>
</div>
<textarea
id="svcNotice"
rows="3"
placeholder="Optional notice (shown on card)"
></textarea>
<input
type="text"
id="svcNoticeLink"
placeholder="Optional link for more info"
/>
<div class="control-actions">
<button id="svcAddBtn" title="Add service and open port on LAN">
Add
</button>
</div>
<div id="svcMsg" class="hint status-msg"></div>
</div>
</div>
</div>
</div>
<div id="advModal" class="modal hidden">
<div class="modal-card wide">
<div class="panel-header sticky">
<div>
<p class="eyebrow warning">Settings</p>
<h3>System controls</h3>
</div>
<button
id="advClose"
class="ghost icon-btn close-btn"
title="Close settings panel"
>
&times;
</button>
</div>
<div class="controls accordion-list">
<div class="accordion">
<button class="accordion-toggle" data-target="acc-updates">
Automatic updates
</button>
<div class="accordion-body" id="acc-updates">
<p class="hint">
Daily unattended-upgrades: choose scope, schedule, cleanup, bandwidth, and reboot policy.
</p>
<div class="control-actions column" id="updatesSection">
<div class="control-actions">
<span id="updatesStatus" class="status-chip">Unknown</span>
<label class="toggle" title="Toggle unattended updates">
<input type="checkbox" id="updatesToggle" />
<span class="slider"></span>
</label>
<span class="hint" id="updatesToggleLabel">Enable/disable unattended upgrades</span>
</div>
<div id="updatesControls">
<div class="form-grid">
<label class="field">
<span>What to update</span>
<select id="updatesScope">
<option value="all">Security + regular updates</option>
<option value="security">Security only</option>
</select>
</label>
<label class="field">
<span>Download updates at</span>
<input type="time" id="updateTimeInput" value="04:00" title="Time of day to download updates" />
</label>
<label class="field">
<span>Install updates at</span>
<input type="time" id="upgradeTimeInput" value="04:30" title="Time of day to install updates" />
</label>
<div class="field checkbox-field">
<span>Cleanup unused packages</span>
<label class="checkbox-row inline tight">
<input type="checkbox" id="updatesCleanup" title="Automatically remove unused dependencies after upgrades" />
<span>Auto-remove dependencies after upgrades</span>
</label>
</div>
<label class="field">
<span>Bandwidth limit (KB/s, 0 = unlimited)</span>
<input
type="number"
id="updatesBandwidth"
min="0"
placeholder="0"
/>
</label>
<div class="field checkbox-field">
<span>Reboot options</span>
<div class="control-actions column tight">
<label class="checkbox-row inline tight">
<input type="checkbox" id="updatesRebootToggle" title="Auto-reboot when updates require it" />
<span>Auto-reboot if required</span>
</label>
<label class="checkbox-row inline tight">
<span>Reboot time</span>
<input type="time" id="updatesRebootTime" value="04:30" title="Scheduled reboot time when auto-reboot is enabled" />
</label>
<label class="checkbox-row inline tight nowrap">
<input type="checkbox" id="updatesRebootUsers" title="Allow reboot even if users are logged in" />
<span>Allow reboot with active users</span>
</label>
</div>
</div>
</div>
<div class="control-actions split-row">
<button id="updatesSaveBtn" title="Save unattended-upgrades settings" disabled>
Save settings
</button>
<span id="updatesUnsavedNote" class="note-warn hidden">Please save changes or they will not apply.</span>
</div>
<div id="updatesMsg" class="hint status-msg"></div>
</div>
</div>
</div>
</div>
<div class="accordion">
<button class="accordion-toggle" data-target="acc-anim">
Animations
</button>
<div class="accordion-body" id="acc-anim">
<p class="hint">
Adds smooth hover and click motion cues across the dashboard.
Default is on.
</p>
<div class="control-actions">
<span class="status-chip">Enable animations</span>
<label class="toggle" title="Toggle UI animations">
<input type="checkbox" id="animToggle" checked />
<span class="slider"></span>
</label>
</div>
<div class="form-grid">
<label class="field">
<span>Toast position</span>
<select id="toastPosSelect" title="Choose where toast notifications appear">
<option value="bottom-center">Bottom center</option>
<option value="bottom-right">Bottom right</option>
<option value="bottom-left">Bottom left</option>
<option value="top-right">Top right</option>
<option value="top-left">Top left</option>
<option value="top-center">Top center</option>
</select>
</label>
<label class="field">
<span>Toast animation</span>
<select id="toastAnimSelect" title="Toast entry animation style">
<option value="slide-in">Slide in</option>
<option value="fade">Fade</option>
<option value="pop">Pop</option>
<option value="bounce">Bounce</option>
<option value="drop">Drop</option>
<option value="grow">Grow</option>
</select>
</label>
<label class="field">
<span>Toast speed (ms)</span>
<input
type="number"
id="toastSpeedInput"
min="100"
max="3000"
step="50"
value="280"
title="Animation duration for toasts"
/>
</label>
<label class="field">
<span>Toast duration (ms)</span>
<input
type="number"
id="toastDurationInput"
min="1000"
max="15000"
step="250"
value="5000"
title="How long to keep toasts visible"
/>
</label>
</div>
<div class="control-actions">
<button id="toastTestBtn" title="Show a sample toast">Test toast</button>
</div>
</div>
</div>
<div class="accordion">
<button class="accordion-toggle" data-target="acc-appearance">
Appearance
</button>
<div class="accordion-body" id="acc-appearance">
<p class="hint">Choose the dashboard typeface.</p>
<div class="form-grid">
<label class="field">
<span>Dashboard font</span>
<select id="fontSelect" title="Choose dashboard typeface">
<option value="redhat">Red Hat (default)</option>
<option value="space">Space Grotesk</option>
<option value="manrope">Manrope</option>
<option value="dmsans">DM Sans</option>
<option value="sora">Sora</option>
<option value="chivo">Chivo</option>
<option value="atkinson">Atkinson Hyperlegible</option>
<option value="plex">IBM Plex Sans</option>
</select>
</label>
</div>
</div>
</div>
<div class="accordion">
<button class="accordion-toggle" data-target="acc-refresh">
Refresh interval
</button>
<div class="accordion-body" id="acc-refresh">
<p class="hint">
Sets how often status and services auto-refresh. Minimum 5
seconds; default 10.
</p>
<div class="control-actions column">
<label class="checkbox-row">
<span>Seconds</span>
<input
type="number"
id="refreshIntervalInput"
min="5"
max="120"
value="10"
/>
</label>
<div class="control-actions">
<button
id="refreshIntervalSave"
title="Update auto refresh rate"
>
Save
</button>
</div>
<div id="refreshIntervalMsg" class="hint status-msg"></div>
</div>
</div>
</div>
<div class="accordion">
<button class="accordion-toggle danger-btn" data-target="acc-reset">
Factory reset
</button>
<div class="accordion-body" id="acc-reset">
<p class="hint">
Restores Pi-Kit defaults, resets firewall and passwords
(root/dietpi &rarr; pikit), then reboots. Type YES to confirm.
</p>
<div class="control-actions column">
<input
type="text"
id="resetConfirm"
placeholder="Type YES to confirm"
/>
<button
id="resetBtn"
class="danger-btn"
disabled
title="Type YES above to enable"
>
Factory reset
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="helpModal" class="modal hidden">
<div class="modal-card wide">
<div class="panel-header">
<div>
<p class="eyebrow">Help</p>
<h3>How Pi-Kit dashboard works</h3>
<p class="hint">Quick, friendly guidance for common tasks.</p>
</div>
<button
id="helpClose"
class="ghost icon-btn close-btn"
title="Close help"
>
&times;
</button>
</div>
<div class="help-body">
<h4>Quick tour</h4>
<ul>
<li><strong>Status chips</strong> show uptime, OS, CPU temp, memory/disk, LAN IP, and auto-update/reboot flags.</li>
<li><strong>Service cards</strong> open your web UIs; the “…” menu lets you rename, change port/protocol/path, or remove.</li>
<li><strong>Hero stats</strong> up top give you a fast health snapshot.</li>
</ul>
<h4>Adding and editing services</h4>
<ul>
<li>Use <strong>Web interfaces → Add service</strong> to register a local UI. Pick HTTP/HTTPS to match the service.</li>
<li>Mark “Selfsigned TLS” if the cert isnt trusted to avoid surprise warnings.</li>
<li>Paths should start with “/” (e.g., <code>/admin</code>); ports must be 165535.</li>
</ul>
<h4>Automatic updates</h4>
<ul>
<li>Turn unattended upgrades on/off, choose securityonly or all updates.</li>
<li>Set download/install times, bandwidth limit, cleanup, and reboot policy.</li>
<li>Save to apply; the top chip shows if a reboot is required/scheduled.</li>
</ul>
<h4>Appearance & toasts</h4>
<ul>
<li>Pick a font in <strong>Settings → Appearance</strong> (Red Hat, Space Grotesk, Manrope, etc.).</li>
<li>Customize toast position, animation, speed, and duration under <strong>Animations</strong>.</li>
</ul>
<h4>Passwords & SSH (friendly defaults)</h4>
<ul>
<li>Default password for <strong>root</strong> and <strong>dietpi</strong>: <code>pikit</code>. Please change it ASAP.</li>
<li>To use keys: <code>ssh-copy-id -i ~/.ssh/yourkey.pub user@pikit</code>, then test with <code>ssh -i ~/.ssh/yourkey user@pikit</code>.</li>
<li>Once keys work, consider disabling password auth in <code>/etc/ssh/sshd_config</code> for extra safety.</li>
</ul>
<h4>Safety</h4>
<ul>
<li>Firewall allows LAN by default; nothing is exposed to the internet by PiKit.</li>
<li>Factory reset reverts passwords and firewall rules and reboots. Use only when you need a clean slate.</li>
</ul>
<h4>Troubleshooting</h4>
<ul>
<li>Service link fails? Make sure the service runs, port/path are right, and edit via the “…” menu.</li>
<li>Key login fails? Re-run <code>ssh-copy-id</code> for the correct user and retry.</li>
</ul>
</div>
</div>
</div>
<div id="menuModal" class="modal hidden">
<div class="modal-card wide">
<div class="panel-header">
<div>
<p class="eyebrow">Service actions</p>
<h3 id="menuTitle"></h3>
<p id="menuSubtitle" class="hint"></p>
</div>
<button class="ghost icon-btn close-btn" id="menuClose" title="Close">
&times;
</button>
</div>
<div class="config-list">
<div class="config-row">
<div class="config-label">
<h4>Rename</h4>
<p class="hint">Update the display name (max 32 characters).</p>
</div>
<div class="config-controls">
<input type="text" id="menuRename" maxlength="32" />
</div>
</div>
<div class="config-row">
<div class="config-label">
<h4>Change port</h4>
<p class="hint">
Moves the service to a new port and opens it on LAN.
</p>
</div>
<div class="config-controls">
<input type="number" id="menuPort" min="1" max="65535" />
</div>
</div>
<div class="config-row">
<div class="config-label">
<h4>Path</h4>
<p class="hint">Optional subpath (e.g. /admin or /ui/).</p>
</div>
<div class="config-controls">
<input type="text" id="menuPath" placeholder="/admin" />
</div>
</div>
<div class="config-row">
<div class="config-label">
<h4>Protocol</h4>
<p class="hint">Choose HTTP or HTTPS link.</p>
</div>
<div class="config-controls">
<select id="menuScheme">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
</div>
<div class="config-row">
<div class="config-label">
<h4>Self-signed TLS</h4>
<p class="hint">Show a self-signed badge.</p>
</div>
<div class="config-controls">
<label class="checkbox-row">
<input type="checkbox" id="menuSelfSigned" />
<span>Mark as self-signed</span>
</label>
</div>
</div>
<div class="config-row">
<div class="config-label">
<h4>Notice</h4>
<p class="hint">Optional badge + info text on the card.</p>
</div>
<div class="config-controls">
<textarea id="menuNotice" rows="4" placeholder="e.g., Uses a self-signed certificate."></textarea>
<input
type="text"
id="menuNoticeLink"
placeholder="Optional link for more info"
/>
</div>
</div>
<div id="menuMsg" class="hint status-msg"></div>
</div>
<div class="modal-actions">
<button
id="menuRemoveBtn"
class="danger-btn"
title="Remove this service"
>
Remove
</button>
<div class="push"></div>
<button id="menuCancelBtn" class="ghost" title="Cancel changes">
Cancel
</button>
<button id="menuSaveBtn" class="primary" title="Save changes">
Save
</button>
</div>
</div>
</div>
<script type="module" src="assets/main.js"></script>
<div id="toastContainer" class="toast-container"></div>
</body>
</html>

1071
pikit-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
pikit-web/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "pikit-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite --host --port 4173",
"build": "vite build --outDir dist --emptyOutDir",
"preview": "vite preview --host --port 4173",
"test:e2e": "playwright test",
"test": "npm run test:e2e"
},
"devDependencies": {
"@playwright/test": "^1.45.0",
"vite": "^5.3.0"
},
"dependencies": {
"@fontsource/atkinson-hyperlegible": "^5.0.8",
"@fontsource/chivo": "^5.0.8",
"@fontsource/dm-sans": "^5.0.8",
"@fontsource/ibm-plex-sans": "^5.0.8",
"@fontsource/manrope": "^5.0.8",
"@fontsource/red-hat-display": "^5.0.12",
"@fontsource/red-hat-text": "^5.0.12",
"@fontsource/sora": "^5.0.8",
"@fontsource/space-grotesk": "^5.0.8"
}
}

View File

@@ -0,0 +1,32 @@
// @ts-check
import { defineConfig, devices } from '@playwright/test';
const PORT = 4173;
const HOST = 'localhost';
const BASE_URL = `http://${HOST}:${PORT}`;
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: {
timeout: 5_000,
},
reporter: [['list']],
use: {
baseURL: BASE_URL,
trace: 'retain-on-failure',
},
webServer: {
command: 'npm run dev',
url: BASE_URL,
reuseExistingServer: !process.env.CI,
stdout: 'pipe',
stderr: 'pipe',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View File

@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
const baseStatus = {
hostname: 'pikit',
ready: true,
uptime_seconds: 100,
load: [0, 0, 0],
memory_mb: { total: 1024, free: 512 },
disk_mb: { total: 10240, free: 9000 },
cpu_temp_c: 40,
lan_ip: '10.0.0.10',
os_version: 'DietPi',
auto_updates_enabled: true,
auto_updates: { enabled: true },
services: [],
};
const defaultUpdatesConfig = {
enabled: true,
scope: 'all',
update_time: '04:00',
upgrade_time: '04:30',
cleanup: true,
bandwidth_limit_kbps: null,
auto_reboot: false,
reboot_time: '04:30',
reboot_with_users: false,
};
test('busy overlay appears while adding a service', async ({ page }) => {
let services = [];
await page.route('**/api/updates/config', async (route) => {
await route.fulfill({ json: defaultUpdatesConfig });
});
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: { ...baseStatus, services } });
});
await page.goto('/');
// Trigger busy overlay via test hook
await page.evaluate(() => {
window.__pikitTest?.showBusy('Adding service', 'Opening firewall rules…');
setTimeout(() => window.__pikitTest?.hideBusy(), 300);
});
const busy = page.locator('#busyOverlay');
await expect(busy).toBeVisible();
await expect(busy).toBeHidden({ timeout: 2000 });
});

View File

@@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';
const services = [
{ name: 'Pi-hole', port: 8089, scheme: 'http', path: '/admin', url: 'http://pikit:8089/admin', online: true, firewall_open: true },
];
test('service cards show path in URL and preserve click target', async ({ page }) => {
await page.goto('/');
await page.evaluate(async (svcList) => {
const mod = await import('/assets/services.js');
const grid = document.getElementById('servicesGrid');
mod.renderServices(grid, svcList);
}, services);
await expect(page.getByText('Pi-hole')).toBeVisible();
await expect(page.getByText('http://pikit:8089/admin')).toBeVisible();
});

View File

@@ -0,0 +1,216 @@
import { test, expect } from '@playwright/test';
const baseStatus = {
hostname: 'pikit',
ready: true,
uptime_seconds: 100,
load: [0, 0, 0],
memory_mb: { total: 1024, free: 512 },
disk_mb: { total: 10240, free: 9000 },
cpu_temp_c: 40,
lan_ip: '10.0.0.10',
os_version: 'DietPi',
auto_updates_enabled: true,
auto_updates: { enabled: true },
};
const defaultUpdatesConfig = {
enabled: true,
scope: 'all',
update_time: '04:00',
upgrade_time: '04:30',
cleanup: true,
bandwidth_limit_kbps: null,
auto_reboot: false,
reboot_time: '04:30',
reboot_with_users: false,
};
async function primeStatus(page, statusData) {
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: statusData });
});
}
async function stubUpdatesConfig(page, cfg = defaultUpdatesConfig) {
await page.route('**/api/updates/config', async (route) => {
if (route.request().method() === 'POST') {
const body = await route.request().postDataJSON();
await route.fulfill({ json: { ...cfg, ...body } });
return;
}
await route.fulfill({ json: cfg });
});
}
test('renders services from status payload', async ({ page }) => {
await stubUpdatesConfig(page);
const statusData = {
...baseStatus,
services: [
{ name: 'Pi-hole', port: 8089, scheme: 'http', path: '/admin', url: 'http://pikit:8089/admin', online: true },
{ name: 'Netdata', port: 19999, scheme: 'http', url: 'http://pikit:19999', online: false },
],
};
await primeStatus(page, statusData);
await page.goto('/');
await expect(page.getByText('Pi-hole')).toBeVisible();
await expect(page.getByText('http://pikit:8089/admin')).toBeVisible();
await expect(page.getByText('Netdata')).toBeVisible();
});
test('add service shows busy overlay and new card', async ({ page }) => {
await stubUpdatesConfig(page);
await page.addInitScript(() => {
window.__pikitTest = window.__pikitTest || {};
window.__pikitTest.forceAccordionsOpen = true;
window.__pikitTest.forceServiceFormVisible = () => {
const ids = ['acc-services', 'svcName', 'svcPort', 'svcPath', 'svcScheme', 'svcAddBtn'];
ids.forEach((id) => {
const el = document.getElementById(id);
if (el) {
el.style.display = 'block';
el.style.opacity = '1';
el.style.visibility = 'visible';
el.style.maxHeight = '2000px';
}
});
};
});
const statusData = { ...baseStatus, services: [] };
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: statusData });
});
await page.route('**/api/services/add', async (route) => {
const body = await route.request().postDataJSON();
statusData.services.push({
name: body.name,
port: body.port,
scheme: body.scheme,
path: body.path,
url: `${body.scheme}://pikit:${body.port}${body.path || ''}`,
online: true,
});
await route.fulfill({ status: 200, json: { services: statusData.services, message: 'Added' } });
});
await page.goto('/');
await page.click('#advBtn');
await page.evaluate(() => {
const acc = document.querySelector('button[data-target="acc-services"]')?.closest('.accordion');
acc?.classList.add('open');
window.__pikitTest?.forceServiceFormVisible?.();
});
await page.fill('#svcName', 'Grafana', { force: true });
await page.fill('#svcPort', '3000', { force: true });
await page.fill('#svcPath', '/dashboards', { force: true });
await page.evaluate(() => {
const sel = document.getElementById('svcScheme');
if (sel) {
sel.value = 'http';
sel.dispatchEvent(new Event('change', { bubbles: true }));
}
});
await page.click('#svcAddBtn', { force: true });
await expect(page.getByText('Grafana')).toBeVisible();
await expect(page.getByText('http://pikit:3000/dashboards')).toBeVisible();
});
test('path validation rejects absolute URLs', async ({ page }) => {
await stubUpdatesConfig(page);
await page.addInitScript(() => {
window.__pikitTest = window.__pikitTest || {};
window.__pikitTest.forceAccordionsOpen = true;
window.__pikitTest.forceServiceFormVisible = () => {
const ids = ['acc-services', 'svcName', 'svcPort', 'svcPath', 'svcScheme', 'svcAddBtn'];
ids.forEach((id) => {
const el = document.getElementById(id);
if (el) {
el.style.display = 'block';
el.style.opacity = '1';
el.style.visibility = 'visible';
el.style.maxHeight = '2000px';
}
});
};
});
const statusData = { ...baseStatus, services: [] };
await primeStatus(page, statusData);
await page.goto('/');
await page.click('#advBtn');
await page.evaluate(() => {
const acc = document.querySelector('button[data-target="acc-services"]')?.closest('.accordion');
acc?.classList.add('open');
window.__pikitTest?.forceServiceFormVisible?.();
});
await page.fill('#svcName', 'BadPath', { force: true });
await page.fill('#svcPort', '8080', { force: true });
await page.fill('#svcPath', 'http://example.com', { force: true });
await page.click('#svcAddBtn', { force: true });
await expect(page.getByText('Path must be relative (e.g. /admin) or blank.')).toBeVisible();
});
test('edit service updates path and scheme', async ({ page }) => {
await stubUpdatesConfig(page);
await page.addInitScript(() => {
window.__pikitTest = window.__pikitTest || {};
window.__pikitTest.forceAccordionsOpen = true;
});
const statusData = {
...baseStatus,
services: [
{ name: 'Uptime Kuma', port: 3001, scheme: 'http', path: '', url: 'http://pikit:3001', online: true },
],
};
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: statusData });
});
await page.route('**/api/services/update', async (route) => {
const body = await route.request().postDataJSON();
statusData.services = statusData.services.map((s) =>
s.port === body.port ? { ...s, scheme: body.scheme, path: body.path, url: `${body.scheme}://pikit:${s.port}${body.path || ''}` } : s
);
await route.fulfill({ status: 200, json: { services: statusData.services, message: 'Service updated' } });
});
await page.goto('/');
await page.click('.menu-btn');
await page.fill('#menuPath', '/status');
await page.selectOption('#menuScheme', 'https');
const updateResp = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/services/update') && r.status() === 200),
page.click('#menuSaveBtn'),
]).then((res) => res[0]);
expect(updateResp.ok()).toBeTruthy();
const statusAfter = await page.evaluate(async () => {
const res = await fetch('/api/status');
return res.json();
});
const svc = statusAfter.services.find((s) => s.port === 3001);
expect(svc).toBeTruthy();
expect(svc.url).toContain('https://pikit:3001/status');
await page.reload();
await expect(page.locator('.service-url')).toContainText('https://pikit:3001/status', { timeout: 8000 });
});
test('remove service updates list', async ({ page }) => {
await stubUpdatesConfig(page);
let services = [
{ name: 'RemoveMe', port: 9000, scheme: 'http', url: 'http://pikit:9000', online: true },
];
const statusData = { ...baseStatus, services };
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: { ...statusData, services } });
});
await page.route('**/api/services/remove', async (route) => {
const body = await route.request().postDataJSON();
services = services.filter((s) => s.port !== body.port);
await route.fulfill({ status: 200, json: { services, message: 'Removed' } });
});
await page.goto('/');
await page.click('.menu-btn');
await page.click('#menuRemoveBtn');
await expect(page.locator('.pill', { hasText: 'RemoveMe' })).toHaveCount(0, { timeout: 2000 });
});

View File

@@ -0,0 +1,114 @@
import { test, expect } from '@playwright/test';
const baseStatus = {
hostname: 'pikit',
ready: true,
uptime_seconds: 100,
load: [0, 0, 0],
memory_mb: { total: 1024, free: 512 },
disk_mb: { total: 10240, free: 9000 },
cpu_temp_c: 40,
lan_ip: '10.0.0.10',
os_version: 'DietPi',
auto_updates_enabled: true,
auto_updates: { enabled: true },
};
const defaultUpdatesConfig = {
enabled: true,
scope: 'all',
update_time: '04:00',
upgrade_time: '04:30',
cleanup: false,
bandwidth_limit_kbps: null,
auto_reboot: false,
reboot_time: '04:30',
reboot_with_users: false,
};
test('update settings form loads and saves config', async ({ page }) => {
let posted = null;
await page.addInitScript(() => {
window.__pikitTest = window.__pikitTest || {};
window.__pikitTest.forceAccordionsOpen = true;
});
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: { ...baseStatus, services: [] } });
});
await page.route('**/api/updates/config', async (route) => {
if (route.request().method() === 'POST') {
posted = await route.request().postDataJSON();
await route.fulfill({ json: { ...defaultUpdatesConfig, ...posted } });
return;
}
await route.fulfill({ json: defaultUpdatesConfig });
});
await page.goto('/');
await page.click('#advBtn');
await expect(page.locator('#acc-updates')).toBeVisible();
await page.selectOption('#updatesScope', 'security');
await page.fill('#updateTimeInput', '03:00');
await page.fill('#upgradeTimeInput', '03:30');
await page.click('#updatesCleanup');
await page.fill('#updatesBandwidth', '500');
await page.click('#updatesRebootToggle');
await page.fill('#updatesRebootTime', '03:45');
await page.click('#updatesRebootUsers');
const resp = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/updates/config') && r.request().method() === 'POST'),
page.click('#updatesSaveBtn'),
]);
expect(resp[0].ok()).toBeTruthy();
expect(posted).toMatchObject({
enable: true,
scope: 'security',
update_time: '03:00',
upgrade_time: '03:30',
cleanup: true,
bandwidth_limit_kbps: 500,
auto_reboot: true,
reboot_time: '03:45',
reboot_with_users: true,
});
await expect(page.getByText('Update settings saved.')).toBeVisible({ timeout: 2000 });
});
test('disabling updates disables controls and saves enable=false', async ({ page }) => {
let posted = null;
await page.addInitScript(() => {
window.__pikitTest = window.__pikitTest || {};
window.__pikitTest.forceAccordionsOpen = true;
});
await page.route('**/api/status', async (route) => {
await route.fulfill({ json: { ...baseStatus, services: [] } });
});
await page.route('**/api/updates/config', async (route) => {
if (route.request().method() === 'POST') {
posted = await route.request().postDataJSON();
await route.fulfill({ json: { ...defaultUpdatesConfig, ...posted } });
return;
}
await route.fulfill({ json: defaultUpdatesConfig });
});
await page.goto('/');
await page.click('#advBtn');
await page.click('#updatesToggle + .slider', { force: true }); // disable via slider
await expect(page.locator('#updatesScope')).toBeDisabled();
await expect(page.locator('#updateTimeInput')).toBeDisabled();
await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/updates/config') && r.request().method() === 'POST'),
page.click('#updatesSaveBtn'),
]);
expect(posted).toMatchObject({ enable: false });
});

18
pikit-web/vite.config.js Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
export default defineConfig({
root: '.',
server: {
host: true,
port: 4173,
proxy: {
// Forward API calls to the local Python API during dev so fetches
// return JSON instead of the Vite index.html shell.
'/api': 'http://127.0.0.1:4000',
},
},
preview: {
host: true,
port: 4173,
},
});