mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-18 05:57:40 +02:00
merge: preserve source 2983 with tested activity dashboard fixes
This commit is contained in:
+144
-1
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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,
|
||||
|
||||
@@ -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 {{{';
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user