feat(settings): data-safe editors, deep-linked search, Data/About/Cost pages

- EditorModal loads once per path (a parent re-render no longer reloads the
  file over the user's typing), refuses Save after a failed load, opens a
  truncated (>256 KB) file read-only, and asks before overwriting a file
  that changed on disk or could not be re-read.
- Settings/Hooks editors unset only keys from the snapshot taken at Edit,
  warn when the file changed underneath, keep a renamed key's value, and
  refuse a new key named like a redacted one (env) or a read-only hook
  event, which used to replace the secret/hooks.
- Project scope shows its resolved folder and is disabled inside the
  plugin cache. Account-profile, pricing-row and pet deletes confirm;
  two-click actions disarm after 3 s; unsaved drafts ask before closing.
- Search indexes every Claude Code / Codex sub-tab, opens it via ?tab= and
  scrolls to + highlights the row; the active config tab is in the URL.
- General splits into General / Data / About; Pricing moves to a Cost
  group with one shared load; config tabs group as Content / Extensions /
  Config with arrow-key roving; Promise.allSettled per tab.
- Data: browser-preferences export/import (allowlisted keys), per-section
  reset, housekeeping dry-run first, clear-all behind a typed phrase.
  Server: purge days must be a whole number >= 1 (INVALID_PURGE_DAYS) and
  dry_run returns exactly what the real run would delete.
- Alerts can be edited; webhooks/alerts/remote-source failures toast;
  copies go through copyText; modals get dialog roles and useDialog.
- Sagi: Ctrl/Cmd+B only when shown and not typing, default spot clear of
  row controls, arrow keys move it, reduced motion honoured; the dead
  /run launch link goes to the workspace.
This commit is contained in:
2026-10-05 13:22:08 +07:00
parent 03f1673fce
commit 6715fbd971
68 changed files with 4817 additions and 1374 deletions
+121
View File
@@ -0,0 +1,121 @@
/**
* @file Tests for POST /api/settings/cleanup's input rules and dry run:
* `purge_days` must be a whole number of days ≥ 1 (a fraction such as 0.1
* would purge everything older than ~2 hours) and `dry_run: true` reports what
* would be abandoned/purged without changing a row.
* @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 os = require("os");
const fs = require("fs");
const http = require("http");
const TEST_DB = path.join(os.tmpdir(), `dashboard-cleanup-test-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
const { db } = require("../db");
let server;
let BASE;
function post(urlPath, body) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: "POST",
headers: { "Content-Type": "application/json" },
},
(res) => {
let text = "";
res.on("data", (c) => (text += c));
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
}
);
req.on("error", reject);
req.write(JSON.stringify(body));
req.end();
});
}
const OLD = new Date(Date.now() - 400 * 86400 * 1000).toISOString();
before(async () => {
server = await startServer(createApp(), 0);
BASE = `http://127.0.0.1:${server.address().port}`;
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, started_at, updated_at) VALUES (?, ?, 'completed', '/tmp', ?, ?)"
).run("old-done", "old", OLD, OLD);
});
after(() => {
if (server) server.close();
db.close();
for (const s of ["", "-wal", "-shm"]) {
try {
fs.unlinkSync(TEST_DB + s);
} catch {
/* ignore */
}
}
});
describe("POST /api/settings/cleanup", () => {
for (const bad of [0.1, 0, -3, 1.5, "30"]) {
it(`refuses purge_days=${JSON.stringify(bad)} with 400`, async () => {
const r = await post("/api/settings/cleanup", { purge_days: bad });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "INVALID_PURGE_DAYS");
});
}
it("dry_run counts what would be purged and deletes nothing", async () => {
const r = await post("/api/settings/cleanup", { purge_days: 30, dry_run: true });
assert.equal(r.status, 200);
assert.equal(r.body.dry_run, true);
assert.equal(r.body.purged_sessions, 1);
assert.ok(db.prepare("SELECT 1 FROM sessions WHERE id = 'old-done'").get());
});
it("a real run purges", async () => {
const r = await post("/api/settings/cleanup", { purge_days: 30 });
assert.equal(r.status, 200);
assert.equal(r.body.purged_sessions, 1);
assert.equal(db.prepare("SELECT 1 FROM sessions WHERE id = 'old-done'").get(), undefined);
});
it("with abandon AND purge, the dry run counts the sessions the real run abandons then purges", async () => {
// Still 'active' but silent for 400 days: a real run abandons it first,
// then the purge (completed/error/abandoned) deletes it.
db.prepare(
"INSERT INTO sessions (id, name, status, cwd, started_at, updated_at) VALUES (?, ?, 'active', '/tmp', ?, ?)"
).run("old-stale", "stale", OLD, OLD);
db.prepare(
"INSERT INTO agents (id, session_id, name, status, started_at) VALUES ('old-stale-a', 'old-stale', 'main', 'working', ?)"
).run(OLD);
db.prepare(
"INSERT INTO events (session_id, event_type, created_at) VALUES ('old-stale', 'PreToolUse', ?)"
).run(OLD);
const body = { abandon_hours: 1, purge_days: 30 };
const dry = await post("/api/settings/cleanup", { ...body, dry_run: true });
assert.equal(dry.status, 200);
const row = db.prepare("SELECT status FROM sessions WHERE id = 'old-stale'").get();
assert.equal(row.status, "active");
const real = await post("/api/settings/cleanup", body);
assert.equal(real.status, 200);
assert.equal(real.body.purged_sessions, 1);
for (const k of ["abandoned", "purged_sessions", "purged_events", "purged_agents"]) {
assert.equal(dry.body[k], real.body[k], k);
}
});
});
+56 -13
View File
@@ -357,18 +357,35 @@ homeRoutes("/claude-home", "CLAUDE_HOME", "claude_home", getClaudeHome, setClaud
// into the current home at its spawn, so a move needs no repair here.
homeRoutes("/codex-home", "CODEX_HOME", "codex_home", getCodexHome, setCodexHome);
// POST /api/settings/cleanup — abandon stale sessions, purge old data
// POST /api/settings/cleanup — abandon stale sessions, purge old data.
// `purge_days` is a whole number ≥ 1 (0.1 days would purge almost everything);
// `dry_run: true` answers the same counts and changes nothing.
const PURGE_CHUNK = 500;
router.post("/cleanup", (req, res) => {
const { abandon_hours, purge_days } = req.body;
const dryRun = req.body.dry_run === true;
if (purge_days !== undefined && purge_days !== null) {
if (!Number.isInteger(purge_days) || purge_days < 1) {
return res.status(400).json({
error: {
code: "INVALID_PURGE_DAYS",
message: "purge_days must be a whole number of days, 1 or more",
},
});
}
}
const result = { abandoned: 0, purged_sessions: 0, purged_events: 0, purged_agents: 0 };
if (dryRun) result.dry_run = true;
// A dry run abandons nothing, so the purge below would not see the sessions a
// real run abandons first and then purges; it adds them back from this list.
let wouldAbandon = [];
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
`SELECT s.id, s.started_at FROM sessions s
WHERE s.status = 'active'
AND s.started_at < ?
AND NOT EXISTS (
@@ -380,17 +397,22 @@ router.post("/cleanup", (req, res) => {
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;
if (dryRun) {
result.abandoned = stale.length;
wouldAbandon = stale;
} else {
// 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;
}
}
if (purge_days && typeof purge_days === "number" && purge_days > 0) {
@@ -402,6 +424,27 @@ router.post("/cleanup", (req, res) => {
)
.all(cutoff);
if (dryRun) {
// Exactly the ids the real run deletes: those already purgeable, plus
// the stale ones it abandons first that are also older than the cutoff.
const ids = [
...toDelete.map((r) => r.id),
...wouldAbandon.filter((r) => r.started_at < cutoff).map((r) => r.id),
];
for (let i = 0; i < ids.length; i += PURGE_CHUNK) {
const chunk = ids.slice(i, i + PURGE_CHUNK);
const placeholders = chunk.map(() => "?").join(",");
result.purged_events += db
.prepare(`SELECT COUNT(*) AS n FROM events WHERE session_id IN (${placeholders})`)
.get(...chunk).n;
result.purged_agents += db
.prepare(`SELECT COUNT(*) AS n FROM agents WHERE session_id IN (${placeholders})`)
.get(...chunk).n;
}
result.purged_sessions = ids.length;
return res.json({ ok: true, ...result });
}
// One transaction, in chunks: a single IN (...) list of every id would
// pass SQLite's bound-variable limit on a large history.
db.transaction(() => {