1c5521cf2e
UI
- No animation runs forever: Sagi moods, the custom pet sprite, status dots
(pulse-dot, pulse-slow, ping) and the chat shimmer play a few cycles and
rest. An infinite animation kept the browser drawing 60 frames a second.
- useSagiBrain: frames that only move lastActivityAt by <1 s keep the old
state; the 1 s tick re-renders only when the mood changes.
- ConversationView and MessageList are memoized and PaneChat passes stable
callbacks: typing in the chat box no longer re-renders the conversation.
- The chat's 700 ms menu poll reads plain text first and scans the terminal
cell by cell (reusing one cell) only when a row could be a menu cursor row.
Server
- GET /sessions/:id/transcript?after=N skips lines <= N before JSON.parse.
- GET /sessions/:id/outline is cached until the session's events change.
- broadcastLane uses the cached tmux.liveSessions(); tmux.hasSession answers
"yes" from the fresh shared list instead of a synchronous spawn (50-150 ms
per psmux call on Windows, blocking terminal echo).
- pane-mode polls every 150 ms; writeHourly writes only changed hour buckets.
- /api/metrics: agenthub_event_loop_delay_seconds{quantile}.
Hooks
- PreToolUse/PostToolUse run with "async": true (settings installer,
plugin.json, S3 managed settings): Claude Code no longer waits a Node start
before and after every tool call.
Committed with --no-verify at the owner's request: the pre-commit full server
suite fails on 9 tests that fail identically on 0c2f498 (lane-sync merge: no
git identity on this host; read-pool; resolveHost; codex-usage 1 ms flake).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
151 lines
4.9 KiB
JavaScript
151 lines
4.9 KiB
JavaScript
/**
|
|
* @file Tests for the Prometheus metrics endpoint (GET /api/metrics). Verifies
|
|
* the exposition format (HELP/TYPE headers, content-type, no NaN samples), that
|
|
* the enumerated status gauges are always present (so a series never drops out
|
|
* at zero), and that the numbers track real data seeded through the hook API.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, before, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const path = require("path");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const http = require("http");
|
|
|
|
const TEST_DB = path.join(os.tmpdir(), `dashboard-metrics-test-${Date.now()}-${process.pid}.db`);
|
|
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
|
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
const { db } = require("../db");
|
|
|
|
let server;
|
|
let BASE;
|
|
|
|
function request(method, urlPath, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL(urlPath, BASE);
|
|
const payload = body ? JSON.stringify(body) : null;
|
|
const req = http.request(
|
|
{
|
|
hostname: url.hostname,
|
|
port: url.port,
|
|
path: url.pathname + url.search,
|
|
method,
|
|
headers: payload
|
|
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
|
|
: {},
|
|
},
|
|
(res) => {
|
|
let b = "";
|
|
res.on("data", (c) => (b += c));
|
|
res.on("end", () => resolve({ status: res.statusCode, headers: res.headers, body: b }));
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
if (payload) req.write(payload);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
before(async () => {
|
|
const app = createApp();
|
|
server = await startServer(app, 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
});
|
|
|
|
after(() => {
|
|
if (server) server.close();
|
|
if (db) db.close();
|
|
for (const suffix of ["", "-wal", "-shm"]) {
|
|
try {
|
|
fs.unlinkSync(TEST_DB + suffix);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
});
|
|
|
|
/** Parse a `name{labels} value` sample line's value, or null if not present. */
|
|
function sampleValue(text, name, labels) {
|
|
const needle = labels ? `${name}{${labels}}` : name;
|
|
for (const line of text.split("\n")) {
|
|
if (line.startsWith("#")) continue;
|
|
if (labels ? line.startsWith(needle + " ") : line.startsWith(needle + " ")) {
|
|
return Number(line.slice(needle.length).trim());
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
describe("GET /api/metrics", () => {
|
|
it("serves a well-formed Prometheus exposition", async () => {
|
|
const res = await request("GET", "/api/metrics");
|
|
assert.equal(res.status, 200);
|
|
assert.match(res.headers["content-type"], /text\/plain/);
|
|
assert.match(res.headers["content-type"], /version=0\.0\.4/);
|
|
|
|
// Core families present with HELP + TYPE headers.
|
|
for (const name of [
|
|
"agenthub_up",
|
|
"agenthub_build_info",
|
|
"agenthub_process_uptime_seconds",
|
|
"agenthub_process_resident_memory_bytes",
|
|
"agenthub_event_loop_delay_seconds",
|
|
"agenthub_sessions",
|
|
"agenthub_agents",
|
|
"agenthub_events_total",
|
|
"agenthub_websocket_clients",
|
|
"agenthub_remote_sources",
|
|
"agenthub_tokens_total",
|
|
]) {
|
|
assert.ok(res.body.includes(`# HELP ${name} `), `HELP for ${name}`);
|
|
assert.ok(res.body.includes(`# TYPE ${name} `), `TYPE for ${name}`);
|
|
}
|
|
|
|
assert.equal(sampleValue(res.body, "agenthub_up"), 1);
|
|
// No sample line may carry a NaN/undefined value.
|
|
for (const line of res.body.split("\n")) {
|
|
if (!line || line.startsWith("#")) continue;
|
|
const value = line.slice(line.lastIndexOf(" ") + 1);
|
|
assert.ok(!Number.isNaN(Number(value)), `numeric sample value on line: ${line}`);
|
|
}
|
|
});
|
|
|
|
it("emits every enumerated status series even at zero", async () => {
|
|
const res = await request("GET", "/api/metrics");
|
|
for (const status of ["active", "completed", "error", "abandoned"]) {
|
|
assert.notEqual(
|
|
sampleValue(res.body, "agenthub_sessions", `status="${status}"`),
|
|
null,
|
|
`agenthub_sessions status=${status} present`
|
|
);
|
|
}
|
|
for (const status of ["working", "waiting", "completed", "error"]) {
|
|
assert.notEqual(
|
|
sampleValue(res.body, "agenthub_agents", `status="${status}"`),
|
|
null,
|
|
`agenthub_agents status=${status} present`
|
|
);
|
|
}
|
|
});
|
|
|
|
it("reflects real data seeded through the hook API", async () => {
|
|
const before = sampleValue(await scrapeText(), "agenthub_sessions", 'status="active"') || 0;
|
|
|
|
await request("POST", "/api/hooks/event", {
|
|
hook_type: "SessionStart",
|
|
data: { session_id: "metrics-sess-1", cwd: "/tmp/metrics" },
|
|
});
|
|
|
|
const after = sampleValue(await scrapeText(), "agenthub_sessions", 'status="active"');
|
|
assert.equal(after, before + 1, "a new active session bumps the gauge by 1");
|
|
});
|
|
|
|
async function scrapeText() {
|
|
return (await request("GET", "/api/metrics")).body;
|
|
}
|
|
});
|