merge: preserve source 2983 with tested activity dashboard fixes

This commit is contained in:
haelyra
2026-10-09 16:09:59 -04:00
4 changed files with 216 additions and 3 deletions
+144 -1
View File
@@ -6,12 +6,22 @@
* Usage: node scripts/dashboard-web.js [port]
* Open http://localhost:3456
*
* The server only binds to loopback and has no authentication, by design --
* it is meant to be viewed on the same machine it runs on. To check it from
* another device you control (a tablet, a laptop), use standard SSH local
* port forwarding instead of exposing the port itself:
* ssh -L 3456:127.0.0.1:3456 <user>@<this-machine> -p <ssh-port>
* then open http://127.0.0.1:3456 on the *remote* device. This requires
* only SSH access you already control on your own machine -- nothing here
* opens the dashboard to the network.
*
* Contribution: https://github.com/affaan-m/ECC
*/
const fs = require('fs');
const path = require('path');
const http = require('http');
const os = require('os');
const {
LOOPBACK_HOSTNAMES,
buildAllowedHostnames,
@@ -437,6 +447,7 @@ function renderHTML(data) {
<button class="nav-it" data-tab="rules" onclick="showTab('rules',this)"><span id="nav-rules"> Rules</span> <span class="ct" id="nav-ct-rules"></span></button>
<button class="nav-it" data-tab="mcps" onclick="showTab('mcps',this)"><span id="nav-mcps"> MCPs</span> <span class="ct" id="nav-ct-mcps"></span></button>
<button class="nav-it" data-tab="hooks" onclick="showTab('hooks',this)"><span id="nav-hooks"> Hooks</span> <span class="ct" id="nav-ct-hooks"></span></button>
<button class="nav-it" data-tab="activity" onclick="showTab('activity',this)"><span id="nav-activity"> Activity</span> <span class="ct" id="nav-ct-activity">live</span></button>
</div>
<div class="out" id="app"></div>
@@ -562,7 +573,7 @@ window.addEventListener('hashchange', handleRoute);
// Render Main Dashboard
function renderMain() {
const app = document.getElementById('app');
app.innerHTML = '<div class="stats" id="stats-bar"></div><div class="panel active" id="panel-agents"></div><div class="panel" id="panel-skills"></div><div class="panel" id="panel-commands"></div><div class="panel" id="panel-rules"></div><div class="panel" id="panel-mcps"></div><div class="panel" id="panel-hooks"></div>';
app.innerHTML = '<div class="stats" id="stats-bar"></div><div class="panel active" id="panel-agents"></div><div class="panel" id="panel-skills"></div><div class="panel" id="panel-commands"></div><div class="panel" id="panel-rules"></div><div class="panel" id="panel-mcps"></div><div class="panel" id="panel-hooks"></div><div class="panel" id="panel-activity"></div>';
document.getElementById('stats-bar').innerHTML =
'<div class="stat c0" onclick="showTab(\\'agents\\',document.querySelector(\\'.nav-it[data-tab=\\\\"agents\\\"]\\'))"><div class="num">'+AGENTS.length+'</div><div class="lbl">'+t('agents')+'</div></div>' +
@@ -811,6 +822,85 @@ document.addEventListener('keydown', e => { if((e.metaKey||e.ctrlKey)&&e.key==='
setLang(lang);
handleRoute();
</script>
<script>
(function () {
var TOOL_KIND = { Skill: 'kind-skill', Agent: 'kind-agent' };
var timer = null;
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
});
}
function rowHtml(e) {
var kindClass = TOOL_KIND[e.tool_name] || 'kind-plain';
var time = '';
try { time = new Date(e.timestamp).toLocaleTimeString(); } catch (err) { time = ''; }
return '<div class="act-row ' + kindClass + '">' +
'<span class="act-time">' + esc(time) + '</span>' +
'<span class="act-tool">' + esc(e.tool_name) + '</span>' +
'<span class="act-summary">' + esc(e.input_summary) + '</span>' +
'<span class="act-session">' + esc((e.session_id || '').slice(0, 8)) + '</span>' +
'</div>';
}
function renderActivity() {
var panel = document.getElementById('panel-activity');
if (!panel) return;
fetch('/api/activity')
.then(function (r) { return r.json(); })
.then(function (data) {
var entries = (data && data.entries) || [];
var skillCount = entries.filter(function (e) { return e.tool_name === 'Skill'; }).length;
var agentCount = entries.filter(function (e) { return e.tool_name === 'Agent'; }).length;
var summary = '<div class="act-summary-bar">' +
'<span>Last ' + entries.length + ' tool calls</span>' +
'<span class="act-pill act-pill-skill">Skill: ' + skillCount + '</span>' +
'<span class="act-pill act-pill-agent">Agent: ' + agentCount + '</span>' +
'</div>';
if (!entries.length) {
panel.innerHTML = summary + '<div class="act-empty">No recorded tool calls yet. This reads ~/.claude/metrics/tool-usage.jsonl, written by the session-activity-tracker PostToolUse hook. Requires that hook in the active session.</div>';
return;
}
panel.innerHTML = summary + '<div class="act-feed">' + entries.map(rowHtml).join('') + '</div>';
})
.catch(function () {
panel.innerHTML = '<div class="act-empty">Could not load activity feed.</div>';
});
}
var style = document.createElement('style');
style.textContent =
'.act-summary-bar{display:flex;gap:12px;align-items:center;padding:10px 0;font-size:13px;color:var(--muted,#888)}' +
'.act-pill{padding:2px 8px;border-radius:10px;font-size:12px;font-weight:600}' +
'.act-pill-skill{background:rgba(80,160,255,.15);color:#4a9eff}' +
'.act-pill-agent{background:rgba(160,100,255,.15);color:#a066ff}' +
'.act-feed{display:flex;flex-direction:column;gap:2px;font-family:ui-monospace,monospace;font-size:12.5px}' +
'.act-row{display:grid;grid-template-columns:90px 90px 1fr 90px;gap:10px;padding:5px 8px;border-radius:4px;align-items:center}' +
'.act-row.kind-skill{background:rgba(80,160,255,.08)}' +
'.act-row.kind-agent{background:rgba(160,100,255,.08)}' +
'.act-time{opacity:.6}' +
'.act-tool{font-weight:600}' +
'.act-summary{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.85}' +
'.act-session{opacity:.5;text-align:right}' +
'.act-empty{padding:24px 8px;opacity:.6;font-size:13px}';
document.head.appendChild(style);
var origShowTab = window.showTab;
window.showTab = function (name, btn) {
origShowTab(name, btn);
if (name === 'activity') {
renderActivity();
if (timer) clearInterval(timer);
timer = setInterval(renderActivity, 3000);
} else if (timer) {
clearInterval(timer);
timer = null;
}
};
})();
</script>
</body></html>`;
/* eslint-enable no-useless-escape */
}
@@ -831,6 +921,43 @@ function sendHtml(res, statusCode, html) {
res.end(html);
}
const MAX_ACTIVITY_LOG_BYTES = 256 * 1024;
function loadActivity(limit = 200, logPath = path.join(os.homedir(), '.claude', 'metrics', 'tool-usage.jsonl')) {
let fd;
let text;
try {
fd = fs.openSync(logPath, 'r');
const stat = fs.fstatSync(fd);
if (!stat.isFile()) return [];
const length = Math.min(stat.size, MAX_ACTIVITY_LOG_BYTES);
const start = stat.size - length;
const buffer = Buffer.alloc(length);
const bytesRead = fs.readSync(fd, buffer, 0, length, start);
text = buffer.toString('utf8', 0, bytesRead);
// A bounded tail may start inside a JSON row or a multibyte character.
if (start > 0) text = text.slice(text.indexOf('\n') + 1);
} catch {
return [];
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
const lines = text.split('\n').filter(Boolean);
const start = Math.max(0, lines.length - limit);
const out = [];
for (let i = lines.length - 1; i >= start; i -= 1) {
try {
const row = JSON.parse(lines[i]);
if (row && !Array.isArray(row) && typeof row.session_id === 'string' && typeof row.tool_name === 'string') {
out.push(row);
}
} catch {
// Skip unparseable lines rather than fail the whole feed.
}
}
return out;
}
function loadDashboardData(root) {
return {
agents: loadAgents(root),
@@ -879,6 +1006,21 @@ function createDashboardServer({
return sendJson(res, 400, { error: 'Bad request' });
}
if (url.pathname === '/api/activity') {
let entries;
try {
entries = loadActivity();
} catch (error) {
reportDashboardFailure(
reportError,
'[ECC] Failed to load activity log:',
error
);
return sendJson(res, 500, { error: 'Internal server error' });
}
return sendJson(res, 200, { entries });
}
if (url.pathname === '/api/data') {
let data;
try {
@@ -940,6 +1082,7 @@ module.exports = {
createDashboardServer,
listenDashboardServer,
loadAgents,
loadActivity,
loadCommands,
loadHooks,
loadMcps,
+18 -2
View File
@@ -11,6 +11,7 @@
const crypto = require('crypto');
const path = require('path');
const { spawnSync } = require('child_process');
const { sanitizeSessionId } = require('../lib/session-bridge');
const {
appendFile,
getClaudeDir,
@@ -561,13 +562,28 @@ function summarizeOutput(toolOutput) {
}
function buildActivityRow(input, env = process.env) {
const hookEvent = String(env.CLAUDE_HOOK_EVENT_NAME || '').trim();
// Same env-var-reliance bug as the session id below: Claude Code doesn't
// set CLAUDE_HOOK_EVENT_NAME for hook subprocesses either. Real hook stdin
// payloads carry hook_event_name as a JSON field -- prefer that.
const hookEvent = String(
input?.hook_event_name || env.CLAUDE_HOOK_EVENT_NAME || ''
).trim();
if (hookEvent && hookEvent !== 'PostToolUse') {
return null;
}
const toolName = String(input?.tool_name || '').trim();
const sessionId = String(env.ECC_SESSION_ID || env.CLAUDE_SESSION_ID || '').trim();
// Claude Code does not set CLAUDE_SESSION_ID as an environment variable for
// hook subprocesses -- the session id is only available in the hook's own
// stdin JSON payload. Prefer that; keep the env vars as a fallback for any
// caller that does set them. Sanitized the same way as the sibling hooks
// (cost-tracker.js, ecc-context-monitor.js, ecc-metrics-bridge.js,
// gateguard-fact-force.js) that already use this exact fallback order.
const sessionId =
sanitizeSessionId(input?.session_id) ||
sanitizeSessionId(env.ECC_SESSION_ID) ||
sanitizeSessionId(env.CLAUDE_SESSION_ID) ||
'';
if (!toolName || !sessionId) {
return null;
}
@@ -407,6 +407,28 @@ function runTests() {
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('reads session_id from stdin JSON when no session env var is set', () => {
// Claude Code does not set CLAUDE_SESSION_ID for hook subprocesses in
// real invocations -- the session id only arrives via stdin JSON.
const tmpHome = makeTempDir();
const input = {
session_id: 'stdin-session-only',
tool_name: 'Read',
tool_input: { file_path: path.join(tmpHome, 'foo.txt') },
};
const result = runScript(input, {
...withTempHome(tmpHome),
CLAUDE_HOOK_EVENT_NAME: 'PostToolUse',
});
assert.strictEqual(result.code, 0);
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'tool-usage.jsonl');
const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
assert.strictEqual(row.session_id, 'stdin-session-only');
fs.rmSync(tmpHome, { recursive: true, force: true });
}) ? passed++ : failed++);
(test('handles invalid JSON gracefully', () => {
const tmpHome = makeTempDir();
const invalidInput = 'not valid json {{{';
+32
View File
@@ -8,6 +8,7 @@ const os = require('os');
const path = require('path');
const http = require('http');
const net = require('net');
const vm = require('vm');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'dashboard-web.js');
@@ -17,6 +18,37 @@ let testFailed = 0;
const asyncTests = [];
const REQUEST_TIMEOUT_MS = 5000;
test('renderHTML emits executable scripts without syntax errors', () => {
const { renderHTML } = require(SCRIPT);
const html = renderHTML({ agents: [], skills: [], commands: [], rules: [], mcps: [], hooks: [] });
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)];
assert.ok(scripts.length > 0);
for (const script of scripts) new vm.Script(script[1]);
});
test('activity loading bounds large logs and ignores malformed rows', () => {
const { loadActivity } = require(SCRIPT);
withTempDir('ecc-activity-', dir => {
const log = path.join(dir, 'usage.jsonl');
const row = name => JSON.stringify({ session_id: 'fixture', tool_name: name });
fs.writeFileSync(log, 'x'.repeat(1024 * 1024) + '\n' + row('Read') + '\nnull\n{"session_id":42,"tool_name":"Read"}\nnot-json\n' + row('Skill') + '\n');
const originalRead = fs.readSync;
let bytesRequested = 0;
fs.readSync = (...args) => {
bytesRequested += args[3];
return originalRead(...args);
};
let rows;
try { rows = loadActivity(200, log); }
finally { fs.readSync = originalRead; }
assert.ok(bytesRequested <= 256 * 1024);
assert.deepStrictEqual(rows.map(entry => entry.tool_name), ['Skill', 'Read']);
fs.writeFileSync(log, row('Edit') + '\n');
assert.deepStrictEqual(loadActivity(200, log).map(entry => entry.tool_name), ['Edit']);
assert.deepStrictEqual(loadActivity(200, path.join(dir, 'missing')), []);
});
});
function test(name, fn) {
try {
fn();