57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
305 lines
10 KiB
JavaScript
305 lines
10 KiB
JavaScript
/**
|
|
* @file Express router for settings-related endpoints, providing system info, database statistics, hook status, and operations to clear data, re-import sessions, reinstall hooks, reset pricing, export data, and perform cleanup of stale sessions. This allows the frontend to manage and maintain the agent monitoring system effectively.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { Router } = require("express");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const os = require("os");
|
|
const { db, stmts, DB_PATH, DEFAULT_PRICING, applyIntroPricing } = require("../db");
|
|
const { getConnectionCount } = require("../websocket");
|
|
const { transcriptCache } = require("./hooks");
|
|
|
|
const router = Router();
|
|
|
|
const APP_VERSION = (() => {
|
|
try {
|
|
return require("../../package.json").version || "0.0.0";
|
|
} catch {
|
|
return "0.0.0";
|
|
}
|
|
})();
|
|
|
|
const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude-home");
|
|
const CLAUDE_SETTINGS_PATH = getSettingsPath();
|
|
|
|
function getDbSize() {
|
|
try {
|
|
const stat = fs.statSync(DB_PATH);
|
|
return stat.size;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
function getTableCounts() {
|
|
const tables = ["sessions", "agents", "events", "model_pricing"];
|
|
const counts = {};
|
|
for (const t of tables) {
|
|
counts[t] = db.prepare(`SELECT COUNT(*) as c FROM ${t}`).get().c;
|
|
}
|
|
counts.token_usage = db
|
|
.prepare("SELECT COUNT(DISTINCT session_id) as c FROM token_usage")
|
|
.get().c;
|
|
return counts;
|
|
}
|
|
|
|
function getHookStatus() {
|
|
try {
|
|
if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) {
|
|
return { installed: false, path: CLAUDE_SETTINGS_PATH, hooks: {} };
|
|
}
|
|
const raw = fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf8");
|
|
const settings = JSON.parse(raw);
|
|
const hookTypes = [
|
|
"PreToolUse",
|
|
"PostToolUse",
|
|
"Stop",
|
|
"SubagentStop",
|
|
"Notification",
|
|
"SessionStart",
|
|
"SessionEnd",
|
|
];
|
|
const hooks = {};
|
|
for (const ht of hookTypes) {
|
|
const entries = settings.hooks?.[ht] || [];
|
|
hooks[ht] = entries.some(
|
|
(e) =>
|
|
(e.command && e.command.includes("hook-handler.js")) ||
|
|
(Array.isArray(e.hooks) &&
|
|
e.hooks.some((h) => h.command && h.command.includes("hook-handler.js")))
|
|
);
|
|
}
|
|
const installed = Object.values(hooks).every(Boolean);
|
|
return { installed, path: CLAUDE_SETTINGS_PATH, hooks };
|
|
} catch {
|
|
return { installed: false, path: CLAUDE_SETTINGS_PATH, hooks: {} };
|
|
}
|
|
}
|
|
|
|
// GET /api/settings/info — system info, db stats, hook status
|
|
router.get("/info", (req, res) => {
|
|
const dbSize = getDbSize();
|
|
const counts = getTableCounts();
|
|
const hookStatus = getHookStatus();
|
|
|
|
// Advanced SQLite info
|
|
const pragmas = {
|
|
journal_mode: db.pragma("journal_mode", { simple: true }),
|
|
synchronous: db.pragma("synchronous", { simple: true }),
|
|
auto_vacuum: db.pragma("auto_vacuum", { simple: true }),
|
|
encoding: db.pragma("encoding", { simple: true }),
|
|
foreign_keys: db.pragma("foreign_keys", { simple: true }),
|
|
busy_timeout: db.pragma("busy_timeout", { simple: true }),
|
|
};
|
|
|
|
// Recent activity load (events in last 5, 15, 60 minutes)
|
|
const getCount = (ms) => {
|
|
const d = new Date(Date.now() - ms).toISOString();
|
|
return db.prepare("SELECT COUNT(*) as c FROM events WHERE created_at > ?").get(d).c;
|
|
};
|
|
|
|
const load_stats = {
|
|
m5: getCount(5 * 60 * 1000),
|
|
m15: getCount(15 * 60 * 1000),
|
|
h1: getCount(60 * 60 * 1000),
|
|
};
|
|
|
|
res.json({
|
|
db: {
|
|
path: DB_PATH,
|
|
size: dbSize,
|
|
counts,
|
|
pragmas,
|
|
load_stats,
|
|
},
|
|
hooks: hookStatus,
|
|
server: {
|
|
version: APP_VERSION,
|
|
uptime: process.uptime(),
|
|
node_version: process.version,
|
|
platform: process.platform,
|
|
ws_connections: getConnectionCount(),
|
|
memory: process.memoryUsage(),
|
|
cpu_load: os.loadavg(),
|
|
arch: os.arch(),
|
|
total_mem: os.totalmem(),
|
|
free_mem: os.freemem(),
|
|
cpus: os.cpus().length,
|
|
},
|
|
transcript_cache: transcriptCache.stats(),
|
|
});
|
|
});
|
|
|
|
// POST /api/settings/clear-data — delete all sessions, agents, events, tokens
|
|
router.post("/clear-data", (_req, res) => {
|
|
const counts = getTableCounts();
|
|
db.pragma("foreign_keys = OFF");
|
|
db.prepare("DELETE FROM token_usage").run();
|
|
db.prepare("DELETE FROM events").run();
|
|
db.prepare("DELETE FROM agents").run();
|
|
db.prepare("DELETE FROM sessions").run();
|
|
// Fired alerts reference the cleared sessions — wipe the feed too. Alert
|
|
// *rules* survive: they're user configuration, like model_pricing.
|
|
db.prepare("DELETE FROM alert_events").run();
|
|
// Webhook delivery log is an audit trail of those fired alerts — wipe it too.
|
|
// Webhook *targets* survive, like alert rules and pricing.
|
|
db.prepare("DELETE FROM webhook_deliveries").run();
|
|
db.pragma("foreign_keys = ON");
|
|
res.json({ ok: true, cleared: counts });
|
|
});
|
|
|
|
// POST /api/settings/reimport — re-import legacy sessions from ~/.claude/
|
|
router.post("/reimport", async (_req, res) => {
|
|
try {
|
|
const { importAllSessions } = require("../../scripts/import-history");
|
|
const dbModule = require("../db");
|
|
const result = await importAllSessions(dbModule);
|
|
res.json({ ok: true, ...result });
|
|
} catch (err) {
|
|
res.status(500).json({
|
|
error: { code: "IMPORT_FAILED", message: err.message },
|
|
});
|
|
}
|
|
});
|
|
|
|
// POST /api/settings/reinstall-hooks — reinstall Claude Code hooks
|
|
router.post("/reinstall-hooks", (_req, res) => {
|
|
try {
|
|
const { installHooks } = require("../../scripts/install-hooks");
|
|
const success = installHooks(true);
|
|
const hookStatus = getHookStatus();
|
|
res.json({ ok: success, hooks: hookStatus });
|
|
} catch (err) {
|
|
res.status(500).json({
|
|
error: { code: "HOOK_INSTALL_FAILED", message: err.message },
|
|
});
|
|
}
|
|
});
|
|
|
|
// POST /api/settings/reset-pricing — reset pricing to defaults
|
|
router.post("/reset-pricing", (_req, res) => {
|
|
db.prepare("DELETE FROM model_pricing").run();
|
|
|
|
const seedPricing = db.prepare(
|
|
"INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok, fast_input_per_mtok, fast_output_per_mtok) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
|
);
|
|
for (const [pattern, name, inp, out, cr, cw, cw1h, fin, fout] of DEFAULT_PRICING) {
|
|
seedPricing.run(pattern, name, inp, out, cr, cw, cw1h, fin, fout);
|
|
}
|
|
// Re-apply time-limited intro rates (e.g. Sonnet 5) — the seed above only
|
|
// carries standard rates, so without this a reset silently drops the promo.
|
|
applyIntroPricing(db);
|
|
|
|
const pricing = stmts.listPricing.all();
|
|
res.json({ ok: true, pricing });
|
|
});
|
|
|
|
// GET /api/settings/export — export all data as JSON
|
|
router.get("/export", (_req, res) => {
|
|
const sessions = db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all();
|
|
const agents = db.prepare("SELECT * FROM agents ORDER BY started_at DESC").all();
|
|
const events = db.prepare("SELECT * FROM events ORDER BY created_at DESC").all();
|
|
const tokenUsage = db.prepare("SELECT * FROM token_usage").all();
|
|
const pricing = stmts.listPricing.all();
|
|
|
|
res.setHeader("Content-Type", "application/json");
|
|
res.setHeader(
|
|
"Content-Disposition",
|
|
`attachment; filename="agent-monitor-export-${new Date().toISOString().slice(0, 10)}.json"`
|
|
);
|
|
res.json({
|
|
exported_at: new Date().toISOString(),
|
|
sessions,
|
|
agents,
|
|
events,
|
|
token_usage: tokenUsage,
|
|
model_pricing: pricing,
|
|
});
|
|
});
|
|
|
|
// GET /api/settings/claude-home — get current CLAUDE_HOME path
|
|
router.get("/claude-home", (_req, res) => {
|
|
res.json({ claude_home: getClaudeHome() });
|
|
});
|
|
|
|
// PUT /api/settings/claude-home — update CLAUDE_HOME path
|
|
router.put("/claude-home", (req, res) => {
|
|
const { path: newPath } = req.body;
|
|
if (!newPath || typeof newPath !== "string") {
|
|
return res.status(400).json({
|
|
error: { code: "INVALID_PATH", message: "path is required and must be a string" },
|
|
});
|
|
}
|
|
try {
|
|
const resolved = setClaudeHome(newPath);
|
|
res.json({ ok: true, claude_home: resolved });
|
|
} catch (err) {
|
|
res.status(400).json({
|
|
error: { code: "INVALID_PATH", message: err.message },
|
|
});
|
|
}
|
|
});
|
|
|
|
// POST /api/settings/cleanup — abandon stale sessions, purge old data
|
|
router.post("/cleanup", (req, res) => {
|
|
const { abandon_hours, purge_days } = req.body;
|
|
const result = { abandoned: 0, purged_sessions: 0, purged_events: 0, purged_agents: 0 };
|
|
|
|
if (abandon_hours && typeof abandon_hours === "number" && abandon_hours > 0) {
|
|
// Mark active sessions with no recent events as abandoned
|
|
const cutoff = new Date(Date.now() - abandon_hours * 3600 * 1000).toISOString();
|
|
const stale = db
|
|
.prepare(
|
|
`SELECT s.id FROM sessions s
|
|
WHERE s.status = 'active'
|
|
AND s.started_at < ?
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM events e WHERE e.session_id = s.id AND e.created_at > ?
|
|
)`
|
|
)
|
|
.all(cutoff, cutoff);
|
|
|
|
for (const row of stale) {
|
|
db.prepare(
|
|
"UPDATE sessions SET status = 'abandoned', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
|
|
).run(row.id);
|
|
// Also complete any lingering agents
|
|
db.prepare(
|
|
"UPDATE agents SET status = 'completed', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE session_id = ? AND status IN ('waiting','working')"
|
|
).run(row.id);
|
|
}
|
|
result.abandoned = stale.length;
|
|
}
|
|
|
|
if (purge_days && typeof purge_days === "number" && purge_days > 0) {
|
|
const cutoff = new Date(Date.now() - purge_days * 86400 * 1000).toISOString();
|
|
// Only purge completed/error/abandoned sessions, never active
|
|
const toDelete = db
|
|
.prepare(
|
|
"SELECT id FROM sessions WHERE status IN ('completed','error','abandoned') AND started_at < ?"
|
|
)
|
|
.all(cutoff);
|
|
|
|
if (toDelete.length > 0) {
|
|
const ids = toDelete.map((r) => r.id);
|
|
const placeholders = ids.map(() => "?").join(",");
|
|
// Cascading deletes handle agents/events, but token_usage FK might not cascade on all setups
|
|
result.purged_events = db
|
|
.prepare(`DELETE FROM events WHERE session_id IN (${placeholders})`)
|
|
.run(...ids).changes;
|
|
result.purged_agents = db
|
|
.prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`)
|
|
.run(...ids).changes;
|
|
db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(...ids);
|
|
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...ids);
|
|
result.purged_sessions = toDelete.length;
|
|
}
|
|
}
|
|
|
|
res.json({ ok: true, ...result });
|
|
});
|
|
|
|
module.exports = router;
|