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 = ` `; 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 = `

No web services detected yet.

`; 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 = `
${name}
${url}

Port ${svc.port}

${ isSelfSigned ? `Self-signed` : "" }
${ hasCustomNotice ? `` : "" }
`; 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(); } }); }