diff options
| author | Liam Mitchell <[email protected]> | 2026-03-09 19:06:36 -0700 |
|---|---|---|
| committer | Liam Mitchell <[email protected]> | 2026-03-09 19:06:36 -0700 |
| commit | d1abc50ee9d4fb72efc646e17decafea741caa34 (patch) | |
| tree | e4288e00f2f7ca0391b83d986efcb69d3ba66a83 /src/zenserver/frontend/html | |
| parent | Allow requests with invalid content-types unless specified in command line or... (diff) | |
| parent | updated chunk–block analyser (#818) (diff) | |
| download | zen-d1abc50ee9d4fb72efc646e17decafea741caa34.tar.xz zen-d1abc50ee9d4fb72efc646e17decafea741caa34.zip | |
Merge branch 'main' into lm/restrict-content-type
Diffstat (limited to 'src/zenserver/frontend/html')
33 files changed, 7628 insertions, 273 deletions
diff --git a/src/zenserver/frontend/html/404.html b/src/zenserver/frontend/html/404.html new file mode 100644 index 000000000..829ef2097 --- /dev/null +++ b/src/zenserver/frontend/html/404.html @@ -0,0 +1,486 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="UTF-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0"> +<title>Ooops</title> +<style> + * { margin: 0; padding: 0; box-sizing: border-box; } + + :root { + --deep-space: #00000f; + --nebula-blue: #0a0a2e; + --star-white: #ffffff; + --star-blue: #c8d8ff; + --star-yellow: #fff3c0; + --star-red: #ffd0c0; + --nebula-glow: rgba(60, 80, 180, 0.12); + } + + body { + background: var(--deep-space); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + font-family: 'Courier New', monospace; + overflow: hidden; + } + + starfield-bg { + display: block; + position: fixed; + inset: 0; + z-index: 0; + } + + canvas { + display: block; + width: 100%; + height: 100%; + } + + .page-content { + position: relative; + z-index: 1; + text-align: center; + color: rgba(200, 216, 255, 0.85); + letter-spacing: 0.25em; + text-transform: uppercase; + pointer-events: none; + user-select: none; + } + + .page-content h1 { + font-size: clamp(1.2rem, 4vw, 2.4rem); + font-weight: 300; + letter-spacing: 0.6em; + text-shadow: 0 0 40px rgba(120, 160, 255, 0.6), 0 0 80px rgba(80, 120, 255, 0.3); + animation: pulse 6s ease-in-out infinite; + } + + .page-content p { + margin-top: 1.2rem; + font-size: clamp(0.55rem, 1.5vw, 0.75rem); + letter-spacing: 0.4em; + opacity: 0.45; + } + + @keyframes pulse { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 1; } + } + + .globe-link { + display: block; + margin: 0 auto 2rem; + width: 160px; + height: 160px; + pointer-events: auto; + cursor: pointer; + border-radius: 50%; + position: relative; + } + + .globe-link:hover .globe-glow { + opacity: 0.6; + } + + .globe-glow { + position: absolute; + inset: -18px; + border-radius: 50%; + background: radial-gradient(circle, rgba(80, 140, 255, 0.35) 0%, transparent 70%); + opacity: 0.35; + transition: opacity 0.4s; + pointer-events: none; + } + + .globe-link canvas { + display: block; + width: 160px; + height: 160px; + border-radius: 50%; + } +</style> +</head> +<body> + +<starfield-bg + star-count="380" + speed="0.6" + depth="true" + nebula="true" + shooting-stars="true" +></starfield-bg> + +<div class="page-content"> + <a class="globe-link" href="/dashboard/" title="Back to Dashboard"> + <div class="globe-glow"></div> + <canvas id="globe" width="320" height="320"></canvas> + </a> + <h1>404 NOT FOUND</h1> +</div> + +<script> +class StarfieldBg extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + this.shadowRoot.innerHTML = ` + <style> + :host { display: block; position: absolute; inset: 0; overflow: hidden; } + canvas { width: 100%; height: 100%; display: block; } + </style> + <canvas></canvas> + `; + + this.canvas = this.shadowRoot.querySelector('canvas'); + this.ctx = this.canvas.getContext('2d'); + + this.starCount = parseInt(this.getAttribute('star-count') || '350'); + this.speed = parseFloat(this.getAttribute('speed') || '0.6'); + this.useDepth = this.getAttribute('depth') !== 'false'; + this.useNebula = this.getAttribute('nebula') !== 'false'; + this.useShooting = this.getAttribute('shooting-stars') !== 'false'; + + this.stars = []; + this.shooters = []; + this.nebulaTime = 0; + this.frame = 0; + + this.resize(); + this.init(); + + this._ro = new ResizeObserver(() => { this.resize(); this.init(); }); + this._ro.observe(this); + + this.raf = requestAnimationFrame(this.tick.bind(this)); + } + + disconnectedCallback() { + cancelAnimationFrame(this.raf); + this._ro.disconnect(); + } + + resize() { + const dpr = window.devicePixelRatio || 1; + const rect = this.getBoundingClientRect(); + this.W = rect.width || window.innerWidth; + this.H = rect.height || window.innerHeight; + this.canvas.width = this.W * dpr; + this.canvas.height = this.H * dpr; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + + init() { + const COLORS = ['#ffffff', '#c8d8ff', '#d0e8ff', '#fff3c0', '#ffd0c0', '#e0f0ff']; + this.stars = Array.from({ length: this.starCount }, () => ({ + x: Math.random() * this.W, + y: Math.random() * this.H, + z: this.useDepth ? Math.random() : 1, // depth: 0=far, 1=near + r: Math.random() * 1.4 + 0.2, + color: COLORS[Math.floor(Math.random() * COLORS.length)], + twinkleOffset: Math.random() * Math.PI * 2, + twinkleSpeed: 0.008 + Math.random() * 0.012, + })); + } + + spawnShooter() { + const edge = Math.random() < 0.7 ? 'top' : 'left'; + const angle = (Math.random() * 30 + 15) * (Math.PI / 180); + this.shooters.push({ + x: edge === 'top' ? Math.random() * this.W : -10, + y: edge === 'top' ? -10 : Math.random() * this.H * 0.5, + vx: Math.cos(angle) * (6 + Math.random() * 6), + vy: Math.sin(angle) * (6 + Math.random() * 6), + len: 80 + Math.random() * 120, + life: 1, + decay: 0.012 + Math.random() * 0.018, + }); + } + + tick() { + this.raf = requestAnimationFrame(this.tick.bind(this)); + this.frame++; + const ctx = this.ctx; + const W = this.W, H = this.H; + + // Background + ctx.fillStyle = '#00000f'; + ctx.fillRect(0, 0, W, H); + + // Nebula clouds (subtle) + if (this.useNebula) { + this.nebulaTime += 0.003; + this.drawNebula(ctx, W, H); + } + + // Stars + for (const s of this.stars) { + const twinkle = 0.55 + 0.45 * Math.sin(this.frame * s.twinkleSpeed + s.twinkleOffset); + const radius = s.r * (this.useDepth ? (0.3 + s.z * 0.7) : 1); + const alpha = (this.useDepth ? (0.25 + s.z * 0.75) : 1) * twinkle; + + // Tiny drift + s.x += (s.z * this.speed * 0.08) * (this.useDepth ? 1 : 0); + s.y += (s.z * this.speed * 0.04) * (this.useDepth ? 1 : 0); + if (s.x > W + 2) s.x = -2; + if (s.y > H + 2) s.y = -2; + + // Glow for bright stars + if (radius > 1.1 && alpha > 0.6) { + const grd = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, radius * 3.5); + grd.addColorStop(0, s.color.replace(')', `, ${alpha * 0.5})`).replace('rgb', 'rgba')); + grd.addColorStop(1, 'transparent'); + ctx.beginPath(); + ctx.arc(s.x, s.y, radius * 3.5, 0, Math.PI * 2); + ctx.fillStyle = grd; + ctx.fill(); + } + + ctx.beginPath(); + ctx.arc(s.x, s.y, radius, 0, Math.PI * 2); + ctx.fillStyle = hexToRgba(s.color, alpha); + ctx.fill(); + } + + // Shooting stars + if (this.useShooting) { + if (this.frame % 140 === 0 && Math.random() < 0.65) this.spawnShooter(); + for (let i = this.shooters.length - 1; i >= 0; i--) { + const s = this.shooters[i]; + const tailX = s.x - s.vx * (s.len / Math.hypot(s.vx, s.vy)); + const tailY = s.y - s.vy * (s.len / Math.hypot(s.vx, s.vy)); + + const grd = ctx.createLinearGradient(tailX, tailY, s.x, s.y); + grd.addColorStop(0, `rgba(255,255,255,0)`); + grd.addColorStop(0.7, `rgba(200,220,255,${s.life * 0.5})`); + grd.addColorStop(1, `rgba(255,255,255,${s.life})`); + + ctx.beginPath(); + ctx.moveTo(tailX, tailY); + ctx.lineTo(s.x, s.y); + ctx.strokeStyle = grd; + ctx.lineWidth = 1.5 * s.life; + ctx.lineCap = 'round'; + ctx.stroke(); + + // Head dot + ctx.beginPath(); + ctx.arc(s.x, s.y, 1.5 * s.life, 0, Math.PI * 2); + ctx.fillStyle = `rgba(255,255,255,${s.life})`; + ctx.fill(); + + s.x += s.vx; + s.y += s.vy; + s.life -= s.decay; + + if (s.life <= 0 || s.x > W + 200 || s.y > H + 200) { + this.shooters.splice(i, 1); + } + } + } + } + + drawNebula(ctx, W, H) { + const t = this.nebulaTime; + const blobs = [ + { x: W * 0.25, y: H * 0.3, rx: W * 0.35, ry: H * 0.25, color: '40,60,180', a: 0.055 }, + { x: W * 0.75, y: H * 0.65, rx: W * 0.30, ry: H * 0.22, color: '100,40,160', a: 0.04 }, + { x: W * 0.5, y: H * 0.5, rx: W * 0.45, ry: H * 0.35, color: '20,50,120', a: 0.035 }, + ]; + ctx.save(); + for (const b of blobs) { + const ox = Math.sin(t * 0.7 + b.x) * 30; + const oy = Math.cos(t * 0.5 + b.y) * 20; + const grd = ctx.createRadialGradient(b.x + ox, b.y + oy, 0, b.x + ox, b.y + oy, Math.max(b.rx, b.ry)); + grd.addColorStop(0, `rgba(${b.color}, ${b.a})`); + grd.addColorStop(0.5, `rgba(${b.color}, ${b.a * 0.4})`); + grd.addColorStop(1, `rgba(${b.color}, 0)`); + ctx.save(); + ctx.scale(b.rx / Math.max(b.rx, b.ry), b.ry / Math.max(b.rx, b.ry)); + ctx.beginPath(); + const scale = Math.max(b.rx, b.ry); + ctx.arc((b.x + ox) / (b.rx / scale), (b.y + oy) / (b.ry / scale), scale, 0, Math.PI * 2); + ctx.fillStyle = grd; + ctx.fill(); + ctx.restore(); + } + ctx.restore(); + } +} + +function hexToRgba(hex, alpha) { + // Handle named-ish values or full hex + const c = hex.startsWith('#') ? hex : '#ffffff'; + const r = parseInt(c.slice(1,3), 16); + const g = parseInt(c.slice(3,5), 16); + const b = parseInt(c.slice(5,7), 16); + return `rgba(${r},${g},${b},${alpha.toFixed(3)})`; +} + +customElements.define('starfield-bg', StarfieldBg); +</script> + +<script> +(function() { + const canvas = document.getElementById('globe'); + const ctx = canvas.getContext('2d'); + const W = canvas.width, H = canvas.height; + const R = W * 0.44; + const cx = W / 2, cy = H / 2; + + // Simplified continent outlines as lon/lat polygon chains (degrees). + // Each continent is an array of [lon, lat] points. + const continents = [ + // North America + [[-130,50],[-125,55],[-120,60],[-115,65],[-100,68],[-85,70],[-75,65],[-60,52],[-65,45],[-70,42],[-75,35],[-80,30],[-85,28],[-90,28],[-95,25],[-100,20],[-105,20],[-110,25],[-115,30],[-120,35],[-125,42],[-130,50]], + // South America + [[-80,10],[-75,5],[-70,5],[-65,0],[-60,-5],[-55,-5],[-50,-10],[-45,-15],[-40,-20],[-40,-25],[-42,-30],[-48,-32],[-52,-34],[-55,-38],[-60,-42],[-65,-50],[-68,-55],[-70,-48],[-72,-40],[-75,-30],[-78,-15],[-80,-5],[-80,5],[-80,10]], + // Europe + [[-10,36],[-5,38],[0,40],[2,43],[5,44],[8,46],[10,48],[15,50],[18,54],[20,56],[25,58],[28,60],[30,62],[35,65],[40,68],[38,60],[35,55],[30,50],[28,48],[25,45],[22,40],[20,38],[15,36],[10,36],[5,36],[0,36],[-5,36],[-10,36]], + // Africa + [[-15,14],[-17,16],[-15,22],[-12,28],[-5,32],[0,35],[5,37],[10,35],[15,32],[20,30],[25,30],[30,28],[35,25],[38,18],[40,12],[42,5],[44,0],[42,-5],[40,-12],[38,-18],[35,-25],[32,-30],[30,-34],[25,-33],[20,-30],[15,-28],[12,-20],[10,-10],[8,-5],[5,0],[2,5],[0,5],[-5,5],[-10,6],[-15,10],[-15,14]], + // Asia (simplified) + [[30,35],[35,38],[40,40],[45,42],[50,45],[55,48],[60,50],[65,55],[70,60],[75,65],[80,68],[90,70],[100,68],[110,65],[120,60],[125,55],[130,50],[135,45],[140,40],[138,35],[130,30],[120,25],[110,20],[105,15],[100,10],[95,12],[90,20],[85,22],[80,25],[75,28],[70,30],[65,35],[55,35],[45,35],[40,35],[35,35],[30,35]], + // Australia + [[115,-12],[120,-14],[125,-15],[130,-14],[135,-13],[138,-16],[140,-18],[145,-20],[148,-22],[150,-25],[152,-28],[150,-33],[148,-35],[145,-37],[140,-38],[135,-36],[130,-33],[125,-30],[120,-25],[118,-22],[116,-20],[114,-18],[115,-15],[115,-12]], + ]; + + function project(lon, lat, rotation) { + // Convert to radians and apply rotation + var lonR = (lon + rotation) * Math.PI / 180; + var latR = lat * Math.PI / 180; + + var x3 = Math.cos(latR) * Math.sin(lonR); + var y3 = -Math.sin(latR); + var z3 = Math.cos(latR) * Math.cos(lonR); + + // Only visible if facing us + if (z3 < 0) return null; + + return { x: cx + x3 * R, y: cy + y3 * R, z: z3 }; + } + + var rotation = 0; + + function draw() { + requestAnimationFrame(draw); + rotation += 0.15; + ctx.clearRect(0, 0, W, H); + + // Atmosphere glow + var atm = ctx.createRadialGradient(cx, cy, R * 0.85, cx, cy, R * 1.15); + atm.addColorStop(0, 'rgba(60,130,255,0.12)'); + atm.addColorStop(0.5, 'rgba(60,130,255,0.06)'); + atm.addColorStop(1, 'rgba(60,130,255,0)'); + ctx.beginPath(); + ctx.arc(cx, cy, R * 1.15, 0, Math.PI * 2); + ctx.fillStyle = atm; + ctx.fill(); + + // Ocean sphere + var oceanGrad = ctx.createRadialGradient(cx - R * 0.3, cy - R * 0.3, R * 0.1, cx, cy, R); + oceanGrad.addColorStop(0, '#1a4a8a'); + oceanGrad.addColorStop(0.5, '#0e2d5e'); + oceanGrad.addColorStop(1, '#071838'); + ctx.beginPath(); + ctx.arc(cx, cy, R, 0, Math.PI * 2); + ctx.fillStyle = oceanGrad; + ctx.fill(); + + // Draw continents + for (var c = 0; c < continents.length; c++) { + var pts = continents[c]; + var projected = []; + var allVisible = true; + + for (var i = 0; i < pts.length; i++) { + var p = project(pts[i][0], pts[i][1], rotation); + if (!p) { allVisible = false; break; } + projected.push(p); + } + + if (!allVisible || projected.length < 3) continue; + + ctx.beginPath(); + ctx.moveTo(projected[0].x, projected[0].y); + for (var i = 1; i < projected.length; i++) { + ctx.lineTo(projected[i].x, projected[i].y); + } + ctx.closePath(); + + // Shade based on average depth + var avgZ = 0; + for (var i = 0; i < projected.length; i++) avgZ += projected[i].z; + avgZ /= projected.length; + var brightness = 0.3 + avgZ * 0.7; + + var r = Math.round(30 * brightness); + var g = Math.round(100 * brightness); + var b = Math.round(50 * brightness); + ctx.fillStyle = 'rgb(' + r + ',' + g + ',' + b + ')'; + ctx.fill(); + } + + // Grid lines (longitude) + ctx.strokeStyle = 'rgba(100,160,255,0.08)'; + ctx.lineWidth = 0.7; + for (var lon = -180; lon < 180; lon += 30) { + ctx.beginPath(); + var started = false; + for (var lat = -90; lat <= 90; lat += 3) { + var p = project(lon, lat, rotation); + if (p) { + if (!started) { ctx.moveTo(p.x, p.y); started = true; } + else ctx.lineTo(p.x, p.y); + } else { + started = false; + } + } + ctx.stroke(); + } + + // Grid lines (latitude) + for (var lat = -60; lat <= 60; lat += 30) { + ctx.beginPath(); + var started = false; + for (var lon = -180; lon <= 180; lon += 3) { + var p = project(lon, lat, rotation); + if (p) { + if (!started) { ctx.moveTo(p.x, p.y); started = true; } + else ctx.lineTo(p.x, p.y); + } else { + started = false; + } + } + ctx.stroke(); + } + + // Specular highlight + var spec = ctx.createRadialGradient(cx - R * 0.35, cy - R * 0.35, 0, cx - R * 0.35, cy - R * 0.35, R * 0.8); + spec.addColorStop(0, 'rgba(180,210,255,0.18)'); + spec.addColorStop(0.4, 'rgba(120,160,255,0.05)'); + spec.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.beginPath(); + ctx.arc(cx, cy, R, 0, Math.PI * 2); + ctx.fillStyle = spec; + ctx.fill(); + + // Rim light + ctx.beginPath(); + ctx.arc(cx, cy, R, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(80,140,255,0.2)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + + draw(); +})(); +</script> +</body> +</html> diff --git a/src/zenserver/frontend/html/banner.js b/src/zenserver/frontend/html/banner.js new file mode 100644 index 000000000..2e878dedf --- /dev/null +++ b/src/zenserver/frontend/html/banner.js @@ -0,0 +1,338 @@ +/** + * zen-banner.js — Zen dashboard banner Web Component + * + * Usage: + * <script src="banner.js" defer></script> + * + * <zen-banner></zen-banner> + * <zen-banner variant="compact"></zen-banner> + * <zen-banner cluster-status="degraded" load="78"></zen-banner> + * + * Attributes: + * variant "full" (default) | "compact" + * cluster-status "nominal" (default) | "degraded" | "offline" + * load 0–100 integer, shown as a percentage (default: hidden) + * tagline custom tagline text (default: "Orchestrator Overview" / "Orchestrator") + * subtitle text after "ZEN" in the wordmark (default: "COMPUTE") + */ + +class ZenBanner extends HTMLElement { + + static get observedAttributes() { + return ['variant', 'cluster-status', 'load', 'tagline', 'subtitle', 'logo-src']; + } + + attributeChangedCallback() { + if (this.shadowRoot) this._render(); + } + + connectedCallback() { + if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); + this._render(); + } + + // ───────────────────────────────────────────── + // Derived values + // ───────────────────────────────────────────── + + get _variant() { return this.getAttribute('variant') || 'full'; } + get _status() { return (this.getAttribute('cluster-status') || 'nominal').toLowerCase(); } + get _load() { return this.getAttribute('load'); } // null → hidden + get _tagline() { return this.getAttribute('tagline'); } // null → default + get _subtitle() { return this.getAttribute('subtitle'); } // null → "COMPUTE" + get _logoSrc() { return this.getAttribute('logo-src'); } // null → inline SVG + + get _statusColor() { + return { nominal: '#7ecfb8', degraded: '#d4a84b', offline: '#c0504d' }[this._status] ?? '#7ecfb8'; + } + + get _statusLabel() { + return { nominal: 'NOMINAL', degraded: 'DEGRADED', offline: 'OFFLINE' }[this._status] ?? 'NOMINAL'; + } + + get _loadColor() { + const v = parseInt(this._load, 10); + if (isNaN(v)) return '#7ecfb8'; + if (v >= 85) return '#c0504d'; + if (v >= 60) return '#d4a84b'; + return '#7ecfb8'; + } + + // ───────────────────────────────────────────── + // Render + // ───────────────────────────────────────────── + + _render() { + const compact = this._variant === 'compact'; + this.shadowRoot.innerHTML = ` + <style>${this._css(compact)}</style> + ${this._html(compact)} + `; + } + + // ───────────────────────────────────────────── + // CSS + // ───────────────────────────────────────────── + + _css(compact) { + const height = compact ? '60px' : '100px'; + const padding = compact ? '0 24px' : '0 32px'; + const gap = compact ? '16px' : '24px'; + const markSize = compact ? '34px' : '52px'; + const divH = compact ? '32px' : '48px'; + const nameSize = compact ? '15px' : '22px'; + const tagSize = compact ? '9px' : '11px'; + const sc = this._statusColor; + const lc = this._loadColor; + + return ` + @import url('https://fonts.googleapis.com/css2?family=Noto+Serif+JP:wght@300;400&family=Space+Mono:wght@400;700&display=swap'); + + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + + :host { + display: block; + font-family: 'Space Mono', monospace; + } + + .banner { + width: 100%; + height: ${height}; + background: var(--theme_g3, #0b0d10); + border: 1px solid var(--theme_g2, #1e2330); + border-radius: 6px; + display: flex; + align-items: center; + padding: ${padding}; + gap: ${gap}; + position: relative; + overflow: hidden; + text-decoration: none; + color: inherit; + cursor: pointer; + } + + /* scan-line texture */ + .banner::before { + content: ''; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 0deg, + transparent, transparent 3px, + rgba(255,255,255,0.012) 3px, rgba(255,255,255,0.012) 4px + ); + pointer-events: none; + } + + /* ambient glow */ + .banner::after { + content: ''; + position: absolute; + right: -60px; + top: 50%; + transform: translateY(-50%); + width: 280px; + height: 280px; + background: radial-gradient(circle, rgba(130,200,180,0.06) 0%, transparent 70%); + pointer-events: none; + } + + .logo-mark { + flex-shrink: 0; + width: ${markSize}; + height: ${markSize}; + } + + .logo-mark svg, .logo-mark img { width: 100%; height: 100%; object-fit: contain; } + + .divider { + width: 1px; + height: ${divH}; + background: linear-gradient(to bottom, transparent, var(--theme_g2, #2a3040), transparent); + flex-shrink: 0; + } + + .text-block { + display: flex; + flex-direction: column; + gap: 4px; + } + + .wordmark { + font-weight: 700; + font-size: ${nameSize}; + letter-spacing: 0.12em; + color: var(--theme_bright, #e8e4dc); + text-transform: uppercase; + line-height: 1; + } + + .wordmark span { color: #7ecfb8; } + + .tagline { + font-family: 'Noto Serif JP', serif; + font-weight: 300; + font-size: ${tagSize}; + letter-spacing: 0.3em; + color: var(--theme_faint, #4a5a68); + text-transform: uppercase; + } + + .spacer { flex: 1; } + + /* ── right-side decorative circuit ── */ + .circuit { flex-shrink: 0; opacity: 0.22; } + + /* ── status cluster ── */ + .status-cluster { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + } + + .status-row { + display: flex; + align-items: center; + gap: 8px; + } + + .status-lbl { + font-size: 9px; + letter-spacing: 0.18em; + color: var(--theme_faint, #3a4555); + text-transform: uppercase; + } + + .pill { + display: flex; + align-items: center; + gap: 5px; + border-radius: 20px; + padding: 2px 10px; + font-size: 10px; + letter-spacing: 0.1em; + } + + .pill.cluster { + color: ${sc}; + background: color-mix(in srgb, ${sc} 8%, transparent); + border: 1px solid color-mix(in srgb, ${sc} 28%, transparent); + } + + .pill.load-pill { + color: ${lc}; + background: color-mix(in srgb, ${lc} 8%, transparent); + border: 1px solid color-mix(in srgb, ${lc} 28%, transparent); + } + + .dot { + width: 5px; + height: 5px; + border-radius: 50%; + animation: pulse 2.4s ease-in-out infinite; + } + + .dot.cluster { background: ${sc}; } + .dot.load-dot { background: ${lc}; animation-delay: 0.5s; } + + @keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.25; } + } + `; + } + + // ───────────────────────────────────────────── + // HTML template + // ───────────────────────────────────────────── + + _html(compact) { + const loadAttr = this._load; + const hasCluster = !compact && this.hasAttribute('cluster-status'); + const hasLoad = !compact && loadAttr !== null; + const showRight = hasCluster || hasLoad; + + const circuit = showRight ? ` + <svg class="circuit" width="60" height="60" viewBox="0 0 60 60" fill="none"> + <path d="M5 30 H22 L28 18 H60" stroke="#7ecfb8" stroke-width="0.8"/> + <path d="M5 38 H18 L24 46 H60" stroke="#7ecfb8" stroke-width="0.8"/> + <circle cx="22" cy="30" r="2" fill="none" stroke="#7ecfb8" stroke-width="0.8"/> + <circle cx="18" cy="38" r="2" fill="none" stroke="#7ecfb8" stroke-width="0.8"/> + <circle cx="10" cy="30" r="1.2" fill="#7ecfb8"/> + <circle cx="10" cy="38" r="1.2" fill="#7ecfb8"/> + </svg>` : ''; + + const clusterRow = hasCluster ? ` + <div class="status-row"> + <span class="status-lbl">Cluster</span> + <div class="pill cluster"> + <div class="dot cluster"></div> + ${this._statusLabel} + </div> + </div>` : ''; + + const loadRow = hasLoad ? ` + <div class="status-row"> + <span class="status-lbl">Load</span> + <div class="pill load-pill"> + <div class="dot load-dot"></div> + ${parseInt(loadAttr, 10)} % + </div> + </div>` : ''; + + const rightSide = showRight ? ` + ${circuit} + <div class="status-cluster"> + ${clusterRow} + ${loadRow} + </div> + ` : ''; + + return ` + <a class="banner" href="/dashboard/"> + <div class="logo-mark">${this._logoMark()}</div> + <div class="divider"></div> + <div class="text-block"> + <div class="wordmark">ZEN<span> ${this._subtitle ?? 'COMPUTE'}</span></div> + <div class="tagline">${this._tagline ?? (compact ? 'Orchestrator' : 'Orchestrator Overview')}</div> + </div> + <div class="spacer"></div> + ${rightSide} + </a> + `; + } + + // ───────────────────────────────────────────── + // SVG logo mark + // ───────────────────────────────────────────── + + _logoMark() { + const src = this._logoSrc; + if (src) { + return `<img src="${src}" alt="zen">`; + } + return ` + <svg viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg"> + <circle cx="26" cy="26" r="22" stroke="#2a3a48" stroke-width="1.5"/> + <path d="M26 4 A22 22 0 1 1 12 43.1" stroke="#7ecfb8" stroke-width="2" stroke-linecap="round" fill="none"/> + <circle cx="17" cy="17" r="1.6" fill="#7ecfb8" /> + <circle cx="26" cy="17" r="1.6" fill="#7ecfb8" /> + <circle cx="35" cy="17" r="1.6" fill="#7ecfb8" /> + <circle cx="17" cy="26" r="1.6" fill="#7ecfb8" opacity="0.6"/> + <circle cx="26" cy="26" r="2.2" fill="#7ecfb8"/> + <circle cx="35" cy="26" r="1.6" fill="#7ecfb8" opacity="0.6"/> + <circle cx="17" cy="35" r="1.6" fill="#7ecfb8"/> + <circle cx="26" cy="35" r="1.6" fill="#7ecfb8"/> + <circle cx="35" cy="35" r="1.6" fill="#7ecfb8"/> + <line x1="17" y1="17" x2="35" y2="17" stroke="#7ecfb8" stroke-width="0.7" stroke-opacity="0.25"/> + <line x1="35" y1="17" x2="17" y2="35" stroke="#7ecfb8" stroke-width="0.7" stroke-opacity="0.25"/> + <line x1="17" y1="35" x2="35" y2="35" stroke="#7ecfb8" stroke-width="0.7" stroke-opacity="0.2"/> + <line x1="26" y1="17" x2="26" y2="35" stroke="#7ecfb8" stroke-width="0.7" stroke-opacity="0.2"/> + </svg> + `; + } +} + +customElements.define('zen-banner', ZenBanner); diff --git a/src/zenserver/frontend/html/compute/compute.html b/src/zenserver/frontend/html/compute/compute.html new file mode 100644 index 000000000..66c20175f --- /dev/null +++ b/src/zenserver/frontend/html/compute/compute.html @@ -0,0 +1,929 @@ +<!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="../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 escapeHtml(text) { + var div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + function formatRate(rate) { + return rate.toFixed(2) + '/s'; + } + + function showError(message) { + const container = document.getElementById('error-container'); + container.innerHTML = `<div class="error">Error: ${escapeHtml(message)}</div>`; + } + + function clearError() { + document.getElementById('error-container').innerHTML = ''; + } + + function updateTimestamp() { + const now = new Date(); + document.getElementById('last-update').textContent = now.toLocaleTimeString(); + } + + // Fetch functions + async function fetchJSON(endpoint) { + const response = await fetch(`${BASE_URL}${endpoint}`, { + headers: { + 'Accept': 'application/json' + } + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return await response.json(); + } + + async function fetchHealth() { + try { + const response = await fetch(`${BASE_URL}/compute/ready`); + const isHealthy = response.status === 200; + + const banner = document.querySelector('zen-banner'); + + if (isHealthy) { + banner.setAttribute('cluster-status', 'nominal'); + banner.setAttribute('load', '0'); + } else { + banner.setAttribute('cluster-status', 'degraded'); + banner.setAttribute('load', '0'); + } + + return isHealthy; + } catch (error) { + const banner = document.querySelector('zen-banner'); + banner.setAttribute('cluster-status', 'degraded'); + banner.setAttribute('load', '0'); + throw error; + } + } + + async function fetchStats() { + const data = await fetchJSON('/stats/compute'); + + // Update action counts + document.getElementById('actions-pending').textContent = data.actions_pending || 0; + document.getElementById('actions-running').textContent = data.actions_submitted || 0; + document.getElementById('actions-complete').textContent = data.actions_complete || 0; + + // Update completion rates + if (data.actions_retired) { + document.getElementById('rate-1').textContent = formatRate(data.actions_retired.rate_1 || 0); + document.getElementById('rate-5').textContent = formatRate(data.actions_retired.rate_5 || 0); + document.getElementById('rate-15').textContent = formatRate(data.actions_retired.rate_15 || 0); + document.getElementById('retired-count').textContent = data.actions_retired.count || 0; + document.getElementById('rate-mean').textContent = formatRate(data.actions_retired.rate_mean || 0); + } + + // Update chart + const now = new Date().toLocaleTimeString(); + history.timestamps.push(now); + history.pending.push(data.actions_pending || 0); + history.running.push(data.actions_submitted || 0); + history.completed.push(data.actions_complete || 0); + + // Keep only last N points + if (history.timestamps.length > MAX_HISTORY_POINTS) { + history.timestamps.shift(); + history.pending.shift(); + history.running.shift(); + history.completed.shift(); + } + + chart.data.labels = history.timestamps; + chart.data.datasets[0].data = history.pending; + chart.data.datasets[1].data = history.running; + chart.data.datasets[2].data = history.completed; + chart.update('none'); + } + + async function fetchSysInfo() { + const data = await fetchJSON('/compute/sysinfo'); + + // Update CPU + const cpuUsage = data.cpu_usage || 0; + document.getElementById('cpu-usage').textContent = cpuUsage.toFixed(1) + '%'; + document.getElementById('cpu-progress').style.width = cpuUsage + '%'; + + const banner = document.querySelector('zen-banner'); + banner.setAttribute('load', cpuUsage.toFixed(1)); + + history.cpu.push(cpuUsage); + if (history.cpu.length > MAX_HISTORY_POINTS) history.cpu.shift(); + cpuChart.data.labels = history.cpu.map(() => ''); + cpuChart.data.datasets[0].data = history.cpu; + cpuChart.update('none'); + + document.getElementById('cpu-packages').textContent = data.cpu_count ?? '-'; + document.getElementById('cpu-cores').textContent = data.core_count ?? '-'; + document.getElementById('cpu-lp').textContent = data.lp_count ?? '-'; + + // Update Memory + const memUsed = data.memory_used || 0; + const memTotal = data.memory_total || 1; + const memPercent = (memUsed / memTotal) * 100; + document.getElementById('memory-used').textContent = formatBytes(memUsed); + document.getElementById('memory-total').textContent = formatBytes(memTotal); + document.getElementById('memory-progress').style.width = memPercent + '%'; + + // Update Disk + const diskUsed = data.disk_used || 0; + const diskTotal = data.disk_total || 1; + const diskPercent = (diskUsed / diskTotal) * 100; + document.getElementById('disk-used').textContent = formatBytes(diskUsed); + document.getElementById('disk-total').textContent = formatBytes(diskTotal); + document.getElementById('disk-progress').style.width = diskPercent + '%'; + } + + // Persists the selected worker ID across refreshes + let selectedWorkerId = null; + + function renderWorkerDetail(id, desc) { + const panel = document.getElementById('worker-detail'); + + if (!desc) { + panel.style.display = 'none'; + return; + } + + function field(label, value) { + return `<tr><td>${label}</td><td>${value ?? '-'}</td></tr>`; + } + + function monoField(label, value) { + return `<tr><td>${label}</td><td class="detail-mono">${value ?? '-'}</td></tr>`; + } + + // Functions + const functions = desc.functions || []; + const functionsHtml = functions.length === 0 ? '<span style="color: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 new file mode 100644 index 000000000..32e1b05db --- /dev/null +++ b/src/zenserver/frontend/html/compute/hub.html @@ -0,0 +1,170 @@ +<!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="../theme.js"></script> + <script src="../banner.js" defer></script> + <script src="../nav.js" defer></script> + <title>Zen Hub Dashboard</title> +</head> +<body> + <div class="container" style="max-width: 1400px; margin: 0 auto;"> + <zen-banner cluster-status="nominal" subtitle="HUB" tagline="Overview" logo-src="../favicon.ico"></zen-banner> + <zen-nav> + <a href="/dashboard/">Home</a> + <a href="hub.html">Hub</a> + </zen-nav> + <div class="timestamp">Last updated: <span id="last-update">Never</span></div> + + <div id="error-container"></div> + + <div class="section-title">Capacity</div> + <div class="grid"> + <div class="card"> + <div class="card-title">Active Modules</div> + <div class="metric-value" id="instance-count">-</div> + <div class="metric-label">Currently provisioned</div> + </div> + <div class="card"> + <div class="card-title">Peak Modules</div> + <div class="metric-value" id="max-instance-count">-</div> + <div class="metric-label">High watermark</div> + </div> + <div class="card"> + <div class="card-title">Instance Limit</div> + <div class="metric-value" id="instance-limit">-</div> + <div class="metric-label">Maximum allowed</div> + <div class="progress-bar"> + <div class="progress-fill" id="capacity-progress" style="width: 0%"></div> + </div> + </div> + </div> + + <div class="section-title">Modules</div> + <div class="card"> + <div class="card-title">Storage Server Instances</div> + <div id="empty-state" class="empty-state">No modules provisioned.</div> + <table id="module-table" style="display: none;"> + <thead> + <tr> + <th>Module ID</th> + <th style="text-align: center;">Status</th> + </tr> + </thead> + <tbody id="module-table-body"></tbody> + </table> + </div> + </div> + + <script> + const BASE_URL = window.location.origin; + const REFRESH_INTERVAL = 2000; + + function escapeHtml(text) { + var div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function showError(message) { + document.getElementById('error-container').innerHTML = + '<div class="error">Error: ' + escapeHtml(message) + '</div>'; + } + + function clearError() { + document.getElementById('error-container').innerHTML = ''; + } + + async function fetchJSON(endpoint) { + var response = await fetch(BASE_URL + endpoint, { + headers: { 'Accept': 'application/json' } + }); + if (!response.ok) { + throw new Error('HTTP ' + response.status + ': ' + response.statusText); + } + return await response.json(); + } + + async function fetchStats() { + var data = await fetchJSON('/hub/stats'); + + var current = data.currentInstanceCount || 0; + var max = data.maxInstanceCount || 0; + var limit = data.instanceLimit || 0; + + document.getElementById('instance-count').textContent = current; + document.getElementById('max-instance-count').textContent = max; + document.getElementById('instance-limit').textContent = limit; + + var pct = limit > 0 ? (current / limit) * 100 : 0; + document.getElementById('capacity-progress').style.width = pct + '%'; + + var banner = document.querySelector('zen-banner'); + if (current === 0) { + banner.setAttribute('cluster-status', 'nominal'); + } else if (limit > 0 && current >= limit * 0.9) { + banner.setAttribute('cluster-status', 'degraded'); + } else { + banner.setAttribute('cluster-status', 'nominal'); + } + } + + async function fetchModules() { + var data = await fetchJSON('/hub/status'); + var modules = data.modules || []; + + var emptyState = document.getElementById('empty-state'); + var table = document.getElementById('module-table'); + var tbody = document.getElementById('module-table-body'); + + if (modules.length === 0) { + emptyState.style.display = ''; + table.style.display = 'none'; + return; + } + + emptyState.style.display = 'none'; + table.style.display = ''; + + tbody.innerHTML = ''; + for (var i = 0; i < modules.length; i++) { + var m = modules[i]; + var moduleId = m.moduleId || ''; + var provisioned = m.provisioned; + + var badge = provisioned + ? '<span class="status-badge active">Provisioned</span>' + : '<span class="status-badge inactive">Inactive</span>'; + + var tr = document.createElement('tr'); + tr.innerHTML = + '<td style="font-family: monospace; font-size: 12px;">' + escapeHtml(moduleId) + '</td>' + + '<td style="text-align: center;">' + badge + '</td>'; + tbody.appendChild(tr); + } + } + + async function updateDashboard() { + var banner = document.querySelector('zen-banner'); + try { + await Promise.all([ + fetchStats(), + fetchModules() + ]); + + clearError(); + document.getElementById('last-update').textContent = new Date().toLocaleTimeString(); + } catch (error) { + console.error('Error updating dashboard:', error); + showError(error.message); + banner.setAttribute('cluster-status', 'offline'); + } + } + + updateDashboard(); + setInterval(updateDashboard, REFRESH_INTERVAL); + </script> +</body> +</html> diff --git a/src/zenserver/frontend/html/compute/index.html b/src/zenserver/frontend/html/compute/index.html new file mode 100644 index 000000000..9597fd7f3 --- /dev/null +++ b/src/zenserver/frontend/html/compute/index.html @@ -0,0 +1 @@ +<meta http-equiv="refresh" content="0; url=compute.html" />
\ No newline at end of file diff --git a/src/zenserver/frontend/html/compute/orchestrator.html b/src/zenserver/frontend/html/compute/orchestrator.html new file mode 100644 index 000000000..a519dee18 --- /dev/null +++ b/src/zenserver/frontend/html/compute/orchestrator.html @@ -0,0 +1,674 @@ +<!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="../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 escapeHtml(text) { + var div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + function showError(message) { + document.getElementById('error-container').innerHTML = + '<div class="error">Error: ' + escapeHtml(message) + '</div>'; + } + + function clearError() { + document.getElementById('error-container').innerHTML = ''; + } + + function formatLastSeen(dtMs) { + if (dtMs == null) return '-'; + var seconds = Math.floor(dtMs / 1000); + if (seconds < 60) return seconds + 's ago'; + var minutes = Math.floor(seconds / 60); + if (minutes < 60) return minutes + 'm ' + (seconds % 60) + 's ago'; + var hours = Math.floor(minutes / 60); + return hours + 'h ' + (minutes % 60) + 'm ago'; + } + + function healthClass(dtMs, reachable) { + if (reachable === false) return 'health-red'; + if (dtMs == null) return 'health-red'; + var seconds = dtMs / 1000; + if (seconds < 30 && reachable === true) return 'health-green'; + if (seconds < 120) return 'health-yellow'; + return 'health-red'; + } + + function healthTitle(dtMs, reachable) { + var seenStr = dtMs != null ? 'Last seen ' + formatLastSeen(dtMs) : 'Never seen'; + if (reachable === true) return seenStr + ' · Reachable'; + if (reachable === false) return seenStr + ' · Unreachable'; + return seenStr + ' · Reachability unknown'; + } + + function formatCpuUsage(percent) { + if (percent == null || percent === 0) return '-'; + return percent.toFixed(1) + '%'; + } + + function formatMemory(usedBytes, totalBytes) { + if (!totalBytes) return '-'; + var usedGiB = usedBytes / (1024 * 1024 * 1024); + var totalGiB = totalBytes / (1024 * 1024 * 1024); + return usedGiB.toFixed(1) + ' / ' + totalGiB.toFixed(1) + ' GiB'; + } + + function formatBytes(bytes) { + if (!bytes) return '-'; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KiB'; + if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MiB'; + if (bytes < 1024 * 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GiB'; + return (bytes / (1024 * 1024 * 1024 * 1024)).toFixed(1) + ' TiB'; + } + + function formatTraffic(recv, sent) { + if (!recv && !sent) return '-'; + return formatBytes(recv) + ' / ' + formatBytes(sent); + } + + function parseIpFromUri(uri) { + try { + var url = new URL(uri); + var host = url.hostname; + // Strip IPv6 brackets + if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1); + // Only handle IPv4 + var parts = host.split('.'); + if (parts.length !== 4) return null; + var octets = parts.map(Number); + if (octets.some(function(o) { return isNaN(o) || o < 0 || o > 255; })) return null; + return octets; + } catch (e) { + return null; + } + } + + function computeCidr(ips) { + if (ips.length === 0) return null; + if (ips.length === 1) return ips[0].join('.') + '/32'; + + // Convert each IP to a 32-bit integer + var ints = ips.map(function(o) { + return ((o[0] << 24) | (o[1] << 16) | (o[2] << 8) | o[3]) >>> 0; + }); + + // Find common prefix length by ANDing all identical high bits + var common = ~0 >>> 0; + for (var i = 1; i < ints.length; i++) { + // XOR to find differing bits, then mask away everything from the first difference down + var diff = (ints[0] ^ ints[i]) >>> 0; + if (diff !== 0) { + var bit = 31 - Math.floor(Math.log2(diff)); + var mask = bit > 0 ? ((~0 << (32 - bit)) >>> 0) : 0; + common = (common & mask) >>> 0; + } + } + + // Count leading ones in the common mask + var prefix = 0; + for (var b = 31; b >= 0; b--) { + if ((common >>> b) & 1) prefix++; + else break; + } + + // Network address + var net = (ints[0] & common) >>> 0; + var a = (net >>> 24) & 0xff; + var bv = (net >>> 16) & 0xff; + var c = (net >>> 8) & 0xff; + var d = net & 0xff; + return a + '.' + bv + '.' + c + '.' + d + '/' + prefix; + } + + function renderDashboard(data) { + var banner = document.querySelector('zen-banner'); + if (data.hostname) { + banner.setAttribute('tagline', 'Orchestrator \u2014 ' + data.hostname); + } + var workers = data.workers || []; + + document.getElementById('agent-count').textContent = workers.length; + + if (workers.length === 0) { + banner.setAttribute('cluster-status', 'degraded'); + banner.setAttribute('load', '0'); + } else { + banner.setAttribute('cluster-status', 'nominal'); + } + + var emptyState = document.getElementById('empty-state'); + var table = document.getElementById('agent-table'); + var tbody = document.getElementById('agent-table-body'); + + if (workers.length === 0) { + emptyState.style.display = ''; + table.style.display = 'none'; + } else { + emptyState.style.display = 'none'; + table.style.display = ''; + + tbody.innerHTML = ''; + var totalCpus = 0; + var totalWeightedCpuUsage = 0; + var totalMemUsed = 0; + var totalMemTotal = 0; + var totalQueues = 0; + var totalPending = 0; + var totalRunning = 0; + var totalCompleted = 0; + var totalBytesRecv = 0; + var totalBytesSent = 0; + var allIps = []; + for (var i = 0; i < workers.length; i++) { + var w = workers[i]; + var uri = w.uri || ''; + var dt = w.dt; + var dashboardUrl = uri + '/dashboard/compute/'; + + var id = w.id || ''; + + var hostname = w.hostname || ''; + var cpus = w.cpus || 0; + totalCpus += cpus; + if (cpus > 0 && typeof w.cpu_usage === 'number') { + totalWeightedCpuUsage += w.cpu_usage * cpus; + } + + var memTotal = w.memory_total || 0; + var memUsed = w.memory_used || 0; + totalMemTotal += memTotal; + totalMemUsed += memUsed; + + var activeQueues = w.active_queues || 0; + totalQueues += activeQueues; + + var actionsPending = w.actions_pending || 0; + var actionsRunning = w.actions_running || 0; + var actionsCompleted = w.actions_completed || 0; + totalPending += actionsPending; + totalRunning += actionsRunning; + totalCompleted += actionsCompleted; + + var bytesRecv = w.bytes_received || 0; + var bytesSent = w.bytes_sent || 0; + totalBytesRecv += bytesRecv; + totalBytesSent += bytesSent; + + var ip = parseIpFromUri(uri); + if (ip) allIps.push(ip); + + var reachable = w.reachable; + var hClass = healthClass(dt, reachable); + var hTitle = healthTitle(dt, reachable); + + var platform = w.platform || ''; + var badges = ''; + if (platform) { + var platColors = { windows: '#0078d4', wine: '#722f37', linux: '#e95420', macos: '#a2aaad' }; + var platColor = platColors[platform] || '#8b949e'; + badges += ' <span style="display:inline-block;padding:1px 6px;border-radius:10px;font-size:10px;font-weight:600;color:#fff;background:' + platColor + ';vertical-align:middle;margin-left:4px;">' + escapeHtml(platform) + '</span>'; + } + var provisioner = w.provisioner || ''; + if (provisioner) { + var provColors = { horde: '#8957e5', nomad: '#3fb950' }; + var provColor = provColors[provisioner] || '#8b949e'; + badges += ' <span style="display:inline-block;padding:1px 6px;border-radius:10px;font-size:10px;font-weight:600;color:#fff;background:' + provColor + ';vertical-align:middle;margin-left:4px;">' + escapeHtml(provisioner) + '</span>'; + } + + var tr = document.createElement('tr'); + tr.title = id; + tr.innerHTML = + '<td style="text-align: center;"><span class="health-dot ' + hClass + '" title="' + escapeHtml(hTitle) + '"></span></td>' + + '<td><a href="' + escapeHtml(dashboardUrl) + '" target="_blank">' + escapeHtml(hostname) + '</a>' + badges + '</td>' + + '<td style="text-align: right;">' + (cpus > 0 ? cpus : '-') + '</td>' + + '<td style="text-align: right;">' + formatCpuUsage(w.cpu_usage) + '</td>' + + '<td style="text-align: right;">' + formatMemory(memUsed, memTotal) + '</td>' + + '<td style="text-align: right;">' + (activeQueues > 0 ? activeQueues : '-') + '</td>' + + '<td style="text-align: right;">' + actionsPending + '</td>' + + '<td style="text-align: right;">' + actionsRunning + '</td>' + + '<td style="text-align: right;">' + actionsCompleted + '</td>' + + '<td style="text-align: right; 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/epicgames.ico b/src/zenserver/frontend/html/epicgames.ico Binary files differnew file mode 100644 index 000000000..1cfa301a2 --- /dev/null +++ b/src/zenserver/frontend/html/epicgames.ico diff --git a/src/zenserver/frontend/html/favicon.ico b/src/zenserver/frontend/html/favicon.ico Binary files differindex 1cfa301a2..f7fb251b5 100644 --- a/src/zenserver/frontend/html/favicon.ico +++ b/src/zenserver/frontend/html/favicon.ico diff --git a/src/zenserver/frontend/html/index.html b/src/zenserver/frontend/html/index.html index 6a736e914..24a136a30 100644 --- a/src/zenserver/frontend/html/index.html +++ b/src/zenserver/frontend/html/index.html @@ -10,6 +10,9 @@ </script> <link rel="shortcut icon" href="favicon.ico"> <link rel="stylesheet" type="text/css" href="zen.css" /> + <script src="theme.js"></script> + <script src="banner.js" defer></script> + <script src="nav.js" defer></script> <script type="module" src="zen.js"></script> </head> </html> diff --git a/src/zenserver/frontend/html/nav.js b/src/zenserver/frontend/html/nav.js new file mode 100644 index 000000000..a5de203f2 --- /dev/null +++ b/src/zenserver/frontend/html/nav.js @@ -0,0 +1,79 @@ +/** + * zen-nav.js — Zen dashboard navigation bar Web Component + * + * Usage: + * <script src="nav.js" defer></script> + * + * <zen-nav> + * <a href="compute.html">Node</a> + * <a href="orchestrator.html">Orchestrator</a> + * </zen-nav> + * + * Each child <a> becomes a nav link. The current page is + * highlighted automatically based on the href. + */ + +class ZenNav extends HTMLElement { + + connectedCallback() { + if (!this.shadowRoot) this.attachShadow({ mode: 'open' }); + this._render(); + } + + _render() { + const currentPath = window.location.pathname; + const items = Array.from(this.querySelectorAll(':scope > a')); + + const links = items.map(a => { + const href = a.getAttribute('href') || ''; + const label = a.textContent.trim(); + const active = currentPath.endsWith(href); + return `<a class="nav-link${active ? ' active' : ''}" href="${href}">${label}</a>`; + }).join(''); + + this.shadowRoot.innerHTML = ` + <style> + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + + :host { + display: block; + margin-bottom: 16px; + } + + .nav-bar { + display: flex; + align-items: center; + gap: 4px; + padding: 4px; + background: var(--theme_g3); + border: 1px solid var(--theme_g2); + border-radius: 6px; + } + + .nav-link { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + font-size: 13px; + font-weight: 500; + color: var(--theme_g1); + text-decoration: none; + padding: 6px 14px; + border-radius: 4px; + transition: color 0.15s, background 0.15s; + } + + .nav-link:hover { + color: var(--theme_g0); + background: var(--theme_p4); + } + + .nav-link.active { + color: var(--theme_bright); + background: var(--theme_g2); + } + </style> + <nav class="nav-bar">${links}</nav> + `; + } +} + +customElements.define('zen-nav', ZenNav); diff --git a/src/zenserver/frontend/html/pages/cache.js b/src/zenserver/frontend/html/pages/cache.js new file mode 100644 index 000000000..3b838958a --- /dev/null +++ b/src/zenserver/frontend/html/pages/cache.js @@ -0,0 +1,690 @@ +// 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" +import { Modal } from "../util/modal.js" +import { Table, Toolbar } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("cache"); + + // Cache Service Stats + const stats_section = this._collapsible_section("Cache Service Stats"); + stats_section.tag().classify("dropall").text("raw yaml \u2192").on_click(() => { + window.open("/stats/z$.yaml?cidstorestats=true&cachestorestats=true", "_blank"); + }); + this._stats_grid = stats_section.tag().classify("grid").classify("stats-tiles"); + this._details_host = stats_section; + this._details_container = null; + this._selected_category = null; + + const stats = await new Fetcher().resource("stats", "z$").json(); + if (stats) + { + this._render_stats(stats); + } + + this._connect_stats_ws(); + + // 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", + "buckets", + "entries", + "size disk", + "size mem", + "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); + }); + } + + // Namespace detail area (inside namespaces section so it collapses together) + this._namespace_host = section; + this._namespace_container = null; + this._selected_namespace = null; + + // Restore namespace from URL if present + const ns_param = this.get_param("namespace"); + if (ns_param) + { + this.view_namespace(ns_param); + } + } + + _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; + } + + _connect_stats_ws() + { + try + { + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${proto}//${location.host}/stats`); + + try { this._ws_paused = localStorage.getItem("zen-ws-paused") === "true"; } catch (e) { this._ws_paused = false; } + document.addEventListener("zen-ws-toggle", (e) => { + this._ws_paused = e.detail.paused; + }); + + ws.onmessage = (ev) => { + if (this._ws_paused) + { + return; + } + try + { + const all_stats = JSON.parse(ev.data); + const stats = all_stats["z$"]; + if (stats) + { + this._render_stats(stats); + } + } + catch (e) { /* ignore parse errors */ } + }; + + ws.onclose = () => { this._stats_ws = null; }; + ws.onerror = () => { ws.close(); }; + + this._stats_ws = ws; + } + catch (e) { /* WebSocket not available */ } + } + + _render_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)"); + } + } + + // Hit/Miss tile + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Hit Ratio"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + const hits = safe(stats, "cache.hits") || 0; + const misses = safe(stats, "cache.misses") || 0; + const writes = safe(stats, "cache.writes") || 0; + const total = hits + misses; + const ratio = total > 0 ? ((hits / total) * 100).toFixed(1) + "%" : "-"; + + this._metric(left, ratio, "hit ratio", true); + this._metric(left, Friendly.sep(hits), "hits"); + this._metric(left, Friendly.sep(misses), "misses"); + this._metric(left, Friendly.sep(writes), "writes"); + + const right = columns.tag().classify("tile-metrics"); + const cidHits = safe(stats, "cache.cidhits") || 0; + const cidMisses = safe(stats, "cache.cidmisses") || 0; + const cidWrites = safe(stats, "cache.cidwrites") || 0; + const cidTotal = cidHits + cidMisses; + const cidRatio = cidTotal > 0 ? ((cidHits / cidTotal) * 100).toFixed(1) + "%" : "-"; + + this._metric(right, cidRatio, "cid hit ratio", true); + this._metric(right, Friendly.sep(cidHits), "cid hits"); + this._metric(right, Friendly.sep(cidMisses), "cid misses"); + this._metric(right, Friendly.sep(cidWrites), "cid writes"); + } + + // 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(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"); + } + } + } + + // 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 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"); + } + } + } + + // Storage tile + { + const tile = grid.tag().classify("card").classify("stats-tile").classify("stats-tile-detailed"); + if (this._selected_category === "storage") tile.classify("stats-tile-selected"); + tile.on_click(() => this._select_category("storage")); + tile.tag().classify("card-title").text("Storage"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + this._metric(left, safe(stats, "cache.size.disk") != null ? Friendly.bytes(safe(stats, "cache.size.disk")) : "-", "cache disk", true); + this._metric(left, safe(stats, "cache.size.memory") != null ? Friendly.bytes(safe(stats, "cache.size.memory")) : "-", "cache memory"); + + const right = columns.tag().classify("tile-metrics"); + this._metric(right, safe(stats, "cid.size.total") != null ? Friendly.bytes(safe(stats, "cid.size.total")) : "-", "cid total", true); + this._metric(right, safe(stats, "cid.size.tiny") != null ? Friendly.bytes(safe(stats, "cid.size.tiny")) : "-", "cid tiny"); + this._metric(right, safe(stats, "cid.size.small") != null ? Friendly.bytes(safe(stats, "cid.size.small")) : "-", "cid small"); + 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) + { + const upstream = safe(stats, "upstream"); + if (upstream) + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Upstream"); + const body = tile.tag().classify("tile-metrics"); + + const upstreamHits = safe(stats, "cache.upstream_hits") || 0; + this._metric(body, Friendly.sep(upstreamHits), "upstream hits", true); + + if (upstream.url) + { + this._metric(body, upstream.url, "endpoint"); + } + } + } + } + + _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); + } + + async _select_category(category) + { + // Toggle off if already selected + if (this._selected_category === category) + { + this._selected_category = null; + this._clear_details(); + this._render_stats(this._last_stats); + return; + } + + this._selected_category = category; + this._render_stats(this._last_stats); + + // Fetch detailed stats + const detailed = await new Fetcher() + .resource("stats", "z$") + .param("cachestorestats", "true") + .param("cidstorestats", "true") + .json(); + + if (!detailed || this._selected_category !== category) + { + return; + } + + this._clear_details(); + + const safe = (obj, path) => path.split(".").reduce((a, b) => a && a[b], obj); + + if (category === "store") + { + this._render_store_details(detailed, safe); + } + else if (category === "storage") + { + this._render_storage_details(detailed, safe); + } + } + + _clear_details() + { + if (this._details_container) + { + this._details_container.inner().remove(); + this._details_container = null; + } + } + + _render_store_details(stats, safe) + { + const namespaces = safe(stats, "cache.store.namespaces") || []; + if (namespaces.length === 0) + { + return; + } + + const container = this._details_host.tag(); + this._details_container = container; + + const columns = [ + "namespace", + "bucket", + "hits", + "misses", + "writes", + "hit ratio", + "read count", + "read bandwidth", + "write count", + "write bandwidth", + ]; + const table = new Table(container, columns, Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric); + + for (const ns of namespaces) + { + const nsHits = ns.hits || 0; + const nsMisses = ns.misses || 0; + const nsTotal = nsHits + nsMisses; + const nsRatio = nsTotal > 0 ? ((nsHits / nsTotal) * 100).toFixed(1) + "%" : "-"; + + const readCount = safe(ns, "read.request.count") || 0; + const readBytes = safe(ns, "read.bytes.count") || 0; + const writeCount = safe(ns, "write.request.count") || 0; + const writeBytes = safe(ns, "write.bytes.count") || 0; + + table.add_row( + ns.namespace, + "", + Friendly.sep(nsHits), + Friendly.sep(nsMisses), + Friendly.sep(ns.writes || 0), + nsRatio, + Friendly.sep(readCount), + Friendly.bytes(readBytes), + Friendly.sep(writeCount), + Friendly.bytes(writeBytes), + ); + + if (ns.buckets && ns.buckets.length > 0) + { + for (const bucket of ns.buckets) + { + const bHits = bucket.hits || 0; + const bMisses = bucket.misses || 0; + const bTotal = bHits + bMisses; + const bRatio = bTotal > 0 ? ((bHits / bTotal) * 100).toFixed(1) + "%" : "-"; + + const bReadCount = safe(bucket, "read.request.count") || 0; + const bReadBytes = safe(bucket, "read.bytes.count") || 0; + const bWriteCount = safe(bucket, "write.request.count") || 0; + const bWriteBytes = safe(bucket, "write.bytes.count") || 0; + + table.add_row( + ns.namespace, + bucket.bucket, + Friendly.sep(bHits), + Friendly.sep(bMisses), + Friendly.sep(bucket.writes || 0), + bRatio, + Friendly.sep(bReadCount), + Friendly.bytes(bReadBytes), + Friendly.sep(bWriteCount), + Friendly.bytes(bWriteBytes), + ); + } + } + } + } + + _render_storage_details(stats, safe) + { + const namespaces = safe(stats, "cache.store.namespaces") || []; + if (namespaces.length === 0) + { + return; + } + + const container = this._details_host.tag(); + this._details_container = container; + + const columns = [ + "namespace", + "bucket", + "disk", + "memory", + ]; + const table = new Table(container, columns, Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric); + + for (const ns of namespaces) + { + const diskSize = safe(ns, "size.disk") || 0; + const memSize = safe(ns, "size.memory") || 0; + + table.add_row( + ns.namespace, + "", + Friendly.bytes(diskSize), + Friendly.bytes(memSize), + ); + + if (ns.buckets && ns.buckets.length > 0) + { + for (const bucket of ns.buckets) + { + const bDisk = safe(bucket, "size.disk") || 0; + const bMem = safe(bucket, "size.memory") || 0; + + table.add_row( + ns.namespace, + bucket.bucket, + Friendly.bytes(bDisk), + Friendly.bytes(bMem), + ); + } + } + } + } + + async view_namespace(namespace) + { + // Toggle off if already selected + if (this._selected_namespace === namespace) + { + this._selected_namespace = null; + this._clear_namespace(); + this._clear_param("namespace"); + return; + } + + this._selected_namespace = namespace; + this._clear_namespace(); + this.set_param("namespace", namespace); + + const info = await new Fetcher().resource(`/z$/${namespace}/`).json(); + if (this._selected_namespace !== namespace) + { + return; + } + + const section = this._namespace_host.add_section(namespace); + this._namespace_container = section; + + // Buckets table + const bucket_section = section.add_section("Buckets"); + const bucket_columns = ["name", "disk", "memory", "entries", "actions"]; + const bucket_table = bucket_section.add_widget( + Table, + bucket_columns, + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric + ); + + // Right-align header for numeric columns (skip # and name) + const header = bucket_table._element.firstElementChild; + for (let i = 2; i < header.children.length - 1; i++) + { + header.children[i].style.textAlign = "right"; + } + + let totalDisk = 0, totalMem = 0, totalEntries = 0; + const total_row = bucket_table.add_row("TOTAL"); + total_row.get_cell(0).style("fontWeight", "bold"); + total_row.get_cell(1).style("textAlign", "right").style("fontWeight", "bold"); + total_row.get_cell(2).style("textAlign", "right").style("fontWeight", "bold"); + total_row.get_cell(3).style("textAlign", "right").style("fontWeight", "bold"); + + for (const bucket of info["Buckets"]) + { + const row = bucket_table.add_row(bucket); + new Fetcher().resource(`/z$/${namespace}/${bucket}`).json().then((data) => { + row.get_cell(1).text(Friendly.bytes(data["StorageSize"]["DiskSize"])).style("textAlign", "right"); + row.get_cell(2).text(Friendly.bytes(data["StorageSize"]["MemorySize"])).style("textAlign", "right"); + row.get_cell(3).text(Friendly.sep(data["DiskEntryCount"])).style("textAlign", "right"); + + const cell = row.get_cell(-1); + const action_tb = new Toolbar(cell, true); + action_tb.left().add("drop").on_click(() => this.drop_bucket(namespace, bucket)); + + totalDisk += data["StorageSize"]["DiskSize"]; + totalMem += data["StorageSize"]["MemorySize"]; + totalEntries += data["DiskEntryCount"]; + total_row.get_cell(1).text(Friendly.bytes(totalDisk)).style("textAlign", "right").style("fontWeight", "bold"); + total_row.get_cell(2).text(Friendly.bytes(totalMem)).style("textAlign", "right").style("fontWeight", "bold"); + total_row.get_cell(3).text(Friendly.sep(totalEntries)).style("textAlign", "right").style("fontWeight", "bold"); + }); + } + + } + + _clear_param(name) + { + this._params.delete(name); + const url = new URL(window.location); + url.searchParams.delete(name); + history.replaceState(null, "", url); + } + + _clear_namespace() + { + if (this._namespace_container) + { + this._namespace_container._parent.inner().remove(); + this._namespace_container = null; + } + } + + drop_bucket(namespace, bucket) + { + const drop = async () => { + await new Fetcher().resource("z$", namespace, bucket).delete(); + // Refresh the namespace view + this._selected_namespace = null; + this._clear_namespace(); + this.view_namespace(namespace); + }; + + new Modal() + .title("Confirmation") + .message(`Drop bucket '${bucket}'?`) + .option("Yes", () => drop()) + .option("No"); + } + + drop_namespace(namespace) + { + const drop = async () => { + await new Fetcher().resource("z$", namespace).delete(); + this.reload(); + }; + + new Modal() + .title("Confirmation") + .message(`Drop cache namespace '${namespace}'?`) + .option("Yes", () => drop()) + .option("No"); + } + + async drop_all() + { + const drop = async () => { + for (const row of this._cache_table) + { + const namespace = row.attr("zs_name"); + await new Fetcher().resource("z$", namespace).delete(); + } + this.reload(); + }; + + new Modal() + .title("Confirmation") + .message("Drop every cache namespace?") + .option("Yes", () => drop()) + .option("No"); + } +} diff --git a/src/zenserver/frontend/html/pages/compute.js b/src/zenserver/frontend/html/pages/compute.js new file mode 100644 index 000000000..ab3d49c27 --- /dev/null +++ b/src/zenserver/frontend/html/pages/compute.js @@ -0,0 +1,693 @@ +// 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" +import { Table } from "../util/widgets.js" + +const MAX_HISTORY_POINTS = 60; + +// Windows FILETIME: 100ns ticks since 1601-01-01 +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`; +} + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("compute"); + + this._history = { timestamps: [], pending: [], running: [], completed: [], cpu: [] }; + this._selected_worker = null; + this._chart_js = null; + this._queue_chart = null; + this._cpu_chart = null; + + this._ws_paused = false; + try { this._ws_paused = localStorage.getItem("zen-ws-paused") === "true"; } catch (e) {} + document.addEventListener("zen-ws-toggle", (e) => { + this._ws_paused = e.detail.paused; + }); + + // Action Queue section + const queue_section = this._collapsible_section("Action Queue"); + this._queue_grid = queue_section.tag().classify("grid").classify("stats-tiles"); + this._chart_host = queue_section; + + // Performance Metrics section + const perf_section = this._collapsible_section("Performance Metrics"); + this._perf_host = perf_section; + this._perf_grid = null; + + // Workers section + const workers_section = this._collapsible_section("Workers"); + this._workers_host = workers_section; + this._workers_table = null; + this._worker_detail_container = null; + + // Queues section + const queues_section = this._collapsible_section("Queues"); + this._queues_host = queues_section; + this._queues_table = null; + + // Action History section + const history_section = this._collapsible_section("Recent Actions"); + this._history_host = history_section; + this._history_table = null; + + // System Resources section + const sys_section = this._collapsible_section("System Resources"); + this._sys_grid = sys_section.tag().classify("grid").classify("stats-tiles"); + + // Load Chart.js dynamically + this._load_chartjs(); + + // Initial fetch + await this._fetch_all(); + + // Poll + this._poll_timer = setInterval(() => { + if (!this._ws_paused) + { + this._fetch_all(); + } + }, 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) + { + this._chart_js = window.Chart; + this._init_charts(); + return; + } + + try + { + const script = document.createElement("script"); + script.src = "https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"; + script.onload = () => { + this._chart_js = window.Chart; + this._init_charts(); + }; + document.head.appendChild(script); + } + catch (e) { /* Chart.js not available */ } + } + + _init_charts() + { + if (!this._chart_js) + { + return; + } + + // Queue history chart + { + const card = this._chart_host.tag().classify("card"); + card.tag().classify("card-title").text("Action Queue History"); + const container = card.tag(); + container.style("position", "relative").style("height", "300px").style("marginTop", "20px"); + const canvas = document.createElement("canvas"); + container.inner().appendChild(canvas); + + this._queue_chart = new this._chart_js(canvas.getContext("2d"), { + 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" } } } + } + }); + } + + // CPU sparkline (will be appended to CPU card later) + this._cpu_canvas = document.createElement("canvas"); + this._cpu_chart = new this._chart_js(this._cpu_canvas.getContext("2d"), { + 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 } } + } + }); + } + + async _fetch_all() + { + try + { + const [stats, sysinfo, workers_data, queues_data, history_data] = await Promise.all([ + new Fetcher().resource("/stats/compute").json().catch(() => null), + new Fetcher().resource("/compute/sysinfo").json().catch(() => null), + new Fetcher().resource("/compute/workers").json().catch(() => null), + new Fetcher().resource("/compute/queues").json().catch(() => null), + new Fetcher().resource("/compute/jobs/history").param("limit", "50").json().catch(() => null), + ]); + + if (stats) + { + this._render_queue_stats(stats); + this._update_queue_chart(stats); + this._render_perf(stats); + } + if (sysinfo) + { + this._render_sysinfo(sysinfo); + } + if (workers_data) + { + this._render_workers(workers_data); + } + if (queues_data) + { + this._render_queues(queues_data); + } + if (history_data) + { + this._render_action_history(history_data); + } + } + catch (e) { /* service unavailable */ } + } + + _render_queue_stats(data) + { + const grid = this._queue_grid; + grid.inner().innerHTML = ""; + + const tiles = [ + { title: "Pending Actions", value: data.actions_pending || 0, label: "waiting to be scheduled" }, + { title: "Running Actions", value: data.actions_submitted || 0, label: "currently executing" }, + { title: "Completed Actions", value: data.actions_complete || 0, label: "results available" }, + ]; + + for (const t of tiles) + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text(t.title); + const body = tile.tag().classify("tile-metrics"); + this._metric(body, Friendly.sep(t.value), t.label, true); + } + } + + _update_queue_chart(data) + { + const h = this._history; + h.timestamps.push(new Date().toLocaleTimeString()); + h.pending.push(data.actions_pending || 0); + h.running.push(data.actions_submitted || 0); + h.completed.push(data.actions_complete || 0); + + while (h.timestamps.length > MAX_HISTORY_POINTS) + { + h.timestamps.shift(); + h.pending.shift(); + h.running.shift(); + h.completed.shift(); + } + + if (this._queue_chart) + { + this._queue_chart.data.labels = h.timestamps; + this._queue_chart.data.datasets[0].data = h.pending; + this._queue_chart.data.datasets[1].data = h.running; + this._queue_chart.data.datasets[2].data = h.completed; + this._queue_chart.update("none"); + } + } + + _render_perf(data) + { + if (!this._perf_grid) + { + this._perf_grid = this._perf_host.tag().classify("grid").classify("stats-tiles"); + } + const grid = this._perf_grid; + grid.inner().innerHTML = ""; + + const retired = data.actions_retired || {}; + + // Completion rate card + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Completion Rate"); + const body = tile.tag().classify("tile-columns"); + + const left = body.tag().classify("tile-metrics"); + this._metric(left, this._fmt_rate(retired.rate_1), "1 min rate", true); + this._metric(left, this._fmt_rate(retired.rate_5), "5 min rate"); + this._metric(left, this._fmt_rate(retired.rate_15), "15 min rate"); + + const right = body.tag().classify("tile-metrics"); + this._metric(right, Friendly.sep(retired.count || 0), "total retired", true); + this._metric(right, this._fmt_rate(retired.rate_mean), "mean rate"); + } + } + + _fmt_rate(rate) + { + if (rate == null) return "-"; + return rate.toFixed(2) + "/s"; + } + + _render_workers(data) + { + const workerIds = data.workers || []; + + if (this._workers_table) + { + this._workers_table.clear(); + } + else + { + this._workers_table = this._workers_host.add_widget( + Table, + ["name", "platform", "cores", "timeout", "functions", "worker ID"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric, -1 + ); + } + + if (workerIds.length === 0) + { + return; + } + + // Fetch each worker's descriptor + Promise.all( + workerIds.map(id => + new Fetcher().resource("/compute/workers", id).json() + .then(desc => ({ id, desc })) + .catch(() => ({ id, desc: null })) + ) + ).then(results => { + this._workers_table.clear(); + for (const { id, desc } of results) + { + 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 row = this._workers_table.add_row( + "", + host, + String(cores), + String(timeout), + String(functions), + 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)); + + // Highlight selected + if (id === this._selected_worker) + { + row.style("background", "var(--theme_p3)"); + } + } + + this._worker_descriptors = Object.fromEntries(results.map(r => [r.id, r.desc])); + + // Re-render detail if still selected + if (this._selected_worker && this._worker_descriptors[this._selected_worker]) + { + this._show_worker_detail(this._selected_worker, this._worker_descriptors[this._selected_worker]); + } + else if (this._selected_worker) + { + this._selected_worker = null; + this._clear_worker_detail(); + } + }); + } + + _toggle_worker_detail(id, desc) + { + if (this._selected_worker === id) + { + this._selected_worker = null; + this._clear_worker_detail(); + return; + } + this._selected_worker = id; + this._show_worker_detail(id, desc); + } + + _clear_worker_detail() + { + if (this._worker_detail_container) + { + this._worker_detail_container._parent.inner().remove(); + this._worker_detail_container = null; + } + } + + _show_worker_detail(id, desc) + { + this._clear_worker_detail(); + if (!desc) + { + return; + } + + const section = this._workers_host.add_section(desc.name || id); + this._worker_detail_container = section; + + // Basic info table + const info_table = section.add_widget( + Table, ["property", "value"], Table.Flag_FitLeft|Table.Flag_PackRight + ); + const fields = [ + ["Worker ID", id], + ["Path", desc.path || "-"], + ["Platform", desc.host || "-"], + ["Build System", desc.buildsystem_version || "-"], + ["Cores", desc.cores != null ? String(desc.cores) : "-"], + ["Timeout", desc.timeout != null ? desc.timeout + "s" : "-"], + ]; + for (const [label, value] of fields) + { + info_table.add_row(label, value); + } + + // Functions + const functions = desc.functions || []; + if (functions.length > 0) + { + const fn_section = section.add_section("Functions"); + const fn_table = fn_section.add_widget( + Table, ["name", "version"], Table.Flag_FitLeft|Table.Flag_PackRight + ); + for (const f of functions) + { + fn_table.add_row(f.name || "-", f.version || "-"); + } + } + + // Executables + const executables = desc.executables || []; + if (executables.length > 0) + { + const exec_section = section.add_section("Executables"); + const exec_table = exec_section.add_widget( + Table, ["path", "hash", "size"], Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_AlignNumeric + ); + let totalSize = 0; + for (const e of executables) + { + exec_table.add_row(e.name || "-", e.hash || "-", e.size != null ? Friendly.bytes(e.size) : "-"); + totalSize += e.size || 0; + } + const total_row = exec_table.add_row("TOTAL", "", Friendly.bytes(totalSize)); + total_row.get_cell(0).style("fontWeight", "bold"); + total_row.get_cell(2).style("fontWeight", "bold"); + } + + // Files + const files = desc.files || []; + if (files.length > 0) + { + const files_section = section.add_section("Files"); + const files_table = files_section.add_widget( + Table, ["name", "hash"], Table.Flag_FitLeft|Table.Flag_PackRight + ); + for (const f of files) + { + files_table.add_row(typeof f === "string" ? f : (f.name || "-"), typeof f === "string" ? "" : (f.hash || "")); + } + } + + // Directories + const dirs = desc.dirs || []; + if (dirs.length > 0) + { + const dirs_section = section.add_section("Directories"); + for (const d of dirs) + { + dirs_section.tag().classify("detail-tag").text(d); + } + } + + // Environment + const env = desc.environment || []; + if (env.length > 0) + { + const env_section = section.add_section("Environment"); + for (const e of env) + { + env_section.tag().classify("detail-tag").text(e); + } + } + } + + _render_queues(data) + { + const queues = data.queues || []; + + if (this._queues_table) + { + this._queues_table.clear(); + } + else + { + this._queues_table = this._queues_host.add_widget( + Table, + ["ID", "status", "active", "completed", "failed", "abandoned", "cancelled", "token"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric, -1 + ); + } + + for (const q of queues) + { + const id = q.queue_id != null ? String(q.queue_id) : "-"; + const status = q.state === "cancelled" ? "cancelled" + : q.state === "draining" ? "draining" + : q.is_complete ? "complete" : "active"; + + this._queues_table.add_row( + id, + status, + String(q.active_count ?? 0), + String(q.completed_count ?? 0), + String(q.failed_count ?? 0), + String(q.abandoned_count ?? 0), + String(q.cancelled_count ?? 0), + q.queue_token || "-", + ); + } + } + + _render_action_history(data) + { + const entries = data.history || []; + + if (this._history_table) + { + this._history_table.clear(); + } + else + { + this._history_table = this._history_host.add_widget( + Table, + ["LSN", "queue", "status", "function", "started", "finished", "duration", "worker ID", "action ID"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric, -1 + ); + } + + // Entries arrive oldest-first; reverse to show newest at top + for (const entry of [...entries].reverse()) + { + const lsn = entry.lsn != null ? String(entry.lsn) : "-"; + const queueId = entry.queueId ? String(entry.queueId) : "-"; + const status = entry.succeeded == null ? "unknown" + : entry.succeeded ? "ok" : "failed"; + const desc = entry.actionDescriptor || {}; + const fn = desc.Function || "-"; + const startDate = filetimeToDate(entry.time_Running); + const endDate = filetimeToDate(entry.time_Completed ?? entry.time_Failed); + + this._history_table.add_row( + lsn, + queueId, + status, + fn, + formatTime(startDate), + formatTime(endDate), + formatDuration(startDate, endDate), + entry.workerId || "-", + entry.actionId || "-", + ); + } + } + + _render_sysinfo(data) + { + const grid = this._sys_grid; + grid.inner().innerHTML = ""; + + // CPU card + { + const cpuUsage = data.cpu_usage || 0; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("CPU Usage"); + const body = tile.tag().classify("tile-metrics"); + this._metric(body, cpuUsage.toFixed(1) + "%", "percent", true); + + // Progress bar + const bar = body.tag().classify("progress-bar"); + bar.tag().classify("progress-fill").style("width", cpuUsage + "%"); + + // CPU sparkline + this._history.cpu.push(cpuUsage); + while (this._history.cpu.length > MAX_HISTORY_POINTS) this._history.cpu.shift(); + if (this._cpu_chart) + { + const sparkContainer = body.tag(); + sparkContainer.style("position", "relative").style("height", "60px").style("marginTop", "12px"); + sparkContainer.inner().appendChild(this._cpu_canvas); + + this._cpu_chart.data.labels = this._history.cpu.map(() => ""); + this._cpu_chart.data.datasets[0].data = this._history.cpu; + this._cpu_chart.update("none"); + } + + // CPU details + this._stat_row(body, "Packages", data.cpu_count != null ? String(data.cpu_count) : "-"); + this._stat_row(body, "Physical Cores", data.core_count != null ? String(data.core_count) : "-"); + this._stat_row(body, "Logical Processors", data.lp_count != null ? String(data.lp_count) : "-"); + } + + // Memory card + { + const memUsed = data.memory_used || 0; + const memTotal = data.memory_total || 1; + const memPercent = (memUsed / memTotal) * 100; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Memory"); + const body = tile.tag().classify("tile-metrics"); + this._stat_row(body, "Used", Friendly.bytes(memUsed)); + this._stat_row(body, "Total", Friendly.bytes(memTotal)); + const bar = body.tag().classify("progress-bar"); + bar.tag().classify("progress-fill").style("width", memPercent + "%"); + } + + // Disk card + { + const diskUsed = data.disk_used || 0; + const diskTotal = data.disk_total || 1; + const diskPercent = (diskUsed / diskTotal) * 100; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Disk"); + const body = tile.tag().classify("tile-metrics"); + this._stat_row(body, "Used", Friendly.bytes(diskUsed)); + this._stat_row(body, "Total", Friendly.bytes(diskTotal)); + const bar = body.tag().classify("progress-bar"); + bar.tag().classify("progress-fill").style("width", diskPercent + "%"); + } + } + + _stat_row(parent, label, value) + { + const row = parent.tag().classify("stats-row"); + row.tag().classify("stats-label").text(label); + row.tag().classify("stats-value").text(value); + } + + _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/cookartifacts.js b/src/zenserver/frontend/html/pages/cookartifacts.js new file mode 100644 index 000000000..f2ae094b9 --- /dev/null +++ b/src/zenserver/frontend/html/pages/cookartifacts.js @@ -0,0 +1,397 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" +import { Table, Toolbar, PropTable } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + main() + { + this.set_title("cook artifacts"); + + const project = this.get_param("project"); + const oplog = this.get_param("oplog"); + const opkey = this.get_param("opkey"); + const artifact_hash = this.get_param("hash"); + + // Fetch the artifact content as JSON + this._artifact = new Fetcher() + .resource("prj", project, "oplog", oplog, artifact_hash + ".json") + .json(); + + // Optionally fetch entry info for display context + if (opkey) + { + this._entry = new Fetcher() + .resource("prj", project, "oplog", oplog, "entries") + .param("opkey", opkey) + .cbo(); + } + + this._build_page(); + } + + // Map CookDependency enum values to display names + _get_dependency_type_name(type_value) + { + const type_names = { + 0: "None", + 1: "File", + 2: "Function", + 3: "TransitiveBuild", + 4: "Package", + 5: "ConsoleVariable", + 6: "Config", + 7: "SettingsObject", + 8: "NativeClass", + 9: "AssetRegistryQuery", + 10: "RedirectionTarget" + }; + return type_names[type_value] || `Unknown (${type_value})`; + } + + // Check if Data content should be expandable + _should_make_expandable(data_string) + { + if (!data_string || data_string.length < 40) + return false; + + // Check if it's JSON array or object + if (!data_string.startsWith('[') && !data_string.startsWith('{')) + return false; + + // Check if formatting would add newlines + try { + const parsed = JSON.parse(data_string); + const formatted = JSON.stringify(parsed, null, 2); + return formatted.includes('\n'); + } catch (e) { + return false; + } + } + + // Get first line of content for collapsed state + _get_first_line(data_string) + { + if (!data_string) + return ""; + + const newline_index = data_string.indexOf('\n'); + if (newline_index === -1) + { + // No newline, truncate if too long + return data_string.length > 80 ? data_string.substring(0, 77) + "..." : data_string; + } + return data_string.substring(0, newline_index) + "..."; + } + + // Format JSON with indentation + _format_json(data_string) + { + try { + const parsed = JSON.parse(data_string); + return JSON.stringify(parsed, null, 2); + } catch (e) { + return data_string; + } + } + + // Toggle expand/collapse state + _toggle_data_cell(cell) + { + const is_expanded = cell.attr("expanded") !== null; + const full_data = cell.attr("data-full"); + + // Find the text wrapper span + const text_wrapper = cell.first_child().next_sibling(); + + if (is_expanded) + { + // Collapse: show first line only + const first_line = this._get_first_line(full_data); + text_wrapper.text(first_line); + cell.attr("expanded", null); + } + else + { + // Expand: show formatted JSON + const formatted = this._format_json(full_data); + text_wrapper.text(formatted); + cell.attr("expanded", ""); + } + } + + // Format dependency data based on its structure + _format_dependency(dep_array) + { + const type = dep_array[0]; + const formatted = {}; + + // Common patterns based on the example data: + // Type 2 (Function): [type, name, array, hash] + // Type 4 (Package): [type, path, hash] + // Type 5 (ConsoleVariable): [type, bool, array, hash] + // Type 8 (NativeClass): [type, path, hash] + // Type 9 (AssetRegistryQuery): [type, bool, object, hash] + // Type 10 (RedirectionTarget): [type, path, hash] + + if (dep_array.length > 1) + { + // Most types have a name/path as second element + if (typeof dep_array[1] === "string") + { + formatted.Name = dep_array[1]; + } + else if (typeof dep_array[1] === "boolean") + { + formatted.Value = dep_array[1].toString(); + } + } + + if (dep_array.length > 2) + { + // Third element varies + if (Array.isArray(dep_array[2])) + { + formatted.Data = JSON.stringify(dep_array[2]); + } + else if (typeof dep_array[2] === "object") + { + formatted.Data = JSON.stringify(dep_array[2]); + } + else if (typeof dep_array[2] === "string") + { + formatted.Hash = dep_array[2]; + } + } + + if (dep_array.length > 3) + { + // Fourth element is usually the hash + if (typeof dep_array[3] === "string") + { + formatted.Hash = dep_array[3]; + } + } + + return formatted; + } + + async _build_page() + { + const project = this.get_param("project"); + const oplog = this.get_param("oplog"); + const opkey = this.get_param("opkey"); + const artifact_hash = this.get_param("hash"); + + // Build page title + let title = "Cook Artifacts"; + if (this._entry) + { + try + { + const entry = await this._entry; + const entry_obj = entry.as_object().find("entry").as_object(); + const key = entry_obj.find("key").as_value(); + title = `Cook Artifacts`; + } + catch (e) + { + console.error("Failed to fetch entry:", e); + } + } + + const section = this.add_section(title); + + // Fetch and parse artifact + let artifact; + try + { + artifact = await this._artifact; + } + catch (e) + { + section.text(`Failed to load artifact: ${e.message}`); + return; + } + + // Display artifact info + const info_section = section.add_section("Artifact Info"); + const info_table = info_section.add_widget(Table, ["Property", "Value"], Table.Flag_PackRight); + + if (artifact.Version !== undefined) + info_table.add_row("Version", artifact.Version.toString()); + if (artifact.HasSaveResults !== undefined) + info_table.add_row("HasSaveResults", artifact.HasSaveResults.toString()); + if (artifact.PackageSavedHash !== undefined) + info_table.add_row("PackageSavedHash", artifact.PackageSavedHash); + + // Process SaveBuildDependencies + if (artifact.SaveBuildDependencies && artifact.SaveBuildDependencies.Dependencies) + { + this._build_dependency_section( + section, + "Save Build Dependencies", + artifact.SaveBuildDependencies.Dependencies, + artifact.SaveBuildDependencies.StoredKey + ); + } + + // Process LoadBuildDependencies + if (artifact.LoadBuildDependencies && artifact.LoadBuildDependencies.Dependencies) + { + this._build_dependency_section( + section, + "Load Build Dependencies", + artifact.LoadBuildDependencies.Dependencies, + artifact.LoadBuildDependencies.StoredKey + ); + } + + // Process RuntimeDependencies + if (artifact.RuntimeDependencies && artifact.RuntimeDependencies.length > 0) + { + const runtime_section = section.add_section("Runtime Dependencies"); + const runtime_table = runtime_section.add_widget(Table, ["Path"], Table.Flag_PackRight); + for (const dep of artifact.RuntimeDependencies) + { + const row = runtime_table.add_row(dep); + // Make Path clickable to navigate to entry + if (this._should_link_dependency(dep)) + { + row.get_cell(0).text(dep).on_click((opkey) => { + window.location = `?page=entry&project=${project}&oplog=${oplog}&opkey=${opkey.toLowerCase()}`; + }, dep); + } + } + } + } + + _should_link_dependency(name) + { + // Exclude dependencies starting with /Script/ (code-defined entries) - case insensitive + if (name && name.toLowerCase().startsWith("/script/")) + return false; + + return true; + } + + _build_dependency_section(parent_section, title, dependencies, stored_key) + { + const section = parent_section.add_section(title); + + // Add stored key info + if (stored_key) + { + const key_toolbar = section.add_widget(Toolbar); + key_toolbar.left().add(`Key: ${stored_key}`); + } + + // Group dependencies by type + const dependencies_by_type = {}; + + for (const dep_array of dependencies) + { + if (!Array.isArray(dep_array) || dep_array.length === 0) + continue; + + const type = dep_array[0]; + if (!dependencies_by_type[type]) + dependencies_by_type[type] = []; + + dependencies_by_type[type].push(this._format_dependency(dep_array)); + } + + // Sort types numerically + const sorted_types = Object.keys(dependencies_by_type).map(Number).sort((a, b) => a - b); + + for (const type_value of sorted_types) + { + const type_name = this._get_dependency_type_name(type_value); + const deps = dependencies_by_type[type_value]; + + const type_section = section.add_section(type_name); + + // Determine columns based on available fields + const all_fields = new Set(); + for (const dep of deps) + { + for (const field in dep) + all_fields.add(field); + } + let columns = Array.from(all_fields); + + // Remove Hash column for RedirectionTarget as it's not useful + if (type_value === 10) + { + columns = columns.filter(col => col !== "Hash"); + } + + if (columns.length === 0) + { + type_section.text("No data fields"); + continue; + } + + // Create table with dynamic columns + const table = type_section.add_widget(Table, columns, Table.Flag_PackRight); + + // Check if this type should have clickable Name links + const should_link = (type_value === 3 || type_value === 4 || type_value === 10); + const name_col_index = columns.indexOf("Name"); + + for (const dep of deps) + { + const row_values = columns.map(col => dep[col] || ""); + const row = table.add_row(...row_values); + + // Make Name field clickable for Package, TransitiveBuild, and RedirectionTarget + if (should_link && name_col_index >= 0 && dep.Name && this._should_link_dependency(dep.Name)) + { + const project = this.get_param("project"); + const oplog = this.get_param("oplog"); + row.get_cell(name_col_index).text(dep.Name).on_click((opkey) => { + window.location = `?page=entry&project=${project}&oplog=${oplog}&opkey=${opkey.toLowerCase()}`; + }, dep.Name); + } + + // Make Data field expandable/collapsible if needed + const data_col_index = columns.indexOf("Data"); + if (data_col_index >= 0 && dep.Data) + { + const data_cell = row.get_cell(data_col_index); + + if (this._should_make_expandable(dep.Data)) + { + // Store full data in attribute + data_cell.attr("data-full", dep.Data); + + // Clear the cell and rebuild with icon + text + data_cell.inner().innerHTML = ""; + + // Create expand/collapse icon + const icon = data_cell.tag("span").classify("zen_expand_icon").text("+"); + icon.on_click(() => { + this._toggle_data_cell(data_cell); + // Update icon text + const is_expanded = data_cell.attr("expanded") !== null; + icon.text(is_expanded ? "-" : "+"); + }); + + // Add text content wrapper + const text_wrapper = data_cell.tag("span").classify("zen_data_text"); + const first_line = this._get_first_line(dep.Data); + text_wrapper.text(first_line); + + // Store reference to text wrapper for updates + data_cell.attr("data-text-wrapper", "true"); + } + } + } + } + } +} diff --git a/src/zenserver/frontend/html/pages/entry.js b/src/zenserver/frontend/html/pages/entry.js index 08589b090..1e4c82e3f 100644 --- a/src/zenserver/frontend/html/pages/entry.js +++ b/src/zenserver/frontend/html/pages/entry.js @@ -26,6 +26,9 @@ export class Page extends ZenPage this._indexer = this.load_indexer(project, oplog); + this._files_index_start = Number(this.get_param("files_start", 0)) || 0; + this._files_index_count = Number(this.get_param("files_count", 50)) || 0; + this._build_page(); } @@ -40,25 +43,39 @@ export class Page extends ZenPage return indexer; } - async _build_deps(section, tree) + _build_deps(section, tree) { - const indexer = await this._indexer; + const project = this.get_param("project"); + const oplog = this.get_param("oplog"); for (const dep_name in tree) { const dep_section = section.add_section(dep_name); const table = dep_section.add_widget(Table, ["name", "id"], Table.Flag_PackRight); + for (const dep_id of tree[dep_name]) { - const cell_values = ["", dep_id.toString(16).padStart(16, "0")]; + const hex_id = dep_id.toString(16).padStart(16, "0"); + const cell_values = ["loading...", hex_id]; const row = table.add_row(...cell_values); - var opkey = indexer.lookup_id(dep_id); - row.get_cell(0).text(opkey).on_click((k) => this.view_opkey(k), opkey); + // Asynchronously resolve the name + this._resolve_dep_name(row.get_cell(0), dep_id, project, oplog); } } } + async _resolve_dep_name(cell, dep_id, project, oplog) + { + const indexer = await this._indexer; + const opkey = indexer.lookup_id(dep_id); + + if (opkey) + { + cell.text(opkey).on_click((k) => this.view_opkey(k), opkey); + } + } + _find_iohash_field(container, name) { const found_field = container.find(name); @@ -76,6 +93,21 @@ export class Page extends ZenPage return null; } + _is_null_io_hash_string(io_hash) + { + if (!io_hash) + return true; + + for (let char of io_hash) + { + if (char != '0') + { + return false; + } + } + return true; + } + async _build_meta(section, entry) { var tree = {} @@ -123,11 +155,23 @@ export class Page extends ZenPage const project = this.get_param("project"); const oplog = this.get_param("oplog"); + const opkey = this.get_param("opkey"); const link = row.get_cell(0).link( - "/" + ["prj", project, "oplog", oplog, value+".json"].join("/") + (key === "cook.artifacts") ? + `?page=cookartifacts&project=${project}&oplog=${oplog}&opkey=${opkey}&hash=${value}` + : "/" + ["prj", project, "oplog", oplog, value+".json"].join("/") ); const action_tb = new Toolbar(row.get_cell(-1), true); + + // Add "view-raw" button for cook.artifacts + if (key === "cook.artifacts") + { + action_tb.left().add("view-raw").on_click(() => { + window.location = "/" + ["prj", project, "oplog", oplog, value+".json"].join("/"); + }); + } + action_tb.left().add("copy-hash").on_click(async (v) => { await navigator.clipboard.writeText(v); }, value); @@ -137,35 +181,55 @@ export class Page extends ZenPage async _build_page() { var entry = await this._entry; + + // Check if entry exists + if (!entry || entry.as_object().find("entry") == null) + { + const opkey = this.get_param("opkey"); + var section = this.add_section("Entry Not Found"); + section.tag("p").text(`The entry "${opkey}" is not present in this dataset.`); + section.tag("p").text("This could mean:"); + const list = section.tag("ul"); + list.tag("li").text("The entry is for an instance defined in code"); + list.tag("li").text("The entry has not been added to the oplog yet"); + list.tag("li").text("The entry key is misspelled"); + list.tag("li").text("The entry was removed or never existed"); + return; + } + entry = entry.as_object().find("entry").as_object(); const name = entry.find("key").as_value(); var section = this.add_section(name); + var has_package_data = false; // tree { var tree = entry.find("$tree"); if (tree == undefined) tree = this._convert_legacy_to_tree(entry); - if (tree == undefined) - return this._display_unsupported(section, entry); - - delete tree["$id"]; - - if (Object.keys(tree).length != 0) + if (tree != undefined) { - const sub_section = section.add_section("deps"); - this._build_deps(sub_section, tree); + delete tree["$id"]; + + if (Object.keys(tree).length != 0) + { + const sub_section = section.add_section("dependencies"); + this._build_deps(sub_section, tree); + } + has_package_data = true; } } // meta + if (has_package_data) { this._build_meta(section, entry); } // data + if (has_package_data) { const sub_section = section.add_section("data"); const table = sub_section.add_widget( @@ -181,7 +245,7 @@ export class Page extends ZenPage for (const item of pkg_data.as_array()) { - var io_hash, size, raw_size, file_name; + var io_hash = undefined, size = undefined, raw_size = undefined, file_name = undefined; for (const field of item.as_object()) { if (field.is_named("data")) io_hash = field.as_value(); @@ -198,8 +262,8 @@ export class Page extends ZenPage io_hash = ret; } - size = (size !== undefined) ? Friendly.kib(size) : ""; - raw_size = (raw_size !== undefined) ? Friendly.kib(raw_size) : ""; + size = (size !== undefined) ? Friendly.bytes(size) : ""; + raw_size = (raw_size !== undefined) ? Friendly.bytes(raw_size) : ""; const row = table.add_row(file_name, size, raw_size); @@ -219,12 +283,76 @@ export class Page extends ZenPage } } + // files + var has_file_data = false; + { + var file_data = entry.find("files"); + if (file_data != undefined) + { + has_file_data = true; + + // Extract files into array + this._files_data = []; + for (const item of file_data.as_array()) + { + var io_hash = undefined, cid = undefined, server_path = undefined, client_path = undefined; + for (const field of item.as_object()) + { + if (field.is_named("data")) io_hash = field.as_value(); + else if (field.is_named("id")) cid = field.as_value(); + else if (field.is_named("serverpath")) server_path = field.as_value(); + else if (field.is_named("clientpath")) client_path = field.as_value(); + } + + if (io_hash instanceof Uint8Array) + { + var ret = ""; + for (var x of io_hash) + ret += x.toString(16).padStart(2, "0"); + io_hash = ret; + } + + if (cid instanceof Uint8Array) + { + var ret = ""; + for (var x of cid) + ret += x.toString(16).padStart(2, "0"); + cid = ret; + } + + this._files_data.push({ + server_path: server_path, + client_path: client_path, + io_hash: io_hash, + cid: cid + }); + } + + this._files_index_max = this._files_data.length; + + const sub_section = section.add_section("files"); + this._build_files_nav(sub_section); + + this._files_table = sub_section.add_widget( + Table, + ["name", "actions"], Table.Flag_PackRight + ); + this._files_table.id("filetable"); + + this._build_files_table(this._files_index_start); + } + } + // props + if (has_package_data) { const object = entry.to_js_object(); var sub_section = section.add_section("props"); sub_section.add_widget(PropTable).add_object(object); } + + if (!has_package_data && !has_file_data) + return this._display_unsupported(section, entry); } _display_unsupported(section, entry) @@ -271,16 +399,30 @@ export class Page extends ZenPage for (const field of pkgst_entry) { const field_name = field.get_name(); - if (!field_name.endsWith("importedpackageids")) - continue; - - var dep_name = field_name.slice(0, -18); - if (dep_name.length == 0) - dep_name = "imported"; - - var out = tree[dep_name] = []; - for (var item of field.as_array()) - out.push(item.as_value(BigInt)); + if (field_name.endsWith("importedpackageids")) + { + var dep_name = field_name.slice(0, -18); + if (dep_name.length == 0) + dep_name = "hard"; + else + dep_name = "hard." + dep_name; + + var out = tree[dep_name] = []; + for (var item of field.as_array()) + out.push(item.as_value(BigInt)); + } + else if (field_name.endsWith("softpackagereferences")) + { + var dep_name = field_name.slice(0, -21); + if (dep_name.length == 0) + dep_name = "soft"; + else + dep_name = "soft." + dep_name; + + var out = tree[dep_name] = []; + for (var item of field.as_array()) + out.push(item.as_value(BigInt)); + } } return tree; @@ -292,4 +434,149 @@ export class Page extends ZenPage params.set("opkey", opkey); window.location.search = params; } + + _build_files_nav(section) + { + const nav = section.add_widget(Toolbar); + const left = nav.left(); + left.add("|<") .on_click(() => this._on_files_next_prev(-10e10)); + left.add("<<") .on_click(() => this._on_files_next_prev(-10)); + left.add("prev").on_click(() => this._on_files_next_prev( -1)); + left.add("next").on_click(() => this._on_files_next_prev( 1)); + left.add(">>") .on_click(() => this._on_files_next_prev( 10)); + left.add(">|") .on_click(() => this._on_files_next_prev( 10e10)); + + left.sep(); + for (var count of [10, 25, 50, 100]) + { + var handler = (n) => this._on_files_change_count(n); + left.add(count).on_click(handler, count); + } + + const right = nav.right(); + right.add(Friendly.sep(this._files_index_max)); + + right.sep(); + var search_input = right.add("search:", "label").tag("input"); + search_input.on("change", (x) => this._search_files(x.inner().value), search_input); + } + + _build_files_table(index) + { + this._files_index_count = Math.max(this._files_index_count, 1); + index = Math.min(index, this._files_index_max - this._files_index_count); + index = Math.max(index, 0); + + const project = this.get_param("project"); + const oplog = this.get_param("oplog"); + + const end_index = Math.min(index + this._files_index_count, this._files_index_max); + + this._files_table.clear(index); + for (var i = index; i < end_index; i++) + { + const file_item = this._files_data[i]; + const row = this._files_table.add_row(file_item.server_path); + + var base_name = file_item.server_path.split("/").pop().split("\\").pop(); + if (this._is_null_io_hash_string(file_item.io_hash)) + { + const link = row.get_cell(0).link( + "/" + ["prj", project, "oplog", oplog, file_item.cid].join("/") + ); + link.first_child().attr("download", `${file_item.cid}_${base_name}`); + + const action_tb = new Toolbar(row.get_cell(-1), true); + action_tb.left().add("copy-id").on_click(async (v) => { + await navigator.clipboard.writeText(v); + }, file_item.cid); + } + else + { + const link = row.get_cell(0).link( + "/" + ["prj", project, "oplog", oplog, file_item.io_hash].join("/") + ); + link.first_child().attr("download", `${file_item.io_hash}_${base_name}`); + + const action_tb = new Toolbar(row.get_cell(-1), true); + action_tb.left().add("copy-hash").on_click(async (v) => { + await navigator.clipboard.writeText(v); + }, file_item.io_hash); + } + } + + this.set_param("files_start", index); + this.set_param("files_count", this._files_index_count); + this._files_index_start = index; + } + + _on_files_change_count(value) + { + this._files_index_count = parseInt(value); + this._build_files_table(this._files_index_start); + } + + _on_files_next_prev(direction) + { + var index = this._files_index_start + (this._files_index_count * direction); + index = Math.max(0, index); + this._build_files_table(index); + } + + _search_files(needle) + { + if (needle.length == 0) + { + this._build_files_table(this._files_index_start); + return; + } + needle = needle.trim().toLowerCase(); + + this._files_table.clear(this._files_index_start); + + const project = this.get_param("project"); + const oplog = this.get_param("oplog"); + + var added = 0; + const truncate_at = this.get_param("searchmax") || 250; + for (const file_item of this._files_data) + { + if (!file_item.server_path.toLowerCase().includes(needle)) + continue; + + const row = this._files_table.add_row(file_item.server_path); + + var base_name = file_item.server_path.split("/").pop().split("\\").pop(); + if (this._is_null_io_hash_string(file_item.io_hash)) + { + const link = row.get_cell(0).link( + "/" + ["prj", project, "oplog", oplog, file_item.cid].join("/") + ); + link.first_child().attr("download", `${file_item.cid}_${base_name}`); + + const action_tb = new Toolbar(row.get_cell(-1), true); + action_tb.left().add("copy-id").on_click(async (v) => { + await navigator.clipboard.writeText(v); + }, file_item.cid); + } + else + { + const link = row.get_cell(0).link( + "/" + ["prj", project, "oplog", oplog, file_item.io_hash].join("/") + ); + link.first_child().attr("download", `${file_item.io_hash}_${base_name}`); + + const action_tb = new Toolbar(row.get_cell(-1), true); + action_tb.left().add("copy-hash").on_click(async (v) => { + await navigator.clipboard.writeText(v); + }, file_item.io_hash); + } + + if (++added >= truncate_at) + { + this._files_table.add_row("...truncated"); + break; + } + } + } } diff --git a/src/zenserver/frontend/html/pages/hub.js b/src/zenserver/frontend/html/pages/hub.js new file mode 100644 index 000000000..f9e4fff33 --- /dev/null +++ b/src/zenserver/frontend/html/pages/hub.js @@ -0,0 +1,122 @@ +// 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" +import { Table } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("hub"); + + // Capacity + const stats_section = this.add_section("Capacity"); + this._stats_grid = stats_section.tag().classify("grid").classify("stats-tiles"); + + // Modules + const mod_section = this.add_section("Modules"); + this._mod_host = mod_section; + this._mod_table = null; + + await this._update(); + this._poll_timer = setInterval(() => this._update(), 2000); + } + + async _update() + { + try + { + const [stats, status] = await Promise.all([ + new Fetcher().resource("/hub/stats").json(), + new Fetcher().resource("/hub/status").json(), + ]); + + this._render_capacity(stats); + this._render_modules(status); + } + catch (e) { /* service unavailable */ } + } + + _render_capacity(data) + { + const grid = this._stats_grid; + grid.inner().innerHTML = ""; + + const current = data.currentInstanceCount || 0; + const max = data.maxInstanceCount || 0; + const limit = data.instanceLimit || 0; + + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Active Modules"); + const body = tile.tag().classify("tile-metrics"); + this._metric(body, Friendly.sep(current), "currently provisioned", true); + } + + { + 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 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) + { + const pct = ((current / limit) * 100).toFixed(0) + "%"; + this._metric(body, pct, "utilization"); + } + } + } + + _render_modules(data) + { + const modules = data.modules || []; + + if (this._mod_table) + { + this._mod_table.clear(); + } + else + { + this._mod_table = this._mod_host.add_widget( + Table, + ["module ID", "status"], + Table.Flag_FitLeft|Table.Flag_PackRight + ); + } + + if (modules.length === 0) + { + return; + } + + for (const m of modules) + { + this._mod_table.add_row( + m.moduleId || "", + m.provisioned ? "provisioned" : "inactive", + ); + } + } + + _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/info.js b/src/zenserver/frontend/html/pages/info.js new file mode 100644 index 000000000..f92765c78 --- /dev/null +++ b/src/zenserver/frontend/html/pages/info.js @@ -0,0 +1,261 @@ +// 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 +{ + async main() + { + this.set_title("info"); + + const [info, gc, services, version] = await Promise.all([ + new Fetcher().resource("/health/info").json(), + new Fetcher().resource("/admin/gc").json().catch(() => null), + new Fetcher().resource("/api/").json().catch(() => ({})), + new Fetcher().resource("/health/version").param("detailed", "true").text(), + ]); + + const section = this.add_section("Server Info"); + const grid = section.tag().classify("grid").classify("info-tiles"); + + // Application + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Application"); + const list = tile.tag().classify("info-props"); + + this._prop(list, "version", version || info.BuildVersion || "-"); + this._prop(list, "http server", info.HttpServerClass || "-"); + this._prop(list, "port", info.Port || "-"); + this._prop(list, "pid", info.Pid || "-"); + this._prop(list, "dedicated", info.IsDedicated ? "yes" : "no"); + + if (info.StartTimeMs) + { + const start = new Date(info.StartTimeMs); + const elapsed = Date.now() - info.StartTimeMs; + this._prop(list, "started", start.toLocaleString()); + this._prop(list, "uptime", this._format_duration(elapsed)); + } + + this._prop(list, "data root", info.DataRoot || "-"); + this._prop(list, "log path", info.AbsLogPath || "-"); + } + + // System + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("System"); + const list = tile.tag().classify("info-props"); + + this._prop(list, "hostname", info.Hostname || "-"); + this._prop(list, "platform", info.Platform || "-"); + this._prop(list, "os", info.OS || "-"); + this._prop(list, "arch", info.Arch || "-"); + + const sys = info.System; + if (sys) + { + this._prop(list, "cpus", sys.cpu_count || "-"); + this._prop(list, "cores", sys.core_count || "-"); + this._prop(list, "logical processors", sys.lp_count || "-"); + this._prop(list, "total memory", sys.total_memory_mb ? Friendly.bytes(sys.total_memory_mb * 1048576) : "-"); + this._prop(list, "available memory", sys.avail_memory_mb ? Friendly.bytes(sys.avail_memory_mb * 1048576) : "-"); + if (sys.uptime_seconds) + { + this._prop(list, "system uptime", this._format_duration(sys.uptime_seconds * 1000)); + } + } + } + + // Runtime Configuration + if (info.RuntimeConfig) + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Runtime Configuration"); + const list = tile.tag().classify("info-props"); + + for (const key in info.RuntimeConfig) + { + this._prop(list, key, info.RuntimeConfig[key] || "-"); + } + } + + // Build Configuration + if (info.BuildConfig) + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Build Configuration"); + const list = tile.tag().classify("info-props"); + + for (const key in info.BuildConfig) + { + this._prop(list, key, info.BuildConfig[key] ? "yes" : "no"); + } + } + + // Services + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Services"); + const list = tile.tag().classify("info-props"); + + const svc_list = (services.services || []).map(s => s.base_uri).sort(); + for (const uri of svc_list) + { + this._prop(list, uri, "registered"); + } + } + + // Garbage Collection + if (gc) + { + const tile = grid.tag().classify("card").classify("info-tile"); + tile.tag().classify("card-title").text("Garbage Collection"); + const list = tile.tag().classify("info-props"); + + this._prop(list, "status", gc.Status || "-"); + + if (gc.AreDiskWritesBlocked !== undefined) + { + this._prop(list, "disk writes blocked", gc.AreDiskWritesBlocked ? "yes" : "no"); + } + + if (gc.DiskSize) + { + this._prop(list, "disk size", gc.DiskSize); + this._prop(list, "disk used", gc.DiskUsed); + this._prop(list, "disk free", gc.DiskFree); + } + + const cfg = gc.Config; + if (cfg) + { + this._prop(list, "gc enabled", cfg.Enabled ? "yes" : "no"); + if (cfg.Interval) + { + this._prop(list, "interval", this._friendly_duration(cfg.Interval)); + } + if (cfg.LightweightInterval) + { + this._prop(list, "lightweight interval", this._friendly_duration(cfg.LightweightInterval)); + } + if (cfg.MaxCacheDuration) + { + this._prop(list, "max cache duration", this._friendly_duration(cfg.MaxCacheDuration)); + } + if (cfg.MaxProjectStoreDuration) + { + this._prop(list, "max project duration", this._friendly_duration(cfg.MaxProjectStoreDuration)); + } + if (cfg.MaxBuildStoreDuration) + { + this._prop(list, "max build duration", this._friendly_duration(cfg.MaxBuildStoreDuration)); + } + } + + if (gc.FullGC) + { + if (gc.FullGC.LastTime) + { + this._prop(list, "last full gc", this._friendly_timestamp(gc.FullGC.LastTime)); + } + if (gc.FullGC.TimeToNext) + { + this._prop(list, "next full gc", this._friendly_duration(gc.FullGC.TimeToNext)); + } + } + + if (gc.LightweightGC) + { + if (gc.LightweightGC.LastTime) + { + this._prop(list, "last lightweight gc", this._friendly_timestamp(gc.LightweightGC.LastTime)); + } + if (gc.LightweightGC.TimeToNext) + { + this._prop(list, "next lightweight gc", this._friendly_duration(gc.LightweightGC.TimeToNext)); + } + } + } + } + + _prop(parent, label, value) + { + const row = parent.tag().classify("info-prop"); + row.tag().classify("info-prop-label").text(label); + const val = row.tag().classify("info-prop-value"); + const str = String(value); + if (str.match(/^[A-Za-z]:[\\/]/) || str.startsWith("/")) + { + val.tag("a").text(str).attr("href", "vscode://" + str.replace(/\\/g, "/")); + } + else + { + val.text(str); + } + } + + _friendly_timestamp(value) + { + const d = new Date(value); + if (isNaN(d.getTime())) + { + return String(value); + } + return d.toLocaleString(undefined, { + year: "numeric", month: "short", day: "numeric", + hour: "2-digit", minute: "2-digit", second: "2-digit", + }); + } + + _friendly_duration(value) + { + if (typeof value === "number") + { + return this._format_duration(value); + } + + const str = String(value); + const match = str.match(/^[+-]?(?:(\d+)\.)?(\d+):(\d+):(\d+)(?:\.(\d+))?$/); + if (!match) + { + return str; + } + + const days = parseInt(match[1] || "0", 10); + const hours = parseInt(match[2], 10); + const minutes = parseInt(match[3], 10); + const seconds = parseInt(match[4], 10); + const total_seconds = days * 86400 + hours * 3600 + minutes * 60 + seconds; + + return this._format_duration(total_seconds * 1000); + } + + _format_duration(ms) + { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) + { + return `${days}d ${hours % 24}h ${minutes % 60}m`; + } + if (hours > 0) + { + return `${hours}h ${minutes % 60}m`; + } + if (minutes > 0) + { + return `${minutes}m ${seconds % 60}s`; + } + return `${seconds}s`; + } +} diff --git a/src/zenserver/frontend/html/pages/map.js b/src/zenserver/frontend/html/pages/map.js index 58046b255..ac8f298aa 100644 --- a/src/zenserver/frontend/html/pages/map.js +++ b/src/zenserver/frontend/html/pages/map.js @@ -116,9 +116,9 @@ export class Page extends ZenPage for (const name of sorted_keys) nodes.push(new_nodes[name] / branch_size); - var stats = Friendly.kib(branch_size); + var stats = Friendly.bytes(branch_size); stats += " / "; - stats += Friendly.kib(total_size); + stats += Friendly.bytes(total_size); stats += " ("; stats += 0|((branch_size * 100) / total_size); stats += "%)"; diff --git a/src/zenserver/frontend/html/pages/metrics.js b/src/zenserver/frontend/html/pages/metrics.js new file mode 100644 index 000000000..e7a2eca67 --- /dev/null +++ b/src/zenserver/frontend/html/pages/metrics.js @@ -0,0 +1,232 @@ +// 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" +import { PropTable, Toolbar } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +class TemporalStat +{ + constructor(data, as_bytes) + { + this._data = data; + this._as_bytes = as_bytes; + } + + toString() + { + const columns = [ + /* count */ {}, + /* rate */ {}, + /* t */ {}, {}, + ]; + const data = this._data; + for (var key in data) + { + var out = columns[0]; + if (key.startsWith("rate_")) out = columns[1]; + else if (key.startsWith("t_p")) out = columns[3]; + else if (key.startsWith("t_")) out = columns[2]; + out[key] = data[key]; + } + + var friendly = this._as_bytes ? Friendly.bytes : Friendly.sep; + + var content = ""; + for (var i = 0; i < columns.length; ++i) + { + const column = columns[i]; + for (var key in column) + { + var value = column[key]; + if (i) + { + value = Friendly.sep(value, 2); + key = key.padStart(9); + content += key + ": " + value; + } + else + content += friendly(value); + content += "\r\n"; + } + } + + return content; + } + + tag() + { + return "pre"; + } +} + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("metrics"); + + const metrics_section = this.add_section("metrics"); + const top_toolbar = metrics_section.add_widget(Toolbar); + const tb_right = top_toolbar.right(); + this._refresh_label = tb_right.add("", "label"); + this._pause_btn = tb_right.add("pause").on_click(() => this._toggle_pause()); + + this._paused = false; + this._last_refresh = Date.now(); + this._provider_views = []; + + const providers_data = await new Fetcher().resource("stats").json(); + const providers = providers_data["providers"] || []; + + const stats_list = await Promise.all(providers.map((provider) => + new Fetcher() + .resource("stats", provider) + .param("cidstorestats", "true") + .param("cachestorestats", "true") + .json() + .then((stats) => ({ provider, stats })) + )); + + for (const { provider, stats } of stats_list) + { + this._condense(stats); + this._provider_views.push(this._render_provider(provider, stats)); + } + + this._last_refresh = Date.now(); + this._update_refresh_label(); + + this._timer_id = setInterval(() => this._refresh(), 5000); + this._tick_id = setInterval(() => this._update_refresh_label(), 1000); + + document.addEventListener("visibilitychange", () => { + if (document.hidden) + this._pause_timer(false); + else if (!this._paused) + this._resume_timer(); + }); + } + + _render_provider(provider, stats) + { + const section = this.add_section(provider); + const toolbar = section.add_widget(Toolbar); + + toolbar.right().add("detailed →").on_click(() => { + window.location = "?page=stat&provider=" + provider; + }); + + const table = section.add_widget(PropTable); + let current_stats = stats; + let current_category = undefined; + + const show_category = (cat) => { + current_category = cat; + table.clear(); + table.add_object(current_stats[cat], true, 3); + }; + + var first = undefined; + for (var name in stats) + { + first = first || name; + toolbar.left().add(name).on_click(show_category, name); + } + + if (first) + show_category(first); + + return { + provider, + set_stats: (new_stats) => { + current_stats = new_stats; + if (current_category && current_stats[current_category]) + show_category(current_category); + }, + }; + } + + async _refresh() + { + const updates = await Promise.all(this._provider_views.map((view) => + new Fetcher() + .resource("stats", view.provider) + .param("cidstorestats", "true") + .param("cachestorestats", "true") + .json() + .then((stats) => ({ view, stats })) + )); + + for (const { view, stats } of updates) + { + this._condense(stats); + view.set_stats(stats); + } + + this._last_refresh = Date.now(); + this._update_refresh_label(); + } + + _update_refresh_label() + { + const elapsed = Math.floor((Date.now() - this._last_refresh) / 1000); + this._refresh_label.inner().textContent = "refreshed " + elapsed + "s ago"; + } + + _toggle_pause() + { + if (this._paused) + this._resume_timer(); + else + this._pause_timer(true); + } + + _pause_timer(user_paused=true) + { + clearInterval(this._timer_id); + this._timer_id = undefined; + if (user_paused) + { + this._paused = true; + this._pause_btn.inner().textContent = "resume"; + } + } + + _resume_timer() + { + this._paused = false; + this._pause_btn.inner().textContent = "pause"; + this._timer_id = setInterval(() => this._refresh(), 5000); + this._refresh(); + } + + _condense(stats) + { + const impl = function(node) + { + for (var name in node) + { + const candidate = node[name]; + if (!(candidate instanceof Object)) + continue; + + if (candidate["rate_mean"] != undefined) + { + const as_bytes = (name.indexOf("bytes") >= 0); + node[name] = new TemporalStat(candidate, as_bytes); + continue; + } + + impl(candidate); + } + } + + for (var name in stats) + impl(stats[name]); + } +} diff --git a/src/zenserver/frontend/html/pages/oplog.js b/src/zenserver/frontend/html/pages/oplog.js index 879fc4c97..fb857affb 100644 --- a/src/zenserver/frontend/html/pages/oplog.js +++ b/src/zenserver/frontend/html/pages/oplog.js @@ -32,7 +32,7 @@ export class Page extends ZenPage this.set_title("oplog - " + oplog); - var section = this.add_section(project + " - " + oplog); + var section = this.add_section(oplog); oplog_info = await oplog_info; this._index_max = oplog_info["opcount"]; @@ -81,7 +81,7 @@ export class Page extends ZenPage const right = nav.right(); right.add(Friendly.sep(oplog_info["opcount"])); - right.add("(" + Friendly.kib(oplog_info["totalsize"]) + ")"); + right.add("(" + Friendly.bytes(oplog_info["totalsize"]) + ")"); right.sep(); var search_input = right.add("search:", "label").tag("input") diff --git a/src/zenserver/frontend/html/pages/orchestrator.js b/src/zenserver/frontend/html/pages/orchestrator.js new file mode 100644 index 000000000..24805c722 --- /dev/null +++ b/src/zenserver/frontend/html/pages/orchestrator.js @@ -0,0 +1,405 @@ +// 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" +import { Table } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("orchestrator"); + + // Agents section + const agents_section = this._collapsible_section("Compute Agents"); + this._agents_host = agents_section; + this._agents_table = null; + + // Clients section + const clients_section = this._collapsible_section("Connected Clients"); + this._clients_host = clients_section; + this._clients_table = null; + + // Event history + const history_section = this._collapsible_section("Worker Events"); + this._history_host = history_section; + this._history_table = null; + + const client_history_section = this._collapsible_section("Client Events"); + this._client_history_host = client_history_section; + this._client_history_table = null; + + this._ws_paused = false; + try { this._ws_paused = localStorage.getItem("zen-ws-paused") === "true"; } catch (e) {} + document.addEventListener("zen-ws-toggle", (e) => { + this._ws_paused = e.detail.paused; + }); + + // Initial fetch + await this._fetch_all(); + + // Connect WebSocket for live updates, fall back to polling + 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([ + 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), + ]); + + this._render_agents(agents); + if (history) + { + this._render_history(history.events || []); + } + if (clients) + { + this._render_clients(clients.clients || []); + } + if (client_history) + { + this._render_client_history(client_history.client_events || []); + } + } + catch (e) { /* service unavailable */ } + } + + _connect_ws() + { + try + { + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${proto}//${location.host}/orch/ws`); + + ws.onopen = () => { + if (this._poll_timer) + { + clearInterval(this._poll_timer); + this._poll_timer = null; + } + }; + + ws.onmessage = (ev) => { + if (this._ws_paused) + { + return; + } + try + { + const data = JSON.parse(ev.data); + this._render_agents(data); + if (data.events) + { + this._render_history(data.events); + } + if (data.clients) + { + this._render_clients(data.clients); + } + if (data.client_events) + { + this._render_client_history(data.client_events); + } + } + catch (e) { /* ignore parse errors */ } + }; + + ws.onclose = () => { + this._start_polling(); + setTimeout(() => this._connect_ws(), 3000); + }; + + ws.onerror = () => { /* onclose will fire */ }; + } + catch (e) + { + this._start_polling(); + } + } + + _start_polling() + { + if (!this._poll_timer) + { + this._poll_timer = setInterval(() => this._fetch_all(), 2000); + } + } + + _render_agents(data) + { + const workers = data.workers || []; + + if (this._agents_table) + { + this._agents_table.clear(); + } + else + { + this._agents_table = this._agents_host.add_widget( + Table, + ["hostname", "CPUs", "CPU usage", "memory", "queues", "pending", "running", "completed", "traffic", "last seen"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric, -1 + ); + } + + if (workers.length === 0) + { + return; + } + + let totalCpus = 0, totalWeightedCpu = 0; + let totalMemUsed = 0, totalMemTotal = 0; + let totalQueues = 0, totalPending = 0, totalRunning = 0, totalCompleted = 0; + let totalRecv = 0, totalSent = 0; + + for (const w of workers) + { + const cpus = w.cpus || 0; + const cpuUsage = w.cpu_usage; + const memUsed = w.memory_used || 0; + const memTotal = w.memory_total || 0; + const queues = w.active_queues || 0; + const pending = w.actions_pending || 0; + const running = w.actions_running || 0; + const completed = w.actions_completed || 0; + const recv = w.bytes_received || 0; + const sent = w.bytes_sent || 0; + + totalCpus += cpus; + if (cpus > 0 && typeof cpuUsage === "number") + { + totalWeightedCpu += cpuUsage * cpus; + } + totalMemUsed += memUsed; + totalMemTotal += memTotal; + totalQueues += queues; + totalPending += pending; + totalRunning += running; + totalCompleted += completed; + totalRecv += recv; + totalSent += sent; + + const hostname = w.hostname || ""; + const row = this._agents_table.add_row( + hostname, + cpus > 0 ? Friendly.sep(cpus) : "-", + typeof cpuUsage === "number" ? cpuUsage.toFixed(1) + "%" : "-", + memTotal > 0 ? Friendly.bytes(memUsed) + " / " + Friendly.bytes(memTotal) : "-", + queues > 0 ? Friendly.sep(queues) : "-", + Friendly.sep(pending), + Friendly.sep(running), + Friendly.sep(completed), + this._format_traffic(recv, sent), + this._format_last_seen(w.dt), + ); + + // Link hostname to worker dashboard + if (w.uri) + { + const cell = row.get_cell(0); + cell.inner().textContent = ""; + cell.tag("a").text(hostname).attr("href", w.uri + "/dashboard/compute/").attr("target", "_blank"); + } + } + + // Total row + const total = this._agents_table.add_row( + "TOTAL", + Friendly.sep(totalCpus), + "", + totalMemTotal > 0 ? Friendly.bytes(totalMemUsed) + " / " + Friendly.bytes(totalMemTotal) : "-", + Friendly.sep(totalQueues), + Friendly.sep(totalPending), + Friendly.sep(totalRunning), + Friendly.sep(totalCompleted), + this._format_traffic(totalRecv, totalSent), + "", + ); + total.get_cell(0).style("fontWeight", "bold"); + } + + _render_clients(clients) + { + if (this._clients_table) + { + this._clients_table.clear(); + } + else + { + this._clients_table = this._clients_host.add_widget( + Table, + ["client ID", "hostname", "address", "last seen"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable, -1 + ); + } + + for (const c of clients) + { + this._clients_table.add_row( + c.id || "", + c.hostname || "", + c.address || "", + this._format_last_seen(c.dt), + ); + } + } + + _render_history(events) + { + if (this._history_table) + { + this._history_table.clear(); + } + else + { + this._history_table = this._history_host.add_widget( + Table, + ["time", "event", "worker", "hostname"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable, -1 + ); + } + + for (const evt of events) + { + this._history_table.add_row( + this._format_timestamp(evt.ts), + evt.type || "", + evt.worker_id || "", + evt.hostname || "", + ); + } + } + + _render_client_history(events) + { + if (this._client_history_table) + { + this._client_history_table.clear(); + } + else + { + this._client_history_table = this._client_history_host.add_widget( + Table, + ["time", "event", "client", "hostname"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable, -1 + ); + } + + for (const evt of events) + { + this._client_history_table.add_row( + this._format_timestamp(evt.ts), + evt.type || "", + evt.client_id || "", + evt.hostname || "", + ); + } + } + + _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); + } + + _format_last_seen(dtMs) + { + if (dtMs == null) + { + return "-"; + } + const seconds = Math.floor(dtMs / 1000); + if (seconds < 60) + { + return seconds + "s ago"; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) + { + return minutes + "m " + (seconds % 60) + "s ago"; + } + const hours = Math.floor(minutes / 60); + return hours + "h " + (minutes % 60) + "m ago"; + } + + _format_traffic(recv, sent) + { + if (!recv && !sent) + { + return "-"; + } + return Friendly.bytes(recv) + " / " + Friendly.bytes(sent); + } + + _format_timestamp(ts) + { + if (!ts) + { + return "-"; + } + let date; + if (typeof ts === "number") + { + // .NET-style ticks: convert to Unix ms + const unixMs = (ts - 621355968000000000) / 10000; + date = new Date(unixMs); + } + else + { + date = new Date(ts); + } + if (isNaN(date.getTime())) + { + return "-"; + } + return date.toLocaleTimeString(); + } +} diff --git a/src/zenserver/frontend/html/pages/page.js b/src/zenserver/frontend/html/pages/page.js index 9a9541904..dd8032c28 100644 --- a/src/zenserver/frontend/html/pages/page.js +++ b/src/zenserver/frontend/html/pages/page.js @@ -3,6 +3,7 @@ "use strict"; import { WidgetHost } from "../util/widgets.js" +import { Fetcher } from "../util/fetcher.js" //////////////////////////////////////////////////////////////////////////////// export class PageBase extends WidgetHost @@ -63,31 +64,85 @@ export class ZenPage extends PageBase super(parent, ...args); super.set_title("zen"); this.add_branding(parent); + this.add_service_nav(parent); this.generate_crumbs(); } add_branding(parent) { - var root = parent.tag().id("branding"); - - const zen_store = root.tag("pre").id("logo").text( - "_________ _______ __\n" + - "\\____ /___ ___ / ___// |__ ___ ______ ____\n" + - " / __/ __ \\ / \\ \\___ \\\\_ __// \\\\_ \\/ __ \\\n" + - " / \\ __// | \\/ \\| | ( - )| |\\/\\ __/\n" + - "/______/\\___/\\__|__/\\______/|__| \\___/ |__| \\___|" - ); - zen_store.tag().id("go_home").on_click(() => window.location.search = ""); - - root.tag("img").attr("src", "favicon.ico").id("ue_logo"); - - /* - _________ _______ __ - \____ /___ ___ / ___// |__ ___ ______ ____ - / __/ __ \ / \ \___ \\_ __// \\_ \/ __ \ - / \ __// | \/ \| | ( - )| |\/\ __/ - /______/\___/\__|__/\______/|__| \___/ |__| \___| - */ + var banner = parent.tag("zen-banner"); + banner.attr("subtitle", "SERVER"); + banner.attr("tagline", "Local Storage Service"); + banner.attr("logo-src", "favicon.ico"); + banner.attr("load", "0"); + + this._banner = banner; + this._poll_status(); + } + + async _poll_status() + { + try + { + var cbo = await new Fetcher().resource("/status/status").cbo(); + if (cbo) + { + var obj = cbo.as_object(); + + var hostname = obj.find("hostname"); + if (hostname) + { + this._banner.attr("tagline", "Local Storage Service \u2014 " + hostname.as_value()); + } + + var cpu = obj.find("cpuUsagePercent"); + if (cpu) + { + this._banner.attr("load", cpu.as_value().toFixed(1)); + } + } + } + catch (e) { console.warn("status poll:", e); } + + setTimeout(() => this._poll_status(), 2000); + } + + add_service_nav(parent) + { + const nav = parent.tag().id("service_nav"); + + // Map service base URIs to dashboard links, this table is also used to detemine + // which links to show based on the services that are currently registered. + + const service_dashboards = [ + { 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" }, + ]; + + nav.tag("a").text("Home").attr("href", "/dashboard/"); + + nav.tag("a").text("Sessions").attr("href", "/dashboard/?page=sessions"); + nav.tag("a").text("Cache").attr("href", "/dashboard/?page=cache"); + nav.tag("a").text("Projects").attr("href", "/dashboard/?page=projects"); + this._info_link = nav.tag("a").text("Info").attr("href", "/dashboard/?page=info"); + + new Fetcher().resource("/api/").json().then((data) => { + const services = data.services || []; + const uris = new Set(services.map(s => s.base_uri)); + + const links = service_dashboards.filter(d => uris.has(d.base_uri)); + + // Insert service links before the Info link + const info_elem = this._info_link.inner(); + for (const link of links) + { + const a = document.createElement("a"); + a.textContent = link.label; + a.href = link.href; + info_elem.parentNode.insertBefore(a, info_elem); + } + }).catch(() => {}); } set_title(...args) @@ -97,7 +152,7 @@ export class ZenPage extends PageBase generate_crumbs() { - const auto_name = this.get_param("page") || "start"; + var auto_name = this.get_param("page") || "start"; if (auto_name == "start") return; @@ -114,15 +169,30 @@ export class ZenPage extends PageBase var project = this.get_param("project"); if (project != undefined) { + auto_name = project; var oplog = this.get_param("oplog"); if (oplog != undefined) { - new_crumb("project", `?page=project&project=${project}`); - if (this.get_param("opkey")) - new_crumb("oplog", `?page=oplog&project=${project}&oplog=${oplog}`); + new_crumb(auto_name, `?page=project&project=${project}`); + auto_name = oplog; + var opkey = this.get_param("opkey") + if (opkey != undefined) + { + new_crumb(auto_name, `?page=oplog&project=${project}&oplog=${oplog}`); + auto_name = opkey.split("/").pop().split("\\").pop(); + + // Check if we're viewing cook artifacts + var page = this.get_param("page"); + var hash = this.get_param("hash"); + if (hash != undefined && page == "cookartifacts") + { + new_crumb(auto_name, `?page=entry&project=${project}&oplog=${oplog}&opkey=${opkey}`); + auto_name = "cook artifacts"; + } + } } } - new_crumb(auto_name.toLowerCase()); + new_crumb(auto_name); } } diff --git a/src/zenserver/frontend/html/pages/project.js b/src/zenserver/frontend/html/pages/project.js index 42ae30c8c..3a7a45527 100644 --- a/src/zenserver/frontend/html/pages/project.js +++ b/src/zenserver/frontend/html/pages/project.js @@ -59,7 +59,7 @@ export class Page extends ZenPage info = await info; row.get_cell(1).text(info["markerpath"]); - row.get_cell(2).text(Friendly.kib(info["totalsize"])); + row.get_cell(2).text(Friendly.bytes(info["totalsize"])); row.get_cell(3).text(Friendly.sep(info["opcount"])); row.get_cell(4).text(info["expired"]); } diff --git a/src/zenserver/frontend/html/pages/projects.js b/src/zenserver/frontend/html/pages/projects.js new file mode 100644 index 000000000..9c1e519d4 --- /dev/null +++ b/src/zenserver/frontend/html/pages/projects.js @@ -0,0 +1,447 @@ +// 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" +import { Modal } from "../util/modal.js" +import { Table, Toolbar } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("projects"); + + // Project Service Stats + const stats_section = this._collapsible_section("Project Service Stats"); + stats_section.tag().classify("dropall").text("raw yaml \u2192").on_click(() => { + window.open("/stats/prj.yaml", "_blank"); + }); + this._stats_grid = stats_section.tag().classify("grid").classify("stats-tiles"); + + const stats = await new Fetcher().resource("stats", "prj").json(); + if (stats) + { + this._render_stats(stats); + } + + this._connect_stats_ws(); + + // 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", + "engine dir", + "oplogs", + "actions", + ]; + + 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(() => {}); + } + + // Project detail area (inside projects section so it collapses together) + this._project_host = section; + this._project_container = null; + this._selected_project = null; + + // Restore project from URL if present + const prj_param = this.get_param("project"); + if (prj_param) + { + this.view_project(prj_param); + } + } + + _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); + const url = new URL(window.location); + url.searchParams.delete(name); + history.replaceState(null, "", url); + } + + _connect_stats_ws() + { + try + { + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${proto}//${location.host}/stats`); + + try { this._ws_paused = localStorage.getItem("zen-ws-paused") === "true"; } catch (e) { this._ws_paused = false; } + document.addEventListener("zen-ws-toggle", (e) => { + this._ws_paused = e.detail.paused; + }); + + ws.onmessage = (ev) => { + if (this._ws_paused) + { + return; + } + try + { + const all_stats = JSON.parse(ev.data); + const stats = all_stats["prj"]; + if (stats) + { + this._render_stats(stats); + } + } + catch (e) { /* ignore parse errors */ } + }; + + ws.onclose = () => { this._stats_ws = null; }; + ws.onerror = () => { ws.close(); }; + + this._stats_ws = ws; + } + catch (e) { /* WebSocket not available */ } + } + + _render_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"); + } + } + } + + // 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"); + } + } + + // 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"); + } + } + + // Storage tile + { + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Storage"); + const columns = tile.tag().classify("tile-columns"); + + const left = columns.tag().classify("tile-metrics"); + this._metric(left, safe(stats, "store.size.disk") != null ? Friendly.bytes(safe(stats, "store.size.disk")) : "-", "store disk", true); + this._metric(left, safe(stats, "store.size.memory") != null ? Friendly.bytes(safe(stats, "store.size.memory")) : "-", "store memory"); + + const right = columns.tag().classify("tile-metrics"); + this._metric(right, safe(stats, "cid.size.total") != null ? Friendly.bytes(safe(stats, "cid.size.total")) : "-", "cid total", true); + this._metric(right, safe(stats, "cid.size.tiny") != null ? Friendly.bytes(safe(stats, "cid.size.tiny")) : "-", "cid tiny"); + this._metric(right, safe(stats, "cid.size.small") != null ? Friendly.bytes(safe(stats, "cid.size.small")) : "-", "cid small"); + this._metric(right, safe(stats, "cid.size.large") != null ? Friendly.bytes(safe(stats, "cid.size.large")) : "-", "cid large"); + } + } + + _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); + } + + async view_project(project_id) + { + // Toggle off if already selected + if (this._selected_project === project_id) + { + this._selected_project = null; + this._clear_project_detail(); + this._clear_param("project"); + return; + } + + this._selected_project = project_id; + this._clear_project_detail(); + this.set_param("project", project_id); + + const info = await new Fetcher().resource("prj", project_id).json(); + if (this._selected_project !== project_id) + { + return; + } + + const section = this._project_host.add_section(project_id); + this._project_container = section; + + // Oplogs table + const oplog_section = section.add_section("Oplogs"); + const oplog_table = oplog_section.add_widget( + Table, + ["name", "marker", "size", "ops", "expired", "actions"], + Table.Flag_FitLeft|Table.Flag_PackRight|Table.Flag_Sortable|Table.Flag_AlignNumeric + ); + + let totalSize = 0, totalOps = 0; + const total_row = oplog_table.add_row("TOTAL"); + total_row.get_cell(0).style("fontWeight", "bold"); + total_row.get_cell(2).style("textAlign", "right").style("fontWeight", "bold"); + total_row.get_cell(3).style("textAlign", "right").style("fontWeight", "bold"); + + // Right-align header for numeric columns (size, ops) + const header = oplog_table._element.firstElementChild; + for (let i = 3; i < header.children.length - 1; i++) + { + header.children[i].style.textAlign = "right"; + } + + for (const oplog of info["oplogs"] || []) + { + const name = oplog["id"]; + const row = oplog_table.add_row(""); + + var cell = row.get_cell(0); + cell.tag().text(name).link("", { + "page": "oplog", + "project": project_id, + "oplog": name, + }); + + cell = row.get_cell(-1); + const action_tb = new Toolbar(cell, true).left(); + action_tb.add("list").link("", { "page": "oplog", "project": project_id, "oplog": name }); + action_tb.add("tree").link("", { "page": "tree", "project": project_id, "oplog": name }); + action_tb.add("drop").on_click(() => this.drop_oplog(project_id, name)); + + new Fetcher().resource("prj", project_id, "oplog", name).json().then((data) => { + row.get_cell(1).text(data["markerpath"]); + row.get_cell(2).text(Friendly.bytes(data["totalsize"])).style("textAlign", "right"); + row.get_cell(3).text(Friendly.sep(data["opcount"])).style("textAlign", "right"); + row.get_cell(4).text(data["expired"]); + + totalSize += data["totalsize"] || 0; + totalOps += data["opcount"] || 0; + total_row.get_cell(2).text(Friendly.bytes(totalSize)).style("textAlign", "right").style("fontWeight", "bold"); + total_row.get_cell(3).text(Friendly.sep(totalOps)).style("textAlign", "right").style("fontWeight", "bold"); + }).catch(() => {}); + } + } + + _clear_project_detail() + { + if (this._project_container) + { + this._project_container._parent.inner().remove(); + this._project_container = null; + } + } + + drop_oplog(project_id, oplog_id) + { + const drop = async () => { + await new Fetcher().resource("prj", project_id, "oplog", oplog_id).delete(); + // Refresh the project view + this._selected_project = null; + this._clear_project_detail(); + this.view_project(project_id); + }; + + new Modal() + .title("Confirmation") + .message(`Drop oplog '${oplog_id}'?`) + .option("Yes", () => drop()) + .option("No"); + } + + drop_project(project_id) + { + const drop = async () => { + await new Fetcher().resource("prj", project_id).delete(); + this.reload(); + }; + + new Modal() + .title("Confirmation") + .message(`Drop project '${project_id}'?`) + .option("Yes", () => drop()) + .option("No"); + } + + async drop_all() + { + const drop = async () => { + for (const row of this._project_table) + { + const project_id = row.attr("zs_name"); + await new Fetcher().resource("prj", project_id).delete(); + } + this.reload(); + }; + + new Modal() + .title("Confirmation") + .message("Drop every project?") + .option("Yes", () => drop()) + .option("No"); + } +} diff --git a/src/zenserver/frontend/html/pages/sessions.js b/src/zenserver/frontend/html/pages/sessions.js new file mode 100644 index 000000000..95533aa96 --- /dev/null +++ b/src/zenserver/frontend/html/pages/sessions.js @@ -0,0 +1,61 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +"use strict"; + +import { ZenPage } from "./page.js" +import { Fetcher } from "../util/fetcher.js" +import { Table } from "../util/widgets.js" + +//////////////////////////////////////////////////////////////////////////////// +export class Page extends ZenPage +{ + async main() + { + this.set_title("sessions"); + + const data = await new Fetcher().resource("/sessions/").json(); + const sessions = data.sessions || []; + + const section = this.add_section("Sessions"); + + if (sessions.length === 0) + { + section.tag().classify("empty-state").text("No active sessions."); + return; + } + + const columns = [ + "id", + "created", + "updated", + "metadata", + ]; + const table = section.add_widget(Table, columns, Table.Flag_FitLeft); + + for (const session of sessions) + { + const created = session.created_at ? new Date(session.created_at).toLocaleString() : "-"; + const updated = session.updated_at ? new Date(session.updated_at).toLocaleString() : "-"; + const meta = this._format_metadata(session.metadata); + + const row = table.add_row( + session.id || "-", + created, + updated, + meta, + ); + } + } + + _format_metadata(metadata) + { + if (!metadata || Object.keys(metadata).length === 0) + { + return "-"; + } + + return Object.entries(metadata) + .map(([k, v]) => `${k}: ${v}`) + .join(", "); + } +} diff --git a/src/zenserver/frontend/html/pages/start.js b/src/zenserver/frontend/html/pages/start.js index 4c8789431..3a68a725d 100644 --- a/src/zenserver/frontend/html/pages/start.js +++ b/src/zenserver/frontend/html/pages/start.js @@ -13,109 +13,117 @@ export class Page extends ZenPage { async main() { + // Discover which services are available + const api_data = await new Fetcher().resource("/api/").json(); + const available = new Set((api_data.services || []).map(s => s.base_uri)); + // project list - var section = this.add_section("projects"); + 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")); + section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all("projects")); - var columns = [ - "name", - "project_dir", - "engine_dir", - "actions", - ]; - var project_table = section.add_widget(Table, columns); + var columns = [ + "name", + "project_dir", + "engine_dir", + "actions", + ]; + project_table = section.add_widget(Table, columns); - for (const project of await new Fetcher().resource("/prj/list").json()) - { - var row = project_table.add_row( - "", - project.ProjectRootDir, - project.EngineRootDir, - ); + 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)); - var cell = row.get_cell(0); - cell.tag().text(project.Id).on_click((x) => this.view_project(x), project.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); + 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); + row.attr("zs_name", project.Id); + } } // cache - var section = this.add_section("z$"); - - section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all("z$")); - - columns = [ - "namespace", - "dir", - "buckets", - "entries", - "size disk", - "size mem", - "actions", - ] - var zcache_info = new Fetcher().resource("/z$/").json(); - const cache_table = section.add_widget(Table, columns, Table.Flag_FitLeft|Table.Flag_PackRight); - for (const namespace of (await zcache_info)["Namespaces"]) + var cache_table = null; + if (available.has("/z$/")) { - new Fetcher().resource(`/z$/${namespace}/`).json().then((data) => { - const row = cache_table.add_row( - "", - data["Configuration"]["RootDir"], - data["Buckets"].length, - data["EntryCount"], - Friendly.kib(data["StorageSize"].DiskSize), - Friendly.kib(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); + var section = this.add_section("Cache"); - 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)); + section.tag().classify("dropall").text("drop-all").on_click(() => this.drop_all("z$")); - row.attr("zs_name", namespace); - }); + var columns = [ + "namespace", + "dir", + "buckets", + "entries", + "size disk", + "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); + }); + } } - // stats + // stats tiles const safe_lookup = (obj, path, pretty=undefined) => { const ret = path.split(".").reduce((a,b) => a && a[b], obj); - if (ret === undefined) return "-"; + if (ret === undefined) return undefined; return pretty ? pretty(ret) : ret; }; - section = this.add_section("stats"); - columns = [ - "name", - "req count", - "size disk", - "size mem", - "cid total", - ]; - const stats_table = section.add_widget(Table, columns, Table.Flag_PackRight); - var providers = new Fetcher().resource("stats").json(); - for (var provider of (await providers)["providers"]) - { - var stats = await new Fetcher().resource("stats", provider).json(); - var size_stat = (stats.store || stats.cache); - var values = [ - "", - safe_lookup(stats, "requests.count"), - safe_lookup(size_stat, "size.disk", Friendly.kib), - safe_lookup(size_stat, "size.memory", Friendly.kib), - safe_lookup(stats, "cid.size.total"), - ]; - row = stats_table.add_row(...values); - row.get_cell(0).tag().text(provider).on_click((x) => this.view_stat(x), provider); - } + var section = this.add_section("Stats"); + section.tag().classify("dropall").text("metrics dashboard →").on_click(() => { + window.location = "?page=metrics"; + }); + + var providers_data = await new Fetcher().resource("stats").json(); + var provider_list = providers_data["providers"] || []; + var all_stats = {}; + await Promise.all(provider_list.map(async (provider) => { + all_stats[provider] = await new Fetcher().resource("stats", provider).json(); + })); + + this._stats_grid = section.tag().classify("grid").classify("stats-tiles"); + this._safe_lookup = safe_lookup; + this._render_stats(all_stats); // version var ver_tag = this.tag().id("version"); @@ -125,6 +133,159 @@ export class Page extends ZenPage this._project_table = project_table; this._cache_table = cache_table; + + // WebSocket for live stats updates + this._connect_stats_ws(); + } + + _connect_stats_ws() + { + try + { + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + const ws = new WebSocket(`${proto}//${location.host}/stats`); + + try { this._ws_paused = localStorage.getItem("zen-ws-paused") === "true"; } catch (e) { this._ws_paused = false; } + document.addEventListener("zen-ws-toggle", (e) => { + this._ws_paused = e.detail.paused; + }); + + ws.onmessage = (ev) => { + if (this._ws_paused) + { + return; + } + try + { + const all_stats = JSON.parse(ev.data); + this._render_stats(all_stats); + } + catch (e) { /* ignore parse errors */ } + }; + + ws.onclose = () => { this._stats_ws = null; }; + ws.onerror = () => { ws.close(); }; + + this._stats_ws = ws; + } + catch (e) { /* WebSocket not available */ } + } + + _render_stats(all_stats) + { + const grid = this._stats_grid; + const safe_lookup = this._safe_lookup; + + // Clear existing tiles + 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"); + + // 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)"); + + // Right column: websocket stats + const ws = all_stats["http"] ? (all_stats["http"]["websockets"] || {}) : {}; + const right = columns.tag().classify("tile-metrics"); + + 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"); + + tile.on_click(() => { window.location = "?page=metrics"; }); + } + + // Cache tile (z$) + if (all_stats["z$"]) + { + const s = all_stats["z$"]; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Cache"); + const body = tile.tag().classify("tile-metrics"); + + const hits = safe_lookup(s, "cache.hits") || 0; + const misses = safe_lookup(s, "cache.misses") || 0; + const ratio = (hits + misses) > 0 ? ((hits / (hits + misses)) * 100).toFixed(1) + "%" : "-"; + + this._add_tile_metric(body, ratio, "hit ratio", true); + 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$"; }); + } + + // Project Store tile (prj) + if (all_stats["prj"]) + { + const s = all_stats["prj"]; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Project 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, "store.size.disk", Friendly.bytes) || "-", "disk"); + + tile.on_click(() => { window.location = "?page=stat&provider=prj"; }); + } + + // Build Store tile (builds) + if (all_stats["builds"]) + { + const s = all_stats["builds"]; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Build 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, "store.size.disk", Friendly.bytes) || "-", "disk"); + + tile.on_click(() => { window.location = "?page=stat&provider=builds"; }); + } + + // Workspace tile (ws) + if (all_stats["ws"]) + { + const s = all_stats["ws"]; + const tile = grid.tag().classify("card").classify("stats-tile"); + tile.tag().classify("card-title").text("Workspace"); + 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"); + + tile.on_click(() => { window.location = "?page=stat&provider=ws"; }); + } + } + + _add_tile_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); } view_stat(provider) diff --git a/src/zenserver/frontend/html/pages/stat.js b/src/zenserver/frontend/html/pages/stat.js index d6c7fa8e8..4f020ac5e 100644 --- a/src/zenserver/frontend/html/pages/stat.js +++ b/src/zenserver/frontend/html/pages/stat.js @@ -33,7 +33,7 @@ class TemporalStat out[key] = data[key]; } - var friendly = this._as_bytes ? Friendly.kib : Friendly.sep; + var friendly = this._as_bytes ? Friendly.bytes : Friendly.sep; var content = ""; for (var i = 0; i < columns.length; ++i) diff --git a/src/zenserver/frontend/html/pages/tree.js b/src/zenserver/frontend/html/pages/tree.js index 08a578492..b5fece5a3 100644 --- a/src/zenserver/frontend/html/pages/tree.js +++ b/src/zenserver/frontend/html/pages/tree.js @@ -106,7 +106,7 @@ export class Page extends ZenPage for (var i = 0; i < 2; ++i) { - const size = Friendly.kib(new_nodes[name][i]); + const size = Friendly.bytes(new_nodes[name][i]); info.tag().text(size); } diff --git a/src/zenserver/frontend/html/pages/zcache.js b/src/zenserver/frontend/html/pages/zcache.js index 974893b21..d8bdc892a 100644 --- a/src/zenserver/frontend/html/pages/zcache.js +++ b/src/zenserver/frontend/html/pages/zcache.js @@ -27,8 +27,8 @@ export class Page extends ZenPage cfg_table.add_object(info["Configuration"], true); - storage_table.add_property("disk", Friendly.kib(info["StorageSize"]["DiskSize"])); - storage_table.add_property("mem", Friendly.kib(info["StorageSize"]["MemorySize"])); + storage_table.add_property("disk", Friendly.bytes(info["StorageSize"]["DiskSize"])); + storage_table.add_property("mem", Friendly.bytes(info["StorageSize"]["MemorySize"])); storage_table.add_property("entries", Friendly.sep(info["EntryCount"])); var column_names = ["name", "disk", "mem", "entries", "actions"]; @@ -41,8 +41,8 @@ export class Page extends ZenPage { const row = bucket_table.add_row(bucket); new Fetcher().resource(`/z$/${namespace}/${bucket}`).json().then((data) => { - row.get_cell(1).text(Friendly.kib(data["StorageSize"]["DiskSize"])); - row.get_cell(2).text(Friendly.kib(data["StorageSize"]["MemorySize"])); + row.get_cell(1).text(Friendly.bytes(data["StorageSize"]["DiskSize"])); + row.get_cell(2).text(Friendly.bytes(data["StorageSize"]["MemorySize"])); row.get_cell(3).text(Friendly.sep(data["DiskEntryCount"])); const cell = row.get_cell(-1); diff --git a/src/zenserver/frontend/html/theme.js b/src/zenserver/frontend/html/theme.js new file mode 100644 index 000000000..52ca116ab --- /dev/null +++ b/src/zenserver/frontend/html/theme.js @@ -0,0 +1,116 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +// Theme toggle: cycles system → light → dark → system. +// Persists choice in localStorage. Applies data-theme attribute on <html>. + +(function() { + var KEY = 'zen-theme'; + + function getStored() { + try { return localStorage.getItem(KEY); } catch (e) { return null; } + } + + function setStored(value) { + try { + if (value) localStorage.setItem(KEY, value); + else localStorage.removeItem(KEY); + } catch (e) {} + } + + function apply(theme) { + if (theme) + document.documentElement.setAttribute('data-theme', theme); + else + document.documentElement.removeAttribute('data-theme'); + } + + function getEffective(stored) { + if (stored) return stored; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + + // Apply stored preference immediately (before paint) + var stored = getStored(); + apply(stored); + + // Create toggle button once DOM is ready + function createToggle() { + var btn = document.createElement('button'); + btn.id = 'zen_theme_toggle'; + btn.title = 'Toggle theme'; + + function updateIcon() { + var effective = getEffective(getStored()); + // Show sun in dark mode (click to go light), moon in light mode (click to go dark) + btn.textContent = effective === 'dark' ? '\u2600' : '\u263E'; + + var isManual = getStored() != null; + btn.title = isManual + ? 'Theme: ' + effective + ' (click to change, double-click for system)' + : 'Theme: system (click to change)'; + } + + btn.addEventListener('click', function() { + var current = getStored(); + var effective = getEffective(current); + // Toggle to the opposite + var next = effective === 'dark' ? 'light' : 'dark'; + setStored(next); + apply(next); + updateIcon(); + }); + + btn.addEventListener('dblclick', function(e) { + e.preventDefault(); + // Reset to system preference + setStored(null); + apply(null); + updateIcon(); + }); + + // Update icon when system preference changes + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() { + if (!getStored()) updateIcon(); + }); + + updateIcon(); + document.body.appendChild(btn); + + // WebSocket pause/play toggle + var WS_KEY = 'zen-ws-paused'; + var wsBtn = document.createElement('button'); + wsBtn.id = 'zen_ws_toggle'; + + var initialPaused = false; + try { initialPaused = localStorage.getItem(WS_KEY) === 'true'; } catch (e) {} + + function updateWsIcon(paused) { + wsBtn.dataset.paused = paused ? 'true' : 'false'; + wsBtn.textContent = paused ? '\u25B6' : '\u23F8'; + wsBtn.title = paused ? 'Resume live updates' : 'Pause live updates'; + } + + updateWsIcon(initialPaused); + + // Fire initial event so pages pick up persisted state + document.addEventListener('DOMContentLoaded', function() { + if (initialPaused) { + document.dispatchEvent(new CustomEvent('zen-ws-toggle', { detail: { paused: true } })); + } + }); + + wsBtn.addEventListener('click', function() { + var paused = wsBtn.dataset.paused !== 'true'; + try { localStorage.setItem(WS_KEY, paused ? 'true' : 'false'); } catch (e) {} + updateWsIcon(paused); + document.dispatchEvent(new CustomEvent('zen-ws-toggle', { detail: { paused: paused } })); + }); + + document.body.appendChild(wsBtn); + } + + if (document.readyState === 'loading') + document.addEventListener('DOMContentLoaded', createToggle); + else + createToggle(); +})(); diff --git a/src/zenserver/frontend/html/util/compactbinary.js b/src/zenserver/frontend/html/util/compactbinary.js index 90e4249f6..415fa4be8 100644 --- a/src/zenserver/frontend/html/util/compactbinary.js +++ b/src/zenserver/frontend/html/util/compactbinary.js @@ -310,8 +310,8 @@ CbFieldView.prototype.as_value = function(int_type=BigInt) case CbFieldType.IntegerPositive: return VarInt.read_uint(this._data_view, int_type)[0]; case CbFieldType.IntegerNegative: return VarInt.read_int(this._data_view, int_type)[0]; - case CbFieldType.Float32: return new DataView(this._data_view.subarray(0, 4)).getFloat32(0, false); - case CbFieldType.Float64: return new DataView(this._data_view.subarray(0, 8)).getFloat64(0, false); + case CbFieldType.Float32: { const s = this._data_view; return new DataView(s.buffer, s.byteOffset, 4).getFloat32(0, false); } + case CbFieldType.Float64: { const s = this._data_view; return new DataView(s.buffer, s.byteOffset, 8).getFloat64(0, false); } case CbFieldType.BoolFalse: return false; case CbFieldType.BoolTrue: return true; diff --git a/src/zenserver/frontend/html/util/friendly.js b/src/zenserver/frontend/html/util/friendly.js index a15252faf..5d4586165 100644 --- a/src/zenserver/frontend/html/util/friendly.js +++ b/src/zenserver/frontend/html/util/friendly.js @@ -20,4 +20,25 @@ export class Friendly static kib(x, p=0) { return Friendly.sep((BigInt(x) + 1023n) / (1n << 10n)|0n, p) + " KiB"; } static mib(x, p=1) { return Friendly.sep( BigInt(x) / (1n << 20n), p) + " MiB"; } static gib(x, p=2) { return Friendly.sep( BigInt(x) / (1n << 30n), p) + " GiB"; } + + static duration(s) + { + const v = Number(s); + if (v >= 1) return Friendly.sep(v, 2) + " s"; + if (v >= 0.001) return Friendly.sep(v * 1000, 2) + " ms"; + if (v >= 0.000001) return Friendly.sep(v * 1000000, 1) + " µs"; + return Friendly.sep(v * 1000000000, 0) + " ns"; + } + + static bytes(x) + { + const v = BigInt(Math.trunc(Number(x))); + if (v >= (1n << 60n)) return Friendly.sep(Number(v) / Number(1n << 60n), 2) + " EiB"; + if (v >= (1n << 50n)) return Friendly.sep(Number(v) / Number(1n << 50n), 2) + " PiB"; + if (v >= (1n << 40n)) return Friendly.sep(Number(v) / Number(1n << 40n), 2) + " TiB"; + if (v >= (1n << 30n)) return Friendly.sep(Number(v) / Number(1n << 30n), 2) + " GiB"; + if (v >= (1n << 20n)) return Friendly.sep(Number(v) / Number(1n << 20n), 1) + " MiB"; + if (v >= (1n << 10n)) return Friendly.sep(Number(v) / Number(1n << 10n), 0) + " KiB"; + return Friendly.sep(Number(v), 0) + " B"; + } } diff --git a/src/zenserver/frontend/html/util/widgets.js b/src/zenserver/frontend/html/util/widgets.js index 32a3f4d28..2964f92f2 100644 --- a/src/zenserver/frontend/html/util/widgets.js +++ b/src/zenserver/frontend/html/util/widgets.js @@ -54,6 +54,8 @@ export class Table extends Widget static Flag_PackRight = 1 << 1; static Flag_BiasLeft = 1 << 2; static Flag_FitLeft = 1 << 3; + static Flag_Sortable = 1 << 4; + static Flag_AlignNumeric = 1 << 5; constructor(parent, column_names, flags=Table.Flag_EvenSpacing, index_base=0) { @@ -81,11 +83,108 @@ export class Table extends Widget root.style("gridTemplateColumns", column_style); - this._add_row(column_names, false); + this._header_row = this._add_row(column_names, false); this._index = index_base; this._num_columns = column_names.length; this._rows = []; + this._flags = flags; + this._sort_column = -1; + this._sort_ascending = true; + + if (flags & Table.Flag_Sortable) + { + this._init_sortable(); + } + } + + _init_sortable() + { + const header_elem = this._element.firstElementChild; + if (!header_elem) + { + return; + } + + const cells = header_elem.children; + for (let i = 0; i < cells.length; i++) + { + const cell = cells[i]; + cell.style.cursor = "pointer"; + cell.style.userSelect = "none"; + cell.addEventListener("click", () => this._sort_by(i)); + } + } + + _sort_by(column_index) + { + if (this._sort_column === column_index) + { + this._sort_ascending = !this._sort_ascending; + } + else + { + this._sort_column = column_index; + this._sort_ascending = true; + } + + // Update header indicators + const header_elem = this._element.firstElementChild; + for (const cell of header_elem.children) + { + const text = cell.textContent.replace(/ [▲▼]$/, ""); + cell.textContent = text; + } + const active_cell = header_elem.children[column_index]; + active_cell.textContent += this._sort_ascending ? " ▲" : " ▼"; + + // Sort rows by comparing cell text content + const dir = this._sort_ascending ? 1 : -1; + const unit_multipliers = { "B": 1, "KiB": 1024, "MiB": 1048576, "GiB": 1073741824, "TiB": 1099511627776, "PiB": 1125899906842624, "EiB": 1152921504606846976 }; + const parse_sortable = (text) => { + // Try byte units first (e.g. "1,234 KiB", "1.5 GiB") + const byte_match = text.match(/^([\d,.]+)\s*(B|[KMGTPE]iB)/); + if (byte_match) + { + const num = parseFloat(byte_match[1].replace(/,/g, "")); + const mult = unit_multipliers[byte_match[2]] || 1; + return num * mult; + } + // Try percentage (e.g. "95.5%") + const pct_match = text.match(/^([\d,.]+)%/); + if (pct_match) + { + return parseFloat(pct_match[1].replace(/,/g, "")); + } + // Try plain number (possibly with commas/separators) + const num = parseFloat(text.replace(/,/g, "")); + if (!isNaN(num)) + { + return num; + } + return null; + }; + this._rows.sort((a, b) => { + const aElem = a.inner().children[column_index]; + const bElem = b.inner().children[column_index]; + const aText = aElem ? aElem.textContent : ""; + const bText = bElem ? bElem.textContent : ""; + + const aNum = parse_sortable(aText); + const bNum = parse_sortable(bText); + + if (aNum !== null && bNum !== null) + { + return (aNum - bNum) * dir; + } + return aText.localeCompare(bText) * dir; + }); + + // Re-order DOM elements + for (const row of this._rows) + { + this._element.appendChild(row.inner()); + } } *[Symbol.iterator]() @@ -121,6 +220,18 @@ export class Table extends Widget ret.push(new TableCell(leaf, row)); } + if ((this._flags & Table.Flag_AlignNumeric) && indexed) + { + for (const c of ret) + { + const t = c.inner().textContent; + if (t && /^\d/.test(t)) + { + c.style("textAlign", "right"); + } + } + } + if (this._index >= 0) ret.shift(); @@ -131,9 +242,34 @@ export class Table extends Widget { var row = this._add_row(args); this._rows.push(row); + + if ((this._flags & Table.Flag_AlignNumeric) && this._rows.length === 1) + { + this._align_header(); + } + return row; } + _align_header() + { + const first_row = this._rows[0]; + if (!first_row) + { + return; + } + const header_elem = this._element.firstElementChild; + const header_cells = header_elem.children; + const data_cells = first_row.inner().children; + for (let i = 0; i < data_cells.length && i < header_cells.length; i++) + { + if (data_cells[i].style.textAlign === "right") + { + header_cells[i].style.textAlign = "right"; + } + } + } + clear(index=0) { const elem = this._element; diff --git a/src/zenserver/frontend/html/zen.css b/src/zenserver/frontend/html/zen.css index cc53c0519..a968aecab 100644 --- a/src/zenserver/frontend/html/zen.css +++ b/src/zenserver/frontend/html/zen.css @@ -2,66 +2,202 @@ /* theme -------------------------------------------------------------------- */ +/* system preference (default) */ @media (prefers-color-scheme: light) { :root { - --theme_g0: #000; - --theme_g4: #fff; - --theme_g1: color-mix(in oklab, var(--theme_g0), var(--theme_g4) 45%); - --theme_g2: color-mix(in oklab, var(--theme_g0), var(--theme_g4) 80%); - --theme_g3: color-mix(in oklab, var(--theme_g0), var(--theme_g4) 96%); - - --theme_p0: #069; - --theme_p4: hsl(210deg 40% 94%); + --theme_g0: #1f2328; + --theme_g1: #656d76; + --theme_g2: #d0d7de; + --theme_g3: #f6f8fa; + --theme_g4: #ffffff; + + --theme_p0: #0969da; + --theme_p4: #ddf4ff; --theme_p1: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 35%); --theme_p2: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 60%); --theme_p3: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 85%); --theme_ln: var(--theme_p0); - --theme_er: #fcc; + --theme_er: #ffebe9; + + --theme_ok: #1a7f37; + --theme_warn: #9a6700; + --theme_fail: #cf222e; + + --theme_bright: #1f2328; + --theme_faint: #6e7781; + --theme_border_subtle: #d8dee4; } } @media (prefers-color-scheme: dark) { :root { - --theme_g0: #ddd; - --theme_g4: #222; - --theme_g1: color-mix(in oklab, var(--theme_g0), var(--theme_g4) 35%); - --theme_g2: color-mix(in oklab, var(--theme_g0), var(--theme_g4) 65%); - --theme_g3: color-mix(in oklab, var(--theme_g0), var(--theme_g4) 88%); - - --theme_p0: #479; - --theme_p4: #333; + --theme_g0: #c9d1d9; + --theme_g1: #8b949e; + --theme_g2: #30363d; + --theme_g3: #161b22; + --theme_g4: #0d1117; + + --theme_p0: #58a6ff; + --theme_p4: #1c2128; --theme_p1: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 35%); --theme_p2: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 60%); --theme_p3: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 85%); - --theme_ln: #feb; - --theme_er: #622; + --theme_ln: #58a6ff; + --theme_er: #1c1c1c; + + --theme_ok: #3fb950; + --theme_warn: #d29922; + --theme_fail: #f85149; + + --theme_bright: #f0f6fc; + --theme_faint: #6e7681; + --theme_border_subtle: #21262d; } } +/* manual overrides (higher specificity than media queries) */ +:root[data-theme="light"] { + --theme_g0: #1f2328; + --theme_g1: #656d76; + --theme_g2: #d0d7de; + --theme_g3: #f6f8fa; + --theme_g4: #ffffff; + + --theme_p0: #0969da; + --theme_p4: #ddf4ff; + --theme_p1: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 35%); + --theme_p2: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 60%); + --theme_p3: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 85%); + + --theme_ln: var(--theme_p0); + --theme_er: #ffebe9; + + --theme_ok: #1a7f37; + --theme_warn: #9a6700; + --theme_fail: #cf222e; + + --theme_bright: #1f2328; + --theme_faint: #6e7781; + --theme_border_subtle: #d8dee4; +} + +:root[data-theme="dark"] { + --theme_g0: #c9d1d9; + --theme_g1: #8b949e; + --theme_g2: #30363d; + --theme_g3: #161b22; + --theme_g4: #0d1117; + + --theme_p0: #58a6ff; + --theme_p4: #1c2128; + --theme_p1: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 35%); + --theme_p2: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 60%); + --theme_p3: color-mix(in oklab, var(--theme_p0), var(--theme_p4) 85%); + + --theme_ln: #58a6ff; + --theme_er: #1c1c1c; + + --theme_ok: #3fb950; + --theme_warn: #d29922; + --theme_fail: #f85149; + + --theme_bright: #f0f6fc; + --theme_faint: #6e7681; + --theme_border_subtle: #21262d; +} + +/* theme toggle ------------------------------------------------------------- */ + +#zen_ws_toggle { + position: fixed; + top: 16px; + right: 60px; + z-index: 10; + width: 36px; + height: 36px; + border-radius: 6px; + border: 1px solid var(--theme_g2); + background: var(--theme_g3); + color: var(--theme_g1); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + line-height: 1; + transition: color 0.15s, background 0.15s, border-color 0.15s; + user-select: none; +} + +#zen_ws_toggle:hover { + color: var(--theme_g0); + background: var(--theme_p4); + border-color: var(--theme_g1); +} + +#zen_theme_toggle { + position: fixed; + top: 16px; + right: 16px; + z-index: 10; + width: 36px; + height: 36px; + border-radius: 6px; + border: 1px solid var(--theme_g2); + background: var(--theme_g3); + color: var(--theme_g1); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + line-height: 1; + transition: color 0.15s, background 0.15s, border-color 0.15s; + user-select: none; +} + +#zen_theme_toggle:hover { + color: var(--theme_g0); + background: var(--theme_p4); + border-color: var(--theme_g1); +} + /* page --------------------------------------------------------------------- */ -body, input { - font-family: consolas, monospace; - font-size: 11pt; +body, input, button { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + font-size: 14px; } body { overflow-y: scroll; margin: 0; + padding: 20px; background-color: var(--theme_g4); color: var(--theme_g0); } -pre { - margin: 0; +pre, code { + font-family: 'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace; + font-size: 13px; + margin: 0; } input { color: var(--theme_g0); background-color: var(--theme_g3); border: 1px solid var(--theme_g2); + border-radius: 4px; + padding: 4px 8px; +} + +button { + color: var(--theme_g0); + background: transparent; + border: none; + cursor: pointer; } * { @@ -69,17 +205,44 @@ input { } #container { - max-width: 130em; - min-width: 80em; + max-width: 1400px; margin: auto; > div { - margin: 0.0em 2.2em 0.0em 2.2em; padding-top: 1.0em; padding-bottom: 1.5em; } } +/* service nav -------------------------------------------------------------- */ + +#service_nav { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 16px; + padding: 4px; + background-color: var(--theme_g3); + border: 1px solid var(--theme_g2); + border-radius: 6px; + + a { + padding: 6px 14px; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + color: var(--theme_g1); + text-decoration: none; + transition: color 0.15s, background 0.15s; + } + + a:hover { + background-color: var(--theme_p4); + color: var(--theme_g0); + text-decoration: none; + } +} + /* links -------------------------------------------------------------------- */ a { @@ -103,28 +266,37 @@ a { } h1 { - font-size: 1.5em; + font-size: 20px; + font-weight: 600; width: 100%; + color: var(--theme_bright); border-bottom: 1px solid var(--theme_g2); + padding-bottom: 0.4em; + margin-bottom: 16px; } h2 { - font-size: 1.25em; - margin-bottom: 0.5em; + font-size: 16px; + font-weight: 600; + margin-bottom: 12px; } h3 { - font-size: 1.1em; + font-size: 14px; + font-weight: 600; margin: 0em; - padding: 0.4em; - background-color: var(--theme_p4); - border-left: 5px solid var(--theme_p2); - font-weight: normal; + padding: 8px 12px; + background-color: var(--theme_g3); + border: 1px solid var(--theme_g2); + border-radius: 6px 6px 0 0; + color: var(--theme_g1); + text-transform: uppercase; + letter-spacing: 0.5px; } - margin-bottom: 3em; + margin-bottom: 2em; > *:not(h1) { - margin-left: 2em; + margin-left: 0; } } @@ -134,23 +306,36 @@ a { .zen_table { display: grid; border: 1px solid var(--theme_g2); - border-left-style: none; + border-radius: 6px; + overflow: hidden; margin-bottom: 1.2em; + font-size: 13px; > div { display: contents; } - > div:nth-of-type(odd) { + > div:nth-of-type(odd) > div { + background-color: var(--theme_g4); + } + + > div:nth-of-type(even) > div { background-color: var(--theme_g3); } > div:first-of-type { - font-weight: bold; - background-color: var(--theme_p3); + font-weight: 600; + > div { + background-color: var(--theme_g3); + color: var(--theme_g1); + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 11px; + border-bottom: 1px solid var(--theme_g2); + } } - > div:hover { + > div:not(:first-of-type):hover > div { background-color: var(--theme_p4); } @@ -160,16 +345,37 @@ a { } > div > div { - padding: 0.3em; - padding-left: 0.75em; - padding-right: 0.75em; + padding: 8px 12px; align-content: center; - border-left: 1px solid var(--theme_g2); + border-left: 1px solid var(--theme_border_subtle); overflow: auto; overflow-wrap: break-word; - background-color: inherit; white-space: pre-wrap; } + + > div > div:first-child { + border-left: none; + } +} + +/* expandable cell ---------------------------------------------------------- */ + +.zen_expand_icon { + cursor: pointer; + margin-right: 0.5em; + color: var(--theme_g1); + font-weight: bold; + user-select: none; +} + +.zen_expand_icon:hover { + color: var(--theme_ln); +} + +.zen_data_text { + user-select: text; + font-family: 'SF Mono', 'Cascadia Mono', Consolas, 'DejaVu Sans Mono', monospace; + font-size: 13px; } /* toolbar ------------------------------------------------------------------ */ @@ -178,6 +384,7 @@ a { display: flex; margin-top: 0.5em; margin-bottom: 0.6em; + font-size: 13px; > div { display: flex; @@ -225,15 +432,16 @@ a { z-index: -1; top: 0; left: 0; - width: 100%; + width: 100%; height: 100%; background: var(--theme_g0); opacity: 0.4; } > div { - border-radius: 0.5em; - background-color: var(--theme_g4); + border-radius: 6px; + background-color: var(--theme_g3); + border: 1px solid var(--theme_g2); opacity: 1.0; width: 35em; padding: 0em 2em 2em 2em; @@ -244,10 +452,11 @@ a { } .zen_modal_title { - font-size: 1.2em; + font-size: 16px; + font-weight: 600; border-bottom: 1px solid var(--theme_g2); padding: 1.2em 0em 0.5em 0em; - color: var(--theme_g1); + color: var(--theme_bright); } .zen_modal_buttons { @@ -257,16 +466,19 @@ a { > div { margin: 0em 1em 0em 1em; - padding: 1em; + padding: 10px 16px; align-content: center; - border-radius: 0.3em; - background-color: var(--theme_p3); + border-radius: 6px; + background-color: var(--theme_p4); + border: 1px solid var(--theme_g2); width: 6em; cursor: pointer; + font-weight: 500; + transition: background 0.15s; } > div:hover { - background-color: var(--theme_p4); + background-color: var(--theme_p3); } } @@ -284,15 +496,18 @@ a { top: 0; left: 0; width: 100%; - height: 0.5em; + height: 4px; + border-radius: 2px; + overflow: hidden; > div:first-of-type { /* label */ padding: 0.3em; - padding-top: 0.8em; - background-color: var(--theme_p4); + padding-top: 8px; + background-color: var(--theme_g3); width: max-content; - font-size: 0.8em; + font-size: 12px; + color: var(--theme_g1); } > div:last-of-type { @@ -302,7 +517,8 @@ a { left: 0; width: 0%; height: 100%; - background-color: var(--theme_p1); + background-color: var(--theme_p0); + transition: width 0.3s ease; } > div:nth-of-type(2) { @@ -312,7 +528,7 @@ a { left: 0; width: 100%; height: 100%; - background-color: var(--theme_p3); + background-color: var(--theme_g3); } } @@ -321,53 +537,25 @@ a { #crumbs { display: flex; position: relative; - top: -1em; + top: -0.5em; + font-size: 13px; + color: var(--theme_g1); > div { padding-right: 0.5em; } > div:nth-child(odd)::after { - content: ":"; - font-weight: bolder; - color: var(--theme_p2); + content: "/"; + color: var(--theme_g2); + padding-left: 0.5em; } } -/* branding ----------------------------------------------------------------- */ - -#branding { - font-size: 10pt; - font-weight: bolder; - margin-bottom: 2.6em; - position: relative; +/* banner ------------------------------------------------------------------- */ - #logo { - width: min-content; - margin: auto; - user-select: none; - position: relative; - - #go_home { - width: 100%; - height: 100%; - position: absolute; - top: 0; - left: 0; - } - } - - #logo:hover { - filter: drop-shadow(0 0.15em 0.1em var(--theme_p2)); - } - - #ue_logo { - position: absolute; - top: 1em; - right: 0; - width: 5em; - margin: auto; - } +zen-banner { + margin-bottom: 24px; } /* error -------------------------------------------------------------------- */ @@ -378,26 +566,23 @@ a { z-index: 1; color: var(--theme_g0); background-color: var(--theme_er); - padding: 1.0em 2em 2em 2em; + padding: 12px 20px 16px 20px; width: 100%; - border-top: 1px solid var(--theme_g0); + border-top: 1px solid var(--theme_g2); display: flex; + gap: 16px; + align-items: center; + font-size: 13px; > div:nth-child(1) { - font-size: 2.5em; - font-weight: bolder; - font-family: serif; - transform: rotate(-13deg); - color: var(--theme_p0); - } - - > div:nth-child(2) { - margin-left: 2em; + font-size: 24px; + font-weight: bold; + color: var(--theme_fail); } > div:nth-child(2) > pre:nth-child(2) { - margin-top: 0.5em; - font-size: 0.8em; + margin-top: 4px; + font-size: 12px; color: var(--theme_g1); } } @@ -409,18 +594,144 @@ a { min-width: 15%; } +/* sections ----------------------------------------------------------------- */ + +.zen_sector { + position: relative; +} + +.dropall { + position: absolute; + top: 16px; + right: 0; + font-size: 12px; + margin: 0; +} + +/* stats tiles -------------------------------------------------------------- */ + +.stats-tiles { + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +.stats-tile { + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} + +.stats-tile:hover { + border-color: var(--theme_p0); + background: var(--theme_p4); +} + +.stats-tile-detailed { + position: relative; +} + +.stats-tile-detailed::after { + content: "details \203A"; + position: absolute; + bottom: 12px; + right: 20px; + font-size: 11px; + color: var(--theme_g1); + opacity: 0.6; + transition: opacity 0.15s; +} + +.stats-tile-detailed:hover::after { + opacity: 1; + color: var(--theme_p0); +} + +.stats-tile-selected { + border-color: var(--theme_p0); + background: var(--theme_p4); + box-shadow: 0 0 0 1px var(--theme_p0); +} + +.stats-tile-selected::after { + content: "details \2039"; + opacity: 1; + color: var(--theme_p0); +} + +.tile-metrics { + display: flex; + flex-direction: column; + gap: 12px; +} + +.tile-columns { + display: flex; + gap: 24px; +} + +.tile-columns > .tile-metrics { + flex: 1; + min-width: 0; +} + +.tile-metric .metric-value { + font-size: 16px; +} + +.tile-metric-hero .metric-value { + font-size: 28px; +} + /* start -------------------------------------------------------------------- */ #start { - .dropall { - text-align: right; - font-size: 0.85em; - margin: -0.5em 0 0.5em 0; - } #version { - color: var(--theme_g1); + color: var(--theme_faint); text-align: center; - font-size: 0.85em; + font-size: 12px; + margin-top: 24px; + } +} + +/* info --------------------------------------------------------------------- */ + +#info { + .info-tiles { + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + } + + .info-tile { + overflow: hidden; + } + + .info-props { + display: flex; + flex-direction: column; + gap: 1px; + font-size: 13px; + } + + .info-prop { + display: flex; + gap: 12px; + padding: 4px 0; + border-bottom: 1px solid var(--theme_border_subtle); + } + + .info-prop:last-child { + border-bottom: none; + } + + .info-prop-label { + color: var(--theme_g1); + min-width: 140px; + flex-shrink: 0; + text-transform: capitalize; + } + + .info-prop-value { + color: var(--theme_bright); + word-break: break-all; + margin-left: auto; + text-align: right; } } @@ -437,6 +748,8 @@ a { /* tree --------------------------------------------------------------------- */ #tree { + font-size: 13px; + #tree_root > ul { margin-left: 0em; } @@ -448,29 +761,33 @@ a { li > div { display: flex; border-bottom: 1px solid transparent; - padding-left: 0.3em; - padding-right: 0.3em; + padding: 4px 6px; + border-radius: 4px; } li > div > div[active] { text-transform: uppercase; + color: var(--theme_p0); + font-weight: 600; } li > div > div:nth-last-child(3) { margin-left: auto; } li > div > div:nth-last-child(-n + 3) { - font-size: 0.8em; + font-size: 12px; width: 10em; text-align: right; + color: var(--theme_g1); + font-family: 'SF Mono', 'Cascadia Mono', Consolas, monospace; } li > div > div:nth-last-child(1) { width: 6em; } li > div:hover { background-color: var(--theme_p4); - border-bottom: 1px solid var(--theme_g2); + border-bottom: 1px solid var(--theme_border_subtle); } li a { - font-weight: bolder; + font-weight: 600; } li::marker { content: "+"; @@ -503,3 +820,262 @@ html:has(#map) { } } } + +/* ========================================================================== */ +/* Shared classes for compute / dashboard pages */ +/* ========================================================================== */ + +/* cards -------------------------------------------------------------------- */ + +.card { + background: var(--theme_g3); + border: 1px solid var(--theme_g2); + border-radius: 6px; + padding: 20px; +} + +.card-title { + font-size: 14px; + font-weight: 600; + color: var(--theme_g1); + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* grid --------------------------------------------------------------------- */ + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 20px; + margin-bottom: 24px; +} + +/* metrics ------------------------------------------------------------------ */ + +.metric-value { + font-size: 36px; + font-weight: 600; + color: var(--theme_bright); + line-height: 1; +} + +.metric-label { + font-size: 12px; + color: var(--theme_g1); + margin-top: 4px; +} + +/* section titles ----------------------------------------------------------- */ + +.section-title { + font-size: 20px; + font-weight: 600; + margin-bottom: 16px; + color: var(--theme_bright); +} + +/* html tables (compute pages) ---------------------------------------------- */ + +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +th { + text-align: left; + color: var(--theme_g1); + padding: 8px 12px; + border-bottom: 1px solid var(--theme_g2); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 11px; +} + +td { + padding: 8px 12px; + border-bottom: 1px solid var(--theme_border_subtle); + color: var(--theme_g0); +} + +tr:last-child td { + border-bottom: none; +} + +.total-row td { + border-top: 2px solid var(--theme_g2); + font-weight: 600; + color: var(--theme_bright); +} + +/* status badges ------------------------------------------------------------ */ + +.status-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +} + +.status-badge.active, +.status-badge.success { + background: color-mix(in srgb, var(--theme_ok) 15%, transparent); + color: var(--theme_ok); +} + +.status-badge.inactive { + background: color-mix(in srgb, var(--theme_g1) 15%, transparent); + color: var(--theme_g1); +} + +.status-badge.failure { + background: color-mix(in srgb, var(--theme_fail) 15%, transparent); + color: var(--theme_fail); +} + +/* health dots -------------------------------------------------------------- */ + +.health-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--theme_g1); +} + +.health-green { + background: var(--theme_ok); +} + +.health-yellow { + background: var(--theme_warn); +} + +.health-red { + background: var(--theme_fail); +} + +/* inline progress bar ------------------------------------------------------ */ + +.progress-bar { + width: 100%; + height: 8px; + background: var(--theme_border_subtle); + border-radius: 4px; + overflow: hidden; + margin-top: 8px; +} + +.progress-fill { + height: 100%; + background: var(--theme_p0); + transition: width 0.3s ease; +} + +/* stats row (label + value pair) ------------------------------------------- */ + +.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; +} + +/* detail tag (inline badge) ------------------------------------------------ */ + +.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; +} + +/* timestamp ---------------------------------------------------------------- */ + +.timestamp { + font-size: 12px; + color: var(--theme_faint); +} + +/* inline error ------------------------------------------------------------- */ + +.error { + color: var(--theme_fail); + padding: 12px; + background: var(--theme_er); + border-radius: 6px; + margin: 20px 0; + font-size: 13px; +} + +/* empty state -------------------------------------------------------------- */ + +.empty-state { + color: var(--theme_faint); + font-size: 13px; + padding: 20px 0; + text-align: center; +} + +/* header layout ------------------------------------------------------------ */ + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +/* history tabs ------------------------------------------------------------- */ + +.history-tabs { + display: flex; + gap: 4px; + background: var(--theme_g4); + border-radius: 6px; + padding: 2px; +} + +.history-tab { + background: transparent; + color: var(--theme_g1); + font-size: 12px; + font-weight: 600; + padding: 4px 12px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.history-tab:hover { + color: var(--theme_g0); +} + +.history-tab.active { + background: var(--theme_g2); + color: var(--theme_bright); +} |