353 lines
12 KiB
JavaScript
353 lines
12 KiB
JavaScript
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">×</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();
|
||
}
|
||
});
|
||
}
|