aboutsummaryrefslogtreecommitdiff
path: root/src/zenserver/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'src/zenserver/frontend')
-rw-r--r--src/zenserver/frontend/frontend.cpp32
-rw-r--r--src/zenserver/frontend/frontend.h23
-rw-r--r--src/zenserver/frontend/html/compute/compute.html925
-rw-r--r--src/zenserver/frontend/html/compute/hub.html2
-rw-r--r--src/zenserver/frontend/html/compute/index.html2
-rw-r--r--src/zenserver/frontend/html/compute/orchestrator.html669
-rw-r--r--src/zenserver/frontend/html/pages/builds.js80
-rw-r--r--src/zenserver/frontend/html/pages/cache.js319
-rw-r--r--src/zenserver/frontend/html/pages/compute.js80
-rw-r--r--src/zenserver/frontend/html/pages/entry.js2
-rw-r--r--src/zenserver/frontend/html/pages/hub.js382
-rw-r--r--src/zenserver/frontend/html/pages/objectstore.js48
-rw-r--r--src/zenserver/frontend/html/pages/orchestrator.js248
-rw-r--r--src/zenserver/frontend/html/pages/page.js132
-rw-r--r--src/zenserver/frontend/html/pages/projects.js276
-rw-r--r--src/zenserver/frontend/html/pages/start.js281
-rw-r--r--src/zenserver/frontend/html/pages/workspaces.js239
-rw-r--r--src/zenserver/frontend/html/util/widgets.js181
-rw-r--r--src/zenserver/frontend/html/zen.css183
-rw-r--r--src/zenserver/frontend/zipfs.cpp228
-rw-r--r--src/zenserver/frontend/zipfs.h35
-rw-r--r--src/zenserver/frontend/zipfs_test.cpp214
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