Files
pi-kit/pikit-web/assets/services.js
2025-12-10 18:51:31 -05:00

362 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
});
}