diff options
Diffstat (limited to 'src/zenserver/frontend')
22 files changed, 1902 insertions, 2679 deletions
diff --git a/src/zenserver/frontend/frontend.cpp b/src/zenserver/frontend/frontend.cpp index 697cc014e..812536074 100644 --- a/src/zenserver/frontend/frontend.cpp +++ b/src/zenserver/frontend/frontend.cpp @@ -9,6 +9,7 @@ #include <zencore/logging.h> #include <zencore/string.h> #include <zencore/trace.h> +#include <zenhttp/httpstats.h> ZEN_THIRD_PARTY_INCLUDES_START #if ZEN_PLATFORM_WINDOWS @@ -28,8 +29,9 @@ static unsigned char gHtmlZipData[] = { namespace zen { //////////////////////////////////////////////////////////////////////////////// -HttpFrontendService::HttpFrontendService(std::filesystem::path Directory, HttpStatusService& StatusService) +HttpFrontendService::HttpFrontendService(std::filesystem::path Directory, HttpStatsService& StatsService, HttpStatusService& StatusService) : m_Directory(Directory) +, m_StatsService(StatsService) , m_StatusService(StatusService) { ZEN_TRACE_CPU("HttpFrontendService::HttpFrontendService"); @@ -94,12 +96,14 @@ HttpFrontendService::HttpFrontendService(std::filesystem::path Directory, HttpSt { ZEN_INFO("front-end is NOT AVAILABLE"); } + m_StatsService.RegisterHandler("dashboard", *this); m_StatusService.RegisterHandler("dashboard", *this); } HttpFrontendService::~HttpFrontendService() { m_StatusService.UnregisterHandler("dashboard", *this); + m_StatsService.UnregisterHandler("dashboard", *this); } const char* @@ -122,6 +126,8 @@ HttpFrontendService::HandleRequest(zen::HttpServerRequest& Request) { using namespace std::literals; + metrics::OperationTiming::Scope $(m_HttpRequests); + ExtendableStringBuilder<256> UriBuilder; std::string_view Uri = Request.RelativeUriWithExtension(); @@ -154,7 +160,7 @@ HttpFrontendService::HandleRequest(zen::HttpServerRequest& Request) ContentType = ParseContentType(DotExt); - // Extensions used only for static file serving — not in the global + // Extensions used only for static file serving - not in the global // ParseContentType table because that table also drives URI extension // stripping for content negotiation, and we don't want /api/foo.txt to // have its extension removed. @@ -230,4 +236,26 @@ HttpFrontendService::HandleRequest(zen::HttpServerRequest& Request) } } +void +HttpFrontendService::HandleStatsRequest(HttpServerRequest& Request) +{ + Request.WriteResponse(HttpResponseCode::OK, CollectStats()); +} + +CbObject +HttpFrontendService::CollectStats() +{ + ZEN_TRACE_CPU("HttpFrontendService::Stats"); + CbObjectWriter Cbo; + + EmitSnapshot("requests", m_HttpRequests, Cbo); + return Cbo.Save(); +} + +uint64_t +HttpFrontendService::GetActivityCounter() +{ + return m_HttpRequests.Count(); +} + } // namespace zen diff --git a/src/zenserver/frontend/frontend.h b/src/zenserver/frontend/frontend.h index 0ae3170ad..0e7a4fe3c 100644 --- a/src/zenserver/frontend/frontend.h +++ b/src/zenserver/frontend/frontend.h @@ -4,27 +4,34 @@ #include <zenhttp/httpserver.h> #include <zenhttp/httpstatus.h> -#include "zipfs.h" +#include <zenhttp/zipfs.h> #include <filesystem> #include <memory> namespace zen { -class HttpFrontendService final : public zen::HttpService, public IHttpStatusProvider +class HttpStatsService; + +class HttpFrontendService final : public HttpService, public IHttpStatusProvider, public IHttpStatsProvider { public: - HttpFrontendService(std::filesystem::path Directory, HttpStatusService& StatusService); + HttpFrontendService(std::filesystem::path Directory, HttpStatsService& StatsService, HttpStatusService& StatusService); virtual ~HttpFrontendService(); virtual const char* BaseUri() const override; - virtual void HandleRequest(zen::HttpServerRequest& Request) override; + virtual void HandleRequest(HttpServerRequest& Request) override; virtual void HandleStatusRequest(HttpServerRequest& Request) override; + virtual void HandleStatsRequest(HttpServerRequest& Request) override; + virtual CbObject CollectStats() override; + virtual uint64_t GetActivityCounter() override; private: - std::unique_ptr<ZipFs> m_ZipFs; - std::filesystem::path m_Directory; - std::filesystem::path m_DocsDirectory; - HttpStatusService& m_StatusService; + std::unique_ptr<ZipFs> m_ZipFs; + std::filesystem::path m_Directory; + std::filesystem::path m_DocsDirectory; + HttpStatsService& m_StatsService; + HttpStatusService& m_StatusService; + metrics::OperationTiming m_HttpRequests; }; } // namespace zen diff --git a/src/zenserver/frontend/html/compute/compute.html b/src/zenserver/frontend/html/compute/compute.html deleted file mode 100644 index c07bbb692..000000000 --- a/src/zenserver/frontend/html/compute/compute.html +++ /dev/null @@ -1,925 +0,0 @@ -<!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> - <link rel="stylesheet" type="text/css" href="../zen.css" /> - <script src="../util/sanitize.js"></script> - <script src="../theme.js"></script> - <script src="../banner.js" defer></script> - <script src="../nav.js" defer></script> - <style> - .grid { - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - } - - .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 var(--theme_border_subtle); - } - - .stats-row:last-child { - border-bottom: none; - margin-bottom: 0; - } - - .stats-label { - color: var(--theme_g1); - font-size: 13px; - } - - .stats-value { - color: var(--theme_bright); - 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: var(--theme_p0); - } - - .rate-label { - font-size: 11px; - color: var(--theme_g1); - margin-top: 4px; - text-transform: uppercase; - } - - .worker-row { - cursor: pointer; - transition: background 0.15s; - } - - .worker-row:hover { - background: var(--theme_p4); - } - - .worker-row.selected { - background: var(--theme_p3); - } - - .worker-detail { - margin-top: 20px; - border-top: 1px solid var(--theme_g2); - padding-top: 16px; - } - - .worker-detail-title { - font-size: 15px; - font-weight: 600; - color: var(--theme_bright); - margin-bottom: 12px; - } - - .detail-section { - margin-bottom: 16px; - } - - .detail-section-label { - font-size: 11px; - font-weight: 600; - color: var(--theme_g1); - 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: var(--theme_g0); - border-bottom: 1px solid var(--theme_border_subtle); - vertical-align: top; - } - - .detail-table td:first-child { - color: var(--theme_g1); - width: 40%; - font-family: monospace; - } - - .detail-table tr:last-child td { - border-bottom: none; - } - - .detail-mono { - font-family: monospace; - font-size: 11px; - color: var(--theme_g1); - } - - .detail-tag { - display: inline-block; - padding: 2px 8px; - border-radius: 4px; - background: var(--theme_border_subtle); - color: var(--theme_g0); - font-size: 11px; - margin: 2px 4px 2px 0; - } - </style> -</head> -<body> - <div class="container" style="max-width: 1400px; margin: 0 auto;"> - <zen-banner cluster-status="nominal" load="0" tagline="Node Overview" logo-src="../favicon.ico"></zen-banner> - <zen-nav> - <a href="/dashboard/">Home</a> - <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"> - <thead> - <tr> - <th>Name</th> - <th>Platform</th> - <th style="text-align: right;">Cores</th> - <th style="text-align: right;">Timeout</th> - <th style="text-align: right;">Functions</th> - <th>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" class="empty-state" style="text-align: left;">No queues.</div> - <div id="queue-list-container" style="display: none;"> - <table id="queue-list-table"> - <thead> - <tr> - <th style="text-align: right; width: 60px;">ID</th> - <th style="text-align: center; width: 80px;">Status</th> - <th style="text-align: right;">Active</th> - <th style="text-align: right;">Completed</th> - <th style="text-align: right;">Failed</th> - <th style="text-align: right;">Abandoned</th> - <th style="text-align: right;">Cancelled</th> - <th>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" class="empty-state" style="text-align: left;">No actions recorded yet.</div> - <div id="action-history-container" style="display: none;"> - <table id="action-history-table"> - <thead> - <tr> - <th style="text-align: right; width: 60px;">LSN</th> - <th style="text-align: right; width: 60px;">Queue</th> - <th style="text-align: center; width: 70px;">Status</th> - <th>Function</th> - <th style="text-align: right; width: 80px;">Started</th> - <th style="text-align: right; width: 80px;">Finished</th> - <th style="text-align: right; width: 80px;">Duration</th> - <th>Worker ID</th> - <th>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 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:var(--theme_faint);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:var(--theme_faint);font-size:12px;">none</span>' : - `<table class="detail-table"> - <tr style="font-size:11px;"> - <td style="color:var(--theme_faint);padding-bottom:4px;">Path</td> - <td style="color:var(--theme_faint);padding-bottom:4px;">Hash</td> - <td style="color:var(--theme_faint);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 var(--theme_g2);"> - <td style="color:var(--theme_g1);padding-top:6px;">Total</td> - <td></td> - <td style="text-align:right;white-space:nowrap;padding-top:6px;color:var(--theme_bright);font-weight:600;">${formatBytes(totalExecSize)}</td> - </tr> - </table>`; - - // Files - const files = desc.files || []; - const filesHtml = files.length === 0 ? '<span style="color:var(--theme_faint);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:var(--theme_faint);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:var(--theme_faint);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="color: var(--theme_bright);">${escapeHtml(name)}</td> - <td>${escapeHtml(host)}</td> - <td style="text-align: right;">${escapeHtml(String(cores))}</td> - <td style="text-align: right;">${escapeHtml(String(timeout))}</td> - <td style="text-align: right;">${escapeHtml(String(functions))}</td> - <td style="color: var(--theme_g1); 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:color-mix(in srgb, var(--theme_warn) 15%, transparent);color:var(--theme_warn);">draining</span>' - : q.is_complete - ? '<span class="status-badge success">complete</span>' - : '<span class="status-badge" style="background:color-mix(in srgb, var(--theme_p0) 15%, transparent);color:var(--theme_p0);">active</span>'; - const token = q.queue_token - ? `<span class="detail-mono">${escapeHtml(q.queue_token)}</span>` - : '<span style="color:var(--theme_faint);">-</span>'; - - const tr = document.createElement('tr'); - tr.innerHTML = ` - <td style="text-align: right; font-family: monospace; color: var(--theme_bright);">${escapeHtml(String(id))}</td> - <td style="text-align: center;">${badge}</td> - <td style="text-align: right;">${q.active_count ?? 0}</td> - <td style="text-align: right; color: var(--theme_ok);">${q.completed_count ?? 0}</td> - <td style="text-align: right; color: var(--theme_fail);">${q.failed_count ?? 0}</td> - <td style="text-align: right; color: var(--theme_warn);">${q.abandoned_count ?? 0}</td> - <td style="text-align: right; color: var(--theme_warn);">${q.cancelled_count ?? 0}</td> - <td>${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:var(--theme_border_subtle);color:var(--theme_g1);">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: var(--theme_ln); text-decoration: none; font-family: monospace;">${escapeHtml(String(queueId))}</a>` - : '<span style="color: var(--theme_faint);">-</span>'; - - const tr = document.createElement('tr'); - tr.innerHTML = ` - <td style="text-align: right; font-family: monospace; color: var(--theme_g1);">${escapeHtml(String(lsn))}</td> - <td style="text-align: right;">${queueCell}</td> - <td style="text-align: center;">${badge}</td> - <td style="color: var(--theme_bright);">${escapeHtml(fn)}</td> - <td style="text-align: right; font-size: 12px; white-space: nowrap; color: var(--theme_g1);">${formatTime(startDate)}</td> - <td style="text-align: right; font-size: 12px; white-space: nowrap; color: var(--theme_g1);">${formatTime(endDate)}</td> - <td style="text-align: right; font-size: 12px; white-space: nowrap;">${formatDuration(startDate, endDate)}</td> - <td style="font-family: monospace; font-size: 11px; color: var(--theme_g1);">${escapeHtml(workerId)}</td> - <td style="font-family: monospace; font-size: 11px; color: var(--theme_g1);">${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 index b15b34577..41c80d3a3 100644 --- a/src/zenserver/frontend/html/compute/hub.html +++ b/src/zenserver/frontend/html/compute/hub.html @@ -83,7 +83,7 @@ } async function fetchStats() { - var data = await fetchJSON('/hub/stats'); + var data = await fetchJSON('/stats/hub'); var current = data.currentInstanceCount || 0; var max = data.maxInstanceCount || 0; diff --git a/src/zenserver/frontend/html/compute/index.html b/src/zenserver/frontend/html/compute/index.html index 9597fd7f3..aaa09aec0 100644 --- a/src/zenserver/frontend/html/compute/index.html +++ b/src/zenserver/frontend/html/compute/index.html @@ -1 +1 @@ -<meta http-equiv="refresh" content="0; url=compute.html" />
\ No newline at end of file +<meta http-equiv="refresh" content="0; url=/dashboard/?page=compute" />
\ No newline at end of file diff --git a/src/zenserver/frontend/html/compute/orchestrator.html b/src/zenserver/frontend/html/compute/orchestrator.html deleted file mode 100644 index d1a2bb015..000000000 --- a/src/zenserver/frontend/html/compute/orchestrator.html +++ /dev/null @@ -1,669 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <link rel="stylesheet" type="text/css" href="../zen.css" /> - <script src="../util/sanitize.js"></script> - <script src="../theme.js"></script> - <script src="../banner.js" defer></script> - <script src="../nav.js" defer></script> - <title>Zen Orchestrator Dashboard</title> - <style> - .agent-count { - display: flex; - align-items: center; - gap: 8px; - font-size: 14px; - padding: 8px 16px; - border-radius: 6px; - background: var(--theme_g3); - border: 1px solid var(--theme_g2); - } - - .agent-count .count { - font-size: 20px; - font-weight: 600; - color: var(--theme_bright); - } - </style> -</head> -<body> - <div class="container" style="max-width: 1400px; margin: 0 auto;"> - <zen-banner cluster-status="nominal" load="0" logo-src="../favicon.ico"></zen-banner> - <zen-nav> - <a href="/dashboard/">Home</a> - <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 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; font-size: 11px; color: var(--theme_g1);">' + formatTraffic(bytesRecv, bytesSent) + '</td>' + - '<td style="text-align: right; color: var(--theme_g1);">' + 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: var(--theme_g1); 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: 'var(--theme_ok)', left: 'var(--theme_fail)', returned: 'var(--theme_warn)' }; - var labels = { joined: 'Joined', left: 'Left', returned: 'Returned' }; - var color = colors[type] || 'var(--theme_g1)'; - var label = labels[type] || type; - return '<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;color:var(--theme_g4);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: var(--theme_g1);">' + 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:var(--theme_faint);" 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: var(--theme_g1);">' + escapeHtml(c.address || '') + '</td>' + - '<td style="text-align: right; color: var(--theme_g1);">' + formatLastSeen(dt) + '</td>'; - tbody.appendChild(tr); - } - } - - function clientEventBadge(type) { - var colors = { connected: 'var(--theme_ok)', disconnected: 'var(--theme_fail)', updated: 'var(--theme_warn)' }; - var labels = { connected: 'Connected', disconnected: 'Disconnected', updated: 'Updated' }; - var color = colors[type] || 'var(--theme_g1)'; - var label = labels[type] || type; - return '<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;color:var(--theme_g4);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: var(--theme_g1);">' + 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> diff --git a/src/zenserver/frontend/html/pages/builds.js b/src/zenserver/frontend/html/pages/builds.js new file mode 100644 index 000000000..c63d13b91 --- /dev/null +++ b/src/zenserver/frontend/html/pages/builds.js @@ -0,0 +1,80 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" +import { Friendly } from "../util/friendly.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + generate_crumbs() {} + + async main() + { + this.set_title("build store"); + + // Build Store Stats + const stats_section = this._collapsible_section("Build Store Service Stats"); + stats_section.tag().classify("dropall").text("raw yaml \u2192").on_click(() => { + window.open("/stats/builds.yaml", "_blank"); + }); + this._stats_grid = stats_section.tag().classify("grid").classify("stats-tiles"); + + const stats = await new Fetcher().resource("stats", "builds").json(); + if (stats) + { + this._render_stats(stats); + } + + this.connect_stats_ws((all_stats) => { + const s = all_stats["builds"]; + if (s) + { + this._render_stats(s); + } + }); + } + + _render_stats(stats) + { + stats = this._merge_last_stats(stats); + const grid = this._stats_grid; + const safe = (obj, path) => path.split(".").reduce((a, b) => a && a[b], obj); + + grid.inner().innerHTML = ""; + + // HTTP Requests tile + this._render_http_requests_tile(grid, safe(stats, "requests"), safe(stats, "store.badrequestcount") || 0); + + // Build Store tile + { + const blobs = safe(stats, "store.blobs") || {}; + const metadata = safe(stats, "store.metadata") || {}; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Build Store"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + this._metric(left, Friendly.bytes(safe(stats, "store.size.disk") || 0), "disk", true); + this._metric(left, Friendly.sep(blobs.count || 0), "blobs"); + this._metric(left, Friendly.sep(blobs.readcount || 0), "blob reads"); + this._metric(left, Friendly.sep(blobs.writecount || 0), "blob writes"); + const blobHitRatio = (blobs.readcount || 0) > 0 + ? (((blobs.hitcount || 0) / blobs.readcount) * 100).toFixed(1) + "%" + : "-"; + this._metric(left, blobHitRatio, "blob hit ratio"); + + const right = columns.tag().classify("tile-metrics"); + this._metric(right, Friendly.sep(metadata.count || 0), "metadata entries", true); + this._metric(right, Friendly.sep(metadata.readcount || 0), "meta reads"); + this._metric(right, Friendly.sep(metadata.writecount || 0), "meta writes"); + const metaHitRatio = (metadata.readcount || 0) > 0 + ? (((metadata.hitcount || 0) / metadata.readcount) * 100).toFixed(1) + "%" + : "-"; + this._metric(right, metaHitRatio, "meta hit ratio"); + } + } + +} diff --git a/src/zenserver/frontend/html/pages/cache.js b/src/zenserver/frontend/html/pages/cache.js index 1fc8227c8..683f7df4f 100644 --- a/src/zenserver/frontend/html/pages/cache.js +++ b/src/zenserver/frontend/html/pages/cache.js @@ -6,7 +6,7 @@ import { ZenPage } from "./page.js" import { Fetcher } from "../util/fetcher.js" import { Friendly } from "../util/friendly.js" import { Modal } from "../util/modal.js" -import { Table, Toolbar } from "../util/widgets.js" +import { Table, Toolbar, Pager, add_copy_button } from "../util/widgets.js" //////////////////////////////////////////////////////////////////////////////// export class Page extends ZenPage @@ -44,8 +44,6 @@ export class Page extends ZenPage // Cache Namespaces var section = this._collapsible_section("Cache Namespaces"); - section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all()); - var columns = [ "namespace", "dir", @@ -56,31 +54,30 @@ export class Page extends ZenPage "actions", ]; - var zcache_info = await new Fetcher().resource("/z$/").json(); this._cache_table = section.add_widget(Table, columns, Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_AlignNumeric); - for (const namespace of zcache_info["Namespaces"] || []) - { - new Fetcher().resource(`/z$/${namespace}/`).json().then((data) => { - const row = this._cache_table.add_row( - "", - data["Configuration"]["RootDir"], - data["Buckets"].length, - data["EntryCount"], - Friendly.bytes(data["StorageSize"].DiskSize), - Friendly.bytes(data["StorageSize"].MemorySize) - ); - var cell = row.get_cell(0); - cell.tag().text(namespace).on_click(() => this.view_namespace(namespace)); - - cell = row.get_cell(-1); - const action_tb = new Toolbar(cell, true); - action_tb.left().add("view").on_click(() => this.view_namespace(namespace)); - action_tb.left().add("drop").on_click(() => this.drop_namespace(namespace)); - - row.attr("zs_name", namespace); - }); - } + this._cache_pager = new Pager(section, 25, () => this._render_cache_page(), + Pager.make_search_fn(() => this._cache_data, item => item.namespace)); + const cache_drop_link = document.createElement("span"); + cache_drop_link.className = "dropall zen_action"; + cache_drop_link.style.position = "static"; + cache_drop_link.textContent = "drop-all"; + cache_drop_link.addEventListener("click", () => this.drop_all()); + this._cache_pager.prepend(cache_drop_link); + + const loading = Pager.loading(section); + const zcache_info = await new Fetcher().resource("/z$/").json(); + const namespaces = zcache_info["Namespaces"] || []; + const results = await Promise.allSettled( + namespaces.map(ns => new Fetcher().resource(`/z$/${ns}/`).json().then(data => ({ namespace: ns, data }))) + ); + this._cache_data = results + .filter(r => r.status === "fulfilled") + .map(r => r.value) + .sort((a, b) => a.namespace.localeCompare(b.namespace)); + this._cache_pager.set_total(this._cache_data.length); + this._render_cache_page(); + loading.remove(); // Namespace detail area (inside namespaces section so it collapses together) this._namespace_host = section; @@ -95,84 +92,79 @@ export class Page extends ZenPage } } - _collapsible_section(name) + _render_cache_page() { - const section = this.add_section(name); - const container = section._parent.inner(); - const heading = container.firstElementChild; + const { start, end } = this._cache_pager.page_range(); + this._cache_table.clear(start); + for (let i = start; i < end; i++) + { + const item = this._cache_data[i]; + const data = item.data; + const row = this._cache_table.add_row( + "", + data["Configuration"]["RootDir"], + data["Buckets"].length, + data["EntryCount"], + Friendly.bytes(data["StorageSize"].DiskSize), + Friendly.bytes(data["StorageSize"].MemorySize) + ); - heading.style.cursor = "pointer"; - heading.style.userSelect = "none"; + const cell = row.get_cell(0); + cell.tag().text(item.namespace).on_click(() => this.view_namespace(item.namespace)); + add_copy_button(cell.inner(), item.namespace); + add_copy_button(row.get_cell(1).inner(), data["Configuration"]["RootDir"]); - const indicator = document.createElement("span"); - indicator.textContent = " \u25BC"; - indicator.style.fontSize = "0.7em"; - heading.appendChild(indicator); + const action_cell = row.get_cell(-1); + const action_tb = new Toolbar(action_cell, true); + action_tb.left().add("view").on_click(() => this.view_namespace(item.namespace)); + action_tb.left().add("drop").on_click(() => this.drop_namespace(item.namespace)); - let collapsed = false; - heading.addEventListener("click", (e) => { - if (e.target !== heading && e.target !== indicator) - { - return; - } - collapsed = !collapsed; - indicator.textContent = collapsed ? " \u25B6" : " \u25BC"; - let sibling = heading.nextElementSibling; - while (sibling) - { - sibling.style.display = collapsed ? "none" : ""; - sibling = sibling.nextElementSibling; - } - }); - - return section; + row.attr("zs_name", item.namespace); + } } _render_stats(stats) { + stats = this._merge_last_stats(stats); const safe = (obj, path) => path.split(".").reduce((a, b) => a && a[b], obj); const grid = this._stats_grid; - this._last_stats = stats; grid.inner().innerHTML = ""; // Store I/O tile { - const store = safe(stats, "cache.store"); - if (store) - { - const tile = grid.tag().classify("card").classify("stats-tile").classify("stats-tile-detailed"); - if (this._selected_category === "store") tile.classify("stats-tile-selected"); - tile.on_click(() => this._select_category("store")); - tile.tag().classify("card-title").text("Store I/O"); - const columns = tile.tag().classify("tile-columns"); - - const left = columns.tag().classify("tile-metrics"); - const storeHits = store.hits || 0; - const storeMisses = store.misses || 0; - const storeTotal = storeHits + storeMisses; - const storeRatio = storeTotal > 0 ? ((storeHits / storeTotal) * 100).toFixed(1) + "%" : "-"; - this._metric(left, storeRatio, "store hit ratio", true); - this._metric(left, Friendly.sep(storeHits), "hits"); - this._metric(left, Friendly.sep(storeMisses), "misses"); - this._metric(left, Friendly.sep(store.writes || 0), "writes"); - this._metric(left, Friendly.sep(store.rejected_reads || 0), "rejected reads"); - this._metric(left, Friendly.sep(store.rejected_writes || 0), "rejected writes"); - - const right = columns.tag().classify("tile-metrics"); - const readRateMean = safe(store, "read.bytes.rate_mean") || 0; - const readRate1 = safe(store, "read.bytes.rate_1") || 0; - const readRate5 = safe(store, "read.bytes.rate_5") || 0; - const writeRateMean = safe(store, "write.bytes.rate_mean") || 0; - const writeRate1 = safe(store, "write.bytes.rate_1") || 0; - const writeRate5 = safe(store, "write.bytes.rate_5") || 0; - this._metric(right, Friendly.bytes(readRateMean) + "/s", "read rate (mean)", true); - this._metric(right, Friendly.bytes(readRate1) + "/s", "read rate (1m)"); - this._metric(right, Friendly.bytes(readRate5) + "/s", "read rate (5m)"); - this._metric(right, Friendly.bytes(writeRateMean) + "/s", "write rate (mean)"); - this._metric(right, Friendly.bytes(writeRate1) + "/s", "write rate (1m)"); - this._metric(right, Friendly.bytes(writeRate5) + "/s", "write rate (5m)"); - } + const store = safe(stats, "cache.store") || {}; + const tile = grid.tag().classify("card").classify("stats-tile").classify("stats-tile-detailed"); + if (this._selected_category === "store") tile.classify("stats-tile-selected"); + tile.on_click(() => this._select_category("store")); + tile.tag().classify("card-title").text("Store I/O"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + const storeHits = store.hits || 0; + const storeMisses = store.misses || 0; + const storeTotal = storeHits + storeMisses; + const storeRatio = storeTotal > 0 ? ((storeHits / storeTotal) * 100).toFixed(1) + "%" : "-"; + this._metric(left, storeRatio, "store hit ratio", true); + this._metric(left, Friendly.sep(storeHits), "hits"); + this._metric(left, Friendly.sep(storeMisses), "misses"); + this._metric(left, Friendly.sep(store.writes || 0), "writes"); + this._metric(left, Friendly.sep(store.rejected_reads || 0), "rejected reads"); + this._metric(left, Friendly.sep(store.rejected_writes || 0), "rejected writes"); + + const right = columns.tag().classify("tile-metrics"); + const readRateMean = safe(store, "read.bytes.rate_mean") || 0; + const readRate1 = safe(store, "read.bytes.rate_1") || 0; + const readRate5 = safe(store, "read.bytes.rate_5") || 0; + const writeRateMean = safe(store, "write.bytes.rate_mean") || 0; + const writeRate1 = safe(store, "write.bytes.rate_1") || 0; + const writeRate5 = safe(store, "write.bytes.rate_5") || 0; + this._metric(right, Friendly.bytes(readRateMean) + "/s", "read rate (mean)", true); + this._metric(right, Friendly.bytes(readRate1) + "/s", "read rate (1m)"); + this._metric(right, Friendly.bytes(readRate5) + "/s", "read rate (5m)"); + this._metric(right, Friendly.bytes(writeRateMean) + "/s", "write rate (mean)"); + this._metric(right, Friendly.bytes(writeRate1) + "/s", "write rate (1m)"); + this._metric(right, Friendly.bytes(writeRate5) + "/s", "write rate (5m)"); } // Hit/Miss tile @@ -208,89 +200,83 @@ export class Page extends ZenPage // HTTP Requests tile { - const req = safe(stats, "requests"); - if (req) - { - const tile = grid.tag().classify("card").classify("stats-tile"); - tile.tag().classify("card-title").text("HTTP Requests"); - const columns = tile.tag().classify("tile-columns"); + const req = safe(stats, "requests") || {}; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("HTTP Requests"); + const columns = tile.tag().classify("tile-columns"); - const left = columns.tag().classify("tile-metrics"); - const reqData = req.requests || req; - this._metric(left, Friendly.sep(reqData.count || 0), "total requests", true); - if (reqData.rate_mean > 0) - { - this._metric(left, Friendly.sep(reqData.rate_mean, 1) + "/s", "req/sec (mean)"); - } - if (reqData.rate_1 > 0) - { - this._metric(left, Friendly.sep(reqData.rate_1, 1) + "/s", "req/sec (1m)"); - } - if (reqData.rate_5 > 0) - { - this._metric(left, Friendly.sep(reqData.rate_5, 1) + "/s", "req/sec (5m)"); - } - if (reqData.rate_15 > 0) - { - this._metric(left, Friendly.sep(reqData.rate_15, 1) + "/s", "req/sec (15m)"); - } - const badRequests = safe(stats, "cache.badrequestcount") || 0; - this._metric(left, Friendly.sep(badRequests), "bad requests"); + const left = columns.tag().classify("tile-metrics"); + const reqData = req.requests || req; + this._metric(left, Friendly.sep(reqData.count || 0), "total requests", true); + if (reqData.rate_mean > 0) + { + this._metric(left, Friendly.sep(reqData.rate_mean, 1) + "/s", "req/sec (mean)"); + } + if (reqData.rate_1 > 0) + { + this._metric(left, Friendly.sep(reqData.rate_1, 1) + "/s", "req/sec (1m)"); + } + if (reqData.rate_5 > 0) + { + this._metric(left, Friendly.sep(reqData.rate_5, 1) + "/s", "req/sec (5m)"); + } + if (reqData.rate_15 > 0) + { + this._metric(left, Friendly.sep(reqData.rate_15, 1) + "/s", "req/sec (15m)"); + } + const badRequests = safe(stats, "cache.badrequestcount") || 0; + this._metric(left, Friendly.sep(badRequests), "bad requests"); - const right = columns.tag().classify("tile-metrics"); - this._metric(right, Friendly.duration(reqData.t_avg || 0), "avg latency", true); - if (reqData.t_p75) - { - this._metric(right, Friendly.duration(reqData.t_p75), "p75"); - } - if (reqData.t_p95) - { - this._metric(right, Friendly.duration(reqData.t_p95), "p95"); - } - if (reqData.t_p99) - { - this._metric(right, Friendly.duration(reqData.t_p99), "p99"); - } - if (reqData.t_p999) - { - this._metric(right, Friendly.duration(reqData.t_p999), "p999"); - } - if (reqData.t_max) - { - this._metric(right, Friendly.duration(reqData.t_max), "max"); - } + const right = columns.tag().classify("tile-metrics"); + this._metric(right, Friendly.duration(reqData.t_avg || 0), "avg latency", true); + if (reqData.t_p75) + { + this._metric(right, Friendly.duration(reqData.t_p75), "p75"); + } + if (reqData.t_p95) + { + this._metric(right, Friendly.duration(reqData.t_p95), "p95"); + } + if (reqData.t_p99) + { + this._metric(right, Friendly.duration(reqData.t_p99), "p99"); + } + if (reqData.t_p999) + { + this._metric(right, Friendly.duration(reqData.t_p999), "p999"); + } + if (reqData.t_max) + { + this._metric(right, Friendly.duration(reqData.t_max), "max"); } } // RPC tile { - const rpc = safe(stats, "cache.rpc"); - if (rpc) - { - const tile = grid.tag().classify("card").classify("stats-tile"); - tile.tag().classify("card-title").text("RPC"); - const columns = tile.tag().classify("tile-columns"); + const rpc = safe(stats, "cache.rpc") || {}; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("RPC"); + const columns = tile.tag().classify("tile-columns"); - const left = columns.tag().classify("tile-metrics"); - this._metric(left, Friendly.sep(rpc.count || 0), "rpc calls", true); - this._metric(left, Friendly.sep(rpc.ops || 0), "batch ops"); + const left = columns.tag().classify("tile-metrics"); + this._metric(left, Friendly.sep(rpc.count || 0), "rpc calls", true); + this._metric(left, Friendly.sep(rpc.ops || 0), "batch ops"); - const right = columns.tag().classify("tile-metrics"); - if (rpc.records) - { - this._metric(right, Friendly.sep(rpc.records.count || 0), "record calls"); - this._metric(right, Friendly.sep(rpc.records.ops || 0), "record ops"); - } - if (rpc.values) - { - this._metric(right, Friendly.sep(rpc.values.count || 0), "value calls"); - this._metric(right, Friendly.sep(rpc.values.ops || 0), "value ops"); - } - if (rpc.chunks) - { - this._metric(right, Friendly.sep(rpc.chunks.count || 0), "chunk calls"); - this._metric(right, Friendly.sep(rpc.chunks.ops || 0), "chunk ops"); - } + const right = columns.tag().classify("tile-metrics"); + if (rpc.records) + { + this._metric(right, Friendly.sep(rpc.records.count || 0), "record calls"); + this._metric(right, Friendly.sep(rpc.records.ops || 0), "record ops"); + } + if (rpc.values) + { + this._metric(right, Friendly.sep(rpc.values.count || 0), "value calls"); + this._metric(right, Friendly.sep(rpc.values.ops || 0), "value ops"); + } + if (rpc.chunks) + { + this._metric(right, Friendly.sep(rpc.chunks.count || 0), "chunk calls"); + this._metric(right, Friendly.sep(rpc.chunks.ops || 0), "chunk ops"); } } @@ -313,7 +299,7 @@ export class Page extends ZenPage this._metric(right, safe(stats, "cid.size.large") != null ? Friendly.bytes(safe(stats, "cid.size.large")) : "-", "cid large"); } - // Upstream tile (only if upstream is active) + // Upstream tile (only shown when upstream is active) { const upstream = safe(stats, "upstream"); if (upstream) @@ -644,10 +630,9 @@ export class Page extends ZenPage async drop_all() { const drop = async () => { - for (const row of this._cache_table) + for (const item of this._cache_data || []) { - const namespace = row.attr("zs_name"); - await new Fetcher().resource("z$", namespace).delete(); + await new Fetcher().resource("z$", item.namespace).delete(); } this.reload(); }; diff --git a/src/zenserver/frontend/html/pages/compute.js b/src/zenserver/frontend/html/pages/compute.js index ab3d49c27..c2257029e 100644 --- a/src/zenserver/frontend/html/pages/compute.js +++ b/src/zenserver/frontend/html/pages/compute.js @@ -5,7 +5,7 @@ import { ZenPage } from "./page.js" import { Fetcher } from "../util/fetcher.js" import { Friendly } from "../util/friendly.js" -import { Table } from "../util/widgets.js" +import { Table, add_copy_button } from "../util/widgets.js" const MAX_HISTORY_POINTS = 60; @@ -24,6 +24,12 @@ function formatTime(date) return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); } +function truncateHash(hash) +{ + if (!hash || hash.length <= 15) return hash; + return hash.slice(0, 6) + "\u2026" + hash.slice(-6); +} + function formatDuration(startDate, endDate) { if (!startDate || !endDate) return "-"; @@ -100,39 +106,6 @@ export class Page extends ZenPage }, 2000); } - _collapsible_section(name) - { - const section = this.add_section(name); - const container = section._parent.inner(); - const heading = container.firstElementChild; - - heading.style.cursor = "pointer"; - heading.style.userSelect = "none"; - - const indicator = document.createElement("span"); - indicator.textContent = " \u25BC"; - indicator.style.fontSize = "0.7em"; - heading.appendChild(indicator); - - let collapsed = false; - heading.addEventListener("click", (e) => { - if (e.target !== heading && e.target !== indicator) - { - return; - } - collapsed = !collapsed; - indicator.textContent = collapsed ? " \u25B6" : " \u25BC"; - let sibling = heading.nextElementSibling; - while (sibling) - { - sibling.style.display = collapsed ? "none" : ""; - sibling = sibling.nextElementSibling; - } - }); - - return section; - } - async _load_chartjs() { if (window.Chart) @@ -338,11 +311,7 @@ export class Page extends ZenPage { const workerIds = data.workers || []; - if (this._workers_table) - { - this._workers_table.clear(); - } - else + if (!this._workers_table) { this._workers_table = this._workers_host.add_widget( Table, @@ -353,6 +322,7 @@ export class Page extends ZenPage if (workerIds.length === 0) { + this._workers_table.clear(); return; } @@ -382,6 +352,10 @@ export class Page extends ZenPage id, ); + // Worker ID column: monospace for hex readability, copy button + row.get_cell(5).style("fontFamily", "'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace"); + add_copy_button(row.get_cell(5).inner(), id); + // Make name clickable to expand detail const cell = row.get_cell(0); cell.tag().text(name).on_click(() => this._toggle_worker_detail(id, desc)); @@ -551,7 +525,7 @@ export class Page extends ZenPage : q.state === "draining" ? "draining" : q.is_complete ? "complete" : "active"; - this._queues_table.add_row( + const qrow = this._queues_table.add_row( id, status, String(q.active_count ?? 0), @@ -561,6 +535,10 @@ export class Page extends ZenPage String(q.cancelled_count ?? 0), q.queue_token || "-", ); + if (q.queue_token) + { + add_copy_button(qrow.get_cell(7).inner(), q.queue_token); + } } } @@ -579,6 +557,11 @@ export class Page extends ZenPage ["LSN", "queue", "status", "function", "started", "finished", "duration", "worker ID", "action ID"], Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric, -1 ); + + // Right-align hash column headers to match data cells + const hdr = this._history_table.inner().firstElementChild; + hdr.children[7].style.textAlign = "right"; + hdr.children[8].style.textAlign = "right"; } // Entries arrive oldest-first; reverse to show newest at top @@ -593,7 +576,10 @@ export class Page extends ZenPage const startDate = filetimeToDate(entry.time_Running); const endDate = filetimeToDate(entry.time_Completed ?? entry.time_Failed); - this._history_table.add_row( + const workerId = entry.workerId || "-"; + const actionId = entry.actionId || "-"; + + const row = this._history_table.add_row( lsn, queueId, status, @@ -601,9 +587,17 @@ export class Page extends ZenPage formatTime(startDate), formatTime(endDate), formatDuration(startDate, endDate), - entry.workerId || "-", - entry.actionId || "-", + truncateHash(workerId), + truncateHash(actionId), ); + + // Hash columns: force right-align (AlignNumeric misses hex strings starting with a-f), + // use monospace for readability, and show full value on hover + const mono = "'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace"; + row.get_cell(7).style("textAlign", "right").style("fontFamily", mono).attr("title", workerId); + if (workerId !== "-") { add_copy_button(row.get_cell(7).inner(), workerId); } + row.get_cell(8).style("textAlign", "right").style("fontFamily", mono).attr("title", actionId); + if (actionId !== "-") { add_copy_button(row.get_cell(8).inner(), actionId); } } } diff --git a/src/zenserver/frontend/html/pages/entry.js b/src/zenserver/frontend/html/pages/entry.js index 1e4c82e3f..e381f4a71 100644 --- a/src/zenserver/frontend/html/pages/entry.js +++ b/src/zenserver/frontend/html/pages/entry.js @@ -168,7 +168,7 @@ export class Page extends ZenPage if (key === "cook.artifacts") { action_tb.left().add("view-raw").on_click(() => { - window.location = "/" + ["prj", project, "oplog", oplog, value+".json"].join("/"); + window.open("/" + ["prj", project, "oplog", oplog, value+".json"].join("/"), "_self"); }); } diff --git a/src/zenserver/frontend/html/pages/hub.js b/src/zenserver/frontend/html/pages/hub.js index 149a5c79c..b2bca9324 100644 --- a/src/zenserver/frontend/html/pages/hub.js +++ b/src/zenserver/frontend/html/pages/hub.js @@ -6,6 +6,7 @@ import { ZenPage } from "./page.js" import { Fetcher } from "../util/fetcher.js" import { Friendly } from "../util/friendly.js" import { Modal } from "../util/modal.js" +import { flash_highlight, copy_button } from "../util/widgets.js" //////////////////////////////////////////////////////////////////////////////// const STABLE_STATES = new Set(["provisioned", "hibernated", "crashed"]); @@ -20,6 +21,7 @@ function _btn_enabled(state, action) if (action === "hibernate") { return state === "provisioned"; } if (action === "wake") { return state === "hibernated"; } if (action === "deprovision") { return _is_actionable(state); } + if (action === "obliterate") { return _is_actionable(state); } return false; } @@ -82,7 +84,7 @@ export class Page extends ZenPage this.set_title("hub"); // Capacity - const stats_section = this.add_section("Capacity"); + const stats_section = this._collapsible_section("Hub Service Stats"); this._stats_grid = stats_section.tag().classify("grid").classify("stats-tiles"); // Modules @@ -96,20 +98,24 @@ export class Page extends ZenPage this._bulk_label.className = "module-bulk-label"; this._btn_bulk_hibernate = _make_bulk_btn("\u23F8", "Hibernate", () => this._exec_action("hibernate", [...this._selected])); this._btn_bulk_wake = _make_bulk_btn("\u25B6", "Wake", () => this._exec_action("wake", [...this._selected])); - this._btn_bulk_deprov = _make_bulk_btn("\u2715", "Deprovision",() => this._confirm_deprovision([...this._selected])); + this._btn_bulk_deprov = _make_bulk_btn("\u23F9", "Deprovision",() => this._confirm_deprovision([...this._selected])); + this._btn_bulk_oblit = _make_bulk_btn("\uD83D\uDD25", "Obliterate", () => this._confirm_obliterate([...this._selected])); const bulk_sep = document.createElement("div"); bulk_sep.className = "module-bulk-sep"; this._btn_hibernate_all = _make_bulk_btn("\u23F8", "Hibernate All", () => this._confirm_all("hibernate", "Hibernate All")); this._btn_wake_all = _make_bulk_btn("\u25B6", "Wake All", () => this._confirm_all("wake", "Wake All")); - this._btn_deprov_all = _make_bulk_btn("\u2715", "Deprovision All",() => this._confirm_all("deprovision", "Deprovision All")); + this._btn_deprov_all = _make_bulk_btn("\u23F9", "Deprovision All",() => this._confirm_all("deprovision", "Deprovision All")); + this._btn_oblit_all = _make_bulk_btn("\uD83D\uDD25", "Obliterate All", () => this._confirm_obliterate(this._modules_data.map(m => m.moduleId))); this._bulk_bar.appendChild(this._bulk_label); this._bulk_bar.appendChild(this._btn_bulk_hibernate); this._bulk_bar.appendChild(this._btn_bulk_wake); this._bulk_bar.appendChild(this._btn_bulk_deprov); + this._bulk_bar.appendChild(this._btn_bulk_oblit); this._bulk_bar.appendChild(bulk_sep); this._bulk_bar.appendChild(this._btn_hibernate_all); this._bulk_bar.appendChild(this._btn_wake_all); this._bulk_bar.appendChild(this._btn_deprov_all); + this._bulk_bar.appendChild(this._btn_oblit_all); mod_host.appendChild(this._bulk_bar); // Module table @@ -152,6 +158,38 @@ export class Page extends ZenPage this._btn_next.className = "module-pager-btn"; this._btn_next.textContent = "Next \u2192"; this._btn_next.addEventListener("click", () => this._go_page(this._page + 1)); + this._btn_provision = _make_bulk_btn("+", "Provision", () => this._show_provision_modal()); + this._btn_obliterate = _make_bulk_btn("\uD83D\uDD25", "Obliterate", () => this._show_obliterate_modal()); + this._search_input = document.createElement("input"); + this._search_input.type = "text"; + this._search_input.className = "module-pager-search"; + this._search_input.placeholder = "Search module\u2026"; + this._search_input.addEventListener("keydown", (e) => + { + if (e.key === "Enter") + { + const term = this._search_input.value.trim().toLowerCase(); + if (!term) { return; } + const idx = this._modules_data.findIndex(m => + (m.moduleId || "").toLowerCase().includes(term) + ); + if (idx >= 0) + { + const id = this._modules_data[idx].moduleId; + this._navigate_to_module(id); + this._flash_module(id); + } + else + { + this._search_input.style.outline = "2px solid var(--theme_fail)"; + setTimeout(() => { this._search_input.style.outline = ""; }, 1000); + } + } + }); + + pager.appendChild(this._btn_provision); + pager.appendChild(this._btn_obliterate); + pager.appendChild(this._search_input); pager.appendChild(this._btn_prev); pager.appendChild(this._pager_label); pager.appendChild(this._btn_next); @@ -164,8 +202,11 @@ export class Page extends ZenPage this._row_cache = new Map(); // moduleId → row refs, for in-place DOM updates this._updating = false; this._page = 0; - this._page_size = 50; + this._page_size = 25; this._expanded = new Set(); // moduleIds with open metrics panel + this._pending_highlight = null; // moduleId to navigate+flash after next poll + this._pending_highlight_timer = null; + this._loading = mod_section.tag().classify("pager-loading").text("Loading\u2026").inner(); await this._update(); this._poll_timer = setInterval(() => this._update(), 2000); @@ -178,12 +219,21 @@ export class Page extends ZenPage try { const [stats, status] = await Promise.all([ - new Fetcher().resource("/hub/stats").json(), + new Fetcher().resource("stats", "hub").json(), new Fetcher().resource("/hub/status").json(), ]); this._render_capacity(stats); this._render_modules(status); + if (this._loading) { this._loading.remove(); this._loading = null; } + if (this._pending_highlight && this._module_map.has(this._pending_highlight)) + { + const id = this._pending_highlight; + this._pending_highlight = null; + clearTimeout(this._pending_highlight_timer); + this._navigate_to_module(id); + this._flash_module(id); + } } catch (e) { /* service unavailable */ } finally { this._updating = false; } @@ -198,29 +248,53 @@ export class Page extends ZenPage const max = data.maxInstanceCount || 0; const limit = data.instanceLimit || 0; + // HTTP Requests tile + this._render_http_requests_tile(grid, data.requests); + { const tile = grid.tag().classify("card").classify("stats-tile"); - tile.tag().classify("card-title").text("Active Modules"); + tile.tag().classify("card-title").text("Instances"); const body = tile.tag().classify("tile-metrics"); this._metric(body, Friendly.sep(current), "currently provisioned", true); + this._metric(body, Friendly.sep(max), "high watermark"); + this._metric(body, Friendly.sep(limit), "maximum allowed"); + if (limit > 0) + { + const pct = ((current / limit) * 100).toFixed(0) + "%"; + this._metric(body, pct, "utilization"); + } } + const machine = data.machine || {}; + const limits = data.resource_limits || {}; + if (machine.disk_total_bytes > 0 || machine.memory_total_mib > 0) { - const tile = grid.tag().classify("card").classify("stats-tile"); - tile.tag().classify("card-title").text("Peak Modules"); - const body = tile.tag().classify("tile-metrics"); - this._metric(body, Friendly.sep(max), "high watermark", true); - } + const disk_used = Math.max(0, (machine.disk_total_bytes || 0) - (machine.disk_free_bytes || 0)); + const mem_used = Math.max(0, (machine.memory_total_mib || 0) - (machine.memory_avail_mib || 0)) * 1024 * 1024; + const vmem_used = Math.max(0, (machine.virtual_memory_total_mib || 0) - (machine.virtual_memory_avail_mib || 0)) * 1024 * 1024; + const disk_limit = limits.disk_bytes || 0; + const mem_limit = limits.memory_bytes || 0; + const disk_over = disk_limit > 0 && disk_used > disk_limit; + const mem_over = mem_limit > 0 && mem_used > mem_limit; - { const tile = grid.tag().classify("card").classify("stats-tile"); - tile.tag().classify("card-title").text("Instance Limit"); - const body = tile.tag().classify("tile-metrics"); - this._metric(body, Friendly.sep(limit), "maximum allowed", true); - if (limit > 0) + if (disk_over || mem_over) { tile.inner().setAttribute("data-over", "true"); } + tile.tag().classify("card-title").text("Resources"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + this._metric(left, Friendly.bytes(disk_used), "disk used", true); + this._metric(left, Friendly.bytes(machine.disk_total_bytes), "disk total"); + if (disk_limit > 0) { this._metric(left, Friendly.bytes(disk_limit), "disk limit"); } + + const right = columns.tag().classify("tile-metrics"); + this._metric(right, Friendly.bytes(mem_used), "memory used", true); + this._metric(right, Friendly.bytes(machine.memory_total_mib * 1024 * 1024), "memory total"); + if (mem_limit > 0) { this._metric(right, Friendly.bytes(mem_limit), "memory limit"); } + if (machine.virtual_memory_total_mib > 0) { - const pct = ((current / limit) * 100).toFixed(0) + "%"; - this._metric(body, pct, "utilization"); + this._metric(right, Friendly.bytes(vmem_used), "vmem used", true); + this._metric(right, Friendly.bytes(machine.virtual_memory_total_mib * 1024 * 1024), "vmem total"); } } } @@ -271,7 +345,7 @@ export class Page extends ZenPage row.idx.textContent = i + 1; row.cb.checked = this._selected.has(id); row.dot.setAttribute("data-state", state); - if (state === "deprovisioning") + if (state === "deprovisioning" || state === "obliterating") { row.dot.setAttribute("data-prev-state", prev); } @@ -281,10 +355,20 @@ export class Page extends ZenPage } row.state_text.nodeValue = state; row.port_text.nodeValue = m.port ? String(m.port) : ""; + row.copy_port_btn.style.display = m.port ? "" : "none"; + if (m.state_change_time) + { + const state_label = state.charAt(0).toUpperCase() + state.slice(1); + row.state_since_label.textContent = state_label + " since"; + row.state_age_label.textContent = state_label + " for"; + row.state_since_node.nodeValue = m.state_change_time; + row.state_age_node.nodeValue = Friendly.timespan(Date.now() - new Date(m.state_change_time).getTime()); + } row.btn_open.disabled = state !== "provisioned"; row.btn_hibernate.disabled = !_btn_enabled(state, "hibernate"); row.btn_wake.disabled = !_btn_enabled(state, "wake"); row.btn_deprov.disabled = !_btn_enabled(state, "deprovision"); + row.btn_oblit.disabled = !_btn_enabled(state, "obliterate"); if (m.process_metrics) { @@ -344,6 +428,8 @@ export class Page extends ZenPage id_wrap.style.cssText = "display:inline-flex;align-items:center;font-family:monospace;font-size:14px;"; id_wrap.appendChild(btn_expand); id_wrap.appendChild(document.createTextNode("\u00A0" + id)); + const copy_id_btn = copy_button(id); + id_wrap.appendChild(copy_id_btn); td_id.appendChild(id_wrap); tr.appendChild(td_id); @@ -351,7 +437,7 @@ export class Page extends ZenPage const dot = document.createElement("span"); dot.className = "module-state-dot"; dot.setAttribute("data-state", state); - if (state === "deprovisioning") + if (state === "deprovisioning" || state === "obliterating") { dot.setAttribute("data-prev-state", prev); } @@ -365,27 +451,33 @@ export class Page extends ZenPage td_port.style.cssText = "font-variant-numeric:tabular-nums;"; const port_node = document.createTextNode(port ? String(port) : ""); td_port.appendChild(port_node); + const copy_port_btn = copy_button(() => port_node.nodeValue); + copy_port_btn.style.display = port ? "" : "none"; + td_port.appendChild(copy_port_btn); tr.appendChild(td_port); const td_action = document.createElement("td"); td_action.className = "module-action-cell"; const [wrap_o, btn_o] = _make_action_btn("\u2197", "Open dashboard", () => { - window.open(`${window.location.protocol}//${window.location.hostname}:${port}`, "_blank"); + window.open(`/hub/proxy/${port}/dashboard/`, "_blank"); }); btn_o.disabled = state !== "provisioned"; const [wrap_h, btn_h] = _make_action_btn("\u23F8", "Hibernate", () => this._post_module_action(id, "hibernate").then(() => this._update())); const [wrap_w, btn_w] = _make_action_btn("\u25B6", "Wake", () => this._post_module_action(id, "wake").then(() => this._update())); - const [wrap_d, btn_d] = _make_action_btn("\u2715", "Deprovision", () => this._confirm_deprovision([id])); + const [wrap_d, btn_d] = _make_action_btn("\u23F9", "Deprovision", () => this._confirm_deprovision([id])); + const [wrap_x, btn_x] = _make_action_btn("\uD83D\uDD25", "Obliterate", () => this._confirm_obliterate([id])); btn_h.disabled = !_btn_enabled(state, "hibernate"); btn_w.disabled = !_btn_enabled(state, "wake"); btn_d.disabled = !_btn_enabled(state, "deprovision"); + btn_x.disabled = !_btn_enabled(state, "obliterate"); td_action.appendChild(wrap_h); td_action.appendChild(wrap_w); td_action.appendChild(wrap_d); + td_action.appendChild(wrap_x); td_action.appendChild(wrap_o); tr.appendChild(td_action); - // Build metrics grid from process_metrics keys. + // Build metrics grid: fixed state-time rows followed by process_metrics keys. // Keys are split into two halves and interleaved so the grid fills // top-to-bottom in the left column before continuing in the right column. const metric_nodes = new Map(); @@ -393,6 +485,28 @@ export class Page extends ZenPage metrics_td.colSpan = 6; const metrics_grid = document.createElement("div"); metrics_grid.className = "module-metrics-grid"; + + const _add_fixed_pair = (label, value_str) => { + const label_el = document.createElement("span"); + label_el.className = "module-metrics-label"; + label_el.textContent = label; + const value_node = document.createTextNode(value_str); + const value_el = document.createElement("span"); + value_el.className = "module-metrics-value"; + value_el.appendChild(value_node); + metrics_grid.appendChild(label_el); + metrics_grid.appendChild(value_el); + return { label_el, value_node }; + }; + + const state_label = m.state ? m.state.charAt(0).toUpperCase() + m.state.slice(1) : "State"; + const state_since_str = m.state_change_time || ""; + const state_age_str = m.state_change_time + ? Friendly.timespan(Date.now() - new Date(m.state_change_time).getTime()) + : ""; + const { label_el: state_since_label, value_node: state_since_node } = _add_fixed_pair(state_label + " since", state_since_str); + const { label_el: state_age_label, value_node: state_age_node } = _add_fixed_pair(state_label + " for", state_age_str); + const keys = Object.keys(m.process_metrics || {}); const half = Math.ceil(keys.length / 2); const add_metric_pair = (key) => { @@ -420,7 +534,7 @@ export class Page extends ZenPage metrics_td.appendChild(metrics_grid); metrics_tr.appendChild(metrics_td); - row = { tr, metrics_tr, idx: td_idx, cb, dot, state_text: state_node, port_text: port_node, btn_expand, btn_open: btn_o, btn_hibernate: btn_h, btn_wake: btn_w, btn_deprov: btn_d, metric_nodes }; + row = { tr, metrics_tr, idx: td_idx, cb, dot, state_text: state_node, port_text: port_node, copy_port_btn, btn_expand, btn_open: btn_o, btn_hibernate: btn_h, btn_wake: btn_w, btn_deprov: btn_d, btn_oblit: btn_x, metric_nodes, state_since_node, state_age_node, state_since_label, state_age_label }; this._row_cache.set(id, row); } @@ -530,6 +644,7 @@ export class Page extends ZenPage this._btn_bulk_hibernate.disabled = !this._all_selected_in_state("provisioned"); this._btn_bulk_wake.disabled = !this._all_selected_in_state("hibernated"); this._btn_bulk_deprov.disabled = selected === 0; + this._btn_bulk_oblit.disabled = selected === 0; this._select_all_cb.disabled = total === 0; this._select_all_cb.checked = selected === total && total > 0; @@ -542,6 +657,7 @@ export class Page extends ZenPage this._btn_hibernate_all.disabled = empty; this._btn_wake_all.disabled = empty; this._btn_deprov_all.disabled = empty; + this._btn_oblit_all.disabled = empty; } _on_select_all() @@ -587,6 +703,35 @@ export class Page extends ZenPage .option("Deprovision", () => this._exec_action("deprovision", ids)); } + _confirm_obliterate(ids) + { + const warn = "\uD83D\uDD25 WARNING: This action is irreversible! \uD83D\uDD25"; + const detail = "All local and backend data will be permanently destroyed.\nThis cannot be undone."; + let message; + if (ids.length === 1) + { + const id = ids[0]; + const state = this._module_state(id) || "unknown"; + message = `${warn}\n\n${detail}\n\nModule ID: ${id}\nCurrent state: ${state}`; + } + else + { + message = `${warn}\n\nObliterate ${ids.length} modules.\n\n${detail}`; + } + + new Modal() + .title("\uD83D\uDD25 Obliterate") + .message(message) + .option("Cancel", null) + .option("\uD83D\uDD25 Obliterate", () => this._exec_obliterate(ids)); + } + + async _exec_obliterate(ids) + { + await Promise.allSettled(ids.map(id => fetch(`/hub/modules/${encodeURIComponent(id)}`, { method: "DELETE" }))); + await this._update(); + } + _confirm_all(action, label) { // Capture IDs at modal-open time so action targets the displayed list @@ -611,14 +756,191 @@ export class Page extends ZenPage await fetch(`/hub/modules/${moduleId}/${action}`, { method: "POST" }); } - _metric(parent, value, label, hero = false) + _show_module_input_modal({ title, submit_label, warning, on_submit }) { - const m = parent.tag().classify("tile-metric"); - if (hero) + const MODULE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/; + + const overlay = document.createElement("div"); + overlay.className = "zen_modal"; + + const bg = document.createElement("div"); + bg.className = "zen_modal_bg"; + bg.addEventListener("click", () => overlay.remove()); + overlay.appendChild(bg); + + const dialog = document.createElement("div"); + overlay.appendChild(dialog); + + const title_el = document.createElement("div"); + title_el.className = "zen_modal_title"; + title_el.textContent = title; + dialog.appendChild(title_el); + + const content = document.createElement("div"); + content.className = "zen_modal_message"; + content.style.textAlign = "center"; + + if (warning) { - m.classify("tile-metric-hero"); + const warn = document.createElement("div"); + warn.style.cssText = "color:var(--theme_fail);font-weight:bold;margin-bottom:12px;"; + warn.textContent = warning; + content.appendChild(warn); + } + + const input = document.createElement("input"); + input.type = "text"; + input.placeholder = "module-name"; + input.style.cssText = "width:100%;font-size:14px;padding:8px 12px;"; + content.appendChild(input); + + const error_div = document.createElement("div"); + error_div.style.cssText = "color:var(--theme_fail);font-size:12px;margin-top:8px;min-height:1.2em;"; + content.appendChild(error_div); + + dialog.appendChild(content); + + const buttons = document.createElement("div"); + buttons.className = "zen_modal_buttons"; + + const btn_cancel = document.createElement("div"); + btn_cancel.textContent = "Cancel"; + btn_cancel.addEventListener("click", () => overlay.remove()); + + const btn_submit = document.createElement("div"); + btn_submit.textContent = submit_label; + + buttons.appendChild(btn_cancel); + buttons.appendChild(btn_submit); + dialog.appendChild(buttons); + + let submitting = false; + + const set_submit_enabled = (enabled) => { + btn_submit.style.opacity = enabled ? "" : "0.4"; + btn_submit.style.pointerEvents = enabled ? "" : "none"; + }; + + set_submit_enabled(false); + + const validate = () => { + if (submitting) { return false; } + const val = input.value.trim(); + if (val.length === 0) + { + error_div.textContent = ""; + set_submit_enabled(false); + return false; + } + if (!MODULE_ID_RE.test(val)) + { + error_div.textContent = "Only letters, numbers, and hyphens allowed (must start with a letter or number)"; + set_submit_enabled(false); + return false; + } + error_div.textContent = ""; + set_submit_enabled(true); + return true; + }; + + input.addEventListener("input", validate); + + const submit = async () => { + if (submitting) { return; } + const moduleId = input.value.trim(); + if (!MODULE_ID_RE.test(moduleId)) { return; } + + submitting = true; + set_submit_enabled(false); + error_div.textContent = ""; + + try + { + const ok = await on_submit(moduleId); + if (ok) + { + overlay.remove(); + await this._update(); + return; + } + } + catch (e) + { + error_div.textContent = e.message || "Request failed"; + } + submitting = false; + set_submit_enabled(true); + }; + + btn_submit.addEventListener("click", submit); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && validate()) { submit(); } + if (e.key === "Escape") { overlay.remove(); } + }); + + document.body.appendChild(overlay); + input.focus(); + + return { error_div }; + } + + _show_provision_modal() + { + const { error_div } = this._show_module_input_modal({ + title: "Provision Module", + submit_label: "Provision", + on_submit: async (moduleId) => { + const resp = await fetch(`/hub/modules/${encodeURIComponent(moduleId)}/provision`, { method: "POST" }); + if (!resp.ok) + { + const msg = await resp.text(); + error_div.textContent = msg || ("HTTP " + resp.status); + return false; + } + // Endpoint returns compact binary (CbObjectWriter), not text + if (resp.status === 200 || resp.status === 202) + { + this._pending_highlight = moduleId; + this._pending_highlight_timer = setTimeout(() => { this._pending_highlight = null; }, 5000); + } + return true; + } + }); + } + + _show_obliterate_modal() + { + const { error_div } = this._show_module_input_modal({ + title: "\uD83D\uDD25 Obliterate Module", + submit_label: "\uD83D\uDD25 Obliterate", + warning: "\uD83D\uDD25 WARNING: This action is irreversible! \uD83D\uDD25\nAll local and backend data will be permanently destroyed.", + on_submit: async (moduleId) => { + const resp = await fetch(`/hub/modules/${encodeURIComponent(moduleId)}`, { method: "DELETE" }); + if (resp.ok) + { + return true; + } + const msg = await resp.text(); + error_div.textContent = msg || ("HTTP " + resp.status); + return false; + } + }); + } + + _navigate_to_module(moduleId) + { + const idx = this._modules_data.findIndex(m => m.moduleId === moduleId); + if (idx >= 0) + { + this._page = Math.floor(idx / this._page_size); + this._render_page(); } - m.tag().classify("metric-value").text(value); - m.tag().classify("metric-label").text(label); } + + _flash_module(id) + { + const cached = this._row_cache.get(id); + if (cached) { flash_highlight(cached.tr); } + } + } diff --git a/src/zenserver/frontend/html/pages/objectstore.js b/src/zenserver/frontend/html/pages/objectstore.js index 69e0a91b3..6b4890614 100644 --- a/src/zenserver/frontend/html/pages/objectstore.js +++ b/src/zenserver/frontend/html/pages/objectstore.js @@ -30,13 +30,16 @@ export class Page extends ZenPage { try { - const data = await new Fetcher().resource("/obj/").json(); - this._render(data); + const [data, stats] = await Promise.all([ + new Fetcher().resource("/obj/").json(), + new Fetcher().resource("stats", "obj").json().catch(() => null), + ]); + this._render(data, stats); } catch (e) { /* service unavailable */ } } - _render(data) + _render(data, stats) { const buckets = data.buckets || []; @@ -53,32 +56,17 @@ export class Page extends ZenPage const total_objects = buckets.reduce((sum, b) => sum + (b.object_count || 0), 0); const total_size = buckets.reduce((sum, b) => sum + (b.size || 0), 0); - { - const tile = grid.tag().classify("card").classify("stats-tile"); - tile.tag().classify("card-title").text("Buckets"); - const body = tile.tag().classify("tile-metrics"); - this._metric(body, Friendly.sep(buckets.length), "total", true); - } - - { - const tile = grid.tag().classify("card").classify("stats-tile"); - tile.tag().classify("card-title").text("Objects"); - const body = tile.tag().classify("tile-metrics"); - this._metric(body, Friendly.sep(total_objects), "total", true); - } + // HTTP Requests tile + this._render_http_requests_tile(grid, stats && stats.requests); { const tile = grid.tag().classify("card").classify("stats-tile"); - tile.tag().classify("card-title").text("Storage"); + tile.tag().classify("card-title").text("Object Store"); const body = tile.tag().classify("tile-metrics"); - this._metric(body, Friendly.bytes(total_size), "total size", true); - } - - { - const tile = grid.tag().classify("card").classify("stats-tile"); - tile.tag().classify("card-title").text("Served"); - const body = tile.tag().classify("tile-metrics"); - this._metric(body, Friendly.bytes(data.total_bytes_served || 0), "total bytes served", true); + this._metric(body, Friendly.sep(buckets.length), "buckets", true); + this._metric(body, Friendly.sep(total_objects), "objects"); + this._metric(body, Friendly.bytes(total_size), "storage"); + this._metric(body, Friendly.bytes(data.total_bytes_served || 0), "bytes served"); } } @@ -219,14 +207,4 @@ export class Page extends ZenPage } } - _metric(parent, value, label, hero = false) - { - const m = parent.tag().classify("tile-metric"); - if (hero) - { - m.classify("tile-metric-hero"); - } - m.tag().classify("metric-value").text(value); - m.tag().classify("metric-label").text(label); - } } diff --git a/src/zenserver/frontend/html/pages/orchestrator.js b/src/zenserver/frontend/html/pages/orchestrator.js index 4a9290a3c..d11306998 100644 --- a/src/zenserver/frontend/html/pages/orchestrator.js +++ b/src/zenserver/frontend/html/pages/orchestrator.js @@ -5,7 +5,7 @@ import { ZenPage } from "./page.js" import { Fetcher } from "../util/fetcher.js" import { Friendly } from "../util/friendly.js" -import { Table } from "../util/widgets.js" +import { Table, add_copy_button } from "../util/widgets.js" //////////////////////////////////////////////////////////////////////////////// export class Page extends ZenPage @@ -14,6 +14,14 @@ export class Page extends ZenPage { this.set_title("orchestrator"); + // Provisioner section (hidden until data arrives) + this._prov_section = this._collapsible_section("Provisioner"); + this._prov_section._parent.inner().style.display = "none"; + this._prov_grid = null; + this._prov_target_dirty = false; + this._prov_commit_timer = null; + this._prov_last_target = null; + // Agents section const agents_section = this._collapsible_section("Compute Agents"); this._agents_host = agents_section; @@ -46,48 +54,16 @@ export class Page extends ZenPage this._connect_ws(); } - _collapsible_section(name) - { - const section = this.add_section(name); - const container = section._parent.inner(); - const heading = container.firstElementChild; - - heading.style.cursor = "pointer"; - heading.style.userSelect = "none"; - - const indicator = document.createElement("span"); - indicator.textContent = " \u25BC"; - indicator.style.fontSize = "0.7em"; - heading.appendChild(indicator); - - let collapsed = false; - heading.addEventListener("click", (e) => { - if (e.target !== heading && e.target !== indicator) - { - return; - } - collapsed = !collapsed; - indicator.textContent = collapsed ? " \u25B6" : " \u25BC"; - let sibling = heading.nextElementSibling; - while (sibling) - { - sibling.style.display = collapsed ? "none" : ""; - sibling = sibling.nextElementSibling; - } - }); - - return section; - } - async _fetch_all() { try { - const [agents, history, clients, client_history] = await Promise.all([ + const [agents, history, clients, client_history, prov] = await Promise.all([ new Fetcher().resource("/orch/agents").json(), new Fetcher().resource("/orch/history").param("limit", "50").json().catch(() => null), new Fetcher().resource("/orch/clients").json().catch(() => null), new Fetcher().resource("/orch/clients/history").param("limit", "50").json().catch(() => null), + new Fetcher().resource("/orch/provisioner/status").json().catch(() => null), ]); this._render_agents(agents); @@ -103,6 +79,7 @@ export class Page extends ZenPage { this._render_client_history(client_history.client_events || []); } + this._render_provisioner(prov); } catch (e) { /* service unavailable */ } } @@ -142,6 +119,7 @@ export class Page extends ZenPage { this._render_client_history(data.client_events); } + this._render_provisioner(data.provisioner); } catch (e) { /* ignore parse errors */ } }; @@ -189,7 +167,7 @@ export class Page extends ZenPage return; } - let totalCpus = 0, totalWeightedCpu = 0; + let totalCpus = 0, activeCpus = 0, totalWeightedCpu = 0; let totalMemUsed = 0, totalMemTotal = 0; let totalQueues = 0, totalPending = 0, totalRunning = 0, totalCompleted = 0; let totalRecv = 0, totalSent = 0; @@ -206,8 +184,14 @@ export class Page extends ZenPage const completed = w.actions_completed || 0; const recv = w.bytes_received || 0; const sent = w.bytes_sent || 0; + const provisioner = w.provisioner || ""; + const isProvisioned = provisioner !== ""; totalCpus += cpus; + if (w.provisioner_status === "active") + { + activeCpus += cpus; + } if (cpus > 0 && typeof cpuUsage === "number") { totalWeightedCpu += cpuUsage * cpus; @@ -242,12 +226,49 @@ export class Page extends ZenPage cell.inner().textContent = ""; cell.tag("a").text(hostname).attr("href", w.uri + "/dashboard/compute/").attr("target", "_blank"); } + + // Visual treatment based on provisioner status + const provStatus = w.provisioner_status || ""; + if (!isProvisioned) + { + row.inner().style.opacity = "0.45"; + } + else + { + const hostCell = row.get_cell(0); + const el = hostCell.inner(); + const badge = document.createElement("span"); + const badgeBase = "display:inline-block;margin-left:6px;padding:1px 5px;border-radius:8px;" + + "font-size:9px;font-weight:600;color:#fff;vertical-align:middle;"; + + if (provStatus === "draining") + { + badge.textContent = "draining"; + badge.style.cssText = badgeBase + "background:var(--theme_warn);"; + row.inner().style.opacity = "0.6"; + } + else if (provStatus === "active") + { + badge.textContent = provisioner; + badge.style.cssText = badgeBase + "background:#8957e5;"; + } + else + { + badge.textContent = "deallocated"; + badge.style.cssText = badgeBase + "background:var(--theme_fail);"; + row.inner().style.opacity = "0.45"; + } + el.appendChild(badge); + } } - // Total row + // Total row — show active / total in CPUs column + const cpuLabel = activeCpus < totalCpus + ? Friendly.sep(activeCpus) + " / " + Friendly.sep(totalCpus) + : Friendly.sep(totalCpus); const total = this._agents_table.add_row( "TOTAL", - Friendly.sep(totalCpus), + cpuLabel, "", totalMemTotal > 0 ? Friendly.bytes(totalMemUsed) + " / " + Friendly.bytes(totalMemTotal) : "-", Friendly.sep(totalQueues), @@ -277,12 +298,13 @@ export class Page extends ZenPage for (const c of clients) { - this._clients_table.add_row( + const crow = this._clients_table.add_row( c.id || "", c.hostname || "", c.address || "", this._format_last_seen(c.dt), ); + if (c.id) { add_copy_button(crow.get_cell(0).inner(), c.id); } } } @@ -338,6 +360,154 @@ export class Page extends ZenPage } } + _render_provisioner(prov) + { + const container = this._prov_section._parent.inner(); + + if (!prov || !prov.name) + { + container.style.display = "none"; + return; + } + container.style.display = ""; + + if (!this._prov_grid) + { + this._prov_grid = this._prov_section.tag().classify("grid").classify("stats-tiles"); + this._prov_tiles = {}; + + // Target cores tile with editable input + const target_tile = this._prov_grid.tag().classify("card").classify("stats-tile"); + target_tile.tag().classify("card-title").text("Target Cores"); + const target_body = target_tile.tag().classify("tile-metrics"); + const target_m = target_body.tag().classify("tile-metric").classify("tile-metric-hero"); + const input = document.createElement("input"); + input.type = "number"; + input.min = "0"; + input.style.cssText = "width:100px;padding:4px 8px;border:1px solid var(--theme_g2);border-radius:4px;" + + "background:var(--theme_g4);color:var(--theme_bright);font-size:20px;font-weight:600;text-align:right;"; + target_m.inner().appendChild(input); + target_m.tag().classify("metric-label").text("target"); + this._prov_tiles.target_input = input; + + input.addEventListener("focus", () => { this._prov_target_dirty = true; }); + input.addEventListener("input", () => { + this._prov_target_dirty = true; + if (this._prov_commit_timer) + { + clearTimeout(this._prov_commit_timer); + } + this._prov_commit_timer = setTimeout(() => this._commit_provisioner_target(), 800); + }); + input.addEventListener("keydown", (e) => { + if (e.key === "Enter") + { + if (this._prov_commit_timer) + { + clearTimeout(this._prov_commit_timer); + } + this._commit_provisioner_target(); + input.blur(); + } + }); + input.addEventListener("blur", () => { + if (this._prov_commit_timer) + { + clearTimeout(this._prov_commit_timer); + } + this._commit_provisioner_target(); + }); + + // Active cores + const active_tile = this._prov_grid.tag().classify("card").classify("stats-tile"); + active_tile.tag().classify("card-title").text("Active Cores"); + const active_body = active_tile.tag().classify("tile-metrics"); + this._prov_tiles.active = active_body; + + // Estimated cores + const est_tile = this._prov_grid.tag().classify("card").classify("stats-tile"); + est_tile.tag().classify("card-title").text("Estimated Cores"); + const est_body = est_tile.tag().classify("tile-metrics"); + this._prov_tiles.estimated = est_body; + + // Agents + const agents_tile = this._prov_grid.tag().classify("card").classify("stats-tile"); + agents_tile.tag().classify("card-title").text("Agents"); + const agents_body = agents_tile.tag().classify("tile-metrics"); + this._prov_tiles.agents = agents_body; + + // Draining + const drain_tile = this._prov_grid.tag().classify("card").classify("stats-tile"); + drain_tile.tag().classify("card-title").text("Draining"); + const drain_body = drain_tile.tag().classify("tile-metrics"); + this._prov_tiles.draining = drain_body; + } + + // Update values + const input = this._prov_tiles.target_input; + if (!this._prov_target_dirty && document.activeElement !== input) + { + input.value = prov.target_cores; + } + this._prov_last_target = prov.target_cores; + + // Re-render metric tiles (clear and recreate content) + for (const key of ["active", "estimated", "agents", "draining"]) + { + this._prov_tiles[key].inner().innerHTML = ""; + } + this._metric(this._prov_tiles.active, Friendly.sep(prov.active_cores), "cores", true); + this._metric(this._prov_tiles.estimated, Friendly.sep(prov.estimated_cores), "cores", true); + this._metric(this._prov_tiles.agents, Friendly.sep(prov.agents), "active", true); + this._metric(this._prov_tiles.draining, Friendly.sep(prov.agents_draining || 0), "agents", true); + } + + async _commit_provisioner_target() + { + const input = this._prov_tiles?.target_input; + if (!input || this._prov_committing) + { + return; + } + const value = parseInt(input.value, 10); + if (isNaN(value) || value < 0) + { + return; + } + if (value === this._prov_last_target) + { + this._prov_target_dirty = false; + return; + } + this._prov_committing = true; + try + { + const resp = await fetch("/orch/provisioner/target", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ target_cores: value }), + }); + if (resp.ok) + { + this._prov_target_dirty = false; + console.log("Target cores set to", value); + } + else + { + const text = await resp.text(); + console.error("Failed to set target cores: HTTP", resp.status, text); + } + } + catch (e) + { + console.error("Failed to set target cores:", e); + } + finally + { + this._prov_committing = false; + } + } + _metric(parent, value, label, hero = false) { const m = parent.tag().classify("tile-metric"); diff --git a/src/zenserver/frontend/html/pages/page.js b/src/zenserver/frontend/html/pages/page.js index d969d651d..3653abb0e 100644 --- a/src/zenserver/frontend/html/pages/page.js +++ b/src/zenserver/frontend/html/pages/page.js @@ -4,6 +4,27 @@ import { WidgetHost } from "../util/widgets.js" import { Fetcher } from "../util/fetcher.js" +import { Friendly } from "../util/friendly.js" + +function _deep_merge_stats(base, update) +{ + const result = Object.assign({}, base); + for (const key of Object.keys(update)) + { + const bv = result[key]; + const uv = update[key]; + if (uv && typeof uv === "object" && !Array.isArray(uv) + && bv && typeof bv === "object" && !Array.isArray(bv)) + { + result[key] = _deep_merge_stats(bv, uv); + } + else + { + result[key] = uv; + } + } + return result; +} //////////////////////////////////////////////////////////////////////////////// export class PageBase extends WidgetHost @@ -148,8 +169,10 @@ export class ZenPage extends PageBase const service_dashboards = [ { base_uri: "/sessions/", label: "Sessions", href: "/dashboard/?page=sessions" }, { base_uri: "/z$/", label: "Cache", href: "/dashboard/?page=cache" }, + { base_uri: "/builds/", label: "Build Store", href: "/dashboard/?page=builds" }, { base_uri: "/prj/", label: "Projects", href: "/dashboard/?page=projects" }, { base_uri: "/obj/", label: "Object Store", href: "/dashboard/?page=objectstore" }, + { base_uri: "/ws/", label: "Workspaces", href: "/dashboard/?page=workspaces" }, { base_uri: "/compute/", label: "Compute", href: "/dashboard/?page=compute" }, { base_uri: "/orch/", label: "Orchestrator", href: "/dashboard/?page=orchestrator" }, { base_uri: "/hub/", label: "Hub", href: "/dashboard/?page=hub" }, @@ -265,4 +288,113 @@ export class ZenPage extends PageBase new_crumb(auto_name); } + + _metric(parent, value, label, hero = false) + { + const m = parent.tag().classify("tile-metric"); + if (hero) + { + m.classify("tile-metric-hero"); + } + m.tag().classify("metric-value").text(value); + m.tag().classify("metric-label").text(label); + } + + _render_http_requests_tile(grid, req, bad_requests = undefined) + { + req = req || {}; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("HTTP Requests"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + const reqData = req.requests || req; + this._metric(left, Friendly.sep(reqData.count || 0), "total requests", true); + if (reqData.rate_mean > 0) + { + this._metric(left, Friendly.sep(reqData.rate_mean, 1) + "/s", "req/sec (mean)"); + } + if (reqData.rate_1 > 0) + { + this._metric(left, Friendly.sep(reqData.rate_1, 1) + "/s", "req/sec (1m)"); + } + if (reqData.rate_5 > 0) + { + this._metric(left, Friendly.sep(reqData.rate_5, 1) + "/s", "req/sec (5m)"); + } + if (reqData.rate_15 > 0) + { + this._metric(left, Friendly.sep(reqData.rate_15, 1) + "/s", "req/sec (15m)"); + } + if (bad_requests !== undefined) + { + this._metric(left, Friendly.sep(bad_requests), "bad requests"); + } + + const right = columns.tag().classify("tile-metrics"); + this._metric(right, Friendly.duration(reqData.t_avg || 0), "avg latency", true); + if (reqData.t_p75) + { + this._metric(right, Friendly.duration(reqData.t_p75), "p75"); + } + if (reqData.t_p95) + { + this._metric(right, Friendly.duration(reqData.t_p95), "p95"); + } + if (reqData.t_p99) + { + this._metric(right, Friendly.duration(reqData.t_p99), "p99"); + } + if (reqData.t_p999) + { + this._metric(right, Friendly.duration(reqData.t_p999), "p999"); + } + if (reqData.t_max) + { + this._metric(right, Friendly.duration(reqData.t_max), "max"); + } + } + + _merge_last_stats(stats) + { + if (this._last_stats) + { + stats = _deep_merge_stats(this._last_stats, stats); + } + this._last_stats = stats; + return stats; + } + + _collapsible_section(name) + { + const section = this.add_section(name); + const container = section._parent.inner(); + const heading = container.firstElementChild; + + heading.style.cursor = "pointer"; + heading.style.userSelect = "none"; + + const indicator = document.createElement("span"); + indicator.textContent = " \u25BC"; + indicator.style.fontSize = "0.7em"; + heading.appendChild(indicator); + + let collapsed = false; + heading.addEventListener("click", (e) => { + if (e.target !== heading && e.target !== indicator) + { + return; + } + collapsed = !collapsed; + indicator.textContent = collapsed ? " \u25B6" : " \u25BC"; + let sibling = heading.nextElementSibling; + while (sibling) + { + sibling.style.display = collapsed ? "none" : ""; + sibling = sibling.nextElementSibling; + } + }); + + return section; + } } diff --git a/src/zenserver/frontend/html/pages/projects.js b/src/zenserver/frontend/html/pages/projects.js index a3c0d1555..2e76a80f1 100644 --- a/src/zenserver/frontend/html/pages/projects.js +++ b/src/zenserver/frontend/html/pages/projects.js @@ -6,7 +6,7 @@ import { ZenPage } from "./page.js" import { Fetcher } from "../util/fetcher.js" import { Friendly } from "../util/friendly.js" import { Modal } from "../util/modal.js" -import { Table, Toolbar } from "../util/widgets.js" +import { Table, Toolbar, Pager, add_copy_button } from "../util/widgets.js" //////////////////////////////////////////////////////////////////////////////// export class Page extends ZenPage @@ -39,8 +39,6 @@ export class Page extends ZenPage // Projects list var section = this._collapsible_section("Projects"); - section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all()); - var columns = [ "name", "project dir", @@ -51,51 +49,21 @@ export class Page extends ZenPage this._project_table = section.add_widget(Table, columns, Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric); - var projects = await new Fetcher().resource("/prj/list").json(); - projects.sort((a, b) => (b.LastAccessTime || 0) - (a.LastAccessTime || 0)); - - for (const project of projects) - { - var row = this._project_table.add_row( - "", - "", - "", - "", - ); - - var cell = row.get_cell(0); - cell.tag().text(project.Id).on_click(() => this.view_project(project.Id)); - - if (project.ProjectRootDir) - { - row.get_cell(1).tag("a").text(project.ProjectRootDir) - .attr("href", "vscode://" + project.ProjectRootDir.replace(/\\/g, "/")); - } - if (project.EngineRootDir) - { - row.get_cell(2).tag("a").text(project.EngineRootDir) - .attr("href", "vscode://" + project.EngineRootDir.replace(/\\/g, "/")); - } - - cell = row.get_cell(-1); - const action_tb = new Toolbar(cell, true).left(); - action_tb.add("view").on_click(() => this.view_project(project.Id)); - action_tb.add("drop").on_click(() => this.drop_project(project.Id)); - - row.attr("zs_name", project.Id); - - // Fetch project details to get oplog count - new Fetcher().resource("prj", project.Id).json().then((info) => { - const oplogs = info["oplogs"] || []; - row.get_cell(3).text(Friendly.sep(oplogs.length)).style("textAlign", "right"); - // Right-align the corresponding header cell - const header = this._project_table._element.firstElementChild; - if (header && header.children[4]) - { - header.children[4].style.textAlign = "right"; - } - }).catch(() => {}); - } + this._project_pager = new Pager(section, 25, () => this._render_projects_page(), + Pager.make_search_fn(() => this._projects_data, p => p.Id)); + const drop_link = document.createElement("span"); + drop_link.className = "dropall zen_action"; + drop_link.style.position = "static"; + drop_link.textContent = "drop-all"; + drop_link.addEventListener("click", () => this.drop_all()); + this._project_pager.prepend(drop_link); + + const loading = Pager.loading(section); + this._projects_data = await new Fetcher().resource("/prj/list").json(); + this._projects_data.sort((a, b) => a.Id.localeCompare(b.Id)); + this._project_pager.set_total(this._projects_data.length); + this._render_projects_page(); + loading.remove(); // Project detail area (inside projects section so it collapses together) this._project_host = section; @@ -110,39 +78,6 @@ export class Page extends ZenPage } } - _collapsible_section(name) - { - const section = this.add_section(name); - const container = section._parent.inner(); - const heading = container.firstElementChild; - - heading.style.cursor = "pointer"; - heading.style.userSelect = "none"; - - const indicator = document.createElement("span"); - indicator.textContent = " \u25BC"; - indicator.style.fontSize = "0.7em"; - heading.appendChild(indicator); - - let collapsed = false; - heading.addEventListener("click", (e) => { - if (e.target !== heading && e.target !== indicator) - { - return; - } - collapsed = !collapsed; - indicator.textContent = collapsed ? " \u25B6" : " \u25BC"; - let sibling = heading.nextElementSibling; - while (sibling) - { - sibling.style.display = collapsed ? "none" : ""; - sibling = sibling.nextElementSibling; - } - }); - - return section; - } - _clear_param(name) { this._params.delete(name); @@ -153,101 +88,59 @@ export class Page extends ZenPage _render_stats(stats) { + stats = this._merge_last_stats(stats); const safe = (obj, path) => path.split(".").reduce((a, b) => a && a[b], obj); const grid = this._stats_grid; grid.inner().innerHTML = ""; // HTTP Requests tile - { - const req = safe(stats, "requests"); - if (req) - { - const tile = grid.tag().classify("card").classify("stats-tile"); - tile.tag().classify("card-title").text("HTTP Requests"); - const columns = tile.tag().classify("tile-columns"); - - const left = columns.tag().classify("tile-metrics"); - const reqData = req.requests || req; - this._metric(left, Friendly.sep(safe(stats, "store.requestcount") || 0), "total requests", true); - if (reqData.rate_mean > 0) - { - this._metric(left, Friendly.sep(reqData.rate_mean, 1) + "/s", "req/sec (mean)"); - } - if (reqData.rate_1 > 0) - { - this._metric(left, Friendly.sep(reqData.rate_1, 1) + "/s", "req/sec (1m)"); - } - const badRequests = safe(stats, "store.badrequestcount") || 0; - this._metric(left, Friendly.sep(badRequests), "bad requests"); - - const right = columns.tag().classify("tile-metrics"); - this._metric(right, Friendly.duration(reqData.t_avg || 0), "avg latency", true); - if (reqData.t_p75) - { - this._metric(right, Friendly.duration(reqData.t_p75), "p75"); - } - if (reqData.t_p95) - { - this._metric(right, Friendly.duration(reqData.t_p95), "p95"); - } - if (reqData.t_p99) - { - this._metric(right, Friendly.duration(reqData.t_p99), "p99"); - } - } - } + this._render_http_requests_tile(grid, safe(stats, "requests"), safe(stats, "store.badrequestcount") || 0); // Store Operations tile { - const store = safe(stats, "store"); - if (store) - { - const tile = grid.tag().classify("card").classify("stats-tile"); - tile.tag().classify("card-title").text("Store Operations"); - const columns = tile.tag().classify("tile-columns"); - - const left = columns.tag().classify("tile-metrics"); - const proj = store.project || {}; - this._metric(left, Friendly.sep(proj.readcount || 0), "project reads", true); - this._metric(left, Friendly.sep(proj.writecount || 0), "project writes"); - this._metric(left, Friendly.sep(proj.deletecount || 0), "project deletes"); - - const right = columns.tag().classify("tile-metrics"); - const oplog = store.oplog || {}; - this._metric(right, Friendly.sep(oplog.readcount || 0), "oplog reads", true); - this._metric(right, Friendly.sep(oplog.writecount || 0), "oplog writes"); - this._metric(right, Friendly.sep(oplog.deletecount || 0), "oplog deletes"); - } + const store = safe(stats, "store") || {}; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Store Operations"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + const proj = store.project || {}; + this._metric(left, Friendly.sep(proj.readcount || 0), "project reads", true); + this._metric(left, Friendly.sep(proj.writecount || 0), "project writes"); + this._metric(left, Friendly.sep(proj.deletecount || 0), "project deletes"); + + const right = columns.tag().classify("tile-metrics"); + const oplog = store.oplog || {}; + this._metric(right, Friendly.sep(oplog.readcount || 0), "oplog reads", true); + this._metric(right, Friendly.sep(oplog.writecount || 0), "oplog writes"); + this._metric(right, Friendly.sep(oplog.deletecount || 0), "oplog deletes"); } // Op & Chunk tile { - const store = safe(stats, "store"); - if (store) - { - const tile = grid.tag().classify("card").classify("stats-tile"); - tile.tag().classify("card-title").text("Ops & Chunks"); - const columns = tile.tag().classify("tile-columns"); - - const left = columns.tag().classify("tile-metrics"); - const op = store.op || {}; - const opTotal = (op.hitcount || 0) + (op.misscount || 0); - const opRatio = opTotal > 0 ? (((op.hitcount || 0) / opTotal) * 100).toFixed(1) + "%" : "-"; - this._metric(left, opRatio, "op hit ratio", true); - this._metric(left, Friendly.sep(op.hitcount || 0), "op hits"); - this._metric(left, Friendly.sep(op.misscount || 0), "op misses"); - this._metric(left, Friendly.sep(op.writecount || 0), "op writes"); - - const right = columns.tag().classify("tile-metrics"); - const chunk = store.chunk || {}; - const chunkTotal = (chunk.hitcount || 0) + (chunk.misscount || 0); - const chunkRatio = chunkTotal > 0 ? (((chunk.hitcount || 0) / chunkTotal) * 100).toFixed(1) + "%" : "-"; - this._metric(right, chunkRatio, "chunk hit ratio", true); - this._metric(right, Friendly.sep(chunk.hitcount || 0), "chunk hits"); - this._metric(right, Friendly.sep(chunk.misscount || 0), "chunk misses"); - this._metric(right, Friendly.sep(chunk.writecount || 0), "chunk writes"); - } + const store = safe(stats, "store") || {}; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Ops & Chunks"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + const op = store.op || {}; + const opTotal = (op.hitcount || 0) + (op.misscount || 0); + const opRatio = opTotal > 0 ? (((op.hitcount || 0) / opTotal) * 100).toFixed(1) + "%" : "-"; + this._metric(left, opRatio, "op hit ratio", true); + this._metric(left, Friendly.sep(op.hitcount || 0), "op hits"); + this._metric(left, Friendly.sep(op.misscount || 0), "op misses"); + this._metric(left, Friendly.sep(op.writecount || 0), "op writes"); + + const right = columns.tag().classify("tile-metrics"); + const chunk = store.chunk || {}; + const chunkTotal = (chunk.hitcount || 0) + (chunk.misscount || 0); + const chunkRatio = chunkTotal > 0 ? (((chunk.hitcount || 0) / chunkTotal) * 100).toFixed(1) + "%" : "-"; + this._metric(right, chunkRatio, "chunk hit ratio", true); + this._metric(right, Friendly.sep(chunk.hitcount || 0), "chunk hits"); + this._metric(right, Friendly.sep(chunk.misscount || 0), "chunk misses"); + this._metric(right, Friendly.sep(chunk.writecount || 0), "chunk writes"); } // Storage tile @@ -268,15 +161,55 @@ export class Page extends ZenPage } } - _metric(parent, value, label, hero = false) + _render_projects_page() { - const m = parent.tag().classify("tile-metric"); - if (hero) + const { start, end } = this._project_pager.page_range(); + this._project_table.clear(start); + for (let i = start; i < end; i++) + { + const project = this._projects_data[i]; + const row = this._project_table.add_row( + "", + "", + "", + "", + ); + + const cell = row.get_cell(0); + cell.tag().text(project.Id).on_click(() => this.view_project(project.Id)); + add_copy_button(cell.inner(), project.Id); + + if (project.ProjectRootDir) + { + row.get_cell(1).tag("a").text(project.ProjectRootDir) + .attr("href", "vscode://" + project.ProjectRootDir.replace(/\\/g, "/")); + add_copy_button(row.get_cell(1).inner(), project.ProjectRootDir); + } + if (project.EngineRootDir) + { + row.get_cell(2).tag("a").text(project.EngineRootDir) + .attr("href", "vscode://" + project.EngineRootDir.replace(/\\/g, "/")); + add_copy_button(row.get_cell(2).inner(), project.EngineRootDir); + } + + const action_cell = row.get_cell(-1); + const action_tb = new Toolbar(action_cell, true).left(); + action_tb.add("view").on_click(() => this.view_project(project.Id)); + action_tb.add("drop").on_click(() => this.drop_project(project.Id)); + + row.attr("zs_name", project.Id); + + new Fetcher().resource("prj", project.Id).json().then((info) => { + const oplogs = info["oplogs"] || []; + row.get_cell(3).text(Friendly.sep(oplogs.length)).style("textAlign", "right"); + }).catch(() => {}); + } + + const header = this._project_table._element.firstElementChild; + if (header && header.children[4]) { - m.classify("tile-metric-hero"); + header.children[4].style.textAlign = "right"; } - m.tag().classify("metric-value").text(value); - m.tag().classify("metric-label").text(label); } async view_project(project_id) @@ -399,10 +332,9 @@ export class Page extends ZenPage async drop_all() { const drop = async () => { - for (const row of this._project_table) + for (const project of this._projects_data || []) { - const project_id = row.attr("zs_name"); - await new Fetcher().resource("prj", project_id).delete(); + await new Fetcher().resource("prj", project.Id).delete(); } this.reload(); }; diff --git a/src/zenserver/frontend/html/pages/start.js b/src/zenserver/frontend/html/pages/start.js index df70ea2f4..d06040b2f 100644 --- a/src/zenserver/frontend/html/pages/start.js +++ b/src/zenserver/frontend/html/pages/start.js @@ -6,7 +6,7 @@ import { ZenPage } from "./page.js" import { Fetcher } from "../util/fetcher.js" import { Friendly } from "../util/friendly.js" import { Modal } from "../util/modal.js" -import { Table, Toolbar } from "../util/widgets.js" +import { Table, Toolbar, Pager } from "../util/widgets.js" //////////////////////////////////////////////////////////////////////////////// export class Page extends ZenPage @@ -36,59 +36,54 @@ export class Page extends ZenPage all_stats[provider] = await new Fetcher().resource("stats", provider).json(); })); + this._http_panel = section.tag().classify("card").classify("stats-tile").classify("stats-http-panel"); + this._http_panel.inner().addEventListener("click", () => { window.location = "?page=metrics"; }); + this._http_panel.tag().classify("http-title").text("HTTP"); + const req_section = this._http_panel.tag().classify("http-section"); + req_section.tag().classify("http-section-label").text("Requests"); + this._http_req_metrics = req_section.tag().classify("tile-metrics"); + const ws_section = this._http_panel.tag().classify("http-section"); + ws_section.tag().classify("http-section-label").text("Websockets"); + this._http_ws_metrics = ws_section.tag().classify("tile-metrics"); this._stats_grid = section.tag().classify("grid").classify("stats-tiles"); this._safe_lookup = safe_lookup; this._render_stats(all_stats); // project list - var project_table = null; if (available.has("/prj/")) { var section = this.add_section("Cooked Projects"); - section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all("projects")); - var columns = [ "name", "project_dir", "engine_dir", "actions", ]; - project_table = section.add_widget(Table, columns); - - var projects = await new Fetcher().resource("/prj/list").json(); - projects.sort((a, b) => (b.LastAccessTime || 0) - (a.LastAccessTime || 0)); - projects = projects.slice(0, 25); - projects.sort((a, b) => a.Id.localeCompare(b.Id)); - - for (const project of projects) - { - var row = project_table.add_row( - "", - project.ProjectRootDir, - project.EngineRootDir, - ); - - var cell = row.get_cell(0); - cell.tag().text(project.Id).on_click((x) => this.view_project(x), project.Id); - - var cell = row.get_cell(-1); - var action_tb = new Toolbar(cell, true); - action_tb.left().add("view").on_click((x) => this.view_project(x), project.Id); - action_tb.left().add("drop").on_click((x) => this.drop_project(x), project.Id); - - row.attr("zs_name", project.Id); - } + this._project_table = section.add_widget(Table, columns); + + this._project_pager = new Pager(section, 25, () => this._render_projects_page(), + Pager.make_search_fn(() => this._projects_data, p => p.Id)); + const drop_link = document.createElement("span"); + drop_link.className = "dropall zen_action"; + drop_link.style.position = "static"; + drop_link.textContent = "drop-all"; + drop_link.addEventListener("click", () => this.drop_all("projects")); + this._project_pager.prepend(drop_link); + + const prj_loading = Pager.loading(section); + this._projects_data = await new Fetcher().resource("/prj/list").json(); + this._projects_data.sort((a, b) => a.Id.localeCompare(b.Id)); + this._project_pager.set_total(this._projects_data.length); + this._render_projects_page(); + prj_loading.remove(); } // cache - var cache_table = null; if (available.has("/z$/")) { var section = this.add_section("Cache"); - section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all("z$")); - var columns = [ "namespace", "dir", @@ -98,31 +93,30 @@ export class Page extends ZenPage "size mem", "actions", ]; - var zcache_info = await new Fetcher().resource("/z$/").json(); - cache_table = section.add_widget(Table, columns, Table.Flag_FitLeft|Table.Flag_PackRight); - for (const namespace of zcache_info["Namespaces"] || []) - { - new Fetcher().resource(`/z$/${namespace}/`).json().then((data) => { - const row = cache_table.add_row( - "", - data["Configuration"]["RootDir"], - data["Buckets"].length, - data["EntryCount"], - Friendly.bytes(data["StorageSize"].DiskSize), - Friendly.bytes(data["StorageSize"].MemorySize) - ); - var cell = row.get_cell(0); - cell.tag().text(namespace).on_click(() => this.view_zcache(namespace)); - row.get_cell(1).tag().text(namespace); - - cell = row.get_cell(-1); - const action_tb = new Toolbar(cell, true); - action_tb.left().add("view").on_click(() => this.view_zcache(namespace)); - action_tb.left().add("drop").on_click(() => this.drop_zcache(namespace)); - - row.attr("zs_name", namespace); - }); - } + this._cache_table = section.add_widget(Table, columns, Table.Flag_FitLeft|Table.Flag_PackRight); + + this._cache_pager = new Pager(section, 25, () => this._render_cache_page(), + Pager.make_search_fn(() => this._cache_data, item => item.namespace)); + const cache_drop_link = document.createElement("span"); + cache_drop_link.className = "dropall zen_action"; + cache_drop_link.style.position = "static"; + cache_drop_link.textContent = "drop-all"; + cache_drop_link.addEventListener("click", () => this.drop_all("z$")); + this._cache_pager.prepend(cache_drop_link); + + const cache_loading = Pager.loading(section); + const zcache_info = await new Fetcher().resource("/z$/").json(); + const namespaces = zcache_info["Namespaces"] || []; + const results = await Promise.allSettled( + namespaces.map(ns => new Fetcher().resource(`/z$/${ns}/`).json().then(data => ({ namespace: ns, data }))) + ); + this._cache_data = results + .filter(r => r.status === "fulfilled") + .map(r => r.value) + .sort((a, b) => a.namespace.localeCompare(b.namespace)); + this._cache_pager.set_total(this._cache_data.length); + this._render_cache_page(); + cache_loading.remove(); } // version @@ -131,57 +125,54 @@ export class Page extends ZenPage version.param("detailed", "true"); version.text().then((data) => ver_tag.text(data)); - this._project_table = project_table; - this._cache_table = cache_table; - // WebSocket for live stats updates this.connect_stats_ws((all_stats) => this._render_stats(all_stats)); } _render_stats(all_stats) { + all_stats = this._merge_last_stats(all_stats); const grid = this._stats_grid; const safe_lookup = this._safe_lookup; - // Clear existing tiles + // Clear and repopulate service tiles grid grid.inner().innerHTML = ""; - // HTTP tile — aggregate request stats across all providers - { - const tile = grid.tag().classify("card").classify("stats-tile"); - tile.tag().classify("card-title").text("HTTP"); - const columns = tile.tag().classify("tile-columns"); + // HTTP panel — update metrics containers built once in main() + const left = this._http_req_metrics; + left.inner().innerHTML = ""; - // Left column: request stats - const left = columns.tag().classify("tile-metrics"); - - let total_requests = 0; - let total_rate = 0; - for (const p in all_stats) - { - total_requests += (safe_lookup(all_stats[p], "requests.count") || 0); - total_rate += (safe_lookup(all_stats[p], "requests.rate_1") || 0); - } - - this._add_tile_metric(left, Friendly.sep(total_requests), "total requests", true); - if (total_rate > 0) - this._add_tile_metric(left, Friendly.sep(total_rate, 1) + "/s", "req/sec (1m)"); + let total_requests = 0; + let total_rate = 0; + for (const p in all_stats) + { + total_requests += (safe_lookup(all_stats[p], "requests.count") || 0); + total_rate += (safe_lookup(all_stats[p], "requests.rate_1") || 0); + } - // Right column: websocket stats - const ws = all_stats["http"] ? (all_stats["http"]["websockets"] || {}) : {}; - const right = columns.tag().classify("tile-metrics"); + this._add_tile_metric(left, Friendly.sep(total_requests), "total requests", true); + if (total_rate > 0) + { + this._add_tile_metric(left, Friendly.sep(total_rate, 1) + "/s", "req/sec (1m)"); + } - this._add_tile_metric(right, Friendly.sep(ws.active_connections || 0), "ws connections", true); - const ws_frames = (ws.frames_received || 0) + (ws.frames_sent || 0); - if (ws_frames > 0) - this._add_tile_metric(right, Friendly.sep(ws_frames), "ws frames"); - const ws_bytes = (ws.bytes_received || 0) + (ws.bytes_sent || 0); - if (ws_bytes > 0) - this._add_tile_metric(right, Friendly.bytes(ws_bytes), "ws traffic"); + const right = this._http_ws_metrics; + right.inner().innerHTML = ""; - tile.on_click(() => { window.location = "?page=metrics"; }); + const ws = all_stats["http"] ? (all_stats["http"]["websockets"] || {}) : {}; + this._add_tile_metric(right, Friendly.sep(ws.active_connections || 0), "ws connections", true); + const ws_frames = (ws.frames_received || 0) + (ws.frames_sent || 0); + if (ws_frames > 0) + { + this._add_tile_metric(right, Friendly.sep(ws_frames), "ws frames"); + } + const ws_bytes = (ws.bytes_received || 0) + (ws.bytes_sent || 0); + if (ws_bytes > 0) + { + this._add_tile_metric(right, Friendly.bytes(ws_bytes), "ws traffic"); } + // Cache tile (z$) if (all_stats["z$"]) { @@ -198,7 +189,7 @@ export class Page extends ZenPage this._add_tile_metric(body, safe_lookup(s, "cache.size.disk", Friendly.bytes) || "-", "disk"); this._add_tile_metric(body, safe_lookup(s, "cache.size.memory", Friendly.bytes) || "-", "memory"); - tile.on_click(() => { window.location = "?page=stat&provider=z$"; }); + tile.inner().addEventListener("click", () => { window.location = "?page=stat&provider=z$"; }); } // Project Store tile (prj) @@ -210,9 +201,9 @@ export class Page extends ZenPage const body = tile.tag().classify("tile-metrics"); this._add_tile_metric(body, safe_lookup(s, "requests.count", Friendly.sep) || "-", "requests", true); - this._add_tile_metric(body, safe_lookup(s, "store.size.disk", Friendly.bytes) || "-", "disk"); + this._add_tile_metric(body, safe_lookup(s, "project_count", Friendly.sep) || "-", "projects"); - tile.on_click(() => { window.location = "?page=stat&provider=prj"; }); + tile.inner().addEventListener("click", () => { window.location = "?page=stat&provider=prj"; }); } // Build Store tile (builds) @@ -226,7 +217,7 @@ export class Page extends ZenPage this._add_tile_metric(body, safe_lookup(s, "requests.count", Friendly.sep) || "-", "requests", true); this._add_tile_metric(body, safe_lookup(s, "store.size.disk", Friendly.bytes) || "-", "disk"); - tile.on_click(() => { window.location = "?page=stat&provider=builds"; }); + tile.inner().addEventListener("click", () => { window.location = "?page=builds"; }); } // Proxy tile @@ -250,7 +241,37 @@ export class Page extends ZenPage this._add_tile_metric(body, Friendly.sep(mappings.length), "mappings"); this._add_tile_metric(body, Friendly.bytes(totalBytes), "traffic"); - tile.on_click(() => { window.location = "?page=proxy"; }); + tile.inner().addEventListener("click", () => { window.location = "?page=proxy"; }); + } + + // Hub tile + if (all_stats["hub"]) + { + const s = all_stats["hub"]; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Hub"); + const body = tile.tag().classify("tile-metrics"); + + const current = safe_lookup(s, "currentInstanceCount") || 0; + const limit = safe_lookup(s, "instanceLimit") || safe_lookup(s, "maxInstanceCount") || 0; + this._add_tile_metric(body, `${current} / ${limit}`, "instances", true); + this._add_tile_metric(body, safe_lookup(s, "requests.count", Friendly.sep) || "-", "requests"); + + tile.inner().addEventListener("click", () => { window.location = "?page=stat&provider=hub"; }); + } + + // Object Store tile (obj) + if (all_stats["obj"]) + { + const s = all_stats["obj"]; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Object Store"); + const body = tile.tag().classify("tile-metrics"); + + this._add_tile_metric(body, safe_lookup(s, "requests.count", Friendly.sep) || "-", "requests", true); + this._add_tile_metric(body, safe_lookup(s, "total_bytes_served", Friendly.bytes) || "-", "bytes served"); + + tile.inner().addEventListener("click", () => { window.location = "?page=stat&provider=obj"; }); } // Workspace tile (ws) @@ -262,9 +283,9 @@ export class Page extends ZenPage const body = tile.tag().classify("tile-metrics"); this._add_tile_metric(body, safe_lookup(s, "requests.count", Friendly.sep) || "-", "requests", true); - this._add_tile_metric(body, safe_lookup(s, "workspaces.filescount", Friendly.sep) || "-", "files"); + this._add_tile_metric(body, safe_lookup(s, "workspaces", Friendly.sep) || "-", "workspaces"); - tile.on_click(() => { window.location = "?page=stat&provider=ws"; }); + tile.inner().addEventListener("click", () => { window.location = "?page=stat&provider=ws"; }); } } @@ -279,6 +300,60 @@ export class Page extends ZenPage m.tag().classify("metric-label").text(label); } + _render_projects_page() + { + const { start, end } = this._project_pager.page_range(); + this._project_table.clear(start); + for (let i = start; i < end; i++) + { + const project = this._projects_data[i]; + const row = this._project_table.add_row( + "", + project.ProjectRootDir, + project.EngineRootDir, + ); + + const cell = row.get_cell(0); + cell.tag().text(project.Id).on_click((x) => this.view_project(x), project.Id); + + const action_cell = row.get_cell(-1); + const action_tb = new Toolbar(action_cell, true); + action_tb.left().add("view").on_click((x) => this.view_project(x), project.Id); + action_tb.left().add("drop").on_click((x) => this.drop_project(x), project.Id); + + row.attr("zs_name", project.Id); + } + } + + _render_cache_page() + { + const { start, end } = this._cache_pager.page_range(); + this._cache_table.clear(start); + for (let i = start; i < end; i++) + { + const item = this._cache_data[i]; + const data = item.data; + const row = this._cache_table.add_row( + "", + data["Configuration"]["RootDir"], + data["Buckets"].length, + data["EntryCount"], + Friendly.bytes(data["StorageSize"].DiskSize), + Friendly.bytes(data["StorageSize"].MemorySize) + ); + + const cell = row.get_cell(0); + cell.tag().text(item.namespace).on_click(() => this.view_zcache(item.namespace)); + + const action_cell = row.get_cell(-1); + const action_tb = new Toolbar(action_cell, true); + action_tb.left().add("view").on_click(() => this.view_zcache(item.namespace)); + action_tb.left().add("drop").on_click(() => this.drop_zcache(item.namespace)); + + row.attr("zs_name", item.namespace); + } + } + view_stat(provider) { window.location = "?page=stat&provider=" + provider; @@ -324,20 +399,18 @@ export class Page extends ZenPage async drop_all_projects() { - for (const row of this._project_table) + for (const project of this._projects_data || []) { - const project_id = row.attr("zs_name"); - await new Fetcher().resource("prj", project_id).delete(); + await new Fetcher().resource("prj", project.Id).delete(); } this.reload(); } async drop_all_zcache() { - for (const row of this._cache_table) + for (const item of this._cache_data || []) { - const namespace = row.attr("zs_name"); - await new Fetcher().resource("z$", namespace).delete(); + await new Fetcher().resource("z$", item.namespace).delete(); } this.reload(); } diff --git a/src/zenserver/frontend/html/pages/workspaces.js b/src/zenserver/frontend/html/pages/workspaces.js new file mode 100644 index 000000000..db02e8be1 --- /dev/null +++ b/src/zenserver/frontend/html/pages/workspaces.js @@ -0,0 +1,239 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" +import { copy_button } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("workspaces"); + + // Workspace Service Stats + const stats_section = this._collapsible_section("Workspace Service Stats"); + this._stats_grid = stats_section.tag().classify("grid").classify("stats-tiles"); + + const stats = await new Fetcher().resource("stats", "ws").json().catch(() => null); + if (stats) { this._render_stats(stats); } + + this.connect_stats_ws((all_stats) => { + const s = all_stats["ws"]; + if (s) { this._render_stats(s); } + }); + + const section = this.add_section("Workspaces"); + const host = section.tag(); + + // Toolbar: refresh button + const toolbar = host.tag().classify("module-bulk-bar"); + this._btn_refresh = toolbar.tag("button").classify("module-bulk-btn").inner(); + this._btn_refresh.textContent = "\u21BB Refresh"; + this._btn_refresh.addEventListener("click", () => this._do_refresh()); + + // Workspace table (raw DOM — in-place row updates require stable element refs) + const table = document.createElement("table"); + table.className = "module-table"; + const thead = document.createElement("thead"); + const hrow = document.createElement("tr"); + for (const label of ["WORKSPACE ID", "ROOT PATH"]) + { + const th = document.createElement("th"); + th.textContent = label; + hrow.appendChild(th); + } + thead.appendChild(hrow); + table.appendChild(thead); + this._tbody = document.createElement("tbody"); + table.appendChild(this._tbody); + host.inner().appendChild(table); + + // State + this._expanded = new Set(); // workspace ids with shares panel open + this._row_cache = new Map(); // workspace id -> row refs, for in-place DOM updates + this._loading = false; + + await this._load(); + } + + async _load() + { + if (this._loading) { return; } + this._loading = true; + this._btn_refresh.disabled = true; + try + { + const data = await new Fetcher().resource("/ws/").json(); + const workspaces = data.workspaces || []; + this._render(workspaces); + } + catch (e) { /* service unavailable */ } + finally + { + this._loading = false; + this._btn_refresh.disabled = false; + } + } + + async _do_refresh() + { + if (this._loading) { return; } + this._btn_refresh.disabled = true; + try + { + await new Fetcher().resource("/ws/refresh").text(); + } + catch (e) { /* ignore */ } + await this._load(); + } + + _render(workspaces) + { + const ws_map = new Map(workspaces.map(w => [w.id, w])); + + // Remove rows for workspaces no longer present + for (const [id, row] of this._row_cache) + { + if (!ws_map.has(id)) + { + row.tr.remove(); + row.detail_tr.remove(); + this._row_cache.delete(id); + this._expanded.delete(id); + } + } + + // Create or update rows, then reorder tbody to match response order. + // appendChild on an existing node moves it, so iterating in response order + // achieves correct ordering without touching rows already in the right position. + for (const ws of workspaces) + { + const id = ws.id || ""; + const shares = ws.shares || []; + + let row = this._row_cache.get(id); + if (row) + { + // Update in-place — preserves DOM node identity so expanded state is kept + row.root_path_node.nodeValue = ws.root_path || ""; + row.detail_tr.style.display = this._expanded.has(id) ? "" : "none"; + row.btn_expand.textContent = this._expanded.has(id) ? "\u25BE" : "\u25B8"; + const shares_json = JSON.stringify(shares); + if (shares_json !== row.shares_json) + { + row.shares_json = shares_json; + this._render_shares(row.sh_tbody, shares); + } + } + else + { + // Create new workspace row + const tr = document.createElement("tr"); + const detail_tr = document.createElement("tr"); + detail_tr.className = "module-metrics-row"; + detail_tr.style.display = this._expanded.has(id) ? "" : "none"; + + const btn_expand = document.createElement("button"); + btn_expand.className = "module-expand-btn"; + btn_expand.textContent = this._expanded.has(id) ? "\u25BE" : "\u25B8"; + btn_expand.addEventListener("click", () => { + if (this._expanded.has(id)) + { + this._expanded.delete(id); + detail_tr.style.display = "none"; + btn_expand.textContent = "\u25B8"; + } + else + { + this._expanded.add(id); + detail_tr.style.display = ""; + btn_expand.textContent = "\u25BE"; + } + }); + + const id_wrap = document.createElement("span"); + id_wrap.className = "ws-id-wrap"; + id_wrap.appendChild(btn_expand); + id_wrap.appendChild(document.createTextNode("\u00A0" + id)); + id_wrap.appendChild(copy_button(id)); + const td_id = document.createElement("td"); + td_id.appendChild(id_wrap); + tr.appendChild(td_id); + + const root_path_node = document.createTextNode(ws.root_path || ""); + const td_root = document.createElement("td"); + td_root.appendChild(root_path_node); + tr.appendChild(td_root); + + // Detail row: nested shares table + const sh_table = document.createElement("table"); + sh_table.className = "module-table ws-share-table"; + const sh_thead = document.createElement("thead"); + const sh_hrow = document.createElement("tr"); + for (const label of ["SHARE ID", "SHARE PATH", "ALIAS"]) + { + const th = document.createElement("th"); + th.textContent = label; + sh_hrow.appendChild(th); + } + sh_thead.appendChild(sh_hrow); + sh_table.appendChild(sh_thead); + const sh_tbody = document.createElement("tbody"); + sh_table.appendChild(sh_tbody); + const detail_td = document.createElement("td"); + detail_td.colSpan = 2; + detail_td.className = "ws-detail-cell"; + detail_td.appendChild(sh_table); + detail_tr.appendChild(detail_td); + + this._render_shares(sh_tbody, shares); + + row = { tr, detail_tr, root_path_node, sh_tbody, btn_expand, shares_json: JSON.stringify(shares) }; + this._row_cache.set(id, row); + } + + this._tbody.appendChild(row.tr); + this._tbody.appendChild(row.detail_tr); + } + } + + _render_stats(stats) + { + stats = this._merge_last_stats(stats); + const grid = this._stats_grid; + grid.inner().innerHTML = ""; + + // HTTP Requests tile + this._render_http_requests_tile(grid, stats.requests); + } + + _render_shares(sh_tbody, shares) + { + sh_tbody.innerHTML = ""; + if (shares.length === 0) + { + const tr = document.createElement("tr"); + const td = document.createElement("td"); + td.colSpan = 3; + td.className = "ws-no-shares-cell"; + td.textContent = "No shares"; + tr.appendChild(td); + sh_tbody.appendChild(tr); + return; + } + for (const share of shares) + { + const tr = document.createElement("tr"); + for (const text of [share.id || "", share.share_path || "", share.alias || ""]) + { + const td = document.createElement("td"); + td.textContent = text; + tr.appendChild(td); + } + sh_tbody.appendChild(tr); + } + } +} diff --git a/src/zenserver/frontend/html/util/widgets.js b/src/zenserver/frontend/html/util/widgets.js index 17bd2fde7..651686a11 100644 --- a/src/zenserver/frontend/html/util/widgets.js +++ b/src/zenserver/frontend/html/util/widgets.js @@ -6,6 +6,58 @@ import { Component } from "./component.js" import { Friendly } from "../util/friendly.js" //////////////////////////////////////////////////////////////////////////////// +export function flash_highlight(element) +{ + if (!element) { return; } + element.classList.add("pager-search-highlight"); + setTimeout(() => { element.classList.remove("pager-search-highlight"); }, 1500); +} + +//////////////////////////////////////////////////////////////////////////////// +export function copy_button(value_or_fn) +{ + if (!navigator.clipboard) + { + const stub = document.createElement("span"); + stub.style.display = "none"; + return stub; + } + + let reset_timer = 0; + const btn = document.createElement("button"); + btn.className = "zen-copy-btn"; + btn.title = "Copy to clipboard"; + btn.textContent = "\u29C9"; + btn.addEventListener("click", async (e) => { + e.stopPropagation(); + const v = typeof value_or_fn === "function" ? value_or_fn() : value_or_fn; + if (!v) { return; } + try + { + await navigator.clipboard.writeText(v); + clearTimeout(reset_timer); + btn.classList.add("zen-copy-ok"); + btn.textContent = "\u2713"; + reset_timer = setTimeout(() => { btn.classList.remove("zen-copy-ok"); btn.textContent = "\u29C9"; }, 800); + } + catch (_e) { /* clipboard not available */ } + }); + return btn; +} + +// Wraps the existing children of `element` plus a copy button into an +// inline-flex nowrap container so the button never wraps to a new line. +export function add_copy_button(element, value_or_fn) +{ + if (!navigator.clipboard) { return; } + const wrap = document.createElement("span"); + wrap.className = "zen-copy-wrap"; + while (element.firstChild) { wrap.appendChild(element.firstChild); } + wrap.appendChild(copy_button(value_or_fn)); + element.appendChild(wrap); +} + +//////////////////////////////////////////////////////////////////////////////// class Widget extends Component { } @@ -402,6 +454,135 @@ export class ProgressBar extends Widget //////////////////////////////////////////////////////////////////////////////// +export class Pager +{ + constructor(section, page_size, on_change, search_fn) + { + this._page = 0; + this._page_size = page_size; + this._total = 0; + this._on_change = on_change; + this._search_fn = search_fn || null; + this._search_input = null; + + const pager = section.tag().classify("module-pager").inner(); + this._btn_prev = document.createElement("button"); + this._btn_prev.className = "module-pager-btn"; + this._btn_prev.textContent = "\u2190 Prev"; + this._btn_prev.addEventListener("click", () => this._go_page(this._page - 1)); + this._label = document.createElement("span"); + this._label.className = "module-pager-label"; + this._btn_next = document.createElement("button"); + this._btn_next.className = "module-pager-btn"; + this._btn_next.textContent = "Next \u2192"; + this._btn_next.addEventListener("click", () => this._go_page(this._page + 1)); + + if (this._search_fn) + { + this._search_input = document.createElement("input"); + this._search_input.type = "text"; + this._search_input.className = "module-pager-search"; + this._search_input.placeholder = "Search\u2026"; + this._search_input.addEventListener("keydown", (e) => + { + if (e.key === "Enter") + { + this._do_search(this._search_input.value.trim()); + } + }); + pager.appendChild(this._search_input); + } + + pager.appendChild(this._btn_prev); + pager.appendChild(this._label); + pager.appendChild(this._btn_next); + this._pager = pager; + + this._update_ui(); + } + + prepend(element) + { + const ref = this._search_input || this._btn_prev; + this._pager.insertBefore(element, ref); + } + + set_total(n) + { + this._total = n; + const max_page = Math.max(0, Math.ceil(n / this._page_size) - 1); + if (this._page > max_page) + { + this._page = max_page; + } + this._update_ui(); + } + + page_range() + { + const start = this._page * this._page_size; + const end = Math.min(start + this._page_size, this._total); + return { start, end }; + } + + _go_page(n) + { + const max = Math.max(0, Math.ceil(this._total / this._page_size) - 1); + this._page = Math.max(0, Math.min(n, max)); + this._update_ui(); + this._on_change(); + } + + _do_search(term) + { + if (!term || !this._search_fn) + { + return; + } + const result = this._search_fn(term); + if (!result) + { + this._search_input.style.outline = "2px solid var(--theme_fail)"; + setTimeout(() => { this._search_input.style.outline = ""; }, 1000); + return; + } + this._go_page(Math.floor(result.index / this._page_size)); + flash_highlight(this._pager.parentNode.querySelector(`[zs_name="${CSS.escape(result.name)}"]`)); + } + + _update_ui() + { + const total = this._total; + const page_count = Math.max(1, Math.ceil(total / this._page_size)); + const start = this._page * this._page_size + 1; + const end = Math.min(start + this._page_size - 1, total); + + this._btn_prev.disabled = this._page === 0; + this._btn_next.disabled = this._page >= page_count - 1; + this._label.textContent = total === 0 + ? "No items" + : `${start}\u2013${end} of ${total}`; + } + + static make_search_fn(get_data, get_key) + { + return (term) => { + const t = term.toLowerCase(); + const data = get_data(); + const i = data.findIndex(item => get_key(item).toLowerCase().includes(t)); + return i < 0 ? null : { index: i, name: get_key(data[i]) }; + }; + } + + static loading(section) + { + return section.tag().classify("pager-loading").text("Loading\u2026").inner(); + } +} + + + +//////////////////////////////////////////////////////////////////////////////// export class WidgetHost { constructor(parent, depth=1) diff --git a/src/zenserver/frontend/html/zen.css b/src/zenserver/frontend/html/zen.css index b4f7270fc..d3c6c9036 100644 --- a/src/zenserver/frontend/html/zen.css +++ b/src/zenserver/frontend/html/zen.css @@ -803,18 +803,21 @@ zen-banner + zen-nav::part(nav-bar) { /* stats tiles -------------------------------------------------------------- */ -.stats-tiles { - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +.grid.stats-tiles { + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); } .stats-tile { cursor: pointer; - transition: border-color 0.15s, background 0.15s; + transition: border-color 0.15s; } .stats-tile:hover { border-color: var(--theme_p0); - background: var(--theme_p4); +} + +.stats-tile[data-over="true"] { + border-color: var(--theme_fail); } .stats-tile-detailed { @@ -873,6 +876,81 @@ zen-banner + zen-nav::part(nav-bar) { font-size: 28px; } +/* HTTP summary panel ------------------------------------------------------- */ + +.stats-http-panel { + display: grid; + grid-template-columns: 20% 1fr 1fr; + align-items: center; + margin-bottom: 16px; +} + +.http-title { + font-size: 22px; + font-weight: 700; + color: var(--theme_bright); + text-transform: uppercase; + letter-spacing: 1px; + line-height: 1; +} + +.http-section { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0 24px; + border-left: 1px solid var(--theme_g2); +} + +.http-section-label { + font-size: 11px; + font-weight: 600; + color: var(--theme_g1); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.stats-http-panel .tile-metrics { + flex-direction: row; + align-items: center; + gap: 20px; +} + +/* workspaces page ---------------------------------------------------------- */ + +.ws-id-wrap { + display: inline-flex; + align-items: center; + font-family: 'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace; + font-size: 14px; +} + +.ws-share-table { + width: 100%; + margin: 4px 0; +} + +.ws-share-table th { + padding: 4px; +} + +.ws-share-table td { + font-family: 'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace; + font-size: 13px; + padding: 4px; +} + +.ws-share-table td.ws-no-shares-cell { + color: var(--theme_g1); + font-style: italic; + font-family: inherit; + padding: 4px 8px; +} + +.module-metrics-row td.ws-detail-cell { + padding-left: 24px; +} + /* start -------------------------------------------------------------------- */ #start { @@ -1030,7 +1108,7 @@ html:has(#map) { .card-title { font-size: 14px; font-weight: 600; - color: var(--theme_g1); + color: var(--theme_g0); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; @@ -1533,6 +1611,25 @@ tr:last-child td { animation: module-dot-deprovisioning-from-provisioned 1s steps(1, end) infinite; } +@keyframes module-dot-obliterating-from-provisioned { + 0%, 59.9% { background: var(--theme_fail); } + 60%, 100% { background: var(--theme_ok); } +} +@keyframes module-dot-obliterating-from-hibernated { + 0%, 59.9% { background: var(--theme_fail); } + 60%, 100% { background: var(--theme_warn); } +} + +.module-state-dot[data-state="obliterating"][data-prev-state="provisioned"] { + animation: module-dot-obliterating-from-provisioned 0.5s steps(1, end) infinite; +} +.module-state-dot[data-state="obliterating"][data-prev-state="hibernated"] { + animation: module-dot-obliterating-from-hibernated 0.5s steps(1, end) infinite; +} +.module-state-dot[data-state="obliterating"] { + animation: module-dot-obliterating-from-provisioned 0.5s steps(1, end) infinite; +} + .module-action-cell { white-space: nowrap; display: flex; @@ -1652,6 +1749,53 @@ tr:last-child td { text-align: center; } +.module-pager-search { + font-size: 12px; + padding: 4px 8px; + width: 14em; + border: 1px solid var(--theme_g2); + border-radius: 4px; + background: var(--theme_g4); + color: var(--theme_g0); + outline: none; + transition: border-color 0.15s, outline 0.3s; +} + +.module-pager-search:focus { + border-color: var(--theme_p0); +} + +.module-pager-search::placeholder { + color: var(--theme_g1); +} + +@keyframes pager-search-flash { + from { box-shadow: inset 0 0 0 100px var(--theme_p2); } + to { box-shadow: inset 0 0 0 100px transparent; } +} + +.zen_table > .pager-search-highlight > div { + animation: pager-search-flash 1s linear forwards; +} + +.module-table .pager-search-highlight td { + animation: pager-search-flash 1s linear forwards; +} + +@keyframes pager-loading-pulse { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 0.2; } +} + +.pager-loading { + color: var(--theme_g1); + font-style: italic; + font-size: 14px; + font-weight: 600; + padding: 12px 0; + animation: pager-loading-pulse 1.5s ease-in-out infinite; +} + .module-table td, .module-table th { padding-top: 4px; padding-bottom: 4px; @@ -1672,6 +1816,35 @@ tr:last-child td { color: var(--theme_bright); } +.zen-copy-btn { + background: transparent; + border: 1px solid var(--theme_g2); + border-radius: 4px; + color: var(--theme_g1); + cursor: pointer; + font-size: 12px; + line-height: 1; + padding: 2px 5px; + margin-left: 6px; + vertical-align: middle; + flex-shrink: 0; + transition: background 0.1s, color 0.1s; +} +.zen-copy-btn:hover { + background: var(--theme_g2); + color: var(--theme_bright); +} +.zen-copy-btn.zen-copy-ok { + color: var(--theme_ok); + border-color: var(--theme_ok); +} + +.zen-copy-wrap { + display: inline-flex; + align-items: center; + white-space: nowrap; +} + .module-metrics-row td { padding: 6px 10px 10px 42px; background: var(--theme_g3); diff --git a/src/zenserver/frontend/zipfs.cpp b/src/zenserver/frontend/zipfs.cpp deleted file mode 100644 index c7c8687ca..000000000 --- a/src/zenserver/frontend/zipfs.cpp +++ /dev/null @@ -1,228 +0,0 @@ -// Copyright Epic Games, Inc. All Rights Reserved. - -#include "zipfs.h" - -#include <zencore/logging.h> - -ZEN_THIRD_PARTY_INCLUDES_START -#include <zlib.h> -ZEN_THIRD_PARTY_INCLUDES_END - -namespace zen { - -////////////////////////////////////////////////////////////////////////// -namespace { - -#if ZEN_COMPILER_MSC -# pragma warning(push) -# pragma warning(disable : 4200) -#endif - - using ZipInt16 = uint16_t; - - struct ZipInt32 - { - operator uint32_t() const { return *(uint32_t*)Parts; } - uint16_t Parts[2]; - }; - - struct EocdRecord - { - enum : uint32_t - { - Magic = 0x0605'4b50, - }; - ZipInt32 Signature; - ZipInt16 ThisDiskIndex; - ZipInt16 CdStartDiskIndex; - ZipInt16 CdRecordThisDiskCount; - ZipInt16 CdRecordCount; - ZipInt32 CdSize; - ZipInt32 CdOffset; - ZipInt16 CommentSize; - char Comment[]; - }; - - struct CentralDirectoryRecord - { - enum : uint32_t - { - Magic = 0x0201'4b50, - }; - - ZipInt32 Signature; - ZipInt16 VersionMadeBy; - ZipInt16 VersionRequired; - ZipInt16 Flags; - ZipInt16 CompressionMethod; - ZipInt16 LastModTime; - ZipInt16 LastModDate; - ZipInt32 Crc32; - ZipInt32 CompressedSize; - ZipInt32 OriginalSize; - ZipInt16 FileNameLength; - ZipInt16 ExtraFieldLength; - ZipInt16 CommentLength; - ZipInt16 DiskIndex; - ZipInt16 InternalFileAttr; - ZipInt32 ExternalFileAttr; - ZipInt32 Offset; - char FileName[]; - }; - - struct LocalFileHeader - { - enum : uint32_t - { - Magic = 0x0403'4b50, - }; - - ZipInt32 Signature; - ZipInt16 VersionRequired; - ZipInt16 Flags; - ZipInt16 CompressionMethod; - ZipInt16 LastModTime; - ZipInt16 LastModDate; - ZipInt32 Crc32; - ZipInt32 CompressedSize; - ZipInt32 OriginalSize; - ZipInt16 FileNameLength; - ZipInt16 ExtraFieldLength; - char FileName[]; - }; - -#if ZEN_COMPILER_MSC -# pragma warning(pop) -#endif - -} // namespace - -////////////////////////////////////////////////////////////////////////// -ZipFs::ZipFs(IoBuffer&& Buffer) -{ - MemoryView View = Buffer.GetView(); - - uint8_t* Cursor = (uint8_t*)(View.GetData()) + View.GetSize(); - if (View.GetSize() < sizeof(EocdRecord)) - { - return; - } - - const auto* EocdCursor = (EocdRecord*)(Cursor - sizeof(EocdRecord)); - - // It is more correct to search backwards for EocdRecord::Magic as the - // comment can be of a variable length. But here we're not going to support - // zip files with comments. - if (EocdCursor->Signature != EocdRecord::Magic) - { - return; - } - - // Zip64 isn't supported either - if (EocdCursor->ThisDiskIndex == 0xffff) - { - return; - } - - Cursor = (uint8_t*)EocdCursor - uint32_t(EocdCursor->CdOffset) - uint32_t(EocdCursor->CdSize); - - const auto* CdCursor = (CentralDirectoryRecord*)(Cursor + EocdCursor->CdOffset); - for (int i = 0, n = EocdCursor->CdRecordCount; i < n; ++i) - { - const CentralDirectoryRecord& Cd = *CdCursor; - - bool Acceptable = true; - Acceptable &= (Cd.OriginalSize > 0); // has some content - Acceptable &= (Cd.CompressionMethod == 0 || Cd.CompressionMethod == 8); // stored or deflate - if (Acceptable) - { - const uint8_t* Lfh = Cursor + Cd.Offset; - if (uintptr_t(Lfh - Cursor) < View.GetSize()) - { - std::string_view FileName(Cd.FileName, Cd.FileNameLength); - FileItem Item; - Item.View = MemoryView{Lfh, size_t(0)}; - Item.CompressionMethod = Cd.CompressionMethod; - Item.CompressedSize = Cd.CompressedSize; - Item.UncompressedSize = Cd.OriginalSize; - m_Files.insert(std::make_pair(FileName, std::move(Item))); - } - } - - uint32_t ExtraBytes = Cd.FileNameLength + Cd.ExtraFieldLength + Cd.CommentLength; - CdCursor = (CentralDirectoryRecord*)(Cd.FileName + ExtraBytes); - } - - m_Buffer = std::move(Buffer); -} - -////////////////////////////////////////////////////////////////////////// -IoBuffer -ZipFs::GetFile(const std::string_view& FileName) const -{ - { - RwLock::SharedLockScope _(m_FilesLock); - - FileMap::const_iterator Iter = m_Files.find(FileName); - if (Iter == m_Files.end()) - { - return {}; - } - - const FileItem& Item = Iter->second; - if (Item.View.GetSize() > 0) - { - return IoBuffer(IoBuffer::Wrap, Item.View.GetData(), Item.View.GetSize()); - } - } - - RwLock::ExclusiveLockScope _(m_FilesLock); - - FileItem& Item = m_Files.find(FileName)->second; - if (Item.View.GetSize() > 0) - { - return IoBuffer(IoBuffer::Wrap, Item.View.GetData(), Item.View.GetSize()); - } - - const auto* Lfh = (LocalFileHeader*)(Item.View.GetData()); - const uint8_t* FileData = (const uint8_t*)(Lfh->FileName + Lfh->FileNameLength + Lfh->ExtraFieldLength); - - if (Item.CompressionMethod == 0) - { - // Stored — point directly into the buffer - Item.View = MemoryView(FileData, Item.UncompressedSize); - } - else - { - // Deflate — decompress using zlib - Item.DecompressedData = IoBuffer(Item.UncompressedSize); - - z_stream Stream = {}; - Stream.next_in = const_cast<Bytef*>(FileData); - Stream.avail_in = Item.CompressedSize; - Stream.next_out = (Bytef*)Item.DecompressedData.GetMutableView().GetData(); - Stream.avail_out = Item.UncompressedSize; - - // Use raw inflate (-MAX_WBITS) since zip stores raw deflate streams - if (inflateInit2(&Stream, -MAX_WBITS) != Z_OK) - { - ZEN_WARN("failed to initialize inflate for '{}'", FileName); - return {}; - } - - int Result = inflate(&Stream, Z_FINISH); - inflateEnd(&Stream); - - if (Result != Z_STREAM_END) - { - ZEN_WARN("failed to decompress '{}' (zlib error {})", FileName, Result); - return {}; - } - - Item.View = Item.DecompressedData.GetView(); - } - - return IoBuffer(IoBuffer::Wrap, Item.View.GetData(), Item.View.GetSize()); -} - -} // namespace zen diff --git a/src/zenserver/frontend/zipfs.h b/src/zenserver/frontend/zipfs.h deleted file mode 100644 index c6acf7334..000000000 --- a/src/zenserver/frontend/zipfs.h +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright Epic Games, Inc. All Rights Reserved. - -#pragma once - -#include <zencore/iobuffer.h> -#include <zencore/thread.h> - -#include <unordered_map> - -namespace zen { - -class ZipFs -{ -public: - explicit ZipFs(IoBuffer&& Buffer); - - IoBuffer GetFile(const std::string_view& FileName) const; - -private: - struct FileItem - { - MemoryView View; // Initially points to LFH (size=0); resolved to file data on first access - uint32_t CompressedSize = 0; - uint32_t UncompressedSize = 0; - uint16_t CompressionMethod = 0; - IoBuffer DecompressedData; // Owns decompressed buffer for deflate entries - }; - - using FileMap = std::unordered_map<std::string_view, FileItem>; - mutable RwLock m_FilesLock; - FileMap mutable m_Files; - IoBuffer m_Buffer; -}; - -} // namespace zen diff --git a/src/zenserver/frontend/zipfs_test.cpp b/src/zenserver/frontend/zipfs_test.cpp deleted file mode 100644 index b5937b71c..000000000 --- a/src/zenserver/frontend/zipfs_test.cpp +++ /dev/null @@ -1,214 +0,0 @@ -// Copyright Epic Games, Inc. All Rights Reserved. - -#include "zipfs.h" - -#include <zencore/iobuffer.h> - -#if ZEN_WITH_TESTS - -ZEN_THIRD_PARTY_INCLUDES_START -# include <doctest/doctest.h> -# include <zlib.h> -ZEN_THIRD_PARTY_INCLUDES_END - -# include <cstring> -# include <vector> - -TEST_SUITE_BEGIN("server.zipfs"); - -namespace { - -// Helpers to build a minimal zip file in memory -struct ZipBuilder -{ - std::vector<uint8_t> Data; - - struct Entry - { - std::string Name; - uint32_t LocalHeaderOffset; - uint16_t CompressionMethod; - uint32_t CompressedSize; - uint32_t UncompressedSize; - }; - - std::vector<Entry> Entries; - - void Append(const void* Src, size_t Size) - { - const uint8_t* Bytes = (const uint8_t*)Src; - Data.insert(Data.end(), Bytes, Bytes + Size); - } - - void AppendU16(uint16_t V) { Append(&V, 2); } - void AppendU32(uint32_t V) { Append(&V, 4); } - - void AddFile(const std::string& Name, const void* Content, size_t ContentSize, bool Deflate) - { - std::vector<uint8_t> FileData; - uint16_t Method = 0; - - if (Deflate) - { - // Compress with raw deflate (no zlib/gzip header) - uLongf BoundSize = compressBound((uLong)ContentSize); - std::vector<uint8_t> TempBuf(BoundSize); - - z_stream Stream = {}; - Stream.next_in = (Bytef*)Content; - Stream.avail_in = (uInt)ContentSize; - Stream.next_out = TempBuf.data(); - Stream.avail_out = (uInt)TempBuf.size(); - - deflateInit2(&Stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, -MAX_WBITS, 8, Z_DEFAULT_STRATEGY); - deflate(&Stream, Z_FINISH); - deflateEnd(&Stream); - - TempBuf.resize(Stream.total_out); - FileData = std::move(TempBuf); - Method = 8; - } - else - { - FileData.assign((const uint8_t*)Content, (const uint8_t*)Content + ContentSize); - } - - Entry E; - E.Name = Name; - E.LocalHeaderOffset = (uint32_t)Data.size(); - E.CompressionMethod = Method; - E.CompressedSize = (uint32_t)FileData.size(); - E.UncompressedSize = (uint32_t)ContentSize; - Entries.push_back(E); - - // Local file header - AppendU32(0x04034b50); // signature - AppendU16(20); // version needed - AppendU16(0); // flags - AppendU16(Method); // compression method - AppendU16(0); // last mod time - AppendU16(0); // last mod date - AppendU32(0); // crc32 (not validated by ZipFs) - AppendU32(E.CompressedSize); // compressed size - AppendU32(E.UncompressedSize); // uncompressed size - AppendU16((uint16_t)Name.size()); // file name length - AppendU16(0); // extra field length - Append(Name.data(), Name.size()); // file name - Append(FileData.data(), FileData.size()); - } - - zen::IoBuffer Build() - { - uint32_t CdOffset = (uint32_t)Data.size(); - - for (const Entry& E : Entries) - { - // Central directory record - AppendU32(0x02014b50); // signature - AppendU16(20); // version made by - AppendU16(20); // version needed - AppendU16(0); // flags - AppendU16(E.CompressionMethod); // compression method - AppendU16(0); // last mod time - AppendU16(0); // last mod date - AppendU32(0); // crc32 - AppendU32(E.CompressedSize); // compressed size - AppendU32(E.UncompressedSize); // uncompressed size - AppendU16((uint16_t)E.Name.size()); // file name length - AppendU16(0); // extra field length - AppendU16(0); // comment length - AppendU16(0); // disk index - AppendU16(0); // internal file attr - AppendU32(0); // external file attr - AppendU32(E.LocalHeaderOffset); // offset - Append(E.Name.data(), E.Name.size()); - } - - uint32_t CdSize = (uint32_t)Data.size() - CdOffset; - - // End of central directory record - AppendU32(0x06054b50); // signature - AppendU16(0); // this disk - AppendU16(0); // cd start disk - AppendU16((uint16_t)Entries.size()); // cd records this disk - AppendU16((uint16_t)Entries.size()); // cd records total - AppendU32(CdSize); // cd size - AppendU32(CdOffset); // cd offset - AppendU16(0); // comment length - - zen::IoBuffer Buffer(Data.size()); - std::memcpy(Buffer.GetMutableView().GetData(), Data.data(), Data.size()); - return Buffer; - } -}; - -} // namespace - -TEST_CASE("zipfs.stored") -{ - const char* Content = "Hello, World!"; - - ZipBuilder Zip; - Zip.AddFile("test.txt", Content, std::strlen(Content), false); - - zen::ZipFs Fs(Zip.Build()); - - zen::IoBuffer Result = Fs.GetFile("test.txt"); - REQUIRE(Result); - CHECK(Result.GetView().GetSize() == std::strlen(Content)); - CHECK(std::memcmp(Result.GetView().GetData(), Content, std::strlen(Content)) == 0); -} - -TEST_CASE("zipfs.deflate") -{ - const char* Content = "This is some content that will be deflate compressed in the zip file."; - - ZipBuilder Zip; - Zip.AddFile("compressed.txt", Content, std::strlen(Content), true); - - zen::ZipFs Fs(Zip.Build()); - - zen::IoBuffer Result = Fs.GetFile("compressed.txt"); - REQUIRE(Result); - CHECK(Result.GetView().GetSize() == std::strlen(Content)); - CHECK(std::memcmp(Result.GetView().GetData(), Content, std::strlen(Content)) == 0); -} - -TEST_CASE("zipfs.mixed") -{ - const char* StoredContent = "stored content"; - const char* DeflateContent = "deflate content that is compressed"; - - ZipBuilder Zip; - Zip.AddFile("stored.txt", StoredContent, std::strlen(StoredContent), false); - Zip.AddFile("deflated.txt", DeflateContent, std::strlen(DeflateContent), true); - - zen::ZipFs Fs(Zip.Build()); - - zen::IoBuffer Stored = Fs.GetFile("stored.txt"); - REQUIRE(Stored); - CHECK(Stored.GetView().GetSize() == std::strlen(StoredContent)); - CHECK(std::memcmp(Stored.GetView().GetData(), StoredContent, std::strlen(StoredContent)) == 0); - - zen::IoBuffer Deflated = Fs.GetFile("deflated.txt"); - REQUIRE(Deflated); - CHECK(Deflated.GetView().GetSize() == std::strlen(DeflateContent)); - CHECK(std::memcmp(Deflated.GetView().GetData(), DeflateContent, std::strlen(DeflateContent)) == 0); -} - -TEST_CASE("zipfs.not_found") -{ - const char* Content = "data"; - - ZipBuilder Zip; - Zip.AddFile("exists.txt", Content, std::strlen(Content), false); - - zen::ZipFs Fs(Zip.Build()); - - zen::IoBuffer Result = Fs.GetFile("missing.txt"); - CHECK(!Result); -} - -TEST_SUITE_END(); - -#endif // ZEN_WITH_TESTS |