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:
@@ -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">📁</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">✎</button>
|
||||||
|
<button class="group-action-btn delete" data-group-id="${escHtml(g.id)}" title="Delete">✕</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>▶</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>■</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>↻</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>⬇</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>📋</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, "&")
|
||||||
|
.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);
|
||||||
@@ -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">☰</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">✚</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">▶ Start All</button>
|
||||||
|
<button class="btn btn-danger btn-sm" id="btn-group-stop" title="Stop all in group">■ Stop All</button>
|
||||||
|
<button class="btn btn-warning btn-sm" id="btn-group-restart" title="Restart all in group">↻ 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">📦</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">✕</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">✕</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
Reference in New Issue
Block a user