From 04c2e1f102a7e4e40154f161a82540ba0764d290 Mon Sep 17 00:00:00 2001 From: Pluto Date: Fri, 6 Mar 2026 07:30:10 -0600 Subject: [PATCH] feat(dashboard): add status filter pills and dim stopped services - Add All/Running/Stopped filter pills with live counts in toolbar - Dim stopped service cards (55% opacity, red left border) - Cards brighten on hover for easy interaction Co-Authored-By: Claude Opus 4.6 --- server/static/app.js | 67 +++++++++++++++++++++++++++++++++++++++- server/static/index.html | 5 +++ server/static/style.css | 57 ++++++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/server/static/app.js b/server/static/app.js index 26f8116..8257ed8 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -12,6 +12,7 @@ let logPanelOpen = false; let modalOpen = false; let editingGroupId = null; // null = creating, string = editing let searchQuery = ""; +let statusFilter = "all"; // "all", "running", "stopped" // --------------------------------------------------------------------------- // DOM references @@ -199,6 +200,39 @@ function renderMain() { 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]; @@ -232,6 +266,13 @@ function getFilteredServices() { ); } + // 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; } @@ -251,6 +292,20 @@ function renderToolbar(filtered) { 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 = @@ -311,8 +366,10 @@ function renderServiceCard(s) { ? `Swarm` : ""; + const stoppedClass = !isRunning ? " stopped" : ""; + return ` -
+
${escHtml(s.name)} @@ -685,6 +742,14 @@ function bindEvents() { }); } + // Status filter pills + $$(".filter-pill").forEach((pill) => { + pill.addEventListener("click", () => { + statusFilter = pill.dataset.filter; + renderMain(); + }); + }); + // Search const searchInput = $("#search-input"); if (searchInput) { diff --git a/server/static/index.html b/server/static/index.html index 2e18466..614a3e7 100644 --- a/server/static/index.html +++ b/server/static/index.html @@ -54,6 +54,11 @@
+
+ + + +
diff --git a/server/static/style.css b/server/static/style.css index 7e0ca7a..b83f48c 100644 --- a/server/static/style.css +++ b/server/static/style.css @@ -344,6 +344,54 @@ body { border-radius: 10px; } +.toolbar-filters { + display: flex; + gap: 4px; +} + +.filter-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 12px; + font-size: 12px; + font-weight: 500; + border: 1px solid var(--border); + border-radius: 20px; + cursor: pointer; + background: transparent; + color: var(--text-secondary); + transition: all var(--transition); +} + +.filter-pill:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.filter-pill.active { + background: rgba(88, 166, 255, 0.15); + color: var(--accent); + border-color: rgba(88, 166, 255, 0.3); +} + +.filter-pill[data-filter="stopped"].active { + background: rgba(248, 81, 73, 0.12); + color: var(--danger); + border-color: rgba(248, 81, 73, 0.3); +} + +.filter-pill[data-filter="running"].active { + background: rgba(63, 185, 80, 0.12); + color: var(--success); + border-color: rgba(63, 185, 80, 0.3); +} + +.filter-count { + font-size: 11px; + font-weight: 600; +} + .toolbar-center { display: flex; gap: 8px; @@ -394,6 +442,15 @@ body { box-shadow: 0 0 0 1px rgba(88, 166, 255, 0.15); } +.service-card.stopped { + opacity: 0.55; + border-left: 3px solid var(--danger); +} + +.service-card.stopped:hover { + opacity: 0.85; +} + .card-header { display: flex; align-items: flex-start;