feat: right-click menus everywhere, workspace/session cost, long-term DB safety
Right-click menus: one portal shell (components/ContextMenu.tsx) for every item with actions - sidebar/Recents/archived session rows (SessionMenu), console tabs, pane headers, the Changes panel's files/tree/commits, the workspace home, sessions outside a workspace (SessionRowMenu: Sessions table, Dashboard, Activity, Workflow runs), prompt images, every Settings list and every Claude Code/Codex config list (FileItemMenu). Buttons stay; a delete keeps its button's confirm; the shell stops portal clicks from reaching the card that opened it. Status bar: Workspace $ and Session $ on the Workspace page, from ?lane= on GET /api/pricing/cost and the focused/replayed session's cost. Database, no data dropped: synchronous=NORMAL, journal_size_limit 64 MB, hourly PASSIVE checkpoint + PRAGMA optimize, idx_events_session_created (replacing the redundant idx_events_session), watchdog reads sessions.transcript_path, transcript parsed before the hook's write transaction, GET /api/sessions/:id?events=0 for the web page (reloads only on its own session, debounced), streamed export, transactional clear-data and chunked cleanup, subagent import reads tool_use_ids once, runtime logs rotated past 5 MB.
This commit is contained in:
+93
-44
@@ -141,7 +141,19 @@ router.get("/info", (req, res) => {
|
||||
// POST /api/settings/clear-data — delete all sessions, agents, events, tokens
|
||||
router.post("/clear-data", (_req, res) => {
|
||||
const counts = getTableCounts();
|
||||
// foreign_keys cannot change inside a transaction, so it is turned off
|
||||
// around one: all of it lands, or none of it (a crash midway used to leave
|
||||
// events without sessions).
|
||||
db.pragma("foreign_keys = OFF");
|
||||
try {
|
||||
db.transaction(clearAll)();
|
||||
} finally {
|
||||
db.pragma("foreign_keys = ON");
|
||||
}
|
||||
res.json({ ok: true, cleared: counts });
|
||||
});
|
||||
|
||||
function clearAll() {
|
||||
db.prepare("DELETE FROM token_usage").run();
|
||||
db.prepare("DELETE FROM token_usage_hourly").run();
|
||||
db.prepare("DELETE FROM events").run();
|
||||
@@ -157,9 +169,10 @@ router.post("/clear-data", (_req, res) => {
|
||||
// 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 });
|
||||
});
|
||||
// Workflow runs belong to the sessions; with foreign keys off they would
|
||||
// not cascade and stay as orphans.
|
||||
db.prepare("DELETE FROM workflows").run();
|
||||
}
|
||||
|
||||
// POST /api/settings/reimport — re-import legacy sessions from ~/.claude/
|
||||
router.post("/reimport", async (_req, res) => {
|
||||
@@ -257,27 +270,53 @@ router.post("/reset-pricing", async (req, res) => {
|
||||
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();
|
||||
|
||||
// GET /api/settings/export — export all data as JSON. Same document as ever,
|
||||
// but written as it is read: `events` alone is GBs on a long-lived install,
|
||||
// and one `.all()` + `res.json` held it in memory twice (past V8's string
|
||||
// limit). Events go out in keyset pages, waiting for the socket to drain.
|
||||
const EXPORT_PAGE = 500;
|
||||
router.get("/export", async (req, res) => {
|
||||
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,
|
||||
});
|
||||
let gone = false;
|
||||
req.on("close", () => (gone = true));
|
||||
const write = async (chunk) => {
|
||||
if (!res.write(chunk)) await new Promise((r) => res.once("drain", r));
|
||||
};
|
||||
const field = (name, value) => `,${JSON.stringify(name)}:${JSON.stringify(value)}`;
|
||||
try {
|
||||
await write(`{"exported_at":${JSON.stringify(new Date().toISOString())}`);
|
||||
await write(
|
||||
field("sessions", db.prepare("SELECT * FROM sessions ORDER BY started_at DESC").all())
|
||||
);
|
||||
await write(field("agents", db.prepare("SELECT * FROM agents ORDER BY started_at DESC").all()));
|
||||
await write(`,"events":[`);
|
||||
const first = db.prepare(
|
||||
`SELECT * FROM events ORDER BY created_at DESC, id DESC LIMIT ${EXPORT_PAGE}`
|
||||
);
|
||||
const next = db.prepare(
|
||||
`SELECT * FROM events WHERE created_at < ? OR (created_at = ? AND id < ?)
|
||||
ORDER BY created_at DESC, id DESC LIMIT ${EXPORT_PAGE}`
|
||||
);
|
||||
let page = first.all();
|
||||
let sep = "";
|
||||
while (page.length && !gone) {
|
||||
await write(sep + page.map((e) => JSON.stringify(e)).join(","));
|
||||
sep = ",";
|
||||
const last = page[page.length - 1];
|
||||
page = next.all(last.created_at, last.created_at, last.id);
|
||||
}
|
||||
await write("]");
|
||||
await write(field("token_usage", db.prepare("SELECT * FROM token_usage").all()));
|
||||
await write(field("model_pricing", stmts.listPricing.all()) + "}");
|
||||
res.end();
|
||||
} catch (err) {
|
||||
console.warn("export failed:", err.message);
|
||||
res.destroy(err);
|
||||
}
|
||||
});
|
||||
|
||||
// The two editable home directories share one shape: GET answers the value and
|
||||
@@ -319,6 +358,7 @@ homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaud
|
||||
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome);
|
||||
|
||||
// POST /api/settings/cleanup — abandon stale sessions, purge old data
|
||||
const PURGE_CHUNK = 500;
|
||||
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 };
|
||||
@@ -337,15 +377,19 @@ router.post("/cleanup", (req, res) => {
|
||||
)
|
||||
.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);
|
||||
}
|
||||
const abandon = db.prepare(
|
||||
"UPDATE sessions SET status = 'abandoned', ended_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?"
|
||||
);
|
||||
// Also complete any lingering agents
|
||||
const complete = 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')"
|
||||
);
|
||||
db.transaction(() => {
|
||||
for (const row of stale) {
|
||||
abandon.run(row.id);
|
||||
complete.run(row.id);
|
||||
}
|
||||
})();
|
||||
result.abandoned = stale.length;
|
||||
}
|
||||
|
||||
@@ -358,23 +402,28 @@ router.post("/cleanup", (req, res) => {
|
||||
)
|
||||
.all(cutoff);
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
// One transaction, in chunks: a single IN (...) list of every id would
|
||||
// pass SQLite's bound-variable limit on a large history.
|
||||
db.transaction(() => {
|
||||
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 token_usage_hourly WHERE session_id IN (${placeholders})`).run(
|
||||
...ids
|
||||
);
|
||||
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...ids);
|
||||
result.purged_sessions = toDelete.length;
|
||||
}
|
||||
for (let i = 0; i < ids.length; i += PURGE_CHUNK) {
|
||||
const chunk = ids.slice(i, i + PURGE_CHUNK);
|
||||
const placeholders = chunk.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(...chunk).changes;
|
||||
result.purged_agents += db
|
||||
.prepare(`DELETE FROM agents WHERE session_id IN (${placeholders})`)
|
||||
.run(...chunk).changes;
|
||||
db.prepare(`DELETE FROM token_usage WHERE session_id IN (${placeholders})`).run(...chunk);
|
||||
db.prepare(`DELETE FROM token_usage_hourly WHERE session_id IN (${placeholders})`).run(
|
||||
...chunk
|
||||
);
|
||||
db.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...chunk);
|
||||
}
|
||||
result.purged_sessions = ids.length;
|
||||
})();
|
||||
}
|
||||
|
||||
res.json({ ok: true, ...result });
|
||||
|
||||
Reference in New Issue
Block a user