diff --git a/js/config.js b/js/config.js index a263c70..1b6de88 100644 --- a/js/config.js +++ b/js/config.js @@ -5,11 +5,17 @@ * ?ws=ws://tower:8080/ws/world-state — WebSocket endpoint * ?token=my-secret — Auth token (Phase 1 shared secret) * ?mock=true — Force mock mode (no real WS) + * ?gitea=http://host:3000/api/v1 — Gitea API base URL (metrics panel) + * ?gtoken=my-gitea-token — Gitea API token (metrics panel) + * ?grepo=owner/repo — Gitea repo slug (metrics panel) * * Or via Vite env vars: - * VITE_WS_URL — WebSocket endpoint - * VITE_WS_TOKEN — Auth token - * VITE_MOCK_MODE — 'true' to force mock mode + * VITE_WS_URL — WebSocket endpoint + * VITE_WS_TOKEN — Auth token + * VITE_MOCK_MODE — 'true' to force mock mode + * VITE_GITEA_URL — Gitea API base URL (metrics panel) + * VITE_GITEA_TOKEN — Gitea API token (metrics panel) + * VITE_GITEA_REPO — Gitea repo slug e.g. owner/repo (metrics panel) * * Priority: URL params > env vars > defaults. * @@ -35,6 +41,15 @@ export const Config = Object.freeze({ /** Force mock mode even if wsUrl is set. Useful for local dev. */ mockMode: param('mock', 'VITE_MOCK_MODE', 'false') === 'true', + /** Gitea API base URL for the metrics dashboard panel (Issue #6). */ + giteaUrl: param('gitea', 'VITE_GITEA_URL', ''), + + /** Gitea API token for authenticated requests to the metrics API. */ + giteaToken: param('gtoken', 'VITE_GITEA_TOKEN', ''), + + /** Gitea repository slug, e.g. "owner/repo", for metric queries. */ + giteaRepo: param('grepo', 'VITE_GITEA_REPO', ''), + /** Reconnection timing */ reconnectBaseMs: 2000, reconnectMaxMs: 30000, diff --git a/js/main.js b/js/main.js index a9185e2..a98e1de 100644 --- a/js/main.js +++ b/js/main.js @@ -8,6 +8,7 @@ import { initUI, updateUI } from './ui.js'; import { initInteraction, updateControls, disposeInteraction } from './interaction.js'; import { initWebSocket, getConnectionState, getJobCount } from './websocket.js'; import { initVisitor } from './visitor.js'; +import { initMetrics, updateMetrics, disposeMetrics } from './metrics.js'; let running = false; let canvas = null; @@ -27,6 +28,7 @@ function buildWorld(firstInit, stateSnapshot) { initEffects(scene); initAgents(scene); + initMetrics(scene); if (stateSnapshot) { applyAgentStates(stateSnapshot); @@ -74,6 +76,7 @@ function buildWorld(firstInit, stateSnapshot) { updateControls(); updateEffects(now); updateAgents(now); + updateMetrics(now); updateUI({ fps: currentFps, agentCount: getAgentCount(), @@ -111,6 +114,7 @@ function teardown({ scene, renderer, ac }) { disposeInteraction(); disposeEffects(); disposeAgents(); + disposeMetrics(); disposeWorld(renderer, scene); } diff --git a/js/metrics.js b/js/metrics.js new file mode 100644 index 0000000..89a23a4 --- /dev/null +++ b/js/metrics.js @@ -0,0 +1,285 @@ +/** + * metrics.js — Floating holographic metrics panel in the 3D world. + * + * Displays live Gitea repository stats: + * - Issues closed + * - PRs merged + * - Success rate (merged / total PRs) + * + * Fetches from Gitea API on init and every 60 s thereafter. + * Configurable via VITE_GITEA_URL / VITE_GITEA_TOKEN / VITE_GITEA_REPO + * or URL params: ?gitea=http://... ?gtoken=... ?grepo=owner/repo + * + * Fixes #6 + */ + +import * as THREE from 'three'; +import { Config } from './config.js'; + +/* ── Panel world-space dimensions ── */ +const PANEL_W = 9; +const PANEL_H = 5.5; +const PANEL_X = 0; +const PANEL_Y = 11; +const PANEL_Z = -18; + +/* ── Canvas texture resolution ── */ +const TEX_W = 512; +const TEX_H = 320; + +let _scene = null; +let panelGroup = null; +let panelMesh = null; +let borderLines = null; +let glowLight = null; +let panelTexture = null; +let panelCanvas = null; +let panelCtx = null; +let fetchTimer = null; + +const pulsePhase = Math.random() * Math.PI * 2; + +const metrics = { + issuesClosed: '--', + prsMerged: '--', + successRate: '--', + lastUpdated: null, +}; + +/* ── Canvas drawing ─────────────────────────────────────────── */ + +function drawPanel() { + const ctx = panelCtx; + const w = TEX_W; + const h = TEX_H; + + ctx.clearRect(0, 0, w, h); + + // Dark holographic background + ctx.fillStyle = 'rgba(0, 8, 0, 0.88)'; + ctx.fillRect(0, 0, w, h); + + // Outer border with glow + ctx.strokeStyle = '#00ff88'; + ctx.lineWidth = 3; + ctx.shadowColor = '#00ff88'; + ctx.shadowBlur = 16; + ctx.strokeRect(4, 4, w - 8, h - 8); + ctx.shadowBlur = 0; + + // Corner accents + const CA = 20; + ctx.strokeStyle = '#00ffcc'; + ctx.lineWidth = 2; + [ + [4, 4], // top-left + [w - 4, 4], // top-right + [4, h - 4], // bottom-left + [w - 4, h - 4], // bottom-right + ].forEach(([cx, cy]) => { + const sx = cx === 4 ? 1 : -1; + const sy = cy === 4 ? 1 : -1; + ctx.beginPath(); + ctx.moveTo(cx + sx * CA, cy); + ctx.lineTo(cx, cy); + ctx.lineTo(cx, cy + sy * CA); + ctx.stroke(); + }); + + // Title + ctx.fillStyle = '#00ff88'; + ctx.font = 'bold 26px Courier New'; + ctx.textAlign = 'center'; + ctx.shadowColor = '#00ff41'; + ctx.shadowBlur = 14; + ctx.fillText('[ REPO METRICS ]', w / 2, 48); + ctx.shadowBlur = 0; + + // Horizontal rule + ctx.strokeStyle = '#003a00'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(24, 62); + ctx.lineTo(w - 24, 62); + ctx.stroke(); + + // Metric rows + const rows = [ + { label: 'ISSUES CLOSED', value: metrics.issuesClosed }, + { label: 'PRs MERGED', value: metrics.prsMerged }, + { label: 'SUCCESS RATE', value: metrics.successRate }, + ]; + + rows.forEach((row, i) => { + const y = 100 + i * 72; + + // Label + ctx.fillStyle = '#006622'; + ctx.font = '15px Courier New'; + ctx.textAlign = 'left'; + ctx.fillText(`▸ ${row.label}`, 32, y); + + // Value (right-aligned, large) + ctx.fillStyle = '#00ff41'; + ctx.font = 'bold 34px Courier New'; + ctx.shadowColor = '#00ff41'; + ctx.shadowBlur = 10; + ctx.textAlign = 'right'; + ctx.fillText(row.value, w - 32, y + 32); + ctx.shadowBlur = 0; + + // Row separator + if (i < rows.length - 1) { + ctx.strokeStyle = '#001a00'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(24, y + 48); + ctx.lineTo(w - 24, y + 48); + ctx.stroke(); + } + }); + + // Last-updated timestamp (footer) + ctx.fillStyle = '#003300'; + ctx.font = '11px Courier New'; + ctx.textAlign = 'center'; + const tsText = metrics.lastUpdated + ? `SYNCED ${new Date(metrics.lastUpdated).toLocaleTimeString()} · 60s REFRESH` + : 'FETCHING DATA...'; + ctx.fillText(tsText, w / 2, h - 10); +} + +/* ── Gitea API fetch ────────────────────────────────────────── */ + +async function fetchMetrics() { + const baseUrl = Config.giteaUrl; + const token = Config.giteaToken; + const repo = Config.giteaRepo; + + if (!baseUrl || !repo) return; + + const headers = {}; + if (token) headers['Authorization'] = `token ${token}`; + + try { + const [closedIssuesRes, mergedPrsRes, openPrsRes] = await Promise.all([ + fetch(`${baseUrl}/repos/${repo}/issues?state=closed&type=issues&limit=1`, { headers }), + fetch(`${baseUrl}/repos/${repo}/pulls?state=closed&limit=1`, { headers }), + fetch(`${baseUrl}/repos/${repo}/pulls?state=open&limit=1`, { headers }), + ]); + + const issuesClosed = parseInt(closedIssuesRes.headers.get('X-Total-Count') || '0', 10); + const prsMerged = parseInt(mergedPrsRes.headers.get('X-Total-Count') || '0', 10); + const prsOpen = parseInt(openPrsRes.headers.get('X-Total-Count') || '0', 10); + const prsTotal = prsMerged + prsOpen; + + metrics.issuesClosed = issuesClosed.toLocaleString(); + metrics.prsMerged = prsMerged.toLocaleString(); + metrics.successRate = prsTotal > 0 + ? `${Math.round(prsMerged / prsTotal * 100)}%` + : '--'; + metrics.lastUpdated = Date.now(); + + drawPanel(); + if (panelTexture) panelTexture.needsUpdate = true; + } catch (err) { + console.warn('[Metrics] fetch failed:', err); + } +} + +/* ── Three.js setup ─────────────────────────────────────────── */ + +function buildBorderLines(w, h) { + const hw = w / 2; + const hh = h / 2; + const points = [ + new THREE.Vector3(-hw, -hh, 0), + new THREE.Vector3( hw, -hh, 0), + new THREE.Vector3( hw, hh, 0), + new THREE.Vector3(-hw, hh, 0), + new THREE.Vector3(-hw, -hh, 0), + ]; + const geo = new THREE.BufferGeometry().setFromPoints(points); + const mat = new THREE.LineBasicMaterial({ color: 0x00ff88, transparent: true, opacity: 0.85 }); + return new THREE.Line(geo, mat); +} + +export function initMetrics(scene) { + _scene = scene; + + // Canvas texture + panelCanvas = document.createElement('canvas'); + panelCanvas.width = TEX_W; + panelCanvas.height = TEX_H; + panelCtx = panelCanvas.getContext('2d'); + + drawPanel(); + + panelTexture = new THREE.CanvasTexture(panelCanvas); + + // Panel mesh + const geo = new THREE.PlaneGeometry(PANEL_W, PANEL_H); + const mat = new THREE.MeshBasicMaterial({ + map: panelTexture, + transparent: true, + opacity: 0.92, + side: THREE.DoubleSide, + }); + panelMesh = new THREE.Mesh(geo, mat); + + // Outline border + borderLines = buildBorderLines(PANEL_W, PANEL_H); + + // Ambient glow from the panel surface + glowLight = new THREE.PointLight(0x00ff41, 1.0, 14); + glowLight.position.set(0, 0, 0.5); + + panelGroup = new THREE.Group(); + panelGroup.add(panelMesh); + panelGroup.add(borderLines); + panelGroup.add(glowLight); + panelGroup.position.set(PANEL_X, PANEL_Y, PANEL_Z); + + scene.add(panelGroup); + + // Fetch now, then every 60 s + fetchMetrics(); + fetchTimer = setInterval(fetchMetrics, 60_000); +} + +export function updateMetrics(time) { + if (!panelGroup) return; + + // Gentle vertical float + panelGroup.position.y = PANEL_Y + Math.sin(time * 0.0007 + pulsePhase) * 0.25; + + // Pulse border opacity + if (borderLines) { + borderLines.material.opacity = 0.55 + 0.3 * Math.sin(time * 0.0018 + pulsePhase); + } + + // Pulse glow intensity + if (glowLight) { + glowLight.intensity = 0.7 + 0.5 * Math.sin(time * 0.0014 + pulsePhase); + } +} + +export function disposeMetrics() { + if (fetchTimer) { + clearInterval(fetchTimer); + fetchTimer = null; + } + if (panelGroup && _scene) { + _scene.remove(panelGroup); + } + if (panelMesh) { + panelMesh.geometry.dispose(); + panelMesh.material.dispose(); + } + if (panelTexture) panelTexture.dispose(); + if (borderLines) { + borderLines.geometry.dispose(); + borderLines.material.dispose(); + } + panelGroup = panelMesh = borderLines = panelTexture = glowLight = _scene = null; +}