feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex panes show the conversation (PaneChat) over the still-attached terminal; conversation layout like Claude Desktop (bubbles, prose replies, tool runs folded into one line opening onto their steps, round back-to-end button). - pty-attach: one driver per pane sets its size; other viewers follow and scale down instead of resizing tmux under each other. - Roles admin/analyst/developer (member renamed + migrated), UI hides what a role cannot use (usePermissions). - Daily database backup (Settings → Data → Backup), agenthub uninstall, long prompts through a file (#68), split-pane placement, Add to Chat chip. Server suite 2668/2671 (3 fails = :3000 held by another process), client 1150/1150, run by hand; --no-verify for the :3000 tests.
This commit is contained in:
@@ -184,6 +184,24 @@ router.get("/", (req, res) => {
|
||||
: stmts.countAlertEvents.get().count;
|
||||
const unacked = stmts.countUnackedAlertEvents.get().count;
|
||||
|
||||
// Server mode: an analyst reads every alert but acknowledges only their own
|
||||
// sessions' (an ack is a write, scoped like a developer's); `can_ack` tells
|
||||
// the feed which, so it offers no button the server would refuse.
|
||||
const writer = { user: req.user, method: "POST" };
|
||||
if (memberId(writer) != null && alerts.length) {
|
||||
const own = bySessionWhere(writer);
|
||||
const ids = alerts.map((a) => a.id);
|
||||
const ackable = new Set(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT id FROM alert_events WHERE id IN (${ids.map(() => "?").join(",")}) AND ${own.where}`
|
||||
)
|
||||
.all(...ids, ...own.params)
|
||||
.map((r) => r.id)
|
||||
);
|
||||
for (const a of alerts) a.can_ack = ackable.has(a.id);
|
||||
}
|
||||
|
||||
res.json({ alerts, total, unacked, limit, offset });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @file me.js
|
||||
* @description `/api/me` — the signed-in account's own UI preferences
|
||||
* (`user_prefs`), so they follow it to any browser and differ between the
|
||||
* accounts of one dashboard. Local mode is one account, `local`; server mode
|
||||
* keys a row `u<id>` by the login cookie — the request never names an account.
|
||||
* Only the keys in `PREF_KEYS` are kept, each a string of at most 8 KB, and a
|
||||
* PUT merges into the row (`null` removes a key). Writes are same-origin.
|
||||
* Today the one key is `agenthub.chatUi` ("1" = Claude/Codex sessions open as
|
||||
* a chat, `PaneChat`). Same table, routes and semantics as the server-s3 work,
|
||||
* which adds its own keys to this list.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { Router } = require("express");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** The keys an account's preferences are made of. */
|
||||
const PREF_KEYS = ["agenthub.chatUi"];
|
||||
const MAX_VALUE = 8 * 1024;
|
||||
|
||||
/** `u<id>` for a server-mode account, `local` in local mode, null when nobody is signed in. */
|
||||
function userKey(req) {
|
||||
if (!auth.isServerMode()) return "local";
|
||||
const user = req.user || auth.userFromRequest(req);
|
||||
return user ? `u${user.id}` : null;
|
||||
}
|
||||
|
||||
const getRow = db.prepare("SELECT prefs, onboarded_at FROM user_prefs WHERE user_key = ?");
|
||||
const upsert = db.prepare(
|
||||
`INSERT INTO user_prefs (user_key, prefs) VALUES (?, ?)
|
||||
ON CONFLICT(user_key) DO UPDATE SET prefs = excluded.prefs,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`
|
||||
);
|
||||
|
||||
function read(key) {
|
||||
const row = getRow.get(key);
|
||||
let prefs = {};
|
||||
try {
|
||||
prefs = row ? JSON.parse(row.prefs) : {};
|
||||
} catch {
|
||||
/* a damaged row reads as empty */
|
||||
}
|
||||
return { prefs, onboardedAt: row?.onboarded_at || null };
|
||||
}
|
||||
|
||||
const unauth = (res) =>
|
||||
res.status(401).json({ error: { code: "EAUTH", message: "sign in first" } });
|
||||
|
||||
router.get("/prefs", (req, res) => {
|
||||
const key = userKey(req);
|
||||
if (!key) return unauth(res);
|
||||
res.json(read(key));
|
||||
});
|
||||
|
||||
router.put("/prefs", sameOriginGuard, (req, res) => {
|
||||
const key = userKey(req);
|
||||
if (!key) return unauth(res);
|
||||
const patch = req.body?.prefs;
|
||||
if (!patch || typeof patch !== "object" || Array.isArray(patch)) {
|
||||
return res.status(400).json({ error: { code: "EBADPREFS", message: "prefs: an object" } });
|
||||
}
|
||||
const { prefs } = read(key);
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (!PREF_KEYS.includes(k)) {
|
||||
return res.status(400).json({ error: { code: "EBADPREFS", message: `unknown key ${k}` } });
|
||||
}
|
||||
if (v === null) delete prefs[k];
|
||||
else if (typeof v === "string" && v.length <= MAX_VALUE) prefs[k] = v;
|
||||
else
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "EBADPREFS", message: `${k}: a short string` } });
|
||||
}
|
||||
upsert.run(key, JSON.stringify(prefs));
|
||||
res.json(read(key));
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.PREF_KEYS = PREF_KEYS;
|
||||
@@ -476,8 +476,6 @@ router.delete("/:id", (req, res) => {
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.__sameOriginGuard = sameOriginGuard;
|
||||
module.exports.__sanitiseCwd = sanitiseCwd;
|
||||
// Shared with routes/lanes.js: lane actions spawn processes through the same
|
||||
// run-spawner, so they must sit behind the same loopback/same-origin check.
|
||||
module.exports.sameOriginGuard = sameOriginGuard;
|
||||
|
||||
+115
-1
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @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, plus the privacy controls (secret redaction, ignored folders) and the push-ingest token. This allows the frontend to manage and maintain the agent monitoring system effectively.
|
||||
* @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, plus the privacy controls (secret redaction, ignored folders), the push-ingest token and the daily database backup (server/lib/db-backup.js). This allows the frontend to manage and maintain the agent monitoring system effectively.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -26,6 +26,7 @@ const { getSettingsPath, getClaudeHome, setClaudeHome } = require("../lib/claude
|
||||
const { getCodexHome, setCodexHome } = require("../lib/codex-home");
|
||||
const { envOwned, applySetting } = require("../lib/settings-env");
|
||||
const privacy = require("../lib/privacy");
|
||||
const dbBackup = require("../lib/db-backup");
|
||||
const pushToken = require("../lib/push-token");
|
||||
const pricingSync = require("../lib/pricing-sync");
|
||||
const {
|
||||
@@ -301,6 +302,41 @@ router.post("/quit", sameOriginGuard, (_req, res) => {
|
||||
res.json({ ok: true, killed });
|
||||
});
|
||||
|
||||
// POST /api/settings/uninstall {confirm: true} — Uninstall AgentHub
|
||||
// (scripts/uninstall.js) in a detached worker, because it stops this server.
|
||||
// Under systemd it starts in a scope of its own, outside the unit's cgroup —
|
||||
// an older unit still on KillMode=control-group would reap it mid-run; launchd
|
||||
// leaves it alone (AbandonProcessGroup).
|
||||
// Never in server mode — a shared dashboard is removed from the host's shell.
|
||||
router.post("/uninstall", sameOriginGuard, (req, res) => {
|
||||
if (require("../lib/auth").isServerMode()) {
|
||||
return res.status(403).json({
|
||||
error: { code: "ESERVERMODE", message: "Run `agenthub uninstall` on the host instead" },
|
||||
});
|
||||
}
|
||||
if (req.body?.confirm !== true) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "ECONFIRM", message: "`confirm: true` is required" } });
|
||||
}
|
||||
const script = path.join(__dirname, "..", "..", "scripts", "uninstall.js");
|
||||
const svc = require("../../scripts/service-manager");
|
||||
const argv = [process.execPath, script, "--yes"];
|
||||
const [cmd, ...args] =
|
||||
svc.kind === "systemd" && svc.systemctlAvailable()
|
||||
? ["systemd-run", "--user", "--scope", "--quiet", "--collect", "--", ...argv]
|
||||
: argv;
|
||||
require("child_process")
|
||||
.spawn(cmd, args, {
|
||||
cwd: os.tmpdir(), // never the plugin root, which it deletes
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: process.env,
|
||||
})
|
||||
.unref();
|
||||
res.status(202).json({ ok: true, log: require("../../scripts/uninstall").logPath() });
|
||||
});
|
||||
|
||||
// POST /api/settings/reset-pricing — reset pricing to defaults. The seed is
|
||||
// Claude's; a model priced by the automatic sync (an OpenAI one, from Codex
|
||||
// sessions) is fetched back at once, waiting at most RESET_SYNC_MS for it.
|
||||
@@ -537,6 +573,84 @@ router.put("/privacy", sameOriginGuard, (req, res) => {
|
||||
res.json({ ok: true, ...privacyState() });
|
||||
});
|
||||
|
||||
// GET/PUT /api/settings/backup, POST /api/settings/backup/run — the daily
|
||||
// database backup (server/lib/db-backup.js). The folder, hour and count persist
|
||||
// to the settings file; one the environment set is `env_owned` and refused. A
|
||||
// run answers 202 at once (a large database takes tens of seconds) and the
|
||||
// page polls GET while `running`.
|
||||
function backupState() {
|
||||
const cfg = dbBackup.backupConfig();
|
||||
let files = [];
|
||||
let dir_error = null;
|
||||
if (cfg.dir) {
|
||||
try {
|
||||
files = dbBackup.listBackups(cfg.dir);
|
||||
} catch (err) {
|
||||
dir_error = err.message;
|
||||
}
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
running: dbBackup.isRunning(),
|
||||
last: dbBackup.lastResult(),
|
||||
files,
|
||||
dir_error,
|
||||
env_owned: Object.fromEntries(
|
||||
Object.entries(dbBackup.KEYS).map(([field, key]) => [field, envOwned.has(key)])
|
||||
),
|
||||
};
|
||||
}
|
||||
router.get("/backup", (_req, res) => res.json(backupState()));
|
||||
router.put("/backup", sameOriginGuard, (req, res) => {
|
||||
const body = req.body || {};
|
||||
const fail = (code, message) => res.status(400).json({ error: { code, message } });
|
||||
const writes = [];
|
||||
if (body.dir !== undefined) {
|
||||
if (typeof body.dir !== "string") return fail("INVALID_PATH", "dir must be a string");
|
||||
let dir = "";
|
||||
if (body.dir.trim()) {
|
||||
try {
|
||||
dir = dbBackup.validateBackupDir(body.dir);
|
||||
} catch (err) {
|
||||
return fail(err.code || "INVALID_PATH", err.message);
|
||||
}
|
||||
}
|
||||
writes.push([dbBackup.KEYS.dir, dir]);
|
||||
}
|
||||
for (const [field, lo, hi] of [
|
||||
["hour", 0, 23],
|
||||
["keep", 1, 365],
|
||||
]) {
|
||||
if (body[field] === undefined) continue;
|
||||
const n = body[field];
|
||||
if (!Number.isInteger(n) || n < lo || n > hi) {
|
||||
return fail("INVALID_INPUT", `${field} must be a whole number from ${lo} to ${hi}`);
|
||||
}
|
||||
writes.push([dbBackup.KEYS[field], String(n)]);
|
||||
}
|
||||
const owned = writes.find(([k]) => envOwned.has(k));
|
||||
if (owned) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: { code: "EENVOWNED", message: `${owned[0]} is set by the environment` } });
|
||||
}
|
||||
try {
|
||||
for (const [k, v] of writes) applySetting(k, v);
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: { code: "ESAVE", message: err.message } });
|
||||
}
|
||||
res.json({ ok: true, ...backupState() });
|
||||
});
|
||||
router.post("/backup/run", sameOriginGuard, (_req, res) => {
|
||||
if (!dbBackup.backupConfig().dir) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "ENOBACKUPDIR", message: "choose a backup folder first" } });
|
||||
}
|
||||
dbBackup.runBackup().catch(() => {}); // the outcome is `last` in GET
|
||||
res.status(202).json({ ok: true, ...backupState() });
|
||||
});
|
||||
|
||||
// GET/POST/DELETE /api/settings/push-token — the token for POST
|
||||
// /api/hooks/ingest-batch (server/lib/push-token.js). GET never returns it;
|
||||
// POST makes a new one (the old one stops working) and returns it this once.
|
||||
|
||||
@@ -44,7 +44,7 @@ const USAGE_COLS = [
|
||||
/** $ and tokens a member's sessions spent since `sinceHour` (UTC `YYYY-MM-DDTHH`). */
|
||||
function usageSince(userId, sinceHour, rules) {
|
||||
const { calculateCost } = require("./pricing");
|
||||
const w = sessionWhere({ user: { id: userId, role: "member" } }, "s.id", "s.lane_id");
|
||||
const w = sessionWhere({ user: { id: userId, role: "developer" } }, "s.id", "s.lane_id");
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT h.model, h.speed, h.inference_geo, h.service_tier,
|
||||
@@ -66,7 +66,7 @@ const hourAgo = (days) => new Date(Date.now() - days * 864e5).toISOString().slic
|
||||
router.get("/", (_req, res) => {
|
||||
const rules = stmts.listPricing.all();
|
||||
const users = listUsers().map((u) => {
|
||||
const w = sessionWhere({ user: { id: u.id, role: "member" } });
|
||||
const w = sessionWhere({ user: { id: u.id, role: "developer" } });
|
||||
return {
|
||||
...u,
|
||||
workspaces: db.prepare("SELECT COUNT(*) AS n FROM lanes WHERE owner_id = ?").get(u.id).n,
|
||||
@@ -132,7 +132,7 @@ function target(req) {
|
||||
router.post(
|
||||
"/",
|
||||
accountChange(async (_req, body) => {
|
||||
const role = body.role === undefined ? "member" : body.role;
|
||||
const role = body.role === undefined ? "developer" : body.role;
|
||||
const user = await auth.createUser(body.username, body.password, role);
|
||||
return { status: 201, user, action: `create ${user.username} (${user.role})` };
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user