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:
@@ -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
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user