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 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 22:31:02 -06:00
parent 6e9465942a
commit 093a7ea95d
3 changed files with 1969 additions and 0 deletions
+778
View File
@@ -0,0 +1,778 @@
/* ===== 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 = "";
// ---------------------------------------------------------------------------
// 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() {
// 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
? `<span class="card-swarm-badge">Swarm</span>`
: "";
return `
<div class="service-card">
<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);
}
// ---------------------------------------------------------------------------
// 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();
});
}
// 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);
+128
View File
@@ -0,0 +1,128 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Farm Manager</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<!-- ===== Header ===== -->
<header id="header">
<h1>Farm Manager</h1>
<div class="header-right">
<span id="refresh-indicator" class="refresh-indicator" title="Auto-refreshing every 15s">
<span class="refresh-dot"></span> Auto-refresh
</span>
<span id="last-updated" class="last-updated"></span>
</div>
</header>
<!-- ===== Sidebar ===== -->
<nav id="sidebar">
<div class="sidebar-section">
<a href="#" class="sidebar-item active" id="nav-all" data-view="all">
<span class="sidebar-icon">&#9776;</span> All Services
</a>
</div>
<div class="sidebar-section">
<div class="sidebar-heading">Nodes</div>
<div id="node-list"></div>
</div>
<div class="sidebar-section">
<div class="sidebar-heading">
Groups
<button class="btn-icon" id="btn-add-group" title="Create group">&#10010;</button>
</div>
<div id="group-list"></div>
</div>
</nav>
<!-- ===== Main Content ===== -->
<main id="main">
<!-- Toolbar -->
<div id="toolbar" class="toolbar">
<div class="toolbar-left">
<h2 id="view-title">All Services</h2>
<span id="service-count" class="service-count"></span>
</div>
<div class="toolbar-center" id="group-actions" style="display:none;">
<button class="btn btn-success btn-sm" id="btn-group-start" title="Start all in group">&#9654; Start All</button>
<button class="btn btn-danger btn-sm" id="btn-group-stop" title="Stop all in group">&#9632; Stop All</button>
<button class="btn btn-warning btn-sm" id="btn-group-restart" title="Restart all in group">&#8635; Restart All</button>
</div>
<div class="toolbar-right">
<input type="text" id="search-input" class="search-input" placeholder="Search services...">
</div>
</div>
<!-- Service Grid -->
<div id="service-grid" class="service-grid"></div>
<!-- Empty state -->
<div id="empty-state" class="empty-state" style="display:none;">
<span class="empty-icon">&#128230;</span>
<p>No services found</p>
</div>
</main>
<!-- ===== Log Viewer (slide-out panel) ===== -->
<div id="log-overlay" class="log-overlay" style="display:none;"></div>
<div id="log-panel" class="log-panel">
<div class="log-header">
<h3 id="log-title">Container Logs</h3>
<button class="btn-icon log-close" id="btn-close-logs" title="Close">&#10005;</button>
</div>
<pre id="log-content" class="log-content"></pre>
</div>
<!-- ===== Group Editor Modal ===== -->
<div id="group-modal" class="modal-overlay" style="display:none;">
<div class="modal">
<div class="modal-header">
<h3 id="group-modal-title">Create Group</h3>
<button class="btn-icon" id="btn-close-group-modal" title="Close">&#10005;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="group-name-input">Group Name</label>
<input type="text" id="group-name-input" class="form-input" placeholder="e.g. Media Stack">
</div>
<div class="form-group">
<label>Services</label>
<input type="text" id="group-service-search" class="form-input" placeholder="Filter services...">
<div id="group-service-list" class="group-service-list"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="btn-cancel-group">Cancel</button>
<button class="btn btn-primary" id="btn-save-group">Save</button>
</div>
</div>
</div>
<!-- ===== Confirmation Modal ===== -->
<div id="confirm-modal" class="modal-overlay" style="display:none;">
<div class="modal modal-sm">
<div class="modal-header">
<h3>Confirm Action</h3>
</div>
<div class="modal-body">
<p id="confirm-message"></p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="btn-confirm-cancel">Cancel</button>
<button class="btn btn-danger" id="btn-confirm-ok">Confirm</button>
</div>
</div>
</div>
<!-- ===== Toast Container ===== -->
<div id="toast-container" class="toast-container"></div>
<script src="/app.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff