Files
farm-manager/server/static/app.js
T
pluto 9a24db2e47 feat: add Frigate Alerts link to Server Links page
Links to frigate-notify v2 web UI at http://192.168.86.192:5199

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:24:19 -06:00

942 lines
33 KiB
JavaScript

/* ===== Farm Manager Dashboard ===== */
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
let services = [];
let groups = [];
let nodes = [];
let currentView = "all"; // "all", "node:<name>", or group id
let refreshInterval = null;
let logPanelOpen = false;
let modalOpen = false;
let editingGroupId = null; // null = creating, string = editing
let searchQuery = "";
let statusFilter = "all"; // "all", "running", "stopped"
// ---------------------------------------------------------------------------
// DOM references
// ---------------------------------------------------------------------------
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
// ---------------------------------------------------------------------------
// API helper
// ---------------------------------------------------------------------------
async function api(method, path, body = null) {
const opts = {
method,
headers: { "Content-Type": "application/json" },
};
if (body !== null) {
opts.body = JSON.stringify(body);
}
const resp = await fetch(path, opts);
if (!resp.ok) {
const text = await resp.text();
throw new Error(`${resp.status}: ${text}`);
}
return resp.json();
}
// ---------------------------------------------------------------------------
// Data loading
// ---------------------------------------------------------------------------
async function loadNodes() {
try {
nodes = await api("GET", "/api/nodes");
} catch (e) {
console.error("Failed to load nodes:", e);
nodes = [];
}
}
async function loadServices() {
try {
services = await api("GET", "/api/services");
} catch (e) {
console.error("Failed to load services:", e);
services = [];
}
}
async function loadGroups() {
try {
groups = await api("GET", "/api/groups");
} catch (e) {
console.error("Failed to load groups:", e);
groups = [];
}
}
async function refreshAll() {
// Skip refresh if modal or log panel is open
if (modalOpen || logPanelOpen) return;
await Promise.all([loadNodes(), loadServices(), loadGroups()]);
renderSidebar();
renderMain();
updateLastUpdated();
}
function updateLastUpdated() {
const el = $("#last-updated");
if (el) {
const now = new Date();
el.textContent = "Updated " + now.toLocaleTimeString();
}
}
// ---------------------------------------------------------------------------
// Sidebar rendering
// ---------------------------------------------------------------------------
function renderSidebar() {
renderNodeList();
renderGroupList();
updateSidebarActive();
}
function renderNodeList() {
const container = $("#node-list");
if (!container) return;
container.innerHTML = nodes
.map((n) => {
const count = services.filter((s) => s.node === n.name).length;
const healthClass = n.healthy ? "healthy" : "unhealthy";
const isActive = currentView === "node:" + n.name;
return `
<div class="node-item${isActive ? " active" : ""}" data-view="node:${escHtml(n.name)}">
<span class="health-dot ${healthClass}"></span>
<span class="node-name">${escHtml(n.name)}</span>
<span class="node-count">${count}</span>
</div>`;
})
.join("");
// Bind click
container.querySelectorAll(".node-item").forEach((el) => {
el.addEventListener("click", () => {
currentView = el.dataset.view;
renderSidebar();
renderMain();
});
});
}
function renderGroupList() {
const container = $("#group-list");
if (!container) return;
if (groups.length === 0) {
container.innerHTML =
'<div style="padding:8px 20px;font-size:12px;color:var(--text-secondary);">No groups yet</div>';
return;
}
container.innerHTML = groups
.map((g) => {
const isActive = currentView === g.id;
return `
<div class="group-item${isActive ? " active" : ""}" data-view="${escHtml(g.id)}">
<span class="sidebar-icon">&#128193;</span>
<span class="group-name">${escHtml(g.name)}</span>
<span class="group-svc-count">${g.services.length}</span>
<span class="group-actions">
<button class="group-action-btn edit" data-group-id="${escHtml(g.id)}" title="Edit">&#9998;</button>
<button class="group-action-btn delete" data-group-id="${escHtml(g.id)}" title="Delete">&#10005;</button>
</span>
</div>`;
})
.join("");
// Click to select group view
container.querySelectorAll(".group-item").forEach((el) => {
el.addEventListener("click", (e) => {
// Don't switch view when clicking edit/delete buttons
if (e.target.closest(".group-action-btn")) return;
currentView = el.dataset.view;
renderSidebar();
renderMain();
});
});
// Edit group
container.querySelectorAll(".group-action-btn.edit").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
openGroupEditor(btn.dataset.groupId);
});
});
// Delete group
container.querySelectorAll(".group-action-btn.delete").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const gid = btn.dataset.groupId;
const g = groups.find((x) => x.id === gid);
showConfirmModal(
`Delete group "${g ? g.name : gid}"? This cannot be undone.`,
() => deleteGroup(gid)
);
});
});
}
function updateSidebarActive() {
const allLink = $("#nav-all");
const linksLink = $("#nav-links");
if (allLink) allLink.classList.toggle("active", currentView === "all");
if (linksLink) linksLink.classList.toggle("active", currentView === "links");
}
// ---------------------------------------------------------------------------
// Main content rendering
// ---------------------------------------------------------------------------
function renderMain() {
const linksView = $("#links-view");
const toolbar = $("#toolbar");
const grid = $("#service-grid");
const emptyState = $("#empty-state");
if (currentView === "links") {
toolbar.style.display = "none";
grid.style.display = "none";
emptyState.style.display = "none";
linksView.style.display = "block";
renderLinksView();
return;
}
toolbar.style.display = "flex";
grid.style.display = "grid";
linksView.style.display = "none";
const filtered = getFilteredServices();
renderToolbar(filtered);
renderServices(filtered);
}
function getPreStatusFilteredServices() {
let list = [...services];
if (currentView === "all") {
// show all
} else if (currentView.startsWith("node:")) {
const nodeName = currentView.slice(5);
list = list.filter((s) => s.node === nodeName);
} else {
const group = groups.find((g) => g.id === currentView);
if (group) {
const svcSet = new Set(
group.services.map((s) => s.node + ":" + s.container)
);
list = list.filter((s) => svcSet.has(s.node + ":" + s.name));
} else {
list = [];
}
}
if (searchQuery) {
const q = searchQuery.toLowerCase();
list = list.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.image.toLowerCase().includes(q) ||
s.node.toLowerCase().includes(q)
);
}
return list;
}
function getFilteredServices() {
let list = [...services];
// Filter by view
if (currentView === "all") {
// show all
} else if (currentView.startsWith("node:")) {
const nodeName = currentView.slice(5);
list = list.filter((s) => s.node === nodeName);
} else {
// Group view
const group = groups.find((g) => g.id === currentView);
if (group) {
const svcSet = new Set(
group.services.map((s) => s.node + ":" + s.container)
);
list = list.filter((s) => svcSet.has(s.node + ":" + s.name));
} else {
list = [];
}
}
// Search filter
if (searchQuery) {
const q = searchQuery.toLowerCase();
list = list.filter(
(s) =>
s.name.toLowerCase().includes(q) ||
s.image.toLowerCase().includes(q) ||
s.node.toLowerCase().includes(q)
);
}
// Status filter
if (statusFilter === "running") {
list = list.filter((s) => s.status === "running");
} else if (statusFilter === "stopped") {
list = list.filter((s) => s.status !== "running");
}
return list;
}
function renderToolbar(filtered) {
// Title
const titleEl = $("#view-title");
if (currentView === "all") {
titleEl.textContent = "All Services";
} else if (currentView.startsWith("node:")) {
titleEl.textContent = currentView.slice(5);
} else {
const g = groups.find((g) => g.id === currentView);
titleEl.textContent = g ? g.name : "Group";
}
// Count
const countEl = $("#service-count");
countEl.textContent = filtered.length + " service" + (filtered.length !== 1 ? "s" : "");
// Status filter counts (from pre-status-filtered list)
const preFiltered = getPreStatusFilteredServices();
const runningCount = preFiltered.filter((s) => s.status === "running").length;
const stoppedCount = preFiltered.filter((s) => s.status !== "running").length;
const countRunEl = $("#count-running");
const countStopEl = $("#count-stopped");
if (countRunEl) countRunEl.textContent = runningCount;
if (countStopEl) countStopEl.textContent = stoppedCount;
// Update active pill
$$(".filter-pill").forEach((pill) => {
pill.classList.toggle("active", pill.dataset.filter === statusFilter);
});
// Group actions visibility
const groupActions = $("#group-actions");
const isGroupView =
currentView !== "all" && !currentView.startsWith("node:");
groupActions.style.display = isGroupView ? "flex" : "none";
}
function renderServices(filtered) {
const grid = $("#service-grid");
const emptyState = $("#empty-state");
if (filtered.length === 0) {
grid.innerHTML = "";
emptyState.style.display = "block";
return;
}
emptyState.style.display = "none";
// Sort: running first, then by name
filtered.sort((a, b) => {
const aRun = a.status === "running" ? 0 : 1;
const bRun = b.status === "running" ? 0 : 1;
if (aRun !== bRun) return aRun - bRun;
return a.name.localeCompare(b.name);
});
grid.innerHTML = filtered.map((s) => renderServiceCard(s)).join("");
// Bind action buttons
grid.querySelectorAll("[data-action]").forEach((btn) => {
btn.addEventListener("click", () => {
const action = btn.dataset.action;
const node = btn.dataset.node;
const cid = btn.dataset.container;
if (action === "logs") {
openLogs(node, cid);
} else {
serviceAction(node, cid, action, btn);
}
});
});
}
function renderServiceCard(s) {
const isRunning = s.status === "running";
const isStopped = s.status === "exited" || s.status === "created" || s.status === "dead" || s.status === "stopped";
const statusClass = "status-" + (s.status || "unknown");
const statusLabel = (s.status || "unknown").toUpperCase();
const uptimeText = isRunning && s.uptime ? s.uptime : isStopped ? "Stopped" : s.status || "";
// Truncate image to last 50 chars if long
const imageDisplay =
s.image.length > 55 ? "..." + s.image.slice(-52) : s.image;
const swarmBadge = s.is_swarm
? `<span class="card-swarm-badge">Swarm</span>`
: "";
const stoppedClass = !isRunning ? " stopped" : "";
return `
<div class="service-card${stoppedClass}">
<div class="card-header">
<div>
<span class="card-name">${escHtml(s.name)}</span>
${swarmBadge}
</div>
<span class="status-badge ${statusClass}">
<span class="status-dot"></span> ${statusLabel}
</span>
</div>
<div class="card-meta">
<div class="card-meta-row">
<span class="card-meta-label">Node</span>
<span class="card-meta-value">${escHtml(s.node)}</span>
</div>
<div class="card-meta-row">
<span class="card-meta-label">Image</span>
<span class="card-meta-value" title="${escHtml(s.image)}">${escHtml(imageDisplay)}</span>
</div>
<div class="card-meta-row">
<span class="card-meta-label">Up</span>
<span class="card-meta-value">${escHtml(uptimeText)}</span>
</div>
</div>
<div class="card-actions">
<button class="card-action-btn action-start" title="Start"
data-action="start" data-node="${escHtml(s.node)}" data-container="${escHtml(s.id)}"
${isRunning ? "disabled" : ""}>
<span>&#9654;</span>
</button>
<button class="card-action-btn action-stop" title="Stop"
data-action="stop" data-node="${escHtml(s.node)}" data-container="${escHtml(s.id)}"
${isStopped ? "disabled" : ""}>
<span>&#9632;</span>
</button>
<button class="card-action-btn action-restart" title="Restart"
data-action="restart" data-node="${escHtml(s.node)}" data-container="${escHtml(s.id)}">
<span>&#8635;</span>
</button>
<button class="card-action-btn action-pull" title="Pull Image"
data-action="pull" data-node="${escHtml(s.node)}" data-container="${escHtml(s.id)}">
<span>&#11015;</span>
</button>
<button class="card-action-btn action-logs" title="View Logs"
data-action="logs" data-node="${escHtml(s.node)}" data-container="${escHtml(s.id)}">
<span>&#128203;</span>
</button>
</div>
</div>`;
}
// ---------------------------------------------------------------------------
// Service actions
// ---------------------------------------------------------------------------
async function serviceAction(node, containerId, action, btnEl) {
if (btnEl) {
btnEl.classList.add("loading");
}
try {
const result = await api("POST", `/api/services/${encodeURIComponent(node)}/${encodeURIComponent(containerId)}/${action}`);
if (result.success) {
showToast(result.message || `${capitalize(action)} successful`, "success");
} else {
showToast(result.message || `${capitalize(action)} failed`, "error");
}
// Refresh after short delay to let Docker state catch up
setTimeout(() => refreshAll(), 1000);
} catch (e) {
showToast(`Error: ${e.message}`, "error");
} finally {
if (btnEl) {
btnEl.classList.remove("loading");
}
}
}
// ---------------------------------------------------------------------------
// Group actions
// ---------------------------------------------------------------------------
async function groupAction(groupId, action) {
try {
const results = await api("POST", `/api/groups/${encodeURIComponent(groupId)}/${action}`);
const successes = results.filter((r) => r.success).length;
const failures = results.length - successes;
if (failures === 0) {
showToast(`${capitalize(action)} all: ${successes} succeeded`, "success");
} else {
showToast(
`${capitalize(action)} all: ${successes} succeeded, ${failures} failed`,
"warning"
);
}
setTimeout(() => refreshAll(), 1500);
} catch (e) {
showToast(`Group action error: ${e.message}`, "error");
}
}
// ---------------------------------------------------------------------------
// Log viewer
// ---------------------------------------------------------------------------
async function openLogs(node, containerId) {
logPanelOpen = true;
updateRefreshIndicator();
const overlay = $("#log-overlay");
const panel = $("#log-panel");
const titleEl = $("#log-title");
const contentEl = $("#log-content");
overlay.style.display = "block";
titleEl.textContent = "Loading...";
contentEl.textContent = "";
// Trigger reflow then add class for transition
requestAnimationFrame(() => {
panel.classList.add("open");
});
try {
const data = await api(
"GET",
`/api/services/${encodeURIComponent(node)}/${encodeURIComponent(containerId)}/logs?tail=200`
);
titleEl.textContent = data.container || containerId;
contentEl.textContent = data.logs || "(no logs)";
// Auto-scroll to bottom
contentEl.scrollTop = contentEl.scrollHeight;
} catch (e) {
titleEl.textContent = "Error";
contentEl.textContent = `Failed to load logs: ${e.message}`;
}
}
function closeLogs() {
logPanelOpen = false;
updateRefreshIndicator();
const overlay = $("#log-overlay");
const panel = $("#log-panel");
panel.classList.remove("open");
overlay.style.display = "none";
}
// ---------------------------------------------------------------------------
// Group management
// ---------------------------------------------------------------------------
function openGroupEditor(groupId = null) {
editingGroupId = groupId;
modalOpen = true;
updateRefreshIndicator();
const modal = $("#group-modal");
const titleEl = $("#group-modal-title");
const nameInput = $("#group-name-input");
const searchInput = $("#group-service-search");
if (groupId) {
const g = groups.find((x) => x.id === groupId);
titleEl.textContent = "Edit Group";
nameInput.value = g ? g.name : "";
} else {
titleEl.textContent = "Create Group";
nameInput.value = "";
}
searchInput.value = "";
renderGroupServiceList();
modal.style.display = "flex";
nameInput.focus();
}
function closeGroupEditor() {
modalOpen = false;
editingGroupId = null;
updateRefreshIndicator();
$("#group-modal").style.display = "none";
}
function renderGroupServiceList() {
const container = $("#group-service-list");
if (!container) return;
// Get currently selected services for the editing group
let selected = new Set();
if (editingGroupId) {
const g = groups.find((x) => x.id === editingGroupId);
if (g) {
g.services.forEach((s) => selected.add(s.node + ":" + s.container));
}
}
// Group services by node
const byNode = {};
services.forEach((s) => {
if (!byNode[s.node]) byNode[s.node] = [];
byNode[s.node].push(s);
});
let html = "";
Object.keys(byNode)
.sort()
.forEach((nodeName) => {
html += `<div class="group-service-node-heading">${escHtml(nodeName)}</div>`;
byNode[nodeName]
.sort((a, b) => a.name.localeCompare(b.name))
.forEach((s) => {
const key = s.node + ":" + s.name;
const checked = selected.has(key) ? "checked" : "";
html += `
<label class="group-service-item" data-search="${escHtml(s.name.toLowerCase() + " " + s.node.toLowerCase())}">
<input type="checkbox" value="${escHtml(key)}" ${checked}>
<span>${escHtml(s.name)}</span>
</label>`;
});
});
container.innerHTML = html;
}
function filterGroupServiceList(query) {
const items = $$("#group-service-list .group-service-item");
const q = query.toLowerCase();
items.forEach((item) => {
const searchText = item.dataset.search || "";
item.classList.toggle("hidden", q && !searchText.includes(q));
});
}
async function saveGroup() {
const nameInput = $("#group-name-input");
const name = nameInput.value.trim();
if (!name) {
showToast("Group name is required", "error");
nameInput.focus();
return;
}
// Collect checked services
const checked = $$("#group-service-list input[type=checkbox]:checked");
const svcList = [];
checked.forEach((cb) => {
const [node, ...containerParts] = cb.value.split(":");
svcList.push({ node, container: containerParts.join(":") });
});
// Generate ID from name if creating
const id = editingGroupId || name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
const payload = { id, name, services: svcList };
try {
if (editingGroupId) {
await api("PUT", `/api/groups/${encodeURIComponent(editingGroupId)}`, payload);
showToast(`Group "${name}" updated`, "success");
} else {
await api("POST", "/api/groups", payload);
showToast(`Group "${name}" created`, "success");
}
closeGroupEditor();
await refreshAll();
} catch (e) {
showToast(`Failed to save group: ${e.message}`, "error");
}
}
async function deleteGroup(groupId) {
try {
await api("DELETE", `/api/groups/${encodeURIComponent(groupId)}`);
showToast("Group deleted", "success");
if (currentView === groupId) {
currentView = "all";
}
await refreshAll();
} catch (e) {
showToast(`Failed to delete group: ${e.message}`, "error");
}
}
// ---------------------------------------------------------------------------
// Confirmation modal
// ---------------------------------------------------------------------------
let confirmCallback = null;
function showConfirmModal(message, onConfirm) {
modalOpen = true;
updateRefreshIndicator();
confirmCallback = onConfirm;
$("#confirm-message").textContent = message;
$("#confirm-modal").style.display = "flex";
}
function closeConfirmModal() {
modalOpen = false;
updateRefreshIndicator();
confirmCallback = null;
$("#confirm-modal").style.display = "none";
}
// ---------------------------------------------------------------------------
// Toast notifications
// ---------------------------------------------------------------------------
function showToast(message, type = "success") {
const container = $("#toast-container");
const toast = document.createElement("div");
toast.className = `toast toast-${type}`;
const icons = {
success: "\u2713",
error: "\u2717",
warning: "\u26A0",
info: "\u2139",
};
toast.innerHTML = `<span>${icons[type] || ""}</span> ${escHtml(message)}`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add("removing");
toast.addEventListener("animationend", () => toast.remove());
}, 3000);
}
// ---------------------------------------------------------------------------
// Search / filter
// ---------------------------------------------------------------------------
function filterServices(query) {
searchQuery = query;
renderMain();
}
// ---------------------------------------------------------------------------
// Refresh indicator
// ---------------------------------------------------------------------------
function updateRefreshIndicator() {
const indicator = $("#refresh-indicator");
if (!indicator) return;
indicator.classList.toggle("paused", logPanelOpen || modalOpen);
}
// ---------------------------------------------------------------------------
// Links page
// ---------------------------------------------------------------------------
const serverLinks = [
{ category: "Public Services", links: [
{ name: "Home Assistant", url: "https://yoda.hobbs.farm", icon: "\uD83C\uDFE0" },
{ name: "Matrix Chat", url: "https://mess.hobbs.farm", icon: "\uD83D\uDCAC" },
{ name: "Guacamole", url: "https://solo.hobbs.farm/guacamole/", icon: "\uD83D\uDDA5\uFE0F" },
{ name: "Vaultwarden", url: "https://vault.hobbs.farm", icon: "\uD83D\uDD12" },
{ name: "Gitea", url: "https://git.hobbs.farm", icon: "\uD83D\uDCE6" },
]},
{ category: "hf-pdocker-01 (192.168.86.192)", links: [
{ name: "Farm Manager", url: "http://192.168.86.192:8888", icon: "\uD83C\uDF3E" },
{ name: "Frigate NVR", url: "http://192.168.86.192:5000", icon: "\uD83D\uDCF7" },
{ name: "Home Assistant", url: "http://192.168.86.192:8123", icon: "\uD83C\uDFE0" },
{ name: "ESPHome", url: "http://192.168.86.192:6052", icon: "\uD83D\uDD0C" },
{ name: "Grafana", url: "http://192.168.86.192:3001", icon: "\uD83D\uDCCA" },
{ name: "Prometheus", url: "http://192.168.86.192:9095", icon: "\uD83D\uDD25" },
{ name: "Alertmanager", url: "http://192.168.86.192:9093", icon: "\uD83D\uDEA8" },
{ name: "AdGuard Home", url: "http://192.168.86.192:3000", icon: "\uD83D\uDEE1\uFE0F" },
{ name: "Frigate Alerts", url: "http://192.168.86.192:5199", icon: "\uD83D\uDD14" },
{ name: "Traefik Dashboard", url: "http://192.168.86.192:8090", icon: "\uD83D\uDEA6" },
{ name: "Portainer", url: "http://192.168.86.192:9000", icon: "\uD83D\uDC33" },
]},
{ category: "hf-pdocker-02 (192.168.86.100)", links: [
{ name: "Gitea", url: "http://192.168.86.100:3003", icon: "\uD83D\uDCE6" },
{ name: "Wiki.js", url: "http://192.168.86.100:3002", icon: "\uD83D\uDCDA" },
{ name: "Semaphore", url: "http://192.168.86.100:3004", icon: "\uD83C\uDFAF" },
{ name: "Unifi Controller", url: "https://192.168.86.100:8443", icon: "\uD83D\uDCF6" },
{ name: "Vaultwarden", url: "http://192.168.86.100:26226", icon: "\uD83D\uDD12" },
{ name: "Actual Budget", url: "http://192.168.86.100:5006", icon: "\uD83D\uDCB0" },
{ name: "MCPO", url: "http://192.168.86.100:8001", icon: "\uD83E\uDD16" },
{ name: "Trivy", url: "http://192.168.86.100:4954", icon: "\uD83D\uDD0D" },
]},
{ category: "bart (192.168.86.167)", links: [
{ name: "Jellyfin", url: "http://192.168.86.167:8096", icon: "\uD83C\uDFAC" },
{ name: "Sonarr", url: "http://192.168.86.167:8989", icon: "\uD83D\uDCFA" },
{ name: "Radarr", url: "http://192.168.86.167:7878", icon: "\uD83C\uDFAC" },
{ name: "SABnzbd", url: "http://192.168.86.167:8082", icon: "\u2B07\uFE0F" },
{ name: "NZBHydra2", url: "http://192.168.86.167:5076", icon: "\uD83D\uDD0E" },
{ name: "Jellyseerr", url: "http://192.168.86.167:5055", icon: "\u2B50" },
{ name: "OpenVAS", url: "http://192.168.86.167:9392", icon: "\uD83D\uDEE1\uFE0F" },
]},
{ category: "Infrastructure", links: [
{ name: "Synology NAS", url: "https://192.168.86.30:5001", icon: "\uD83D\uDCBE" },
]},
];
function renderLinksView() {
const container = $("#links-view");
container.innerHTML = `<h2 style="margin-bottom:20px;">Server Links</h2>` +
serverLinks.map((cat) => `
<div class="links-category">
<div class="links-category-title">${escHtml(cat.category)}</div>
<div class="links-grid">
${cat.links.map((l) => `
<a href="${escHtml(l.url)}" target="_blank" rel="noopener" class="link-card">
<div class="link-icon">${l.icon}</div>
<div class="link-info">
<div class="link-name">${escHtml(l.name)}</div>
<div class="link-url">${escHtml(l.url)}</div>
</div>
<span class="link-arrow">\u203A</span>
</a>
`).join("")}
</div>
</div>
`).join("");
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function escHtml(str) {
if (str == null) return "";
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
// ---------------------------------------------------------------------------
// Event bindings
// ---------------------------------------------------------------------------
function bindEvents() {
// All Services nav
const allNav = $("#nav-all");
if (allNav) {
allNav.addEventListener("click", (e) => {
e.preventDefault();
currentView = "all";
renderSidebar();
renderMain();
});
}
// Links nav
const linksNav = $("#nav-links");
if (linksNav) {
linksNav.addEventListener("click", (e) => {
e.preventDefault();
currentView = "links";
renderSidebar();
renderMain();
});
}
// Status filter pills
$$(".filter-pill").forEach((pill) => {
pill.addEventListener("click", () => {
statusFilter = pill.dataset.filter;
renderMain();
});
});
// Search
const searchInput = $("#search-input");
if (searchInput) {
searchInput.addEventListener("input", () => {
filterServices(searchInput.value);
});
}
// Log panel close
$("#btn-close-logs").addEventListener("click", closeLogs);
$("#log-overlay").addEventListener("click", closeLogs);
// Group modal
$("#btn-add-group").addEventListener("click", () => openGroupEditor());
$("#btn-close-group-modal").addEventListener("click", closeGroupEditor);
$("#btn-cancel-group").addEventListener("click", closeGroupEditor);
$("#btn-save-group").addEventListener("click", saveGroup);
// Group service search within modal
$("#group-service-search").addEventListener("input", (e) => {
filterGroupServiceList(e.target.value);
});
// Confirm modal
$("#btn-confirm-cancel").addEventListener("click", closeConfirmModal);
$("#btn-confirm-ok").addEventListener("click", () => {
const cb = confirmCallback;
closeConfirmModal();
if (cb) cb();
});
// Group bulk actions
$("#btn-group-start").addEventListener("click", () => {
if (currentView && !currentView.startsWith("node:") && currentView !== "all") {
groupAction(currentView, "start");
}
});
$("#btn-group-stop").addEventListener("click", () => {
if (currentView && !currentView.startsWith("node:") && currentView !== "all") {
const g = groups.find((x) => x.id === currentView);
showConfirmModal(
`Stop all services in "${g ? g.name : currentView}"?`,
() => groupAction(currentView, "stop")
);
}
});
$("#btn-group-restart").addEventListener("click", () => {
if (currentView && !currentView.startsWith("node:") && currentView !== "all") {
const g = groups.find((x) => x.id === currentView);
showConfirmModal(
`Restart all services in "${g ? g.name : currentView}"?`,
() => groupAction(currentView, "restart")
);
}
});
// Close modals on Escape key
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
if (logPanelOpen) closeLogs();
if (modalOpen) {
closeGroupEditor();
closeConfirmModal();
}
}
});
// Close group modal when clicking overlay backdrop
$("#group-modal").addEventListener("click", (e) => {
if (e.target === $("#group-modal")) {
closeGroupEditor();
}
});
$("#confirm-modal").addEventListener("click", (e) => {
if (e.target === $("#confirm-modal")) {
closeConfirmModal();
}
});
}
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
async function init() {
bindEvents();
await refreshAll();
refreshInterval = setInterval(refreshAll, 15000);
}
document.addEventListener("DOMContentLoaded", init);