From 093a7ea95dbfef82d47e61f6ad858d960affd2f9 Mon Sep 17 00:00:00 2001 From: Pluto Date: Thu, 5 Mar 2026 22:31:02 -0600 Subject: [PATCH] feat(dashboard): service grid, log viewer, group management, dark theme Single-page dashboard with vanilla HTML/CSS/JS: - Responsive service card grid with status badges and action buttons - Sidebar with node health indicators, group navigation - Slide-out log viewer panel with monospace dark output - Group editor modal with searchable service checkbox list - Bulk start/stop/restart with confirmation dialogs - Toast notifications, auto-refresh every 15s, search filtering - Dark theme using CSS custom properties Co-Authored-By: Claude Opus 4.6 --- server/static/app.js | 778 ++++++++++++++++++++++++++++ server/static/index.html | 128 +++++ server/static/style.css | 1063 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 1969 insertions(+) create mode 100644 server/static/app.js create mode 100644 server/static/index.html create mode 100644 server/static/style.css diff --git a/server/static/app.js b/server/static/app.js new file mode 100644 index 0000000..26f8116 --- /dev/null +++ b/server/static/app.js @@ -0,0 +1,778 @@ +/* ===== 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 = ""; + +// --------------------------------------------------------------------------- +// 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() { + // All services link + const allLink = $("#nav-all"); + if (allLink) { + allLink.classList.toggle("active", currentView === "all"); + } +} + +// --------------------------------------------------------------------------- +// Main content rendering +// --------------------------------------------------------------------------- +function renderMain() { + const filtered = getFilteredServices(); + renderToolbar(filtered); + renderServices(filtered); +} + +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) + ); + } + + 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" : ""); + + // 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"; + 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` + : ""; + + 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); +} + +// --------------------------------------------------------------------------- +// 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(); + }); + } + + // 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); diff --git a/server/static/index.html b/server/static/index.html new file mode 100644 index 0000000..2e18466 --- /dev/null +++ b/server/static/index.html @@ -0,0 +1,128 @@ + + + + + + Farm Manager + + + + + + + + + + + +
+ +
+
+

All Services

+ +
+ +
+ +
+
+ + +
+ + + +
+ + + +
+
+

Container Logs

+ +
+

+
+ + + + + + + + +
+ + + + diff --git a/server/static/style.css b/server/static/style.css new file mode 100644 index 0000000..7e0ca7a --- /dev/null +++ b/server/static/style.css @@ -0,0 +1,1063 @@ +/* ===== CSS Variables ===== */ +:root { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --bg-card: #1c2128; + --border: #30363d; + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --accent: #58a6ff; + --success: #3fb950; + --danger: #f85149; + --warning: #d29922; + --info: #58a6ff; + + --sidebar-width: 280px; + --header-height: 56px; + --log-panel-width: 520px; + --card-radius: 8px; + --transition: 0.2s ease; +} + +/* ===== Reset & Base ===== */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.5; + overflow-x: hidden; +} + +/* ===== Scrollbar ===== */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--bg-tertiary); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--border); +} + +/* ===== Header ===== */ +#header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: var(--header-height); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + z-index: 100; +} + +#header h1 { + font-size: 18px; + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.3px; +} + +.header-right { + display: flex; + align-items: center; + gap: 16px; +} + +.refresh-indicator { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); +} + +.refresh-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--success); + animation: pulse 2s infinite; +} + +.refresh-indicator.paused .refresh-dot { + background: var(--warning); + animation: none; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +.last-updated { + font-size: 12px; + color: var(--text-secondary); +} + +/* ===== Sidebar ===== */ +#sidebar { + position: fixed; + top: var(--header-height); + left: 0; + bottom: 0; + width: var(--sidebar-width); + background: var(--bg-secondary); + border-right: 1px solid var(--border); + overflow-y: auto; + z-index: 50; + padding: 12px 0; +} + +.sidebar-section { + padding: 4px 0; +} + +.sidebar-section + .sidebar-section { + border-top: 1px solid var(--border); + margin-top: 4px; + padding-top: 8px; +} + +.sidebar-heading { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 16px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); +} + +.sidebar-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 16px; + font-size: 14px; + color: var(--text-secondary); + text-decoration: none; + cursor: pointer; + transition: background var(--transition), color var(--transition); + border: none; + background: none; + width: 100%; + text-align: left; +} + +.sidebar-item:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.sidebar-item.active { + background: rgba(88, 166, 255, 0.1); + color: var(--accent); + border-right: 2px solid var(--accent); +} + +.sidebar-icon { + font-size: 14px; + width: 18px; + text-align: center; + flex-shrink: 0; +} + +/* Node items */ +.node-item { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 16px 7px 20px; + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; + transition: background var(--transition); +} + +.node-item:hover { + background: var(--bg-tertiary); +} + +.node-item.active { + background: rgba(88, 166, 255, 0.1); + color: var(--accent); +} + +.health-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.health-dot.healthy { + background: var(--success); + box-shadow: 0 0 4px rgba(63, 185, 80, 0.4); +} + +.health-dot.unhealthy { + background: var(--danger); + box-shadow: 0 0 4px rgba(248, 81, 73, 0.4); +} + +.node-name { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.node-count { + font-size: 11px; + color: var(--text-secondary); + background: var(--bg-tertiary); + padding: 1px 6px; + border-radius: 10px; +} + +/* Group items */ +.group-item { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 16px 7px 20px; + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; + transition: background var(--transition); +} + +.group-item:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.group-item.active { + background: rgba(88, 166, 255, 0.1); + color: var(--accent); +} + +.group-item .group-name { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.group-item .group-actions { + display: flex; + gap: 4px; + opacity: 0; + transition: opacity var(--transition); +} + +.group-item:hover .group-actions { + opacity: 1; +} + +.group-action-btn { + background: none; + border: none; + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + padding: 2px 4px; + border-radius: 3px; + transition: color var(--transition), background var(--transition); + line-height: 1; +} + +.group-action-btn:hover { + color: var(--text-primary); + background: var(--bg-tertiary); +} + +.group-action-btn.delete:hover { + color: var(--danger); +} + +.group-svc-count { + font-size: 11px; + color: var(--text-secondary); + background: var(--bg-tertiary); + padding: 1px 6px; + border-radius: 10px; + flex-shrink: 0; +} + +/* ===== Main Content ===== */ +#main { + margin-left: var(--sidebar-width); + margin-top: var(--header-height); + padding: 20px 24px; + min-height: calc(100vh - var(--header-height)); +} + +/* ===== Toolbar ===== */ +.toolbar { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.toolbar-left { + display: flex; + align-items: baseline; + gap: 10px; + flex-shrink: 0; +} + +.toolbar-left h2 { + font-size: 20px; + font-weight: 600; +} + +.service-count { + font-size: 13px; + color: var(--text-secondary); + background: var(--bg-tertiary); + padding: 2px 8px; + border-radius: 10px; +} + +.toolbar-center { + display: flex; + gap: 8px; +} + +.toolbar-right { + margin-left: auto; +} + +.search-input { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 12px; + font-size: 13px; + color: var(--text-primary); + width: 240px; + transition: border-color var(--transition); +} + +.search-input::placeholder { + color: var(--text-secondary); +} + +.search-input:focus { + outline: none; + border-color: var(--accent); +} + +/* ===== Service Grid ===== */ +.service-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 14px; +} + +/* ===== Service Card ===== */ +.service-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--card-radius); + padding: 16px; + transition: border-color var(--transition), box-shadow var(--transition); +} + +.service-card:hover { + border-color: var(--accent); + box-shadow: 0 0 0 1px rgba(88, 166, 255, 0.15); +} + +.card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 10px; +} + +.card-name { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + word-break: break-all; + line-height: 1.3; +} + +.card-swarm-badge { + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + background: rgba(88, 166, 255, 0.15); + color: var(--accent); + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.5px; + flex-shrink: 0; + margin-left: 8px; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.status-dot { + width: 7px; + height: 7px; + border-radius: 50%; +} + +.status-running .status-dot { + background: var(--success); + box-shadow: 0 0 4px rgba(63, 185, 80, 0.5); +} + +.status-running { + color: var(--success); +} + +.status-exited .status-dot, +.status-stopped .status-dot, +.status-dead .status-dot { + background: var(--danger); +} + +.status-exited, +.status-stopped, +.status-dead { + color: var(--danger); +} + +.status-restarting .status-dot { + background: var(--warning); + animation: pulse 1s infinite; +} + +.status-restarting { + color: var(--warning); +} + +.status-created .status-dot, +.status-paused .status-dot { + background: var(--text-secondary); +} + +.status-created, +.status-paused { + color: var(--text-secondary); +} + +.card-meta { + display: flex; + flex-direction: column; + gap: 3px; + margin: 10px 0; +} + +.card-meta-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); +} + +.card-meta-label { + flex-shrink: 0; + min-width: 40px; +} + +.card-meta-value { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-actions { + display: flex; + gap: 6px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +/* ===== Buttons ===== */ +.btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 6px 12px; + font-size: 13px; + font-weight: 500; + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; + line-height: 1.4; +} + +.btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +.btn-primary { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} + +.btn-primary:hover:not(:disabled) { + background: #4393e6; +} + +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + border-color: var(--border); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--border); +} + +.btn-success { + background: rgba(63, 185, 80, 0.15); + color: var(--success); + border-color: rgba(63, 185, 80, 0.3); +} + +.btn-success:hover:not(:disabled) { + background: rgba(63, 185, 80, 0.25); +} + +.btn-danger { + background: rgba(248, 81, 73, 0.15); + color: var(--danger); + border-color: rgba(248, 81, 73, 0.3); +} + +.btn-danger:hover:not(:disabled) { + background: rgba(248, 81, 73, 0.25); +} + +.btn-warning { + background: rgba(210, 153, 34, 0.15); + color: var(--warning); + border-color: rgba(210, 153, 34, 0.3); +} + +.btn-warning:hover:not(:disabled) { + background: rgba(210, 153, 34, 0.25); +} + +.btn-info { + background: rgba(88, 166, 255, 0.15); + color: var(--info); + border-color: rgba(88, 166, 255, 0.3); +} + +.btn-info:hover:not(:disabled) { + background: rgba(88, 166, 255, 0.25); +} + +.btn-ghost { + background: transparent; + color: var(--text-secondary); + border-color: transparent; +} + +.btn-ghost:hover:not(:disabled) { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.btn-icon { + background: none; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 14px; + padding: 4px 6px; + border-radius: 4px; + transition: all var(--transition); + line-height: 1; +} + +.btn-icon:hover { + color: var(--text-primary); + background: var(--bg-tertiary); +} + +/* Card action buttons */ +.card-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 28px; + font-size: 14px; + border: 1px solid var(--border); + border-radius: 5px; + cursor: pointer; + transition: all var(--transition); + background: var(--bg-tertiary); + color: var(--text-secondary); + position: relative; +} + +.card-action-btn:hover:not(:disabled) { + color: var(--text-primary); +} + +.card-action-btn:disabled { + opacity: 0.25; + cursor: not-allowed; +} + +.card-action-btn.action-start:hover:not(:disabled) { + border-color: var(--success); + color: var(--success); + background: rgba(63, 185, 80, 0.1); +} + +.card-action-btn.action-stop:hover:not(:disabled) { + border-color: var(--danger); + color: var(--danger); + background: rgba(248, 81, 73, 0.1); +} + +.card-action-btn.action-restart:hover:not(:disabled) { + border-color: var(--warning); + color: var(--warning); + background: rgba(210, 153, 34, 0.1); +} + +.card-action-btn.action-pull:hover:not(:disabled) { + border-color: var(--info); + color: var(--info); + background: rgba(88, 166, 255, 0.1); +} + +.card-action-btn.action-logs:hover:not(:disabled) { + border-color: var(--text-secondary); + color: var(--text-primary); + background: var(--bg-tertiary); +} + +/* Tooltip on action buttons */ +.card-action-btn[title]::after { + content: attr(title); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + background: var(--bg-tertiary); + color: var(--text-primary); + font-size: 11px; + padding: 3px 8px; + border-radius: 4px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s; + border: 1px solid var(--border); + z-index: 10; +} + +.card-action-btn[title]:hover::after { + opacity: 1; +} + +/* Spinning indicator for loading */ +.card-action-btn.loading { + pointer-events: none; +} + +.card-action-btn.loading::before { + content: ""; + position: absolute; + inset: 4px; + border: 2px solid transparent; + border-top-color: currentColor; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +.card-action-btn.loading > span { + visibility: hidden; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ===== Log Panel ===== */ +.log-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 200; +} + +.log-panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: var(--log-panel-width); + background: var(--bg-secondary); + border-left: 1px solid var(--border); + z-index: 210; + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.3s ease; +} + +.log-panel.open { + transform: translateX(0); +} + +.log-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.log-header h3 { + font-size: 14px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.log-close { + font-size: 16px; +} + +.log-content { + flex: 1; + overflow: auto; + padding: 14px 18px; + font-family: "SF Mono", "Cascadia Code", "Fira Code", "Consolas", monospace; + font-size: 12px; + line-height: 1.6; + color: #c9d1d9; + background: #010409; + white-space: pre-wrap; + word-break: break-all; + margin: 0; +} + +/* ===== Modals ===== */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 300; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.15s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 12px; + width: 560px; + max-width: 90vw; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4); + animation: slideUp 0.2s ease; +} + +.modal-sm { + width: 400px; +} + +@keyframes slideUp { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +.modal-header h3 { + font-size: 16px; + font-weight: 600; +} + +.modal-body { + padding: 20px; + overflow-y: auto; + flex: 1; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 14px 20px; + border-top: 1px solid var(--border); +} + +/* ===== Form ===== */ +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} + +.form-input { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 12px; + font-size: 14px; + color: var(--text-primary); + transition: border-color var(--transition); +} + +.form-input::placeholder { + color: var(--text-secondary); +} + +.form-input:focus { + outline: none; + border-color: var(--accent); +} + +/* Group service list in modal */ +.group-service-list { + max-height: 340px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 6px; + margin-top: 8px; + background: var(--bg-primary); +} + +.group-service-node-heading { + padding: 8px 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 1; +} + +.group-service-item { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + font-size: 13px; + color: var(--text-primary); + cursor: pointer; + transition: background var(--transition); +} + +.group-service-item:hover { + background: var(--bg-tertiary); +} + +.group-service-item input[type="checkbox"] { + accent-color: var(--accent); + width: 15px; + height: 15px; + cursor: pointer; +} + +.group-service-item.hidden { + display: none; +} + +/* ===== Toasts ===== */ +.toast-container { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 400; + display: flex; + flex-direction: column-reverse; + gap: 8px; +} + +.toast { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-radius: 8px; + font-size: 13px; + min-width: 280px; + max-width: 420px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + animation: toastIn 0.3s ease; + border: 1px solid; +} + +.toast.toast-success { + background: rgba(63, 185, 80, 0.12); + color: var(--success); + border-color: rgba(63, 185, 80, 0.25); +} + +.toast.toast-error { + background: rgba(248, 81, 73, 0.12); + color: var(--danger); + border-color: rgba(248, 81, 73, 0.25); +} + +.toast.toast-info { + background: rgba(88, 166, 255, 0.12); + color: var(--info); + border-color: rgba(88, 166, 255, 0.25); +} + +.toast.toast-warning { + background: rgba(210, 153, 34, 0.12); + color: var(--warning); + border-color: rgba(210, 153, 34, 0.25); +} + +.toast.removing { + animation: toastOut 0.3s ease forwards; +} + +@keyframes toastIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +@keyframes toastOut { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } +} + +/* ===== Empty State ===== */ +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-secondary); +} + +.empty-icon { + font-size: 48px; + display: block; + margin-bottom: 12px; + opacity: 0.5; +} + +.empty-state p { + font-size: 15px; +} + +/* ===== Responsive ===== */ +@media (max-width: 1200px) { + .service-grid { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + } +} + +@media (max-width: 900px) { + #sidebar { + width: 220px; + } + #main { + margin-left: 220px; + } + .log-panel { + width: 100%; + } +} + +@media (max-width: 640px) { + #sidebar { + display: none; + } + #main { + margin-left: 0; + } + .service-grid { + grid-template-columns: 1fr; + } + .toolbar { + flex-direction: column; + align-items: stretch; + } + .toolbar-right { + margin-left: 0; + } + .search-input { + width: 100%; + } +}