diff options
| author | Stefan Boberg <[email protected]> | 2026-03-04 14:13:46 +0100 |
|---|---|---|
| committer | GitHub Enterprise <[email protected]> | 2026-03-04 14:13:46 +0100 |
| commit | 0763d09a81e5a1d3df11763a7ec75e7860c9510a (patch) | |
| tree | 074575ba6ea259044a179eab0bb396d37268fb09 /src/zenserver/frontend/html/compute | |
| parent | native xmake toolchain definition for UE-clang (#805) (diff) | |
| download | zen-0763d09a81e5a1d3df11763a7ec75e7860c9510a.tar.xz zen-0763d09a81e5a1d3df11763a7ec75e7860c9510a.zip | |
compute orchestration (#763)
- Added local process runners for Linux/Wine, Mac with some sandboxing support
- Horde & Nomad provisioning for development and testing
- Client session queues with lifecycle management (active/draining/cancelled), automatic retry with configurable limits, and manual reschedule API
- Improved web UI for orchestrator, compute, and hub dashboards with WebSocket push updates
- Some security hardening
- Improved scalability and `zen exec` command
Still experimental - compute support is disabled by default
Diffstat (limited to 'src/zenserver/frontend/html/compute')
| -rw-r--r-- | src/zenserver/frontend/html/compute/banner.js | 321 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/compute/compute.html | 1072 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/compute/hub.html | 310 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/compute/index.html | 1 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/compute/nav.js | 79 | ||||
| -rw-r--r-- | src/zenserver/frontend/html/compute/orchestrator.html | 831 |
6 files changed, 2614 insertions, 0 deletions
diff --git a/src/zenserver/frontend/html/compute/banner.js b/src/zenserver/frontend/html/compute/banner.js new file mode 100644 index 000000000..61c7ce21f --- /dev/null +++ b/src/zenserver/frontend/html/compute/banner.js @@ -0,0 +1,321 @@ +/** + * zen-banner.js — Zen Compute dashboard banner Web Component + * + * Usage: + * <script src="/components/zen-banner.js" defer></script> + * + * <zen-banner></zen-banner> + * <zen-banner variant="compact"></zen-banner> + * <zen-banner cluster-status="degraded" load="78"></zen-banner> + * + * Attributes: + * variant "full" (default) | "compact" + * cluster-status "nominal" (default) | "degraded" | "offline" + * load 0–100 integer, shown as a percentage (default: hidden) + * tagline custom tagline text (default: "Orchestrator Overview" / "Orchestrator") + * subtitle text after "ZEN" in the wordmark (default: "COMPUTE") + */ + +class ZenBanner extends HTMLElement { + + static get observedAttributes() { + return ['variant', 'cluster-status', 'load', 'tagline', 'subtitle']; + } + + attributeChangedCallback() { + if (this.shadowRoot) this._render(); + } + + connectedCallback() { + if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); + this._render(); + } + + // ───────────────────────────────────────────── + // Derived values + // ───────────────────────────────────────────── + + get _variant() { return this.getAttribute('variant') || 'full'; } + get _status() { return (this.getAttribute('cluster-status') || 'nominal').toLowerCase(); } + get _load() { return this.getAttribute('load'); } // null → hidden + get _tagline() { return this.getAttribute('tagline'); } // null → default + get _subtitle() { return this.getAttribute('subtitle'); } // null → "COMPUTE" + + get _statusColor() { + return { nominal: '#7ecfb8', degraded: '#d4a84b', offline: '#c0504d' }[this._status] ?? '#7ecfb8'; + } + + get _statusLabel() { + return { nominal: 'NOMINAL', degraded: 'DEGRADED', offline: 'OFFLINE' }[this._status] ?? 'NOMINAL'; + } + + get _loadColor() { + const v = parseInt(this._load, 10); + if (isNaN(v)) return '#7ecfb8'; + if (v >= 85) return '#c0504d'; + if (v >= 60) return '#d4a84b'; + return '#7ecfb8'; + } + + // ───────────────────────────────────────────── + // Render + // ───────────────────────────────────────────── + + _render() { + const compact = this._variant === 'compact'; + this.shadowRoot.innerHTML = ` + <style>${this._css(compact)}</style> + ${this._html(compact)} + `; + } + + // ───────────────────────────────────────────── + // CSS + // ───────────────────────────────────────────── + + _css(compact) { + const height = compact ? '60px' : '100px'; + const padding = compact ? '0 24px' : '0 32px'; + const gap = compact ? '16px' : '24px'; + const markSize = compact ? '34px' : '52px'; + const divH = compact ? '32px' : '48px'; + const nameSize = compact ? '15px' : '22px'; + const tagSize = compact ? '9px' : '11px'; + const sc = this._statusColor; + const lc = this._loadColor; + + return ` + @import url('https://fonts.googleapis.com/css2?family=Noto+Serif+JP:wght@300;400&family=Space+Mono:wght@400;700&display=swap'); + + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + + :host { + display: block; + font-family: 'Space Mono', monospace; + } + + .banner { + width: 100%; + height: ${height}; + background: #0b0d10; + border: 1px solid #1e2330; + border-radius: 6px; + display: flex; + align-items: center; + padding: ${padding}; + gap: ${gap}; + position: relative; + overflow: hidden; + } + + /* scan-line texture */ + .banner::before { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 0deg, + transparent, transparent 3px, + rgba(255,255,255,0.012) 3px, rgba(255,255,255,0.012) 4px + ); + pointer-events: none; + } + + /* ambient glow */ + .banner::after { + content: ''; + position: absolute; + right: -60px; + top: 50%; + transform: translateY(-50%); + width: 280px; + height: 280px; + background: radial-gradient(circle, rgba(130,200,180,0.06) 0%, transparent 70%); + pointer-events: none; + } + + .logo-mark { + flex-shrink: 0; + width: ${markSize}; + height: ${markSize}; + } + + .logo-mark svg { width: 100%; height: 100%; } + + .divider { + width: 1px; + height: ${divH}; + background: linear-gradient(to bottom, transparent, #2a3040, transparent); + flex-shrink: 0; + } + + .text-block { + display: flex; + flex-direction: column; + gap: 4px; + } + + .wordmark { + font-weight: 700; + font-size: ${nameSize}; + letter-spacing: 0.12em; + color: #e8e4dc; + text-transform: uppercase; + line-height: 1; + } + + .wordmark span { color: #7ecfb8; } + + .tagline { + font-family: 'Noto Serif JP', serif; + font-weight: 300; + font-size: ${tagSize}; + letter-spacing: 0.3em; + color: #4a5a68; + text-transform: uppercase; + } + + .spacer { flex: 1; } + + /* ── right-side decorative circuit ── */ + .circuit { flex-shrink: 0; opacity: 0.22; } + + /* ── status cluster ── */ + .status-cluster { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + } + + .status-row { + display: flex; + align-items: center; + gap: 8px; + } + + .status-lbl { + font-size: 9px; + letter-spacing: 0.18em; + color: #3a4555; + text-transform: uppercase; + } + + .pill { + display: flex; + align-items: center; + gap: 5px; + border-radius: 20px; + padding: 2px 10px; + font-size: 10px; + letter-spacing: 0.1em; + } + + .pill.cluster { + color: ${sc}; + background: color-mix(in srgb, ${sc} 8%, transparent); + border: 1px solid color-mix(in srgb, ${sc} 28%, transparent); + } + + .pill.load-pill { + color: ${lc}; + background: color-mix(in srgb, ${lc} 8%, transparent); + border: 1px solid color-mix(in srgb, ${lc} 28%, transparent); + } + + .dot { + width: 5px; + height: 5px; + border-radius: 50%; + animation: pulse 2.4s ease-in-out infinite; + } + + .dot.cluster { background: ${sc}; } + .dot.load-dot { background: ${lc}; animation-delay: 0.5s; } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.25; } + } + `; + } + + // ───────────────────────────────────────────── + // HTML template + // ───────────────────────────────────────────── + + _html(compact) { + const loadAttr = this._load; + const showStatus = !compact; + + const rightSide = showStatus ? ` + <svg class="circuit" width="60" height="60" viewBox="0 0 60 60" fill="none"> + <path d="M5 30 H22 L28 18 H60" stroke="#7ecfb8" stroke-width="0.8"/> + <path d="M5 38 H18 L24 46 H60" stroke="#7ecfb8" stroke-width="0.8"/> + <circle cx="22" cy="30" r="2" fill="none" stroke="#7ecfb8" stroke-width="0.8"/> + <circle cx="18" cy="38" r="2" fill="none" stroke="#7ecfb8" stroke-width="0.8"/> + <circle cx="10" cy="30" r="1.2" fill="#7ecfb8"/> + <circle cx="10" cy="38" r="1.2" fill="#7ecfb8"/> + </svg> + + <div class="status-cluster"> + <div class="status-row"> + <span class="status-lbl">Cluster</span> + <div class="pill cluster"> + <div class="dot cluster"></div> + ${this._statusLabel} + </div> + </div> + ${loadAttr !== null ? ` + <div class="status-row"> + <span class="status-lbl">Load</span> + <div class="pill load-pill"> + <div class="dot load-dot"></div> + ${parseInt(loadAttr, 10)} % + </div> + </div>` : ''} + </div> + ` : ''; + + return ` + <div class="banner"> + <div class="logo-mark">${this._svgMark()}</div> + <div class="divider"></div> + <div class="text-block"> + <div class="wordmark">ZEN<span> ${this._subtitle ?? 'COMPUTE'}</span></div> + <div class="tagline">${this._tagline ?? (compact ? 'Orchestrator' : 'Orchestrator Overview')}</div> + </div> + <div class="spacer"></div> + ${rightSide} + </div> + `; + } + + // ───────────────────────────────────────────── + // SVG logo mark + // ───────────────────────────────────────────── + + _svgMark() { + return ` + <svg viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg"> + <circle cx="26" cy="26" r="22" stroke="#2a3a48" stroke-width="1.5"/> + <path d="M26 4 A22 22 0 1 1 12 43.1" stroke="#7ecfb8" stroke-width="2" stroke-linecap="round" fill="none"/> + <circle cx="17" cy="17" r="1.6" fill="#7ecfb8" /> + <circle cx="26" cy="17" r="1.6" fill="#7ecfb8" /> + <circle cx="35" cy="17" r="1.6" fill="#7ecfb8" /> + <circle cx="17" cy="26" r="1.6" fill="#7ecfb8" opacity="0.6"/> + <circle cx="26" cy="26" r="2.2" fill="#7ecfb8"/> + <circle cx="35" cy="26" r="1.6" fill="#7ecfb8" opacity="0.6"/> + <circle cx="17" cy="35" r="1.6" fill="#7ecfb8"/> + <circle cx="26" cy="35" r="1.6" fill="#7ecfb8"/> + <circle cx="35" cy="35" r="1.6" fill="#7ecfb8"/> + <line x1="17" y1="17" x2="35" y2="17" stroke="#7ecfb8" stroke-width="0.7" stroke-opacity="0.25"/> + <line x1="35" y1="17" x2="17" y2="35" stroke="#7ecfb8" stroke-width="0.7" stroke-opacity="0.25"/> + <line x1="17" y1="35" x2="35" y2="35" stroke="#7ecfb8" stroke-width="0.7" stroke-opacity="0.2"/> + <line x1="26" y1="17" x2="26" y2="35" stroke="#7ecfb8" stroke-width="0.7" stroke-opacity="0.2"/> + </svg> + `; + } +} + +customElements.define('zen-banner', ZenBanner); diff --git a/src/zenserver/frontend/html/compute/compute.html b/src/zenserver/frontend/html/compute/compute.html new file mode 100644 index 000000000..1e101d839 --- /dev/null +++ b/src/zenserver/frontend/html/compute/compute.html @@ -0,0 +1,1072 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Zen Compute Dashboard</title> + <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script> + <script src="banner.js" defer></script> + <script src="nav.js" defer></script> + <style> + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: #0d1117; + color: #c9d1d9; + padding: 20px; + } + + .container { + max-width: 1400px; + margin: 0 auto; + } + + h1 { + font-size: 32px; + font-weight: 600; + margin-bottom: 10px; + color: #f0f6fc; + } + + .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + } + + .health-indicator { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + padding: 8px 16px; + border-radius: 6px; + background: #161b22; + border: 1px solid #30363d; + } + + .health-indicator .status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #6e7681; + } + + .health-indicator.healthy .status-dot { + background: #3fb950; + } + + .health-indicator.unhealthy .status-dot { + background: #f85149; + } + + .grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + margin-bottom: 30px; + } + + .card { + background: #161b22; + border: 1px solid #30363d; + border-radius: 6px; + padding: 20px; + } + + .card-title { + font-size: 14px; + font-weight: 600; + color: #8b949e; + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .metric-value { + font-size: 36px; + font-weight: 600; + color: #f0f6fc; + line-height: 1; + } + + .metric-label { + font-size: 12px; + color: #8b949e; + margin-top: 4px; + } + + .chart-container { + position: relative; + height: 300px; + margin-top: 20px; + } + + .stats-row { + display: flex; + justify-content: space-between; + margin-bottom: 12px; + padding: 8px 0; + border-bottom: 1px solid #21262d; + } + + .stats-row:last-child { + border-bottom: none; + margin-bottom: 0; + } + + .stats-label { + color: #8b949e; + font-size: 13px; + } + + .stats-value { + color: #f0f6fc; + font-weight: 600; + font-size: 13px; + } + + .rate-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + margin-top: 16px; + } + + .rate-item { + text-align: center; + } + + .rate-value { + font-size: 20px; + font-weight: 600; + color: #58a6ff; + } + + .rate-label { + font-size: 11px; + color: #8b949e; + margin-top: 4px; + text-transform: uppercase; + } + + .progress-bar { + width: 100%; + height: 8px; + background: #21262d; + border-radius: 4px; + overflow: hidden; + margin-top: 8px; + } + + .progress-fill { + height: 100%; + background: #58a6ff; + transition: width 0.3s ease; + } + + .timestamp { + font-size: 12px; + color: #6e7681; + text-align: right; + margin-top: 30px; + } + + .error { + color: #f85149; + padding: 12px; + background: #1c1c1c; + border-radius: 6px; + margin: 20px 0; + font-size: 13px; + } + + .section-title { + font-size: 20px; + font-weight: 600; + margin-bottom: 20px; + color: #f0f6fc; + } + + .worker-row { + cursor: pointer; + transition: background 0.15s; + } + + .worker-row:hover { + background: #1c2128; + } + + .worker-row.selected { + background: #1f2d3d; + } + + .worker-detail { + margin-top: 20px; + border-top: 1px solid #30363d; + padding-top: 16px; + } + + .worker-detail-title { + font-size: 15px; + font-weight: 600; + color: #f0f6fc; + margin-bottom: 12px; + } + + .detail-section { + margin-bottom: 16px; + } + + .detail-section-label { + font-size: 11px; + font-weight: 600; + color: #8b949e; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; + } + + .detail-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + } + + .detail-table td { + padding: 4px 8px; + color: #c9d1d9; + border-bottom: 1px solid #21262d; + vertical-align: top; + } + + .detail-table td:first-child { + color: #8b949e; + width: 40%; + font-family: monospace; + } + + .detail-table tr:last-child td { + border-bottom: none; + } + + .detail-mono { + font-family: monospace; + font-size: 11px; + color: #8b949e; + } + + .detail-tag { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + background: #21262d; + color: #c9d1d9; + font-size: 11px; + margin: 2px 4px 2px 0; + } + + .status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + } + + .status-badge.success { + background: rgba(63, 185, 80, 0.15); + color: #3fb950; + } + + .status-badge.failure { + background: rgba(248, 81, 73, 0.15); + color: #f85149; + } + </style> +</head> +<body> + <div class="container"> + <zen-banner cluster-status="nominal" load="0" tagline="Node Overview"></zen-banner> + <zen-nav> + <a href="compute.html">Node</a> + <a href="orchestrator.html">Orchestrator</a> + </zen-nav> + <div class="timestamp">Last updated: <span id="last-update">Never</span></div> + + <div id="error-container"></div> + + <!-- Action Queue Stats --> + <div class="section-title">Action Queue</div> + <div class="grid"> + <div class="card"> + <div class="card-title">Pending Actions</div> + <div class="metric-value" id="actions-pending">-</div> + <div class="metric-label">Waiting to be scheduled</div> + </div> + <div class="card"> + <div class="card-title">Running Actions</div> + <div class="metric-value" id="actions-running">-</div> + <div class="metric-label">Currently executing</div> + </div> + <div class="card"> + <div class="card-title">Completed Actions</div> + <div class="metric-value" id="actions-complete">-</div> + <div class="metric-label">Results available</div> + </div> + </div> + + <!-- Action Queue Chart --> + <div class="card" style="margin-bottom: 30px;"> + <div class="card-title">Action Queue History</div> + <div class="chart-container"> + <canvas id="queue-chart"></canvas> + </div> + </div> + + <!-- Performance Metrics --> + <div class="section-title">Performance Metrics</div> + <div class="card" style="margin-bottom: 30px;"> + <div class="card-title">Completion Rate</div> + <div class="rate-stats"> + <div class="rate-item"> + <div class="rate-value" id="rate-1">-</div> + <div class="rate-label">1 min rate</div> + </div> + <div class="rate-item"> + <div class="rate-value" id="rate-5">-</div> + <div class="rate-label">5 min rate</div> + </div> + <div class="rate-item"> + <div class="rate-value" id="rate-15">-</div> + <div class="rate-label">15 min rate</div> + </div> + </div> + <div style="margin-top: 20px;"> + <div class="stats-row"> + <span class="stats-label">Total Retired</span> + <span class="stats-value" id="retired-count">-</span> + </div> + <div class="stats-row"> + <span class="stats-label">Mean Rate</span> + <span class="stats-value" id="rate-mean">-</span> + </div> + </div> + </div> + + <!-- Workers --> + <div class="section-title">Workers</div> + <div class="card" style="margin-bottom: 30px;"> + <div class="card-title">Worker Status</div> + <div class="stats-row"> + <span class="stats-label">Registered Workers</span> + <span class="stats-value" id="worker-count">-</span> + </div> + <div id="worker-table-container" style="margin-top: 16px; display: none;"> + <table id="worker-table" style="width: 100%; border-collapse: collapse; font-size: 13px;"> + <thead> + <tr> + <th style="text-align: left; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Name</th> + <th style="text-align: left; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Platform</th> + <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Cores</th> + <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Timeout</th> + <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Functions</th> + <th style="text-align: left; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Worker ID</th> + </tr> + </thead> + <tbody id="worker-table-body"></tbody> + </table> + <div id="worker-detail" class="worker-detail" style="display: none;"></div> + </div> + </div> + + <!-- Queues --> + <div class="section-title">Queues</div> + <div class="card" style="margin-bottom: 30px;"> + <div class="card-title">Queue Status</div> + <div id="queue-list-empty" style="color: #6e7681; font-size: 13px;">No queues.</div> + <div id="queue-list-container" style="display: none;"> + <table id="queue-list-table" style="width: 100%; border-collapse: collapse; font-size: 13px;"> + <thead> + <tr> + <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; width: 60px;">ID</th> + <th style="text-align: center; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; width: 80px;">Status</th> + <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Active</th> + <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Completed</th> + <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Failed</th> + <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Abandoned</th> + <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Cancelled</th> + <th style="text-align: left; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Token</th> + </tr> + </thead> + <tbody id="queue-list-body"></tbody> + </table> + </div> + </div> + + <!-- Action History --> + <div class="section-title">Recent Actions</div> + <div class="card" style="margin-bottom: 30px;"> + <div class="card-title">Action History</div> + <div id="action-history-empty" style="color: #6e7681; font-size: 13px;">No actions recorded yet.</div> + <div id="action-history-container" style="display: none;"> + <table id="action-history-table" style="width: 100%; border-collapse: collapse; font-size: 13px;"> + <thead> + <tr> + <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; width: 60px;">LSN</th> + <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; width: 60px;">Queue</th> + <th style="text-align: center; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; width: 70px;">Status</th> + <th style="text-align: left; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Function</th> + <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; width: 80px;">Started</th> + <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; width: 80px;">Finished</th> + <th style="text-align: right; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; width: 80px;">Duration</th> + <th style="text-align: left; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Worker ID</th> + <th style="text-align: left; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #30363d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px;">Action ID</th> + </tr> + </thead> + <tbody id="action-history-body"></tbody> + </table> + </div> + </div> + + <!-- System Resources --> + <div class="section-title">System Resources</div> + <div class="grid"> + <div class="card"> + <div class="card-title">CPU Usage</div> + <div class="metric-value" id="cpu-usage">-</div> + <div class="metric-label">Percent</div> + <div class="progress-bar"> + <div class="progress-fill" id="cpu-progress" style="width: 0%"></div> + </div> + <div style="position: relative; height: 60px; margin-top: 12px;"> + <canvas id="cpu-chart"></canvas> + </div> + <div style="margin-top: 12px;"> + <div class="stats-row"> + <span class="stats-label">Packages</span> + <span class="stats-value" id="cpu-packages">-</span> + </div> + <div class="stats-row"> + <span class="stats-label">Physical Cores</span> + <span class="stats-value" id="cpu-cores">-</span> + </div> + <div class="stats-row"> + <span class="stats-label">Logical Processors</span> + <span class="stats-value" id="cpu-lp">-</span> + </div> + </div> + </div> + <div class="card"> + <div class="card-title">Memory</div> + <div class="stats-row"> + <span class="stats-label">Used</span> + <span class="stats-value" id="memory-used">-</span> + </div> + <div class="stats-row"> + <span class="stats-label">Total</span> + <span class="stats-value" id="memory-total">-</span> + </div> + <div class="progress-bar"> + <div class="progress-fill" id="memory-progress" style="width: 0%"></div> + </div> + </div> + <div class="card"> + <div class="card-title">Disk</div> + <div class="stats-row"> + <span class="stats-label">Used</span> + <span class="stats-value" id="disk-used">-</span> + </div> + <div class="stats-row"> + <span class="stats-label">Total</span> + <span class="stats-value" id="disk-total">-</span> + </div> + <div class="progress-bar"> + <div class="progress-fill" id="disk-progress" style="width: 0%"></div> + </div> + </div> + </div> + </div> + + <script> + // Configuration + const BASE_URL = window.location.origin; + const REFRESH_INTERVAL = 2000; // 2 seconds + const MAX_HISTORY_POINTS = 60; // Show last 2 minutes + + // Data storage + const history = { + timestamps: [], + pending: [], + running: [], + completed: [], + cpu: [] + }; + + // CPU sparkline chart + const cpuCtx = document.getElementById('cpu-chart').getContext('2d'); + const cpuChart = new Chart(cpuCtx, { + type: 'line', + data: { + labels: [], + datasets: [{ + data: [], + borderColor: '#58a6ff', + backgroundColor: 'rgba(88, 166, 255, 0.15)', + borderWidth: 1.5, + tension: 0.4, + fill: true, + pointRadius: 0 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { + x: { display: false }, + y: { display: false, min: 0, max: 100 } + } + } + }); + + // Queue chart setup + const ctx = document.getElementById('queue-chart').getContext('2d'); + const chart = new Chart(ctx, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: 'Pending', + data: [], + borderColor: '#f0883e', + backgroundColor: 'rgba(240, 136, 62, 0.1)', + tension: 0.4, + fill: true + }, + { + label: 'Running', + data: [], + borderColor: '#58a6ff', + backgroundColor: 'rgba(88, 166, 255, 0.1)', + tension: 0.4, + fill: true + }, + { + label: 'Completed', + data: [], + borderColor: '#3fb950', + backgroundColor: 'rgba(63, 185, 80, 0.1)', + tension: 0.4, + fill: true + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + labels: { + color: '#8b949e' + } + } + }, + scales: { + x: { + display: false + }, + y: { + beginAtZero: true, + ticks: { + color: '#8b949e' + }, + grid: { + color: '#21262d' + } + } + } + } + }); + + // Helper functions + function escapeHtml(text) { + var div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + function formatRate(rate) { + return rate.toFixed(2) + '/s'; + } + + function showError(message) { + const container = document.getElementById('error-container'); + container.innerHTML = `<div class="error">Error: ${escapeHtml(message)}</div>`; + } + + function clearError() { + document.getElementById('error-container').innerHTML = ''; + } + + function updateTimestamp() { + const now = new Date(); + document.getElementById('last-update').textContent = now.toLocaleTimeString(); + } + + // Fetch functions + async function fetchJSON(endpoint) { + const response = await fetch(`${BASE_URL}${endpoint}`, { + headers: { + 'Accept': 'application/json' + } + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return await response.json(); + } + + async function fetchHealth() { + try { + const response = await fetch(`${BASE_URL}/compute/ready`); + const isHealthy = response.status === 200; + + const banner = document.querySelector('zen-banner'); + + if (isHealthy) { + banner.setAttribute('cluster-status', 'nominal'); + banner.setAttribute('load', '0'); + } else { + banner.setAttribute('cluster-status', 'degraded'); + banner.setAttribute('load', '0'); + } + + return isHealthy; + } catch (error) { + const banner = document.querySelector('zen-banner'); + banner.setAttribute('cluster-status', 'degraded'); + banner.setAttribute('load', '0'); + throw error; + } + } + + async function fetchStats() { + const data = await fetchJSON('/stats/compute'); + + // Update action counts + document.getElementById('actions-pending').textContent = data.actions_pending || 0; + document.getElementById('actions-running').textContent = data.actions_submitted || 0; + document.getElementById('actions-complete').textContent = data.actions_complete || 0; + + // Update completion rates + if (data.actions_retired) { + document.getElementById('rate-1').textContent = formatRate(data.actions_retired.rate_1 || 0); + document.getElementById('rate-5').textContent = formatRate(data.actions_retired.rate_5 || 0); + document.getElementById('rate-15').textContent = formatRate(data.actions_retired.rate_15 || 0); + document.getElementById('retired-count').textContent = data.actions_retired.count || 0; + document.getElementById('rate-mean').textContent = formatRate(data.actions_retired.rate_mean || 0); + } + + // Update chart + const now = new Date().toLocaleTimeString(); + history.timestamps.push(now); + history.pending.push(data.actions_pending || 0); + history.running.push(data.actions_submitted || 0); + history.completed.push(data.actions_complete || 0); + + // Keep only last N points + if (history.timestamps.length > MAX_HISTORY_POINTS) { + history.timestamps.shift(); + history.pending.shift(); + history.running.shift(); + history.completed.shift(); + } + + chart.data.labels = history.timestamps; + chart.data.datasets[0].data = history.pending; + chart.data.datasets[1].data = history.running; + chart.data.datasets[2].data = history.completed; + chart.update('none'); + } + + async function fetchSysInfo() { + const data = await fetchJSON('/compute/sysinfo'); + + // Update CPU + const cpuUsage = data.cpu_usage || 0; + document.getElementById('cpu-usage').textContent = cpuUsage.toFixed(1) + '%'; + document.getElementById('cpu-progress').style.width = cpuUsage + '%'; + + const banner = document.querySelector('zen-banner'); + banner.setAttribute('load', cpuUsage.toFixed(1)); + + history.cpu.push(cpuUsage); + if (history.cpu.length > MAX_HISTORY_POINTS) history.cpu.shift(); + cpuChart.data.labels = history.cpu.map(() => ''); + cpuChart.data.datasets[0].data = history.cpu; + cpuChart.update('none'); + + document.getElementById('cpu-packages').textContent = data.cpu_count ?? '-'; + document.getElementById('cpu-cores').textContent = data.core_count ?? '-'; + document.getElementById('cpu-lp').textContent = data.lp_count ?? '-'; + + // Update Memory + const memUsed = data.memory_used || 0; + const memTotal = data.memory_total || 1; + const memPercent = (memUsed / memTotal) * 100; + document.getElementById('memory-used').textContent = formatBytes(memUsed); + document.getElementById('memory-total').textContent = formatBytes(memTotal); + document.getElementById('memory-progress').style.width = memPercent + '%'; + + // Update Disk + const diskUsed = data.disk_used || 0; + const diskTotal = data.disk_total || 1; + const diskPercent = (diskUsed / diskTotal) * 100; + document.getElementById('disk-used').textContent = formatBytes(diskUsed); + document.getElementById('disk-total').textContent = formatBytes(diskTotal); + document.getElementById('disk-progress').style.width = diskPercent + '%'; + } + + // Persists the selected worker ID across refreshes + let selectedWorkerId = null; + + function renderWorkerDetail(id, desc) { + const panel = document.getElementById('worker-detail'); + + if (!desc) { + panel.style.display = 'none'; + return; + } + + function field(label, value) { + return `<tr><td>${label}</td><td>${value ?? '-'}</td></tr>`; + } + + function monoField(label, value) { + return `<tr><td>${label}</td><td class="detail-mono">${value ?? '-'}</td></tr>`; + } + + // Functions + const functions = desc.functions || []; + const functionsHtml = functions.length === 0 ? '<span style="color:#6e7681;font-size:12px;">none</span>' : + `<table class="detail-table">${functions.map(f => + `<tr><td>${escapeHtml(f.name || '-')}</td><td class="detail-mono">${escapeHtml(f.version || '-')}</td></tr>` + ).join('')}</table>`; + + // Executables + const executables = desc.executables || []; + const totalExecSize = executables.reduce((sum, e) => sum + (e.size || 0), 0); + const execHtml = executables.length === 0 ? '<span style="color:#6e7681;font-size:12px;">none</span>' : + `<table class="detail-table"> + <tr style="font-size:11px;"> + <td style="color:#6e7681;padding-bottom:4px;">Path</td> + <td style="color:#6e7681;padding-bottom:4px;">Hash</td> + <td style="color:#6e7681;padding-bottom:4px;text-align:right;">Size</td> + </tr> + ${executables.map(e => + `<tr> + <td>${escapeHtml(e.name || '-')}</td> + <td class="detail-mono">${escapeHtml(e.hash || '-')}</td> + <td style="text-align:right;white-space:nowrap;">${e.size != null ? formatBytes(e.size) : '-'}</td> + </tr>` + ).join('')} + <tr style="border-top:1px solid #30363d;"> + <td style="color:#8b949e;padding-top:6px;">Total</td> + <td></td> + <td style="text-align:right;white-space:nowrap;padding-top:6px;color:#f0f6fc;font-weight:600;">${formatBytes(totalExecSize)}</td> + </tr> + </table>`; + + // Files + const files = desc.files || []; + const filesHtml = files.length === 0 ? '<span style="color:#6e7681;font-size:12px;">none</span>' : + `<table class="detail-table">${files.map(f => + `<tr><td>${escapeHtml(f.name || f)}</td><td class="detail-mono">${escapeHtml(f.hash || '')}</td></tr>` + ).join('')}</table>`; + + // Dirs + const dirs = desc.dirs || []; + const dirsHtml = dirs.length === 0 ? '<span style="color:#6e7681;font-size:12px;">none</span>' : + dirs.map(d => `<span class="detail-tag">${escapeHtml(d)}</span>`).join(''); + + // Environment + const env = desc.environment || []; + const envHtml = env.length === 0 ? '<span style="color:#6e7681;font-size:12px;">none</span>' : + env.map(e => `<span class="detail-tag">${escapeHtml(e)}</span>`).join(''); + + panel.innerHTML = ` + <div class="worker-detail-title">${escapeHtml(desc.name || id)}</div> + <div class="detail-section"> + <table class="detail-table"> + ${field('Worker ID', `<span class="detail-mono">${escapeHtml(id)}</span>`)} + ${field('Path', escapeHtml(desc.path || '-'))} + ${field('Platform', escapeHtml(desc.host || '-'))} + ${monoField('Build System', desc.buildsystem_version)} + ${field('Cores', desc.cores)} + ${field('Timeout', desc.timeout != null ? desc.timeout + 's' : null)} + </table> + </div> + <div class="detail-section"> + <div class="detail-section-label">Functions</div> + ${functionsHtml} + </div> + <div class="detail-section"> + <div class="detail-section-label">Executables</div> + ${execHtml} + </div> + <div class="detail-section"> + <div class="detail-section-label">Files</div> + ${filesHtml} + </div> + <div class="detail-section"> + <div class="detail-section-label">Directories</div> + ${dirsHtml} + </div> + <div class="detail-section"> + <div class="detail-section-label">Environment</div> + ${envHtml} + </div> + `; + panel.style.display = 'block'; + } + + async function fetchWorkers() { + const data = await fetchJSON('/compute/workers'); + const workerIds = data.workers || []; + + document.getElementById('worker-count').textContent = workerIds.length; + + const container = document.getElementById('worker-table-container'); + const tbody = document.getElementById('worker-table-body'); + + if (workerIds.length === 0) { + container.style.display = 'none'; + selectedWorkerId = null; + return; + } + + const descriptors = await Promise.all( + workerIds.map(id => fetchJSON(`/compute/workers/${id}`).catch(() => null)) + ); + + // Build a map for quick lookup by ID + const descriptorMap = {}; + workerIds.forEach((id, i) => { descriptorMap[id] = descriptors[i]; }); + + tbody.innerHTML = ''; + descriptors.forEach((desc, i) => { + const id = workerIds[i]; + const name = desc ? (desc.name || '-') : '-'; + const host = desc ? (desc.host || '-') : '-'; + const cores = desc ? (desc.cores != null ? desc.cores : '-') : '-'; + const timeout = desc ? (desc.timeout != null ? desc.timeout + 's' : '-') : '-'; + const functions = desc ? (desc.functions ? desc.functions.length : 0) : '-'; + + const tr = document.createElement('tr'); + tr.className = 'worker-row' + (id === selectedWorkerId ? ' selected' : ''); + tr.dataset.workerId = id; + tr.innerHTML = ` + <td style="padding: 6px 8px; color: #f0f6fc; border-bottom: 1px solid #21262d;">${escapeHtml(name)}</td> + <td style="padding: 6px 8px; color: #c9d1d9; border-bottom: 1px solid #21262d;">${escapeHtml(host)}</td> + <td style="padding: 6px 8px; color: #c9d1d9; border-bottom: 1px solid #21262d; text-align: right;">${escapeHtml(String(cores))}</td> + <td style="padding: 6px 8px; color: #c9d1d9; border-bottom: 1px solid #21262d; text-align: right;">${escapeHtml(String(timeout))}</td> + <td style="padding: 6px 8px; color: #c9d1d9; border-bottom: 1px solid #21262d; text-align: right;">${escapeHtml(String(functions))}</td> + <td style="padding: 6px 8px; color: #8b949e; border-bottom: 1px solid #21262d; font-family: monospace; font-size: 11px;">${escapeHtml(id)}</td> + `; + tr.addEventListener('click', () => { + document.querySelectorAll('.worker-row').forEach(r => r.classList.remove('selected')); + if (selectedWorkerId === id) { + // Toggle off + selectedWorkerId = null; + document.getElementById('worker-detail').style.display = 'none'; + } else { + selectedWorkerId = id; + tr.classList.add('selected'); + renderWorkerDetail(id, descriptorMap[id]); + } + }); + tbody.appendChild(tr); + }); + + // Re-render detail if selected worker is still present + if (selectedWorkerId && descriptorMap[selectedWorkerId]) { + renderWorkerDetail(selectedWorkerId, descriptorMap[selectedWorkerId]); + } else if (selectedWorkerId && !descriptorMap[selectedWorkerId]) { + selectedWorkerId = null; + document.getElementById('worker-detail').style.display = 'none'; + } + + container.style.display = 'block'; + } + + // Windows FILETIME: 100ns ticks since 1601-01-01. Convert to JS Date. + const FILETIME_EPOCH_OFFSET_MS = 11644473600000n; + function filetimeToDate(ticks) { + if (!ticks) return null; + const ms = BigInt(ticks) / 10000n - FILETIME_EPOCH_OFFSET_MS; + return new Date(Number(ms)); + } + + function formatTime(date) { + if (!date) return '-'; + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } + + function formatDuration(startDate, endDate) { + if (!startDate || !endDate) return '-'; + const ms = endDate - startDate; + if (ms < 0) return '-'; + if (ms < 1000) return ms + ' ms'; + if (ms < 60000) return (ms / 1000).toFixed(2) + ' s'; + const m = Math.floor(ms / 60000); + const s = ((ms % 60000) / 1000).toFixed(0).padStart(2, '0'); + return `${m}m ${s}s`; + } + + async function fetchQueues() { + const data = await fetchJSON('/compute/queues'); + const queues = data.queues || []; + + const empty = document.getElementById('queue-list-empty'); + const container = document.getElementById('queue-list-container'); + const tbody = document.getElementById('queue-list-body'); + + if (queues.length === 0) { + empty.style.display = ''; + container.style.display = 'none'; + return; + } + + empty.style.display = 'none'; + tbody.innerHTML = ''; + + for (const q of queues) { + const id = q.queue_id ?? '-'; + const badge = q.state === 'cancelled' + ? '<span class="status-badge failure">cancelled</span>' + : q.state === 'draining' + ? '<span class="status-badge" style="background:rgba(210,153,34,0.15);color:#d29922;">draining</span>' + : q.is_complete + ? '<span class="status-badge success">complete</span>' + : '<span class="status-badge" style="background:rgba(88,166,255,0.15);color:#58a6ff;">active</span>'; + const token = q.queue_token + ? `<span class="detail-mono">${escapeHtml(q.queue_token)}</span>` + : '<span style="color:#6e7681;">-</span>'; + + const tr = document.createElement('tr'); + tr.innerHTML = ` + <td style="padding: 6px 8px; color: #f0f6fc; border-bottom: 1px solid #21262d; text-align: right; font-family: monospace;">${escapeHtml(String(id))}</td> + <td style="padding: 6px 8px; border-bottom: 1px solid #21262d; text-align: center;">${badge}</td> + <td style="padding: 6px 8px; color: #c9d1d9; border-bottom: 1px solid #21262d; text-align: right;">${q.active_count ?? 0}</td> + <td style="padding: 6px 8px; color: #3fb950; border-bottom: 1px solid #21262d; text-align: right;">${q.completed_count ?? 0}</td> + <td style="padding: 6px 8px; color: #f85149; border-bottom: 1px solid #21262d; text-align: right;">${q.failed_count ?? 0}</td> + <td style="padding: 6px 8px; color: #d29922; border-bottom: 1px solid #21262d; text-align: right;">${q.abandoned_count ?? 0}</td> + <td style="padding: 6px 8px; color: #f0883e; border-bottom: 1px solid #21262d; text-align: right;">${q.cancelled_count ?? 0}</td> + <td style="padding: 6px 8px; border-bottom: 1px solid #21262d;">${token}</td> + `; + tbody.appendChild(tr); + } + + container.style.display = 'block'; + } + + async function fetchActionHistory() { + const data = await fetchJSON('/compute/jobs/history?limit=50'); + const entries = data.history || []; + + const empty = document.getElementById('action-history-empty'); + const container = document.getElementById('action-history-container'); + const tbody = document.getElementById('action-history-body'); + + if (entries.length === 0) { + empty.style.display = ''; + container.style.display = 'none'; + return; + } + + empty.style.display = 'none'; + tbody.innerHTML = ''; + + // Entries arrive oldest-first; reverse to show newest at top + for (const entry of [...entries].reverse()) { + const lsn = entry.lsn ?? '-'; + const succeeded = entry.succeeded; + const badge = succeeded == null + ? '<span class="status-badge" style="background:#21262d;color:#8b949e;">unknown</span>' + : succeeded + ? '<span class="status-badge success">ok</span>' + : '<span class="status-badge failure">failed</span>'; + const desc = entry.actionDescriptor || {}; + const fn = desc.Function || '-'; + const workerId = entry.workerId || '-'; + const actionId = entry.actionId || '-'; + + const startDate = filetimeToDate(entry.time_Running); + const endDate = filetimeToDate(entry.time_Completed ?? entry.time_Failed); + + const queueId = entry.queueId || 0; + const queueCell = queueId + ? `<a href="/compute/queues/${queueId}" style="color: #58a6ff; text-decoration: none; font-family: monospace;">${escapeHtml(String(queueId))}</a>` + : '<span style="color: #6e7681;">-</span>'; + + const tr = document.createElement('tr'); + tr.innerHTML = ` + <td style="padding: 6px 8px; color: #8b949e; border-bottom: 1px solid #21262d; text-align: right; font-family: monospace;">${escapeHtml(String(lsn))}</td> + <td style="padding: 6px 8px; border-bottom: 1px solid #21262d; text-align: right;">${queueCell}</td> + <td style="padding: 6px 8px; border-bottom: 1px solid #21262d; text-align: center;">${badge}</td> + <td style="padding: 6px 8px; color: #f0f6fc; border-bottom: 1px solid #21262d;">${escapeHtml(fn)}</td> + <td style="padding: 6px 8px; color: #8b949e; border-bottom: 1px solid #21262d; text-align: right; font-size: 12px; white-space: nowrap;">${formatTime(startDate)}</td> + <td style="padding: 6px 8px; color: #8b949e; border-bottom: 1px solid #21262d; text-align: right; font-size: 12px; white-space: nowrap;">${formatTime(endDate)}</td> + <td style="padding: 6px 8px; color: #c9d1d9; border-bottom: 1px solid #21262d; text-align: right; font-size: 12px; white-space: nowrap;">${formatDuration(startDate, endDate)}</td> + <td style="padding: 6px 8px; color: #8b949e; border-bottom: 1px solid #21262d; font-family: monospace; font-size: 11px;">${escapeHtml(workerId)}</td> + <td style="padding: 6px 8px; color: #8b949e; border-bottom: 1px solid #21262d; font-family: monospace; font-size: 11px;">${escapeHtml(actionId)}</td> + `; + tbody.appendChild(tr); + } + + container.style.display = 'block'; + } + + async function updateDashboard() { + try { + await Promise.all([ + fetchHealth(), + fetchStats(), + fetchSysInfo(), + fetchWorkers(), + fetchQueues(), + fetchActionHistory() + ]); + + clearError(); + updateTimestamp(); + } catch (error) { + console.error('Error updating dashboard:', error); + showError(error.message); + } + } + + // Start updating + updateDashboard(); + setInterval(updateDashboard, REFRESH_INTERVAL); + </script> +</body> +</html> diff --git a/src/zenserver/frontend/html/compute/hub.html b/src/zenserver/frontend/html/compute/hub.html new file mode 100644 index 000000000..f66ba94d5 --- /dev/null +++ b/src/zenserver/frontend/html/compute/hub.html @@ -0,0 +1,310 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <script src="banner.js" defer></script> + <script src="nav.js" defer></script> + <title>Zen Hub Dashboard</title> + <style> + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: #0d1117; + color: #c9d1d9; + padding: 20px; + } + + .container { + max-width: 1400px; + margin: 0 auto; + } + + .timestamp { + font-size: 12px; + color: #6e7681; + } + + .grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 20px; + margin-bottom: 30px; + } + + .card { + background: #161b22; + border: 1px solid #30363d; + border-radius: 6px; + padding: 20px; + } + + .card-title { + font-size: 14px; + font-weight: 600; + color: #8b949e; + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .metric-value { + font-size: 36px; + font-weight: 600; + color: #f0f6fc; + line-height: 1; + } + + .metric-label { + font-size: 12px; + color: #8b949e; + margin-top: 4px; + } + + .progress-bar { + width: 100%; + height: 8px; + background: #21262d; + border-radius: 4px; + overflow: hidden; + margin-top: 8px; + } + + .progress-fill { + height: 100%; + background: #58a6ff; + transition: width 0.3s ease; + } + + .error { + color: #f85149; + padding: 12px; + background: #1c1c1c; + border-radius: 6px; + margin: 20px 0; + font-size: 13px; + } + + .section-title { + font-size: 20px; + font-weight: 600; + margin-bottom: 20px; + color: #f0f6fc; + } + + table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + } + + th { + text-align: left; + color: #8b949e; + padding: 8px 12px; + border-bottom: 1px solid #30363d; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 11px; + } + + td { + padding: 8px 12px; + border-bottom: 1px solid #21262d; + color: #c9d1d9; + } + + tr:last-child td { + border-bottom: none; + } + + .status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + } + + .status-badge.active { + background: rgba(63, 185, 80, 0.15); + color: #3fb950; + } + + .status-badge.inactive { + background: rgba(139, 148, 158, 0.15); + color: #8b949e; + } + + .empty-state { + color: #6e7681; + font-size: 13px; + padding: 20px 0; + text-align: center; + } + </style> +</head> +<body> + <div class="container"> + <zen-banner cluster-status="nominal" subtitle="HUB" tagline="Overview"></zen-banner> + <zen-nav> + <a href="hub.html">Hub</a> + </zen-nav> + <div class="timestamp">Last updated: <span id="last-update">Never</span></div> + + <div id="error-container"></div> + + <div class="section-title">Capacity</div> + <div class="grid"> + <div class="card"> + <div class="card-title">Active Modules</div> + <div class="metric-value" id="instance-count">-</div> + <div class="metric-label">Currently provisioned</div> + </div> + <div class="card"> + <div class="card-title">Peak Modules</div> + <div class="metric-value" id="max-instance-count">-</div> + <div class="metric-label">High watermark</div> + </div> + <div class="card"> + <div class="card-title">Instance Limit</div> + <div class="metric-value" id="instance-limit">-</div> + <div class="metric-label">Maximum allowed</div> + <div class="progress-bar"> + <div class="progress-fill" id="capacity-progress" style="width: 0%"></div> + </div> + </div> + </div> + + <div class="section-title">Modules</div> + <div class="card"> + <div class="card-title">Storage Server Instances</div> + <div id="empty-state" class="empty-state">No modules provisioned.</div> + <table id="module-table" style="display: none;"> + <thead> + <tr> + <th>Module ID</th> + <th style="text-align: center;">Status</th> + </tr> + </thead> + <tbody id="module-table-body"></tbody> + </table> + </div> + </div> + + <script> + const BASE_URL = window.location.origin; + const REFRESH_INTERVAL = 2000; + + function escapeHtml(text) { + var div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function showError(message) { + document.getElementById('error-container').innerHTML = + '<div class="error">Error: ' + escapeHtml(message) + '</div>'; + } + + function clearError() { + document.getElementById('error-container').innerHTML = ''; + } + + async function fetchJSON(endpoint) { + var response = await fetch(BASE_URL + endpoint, { + headers: { 'Accept': 'application/json' } + }); + if (!response.ok) { + throw new Error('HTTP ' + response.status + ': ' + response.statusText); + } + return await response.json(); + } + + async function fetchStats() { + var data = await fetchJSON('/hub/stats'); + + var current = data.currentInstanceCount || 0; + var max = data.maxInstanceCount || 0; + var limit = data.instanceLimit || 0; + + document.getElementById('instance-count').textContent = current; + document.getElementById('max-instance-count').textContent = max; + document.getElementById('instance-limit').textContent = limit; + + var pct = limit > 0 ? (current / limit) * 100 : 0; + document.getElementById('capacity-progress').style.width = pct + '%'; + + var banner = document.querySelector('zen-banner'); + if (current === 0) { + banner.setAttribute('cluster-status', 'nominal'); + } else if (limit > 0 && current >= limit * 0.9) { + banner.setAttribute('cluster-status', 'degraded'); + } else { + banner.setAttribute('cluster-status', 'nominal'); + } + } + + async function fetchModules() { + var data = await fetchJSON('/hub/status'); + var modules = data.modules || []; + + var emptyState = document.getElementById('empty-state'); + var table = document.getElementById('module-table'); + var tbody = document.getElementById('module-table-body'); + + if (modules.length === 0) { + emptyState.style.display = ''; + table.style.display = 'none'; + return; + } + + emptyState.style.display = 'none'; + table.style.display = ''; + + tbody.innerHTML = ''; + for (var i = 0; i < modules.length; i++) { + var m = modules[i]; + var moduleId = m.moduleId || ''; + var provisioned = m.provisioned; + + var badge = provisioned + ? '<span class="status-badge active">Provisioned</span>' + : '<span class="status-badge inactive">Inactive</span>'; + + var tr = document.createElement('tr'); + tr.innerHTML = + '<td style="font-family: monospace; font-size: 12px;">' + escapeHtml(moduleId) + '</td>' + + '<td style="text-align: center;">' + badge + '</td>'; + tbody.appendChild(tr); + } + } + + async function updateDashboard() { + var banner = document.querySelector('zen-banner'); + try { + await Promise.all([ + fetchStats(), + fetchModules() + ]); + + clearError(); + document.getElementById('last-update').textContent = new Date().toLocaleTimeString(); + } catch (error) { + console.error('Error updating dashboard:', error); + showError(error.message); + banner.setAttribute('cluster-status', 'offline'); + } + } + + updateDashboard(); + setInterval(updateDashboard, REFRESH_INTERVAL); + </script> +</body> +</html> diff --git a/src/zenserver/frontend/html/compute/index.html b/src/zenserver/frontend/html/compute/index.html new file mode 100644 index 000000000..9597fd7f3 --- /dev/null +++ b/src/zenserver/frontend/html/compute/index.html @@ -0,0 +1 @@ +<meta http-equiv="refresh" content="0; url=compute.html" />
\ No newline at end of file diff --git a/src/zenserver/frontend/html/compute/nav.js b/src/zenserver/frontend/html/compute/nav.js new file mode 100644 index 000000000..8ec42abd0 --- /dev/null +++ b/src/zenserver/frontend/html/compute/nav.js @@ -0,0 +1,79 @@ +/** + * zen-nav.js — Zen dashboard navigation bar Web Component + * + * Usage: + * <script src="nav.js" defer></script> + * + * <zen-nav> + * <a href="compute.html">Node</a> + * <a href="orchestrator.html">Orchestrator</a> + * </zen-nav> + * + * Each child <a> becomes a nav link. The current page is + * highlighted automatically based on the href. + */ + +class ZenNav extends HTMLElement { + + connectedCallback() { + if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); + this._render(); + } + + _render() { + const currentPath = window.location.pathname; + const items = Array.from(this.querySelectorAll(':scope > a')); + + const links = items.map(a => { + const href = a.getAttribute('href') || ''; + const label = a.textContent.trim(); + const active = currentPath.endsWith(href); + return `<a class="nav-link${active ? ' active' : ''}" href="${href}">${label}</a>`; + }).join(''); + + this.shadowRoot.innerHTML = ` + <style> + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + + :host { + display: block; + margin-bottom: 16px; + } + + .nav-bar { + display: flex; + align-items: center; + gap: 4px; + padding: 4px; + background: #161b22; + border: 1px solid #30363d; + border-radius: 6px; + } + + .nav-link { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + font-size: 13px; + font-weight: 500; + color: #8b949e; + text-decoration: none; + padding: 6px 14px; + border-radius: 4px; + transition: color 0.15s, background 0.15s; + } + + .nav-link:hover { + color: #c9d1d9; + background: #21262d; + } + + .nav-link.active { + color: #f0f6fc; + background: #30363d; + } + </style> + <nav class="nav-bar">${links}</nav> + `; + } +} + +customElements.define('zen-nav', ZenNav); diff --git a/src/zenserver/frontend/html/compute/orchestrator.html b/src/zenserver/frontend/html/compute/orchestrator.html new file mode 100644 index 000000000..2ee57b6b3 --- /dev/null +++ b/src/zenserver/frontend/html/compute/orchestrator.html @@ -0,0 +1,831 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <script src="banner.js" defer></script> + <script src="nav.js" defer></script> + <title>Zen Orchestrator Dashboard</title> + <style> + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: #0d1117; + color: #c9d1d9; + padding: 20px; + } + + .container { + max-width: 1400px; + margin: 0 auto; + } + + h1 { + font-size: 32px; + font-weight: 600; + margin-bottom: 10px; + color: #f0f6fc; + } + + .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + } + + .timestamp { + font-size: 12px; + color: #6e7681; + } + + .agent-count { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + padding: 8px 16px; + border-radius: 6px; + background: #161b22; + border: 1px solid #30363d; + } + + .agent-count .count { + font-size: 20px; + font-weight: 600; + color: #f0f6fc; + } + + .card { + background: #161b22; + border: 1px solid #30363d; + border-radius: 6px; + padding: 20px; + } + + .card-title { + font-size: 14px; + font-weight: 600; + color: #8b949e; + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .error { + color: #f85149; + padding: 12px; + background: #1c1c1c; + border-radius: 6px; + margin: 20px 0; + font-size: 13px; + } + + table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + } + + th { + text-align: left; + color: #8b949e; + padding: 8px 12px; + border-bottom: 1px solid #30363d; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 11px; + } + + td { + padding: 8px 12px; + border-bottom: 1px solid #21262d; + color: #c9d1d9; + } + + tr:last-child td { + border-bottom: none; + } + + .total-row td { + border-top: 2px solid #30363d; + font-weight: 600; + color: #f0f6fc; + } + + .health-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + } + + .health-green { + background: #3fb950; + } + + .health-yellow { + background: #d29922; + } + + .health-red { + background: #f85149; + } + + a { + color: #58a6ff; + text-decoration: none; + } + + a:hover { + text-decoration: underline; + } + + .empty-state { + color: #6e7681; + font-size: 13px; + padding: 20px 0; + text-align: center; + } + + .history-tabs { + display: flex; + gap: 4px; + background: #0d1117; + border-radius: 6px; + padding: 2px; + } + + .history-tab { + background: transparent; + border: none; + color: #8b949e; + font-size: 12px; + font-weight: 600; + padding: 4px 12px; + border-radius: 4px; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .history-tab:hover { + color: #c9d1d9; + } + + .history-tab.active { + background: #30363d; + color: #f0f6fc; + } + </style> +</head> +<body> + <div class="container"> + <zen-banner cluster-status="nominal" load="0"></zen-banner> + <zen-nav> + <a href="compute.html">Node</a> + <a href="orchestrator.html">Orchestrator</a> + </zen-nav> + <div class="header"> + <div> + <div class="timestamp">Last updated: <span id="last-update">Never</span></div> + </div> + <div class="agent-count"> + <span>Agents:</span> + <span class="count" id="agent-count">-</span> + </div> + </div> + + <div id="error-container"></div> + + <div class="card"> + <div class="card-title">Compute Agents</div> + <div id="empty-state" class="empty-state">No agents registered.</div> + <table id="agent-table" style="display: none;"> + <thead> + <tr> + <th style="width: 40px; text-align: center;">Health</th> + <th>Hostname</th> + <th style="text-align: right;">CPUs</th> + <th style="text-align: right;">CPU Usage</th> + <th style="text-align: right;">Memory</th> + <th style="text-align: right;">Queues</th> + <th style="text-align: right;">Pending</th> + <th style="text-align: right;">Running</th> + <th style="text-align: right;">Completed</th> + <th style="text-align: right;">Traffic</th> + <th style="text-align: right;">Last Seen</th> + </tr> + </thead> + <tbody id="agent-table-body"></tbody> + </table> + </div> + <div class="card" style="margin-top: 20px;"> + <div class="card-title">Connected Clients</div> + <div id="clients-empty" class="empty-state">No clients connected.</div> + <table id="clients-table" style="display: none;"> + <thead> + <tr> + <th style="width: 40px; text-align: center;">Health</th> + <th>Client ID</th> + <th>Hostname</th> + <th>Address</th> + <th style="text-align: right;">Last Seen</th> + </tr> + </thead> + <tbody id="clients-table-body"></tbody> + </table> + </div> + <div class="card" style="margin-top: 20px;"> + <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;"> + <div class="card-title" style="margin-bottom: 0;">Event History</div> + <div class="history-tabs"> + <button class="history-tab active" data-tab="workers" onclick="switchHistoryTab('workers')">Workers</button> + <button class="history-tab" data-tab="clients" onclick="switchHistoryTab('clients')">Clients</button> + </div> + </div> + <div id="history-panel-workers"> + <div id="history-empty" class="empty-state">No provisioning events recorded.</div> + <table id="history-table" style="display: none;"> + <thead> + <tr> + <th>Time</th> + <th>Event</th> + <th>Worker</th> + <th>Hostname</th> + </tr> + </thead> + <tbody id="history-table-body"></tbody> + </table> + </div> + <div id="history-panel-clients" style="display: none;"> + <div id="client-history-empty" class="empty-state">No client events recorded.</div> + <table id="client-history-table" style="display: none;"> + <thead> + <tr> + <th>Time</th> + <th>Event</th> + <th>Client</th> + <th>Hostname</th> + </tr> + </thead> + <tbody id="client-history-table-body"></tbody> + </table> + </div> + </div> + </div> + + <script> + const BASE_URL = window.location.origin; + const REFRESH_INTERVAL = 2000; + + function escapeHtml(text) { + var div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function showError(message) { + document.getElementById('error-container').innerHTML = + '<div class="error">Error: ' + escapeHtml(message) + '</div>'; + } + + function clearError() { + document.getElementById('error-container').innerHTML = ''; + } + + function formatLastSeen(dtMs) { + if (dtMs == null) return '-'; + var seconds = Math.floor(dtMs / 1000); + if (seconds < 60) return seconds + 's ago'; + var minutes = Math.floor(seconds / 60); + if (minutes < 60) return minutes + 'm ' + (seconds % 60) + 's ago'; + var hours = Math.floor(minutes / 60); + return hours + 'h ' + (minutes % 60) + 'm ago'; + } + + function healthClass(dtMs, reachable) { + if (reachable === false) return 'health-red'; + if (dtMs == null) return 'health-red'; + var seconds = dtMs / 1000; + if (seconds < 30 && reachable === true) return 'health-green'; + if (seconds < 120) return 'health-yellow'; + return 'health-red'; + } + + function healthTitle(dtMs, reachable) { + var seenStr = dtMs != null ? 'Last seen ' + formatLastSeen(dtMs) : 'Never seen'; + if (reachable === true) return seenStr + ' · Reachable'; + if (reachable === false) return seenStr + ' · Unreachable'; + return seenStr + ' · Reachability unknown'; + } + + function formatCpuUsage(percent) { + if (percent == null || percent === 0) return '-'; + return percent.toFixed(1) + '%'; + } + + function formatMemory(usedBytes, totalBytes) { + if (!totalBytes) return '-'; + var usedGiB = usedBytes / (1024 * 1024 * 1024); + var totalGiB = totalBytes / (1024 * 1024 * 1024); + return usedGiB.toFixed(1) + ' / ' + totalGiB.toFixed(1) + ' GiB'; + } + + function formatBytes(bytes) { + if (!bytes) return '-'; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KiB'; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MiB'; + if (bytes < 1024 * 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GiB'; + return (bytes / (1024 * 1024 * 1024 * 1024)).toFixed(1) + ' TiB'; + } + + function formatTraffic(recv, sent) { + if (!recv && !sent) return '-'; + return formatBytes(recv) + ' / ' + formatBytes(sent); + } + + function parseIpFromUri(uri) { + try { + var url = new URL(uri); + var host = url.hostname; + // Strip IPv6 brackets + if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1); + // Only handle IPv4 + var parts = host.split('.'); + if (parts.length !== 4) return null; + var octets = parts.map(Number); + if (octets.some(function(o) { return isNaN(o) || o < 0 || o > 255; })) return null; + return octets; + } catch (e) { + return null; + } + } + + function computeCidr(ips) { + if (ips.length === 0) return null; + if (ips.length === 1) return ips[0].join('.') + '/32'; + + // Convert each IP to a 32-bit integer + var ints = ips.map(function(o) { + return ((o[0] << 24) | (o[1] << 16) | (o[2] << 8) | o[3]) >>> 0; + }); + + // Find common prefix length by ANDing all identical high bits + var common = ~0 >>> 0; + for (var i = 1; i < ints.length; i++) { + // XOR to find differing bits, then mask away everything from the first difference down + var diff = (ints[0] ^ ints[i]) >>> 0; + if (diff !== 0) { + var bit = 31 - Math.floor(Math.log2(diff)); + var mask = bit > 0 ? ((~0 << (32 - bit)) >>> 0) : 0; + common = (common & mask) >>> 0; + } + } + + // Count leading ones in the common mask + var prefix = 0; + for (var b = 31; b >= 0; b--) { + if ((common >>> b) & 1) prefix++; + else break; + } + + // Network address + var net = (ints[0] & common) >>> 0; + var a = (net >>> 24) & 0xff; + var bv = (net >>> 16) & 0xff; + var c = (net >>> 8) & 0xff; + var d = net & 0xff; + return a + '.' + bv + '.' + c + '.' + d + '/' + prefix; + } + + function renderDashboard(data) { + var banner = document.querySelector('zen-banner'); + if (data.hostname) { + banner.setAttribute('tagline', 'Orchestrator \u2014 ' + data.hostname); + } + var workers = data.workers || []; + + document.getElementById('agent-count').textContent = workers.length; + + if (workers.length === 0) { + banner.setAttribute('cluster-status', 'degraded'); + banner.setAttribute('load', '0'); + } else { + banner.setAttribute('cluster-status', 'nominal'); + } + + var emptyState = document.getElementById('empty-state'); + var table = document.getElementById('agent-table'); + var tbody = document.getElementById('agent-table-body'); + + if (workers.length === 0) { + emptyState.style.display = ''; + table.style.display = 'none'; + } else { + emptyState.style.display = 'none'; + table.style.display = ''; + + tbody.innerHTML = ''; + var totalCpus = 0; + var totalWeightedCpuUsage = 0; + var totalMemUsed = 0; + var totalMemTotal = 0; + var totalQueues = 0; + var totalPending = 0; + var totalRunning = 0; + var totalCompleted = 0; + var totalBytesRecv = 0; + var totalBytesSent = 0; + var allIps = []; + for (var i = 0; i < workers.length; i++) { + var w = workers[i]; + var uri = w.uri || ''; + var dt = w.dt; + var dashboardUrl = uri + '/dashboard/compute/'; + + var id = w.id || ''; + + var hostname = w.hostname || ''; + var cpus = w.cpus || 0; + totalCpus += cpus; + if (cpus > 0 && typeof w.cpu_usage === 'number') { + totalWeightedCpuUsage += w.cpu_usage * cpus; + } + + var memTotal = w.memory_total || 0; + var memUsed = w.memory_used || 0; + totalMemTotal += memTotal; + totalMemUsed += memUsed; + + var activeQueues = w.active_queues || 0; + totalQueues += activeQueues; + + var actionsPending = w.actions_pending || 0; + var actionsRunning = w.actions_running || 0; + var actionsCompleted = w.actions_completed || 0; + totalPending += actionsPending; + totalRunning += actionsRunning; + totalCompleted += actionsCompleted; + + var bytesRecv = w.bytes_received || 0; + var bytesSent = w.bytes_sent || 0; + totalBytesRecv += bytesRecv; + totalBytesSent += bytesSent; + + var ip = parseIpFromUri(uri); + if (ip) allIps.push(ip); + + var reachable = w.reachable; + var hClass = healthClass(dt, reachable); + var hTitle = healthTitle(dt, reachable); + + var platform = w.platform || ''; + var badges = ''; + if (platform) { + var platColors = { windows: '#0078d4', wine: '#722f37', linux: '#e95420', macos: '#a2aaad' }; + var platColor = platColors[platform] || '#8b949e'; + badges += ' <span style="display:inline-block;padding:1px 6px;border-radius:10px;font-size:10px;font-weight:600;color:#fff;background:' + platColor + ';vertical-align:middle;margin-left:4px;">' + escapeHtml(platform) + '</span>'; + } + var provisioner = w.provisioner || ''; + if (provisioner) { + var provColors = { horde: '#8957e5', nomad: '#3fb950' }; + var provColor = provColors[provisioner] || '#8b949e'; + badges += ' <span style="display:inline-block;padding:1px 6px;border-radius:10px;font-size:10px;font-weight:600;color:#fff;background:' + provColor + ';vertical-align:middle;margin-left:4px;">' + escapeHtml(provisioner) + '</span>'; + } + + var tr = document.createElement('tr'); + tr.title = id; + tr.innerHTML = + '<td style="text-align: center;"><span class="health-dot ' + hClass + '" title="' + escapeHtml(hTitle) + '"></span></td>' + + '<td><a href="' + escapeHtml(dashboardUrl) + '" target="_blank">' + escapeHtml(hostname) + '</a>' + badges + '</td>' + + '<td style="text-align: right;">' + (cpus > 0 ? cpus : '-') + '</td>' + + '<td style="text-align: right;">' + formatCpuUsage(w.cpu_usage) + '</td>' + + '<td style="text-align: right;">' + formatMemory(memUsed, memTotal) + '</td>' + + '<td style="text-align: right;">' + (activeQueues > 0 ? activeQueues : '-') + '</td>' + + '<td style="text-align: right;">' + actionsPending + '</td>' + + '<td style="text-align: right;">' + actionsRunning + '</td>' + + '<td style="text-align: right;">' + actionsCompleted + '</td>' + + '<td style="text-align: right; color: #8b949e; font-size: 11px;">' + formatTraffic(bytesRecv, bytesSent) + '</td>' + + '<td style="text-align: right; color: #8b949e;">' + formatLastSeen(dt) + '</td>'; + tbody.appendChild(tr); + } + + var clusterLoad = totalCpus > 0 ? (totalWeightedCpuUsage / totalCpus) : 0; + banner.setAttribute('load', clusterLoad.toFixed(1)); + + // Total row + var cidr = computeCidr(allIps); + var totalTr = document.createElement('tr'); + totalTr.className = 'total-row'; + totalTr.innerHTML = + '<td></td>' + + '<td style="text-align: right; color: #8b949e; text-transform: uppercase; font-size: 11px;">Total' + (cidr ? ' <span style="font-family: monospace; font-weight: normal;">' + escapeHtml(cidr) + '</span>' : '') + '</td>' + + '<td style="text-align: right;">' + totalCpus + '</td>' + + '<td></td>' + + '<td style="text-align: right;">' + formatMemory(totalMemUsed, totalMemTotal) + '</td>' + + '<td style="text-align: right;">' + totalQueues + '</td>' + + '<td style="text-align: right;">' + totalPending + '</td>' + + '<td style="text-align: right;">' + totalRunning + '</td>' + + '<td style="text-align: right;">' + totalCompleted + '</td>' + + '<td style="text-align: right; font-size: 11px;">' + formatTraffic(totalBytesRecv, totalBytesSent) + '</td>' + + '<td></td>'; + tbody.appendChild(totalTr); + } + + clearError(); + document.getElementById('last-update').textContent = new Date().toLocaleTimeString(); + + // Render provisioning history if present in WebSocket payload + if (data.events) { + renderProvisioningHistory(data.events); + } + + // Render connected clients if present + if (data.clients) { + renderClients(data.clients); + } + + // Render client history if present + if (data.client_events) { + renderClientHistory(data.client_events); + } + } + + function eventBadge(type) { + var colors = { joined: '#3fb950', left: '#f85149', returned: '#d29922' }; + var labels = { joined: 'Joined', left: 'Left', returned: 'Returned' }; + var color = colors[type] || '#8b949e'; + var label = labels[type] || type; + return '<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;color:#0d1117;background:' + color + ';">' + escapeHtml(label) + '</span>'; + } + + function formatTimestamp(ts) { + if (!ts) return '-'; + // CbObject DateTime serialized as ticks (100ns since 0001-01-01) or ISO string + var date; + if (typeof ts === 'number') { + // .NET-style ticks: convert to Unix ms + var unixMs = (ts - 621355968000000000) / 10000; + date = new Date(unixMs); + } else { + date = new Date(ts); + } + if (isNaN(date.getTime())) return '-'; + return date.toLocaleTimeString(); + } + + var activeHistoryTab = 'workers'; + + function switchHistoryTab(tab) { + activeHistoryTab = tab; + var tabs = document.querySelectorAll('.history-tab'); + for (var i = 0; i < tabs.length; i++) { + tabs[i].classList.toggle('active', tabs[i].getAttribute('data-tab') === tab); + } + document.getElementById('history-panel-workers').style.display = tab === 'workers' ? '' : 'none'; + document.getElementById('history-panel-clients').style.display = tab === 'clients' ? '' : 'none'; + } + + function renderProvisioningHistory(events) { + var emptyState = document.getElementById('history-empty'); + var table = document.getElementById('history-table'); + var tbody = document.getElementById('history-table-body'); + + if (!events || events.length === 0) { + emptyState.style.display = ''; + table.style.display = 'none'; + return; + } + + emptyState.style.display = 'none'; + table.style.display = ''; + tbody.innerHTML = ''; + + for (var i = 0; i < events.length; i++) { + var evt = events[i]; + var tr = document.createElement('tr'); + tr.innerHTML = + '<td style="color: #8b949e;">' + formatTimestamp(evt.ts) + '</td>' + + '<td>' + eventBadge(evt.type) + '</td>' + + '<td>' + escapeHtml(evt.worker_id || '') + '</td>' + + '<td>' + escapeHtml(evt.hostname || '') + '</td>'; + tbody.appendChild(tr); + } + } + + function clientHealthClass(dtMs) { + if (dtMs == null) return 'health-red'; + var seconds = dtMs / 1000; + if (seconds < 30) return 'health-green'; + if (seconds < 120) return 'health-yellow'; + return 'health-red'; + } + + function renderClients(clients) { + var emptyState = document.getElementById('clients-empty'); + var table = document.getElementById('clients-table'); + var tbody = document.getElementById('clients-table-body'); + + if (!clients || clients.length === 0) { + emptyState.style.display = ''; + table.style.display = 'none'; + return; + } + + emptyState.style.display = 'none'; + table.style.display = ''; + tbody.innerHTML = ''; + + for (var i = 0; i < clients.length; i++) { + var c = clients[i]; + var dt = c.dt; + var hClass = clientHealthClass(dt); + var hTitle = dt != null ? 'Last seen ' + formatLastSeen(dt) : 'Never seen'; + + var sessionBadge = ''; + if (c.session_id) { + sessionBadge = ' <span style="font-family:monospace;font-size:10px;color:#6e7681;" title="Session ' + escapeHtml(c.session_id) + '">' + escapeHtml(c.session_id.substring(0, 8)) + '</span>'; + } + + var tr = document.createElement('tr'); + tr.innerHTML = + '<td style="text-align: center;"><span class="health-dot ' + hClass + '" title="' + escapeHtml(hTitle) + '"></span></td>' + + '<td>' + escapeHtml(c.id || '') + sessionBadge + '</td>' + + '<td>' + escapeHtml(c.hostname || '') + '</td>' + + '<td style="font-family: monospace; font-size: 12px; color: #8b949e;">' + escapeHtml(c.address || '') + '</td>' + + '<td style="text-align: right; color: #8b949e;">' + formatLastSeen(dt) + '</td>'; + tbody.appendChild(tr); + } + } + + function clientEventBadge(type) { + var colors = { connected: '#3fb950', disconnected: '#f85149', updated: '#d29922' }; + var labels = { connected: 'Connected', disconnected: 'Disconnected', updated: 'Updated' }; + var color = colors[type] || '#8b949e'; + var label = labels[type] || type; + return '<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;color:#0d1117;background:' + color + ';">' + escapeHtml(label) + '</span>'; + } + + function renderClientHistory(events) { + var emptyState = document.getElementById('client-history-empty'); + var table = document.getElementById('client-history-table'); + var tbody = document.getElementById('client-history-table-body'); + + if (!events || events.length === 0) { + emptyState.style.display = ''; + table.style.display = 'none'; + return; + } + + emptyState.style.display = 'none'; + table.style.display = ''; + tbody.innerHTML = ''; + + for (var i = 0; i < events.length; i++) { + var evt = events[i]; + var tr = document.createElement('tr'); + tr.innerHTML = + '<td style="color: #8b949e;">' + formatTimestamp(evt.ts) + '</td>' + + '<td>' + clientEventBadge(evt.type) + '</td>' + + '<td>' + escapeHtml(evt.client_id || '') + '</td>' + + '<td>' + escapeHtml(evt.hostname || '') + '</td>'; + tbody.appendChild(tr); + } + } + + // Fetch-based polling fallback + var pollTimer = null; + + async function fetchProvisioningHistory() { + try { + var response = await fetch(BASE_URL + '/orch/history?limit=50', { + headers: { 'Accept': 'application/json' } + }); + if (response.ok) { + var data = await response.json(); + renderProvisioningHistory(data.events || []); + } + } catch (e) { + console.error('Error fetching provisioning history:', e); + } + } + + async function fetchClients() { + try { + var response = await fetch(BASE_URL + '/orch/clients', { + headers: { 'Accept': 'application/json' } + }); + if (response.ok) { + var data = await response.json(); + renderClients(data.clients || []); + } + } catch (e) { + console.error('Error fetching clients:', e); + } + } + + async function fetchClientHistory() { + try { + var response = await fetch(BASE_URL + '/orch/clients/history?limit=50', { + headers: { 'Accept': 'application/json' } + }); + if (response.ok) { + var data = await response.json(); + renderClientHistory(data.client_events || []); + } + } catch (e) { + console.error('Error fetching client history:', e); + } + } + + async function fetchDashboard() { + var banner = document.querySelector('zen-banner'); + try { + var response = await fetch(BASE_URL + '/orch/agents', { + headers: { 'Accept': 'application/json' } + }); + + if (!response.ok) { + banner.setAttribute('cluster-status', 'degraded'); + throw new Error('HTTP ' + response.status + ': ' + response.statusText); + } + + renderDashboard(await response.json()); + fetchProvisioningHistory(); + fetchClients(); + fetchClientHistory(); + } catch (error) { + console.error('Error updating dashboard:', error); + showError(error.message); + banner.setAttribute('cluster-status', 'offline'); + } + } + + function startPolling() { + if (pollTimer) return; + fetchDashboard(); + pollTimer = setInterval(fetchDashboard, REFRESH_INTERVAL); + } + + function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + // WebSocket connection with automatic reconnect and polling fallback + var ws = null; + + function connectWebSocket() { + var proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + ws = new WebSocket(proto + '//' + window.location.host + '/orch/ws'); + + ws.onopen = function() { + stopPolling(); + clearError(); + }; + + ws.onmessage = function(event) { + try { + renderDashboard(JSON.parse(event.data)); + } catch (e) { + console.error('WebSocket message parse error:', e); + } + }; + + ws.onclose = function() { + ws = null; + startPolling(); + setTimeout(connectWebSocket, 3000); + }; + + ws.onerror = function() { + // onclose will fire after onerror + }; + } + + // Fetch orchestrator hostname for the banner + fetch(BASE_URL + '/orch/status', { headers: { 'Accept': 'application/json' } }) + .then(function(r) { return r.ok ? r.json() : null; }) + .then(function(d) { + if (d && d.hostname) { + document.querySelector('zen-banner').setAttribute('tagline', 'Orchestrator \u2014 ' + d.hostname); + } + }) + .catch(function() {}); + + // Initial load via fetch, then try WebSocket + fetchDashboard(); + connectWebSocket(); + </script> +</body> +</html> |