/* ===== Farm Manager Dashboard ===== */ // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- let services = []; let groups = []; let nodes = []; let currentView = "all"; // "all", "node:", 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 `
${escHtml(n.name)} ${count}
`; }) .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 = '
No groups yet
'; return; } container.innerHTML = groups .map((g) => { const isActive = currentView === g.id; return `
📁 ${escHtml(g.name)} ${g.services.length}
`; }) .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 ? `Swarm` : ""; const stoppedClass = !isRunning ? " stopped" : ""; return `
${escHtml(s.name)} ${swarmBadge}
${statusLabel}
Node ${escHtml(s.node)}
Image ${escHtml(imageDisplay)}
Up ${escHtml(uptimeText)}
`; } // --------------------------------------------------------------------------- // 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 += `
${escHtml(nodeName)}
`; 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 += ` `; }); }); 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 = `${icons[type] || ""} ${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 = `

Server Links

` + serverLinks.map((cat) => ` `).join(""); } // --------------------------------------------------------------------------- // Utilities // --------------------------------------------------------------------------- function escHtml(str) { if (str == null) return ""; return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } 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);