feat(ui): cluster node registry page — health, CPU, memory, deregister

This commit is contained in:
Zac Gaetano 2026-05-19 23:58:17 -04:00
parent e3cdf70883
commit 0c761d553c

View file

@ -0,0 +1,393 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<title>Cluster — Z-AMPP</title>
<link rel="stylesheet" href="css/common.css">
<style>
.page-body {
flex: 1;
overflow: auto;
padding: 24px;
}
.z-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.z-table th {
padding: 8px 12px;
text-align: left;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--text-tertiary);
border-bottom: 1px solid var(--border);
background: var(--bg-panel);
position: sticky;
top: 0;
z-index: 1;
white-space: nowrap;
}
.z-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
color: var(--text-secondary);
}
.z-table tr:hover td { background: var(--bg-hover); }
.z-table .empty-row {
text-align: center;
padding: 48px;
color: var(--text-tertiary);
}
.node-status-dot {
width: 8px; height: 8px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.status-online { background: oklch(62% 0.20 145); box-shadow: 0 0 5px oklch(62% 0.20 145 / 0.6); }
.status-warn { background: oklch(68% 0.18 80); box-shadow: 0 0 5px oklch(68% 0.18 80 / 0.6); }
.status-offline { background: oklch(55% 0.16 25); }
.role-badge {
display: inline-flex;
align-items: center;
padding: 2px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
}
.role-primary { background: oklch(22% 0.07 266 / 0.6); color: oklch(68% 0.18 266); border: 1px solid oklch(45% 0.14 266 / 0.5); }
.role-worker { background: oklch(18% 0.03 250 / 0.6); color: oklch(55% 0.06 250); border: 1px solid oklch(35% 0.05 250 / 0.5); }
.mem-bar-wrap {
width: 80px;
height: 4px;
background: var(--bg-surface);
border-radius: 2px;
overflow: hidden;
margin-top: 3px;
}
.mem-bar {
height: 100%;
border-radius: 2px;
background: oklch(55% 0.16 266);
transition: width 0.4s ease;
}
.refresh-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--text-xs);
color: var(--text-tertiary);
}
.refresh-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: oklch(65% 0.18 80);
flex-shrink: 0;
}
.refresh-dot.live {
background: oklch(62% 0.18 145);
animation: rd-pulse 2s ease-in-out infinite;
}
@keyframes rd-pulse { 0%,100%{opacity:.75;} 50%{opacity:1;} }
.cluster-summary {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.cluster-stat {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 20px;
min-width: 100px;
}
.cluster-stat-value {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
.cluster-stat-label {
font-size: 10px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: .08em;
color: var(--text-tertiary);
margin-top: 4px;
}
</style>
</head>
<body>
<div class="shell">
<nav class="sidebar" aria-label="Main navigation">
<div class="sidebar-brand">
<img src="img/dragon-logo.png?v=1" alt="Wild Dragon" class="sidebar-logo">
<span class="sidebar-brand-name">Z-AMPP</span>
</div>
<nav class="sidebar-nav">
<a href="home.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
Home
</a>
<a href="index.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
Library
</a>
<a href="projects.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
Projects
</a>
<a href="upload.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
Ingest
</a>
<a href="recorders.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
Recorders
</a>
<a href="capture.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
Capture
</a>
<a href="jobs.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
Jobs
</a>
<a href="editor.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
Editor
</a>
<div class="sidebar-section-label">Admin</div>
<a href="users.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
Users
</a>
<a href="tokens.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
Tokens
</a>
<a href="containers.html" class="nav-item">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="5" width="14" height="9" rx="1"/><path d="M1 5l2-3h10l2 3"/><line x1="5" y1="5" x2="5" y2="14"/><line x1="11" y1="5" x2="11" y2="14"/></svg>
Containers
</a>
<a href="cluster.html" class="nav-item active">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="3" r="2"/><circle cx="3" cy="13" r="2"/><circle cx="13" cy="13" r="2"/><line x1="8" y1="5" x2="8" y2="9"/><line x1="8" y1="9" x2="3" y2="11"/><line x1="8" y1="9" x2="13" y2="11"/></svg>
Cluster
</a>
</nav>
<div class="sidebar-footer">
<div class="sidebar-user">
<div class="sidebar-user-avatar" id="userAvatar">?</div>
<div class="sidebar-user-info">
<div class="sidebar-user-name" id="userName">&#8212;</div>
<div class="sidebar-user-role" id="userRole"></div>
</div>
<button class="btn btn-ghost" id="logoutBtn" style="padding:0;width:28px;height:28px;flex-shrink:0;" title="Sign out">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3M11 11l3-3-3-3M6 8h8"/></svg>
</button>
</div>
</div>
</nav>
<div class="main">
<header class="topbar">
<div class="topbar-left">
<span class="page-title">Cluster</span>
</div>
<div class="topbar-right">
<div class="refresh-indicator">
<span class="refresh-dot" id="refreshDot"></span>
<span id="refreshText">Loading&hellip;</span>
</div>
<button class="btn btn-ghost btn-sm" id="refreshBtn" style="margin-left:8px;">Refresh</button>
</div>
</header>
<div class="page-body">
<div class="cluster-summary">
<div class="cluster-stat">
<div class="cluster-stat-value" id="statTotal"></div>
<div class="cluster-stat-label">Total nodes</div>
</div>
<div class="cluster-stat">
<div class="cluster-stat-value" id="statOnline" style="color:oklch(62% 0.20 145);"></div>
<div class="cluster-stat-label">Online</div>
</div>
<div class="cluster-stat">
<div class="cluster-stat-value" id="statOffline" style="color:oklch(55% 0.16 25);"></div>
<div class="cluster-stat-label">Offline</div>
</div>
</div>
<table class="z-table">
<thead>
<tr>
<th style="width:16px"></th>
<th>Node</th>
<th>IP</th>
<th>Role</th>
<th>CPU</th>
<th>Memory</th>
<th>Version</th>
<th>Last seen</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody id="clusterBody">
<tr><td colspan="9" class="empty-row">Loading&hellip;</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
<script src="js/api.js?v=6"></script>
<script>
let nodes = [];
async function loadNodes() {
try {
const r = await fetch('/api/v1/cluster', { credentials: 'include' });
if (!r.ok) throw new Error('HTTP ' + r.status);
nodes = await r.json();
renderNodes();
setDot('live', 'Live');
} catch (err) {
setDot('error', 'Error');
console.error('Cluster load failed:', err);
}
}
function statusClass(stale) {
if (stale < 60) return 'online';
if (stale < 300) return 'warn';
return 'offline';
}
function relTime(isoStr) {
if (!isoStr) return '—';
const diff = (Date.now() - new Date(isoStr)) / 1000;
if (diff < 5) return 'just now';
if (diff < 60) return Math.round(diff) + 's ago';
if (diff < 3600) return Math.round(diff / 60) + 'm ago';
if (diff < 86400) return Math.round(diff / 3600) + 'h ago';
return Math.round(diff / 86400) + 'd ago';
}
function renderNodes() {
const tbody = document.getElementById('clusterBody');
const online = nodes.filter(n => Number(n.stale_seconds) < 120).length;
const offline = nodes.length - online;
document.getElementById('statTotal').textContent = nodes.length;
document.getElementById('statOnline').textContent = online;
document.getElementById('statOffline').textContent = offline;
if (!nodes.length) {
tbody.innerHTML = '<tr><td colspan="9" class="empty-row">No cluster nodes registered.</td></tr>';
return;
}
tbody.innerHTML = nodes.map(n => {
const stale = Number(n.stale_seconds || 9999);
const sc = statusClass(stale);
const memPct = n.mem_total_mb ? Math.round((n.mem_used_mb / n.mem_total_mb) * 100) : 0;
const memTxt = n.mem_total_mb
? `${Math.round(n.mem_used_mb || 0)} / ${Math.round(n.mem_total_mb)} MB`
: '—';
const cpuTxt = n.cpu_usage != null ? parseFloat(n.cpu_usage).toFixed(2) + '%' : '—';
const roleCls = n.role === 'primary' ? 'role-primary' : 'role-worker';
return `
<tr>
<td><span class="node-status-dot status-${sc}" title="${sc}"></span></td>
<td>
<div style="font-weight:500;color:var(--text-primary);">${esc(n.hostname)}</div>
${n.api_url ? `<div style="font-size:10px;color:var(--text-tertiary);font-family:var(--font-mono);">${esc(n.api_url)}</div>` : ''}
</td>
<td style="font-family:var(--font-mono);font-size:11px;">${esc(n.ip_address || '—')}</td>
<td><span class="role-badge ${roleCls}">${esc(n.role)}</span></td>
<td style="font-family:var(--font-mono);font-size:11px;">${cpuTxt}</td>
<td>
<div style="font-size:11px;font-family:var(--font-mono);">${memTxt}</div>
${n.mem_total_mb ? `<div class="mem-bar-wrap"><div class="mem-bar" style="width:${memPct}%"></div></div>` : ''}
</td>
<td style="font-family:var(--font-mono);font-size:11px;">${esc(n.version || '—')}</td>
<td style="font-size:11px;color:var(--text-tertiary);">${relTime(n.last_seen)}</td>
<td style="text-align:right">
<button class="btn btn-ghost btn-sm"
style="${n.role === 'primary' ? 'opacity:0.4;' : ''}"
onclick="removeNode('${esc(n.id)}','${esc(n.hostname)}',this)"
${n.role === 'primary' ? 'title="Cannot remove the primary node"' : 'title="Remove node"'}>
Remove
</button>
</td>
</tr>`;
}).join('');
}
async function removeNode(id, hostname, btn) {
if (btn.disabled) return;
if (!confirm(`Remove node "${hostname}" from the cluster registry?`)) return;
btn.disabled = true;
try {
const r = await fetch(`/api/v1/cluster/${id}`, { method: 'DELETE', credentials: 'include' });
if (!r.ok) {
const j = await r.json().catch(() => ({}));
throw new Error(j.error || 'HTTP ' + r.status);
}
toast('Node removed', hostname, 'success');
await loadNodes();
} catch (err) {
toast('Remove failed', err.message, 'error');
btn.disabled = false;
}
}
function setDot(state, label) {
const dot = document.getElementById('refreshDot');
document.getElementById('refreshText').textContent = label;
dot.className = 'refresh-dot' + (state === 'live' ? ' live' : '');
}
function toast(title, msg, type) {
const el = document.createElement('div');
el.className = 'toast toast--' + (type || 'info');
el.innerHTML = '<div class="toast-body"><div class="toast-title">' + esc(title) + '</div>' +
(msg ? '<div class="toast-msg">' + esc(msg) + '</div>' : '') + '</div>';
document.getElementById('toastContainer').appendChild(el);
setTimeout(() => el.remove(), 4000);
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
document.addEventListener('DOMContentLoaded', () => {
loadNodes();
setInterval(loadNodes, 30000);
// Update relative timestamps every minute without a full fetch
setInterval(() => { if (nodes.length) renderNodes(); }, 60000);
document.getElementById('refreshBtn').onclick = loadNodes;
});
</script>
<script src="js/auth-guard.js"></script>
</body>
</html>