Files
pi-kit/pikit-web/assets/services.js
2025-12-13 17:04:32 -05:00

353 lines
12 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";
import { logUi } from "./diaglog.js";
import {
DEFAULT_SELF_SIGNED_MSG,
isValidLink,
normalizePath,
validateServiceFields,
} from "./services-helpers.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;
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 = "";
const original = { ...menuContext };
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");
logUi(isRemove ? "Service removed" : "Service updated", "info", {
name: body.name || original.name,
port_from: original.port,
port_to: body.new_port || original.port,
scheme_from: original.scheme,
scheme_to: body.scheme || original.scheme,
path_from: original.path,
path_to: body.path ?? original.path,
notice_changed: body.notice !== undefined,
self_signed: body.self_signed,
});
modal?.classList.add("hidden");
menuContext = null;
await onChange?.();
} catch (e) {
const err = e.error || "Action failed.";
msg.textContent = "";
toast?.(err, "error");
logUi("Service update failed", "error", {
action,
name: body.name || original.name,
port: original.port,
reason: err,
});
} 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 },
(m) => toast?.(m, "error"),
)
)
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 }, (m) => toast?.(m, "error"))
)
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");
logUi("Service added", "info", {
name,
port,
scheme,
path,
notice: !!notice,
notice_link: !!notice_link,
self_signed,
});
await onChange?.();
} catch (e) {
const err = e.error || "Failed to add.";
addMsg.textContent = "";
toast?.(err, "error");
logUi("Service add failed", "error", { name, port, scheme, reason: err });
} finally {
addBtn.disabled = false;
hideBusy();
}
});
}