1 Commits

Author SHA1 Message Date
Alexander Whitestone
a4400093c2 feat: add holographic metrics dashboard panel in 3D world (#6)
Create a floating 3D panel in the scene (js/metrics.js) that pulls
live Gitea stats — issues closed, PRs merged, success rate — via the
Gitea REST API. The panel renders via CanvasTexture onto a Three.js
PlaneGeometry, floats gently in world-space, and refreshes every 60 s.

Config exposed via URL params (?gitea=, ?gtoken=, ?grepo=) and Vite
env vars (VITE_GITEA_URL / VITE_GITEA_TOKEN / VITE_GITEA_REPO).

Fixes #6

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 14:06:07 -04:00
8 changed files with 309 additions and 406 deletions

View File

@@ -159,95 +159,6 @@
@media (max-width: 500px) {
#status-panel { top: 100px !important; left: 16px; right: auto; }
}
/* ── Agent info panel (#8) ── */
#agent-panel {
position: fixed; top: 50%; right: -360px;
transform: translateY(-50%);
width: 320px; max-height: 80vh;
background: rgba(0, 8, 0, 0.92);
border: 1px solid #003300;
border-right: none;
color: #00ff41;
font-family: 'Courier New', monospace;
font-size: clamp(10px, 1.3vw, 12px);
z-index: 30;
pointer-events: auto;
transition: right 0.3s ease;
display: flex; flex-direction: column;
overflow: hidden;
}
#agent-panel.visible { right: 0; }
.ap-header {
display: flex; align-items: flex-start; justify-content: space-between;
padding: 14px 16px 10px;
border-bottom: 1px solid #002200;
flex-shrink: 0;
}
#ap-agent-name {
font-size: clamp(14px, 2vw, 18px);
font-weight: bold;
letter-spacing: 3px;
text-shadow: 0 0 10px currentColor;
}
#ap-agent-role {
color: #007722;
font-size: clamp(9px, 1vw, 11px);
letter-spacing: 2px;
margin-top: 2px;
}
#agent-panel-close {
background: transparent;
border: 1px solid #003300;
color: #005500;
font-family: 'Courier New', monospace;
font-size: 12px;
padding: 2px 6px;
cursor: pointer;
flex-shrink: 0;
margin-left: 8px;
transition: color 0.15s, border-color 0.15s;
}
#agent-panel-close:hover { color: #00ff41; border-color: #00ff41; }
.ap-body {
padding: 12px 16px;
overflow-y: auto;
flex: 1;
}
.ap-section {
margin-bottom: 14px;
}
.ap-label {
color: #005500;
font-size: clamp(8px, 1vw, 10px);
letter-spacing: 2px;
margin-bottom: 4px;
}
.ap-loading { color: #004400; }
.ap-error { color: #aa2200; }
.ap-dim { color: #004400; }
.ap-code { color: #00aaff; }
.ap-link { color: #00ff88; text-decoration: none; word-break: break-word; }
.ap-link:hover { text-decoration: underline; }
.ap-commit {
padding: 2px 0;
border-bottom: 1px solid #001800;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ap-sha {
color: #007722;
margin-right: 6px;
font-size: clamp(9px, 1vw, 11px);
}
@media (max-width: 500px) {
#agent-panel { width: 85vw; max-height: 70vh; }
}
@supports (padding: env(safe-area-inset-right)) {
#agent-panel.visible { right: env(safe-area-inset-right, 0px); }
}
</style>
</head>
<body>
@@ -272,35 +183,6 @@
<div id="bark-container"></div>
<div id="connection-status">OFFLINE</div>
</div>
<!-- Agent info panel (Issue #8) -->
<div id="agent-panel">
<div class="ap-header">
<div>
<div id="ap-agent-name">AGENT</div>
<div id="ap-agent-role">ROLE</div>
</div>
<button id="agent-panel-close" title="Close [Esc]">&#x2715;</button>
</div>
<div class="ap-body">
<div class="ap-section">
<div class="ap-label">CURRENT ISSUE</div>
<div id="ap-issue"><span class="ap-dim"></span></div>
</div>
<div class="ap-section">
<div class="ap-label">BRANCH</div>
<div id="ap-branch"><span class="ap-dim"></span></div>
</div>
<div class="ap-section">
<div class="ap-label">PULL REQUEST</div>
<div id="ap-pr"><span class="ap-dim"></span></div>
</div>
<div class="ap-section">
<div class="ap-label">RECENT COMMITS</div>
<div id="ap-commits"><span class="ap-dim"></span></div>
</div>
</div>
</div>
<div id="chat-input-bar">
<input id="chat-input" type="text" placeholder="Say something to the Workshop..." autocomplete="off" />
<button id="chat-send">&gt;</button>

View File

@@ -13,10 +13,10 @@
* x, z — world-space position on the horizontal plane (y is always 0)
*/
export const AGENT_DEFS = [
{ id: 'alpha', label: 'ALPHA', color: 0x00ff88, role: 'orchestrator', direction: 'north', x: 0, z: -6, gitLogin: null },
{ id: 'beta', label: 'BETA', color: 0x00aaff, role: 'worker', direction: 'east', x: 6, z: 0, gitLogin: null },
{ id: 'gamma', label: 'GAMMA', color: 0xff6600, role: 'validator', direction: 'south', x: 0, z: 6, gitLogin: null },
{ id: 'delta', label: 'DELTA', color: 0xaa00ff, role: 'monitor', direction: 'west', x: -6, z: 0, gitLogin: null },
{ id: 'alpha', label: 'ALPHA', color: 0x00ff88, role: 'orchestrator', direction: 'north', x: 0, z: -6 },
{ id: 'beta', label: 'BETA', color: 0x00aaff, role: 'worker', direction: 'east', x: 6, z: 0 },
{ id: 'gamma', label: 'GAMMA', color: 0xff6600, role: 'validator', direction: 'south', x: 0, z: 6 },
{ id: 'delta', label: 'DELTA', color: 0xaa00ff, role: 'monitor', direction: 'west', x: -6, z: 0 },
];
/**

View File

@@ -1,202 +0,0 @@
/**
* agent-panel.js — Click-to-view-PR panel (Issue #8).
*
* Shows a panel when an agent is clicked in the 3D world, displaying:
* - Current open issue
* - Active branch
* - Recent commits
* - Link to open PR on Gitea
*
* Requires VITE_GITEA_URL (and optionally VITE_GITEA_TOKEN, VITE_GITEA_REPO)
* to be set. Agents must have a `gitLogin` field in AGENT_DEFS to enable
* Gitea lookups; if absent the panel shows a placeholder.
*/
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
import { Config } from './config.js';
let $panel = null;
let $name = null;
let $role = null;
let $issue = null;
let $branch = null;
let $commits = null;
let $pr = null;
let cleanupFn = null; // AbortController cleanup for in-flight fetches
export function initAgentPanel() {
$panel = document.getElementById('agent-panel');
$name = document.getElementById('ap-agent-name');
$role = document.getElementById('ap-agent-role');
$issue = document.getElementById('ap-issue');
$branch = document.getElementById('ap-branch');
$commits = document.getElementById('ap-commits');
$pr = document.getElementById('ap-pr');
const $close = document.getElementById('agent-panel-close');
if ($close) $close.addEventListener('click', hideAgentPanel);
document.addEventListener('keydown', e => {
if (e.key === 'Escape') hideAgentPanel();
});
}
export function showAgentPanel(agentId) {
if (!$panel) return;
const def = AGENT_DEFS.find(d => d.id === agentId);
if (!def) return;
// Cancel any previous in-flight request
if (cleanupFn) { cleanupFn(); cleanupFn = null; }
const cssColor = colorToCss(def.color);
$name.textContent = def.label;
$name.style.color = cssColor;
$role.textContent = def.role.toUpperCase();
// Show loading state
const loading = '<span class="ap-loading">LOADING...</span>';
$issue.innerHTML = loading;
$branch.innerHTML = loading;
$commits.innerHTML = loading;
$pr.innerHTML = loading;
$panel.classList.add('visible');
if (!Config.giteaUrl) {
const msg = '<span class="ap-dim">Gitea URL not configured</span>';
$issue.innerHTML = msg;
$branch.innerHTML = msg;
$commits.innerHTML = msg;
$pr.innerHTML = msg;
return;
}
if (!def.gitLogin) {
const msg = '<span class="ap-dim">No Gitea user mapped to this agent</span>';
$issue.innerHTML = msg;
$branch.innerHTML = msg;
$commits.innerHTML = msg;
$pr.innerHTML = msg;
return;
}
const ac = new AbortController();
cleanupFn = () => ac.abort();
fetchAgentData(def, ac.signal).catch(err => {
if (err.name === 'AbortError') return;
console.warn('[AgentPanel] Fetch error:', err);
const msg = '<span class="ap-error">UNAVAILABLE</span>';
$issue.innerHTML = msg;
$branch.innerHTML = msg;
$commits.innerHTML = msg;
$pr.innerHTML = msg;
});
}
export function hideAgentPanel() {
if (!$panel) return;
if (cleanupFn) { cleanupFn(); cleanupFn = null; }
$panel.classList.remove('visible');
}
/* ── Gitea data fetching ──────────────────────────────────────── */
async function fetchAgentData(def, signal) {
const base = Config.giteaUrl.replace(/\/$/, '');
const repo = Config.giteaRepo;
const headers = {};
if (Config.giteaToken) headers['Authorization'] = 'token ' + Config.giteaToken;
const api = `${base}/api/v1`;
// Fetch open PRs for this repo, look for ones authored by the agent user
const prRes = await fetch(`${api}/repos/${repo}/pulls?state=open&limit=20`, { headers, signal });
if (!prRes.ok) throw new Error(`PR list failed: ${prRes.status}`);
const pulls = await prRes.json();
const pr = pulls.find(p =>
p.user?.login === def.gitLogin ||
p.head?.label?.startsWith(def.gitLogin + ':')
);
if (!pr) {
// No open PR — try looking for recently closed ones
const closedRes = await fetch(
`${api}/repos/${repo}/pulls?state=closed&limit=5`,
{ headers, signal }
);
const closed = closedRes.ok ? await closedRes.json() : [];
const recent = closed.find(p =>
p.user?.login === def.gitLogin ||
p.head?.label?.startsWith(def.gitLogin + ':')
);
if (recent) {
$pr.innerHTML = `<a href="${esc(recent.html_url)}" target="_blank" rel="noopener" class="ap-link">PR #${recent.number} (merged): ${esc(recent.title)}</a>`;
} else {
$pr.innerHTML = '<span class="ap-dim">No open PR</span>';
}
$issue.innerHTML = '<span class="ap-dim">No active issue</span>';
$branch.innerHTML = '<span class="ap-dim">—</span>';
$commits.innerHTML = '<span class="ap-dim">—</span>';
return;
}
// PR found
$pr.innerHTML = `<a href="${esc(pr.html_url)}" target="_blank" rel="noopener" class="ap-link">PR #${pr.number}: ${esc(pr.title)}</a>`;
const branch = pr.head?.ref || pr.head?.label?.split(':')[1] || '';
$branch.innerHTML = branch
? `<span class="ap-code">${esc(branch)}</span>`
: '<span class="ap-dim">—</span>';
// Extract issue number from branch (e.g. claude/issue-8 → 8)
const issueMatch = branch.match(/issue[/-](\d+)/i);
if (issueMatch) {
const issueRes = await fetch(`${api}/repos/${repo}/issues/${issueMatch[1]}`, { headers, signal });
if (issueRes.ok) {
const issue = await issueRes.json();
$issue.innerHTML = `<a href="${esc(issue.html_url)}" target="_blank" rel="noopener" class="ap-link">#${issue.number}: ${esc(issue.title)}</a>`;
} else {
$issue.innerHTML = `<span class="ap-dim">Issue #${issueMatch[1]}</span>`;
}
} else {
$issue.innerHTML = '<span class="ap-dim">No linked issue</span>';
}
// Recent commits on the branch
if (branch) {
const commitsRes = await fetch(
`${api}/repos/${repo}/commits?sha=${encodeURIComponent(branch)}&limit=5`,
{ headers, signal }
);
if (commitsRes.ok) {
const commits = await commitsRes.json();
if (!commits.length) {
$commits.innerHTML = '<span class="ap-dim">No commits</span>';
} else {
$commits.innerHTML = commits.map(c => {
const sha = (c.sha || '').slice(0, 7);
const msg = (c.commit?.message || '').split('\n')[0];
return `<div class="ap-commit"><span class="ap-sha">${esc(sha)}</span>${esc(msg)}</div>`;
}).join('');
}
} else {
$commits.innerHTML = '<span class="ap-dim">Could not load commits</span>';
}
} else {
$commits.innerHTML = '<span class="ap-dim">—</span>';
}
}
function esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

View File

@@ -31,7 +31,6 @@ class Agent {
this.group = new THREE.Group();
this.group.position.copy(this.position);
this.group.userData.agentId = this.id;
this._buildMeshes();
this._buildLabel();
@@ -175,10 +174,6 @@ export function setAgentState(agentId, state) {
if (agent) agent.setState(state);
}
export function getAgentGroups() {
return [...agents.values()].map(a => a.group);
}
export function getAgentDefs() {
return [...agents.values()].map(a => ({
id: a.id, label: a.label, role: a.role, color: a.color, state: a.state,

View File

@@ -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,14 +41,14 @@ 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 base URL (no trailing slash), e.g. http://gitea.example.com */
/** Gitea API base URL for the metrics dashboard panel (Issue #6). */
giteaUrl: param('gitea', 'VITE_GITEA_URL', ''),
/** Gitea API token for authenticated requests (read-only is sufficient). */
giteaToken: param('gitea_token', 'VITE_GITEA_TOKEN', ''),
/** Gitea API token for authenticated requests to the metrics API. */
giteaToken: param('gtoken', 'VITE_GITEA_TOKEN', ''),
/** Gitea repo in owner/repo format, e.g. rockachopa/the-matrix */
giteaRepo: param('gitea_repo', 'VITE_GITEA_REPO', 'rockachopa/the-matrix'),
/** Gitea repository slug, e.g. "owner/repo", for metric queries. */
giteaRepo: param('grepo', 'VITE_GITEA_REPO', ''),
/** Reconnection timing */
reconnectBaseMs: 2000,

View File

@@ -1,4 +1,3 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
let controls;
@@ -30,66 +29,3 @@ export function disposeInteraction() {
controls = null;
}
}
/**
* Set up pointer-based click detection for agent meshes.
* Distinguishes clicks from orbit-control drags (pointer must move < 6px).
*
* @param {THREE.Camera} camera
* @param {THREE.WebGLRenderer} renderer
* @param {THREE.Object3D[]} agentGroups — array of agent group objects to raycast against
* @param {function} onAgentClick — called with agentId string on hit
* @returns {function} cleanup — call to remove event listeners
*/
export function initClickDetection(camera, renderer, agentGroups, onAgentClick) {
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let downX = 0, downY = 0;
function toNDC(clientX, clientY) {
const rect = renderer.domElement.getBoundingClientRect();
return {
x: ((clientX - rect.left) / rect.width) * 2 - 1,
y: -((clientY - rect.top) / rect.height) * 2 + 1,
};
}
function onPointerDown(e) {
downX = e.clientX;
downY = e.clientY;
}
function onPointerUp(e) {
const dx = e.clientX - downX;
const dy = e.clientY - downY;
if (dx * dx + dy * dy > 36) return; // drag threshold 6px
const ndc = toNDC(e.clientX, e.clientY);
pointer.set(ndc.x, ndc.y);
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(agentGroups, true);
if (hits.length > 0) {
const agentId = findAgentId(hits[0].object);
if (agentId) onAgentClick(agentId);
}
}
const el = renderer.domElement;
el.addEventListener('pointerdown', onPointerDown);
el.addEventListener('pointerup', onPointerUp);
return () => {
el.removeEventListener('pointerdown', onPointerDown);
el.removeEventListener('pointerup', onPointerUp);
};
}
function findAgentId(object) {
let o = object;
while (o) {
if (o.userData?.agentId) return o.userData.agentId;
o = o.parent;
}
return null;
}

View File

@@ -1,14 +1,14 @@
import { initWorld, onWindowResize, disposeWorld } from './world.js';
import {
initAgents, updateAgents, getAgentCount,
disposeAgents, getAgentStates, applyAgentStates, getAgentGroups,
disposeAgents, getAgentStates, applyAgentStates,
} from './agents.js';
import { initEffects, updateEffects, disposeEffects } from './effects.js';
import { initUI, updateUI } from './ui.js';
import { initInteraction, updateControls, disposeInteraction, initClickDetection } from './interaction.js';
import { initInteraction, updateControls, disposeInteraction } from './interaction.js';
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
import { initVisitor } from './visitor.js';
import { initAgentPanel, showAgentPanel } from './agent-panel.js';
import { initMetrics, updateMetrics, disposeMetrics } from './metrics.js';
let running = false;
let canvas = null;
@@ -28,19 +28,18 @@ function buildWorld(firstInit, stateSnapshot) {
initEffects(scene);
initAgents(scene);
initMetrics(scene);
if (stateSnapshot) {
applyAgentStates(stateSnapshot);
}
initInteraction(camera, renderer);
initClickDetection(camera, renderer, getAgentGroups(), showAgentPanel);
if (firstInit) {
initUI();
initWebSocket(scene);
initVisitor();
initAgentPanel();
// Dismiss loading screen
const loadingScreen = document.getElementById('loading-screen');
@@ -77,6 +76,7 @@ function buildWorld(firstInit, stateSnapshot) {
updateControls();
updateEffects(now);
updateAgents(now);
updateMetrics(now);
updateUI({
fps: currentFps,
agentCount: getAgentCount(),
@@ -114,6 +114,7 @@ function teardown({ scene, renderer, ac }) {
disposeInteraction();
disposeEffects();
disposeAgents();
disposeMetrics();
disposeWorld(renderer, scene);
}

285
js/metrics.js Normal file
View File

@@ -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;
}