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