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

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();
}
});
}