Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4999325210 | |||
| 1c6dfea5b5 | |||
| df15789982 | |||
| 272a3e7d6e | |||
| 569b68ec98 | |||
| 631ea96f19 | |||
| dce32b96b7 | |||
| a0cd808456 | |||
| e3745d7504 | |||
| 8546748759 | |||
| cc2ece94c3 |
+2
-2
@@ -7,7 +7,7 @@ Architectural overview and technical reference for the Agent Dashboard system, c
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -2557,7 +2557,7 @@ Components that consume this: SessionDetail, Analytics (donut chart + breakdown)
|
||||
| **SQLite** (via `better-sqlite3` or built-in `node:sqlite`) | Zero-config, embedded, no server process. WAL mode gives concurrent reads. Synchronous API is simpler than async alternatives for this use case. Falls back to Node.js built-in `node:sqlite` when `better-sqlite3` cannot be compiled |
|
||||
| **Express** | Battle-tested, minimal, well-understood. Overkill would be Fastify for this scale; underkill would be raw `http` module |
|
||||
| **ws** | Fastest, most lightweight WebSocket library for Node. No Socket.IO overhead needed since we only push JSON messages |
|
||||
| **React 18** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
|
||||
| **React 19** | Stable, widely known, strong TypeScript support. No need for Server Components or RSC given this is a client-rendered SPA |
|
||||
| **Vite** | Fast builds, native ESM, excellent dev experience. Proxy config handles the dev server split cleanly |
|
||||
| **Tailwind CSS** | Utility-first approach keeps styles colocated with markup. No CSS module boilerplate. Colors are CSS-variable-backed tokens (`darkMode: "class"`) so the Dark/Light/System switch re-themes the app with one class flip rather than per-component `dark:` variants, and a named theme (Settings → Appearance) is one `data-theme` attribute selecting an override block of the same variables |
|
||||
| **React Router 6** | Standard routing for React SPAs. Layout routes with `<Outlet>` give clean shell composition |
|
||||
|
||||
+67
-69
@@ -1281,7 +1281,7 @@ async function cmdDoctor() {
|
||||
// Asked of the SERVER, not of this shell: a run is a tmux session the server
|
||||
// spawns, so the only PATH that decides whether Start Run works is its own —
|
||||
// a systemd --user service never reads your shell profile. Missing tmux is a
|
||||
// warning, not a failure: it costs the run console and `lanes shell`, nothing
|
||||
// warning, not a failure: it costs the run console and `workspace shell`, nothing
|
||||
// else.
|
||||
try {
|
||||
const { available, upgrade } = await get("/api/run/tmux");
|
||||
@@ -1693,7 +1693,7 @@ function cmdOpen() {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes add` — adopt a working directory or provision a managed worktree.
|
||||
* `agenthub workspace add` — adopt a working directory or provision a managed worktree.
|
||||
*/
|
||||
async function cmdLanesAdd(args) {
|
||||
const flag = (name) => {
|
||||
@@ -1719,12 +1719,12 @@ async function cmdLanesAdd(args) {
|
||||
if (base) body.base = base;
|
||||
if (slug) body.slug = slug;
|
||||
if (tool) body.tool = tool;
|
||||
const { lane: provisioning } = await post("/api/lanes/worktree", body);
|
||||
const { lane: provisioning } = await post("/api/workspaces/worktree", body);
|
||||
const deadline = Date.now() + 30_000;
|
||||
let lane = provisioning;
|
||||
while (lane.status === "provisioning" && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
({ lane } = await get(`/api/lanes/${provisioning.id}`));
|
||||
({ lane } = await get(`/api/workspaces/${provisioning.id}`));
|
||||
}
|
||||
if (lane.status === "provisioning") {
|
||||
console.error(
|
||||
@@ -1755,12 +1755,12 @@ async function cmdLanesAdd(args) {
|
||||
const body = { cwd, title };
|
||||
if (pipeline) body.pipeline = pipeline;
|
||||
if (tool) body.tool = tool;
|
||||
const { lane } = await post("/api/lanes", body);
|
||||
const { lane } = await post("/api/workspaces", body);
|
||||
console.log(`${c.green("✔")} Created workspace #${lane.id}: ${lane.title}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes gc [--dry-run]` — reap orphaned Playwright MCP processes
|
||||
* `agenthub workspace gc [--dry-run]` — reap orphaned Playwright MCP processes
|
||||
* (owning session died) and cap oversized hook logs, across every lane on
|
||||
* this machine. Machine-wide, not one lane's; pure local process/file
|
||||
* action, no HTTP round-trip.
|
||||
@@ -1790,7 +1790,7 @@ function cmdLanesGc(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes profile init <repo>` — detect a Node.js project and scaffold
|
||||
* `agenthub workspace profile init <repo>` — detect a Node.js project and scaffold
|
||||
* `.agenthub/profile/`. Pure filesystem action against the SOURCE repo; does not
|
||||
* talk to the dashboard server at all.
|
||||
*/
|
||||
@@ -1844,9 +1844,9 @@ function cmdLanesProfileInit(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes profile check [<path>]` — validate a profile without needing a
|
||||
* `agenthub workspace profile check [<path>]` — validate a profile without needing a
|
||||
* lane to exist for it yet. Defaults to the current directory, NOT lane-id
|
||||
* resolution (unlike every other `lanes` subcommand) — this is meant to run
|
||||
* resolution (unlike every other `workspace` subcommand) — this is meant to run
|
||||
* against a bare repo right after `profile init`.
|
||||
*/
|
||||
async function cmdLanesProfileCheck(args) {
|
||||
@@ -1883,7 +1883,7 @@ function printLaneFacts(title, facts, fields) {
|
||||
}
|
||||
}
|
||||
|
||||
/** `agenthub lanes reset|remove|purge` — destructive work only after a fact check. */
|
||||
/** `agenthub workspace reset|remove|purge` — destructive work only after a fact check. */
|
||||
async function cmdLanesLifecycle(action, args) {
|
||||
const id = args.find((arg) => !arg.startsWith("--"));
|
||||
const fields = LANE_PREFLIGHT_FIELDS[action];
|
||||
@@ -1894,7 +1894,7 @@ async function cmdLanesLifecycle(action, args) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preflight = await get(`/api/lanes/${id}/preflight?action=${action}`);
|
||||
const preflight = await get(`/api/workspaces/${id}/preflight?action=${action}`);
|
||||
printLaneFacts(`Preflight for ${action} workspace #${id}:`, preflight, fields);
|
||||
const keepDb = action === "reset" && args.includes("--keep-db");
|
||||
if (preflight.database) {
|
||||
@@ -1938,7 +1938,7 @@ async function cmdLanesLifecycle(action, args) {
|
||||
const body = { confirm: true, expect };
|
||||
if (args.includes("--force")) body.force = true;
|
||||
if (keepDb) body.keepDb = true;
|
||||
const result = await post(`/api/lanes/${id}/${action}`, body, { allowError: true });
|
||||
const result = await post(`/api/workspaces/${id}/${action}`, body, { allowError: true });
|
||||
if (result.status) {
|
||||
const error = result.data?.error || {};
|
||||
console.error(`✖ ${action} workspace #${id} → ${error.message || `HTTP ${result.status}`}`);
|
||||
@@ -1961,7 +1961,7 @@ async function cmdLanesLifecycle(action, args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes` — one row per lane: what it is, where it is in its pipeline, and
|
||||
* `agenthub workspaces` — one row per lane: what it is, where it is in its pipeline, and
|
||||
* whether the driving session is still breathing.
|
||||
*/
|
||||
/**
|
||||
@@ -1993,7 +1993,7 @@ function detectionLeadsDeclaration(l) {
|
||||
}
|
||||
|
||||
async function cmdLanes() {
|
||||
const { lanes, counts } = await get("/api/lanes");
|
||||
const { lanes, counts } = await get("/api/workspaces");
|
||||
if (!lanes.length) {
|
||||
console.log(
|
||||
"no workspaces yet — create one with: agenthub workspace add --cwd <path> --title <text>"
|
||||
@@ -2040,7 +2040,7 @@ function toolFromEnv() {
|
||||
*
|
||||
* Only the FIRST positional counts as an id, and only when it is all digits.
|
||||
* Scanning the whole argv for a number would swallow flag values —
|
||||
* `agenthub lanes logs web --tail 4096` would have addressed lane 4096.
|
||||
* `agenthub workspace logs web --tail 4096` would have addressed lane 4096.
|
||||
*
|
||||
* @param {string[]} args - Arguments after the subcommand.
|
||||
* @returns {Promise<{laneId: string|number, rest: string[]}|null>} null once an error is printed.
|
||||
@@ -2051,13 +2051,13 @@ async function resolveLaneArg(args) {
|
||||
return i > -1 ? args[i + 1] : undefined;
|
||||
};
|
||||
|
||||
const explicit = flagValue("lane") || flagValue("workspace");
|
||||
const explicit = flagValue("workspace");
|
||||
if (explicit) return { laneId: explicit, rest: args };
|
||||
if (args.length && /^\d+$/.test(args[0])) return { laneId: args[0], rest: args.slice(1) };
|
||||
|
||||
const tool = flagValue("tool") || toolFromEnv();
|
||||
const cwd = require("path").resolve(flagValue("cwd") || process.cwd());
|
||||
const { lanes } = await get("/api/lanes");
|
||||
const { lanes } = await get("/api/workspaces");
|
||||
|
||||
// A pane's AGENTHUB_LANE_ID beats cwd matching, but only after it is checked
|
||||
// against the live lane list AND the directory: a pane outlives the lane it
|
||||
@@ -2146,7 +2146,7 @@ function printRuntime(runtime) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes up|down|runtime|logs|hook` — a lane's own application stack, as
|
||||
* `agenthub workspace up|down|runtime|logs|hook` — a lane's own application stack, as
|
||||
* opposed to `start`/`stop`, which drive its Claude run. Two lifecycles, one
|
||||
* lane id.
|
||||
*
|
||||
@@ -2160,7 +2160,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
const { laneId, rest: laneArgs } = resolved;
|
||||
|
||||
if (sub === "runtime") {
|
||||
printRuntime(await get(`/api/lanes/${laneId}/runtime`));
|
||||
printRuntime(await get(`/api/workspaces/${laneId}/runtime`));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2168,7 +2168,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
const body = {};
|
||||
if (laneArgs.includes("--no-build")) body.build = false;
|
||||
if (laneArgs.includes("--qc")) body.qc = true;
|
||||
const result = await post(`/api/lanes/${laneId}/up`, body, { allowError: true });
|
||||
const result = await post(`/api/workspaces/${laneId}/up`, body, { allowError: true });
|
||||
if (result.status) {
|
||||
console.error(`✖ up workspace #${laneId} → ${result.data?.error?.message || result.status}`);
|
||||
process.exitCode = 1;
|
||||
@@ -2181,7 +2181,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
const deadline = Date.now() + 15 * 60 * 1000;
|
||||
for (;;) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const runtime = await get(`/api/lanes/${laneId}/runtime`);
|
||||
const runtime = await get(`/api/workspaces/${laneId}/runtime`);
|
||||
if (runtime.healthy) {
|
||||
printRuntime(runtime);
|
||||
return;
|
||||
@@ -2202,7 +2202,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
}
|
||||
|
||||
if (sub === "down") {
|
||||
const result = await post(`/api/lanes/${laneId}/down`, {}, { allowError: true });
|
||||
const result = await post(`/api/workspaces/${laneId}/down`, {}, { allowError: true });
|
||||
if (result.status) {
|
||||
console.error(
|
||||
`✖ down workspace #${laneId} → ${result.data?.error?.message || result.status}`
|
||||
@@ -2223,7 +2223,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
}
|
||||
const i = laneArgs.indexOf("--tail");
|
||||
const query = i > -1 && laneArgs[i + 1] ? `?tail=${encodeURIComponent(laneArgs[i + 1])}` : "";
|
||||
const log = await get(`/api/lanes/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
|
||||
const log = await get(`/api/workspaces/${laneId}/logs/${encodeURIComponent(svc)}${query}`);
|
||||
if (!log.available) {
|
||||
console.log("no logs for this workspace yet.");
|
||||
return;
|
||||
@@ -2241,7 +2241,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
return;
|
||||
}
|
||||
const result = await post(
|
||||
`/api/lanes/${laneId}/hook/${encodeURIComponent(name)}`,
|
||||
`/api/workspaces/${laneId}/hook/${encodeURIComponent(name)}`,
|
||||
{ args: laneArgs.slice(1) },
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2263,7 +2263,7 @@ async function cmdLanesRuntime(sub, args) {
|
||||
: "merge";
|
||||
const branch = laneArgs.find((arg) => !arg.startsWith("--"));
|
||||
const result = await post(
|
||||
`/api/lanes/${laneId}/sync-base`,
|
||||
`/api/workspaces/${laneId}/sync-base`,
|
||||
{ mode, branch },
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2342,7 +2342,7 @@ async function defaultHolder(argsAfterName) {
|
||||
|
||||
const resolved = await resolveLaneArg(argsAfterName);
|
||||
if (!resolved) return null;
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||
return lane.slot ? `lane${lane.slot}` : `lane${lane.id}`;
|
||||
}
|
||||
|
||||
@@ -2474,7 +2474,7 @@ function fmtFeatureRow(f) {
|
||||
async function cmdFeatureList(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const { features } = await get(`/api/lanes/${resolved.laneId}/features`);
|
||||
const { features } = await get(`/api/workspaces/${resolved.laneId}/features`);
|
||||
if (!features.length) {
|
||||
console.log("no features activated yet — start one with: agenthub feature activate <slug>");
|
||||
return;
|
||||
@@ -2496,7 +2496,7 @@ async function cmdFeatureActivate(args) {
|
||||
};
|
||||
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||
if (!resolved) return;
|
||||
const { lane, feature } = await post(`/api/lanes/${resolved.laneId}/features/activate`, {
|
||||
const { lane, feature } = await post(`/api/workspaces/${resolved.laneId}/features/activate`, {
|
||||
slug,
|
||||
title: flag("title"),
|
||||
});
|
||||
@@ -2506,13 +2506,13 @@ async function cmdFeatureActivate(args) {
|
||||
}
|
||||
|
||||
/** `agenthub feature show <slug> [<id>] [--cwd path]` — one feature's saved pipeline. */
|
||||
/** `agenthub lanes proof-link [<id>] [--cwd path]` — converge the clone-root
|
||||
/** `agenthub workspace proof-link [<id>] [--cwd path]` — converge the clone-root
|
||||
* `proof/` onto `.playwright-mcp/proof` (idempotent). Never run automatically
|
||||
* by anything else in this codebase; a session or hook calls it explicitly. */
|
||||
async function cmdLanesProofLink(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const { linked } = await post(`/api/lanes/${resolved.laneId}/proof-link`);
|
||||
const { linked } = await post(`/api/workspaces/${resolved.laneId}/proof-link`);
|
||||
console.log(
|
||||
linked
|
||||
? `${c.green("✔")} linked proof/ -> .playwright-mcp/proof`
|
||||
@@ -2520,13 +2520,13 @@ async function cmdLanesProofLink(args) {
|
||||
);
|
||||
}
|
||||
|
||||
/** `agenthub lanes mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
||||
/** `agenthub workspace mcp sync [<id>] [--cwd path]` — relocate the source repo's
|
||||
* already-configured MCP servers into <lane>/.mcp.json and seed Chromium
|
||||
* profiles. Never run automatically; a session calls it explicitly. */
|
||||
async function cmdLanesMcpSync(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const result = await post(`/api/lanes/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
|
||||
const result = await post(`/api/workspaces/${resolved.laneId}/mcp/sync`, {}, { allowError: true });
|
||||
if (result.status) {
|
||||
console.error(`✖ mcp sync → ${result.data?.error?.message || result.status}`);
|
||||
process.exitCode = 1;
|
||||
@@ -2538,7 +2538,7 @@ async function cmdLanesMcpSync(args) {
|
||||
}
|
||||
}
|
||||
|
||||
/** `agenthub lanes integration <name> [<id>]` — check whether a named
|
||||
/** `agenthub workspace integration <name> [<id>]` — check whether a named
|
||||
* integration (tracker, dev_qc, ci_wait) is turned on for this lane, per
|
||||
* its .agenthub/profile/integrations.env. Exit 0 = on, 1 = off. */
|
||||
async function cmdLanesIntegration(args) {
|
||||
@@ -2551,13 +2551,13 @@ async function cmdLanesIntegration(args) {
|
||||
const resolved = await resolveLaneArg(args.filter((a) => a !== name));
|
||||
if (!resolved) return;
|
||||
const { enabled } = await get(
|
||||
`/api/lanes/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
||||
`/api/workspaces/${resolved.laneId}/integrations/${encodeURIComponent(name)}`
|
||||
);
|
||||
console.log(enabled ? "on" : "off");
|
||||
process.exitCode = enabled ? 0 : 1;
|
||||
}
|
||||
|
||||
/** `agenthub lanes ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
|
||||
/** `agenthub workspace ci status <branch-or-sha> [<lane-id>]` — one-shot GitHub
|
||||
* Actions status snapshot for a ref, via GET /:id/ci/status. Never polls;
|
||||
* the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code
|
||||
* mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */
|
||||
@@ -2576,7 +2576,7 @@ async function cmdLanesCi(args) {
|
||||
const resolved = await resolveLaneArg(args.slice(2));
|
||||
if (!resolved) return;
|
||||
const result = await get(
|
||||
`/api/lanes/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
|
||||
`/api/workspaces/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`,
|
||||
undefined,
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2590,7 +2590,7 @@ async function cmdLanesCi(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
|
||||
* `agenthub workspace node [<node-id>] [extra…] [<id>] [--print]` — run ONE node of the
|
||||
* lane's pipeline instead of the whole thing.
|
||||
*
|
||||
* Every node already carries the one command that drives it (`command`, added
|
||||
@@ -2599,7 +2599,7 @@ async function cmdLanesCi(args) {
|
||||
* pasted it into a session by hand. This runs it, in the lane's own cwd:
|
||||
*
|
||||
* - a slash command starts `claude` with it as the prompt;
|
||||
* - anything else (`npm test`, `agenthub lanes hook ci-gate`) is spawned directly.
|
||||
* - anything else (`npm test`, `agenthub workspace hook ci-gate`) is spawned directly.
|
||||
*
|
||||
* With no node id it lists the nodes and their commands, which is also the
|
||||
* error path's answer. `--print` shows the argv and runs nothing.
|
||||
@@ -2621,10 +2621,10 @@ async function cmdLanesNode(args) {
|
||||
const { flags, positional } = parseArgs(args);
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||
// The lane payload's `pipeline_nodes` carries render state but not `aliases`;
|
||||
// the template does. Merge, so the listing can still mark the current node.
|
||||
const { pipelines } = await get("/api/lanes/pipelines");
|
||||
const { pipelines } = await get("/api/workspaces/pipelines");
|
||||
const stateOf = new Map((lane.pipeline_nodes || []).map((n) => [n.id, n.state]));
|
||||
const nodes = (pipelines.find((p) => p.id === lane.pipeline)?.nodes || []).map((n) => ({
|
||||
...n,
|
||||
@@ -2699,8 +2699,8 @@ async function cmdLanesNode(args) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `agenthub lanes pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
||||
* template a lane renders against. `agenthub lanes add --pipeline` could only set
|
||||
* `agenthub workspace pipeline [<template-id>] [<id>]` — show or switch which pipeline
|
||||
* template a lane renders against. `agenthub workspace add --pipeline` could only set
|
||||
* this at creation time, so every lane added from the dashboard's "+ Add lane"
|
||||
* was stuck on `default` with no way back to a 16-node template.
|
||||
*/
|
||||
@@ -2710,15 +2710,15 @@ async function cmdLanesPipeline(args) {
|
||||
if (!resolved) return;
|
||||
|
||||
if (!target) {
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { pipelines } = await get("/api/lanes/pipelines");
|
||||
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||
const { pipelines } = await get("/api/workspaces/pipelines");
|
||||
console.log(`workspace #${lane.id} → ${lane.pipeline} (${lane.pipeline_nodes.length} nodes)`);
|
||||
if (lane.pipeline_skill) console.log(`driver: ${lane.pipeline_skill} <requirement>`);
|
||||
console.log(`available: ${pipelines.map((p) => `${p.id} (${p.nodes.length})`).join(", ")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { lane } = await patch(`/api/lanes/${resolved.laneId}`, { pipeline: target });
|
||||
const { lane } = await patch(`/api/workspaces/${resolved.laneId}`, { pipeline: target });
|
||||
console.log(
|
||||
`${c.green("✔")} workspace #${lane.id} → pipeline ${lane.pipeline} ` +
|
||||
`(${lane.pipeline_nodes.length} nodes, stage ${lane.stage}, ${lane.progress}%)`
|
||||
@@ -2746,7 +2746,7 @@ async function cmdLanesPipeline(args) {
|
||||
async function cmdLanesShell(args) {
|
||||
const resolved = await resolveLaneArg(args);
|
||||
if (!resolved) return;
|
||||
const { lane } = await get(`/api/lanes/${resolved.laneId}`);
|
||||
const { lane } = await get(`/api/workspaces/${resolved.laneId}`);
|
||||
const sessionName = `agenthub-lane-${lane.id}`;
|
||||
const child = spawn("tmux", ["new-session", "-A", "-s", sessionName, "-c", lane.cwd], {
|
||||
stdio: "inherit",
|
||||
@@ -2771,7 +2771,7 @@ async function cmdFeatureShow(args) {
|
||||
const resolved = await resolveLaneArg(args.filter((a) => a !== slug));
|
||||
if (!resolved) return;
|
||||
const result = await get(
|
||||
`/api/lanes/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
||||
`/api/workspaces/${resolved.laneId}/features/${encodeURIComponent(slug)}`,
|
||||
undefined,
|
||||
{ allowError: true }
|
||||
);
|
||||
@@ -2806,7 +2806,7 @@ async function cmdStage(args) {
|
||||
if (!resolved) return;
|
||||
const laneId = resolved.laneId;
|
||||
|
||||
const { lane } = await post(`/api/lanes/${laneId}/stage`, {
|
||||
const { lane } = await post(`/api/workspaces/${laneId}/stage`, {
|
||||
stage,
|
||||
status: flag("status"),
|
||||
evidence: flag("evidence"),
|
||||
@@ -3101,8 +3101,8 @@ falling back to http://127.0.0.1:4820.
|
||||
${c.bold("Output:")} colors auto-enable on a TTY and turn off when piped.
|
||||
Disable with --no-color or NO_COLOR=1; force with FORCE_COLOR=1 / AGENTHUB_COLOR=1.
|
||||
|
||||
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} still work — the same
|
||||
commands as ${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
|
||||
${c.bold("Old name:")} ${c.bold("agenthub lanes …")} and ${c.bold("--lane")} were removed — use
|
||||
${c.bold("agenthub workspace …")} and ${c.bold("--workspace")}.
|
||||
|
||||
${c.bold("Note:")} agenthub talks to the local dashboard server — API-backed commands
|
||||
require it to be running (${c.bold("agenthub start")} brings one up in the background).
|
||||
@@ -3512,8 +3512,8 @@ function replHelp() {
|
||||
/** Detailed help for one command (or command prefix): every catalog entry
|
||||
* whose invocation starts with the token. Returns false if none matched. */
|
||||
function commandHelp(token) {
|
||||
// `lanes`, `workspace` and `workspaces` are one command (COMMAND_ALIASES),
|
||||
// so `help lanes` finds the `workspace …` rows and vice versa.
|
||||
// `workspace` and `workspaces` are one command (COMMAND_ALIASES), so
|
||||
// `help workspaces` finds the `workspace …` rows and vice versa.
|
||||
const canon = (words) => {
|
||||
const [head, ...tail] = words.split(" ");
|
||||
return [COMMAND_ALIASES[head] || head, ...tail].join(" ");
|
||||
@@ -3774,23 +3774,22 @@ async function cmdRepl() {
|
||||
// prompt. Handlers that need the server throw ServerDownError; the caller
|
||||
// decides whether to fall back to an offline reader or report the refusal.
|
||||
|
||||
/** Command names that are another command under the user-facing word.
|
||||
* `workspace` is the name to use; `lanes` (the handler's own name) and
|
||||
* `--lane` are the OLD names, still working but deprecated. Removing them is
|
||||
* step 4 of docs/plans/workspace-rename/README.md — not before the third
|
||||
* release after the first one that shipped these aliases. */
|
||||
const COMMAND_ALIASES = { workspace: "lanes", workspaces: "lanes" };
|
||||
/** Command names that are another command under the user-facing word. */
|
||||
const COMMAND_ALIASES = { workspaces: "workspace" };
|
||||
const canonicalArgv = (argv) =>
|
||||
COMMAND_ALIASES[argv[0]] ? [COMMAND_ALIASES[argv[0]], ...argv.slice(1)] : argv;
|
||||
|
||||
/** One stderr line when an old name is typed, so people and agents move over
|
||||
* before step 4 removes it. stderr only: scripts reading stdout see nothing. */
|
||||
function warnOldNames(argv) {
|
||||
const old = [];
|
||||
if (argv[0] === "lanes") old.push("`agenthub lanes` → `agenthub workspace`");
|
||||
if (argv.includes("--lane")) old.push("`--lane` → `--workspace`");
|
||||
if (old.length) {
|
||||
console.error(c.dim(`note: old name, still works for now — use ${old.join(", ")}`));
|
||||
/** The old names `agenthub workspace` and `--lane` were removed in step 4 of
|
||||
* docs/plans/workspace-rename/README.md. They exit 1 naming the new spelling
|
||||
* — never "Unknown command" or help, so a script written before says why. */
|
||||
function refuseOldNames(argv) {
|
||||
if (argv[0] === "lanes") {
|
||||
console.error(c.red("✖ renamed: use agenthub workspace …"));
|
||||
process.exit(1);
|
||||
}
|
||||
if (argv.includes("--lane")) {
|
||||
console.error(c.red("✖ renamed: use --workspace <id>"));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3803,7 +3802,6 @@ const COMMANDS = (() => {
|
||||
for (const [name] of items) set.add(name.split(" ")[0]);
|
||||
}
|
||||
set.add("help");
|
||||
// The old names stay typeable (and completable) for scripts written before.
|
||||
for (const [alias, canonical] of Object.entries(COMMAND_ALIASES)) set.add(alias).add(canonical);
|
||||
return [...set];
|
||||
})();
|
||||
@@ -4498,7 +4496,7 @@ async function cmdAccounts(sub, positional, flags = {}) {
|
||||
* for usage/validation failures. `cmd === "repl"` is handled by the entry
|
||||
* points, not here, to avoid recursive REPLs. */
|
||||
async function runCommand(argv) {
|
||||
warnOldNames(argv);
|
||||
refuseOldNames(argv);
|
||||
const [cmd, ...rest] = canonicalArgv(argv);
|
||||
const { flags, positional } = parseArgs(rest);
|
||||
switch (cmd) {
|
||||
@@ -4571,7 +4569,7 @@ async function runCommand(argv) {
|
||||
return cmdReinstallHooks();
|
||||
case "update-check":
|
||||
return cmdUpdateCheck();
|
||||
case "lanes":
|
||||
case "workspace":
|
||||
if (rest[0] === "add") {
|
||||
return cmdLanesAdd(rest.slice(1));
|
||||
}
|
||||
|
||||
+3
-3
@@ -49,8 +49,8 @@ Enterprise-grade React + TypeScript dashboard for real-time Claude Code agent mo
|
||||
|
||||
The client is a single-page application (SPA) built with modern web technologies:
|
||||
|
||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **React 19** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 7** - Full type safety across components, utilities, and API contracts
|
||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
|
||||
|
||||
@@ -1215,7 +1215,7 @@ graph TB
|
||||
|
||||
The client is a production-ready React application with:
|
||||
|
||||
- 🚀 **Modern Stack** - React 18, TypeScript, Vite, Tailwind
|
||||
- 🚀 **Modern Stack** - React 19, TypeScript, Vite, Tailwind
|
||||
- ⚡ **Real-time** - WebSocket integration for live updates
|
||||
- 🧪 **Tested** - Vitest + React Testing Library
|
||||
- 📦 **Optimized** - Code splitting, tree shaking, lazy loading
|
||||
|
||||
Generated
+427
-139
@@ -15,18 +15,18 @@
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/maple-mono": "^5.3.0",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"d3": "^7.9.0",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"lucide-react": "^1.61.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
@@ -34,17 +34,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^27.0.1",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
@@ -450,10 +450,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/merge": {
|
||||
"version": "6.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
|
||||
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
|
||||
"license": "MIT",
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.3.tgz",
|
||||
"integrity": "sha512-Wq9wXrp41cW/nIpi1CQPyNwD+36kg8KIX676ym0Hyfn3Dpr+XQHNAIsZYVNlaivuVB4SAASbreG3V0VXinbqZA==",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
@@ -478,10 +477,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.14",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
|
||||
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
|
||||
"license": "MIT",
|
||||
"version": "6.43.15",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz",
|
||||
"integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
@@ -1661,7 +1659,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
|
||||
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
@@ -2108,30 +2105,21 @@
|
||||
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"license": "MIT",
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
||||
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
|
||||
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
"@types/react": "^19.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/unist": {
|
||||
@@ -2140,6 +2128,326 @@
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-loong64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-s390x": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-sunos-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ungap/structured-clone": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
|
||||
@@ -2282,59 +2590,38 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-canvas": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz",
|
||||
"integrity": "sha512-LF5LYcfvefJuJ7QotNRdRSPc9YASAVDeoT5uyXS/nZshZXjYplGXRECBGiznwvhNL2I8bq1Lf5MzRwstsYQ2Iw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-clipboard": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.1.0.tgz",
|
||||
"integrity": "sha512-zdoM7p53T5sv/HbRTyp4hY0kKmEQ3MZvAvEtiXqNIHc/JdpqwByCtsTaQF5DX2n4hYdXRPO4P/eOS0QEhX1nPw==",
|
||||
"license": "MIT",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.2.0.tgz",
|
||||
"integrity": "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==",
|
||||
"dependencies": {
|
||||
"js-base64": "^3.7.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="
|
||||
},
|
||||
"node_modules/@xterm/addon-search": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.15.0.tgz",
|
||||
"integrity": "sha512-ZBZKLQ+EuKE83CqCmSSz5y1tx+aNOCUaA7dm6emgOX+8J9H1FWXZyrKfzjwzV+V14TV3xToz1goIeRhXBS5qjg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz",
|
||||
"integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA=="
|
||||
},
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz",
|
||||
"integrity": "sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="
|
||||
},
|
||||
"node_modules/@xterm/addon-webgl": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz",
|
||||
"integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A=="
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
@@ -3706,9 +3993,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||
"version": "26.5.11",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.11.tgz",
|
||||
"integrity": "sha512-Wc0klBPKxqZ2tL2qdAnF8xvvnWgr1IMGSY2ikTiAvaFVbz7xiFARtkoU4RidBDw1SyziGROp0qxu6qnJ7l2Wug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -3723,7 +4010,6 @@
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
@@ -3734,12 +4020,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
||||
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
||||
"license": "MIT",
|
||||
"version": "8.2.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.3.tgz",
|
||||
"integrity": "sha512-0jSTK3ncpXj9MFdxRz+TVTKDkSvYH8ifB/OCmUdEMprvXC3vy7kEvdKpj0yYB72B0UKEHJLPMEeRVC/UXroWnw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
"@babel/runtime": "^7.29.2"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
@@ -3924,6 +4209,7 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
@@ -4022,18 +4308,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
@@ -4052,12 +4326,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.474.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.474.0.tgz",
|
||||
"integrity": "sha512-CmghgHkh0OJNmxGKWc0qfPJCYHASPMVSyGY8fj3xgk4v84ItqDg64JNKFZn5hC6E0vHi6gxnbCgwhyVB09wQtA==",
|
||||
"license": "ISC",
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
|
||||
"integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
@@ -5378,35 +5657,28 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"version": "19.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
|
||||
"integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"version": "19.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz",
|
||||
"integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
"scheduler": "^0.28.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
"react": "^19.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.16",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.16.tgz",
|
||||
"integrity": "sha512-7uImVD9hG5yUOaP5KHVied1x0A004Di/KU031ULR35Ma+9e9H5GdnNZHgQEIByyAz+IAXBkRDTedNVzjPUH8ZA==",
|
||||
"license": "MIT",
|
||||
"version": "17.1.9",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.9.tgz",
|
||||
"integrity": "sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"html-parse-stringify": "^4.0.1",
|
||||
@@ -5755,13 +6027,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
"version": "0.28.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
|
||||
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
@@ -5774,9 +6042,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz",
|
||||
"integrity": "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg=="
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
@@ -6183,17 +6451,37 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
"tsc": "bin/tsc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
"node": ">=16.20.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||
"@typescript/typescript-linux-arm": "7.0.2",
|
||||
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||
"@typescript/typescript-linux-x64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/unified": {
|
||||
|
||||
+13
-13
@@ -18,18 +18,18 @@
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/maple-mono": "^5.3.0",
|
||||
"@xterm/addon-canvas": "^0.7.0",
|
||||
"@xterm/addon-clipboard": "^0.1.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-clipboard": "^0.2.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-search": "^0.16.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"d3": "^7.9.0",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"lucide-react": "^1.61.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-i18next": "^17.0.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.18.4",
|
||||
@@ -37,17 +37,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^27.0.1",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^6.1.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export const SagiAvatar = memo(function SagiAvatar({
|
||||
moment = null,
|
||||
}: SagiAvatarProps) {
|
||||
const rootRef = useRef<SVGSVGElement | null>(null);
|
||||
const rafRef = useRef<number>();
|
||||
const rafRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// Pupils follow the cursor whenever motion is allowed and the eyes are open.
|
||||
// (Only `sleeping` closes them by intent; `disconnected` hides the open-eye
|
||||
|
||||
@@ -197,11 +197,11 @@ export function useSagiBrain(): SagiBrain {
|
||||
const [bubble, setBubbleState] = useState<SagiBubble | null>(null);
|
||||
const [moment, setMoment] = useState<SagiBrain["moment"]>(null);
|
||||
const [reaction, setReaction] = useState<SagiBrain["reaction"]>(null);
|
||||
const reactionTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const reactionTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const [muted, setMuted] = useState<boolean>(() => sagiPrefs.getMuted());
|
||||
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const momentTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const bubbleTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const momentTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const lastBubbleAt = useRef(0);
|
||||
const burst = useRef<Burst | null>(null);
|
||||
const momentKey = useRef(0);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* @file SessionToolBadge — names the AI coding CLI that produced a session
|
||||
* when it is not Claude Code (`sessions.tool` = `codex`, `antigravity`, …, by
|
||||
* its AI Tools label). Renders nothing for a Claude session, so the Sessions
|
||||
* list and session header look as before for every Claude row.
|
||||
* its AI Tools label). Renders nothing for a Claude session, so the session
|
||||
* header looks as before for every Claude row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ function QuotaChips() {
|
||||
const [open, setOpen] = useState(false);
|
||||
// Leaving the chips closes the panel only after a beat, and entering the
|
||||
// panel cancels that: the pointer crosses the gap to read it.
|
||||
const closeTimer = useRef<number>();
|
||||
const closeTimer = useRef<number | undefined>(undefined);
|
||||
const show = () => {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
setOpen(true);
|
||||
|
||||
@@ -260,9 +260,8 @@ export function UpdateNotifier() {
|
||||
// poll that reloads the page keep running, and a failure re-opens it.
|
||||
const closeForNow = useCallback(() => {
|
||||
if (!dismissKey) return;
|
||||
if (applying || restarting) toast.info(t("backgroundNote"));
|
||||
setClosedKey(dismissKey);
|
||||
}, [applying, restarting, dismissKey, t]);
|
||||
}, [dismissKey]);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
if (busy || !dismissKey) return;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AgentCard } from "../AgentCard";
|
||||
import type { Agent } from "../../lib/types";
|
||||
import { formatModelName, fmtCost } from "../../lib/format";
|
||||
|
||||
function renderCard(element: JSX.Element) {
|
||||
function renderCard(element: React.JSX.Element) {
|
||||
return render(<MemoryRouter>{element}</MemoryRouter>);
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ import { api } from "../../lib/api";
|
||||
import { StatusBar } from "../StatusBar";
|
||||
import { LIVE_RUNS_EVENT } from "../WorkspaceTree";
|
||||
|
||||
const at = (url: string, ui: JSX.Element) =>
|
||||
const at = (url: string, ui: React.JSX.Element) =>
|
||||
render(<MemoryRouter initialEntries={[url]}>{ui}</MemoryRouter>);
|
||||
|
||||
describe("StatusBar", () => {
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* @file MiniChart.tsx
|
||||
* @description The Administration page's small single-series charts: a bar
|
||||
* chart (a user's daily spend) and a line chart (System Health's CPU and
|
||||
* memory history), plain SVG in the app's theme tokens. One series each — the
|
||||
* card's title names it, so there is no legend — thin marks, rounded bar ends
|
||||
* on the baseline, a recessive grid with a max label, and a hover tooltip
|
||||
* (crosshair for the line, per bar for the bars) whose text is the caller's.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useRef, useState, type MouseEvent } from "react";
|
||||
|
||||
export type ChartPoint = { x: string; y: number };
|
||||
|
||||
const H = 120;
|
||||
const PAD_TOP = 14;
|
||||
const PAD_BOTTOM = 18;
|
||||
|
||||
/**
|
||||
* @param props.points - In x order; `x` is the axis/tooltip label.
|
||||
* @param props.kind - `bar` or `line`.
|
||||
* @param props.format - Formats a value for the max label and the tooltip.
|
||||
* @param props.max - A fixed top (e.g. 100 for a percentage); else the data's max.
|
||||
* @param props.label - The chart's accessible name.
|
||||
*/
|
||||
export function MiniChart({
|
||||
points,
|
||||
kind,
|
||||
format,
|
||||
max,
|
||||
label,
|
||||
emptyText,
|
||||
}: {
|
||||
points: ChartPoint[];
|
||||
kind: "bar" | "line";
|
||||
format: (v: number) => string;
|
||||
max?: number;
|
||||
label: string;
|
||||
emptyText?: string;
|
||||
}) {
|
||||
const ref = useRef<SVGSVGElement>(null);
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
if (points.length === 0)
|
||||
return (
|
||||
<div className="flex h-[120px] items-center justify-center text-xs text-fg-muted">
|
||||
{emptyText ?? "—"}
|
||||
</div>
|
||||
);
|
||||
|
||||
const W = 600;
|
||||
const top = Math.max(max ?? 0, ...points.map((p) => p.y), 1e-9);
|
||||
const plotH = H - PAD_TOP - PAD_BOTTOM;
|
||||
const yOf = (v: number) => PAD_TOP + plotH - (v / top) * plotH;
|
||||
const step = W / points.length;
|
||||
const xOf = (i: number) =>
|
||||
kind === "bar" ? i * step + step / 2 : (i / Math.max(1, points.length - 1)) * W;
|
||||
|
||||
const onMove = (e: MouseEvent<SVGSVGElement>) => {
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (!r || r.width === 0) return;
|
||||
const fx = ((e.clientX - r.left) / r.width) * W;
|
||||
const i =
|
||||
kind === "bar"
|
||||
? Math.floor(fx / step)
|
||||
: Math.round((fx / W) * Math.max(1, points.length - 1));
|
||||
setHover(Math.max(0, Math.min(points.length - 1, i)));
|
||||
};
|
||||
|
||||
const h = hover != null ? points[hover] : null;
|
||||
const barW = Math.max(2, Math.min(24, step - 2));
|
||||
const line = points
|
||||
.map((p, i) => `${i ? "L" : "M"}${xOf(i).toFixed(1)},${yOf(p.y).toFixed(1)}`)
|
||||
.join("");
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg
|
||||
ref={ref}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="block h-[120px] w-full"
|
||||
role="img"
|
||||
aria-label={label}
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{[0, 0.5, 1].map((f) => (
|
||||
<line
|
||||
key={f}
|
||||
x1={0}
|
||||
x2={W}
|
||||
y1={yOf(top * f)}
|
||||
y2={yOf(top * f)}
|
||||
className="stroke-border"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeDasharray={f === 0 ? undefined : "3 3"}
|
||||
/>
|
||||
))}
|
||||
{kind === "bar" ? (
|
||||
points.map((p, i) => {
|
||||
const y = yOf(p.y);
|
||||
const bh = Math.max(0, PAD_TOP + plotH - y);
|
||||
const r = Math.min(4, barW / 2, bh);
|
||||
const x = xOf(i) - barW / 2;
|
||||
const base = PAD_TOP + plotH;
|
||||
// Rounded top only; the base sits flat on the axis.
|
||||
const d =
|
||||
bh <= 0
|
||||
? ""
|
||||
: `M${x},${base}V${y + r}Q${x},${y} ${x + r},${y}H${x + barW - r}Q${x + barW},${y} ${x + barW},${y + r}V${base}Z`;
|
||||
return (
|
||||
<path key={i} d={d} className={hover === i ? "fill-accent" : "fill-accent-solid"} />
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<path
|
||||
d={`${line}L${W},${PAD_TOP + plotH}L0,${PAD_TOP + plotH}Z`}
|
||||
className="fill-accent-solid"
|
||||
opacity={0.12}
|
||||
/>
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
className="stroke-accent-solid"
|
||||
strokeWidth={2}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{hover != null && (
|
||||
<line
|
||||
x1={xOf(hover)}
|
||||
x2={xOf(hover)}
|
||||
y1={PAD_TOP}
|
||||
y2={PAD_TOP + plotH}
|
||||
className="stroke-fg-muted"
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
<span className="pointer-events-none absolute left-0 top-0 text-[10px] text-fg-muted tabular-nums">
|
||||
{format(top)}
|
||||
</span>
|
||||
<div className="pointer-events-none flex justify-between text-[10px] text-fg-muted">
|
||||
<span>{points[0]!.x}</span>
|
||||
<span>{points[points.length - 1]!.x}</span>
|
||||
</div>
|
||||
{h && hover != null && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 whitespace-nowrap rounded-md border border-border bg-surface-1 px-2 py-1 text-[11px] shadow-lg"
|
||||
style={{ left: `${Math.min(92, Math.max(8, (xOf(hover) / W) * 100))}%` }}
|
||||
role="status"
|
||||
>
|
||||
<div className="text-fg-muted">{h.x}</div>
|
||||
<div className="font-medium text-fg-primary tabular-nums">{format(h.y)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
/**
|
||||
* @file ServerPanel.tsx
|
||||
* @description The Administration page's Server tab: the machine AgentHub
|
||||
* runs on — hostname, OS, uptime, CPU and its load, memory and swap, every
|
||||
* real disk (HDD/SSD, how full), any NVIDIA GPU — and each user's live share
|
||||
* of it (`GET /api/host`, server/lib/host-stats.js): CPU % of the whole
|
||||
* machine, resident memory, process count, the busiest processes by name, and
|
||||
* the disk their home takes (`GET /api/users/storage`, as a share of the disk
|
||||
* that holds the homes). Re-read every `POLL_MS` while shown; Refresh re-reads
|
||||
* at once. Read-only — the only action here is the members' tmux card
|
||||
* (`MemberTmuxCard`, an admin's restart).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
MonitorCog,
|
||||
RefreshCw,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type HostStats,
|
||||
type HostUsageGroup,
|
||||
type ServerUserUsage,
|
||||
type UserStorage,
|
||||
} from "../../lib/api";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { StatCard } from "../StatCard";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
import { MemberTmuxCard } from "../users/MemberTmuxCard";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
|
||||
const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||
|
||||
/** A thin bar, amber past 75 %, red past 90 %. */
|
||||
function Meter({ value, label }: { value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
|
||||
return (
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
|
||||
>
|
||||
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return d > 0 ? t("server.uptimeDays", { d, h }) : t("server.uptimeHours", { h, m });
|
||||
}
|
||||
|
||||
export function ServerPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("admin");
|
||||
const [stats, setStats] = useState<HostStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
|
||||
const [open, setOpen] = useState<Set<string>>(new Set());
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.stats()
|
||||
.then((s) => {
|
||||
setStats(s);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
}, POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
// Names, pictures and home sizes: read once, they change slowly.
|
||||
useEffect(() => {
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => setUsers(r.users))
|
||||
.catch(() => setUsers([]));
|
||||
api.users
|
||||
.storage()
|
||||
.then((r) => setStorage(r.storage))
|
||||
.catch(() => setStorage({}));
|
||||
}, []);
|
||||
|
||||
const byId = new Map((users ?? []).map((u) => [u.id, u]));
|
||||
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
|
||||
const loading = !stats && !error;
|
||||
const mem = stats?.memory;
|
||||
const toggle = (key: string) =>
|
||||
setOpen((s) => {
|
||||
const n = new Set(s);
|
||||
if (n.has(key)) n.delete(key);
|
||||
else n.add(key);
|
||||
return n;
|
||||
});
|
||||
|
||||
const groupName = (g: HostUsageGroup) => {
|
||||
if (g.kind === "host") return t("server.hostGroup");
|
||||
if (g.kind === "system") return t("server.systemGroup");
|
||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||
return u?.displayName || g.username || "";
|
||||
};
|
||||
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="server-panel">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
|
||||
<Server className="w-4 h-4 text-accent" />
|
||||
<span className="truncate">{stats?.hostname ?? t("server.title")}</span>
|
||||
</h2>
|
||||
{stats && (
|
||||
<p className="mt-0.5 text-xs text-fg-muted">
|
||||
{[stats.os.distro ?? stats.os.platform, stats.os.release, stats.os.arch].join(" · ")}
|
||||
{" · "}
|
||||
{t("server.uptime", { v: uptimeText(t, stats.uptime) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
className="btn-ghost"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
|
||||
{t("common:refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="card flex flex-wrap items-center gap-3 p-3 border-status-danger/40 text-sm text-status-danger"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">{error}</span>
|
||||
<button type="button" onClick={refresh} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
label={t("server.cpu")}
|
||||
value={stats?.usage ? fmtPct(stats.usage.cpu) : "—"}
|
||||
icon={Cpu}
|
||||
trend={
|
||||
stats
|
||||
? t("server.cores", {
|
||||
n: stats.cpu.cores,
|
||||
load: stats.cpu.load.map((l) => l.toFixed(2)).join(" "),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
raw={stats?.cpu.model ?? undefined}
|
||||
help={t("server.cpuHelp")}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.memory")}
|
||||
value={mem ? fmtPct(pct(mem.used, mem.total)) : "—"}
|
||||
icon={MemoryStick}
|
||||
trend={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.swap")}
|
||||
value={mem && mem.swapTotal ? fmtPct(pct(mem.swapUsed, mem.swapTotal)) : "—"}
|
||||
icon={MonitorCog}
|
||||
trend={mem ? `${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}` : undefined}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("server.homesDisk")}
|
||||
value={homesDisk ? fmtPct(pct(homesDisk.used, homesDisk.total)) : "—"}
|
||||
icon={HardDrive}
|
||||
trend={
|
||||
homesDisk
|
||||
? `${formatBytes(homesDisk.free)} ${t("server.free")} · ${homesDisk.mount}`
|
||||
: undefined
|
||||
}
|
||||
help={t("server.homesDiskHelp")}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<div className="px-5 pt-4 pb-2">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.usageTitle")}</h3>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("server.usageHelp", { s: (stats?.usage?.sampleMs ?? 1000) / 1000 })}
|
||||
</p>
|
||||
</div>
|
||||
{stats && !stats.usage ? (
|
||||
<p className="px-5 pb-4 text-sm text-fg-muted">{t("server.usageUnavailable")}</p>
|
||||
) : (
|
||||
<table className="w-full min-w-[760px]" data-testid="server-usage-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("server.who")}</th>
|
||||
<th className={`${th} w-[18%]`}>{t("server.cpu")}</th>
|
||||
<th className={`${th} w-[22%]`}>{t("server.memory")}</th>
|
||||
<th className={`${th} text-right`}>{t("server.processes")}</th>
|
||||
<th className={`${th} w-[18%]`}>{t("server.home")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-6 text-sm text-fg-muted">
|
||||
{t("server.sampling")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{stats?.usage?.groups.map((g) => {
|
||||
const u = g.userId != null ? byId.get(g.userId) : undefined;
|
||||
const home = g.userId != null && storage ? storage[g.userId] : undefined;
|
||||
const isOpen = open.has(g.key);
|
||||
const name = groupName(g);
|
||||
return (
|
||||
<Fragment key={g.key}>
|
||||
<tr className="hover:bg-surface-4/40 transition-colors">
|
||||
<td className="px-5 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
{g.top.length ? (
|
||||
isOpen ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-fg-muted" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-fg-muted" />
|
||||
)
|
||||
) : (
|
||||
<span className="w-3.5" />
|
||||
)}
|
||||
{g.kind === "user" ? (
|
||||
<UserAvatar
|
||||
name={name}
|
||||
avatarAt={u?.avatarAt}
|
||||
userId={g.userId}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-surface-4">
|
||||
<Server className="w-3.5 h-3.5 text-fg-muted" />
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm text-fg-primary truncate">{name}</span>
|
||||
{g.kind === "user" && u?.displayName && (
|
||||
<span className="block text-[11px] text-fg-muted">{g.username}</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">{fmtPct(g.cpu)}</div>
|
||||
<Meter value={g.cpu} label={`${name} ${t("server.cpu")}`} />
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(g.memory)}{" "}
|
||||
<span className="text-fg-muted">({fmtPct(g.memoryPercent)})</span>
|
||||
</div>
|
||||
<Meter value={g.memoryPercent} label={`${name} ${t("server.memory")}`} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||
{g.processes}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
{home ? (
|
||||
<>
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(home.bytes)}
|
||||
{homesDisk && (
|
||||
<span className="text-fg-muted">
|
||||
{" "}
|
||||
({fmtPct(pct(home.bytes, homesDisk.total))})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{homesDisk && (
|
||||
<Meter
|
||||
value={pct(home.bytes, homesDisk.total)}
|
||||
label={`${name} ${t("server.home")}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-fg-muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isOpen &&
|
||||
g.top.map((p) => (
|
||||
<tr key={`${g.key}:${p.pid}`} className="bg-surface-2/40">
|
||||
<td className="pl-14 pr-5 py-1.5 text-xs text-fg-secondary font-mono truncate">
|
||||
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
||||
</td>
|
||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||
{fmtPct(p.cpu)}
|
||||
</td>
|
||||
<td className="px-5 py-1.5 text-xs text-fg-secondary tabular-nums">
|
||||
{formatBytes(p.memory)}
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats && stats.disks.length > 0 && (
|
||||
<div className="card overflow-x-auto">
|
||||
<h3 className="px-5 pt-4 pb-2 text-sm font-semibold text-fg-primary">
|
||||
{t("server.disks")}
|
||||
</h3>
|
||||
<table className="w-full min-w-[640px]" data-testid="server-disks-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("server.mount")}</th>
|
||||
<th className={th}>{t("server.device")}</th>
|
||||
<th className={`${th} w-[30%]`}>{t("server.used")}</th>
|
||||
<th className={`${th} text-right`}>{t("server.free")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{stats.disks.map((d) => (
|
||||
<tr key={d.device}>
|
||||
<td className="px-5 py-3 text-sm text-fg-primary">
|
||||
<span className="font-mono">{d.mount}</span>
|
||||
{d.mount === stats.homesMount && (
|
||||
<span className="badge ml-2 py-0 text-[10px]">{t("server.homesBadge")}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-xs text-fg-secondary">
|
||||
<span className="font-mono">{d.device}</span> · {d.type}
|
||||
{d.kind && <span className="badge ml-2 py-0 text-[10px]">{d.kind}</span>}
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="text-sm text-fg-primary tabular-nums">
|
||||
{formatBytes(d.used)} / {formatBytes(d.total)}{" "}
|
||||
<span className="text-fg-muted">({fmtPct(pct(d.used, d.total))})</span>
|
||||
</div>
|
||||
<Meter value={pct(d.used, d.total)} label={d.mount} />
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm text-fg-secondary tabular-nums">
|
||||
{formatBytes(d.free)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats && stats.gpus.length > 0 && (
|
||||
<div className="card p-5 space-y-3">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("server.gpus")}</h3>
|
||||
{stats.gpus.map((g, i) => (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-fg-primary">{g.name}</span>
|
||||
<span className="text-fg-muted tabular-nums">
|
||||
{fmtPct(g.utilization)} · {formatBytes(g.memoryUsed)} /{" "}
|
||||
{formatBytes(g.memoryTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<Meter value={g.utilization} label={g.name} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MemberTmuxCard isAdmin={isAdmin} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
/**
|
||||
* @file HostHealth.tsx
|
||||
* @description The machine AgentHub runs on, as part of the Dashboard's
|
||||
* System Health tab (`GET /api/host`, server/lib/host-stats.js — every
|
||||
* signed-in user sees all of it). In the tab's own card style:
|
||||
* `HostCard` — hostname, OS, uptime, CPU model and load, the AgentHub process
|
||||
* (pid, uptime, Node RSS) and bars for CPU, memory, swap and the V8 heap;
|
||||
* `HostSections` — the tab's whole "machine" group: `HostCard` beside the
|
||||
* CPU / memory history (`GET /api/host/history`, kept in memory since the
|
||||
* server started); then CPU (every thread as a compact cell, temperatures,
|
||||
* GPUs), disks (how full, read / write rate) with network interfaces, and the
|
||||
* busiest processes with who each counts for; and — server mode — each user's
|
||||
* live CPU / RAM share (a tile per user; click for their busiest processes). Also exports `useHostStats` (a poll every `pollMs` while
|
||||
* the tab is visible; 0 = read once), `Meter`, `pct` and `fmtPct`, which the
|
||||
* Administration users table shares for each home's share of the homes disk.
|
||||
* Read-only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ComponentType, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Activity,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
ListTree,
|
||||
Network,
|
||||
Server,
|
||||
Thermometer,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
api,
|
||||
type HostHistory,
|
||||
type HostHistoryRange,
|
||||
type HostStats,
|
||||
type HostUsageGroup,
|
||||
} from "../../lib/api";
|
||||
import { formatBytes, formatDateTime } from "../../lib/format";
|
||||
import { Tip } from "../Tip";
|
||||
import { MiniChart } from "../admin/MiniChart";
|
||||
import { UserAvatar } from "../users/UserAvatar";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
const HISTORY_POLL_MS = 30000;
|
||||
const RANGES: HostHistoryRange[] = ["1h", "6h", "24h"];
|
||||
|
||||
export const pct = (part: number, whole: number) => (whole > 0 ? (part / whole) * 100 : 0);
|
||||
export const fmtPct = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||
|
||||
/** `GET /api/host`, re-read every `POLL_MS` while the tab is visible. */
|
||||
export type HostState = {
|
||||
stats: HostStats | null;
|
||||
error: string | null;
|
||||
reload: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function useHostStats(enabled = true, pollMs = POLL_MS): HostState {
|
||||
const [stats, setStats] = useState<HostStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const reload = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.stats()
|
||||
.then((s) => {
|
||||
setStats(s);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
void reload();
|
||||
if (!pollMs) return;
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void reload();
|
||||
}, pollMs);
|
||||
return () => clearInterval(id);
|
||||
}, [enabled, pollMs, reload]);
|
||||
return { stats, error, reload };
|
||||
}
|
||||
|
||||
/** A thin bar, amber past 75 %, red past 90 %. */
|
||||
export function Meter({ value, label }: { value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v >= 90 ? "bg-status-danger" : v >= 75 ? "bg-status-warning" : "bg-accent-solid";
|
||||
return (
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="h-1.5 w-full rounded-full bg-surface-4 overflow-hidden"
|
||||
>
|
||||
<div className={`h-full rounded-full ${tone}`} style={{ width: `${v}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The System Health tab's own bar: h-2, green / amber / red by level. */
|
||||
function HealthBar({
|
||||
label,
|
||||
value,
|
||||
tip,
|
||||
warn = 70,
|
||||
bad = 90,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | null;
|
||||
tip?: string;
|
||||
warn?: number;
|
||||
bad?: number;
|
||||
}) {
|
||||
const v = value == null ? 0 : Math.max(0, Math.min(100, value));
|
||||
const tone = v > bad ? "bg-status-danger" : v > warn ? "bg-status-warning" : "bg-status-success";
|
||||
return (
|
||||
<Tip block raw={tip}>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-fg-muted">{label}</span>
|
||||
<span className="text-fg-secondary font-mono">
|
||||
{value == null ? "—" : `${Math.round(v)}%`}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={label}
|
||||
aria-valuenow={Math.round(v)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="w-full bg-surface-3 rounded-full h-2"
|
||||
>
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-700 ${tone}`}
|
||||
style={{ width: `${v}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tip>
|
||||
);
|
||||
}
|
||||
|
||||
/** One CPU thread in the CPU card's compact grid: its number, % and a thin bar. */
|
||||
function CoreCell({ n, value, label }: { n: number; value: number; label: string }) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const tone = v > 90 ? "text-status-danger" : v > 70 ? "text-status-warning" : "text-fg-secondary";
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-[10px] font-mono">
|
||||
<span className="text-fg-muted">{n}</span>
|
||||
<span className={tone}>{Math.round(v)}%</span>
|
||||
</div>
|
||||
<Meter value={v} label={label} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHead({
|
||||
icon: Icon,
|
||||
tone,
|
||||
title,
|
||||
right,
|
||||
}: {
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
tone: string;
|
||||
title: string;
|
||||
right?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Icon className={`w-4 h-4 flex-shrink-0 ${tone}`} />
|
||||
<span className="text-xs text-fg-muted uppercase tracking-wider truncate">{title}</span>
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{label}</span>
|
||||
<span className="ml-auto min-w-0 truncate text-right text-xs text-fg-secondary font-mono">
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function uptimeText(t: (k: string, o?: Record<string, unknown>) => string, sec: number) {
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return d > 0 ? t("host.uptimeDays", { d, h }) : t("host.uptimeHours", { h, m });
|
||||
}
|
||||
|
||||
/** The process's own heap, from `GET /api/settings/info` (the host read has none). */
|
||||
export type HeapInfo = {
|
||||
heapUsed: number;
|
||||
heapTotal: number;
|
||||
heapLimit?: number;
|
||||
external: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The tab's first card: the machine and the AgentHub process on it.
|
||||
* `heap` comes from the tab's settings read.
|
||||
*/
|
||||
export function HostCard({ host, heap }: { host: HostState; heap?: HeapInfo }) {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const s = host.stats;
|
||||
const mem = s?.memory;
|
||||
const heapMax = heap ? heap.heapLimit || heap.heapTotal : 0;
|
||||
const heapPct = heap && heapMax > 0 ? (heap.heapUsed / heapMax) * 100 : null;
|
||||
return (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-facts">
|
||||
<CardHead
|
||||
icon={Server}
|
||||
tone="text-status-success"
|
||||
title={t("host.title")}
|
||||
right={
|
||||
s && (
|
||||
<span className="text-[10px] font-mono text-fg-muted truncate" title={s.hostname}>
|
||||
{s.hostname}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{!s ? (
|
||||
<p className="text-xs text-fg-muted">{host.error ?? t("host.sampling")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2.5">
|
||||
<Row label={t("host.os")}>
|
||||
{s.os.distro ?? s.os.platform} · {s.os.release}
|
||||
</Row>
|
||||
<Row label={t("host.uptime")}>{uptimeText(t, s.uptime)}</Row>
|
||||
<Row label={t("host.cpuModel")}>
|
||||
<Tip raw={s.cpu.model ?? undefined}>
|
||||
<span>
|
||||
{s.cpu.physicalCores
|
||||
? `${t("host.physical", { n: s.cpu.physicalCores })} · `
|
||||
: ""}
|
||||
{t("host.threads", { n: s.cpu.cores })} · {s.os.arch}
|
||||
</span>
|
||||
</Tip>
|
||||
</Row>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary w-28 flex-shrink-0">{t("host.load")}</span>
|
||||
<div className="flex gap-1 ml-auto">
|
||||
{s.cpu.load.slice(0, 3).map((load, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${i === 0 && load > s.cpu.cores ? "bg-status-danger/20 text-status-danger" : "bg-surface-3 text-fg-secondary"}`}
|
||||
>
|
||||
{load.toFixed(2)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{s.process && (
|
||||
<Row label={t("host.agenthub")}>
|
||||
<Tip raw={t("host.agenthubTip", { pid: s.process.pid, node: s.process.node })}>
|
||||
<span>
|
||||
{uptimeText(t, s.process.uptime)} · {formatBytes(s.process.rss)}
|
||||
</span>
|
||||
</Tip>
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-border/40 pt-3 space-y-3">
|
||||
<HealthBar
|
||||
label={t("host.cpu")}
|
||||
value={s.usage ? s.usage.cpu : null}
|
||||
tip={t("host.cpuHelp")}
|
||||
/>
|
||||
<HealthBar
|
||||
label={t("host.memory")}
|
||||
value={mem ? pct(mem.used, mem.total) : null}
|
||||
tip={mem ? `${formatBytes(mem.used)} / ${formatBytes(mem.total)}` : undefined}
|
||||
/>
|
||||
{mem && mem.swapTotal > 0 && (
|
||||
<HealthBar
|
||||
label={t("host.swap")}
|
||||
value={pct(mem.swapUsed, mem.swapTotal)}
|
||||
tip={`${formatBytes(mem.swapUsed)} / ${formatBytes(mem.swapTotal)}`}
|
||||
/>
|
||||
)}
|
||||
{heap && (
|
||||
<HealthBar
|
||||
label={t("health.heap")}
|
||||
value={heapPct}
|
||||
warn={60}
|
||||
bad={85}
|
||||
tip={t("health.heapTip", {
|
||||
pct: (heapPct ?? 0).toFixed(1),
|
||||
used: formatBytes(heap.heapUsed),
|
||||
limit: formatBytes(heapMax),
|
||||
allocated: formatBytes(heap.heapTotal),
|
||||
external: formatBytes(heap.external),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The tab's machine group: host + history, CPU / disks / processes, users. */
|
||||
export function HostSections({ host, heap }: { host: HostState; heap?: HeapInfo }) {
|
||||
const { t } = useTranslation("dashboard");
|
||||
const { stats } = host;
|
||||
const [range, setRange] = useState<HostHistoryRange>("1h");
|
||||
const [history, setHistory] = useState<HostHistory | null>(null);
|
||||
// The user tile whose busiest processes are shown.
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
|
||||
const loadHistory = useCallback(
|
||||
() =>
|
||||
api.host
|
||||
.history(range)
|
||||
.then(setHistory)
|
||||
.catch(() => setHistory(null)),
|
||||
[range]
|
||||
);
|
||||
useEffect(() => {
|
||||
void loadHistory();
|
||||
const id = setInterval(() => {
|
||||
if (document.visibilityState === "visible") void loadHistory();
|
||||
}, HISTORY_POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [loadHistory]);
|
||||
|
||||
// Same tree as the full render below, so the card is not remounted once stats arrive.
|
||||
if (!stats)
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<HostCard host={host} heap={heap} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const groups = stats.usage?.groups ?? [];
|
||||
const groupName = (g: HostUsageGroup) => {
|
||||
if (g.kind === "host") return t("host.hostGroup");
|
||||
if (g.kind === "system") return t("host.systemGroup");
|
||||
return g.displayName || g.username || "";
|
||||
};
|
||||
const groupByKey = new Map(groups.map((g) => [g.key, g]));
|
||||
const ownerOf = (key: string) => {
|
||||
const g = groupByKey.get(key);
|
||||
if (g) return groupName(g);
|
||||
if (key === "host") return t("host.hostGroup");
|
||||
if (key === "system") return t("host.systemGroup");
|
||||
return key;
|
||||
};
|
||||
const perSec = (v: number) => t("host.perSecond", { v: formatBytes(v) });
|
||||
const timeLabel = (iso: string) =>
|
||||
new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
const histPoints = (key: "cpu" | "memory") =>
|
||||
(history?.points ?? [])
|
||||
.filter((p) => p[key] != null)
|
||||
.map((p) => ({ x: timeLabel(p.at), y: p[key] as number }));
|
||||
const perCore = stats.cpu.perCore ?? [];
|
||||
const network = stats.network ?? [];
|
||||
const temps = stats.temperatures ?? [];
|
||||
const topProcs = stats.topProcesses ?? [];
|
||||
const others = groups.filter((g) => g.kind !== "user");
|
||||
const people = groups.filter((g) => g.kind === "user");
|
||||
|
||||
// A whole disk's I/O goes on the first mounted file system that lives on it
|
||||
// (`/dev/sda1` → `sda`, `/dev/nvme0n1p2` → `nvme0n1`); a disk with nothing
|
||||
// mounted gets a row of its own.
|
||||
const ioLeft = new Map((stats.diskIo ?? []).map((d) => [d.name, d]));
|
||||
const diskRows = stats.disks.map((d) => {
|
||||
const base = d.device.split("/").pop() ?? "";
|
||||
const key = [base, base.replace(/p?\d+$/, "")].find((k) => ioLeft.has(k));
|
||||
const io = key ? ioLeft.get(key) : undefined;
|
||||
if (key) ioLeft.delete(key);
|
||||
return { disk: d, io };
|
||||
});
|
||||
const loneIo = [...ioLeft.values()];
|
||||
|
||||
const rate = (down?: number, up?: number) => (
|
||||
<span className="flex items-center gap-2 text-[10px] font-mono text-fg-muted">
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<ArrowDown className="w-2.5 h-2.5" />
|
||||
{down == null ? "—" : perSec(down)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<ArrowUp className="w-2.5 h-2.5" />
|
||||
{up == null ? "—" : perSec(up)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<HostCard host={host} heap={heap} />
|
||||
|
||||
{/* CPU and memory over time. */}
|
||||
<div className="card p-5 flex flex-col gap-4 lg:col-span-2" data-testid="server-history">
|
||||
<CardHead
|
||||
icon={Activity}
|
||||
tone="text-accent"
|
||||
title={t("host.historyTitle")}
|
||||
right={
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("host.range")}
|
||||
className="flex gap-1 rounded-lg border border-border bg-surface-1 p-0.5"
|
||||
>
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => setRange(r)}
|
||||
aria-pressed={range === r}
|
||||
className={`rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors ${
|
||||
range === r
|
||||
? "bg-surface-4 text-fg-secondary"
|
||||
: "text-fg-muted hover:text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{t(`host.range${r}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyCpu")}</div>
|
||||
<MiniChart
|
||||
kind="line"
|
||||
points={histPoints("cpu")}
|
||||
max={100}
|
||||
format={fmtPct}
|
||||
label={t("host.historyCpu")}
|
||||
emptyText={t("host.historyNone")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] text-fg-muted">{t("host.historyMemory")}</div>
|
||||
<MiniChart
|
||||
kind="line"
|
||||
points={histPoints("memory")}
|
||||
max={100}
|
||||
format={fmtPct}
|
||||
label={t("host.historyMemory")}
|
||||
emptyText={t("host.historyNone")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{history?.startedAt && (
|
||||
<p className="text-[10px] text-fg-muted">
|
||||
{t("host.historyHelp", {
|
||||
s: history.intervalMs / 1000,
|
||||
at: formatDateTime(history.startedAt),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* CPU: every thread as a compact cell, then temperatures and GPUs. */}
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-cores">
|
||||
<CardHead
|
||||
icon={Cpu}
|
||||
tone="text-syntax-keyword"
|
||||
title={t("host.cpu")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.threads", { n: perCore.length || stats.cpu.cores })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{perCore.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-x-3 gap-y-2">
|
||||
{perCore.map((v, i) => (
|
||||
<CoreCell key={i} n={i} value={v} label={t("host.coreN", { n: i })} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{temps.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-2"
|
||||
data-testid="server-temperatures"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-fg-muted uppercase tracking-wider">
|
||||
<Thermometer className="w-3 h-3 text-status-warning" />
|
||||
{t("host.temperatures")}
|
||||
</div>
|
||||
{temps.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary min-w-0 truncate" title={s.chip}>
|
||||
{s.label} <span className="text-fg-muted">· {s.chip}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`ml-auto text-[10px] font-mono px-1.5 py-0.5 rounded ${
|
||||
s.celsius >= 90
|
||||
? "bg-status-danger/20 text-status-danger"
|
||||
: s.celsius >= 75
|
||||
? "bg-status-warning/20 text-status-warning"
|
||||
: "bg-surface-3 text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{s.celsius.toFixed(0)} °C
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{stats.gpus.length > 0 && (
|
||||
<div className="border-t border-border/40 pt-3 space-y-3">
|
||||
{stats.gpus.map((g, i) => (
|
||||
<HealthBar
|
||||
key={i}
|
||||
label={g.name}
|
||||
value={g.utilization}
|
||||
tip={`${formatBytes(g.memoryUsed)} / ${formatBytes(g.memoryTotal)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Disks (with their I/O) and network interfaces. */}
|
||||
{(diskRows.length > 0 || loneIo.length > 0 || network.length > 0) && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-disks-table">
|
||||
<CardHead
|
||||
icon={HardDrive}
|
||||
tone="text-syntax-builtin"
|
||||
title={t("host.storageNetwork")}
|
||||
/>
|
||||
<div className="space-y-3" data-testid="server-disk-io">
|
||||
{diskRows.map(({ disk: d, io }) => (
|
||||
<div key={d.device} className="space-y-1">
|
||||
<HealthBar
|
||||
label={`${d.mount}${d.kind ? ` · ${d.kind}` : ""}${d.mount === stats.homesMount ? ` · ${t("host.homesBadge")}` : ""}`}
|
||||
value={pct(d.used, d.total)}
|
||||
warn={75}
|
||||
tip={`${d.device} · ${d.type}\n${formatBytes(d.used)} / ${formatBytes(d.total)} · ${formatBytes(d.free)} ${t("host.free")}`}
|
||||
/>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{formatBytes(d.free)} {t("host.free")}
|
||||
</span>
|
||||
{io && rate(io.read, io.write)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loneIo.map((io) => (
|
||||
<div key={io.name} className="flex items-center gap-3">
|
||||
<span className="text-xs text-fg-secondary font-mono">{io.name}</span>
|
||||
<span className="ml-auto">{rate(io.read, io.write)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{network.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-2"
|
||||
data-testid="server-network"
|
||||
>
|
||||
{network.map((n) => (
|
||||
<div key={n.name} className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-fg-secondary">
|
||||
<Network className="w-3 h-3 text-fg-muted" />
|
||||
<span className="font-mono">{n.name}</span>
|
||||
</span>
|
||||
<span className="ml-auto">{rate(n.rx, n.tx)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The busiest processes, and what belongs to no user. */}
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-top-processes">
|
||||
<CardHead
|
||||
icon={ListTree}
|
||||
tone="text-syntax-function"
|
||||
title={t("host.topProcesses")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.sampled", { s: (stats.usage?.sampleMs ?? 1000) / 1000 })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{!stats.usage ? (
|
||||
<p className="text-xs text-fg-muted">{t("host.usageUnavailable")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topProcs.map((p) => (
|
||||
<Tip block key={p.pid} raw={`#${p.pid} · ${ownerOf(p.group)}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="min-w-0 flex-1 truncate text-xs">
|
||||
<span className="font-mono text-fg-secondary">{p.name}</span>
|
||||
<span className="ml-1.5 text-[10px] text-fg-muted">{ownerOf(p.group)}</span>
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{formatBytes(p.memory)}
|
||||
</span>
|
||||
<span
|
||||
className={`w-12 text-right text-[10px] font-mono px-1.5 py-0.5 rounded ${p.cpu >= 50 ? "bg-status-warning/20 text-status-warning" : "bg-surface-3 text-fg-secondary"}`}
|
||||
>
|
||||
{fmtPct(p.cpu)}
|
||||
</span>
|
||||
</div>
|
||||
</Tip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{others.length > 0 && (
|
||||
<div
|
||||
className="border-t border-border/40 pt-3 space-y-1.5"
|
||||
data-testid="server-usage-table"
|
||||
>
|
||||
{others.map((g) => (
|
||||
<div key={g.key} className="flex items-center gap-3 text-[10px]">
|
||||
<span className="min-w-0 flex-1 truncate text-fg-muted" title={groupName(g)}>
|
||||
{groupName(g)}
|
||||
</span>
|
||||
<span className="font-mono text-fg-secondary">
|
||||
{fmtPct(g.cpu)} · {formatBytes(g.memory)} ·{" "}
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Each user's live share of the machine (server mode). */}
|
||||
{people.length > 0 && (
|
||||
<div className="card p-5 flex flex-col gap-4" data-testid="server-users">
|
||||
<CardHead
|
||||
icon={Users}
|
||||
tone="text-syntax-string"
|
||||
title={t("host.usersTitle")}
|
||||
right={
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.usersCount", { n: people.length })}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{people.map((g) => {
|
||||
const name = groupName(g);
|
||||
const isOpen = open === g.key;
|
||||
return (
|
||||
<div key={g.key} className="space-y-2" data-testid="server-user">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(isOpen ? null : g.key)}
|
||||
disabled={!g.top.length}
|
||||
aria-expanded={isOpen}
|
||||
className="flex w-full items-center gap-2 text-left disabled:cursor-default"
|
||||
>
|
||||
<UserAvatar name={name} className="h-6 w-6 text-[10px]" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-fg-secondary">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-fg-muted">
|
||||
{t("host.processCount", { n: g.processes })}
|
||||
</span>
|
||||
</button>
|
||||
<HealthBar label="CPU" value={g.cpu} tip={t("host.cpuHelp")} />
|
||||
<HealthBar label={`RAM · ${formatBytes(g.memory)}`} value={g.memoryPercent} />
|
||||
{isOpen && (
|
||||
<ul className="space-y-1 rounded-md bg-surface-2/60 px-2 py-1.5">
|
||||
{g.top.map((p) => (
|
||||
<li
|
||||
key={p.pid}
|
||||
className="flex justify-between gap-2 text-[10px] font-mono"
|
||||
>
|
||||
<span className="truncate text-fg-secondary">
|
||||
{p.name} <span className="text-fg-muted">#{p.pid}</span>
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-fg-muted">
|
||||
{fmtPct(p.cpu)} · {formatBytes(p.memory)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* @file HostHealth.test.tsx
|
||||
* @description The machine on the Dashboard's System Health tab
|
||||
* (`HostHealth.tsx`, `GET /api/host`), its whole machine group: the first card names the host, its OS,
|
||||
* load and the AgentHub process with bars for CPU, memory, swap and heap; the
|
||||
* rows under it show every CPU thread, temperatures, each disk with its I/O
|
||||
* rate and each network interface, the CPU / memory history with its range
|
||||
* switch (`GET /api/host/history`), and the busiest processes with who each
|
||||
* counts for, the processes of no user in a short list of their own, and
|
||||
* each user's live CPU / RAM as a tile (their busiest processes on click).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import type { HostStats } from "../../../lib/api";
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
const stats: HostStats = {
|
||||
at: "2026-10-15T00:00:00Z",
|
||||
hostname: "box",
|
||||
os: { platform: "linux", release: "7.0.0", distro: "Ubuntu 26.04", arch: "x64" },
|
||||
uptime: 90000,
|
||||
cpu: { model: "i5-7500", cores: 4, load: [1, 0.5, 0.25] },
|
||||
memory: { total: 16 * GB, available: 8 * GB, used: 8 * GB, swapTotal: 4 * GB, swapUsed: 0 },
|
||||
disks: [
|
||||
{
|
||||
device: "/dev/sdb2",
|
||||
mount: "/",
|
||||
type: "ext4",
|
||||
kind: "SSD",
|
||||
total: 200 * GB,
|
||||
used: 50 * GB,
|
||||
free: 150 * GB,
|
||||
},
|
||||
{
|
||||
device: "/dev/sda1",
|
||||
mount: "/data",
|
||||
type: "ext4",
|
||||
kind: "HDD",
|
||||
total: 500 * GB,
|
||||
used: 0,
|
||||
free: 500 * GB,
|
||||
},
|
||||
],
|
||||
homesMount: "/",
|
||||
gpus: [],
|
||||
usage: {
|
||||
cpu: 40,
|
||||
sampleMs: 1000,
|
||||
groups: [
|
||||
{
|
||||
key: "user:2",
|
||||
kind: "user",
|
||||
userId: 2,
|
||||
username: "mia",
|
||||
cpu: 25,
|
||||
memory: 2 * GB,
|
||||
memoryPercent: 12.5,
|
||||
processes: 3,
|
||||
top: [{ pid: 77, name: "claude", cpu: 20, memory: GB }],
|
||||
},
|
||||
{ key: "host", kind: "host", cpu: 5, memory: GB, memoryPercent: 6.3, processes: 90, top: [] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
host: { stats: vi.fn(), history: vi.fn() },
|
||||
me: { avatarUrl: () => null },
|
||||
}));
|
||||
vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return { ...actual, api };
|
||||
});
|
||||
|
||||
import { HostSections, useHostStats } from "../HostHealth";
|
||||
|
||||
function Tab() {
|
||||
const host = useHostStats();
|
||||
return (
|
||||
<HostSections
|
||||
host={host}
|
||||
heap={{ heapUsed: GB / 4, heapTotal: GB / 2, heapLimit: GB, external: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const full: HostStats = {
|
||||
...stats,
|
||||
usage: {
|
||||
...stats.usage!,
|
||||
groups: stats.usage!.groups.map((g) => (g.kind === "user" ? { ...g, displayName: "Mia" } : g)),
|
||||
},
|
||||
bootTime: "2026-10-14T00:00:00Z",
|
||||
cpu: { ...stats.cpu, physicalCores: 4, perCore: [10, 20, 30, 40] },
|
||||
network: [{ name: "enp4s0", rx: 2048, tx: 1024 }],
|
||||
diskIo: [{ name: "sda", read: 4096, write: 0 }],
|
||||
temperatures: [{ chip: "coretemp", label: "Package id 0", celsius: 55 }],
|
||||
topProcesses: [{ pid: 77, name: "claude", cpu: 20, memory: GB, group: "user:2" }],
|
||||
process: { pid: 9, node: "v24.0.0", uptime: 3600, rss: GB, heapUsed: GB / 2 },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.host.stats.mockResolvedValue(full);
|
||||
api.host.history.mockResolvedValue({
|
||||
range: "1h",
|
||||
intervalMs: 30000,
|
||||
running: true,
|
||||
startedAt: "2026-10-15T00:00:00Z",
|
||||
points: [
|
||||
{
|
||||
at: "2026-10-15T00:00:00Z",
|
||||
cpu: 30,
|
||||
memory: 50,
|
||||
memoryUsed: 0,
|
||||
swapUsed: 0,
|
||||
load1: 1,
|
||||
users: {},
|
||||
},
|
||||
{
|
||||
at: "2026-10-15T00:00:30Z",
|
||||
cpu: 40,
|
||||
memory: 52,
|
||||
memoryUsed: 0,
|
||||
swapUsed: 0,
|
||||
load1: 1,
|
||||
users: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("HostHealth", () => {
|
||||
it("names the machine and the AgentHub process, with bars", async () => {
|
||||
render(<Tab />);
|
||||
const card = await screen.findByTestId("server-facts");
|
||||
expect(await within(card).findByText("box")).toBeTruthy();
|
||||
expect(card.textContent).toContain("Ubuntu 26.04");
|
||||
expect(card.textContent).toContain("1.0 GB");
|
||||
expect(within(card).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
|
||||
"40"
|
||||
);
|
||||
expect(within(card).getByRole("meter", { name: "Memory" }).getAttribute("aria-valuenow")).toBe(
|
||||
"50"
|
||||
);
|
||||
expect(within(card).getByRole("meter", { name: "V8 Heap" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows every thread, sensors, disks with I/O, network, history and processes", async () => {
|
||||
render(<Tab />);
|
||||
expect(await screen.findByTestId("server-history")).toBeTruthy();
|
||||
expect(await screen.findByRole("img", { name: /CPU/ })).toBeTruthy();
|
||||
expect(screen.getByTestId("server-cores").textContent).toContain("40%");
|
||||
expect(screen.getByTestId("server-temperatures").textContent).toContain("55 °C");
|
||||
const disks = screen.getByTestId("server-disk-io").textContent;
|
||||
expect(disks).toContain("/data · HDD");
|
||||
expect(disks).toContain("4.0 KB/s");
|
||||
expect(screen.getByTestId("server-network").textContent).toContain("enp4s0");
|
||||
const top = screen.getByTestId("server-top-processes");
|
||||
expect(await within(top).findByText("Mia")).toBeTruthy();
|
||||
const other = screen.getByTestId("server-usage-table");
|
||||
expect(within(other).queryByText("Mia")).toBeNull();
|
||||
expect(within(other).getByText(/^Host account/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "24 h" }));
|
||||
expect(api.host.history).toHaveBeenLastCalledWith("24h");
|
||||
});
|
||||
|
||||
it("shows each user's live CPU and RAM, their processes on click", async () => {
|
||||
render(<Tab />);
|
||||
const tile = await screen.findByTestId("server-user");
|
||||
expect(tile.textContent).toContain("Mia");
|
||||
expect(within(tile).getByRole("meter", { name: "CPU" }).getAttribute("aria-valuenow")).toBe(
|
||||
"25"
|
||||
);
|
||||
expect(tile.textContent).toContain("2.0 GB");
|
||||
expect(within(tile).queryByText("claude")).toBeNull();
|
||||
fireEvent.click(within(tile).getByRole("button", { expanded: false }));
|
||||
expect(within(tile).getByText("claude")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -382,7 +382,8 @@ describe("LaneChangesPanel", () => {
|
||||
const box = within(folder.parentElement!).getByRole("checkbox", {
|
||||
name: "Tick every file in server/lib",
|
||||
});
|
||||
expect(box).toBeChecked();
|
||||
// The default ticks land in an effect after the list renders.
|
||||
await waitFor(() => expect(box).toBeChecked());
|
||||
await user.click(box);
|
||||
expect(tickOf("server/lib/git-ops.js")).not.toBeChecked();
|
||||
await user.click(folder);
|
||||
|
||||
@@ -765,7 +765,7 @@ export function Divider({
|
||||
onRatio,
|
||||
label,
|
||||
}: {
|
||||
gridRef: React.RefObject<HTMLDivElement>;
|
||||
gridRef: React.RefObject<HTMLDivElement | null>;
|
||||
dir: SplitDir;
|
||||
at: number;
|
||||
box: Rect;
|
||||
|
||||
@@ -48,7 +48,9 @@
|
||||
* most every 150 ms. Several browsers on one pane share ONE size: the server
|
||||
* names a driver (see pty-attach.js) and the others FOLLOW — their xterm takes
|
||||
* the driver's grid (`size`), scaled down to fit, until a click or a keystroke
|
||||
* here takes the lead back (`resize` with `drive: true`).
|
||||
* here takes the lead back (`resize` with `drive: true`). Cells are drawn by
|
||||
* the WebGL addon (the DOM renderer after a lost context), and Alt+arrow keeps
|
||||
* the word-move keys xterm 5 sent (`altArrowSeq`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import {
|
||||
@@ -63,7 +65,7 @@ import {
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import type { CanvasAddon } from "@xterm/addon-canvas";
|
||||
import type { WebglAddon } from "@xterm/addon-webgl";
|
||||
import type { ClipboardAddon, IClipboardProvider } from "@xterm/addon-clipboard";
|
||||
import type { SearchAddon } from "@xterm/addon-search";
|
||||
import { ArrowDown, ArrowUp, X } from "lucide-react";
|
||||
@@ -123,10 +125,10 @@ const clipboardProvider: IClipboardProvider = {
|
||||
* Workspace chunk. The result is cached, so every later pane (and a test that
|
||||
* awaits `loadXterm()` first) sets up synchronously. */
|
||||
const importXterm = async () => {
|
||||
const [xterm, fit, canvas, clipboard, search, links] = await Promise.all([
|
||||
const [xterm, fit, webgl, clipboard, search, links] = await Promise.all([
|
||||
import("@xterm/xterm"),
|
||||
import("@xterm/addon-fit"),
|
||||
import("@xterm/addon-canvas"),
|
||||
import("@xterm/addon-webgl"),
|
||||
import("@xterm/addon-clipboard"),
|
||||
import("@xterm/addon-search"),
|
||||
import("@xterm/addon-web-links"),
|
||||
@@ -134,7 +136,7 @@ const importXterm = async () => {
|
||||
return {
|
||||
Terminal: xterm.Terminal,
|
||||
FitAddon: fit.FitAddon,
|
||||
CanvasAddon: canvas.CanvasAddon,
|
||||
WebglAddon: webgl.WebglAddon,
|
||||
SearchAddon: search.SearchAddon,
|
||||
WebLinksAddon: links.WebLinksAddon,
|
||||
// The addon's typings declare one argument, its code takes (base64, provider).
|
||||
@@ -158,6 +160,25 @@ export function loadXterm(): Promise<Xterm> {
|
||||
return xtermLoading;
|
||||
}
|
||||
|
||||
const IS_MAC = /^Mac/.test(typeof navigator === "undefined" ? "" : navigator.platform);
|
||||
/** What xterm 5 sent for a bare Alt+arrow (its Keyboard.ts "HACK"s, removed
|
||||
* in xterm 6): Ctrl+arrow's sequence, except macOS's left/right, which are
|
||||
* readline's backward-word / forward-word and whose up/down stay Alt's. */
|
||||
export function altArrowSeq(key: string, mac = IS_MAC): string | null {
|
||||
switch (key) {
|
||||
case "ArrowLeft":
|
||||
return mac ? "\x1bb" : "\x1b[1;5D";
|
||||
case "ArrowRight":
|
||||
return mac ? "\x1bf" : "\x1b[1;5C";
|
||||
case "ArrowUp":
|
||||
return mac ? null : "\x1b[1;5A";
|
||||
case "ArrowDown":
|
||||
return mac ? null : "\x1b[1;5B";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconnect delay: 1.5 s, doubling per failed attempt up to 30 s. */
|
||||
const RETRY_MIN_MS = 1500;
|
||||
const RETRY_MAX_MS = 30_000;
|
||||
@@ -369,7 +390,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
const attach = ({
|
||||
Terminal,
|
||||
FitAddon,
|
||||
CanvasAddon,
|
||||
WebglAddon,
|
||||
SearchAddon,
|
||||
WebLinksAddon,
|
||||
ClipboardWithProvider,
|
||||
@@ -379,7 +400,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
// which on Windows lacks Vietnamese precomposed diacritics — the OS then
|
||||
// substitutes those specific glyphs from a different face with a
|
||||
// mismatched advance width, corrupting the cell it's drawn into even
|
||||
// under the canvas renderer (which only stops a glyph from shoving its
|
||||
// under the WebGL renderer (which only stops a glyph from shoving its
|
||||
// *neighbours*, not from overflowing its own cell). JetBrains Mono is
|
||||
// already bundled for the rest of the UI; main.tsx additionally imports
|
||||
// its `vietnamese` subset so this covers the diacritics too. Settings →
|
||||
@@ -425,17 +446,30 @@ export const TerminalView = memo(function TerminalView({
|
||||
// face whose advance width differs, so glyphs shove their neighbours along
|
||||
// and the row's tail spills onto the row below. The buffer is correct
|
||||
// throughout (copying the line yields clean text); only the painting drifts,
|
||||
// and no CSS can correct it. Best-effort: a browser that cannot give a 2D
|
||||
// context keeps the DOM renderer rather than losing the terminal.
|
||||
let canvas: CanvasAddon | null = null;
|
||||
// and no CSS can correct it. xterm 6 dropped its canvas renderer, so the
|
||||
// cells are drawn by the WebGL one. Best-effort: a browser with no WebGL2
|
||||
// keeps the DOM renderer rather than losing the terminal, and so does a
|
||||
// pane whose context the browser takes back (GPU reset, or past the
|
||||
// browser's cap of live contexts with many panes open) — disposing the
|
||||
// addon hands the drawing back to the DOM renderer.
|
||||
let gpu: WebglAddon | null = null;
|
||||
try {
|
||||
canvas = new CanvasAddon();
|
||||
term.loadAddon(canvas);
|
||||
const addon = new WebglAddon();
|
||||
term.loadAddon(addon);
|
||||
gpu = addon;
|
||||
addon.onContextLoss(() => {
|
||||
addon.dispose();
|
||||
if (gpu === addon) gpu = null;
|
||||
});
|
||||
} catch {
|
||||
canvas = null;
|
||||
gpu = null;
|
||||
}
|
||||
|
||||
fit.fit();
|
||||
// A terminal mounted on a hidden tab sits in a 0×0 box (`display:none`):
|
||||
// fitting it there shrinks xterm to its 12×6 minimum, and that size would
|
||||
// reach tmux with the attach. It stays unfitted until it is shown.
|
||||
const hasBox = () => !!containerRef.current?.clientWidth && !!containerRef.current.clientHeight;
|
||||
if (hasBox()) fit.fit();
|
||||
// Take the keyboard as soon as the pane is on screen: the terminal IS the
|
||||
// page here, and having to click it first meant the first thing typed went
|
||||
// nowhere. A click elsewhere still moves focus away, as in any editor.
|
||||
@@ -522,6 +556,17 @@ export const TerminalView = memo(function TerminalView({
|
||||
term.input("\x1f", true);
|
||||
return false;
|
||||
}
|
||||
// Alt+arrow moves by word, as it did before xterm 6 dropped its own
|
||||
// mapping (xterm.js #5346): Ctrl+arrow's sequence, or readline's
|
||||
// ESC b / ESC f for left/right on macOS — the same keys tmux,
|
||||
// bash and claude's prompt have always been handed from this pane.
|
||||
const altArrow =
|
||||
ev.altKey && !ev.ctrlKey && !ev.metaKey && !ev.shiftKey ? altArrowSeq(ev.key) : null;
|
||||
if (altArrow) {
|
||||
ev.preventDefault();
|
||||
term.input(altArrow, true);
|
||||
return false;
|
||||
}
|
||||
if (ev.key === "Tab" && tabMovesRef.current) return false;
|
||||
if (ev.key === "Tab" && ev.shiftKey) ev.preventDefault();
|
||||
return true;
|
||||
@@ -564,7 +609,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
*/
|
||||
const sendSizeNow = (drive = false) => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
if (term.cols < 2 || term.rows < 2) return;
|
||||
if (!hasBox() || term.cols < 2 || term.rows < 2) return;
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, drive }));
|
||||
};
|
||||
// This browser is the one in use now: back to its own size, and the
|
||||
@@ -601,7 +646,10 @@ export const TerminalView = memo(function TerminalView({
|
||||
// scrollback as torn fragments along the left margin, visible as soon as
|
||||
// you scroll back (issue #33). Happens on every attach: page load,
|
||||
// lane switch, and each reconnect.
|
||||
const size = term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
||||
// A hidden terminal sends none: the server attaches it at the window's
|
||||
// own size, so a pane on a background tab is never resized by it.
|
||||
const size =
|
||||
hasBox() && term.cols > 1 && term.rows > 1 ? `?cols=${term.cols}&rows=${term.rows}` : "";
|
||||
const sock = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}${size}`);
|
||||
ws = sock;
|
||||
wsRef.current = sock;
|
||||
@@ -696,6 +744,9 @@ export const TerminalView = memo(function TerminalView({
|
||||
if (!el || !el.clientWidth || !el.clientHeight) return;
|
||||
if (follow) return scaleToFit(); // the driver's grid, not ours
|
||||
fit.fit(); // a real change fires onResize, which reports it
|
||||
// A tab shown for the first time may fit to the very size xterm already
|
||||
// had (no onResize), yet tmux has never heard it: report it anyway.
|
||||
sendSize();
|
||||
};
|
||||
const resizeObserver = new ResizeObserver(refit);
|
||||
if (containerRef.current) resizeObserver.observe(containerRef.current);
|
||||
@@ -734,7 +785,7 @@ export const TerminalView = memo(function TerminalView({
|
||||
resizeDisposable.dispose();
|
||||
dataDisposable.dispose();
|
||||
ws?.close();
|
||||
canvas?.dispose();
|
||||
gpu?.dispose();
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
};
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
* included) as outgoing binary frames, and
|
||||
* re-attaches after the socket drops (server restart) with backoff, but not
|
||||
* after `exit` or a policy close,
|
||||
* and recolours live on a dark-theme change without re-attaching.
|
||||
* recolours live on a dark-theme change without re-attaching, draws through
|
||||
* the WebGL addon (DOM renderer again on a lost context) and keeps xterm 5's
|
||||
* Alt+arrow word moves.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, onTestFinished } from "vitest";
|
||||
import {
|
||||
render,
|
||||
cleanup,
|
||||
@@ -19,7 +21,7 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { TerminalView, loadXterm } from "../TerminalView";
|
||||
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
|
||||
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
||||
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
|
||||
|
||||
@@ -109,8 +111,24 @@ vi.mock("../../../lib/terminalTheme", () => ({
|
||||
vi.mock("@xterm/addon-fit", () => ({
|
||||
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
|
||||
}));
|
||||
vi.mock("@xterm/addon-canvas", () => ({
|
||||
CanvasAddon: vi.fn().mockImplementation(() => ({ __canvas: true, dispose: vi.fn() })),
|
||||
// The newest WebGL addon, with the context-loss listener the view gave it.
|
||||
const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = {
|
||||
last: null,
|
||||
};
|
||||
vi.mock("@xterm/addon-webgl", () => ({
|
||||
WebglAddon: vi.fn().mockImplementation(() => {
|
||||
let onLoss = () => {};
|
||||
const addon = {
|
||||
__webgl: true,
|
||||
dispose: vi.fn(),
|
||||
onContextLoss: (fn: () => void) => {
|
||||
onLoss = fn;
|
||||
return { dispose: vi.fn() };
|
||||
},
|
||||
};
|
||||
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
|
||||
return addon;
|
||||
}),
|
||||
}));
|
||||
|
||||
class MockWebSocket {
|
||||
@@ -136,6 +154,17 @@ class MockWebSocket {
|
||||
// @ts-expect-error test override
|
||||
global.WebSocket = MockWebSocket;
|
||||
|
||||
/** Give every element a real box (jsdom's are 0×0, like a terminal on a hidden
|
||||
* tab); returns the restore. */
|
||||
function withBox(width = 800, height = 600) {
|
||||
const w = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(width);
|
||||
const h = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(height);
|
||||
return () => {
|
||||
w.mockRestore();
|
||||
h.mockRestore();
|
||||
};
|
||||
}
|
||||
|
||||
describe("TerminalView", () => {
|
||||
// xterm is a lazy chunk; once loaded every terminal sets up synchronously.
|
||||
beforeAll(() => loadXterm());
|
||||
@@ -167,11 +196,51 @@ describe("TerminalView", () => {
|
||||
// it: tmux resizes the window to the newest client, so attaching at the
|
||||
// server's default 80x24 makes claude redraw its UI at 80 columns and
|
||||
// leaves that width in the scrollback (issue #33).
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
|
||||
);
|
||||
const restore = withBox();
|
||||
try {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
expect(MockWebSocket.instances[0]!.url).toBe(
|
||||
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("a terminal on a hidden tab sends no size until it is shown", () => {
|
||||
// Its box is 0×0 (display:none): fitting there shrank xterm to 12×6 and
|
||||
// that size resized the real tmux pane under every other client.
|
||||
let observed: (() => void) | null = null;
|
||||
const RO = globalThis.ResizeObserver;
|
||||
// @ts-expect-error test override
|
||||
globalThis.ResizeObserver = class {
|
||||
constructor(cb: () => void) {
|
||||
observed = cb;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
};
|
||||
vi.useFakeTimers();
|
||||
let restore = () => {};
|
||||
try {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
expect(ws.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
|
||||
ws.onopen?.();
|
||||
expect(ws.sent).toEqual([]);
|
||||
// The tab is shown: the box gets a size and its size reaches tmux.
|
||||
restore = withBox();
|
||||
observed!();
|
||||
vi.advanceTimersByTime(200);
|
||||
expect(ws.sent).toEqual([
|
||||
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false }),
|
||||
]);
|
||||
} finally {
|
||||
restore();
|
||||
vi.useRealTimers();
|
||||
globalThis.ResizeObserver = RO;
|
||||
}
|
||||
});
|
||||
|
||||
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
|
||||
@@ -397,34 +466,75 @@ describe("TerminalView", () => {
|
||||
expect(onExit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders through the canvas addon, not xterm's DOM renderer", () => {
|
||||
it("renders through the WebGL addon, not xterm's DOM renderer", () => {
|
||||
// The DOM renderer aligns a row with one `letter-spacing`, which holds only
|
||||
// while every glyph shares a font. Vietnamese diacritics and CJK fall to a
|
||||
// fallback face with a different advance, so glyphs shove their neighbours
|
||||
// and the row's tail spills onto the row below — buffer clean, painting
|
||||
// wrong. The canvas renderer draws each cell at a fixed position instead.
|
||||
// wrong. The WebGL renderer draws each cell at a fixed position instead
|
||||
// (xterm 6 removed the canvas one).
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __canvas?: boolean }).__canvas)).toBe(
|
||||
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const addon = gpu.last!;
|
||||
expect(addon.dispose).not.toHaveBeenCalled();
|
||||
addon.lose();
|
||||
expect(addon.dispose).toHaveBeenCalledTimes(1);
|
||||
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
|
||||
});
|
||||
|
||||
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
|
||||
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
|
||||
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
|
||||
expect(altArrowSeq("ArrowRight", false)).toBe("\x1b[1;5C");
|
||||
expect(altArrowSeq("ArrowUp", false)).toBe("\x1b[1;5A");
|
||||
expect(altArrowSeq("ArrowDown", false)).toBe("\x1b[1;5B");
|
||||
expect(altArrowSeq("ArrowLeft", true)).toBe("\x1bb");
|
||||
expect(altArrowSeq("ArrowRight", true)).toBe("\x1bf");
|
||||
expect(altArrowSeq("ArrowUp", true)).toBeNull();
|
||||
expect(altArrowSeq("a", false)).toBeNull();
|
||||
|
||||
inputMock.mockClear();
|
||||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||||
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
|
||||
const ev = new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, cancelable: true });
|
||||
expect(handler(ev)).toBe(false);
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
expect(inputMock).toHaveBeenCalledWith(altArrowSeq("ArrowLeft"), true);
|
||||
// Another modifier with it is xterm's own sequence.
|
||||
expect(
|
||||
handler(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, shiftKey: true }))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe("size reporting (tmux must never be wider than the terminal)", () => {
|
||||
// A tmux pane wider than xterm wraps each over-long row's tail onto the
|
||||
// next row's left margin, and tmux never repaints a row it believes
|
||||
// unchanged — the stray characters glued to the left edge after scrolling.
|
||||
it("reports its size when the socket opens", () => {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
ws.onopen?.();
|
||||
expect(ws.sent).toContain(
|
||||
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
|
||||
);
|
||||
const restore = withBox();
|
||||
try {
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
ws.onopen?.();
|
||||
expect(ws.sent).toContain(
|
||||
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a size change xterm made on its own, with no container resize", async () => {
|
||||
// Webfont load and page zoom both move cols without resizing the element
|
||||
// ResizeObserver watches, which is why onResize is the hook.
|
||||
const restore = withBox();
|
||||
onTestFinished(restore);
|
||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||
const ws = MockWebSocket.instances[0]!;
|
||||
ws.onopen?.();
|
||||
|
||||
@@ -79,14 +79,6 @@ function Step({ n, children }: { n: number; children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Code({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<code className="mt-1 block break-all rounded bg-surface-2 px-1.5 py-1 font-mono text-[10px] text-fg-primary">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
|
||||
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
|
||||
|
||||
@@ -642,10 +634,6 @@ export function GitIdentitySettings() {
|
||||
onRemove={() => removeKey("ed25519")}
|
||||
>
|
||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||
<Step n={3}>
|
||||
{t("gitIdentity.gitHubStepClone")}
|
||||
<Code>git clone git@github.com:<org>/<repo>.git</Code>
|
||||
</Step>
|
||||
</KeyCard>
|
||||
{namedOf("ed25519").map((k) => {
|
||||
const rows = rowsOf(otherRows, k);
|
||||
@@ -668,11 +656,6 @@ export function GitIdentitySettings() {
|
||||
{id.writable && rows.length === 0 && (
|
||||
<GitHostForm busy={busy} onSave={(host) => saveGitHost(host, k)} />
|
||||
)}
|
||||
{rows.map((r) => (
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/<org>/<repo>.git
|
||||
</Code>
|
||||
))}
|
||||
</Step>
|
||||
</KeyCard>
|
||||
);
|
||||
@@ -720,16 +703,6 @@ export function GitIdentitySettings() {
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
{rows.length > 0 && (
|
||||
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
|
||||
{t("gitIdentity.awsReady")}
|
||||
{rows.map((r) => (
|
||||
<Code key={r.alias ?? r.host}>
|
||||
git clone ssh://{r.alias ?? r.host}/v1/repos/<repo>
|
||||
</Code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</KeyCard>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* @file NineRouterUpdate.tsx
|
||||
* @description **Update 9router** on the AI Proxy page (an admin's; beside
|
||||
* Config 9router): a button — dotted when npm has a newer 9router — and a
|
||||
* dialog that runs the server's three-step job (`server/lib/nine-router-update.js`):
|
||||
* disconnect and shut 9router down, `npm install -g 9router@latest`, start it
|
||||
* again hidden (tray, no browser) — the last only when it was running before.
|
||||
* The dialog shows why an update cannot run right now (the server's `blocker`,
|
||||
* with the command to run by hand when sudo needs a password) and follows the
|
||||
* job by polling until it ends. Closing the dialog does not stop the job.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUpCircle, Check, Circle, Copy, Loader2, MinusCircle, X } from "lucide-react";
|
||||
import { api, type NineRouterUpdateStatus, type NineRouterUpdateStep } from "../../lib/api";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
|
||||
const POLL_MS = 1000;
|
||||
|
||||
function StepIcon({ status }: { status: NineRouterUpdateStep["status"] }) {
|
||||
if (status === "running") return <Loader2 className="h-3.5 w-3.5 animate-spin text-accent" />;
|
||||
if (status === "done") return <Check className="h-3.5 w-3.5 text-status-success" />;
|
||||
if (status === "failed") return <X className="h-3.5 w-3.5 text-status-danger" />;
|
||||
if (status === "skipped") return <MinusCircle className="h-3.5 w-3.5 text-fg-muted" />;
|
||||
return <Circle className="h-3.5 w-3.5 text-fg-muted" />;
|
||||
}
|
||||
|
||||
export function NineRouterUpdate() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [status, setStatus] = useState<NineRouterUpdateStatus | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
useDialog(open, dialogRef, () => setOpen(false));
|
||||
|
||||
const load = useCallback(async (fresh = false) => {
|
||||
try {
|
||||
setStatus(await api.aiProxies.nineRouterUpdateStatus(fresh));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const running = status?.job?.status === "running";
|
||||
// Follow a running job — also one started from another tab.
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const timer = window.setInterval(() => void load(), POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [running, load]);
|
||||
|
||||
const openDialog = () => {
|
||||
setError(null);
|
||||
setOpen(true);
|
||||
void load(true);
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const job = await api.aiProxies.nineRouterUpdate();
|
||||
setStatus((s) => (s ? { ...s, job } : s));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
void load(true);
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!status?.installed) return null;
|
||||
const job = status.job;
|
||||
const blocker = status.blocker;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDialog}
|
||||
className="relative inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary transition-colors hover:border-accent/30 hover:bg-accent/10 hover:text-accent"
|
||||
>
|
||||
{running ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<ArrowUpCircle className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("aiProxy.update9router")}
|
||||
{status.updateAvailable && !running && (
|
||||
<span
|
||||
className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-accent-solid"
|
||||
aria-label={t("aiProxy.update9routerAvailable")}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="nine-router-update-title"
|
||||
className="max-h-[90vh] w-full max-w-md overflow-y-auto rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
|
||||
>
|
||||
<h3
|
||||
id="nine-router-update-title"
|
||||
className="mb-3 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
{t("aiProxy.update9router")}
|
||||
</h3>
|
||||
|
||||
<dl className="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
<dt className="text-fg-muted">{t("aiProxy.update9routerCurrent")}</dt>
|
||||
<dd className="font-mono text-fg-primary">{status.version ?? "—"}</dd>
|
||||
<dt className="text-fg-muted">{t("aiProxy.update9routerLatest")}</dt>
|
||||
<dd className="font-mono text-fg-primary">{status.latest ?? "—"}</dd>
|
||||
<dt className="text-fg-muted">{t("aiProxy.update9routerState")}</dt>
|
||||
<dd className="text-fg-primary">
|
||||
{status.running
|
||||
? status.managedBy === "systemd"
|
||||
? t("aiProxy.update9routerRunningUnit")
|
||||
: t("aiProxy.update9routerRunning")
|
||||
: t("aiProxy.update9routerStopped")}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<p className="mb-3 text-[11px] text-fg-muted">
|
||||
{status.running
|
||||
? t("aiProxy.update9routerExplain")
|
||||
: t("aiProxy.update9routerExplainStopped")}
|
||||
</p>
|
||||
|
||||
{job && (
|
||||
<ol className="mb-3 space-y-1.5 rounded-md border border-border bg-surface-0 p-3">
|
||||
{job.steps.map((s) => (
|
||||
<li key={s.id} className="flex items-start gap-2 text-xs">
|
||||
<span className="mt-0.5">
|
||||
<StepIcon status={s.status} />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="text-fg-primary">
|
||||
{t(`aiProxy.update9routerStep.${s.id}`)}
|
||||
</span>
|
||||
{s.message && (
|
||||
<span className="block break-words text-[11px] text-fg-muted">
|
||||
{s.message}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
{job && job.status !== "running" && (
|
||||
<p
|
||||
className={`mb-3 text-xs ${
|
||||
job.status === "done" ? "text-status-success" : "text-status-danger"
|
||||
}`}
|
||||
>
|
||||
{job.status !== "done"
|
||||
? t("aiProxy.update9routerFailed")
|
||||
: job.steps.find((s) => s.id === "relaunch")?.status === "skipped"
|
||||
? t("aiProxy.update9routerDoneStopped", { version: job.to ?? "—" })
|
||||
: t("aiProxy.update9routerDone", { version: job.to ?? "—" })}
|
||||
</p>
|
||||
)}
|
||||
{job?.log && (
|
||||
<details className="mb-3">
|
||||
<summary className="cursor-pointer text-[11px] text-fg-muted">
|
||||
{t("aiProxy.update9routerLog")}
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-48 overflow-auto whitespace-pre-wrap rounded-md bg-surface-0 p-2 font-mono text-[10px] text-fg-secondary">
|
||||
{job.log}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{blocker && !running && blocker.code !== "EUPTODATE" && (
|
||||
<div className="mb-3 rounded-md border border-status-warning/40 bg-status-warning/10 px-3 py-2 text-xs text-fg-secondary">
|
||||
<p>{blocker.message}</p>
|
||||
{blocker.command && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded bg-surface-0 px-2 py-1 font-mono text-[11px]">
|
||||
{blocker.command}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyText(blocker.command!)}
|
||||
className="shrink-0 rounded p-1 text-fg-muted hover:text-accent"
|
||||
aria-label={t("aiProxy.update9routerCopy")}
|
||||
title={t("aiProxy.update9routerCopy")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{blocker?.code === "EUPTODATE" && !running && (
|
||||
<p className="mb-3 text-xs text-fg-muted">{t("aiProxy.update9routerUpToDate")}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="mb-3 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-lg border border-border px-3 py-1.5 text-xs text-fg-secondary hover:bg-surface-2"
|
||||
>
|
||||
{t("aiProxy.update9routerClose")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void start()}
|
||||
disabled={Boolean(blocker) || running || starting}
|
||||
className="btn-primary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs disabled:opacity-50"
|
||||
>
|
||||
{(running || starting) && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
|
||||
{t("aiProxy.update9routerStart")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -156,7 +156,6 @@ describe("GitIdentitySettings", () => {
|
||||
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
|
||||
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({ host: H, user: "APKA1" });
|
||||
expect(await within(aws).findByText("eu-west-1 · APKA1 · Any key")).toBeInTheDocument();
|
||||
expect(within(aws).getByText(`git clone ssh://${H}/v1/repos/<repo>`)).toBeInTheDocument();
|
||||
// A CodeCommit host never shows up again under "other hosts".
|
||||
expect(screen.queryByText(`APKA1@${H}`)).toBeNull();
|
||||
await userEvent.click(within(aws).getByRole("button", { name: `Delete ${H}` }));
|
||||
@@ -260,7 +259,6 @@ describe("GitIdentitySettings", () => {
|
||||
expect(
|
||||
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
|
||||
).toBeInTheDocument();
|
||||
expect(within(prod).getByText("git clone ssh://aws-2/v1/repos/<repo>")).toBeInTheDocument();
|
||||
// The default key's card does not list the named key's login.
|
||||
const def = screen.getByRole("region", { name: "AWS CodeCommit" });
|
||||
expect(within(def).queryByText(/APKA2/)).toBeNull();
|
||||
@@ -301,7 +299,7 @@ describe("GitIdentitySettings", () => {
|
||||
key: "id_ed25519_git-2",
|
||||
});
|
||||
expect(
|
||||
await within(work).findByText("git clone ssh://git-2/<org>/<repo>.git")
|
||||
await within(work).findByText("ssh://git-2/ → git@github.com")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* @file Tests for **Update 9router** on the AI Proxy page: hidden without a
|
||||
* 9router, a dialog that shows versions and starts the job, the server's
|
||||
* blocker (with the manual command) disabling the start, and the job's steps.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { NineRouterUpdate } from "../NineRouterUpdate";
|
||||
import { api, type NineRouterUpdateJob, type NineRouterUpdateStatus } from "../../../lib/api";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { aiProxies: { nineRouterUpdateStatus: vi.fn(), nineRouterUpdate: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const statusMock = vi.mocked(api.aiProxies.nineRouterUpdateStatus);
|
||||
const startMock = vi.mocked(api.aiProxies.nineRouterUpdate);
|
||||
|
||||
const base: NineRouterUpdateStatus = {
|
||||
installed: true,
|
||||
version: "0.5.104",
|
||||
latest: "0.5.109",
|
||||
updateAvailable: true,
|
||||
running: true,
|
||||
managedBy: "systemd",
|
||||
blocker: null,
|
||||
job: null,
|
||||
};
|
||||
|
||||
const job: NineRouterUpdateJob = {
|
||||
status: "running",
|
||||
startedAt: "2026-10-26T00:00:00Z",
|
||||
finishedAt: null,
|
||||
from: "0.5.104",
|
||||
to: null,
|
||||
managedBy: "systemd",
|
||||
install: "direct",
|
||||
steps: [
|
||||
{ id: "shutdown", status: "done", message: null },
|
||||
{ id: "update", status: "running", message: null },
|
||||
{ id: "relaunch", status: "pending", message: null },
|
||||
],
|
||||
log: "",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
statusMock.mockReset();
|
||||
startMock.mockReset();
|
||||
});
|
||||
|
||||
describe("NineRouterUpdate", () => {
|
||||
it("renders nothing when 9router is not installed", async () => {
|
||||
statusMock.mockResolvedValue({ ...base, installed: false });
|
||||
const { container } = render(<NineRouterUpdate />);
|
||||
await waitFor(() => expect(statusMock).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("opens with both versions and starts the three-step job", async () => {
|
||||
statusMock.mockResolvedValue(base);
|
||||
startMock.mockResolvedValue(job);
|
||||
render(<NineRouterUpdate />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
|
||||
expect(statusMock).toHaveBeenLastCalledWith(true);
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toHaveTextContent("0.5.104");
|
||||
expect(dialog).toHaveTextContent("0.5.109");
|
||||
await userEvent.click(screen.getByRole("button", { name: "aiProxy.update9routerStart" }));
|
||||
expect(startMock).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText("aiProxy.update9routerStep.shutdown")).toBeInTheDocument();
|
||||
expect(screen.getByText("aiProxy.update9routerStep.update")).toBeInTheDocument();
|
||||
expect(screen.getByText("aiProxy.update9routerStep.relaunch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the blocker with its manual command and keeps the start disabled", async () => {
|
||||
statusMock.mockResolvedValue({
|
||||
...base,
|
||||
blocker: {
|
||||
code: "ENOPERM",
|
||||
message: "/usr/local is not writable by this account and sudo needs a password",
|
||||
command: "sudo npm install -g 9router@latest",
|
||||
},
|
||||
});
|
||||
render(<NineRouterUpdate />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: /aiProxy.update9router/ }));
|
||||
expect(await screen.findByText("sudo npm install -g 9router@latest")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "aiProxy.update9routerStart" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -72,15 +72,15 @@ type Row = {
|
||||
};
|
||||
|
||||
/** One quota window of an account: % used and when it rolls over. */
|
||||
type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
|
||||
export type QuotaWindow = { key: "5h" | "wk"; used: number | null; resetsAt: string | null };
|
||||
|
||||
/**
|
||||
* The 5-hour and weekly windows of an account, from Claude's `fiveHour` /
|
||||
* `sevenDay` or Codex's `5h` / `wk` rollout windows. A window whose reset has
|
||||
* passed is empty again (the reading predates it), as the balancer treats it.
|
||||
*/
|
||||
function windowsOf(
|
||||
a: AccountPool["accounts"][number] | AccountPool["codex"][number]
|
||||
export function windowsOf(
|
||||
a: Pick<AccountPool["accounts"][number] | AccountPool["codex"][number], "quota">
|
||||
): QuotaWindow[] {
|
||||
const q = a.quota as
|
||||
| {
|
||||
@@ -558,7 +558,7 @@ function AccountCard({
|
||||
* One window of an account's quota, shown as what is LEFT (100% = untouched,
|
||||
* 0% = used up), with when it resets.
|
||||
*/
|
||||
function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
export function QuotaBar({ window: w }: { window: QuotaWindow }) {
|
||||
const { t } = useTranslation("users");
|
||||
const left = w.used == null ? null : Math.max(0, Math.min(100, 100 - Math.round(w.used)));
|
||||
const resets = untilText(w.resetsAt);
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* @file MemberTmux.tsx
|
||||
* @description Each member's own tmux server as part of the Administration
|
||||
* page's users table (one Linux user per account only, `GET /api/users/tmux`,
|
||||
* `useMemberTmux`): a `TmuxCell` per row — the version it runs, a badge when
|
||||
* older than the installed one, its sessions, whether an agent of theirs is
|
||||
* working — and `MemberTmuxBar` above the table, so an admin sees who still
|
||||
* runs an old tmux after an upgrade.
|
||||
* An admin restarts one member's server, or every idle outdated one at once
|
||||
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
|
||||
* time with their own password again; a restart ends that member's panes,
|
||||
* and the next start runs the installed tmux. An analyst only reads.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { RotateCcw, TerminalSquare } from "lucide-react";
|
||||
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
|
||||
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||
const err = e as Error & { code?: string };
|
||||
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`tmux.errors.${err.code}`)
|
||||
: err.message;
|
||||
}
|
||||
|
||||
const BADGE = "badge ml-1.5 py-0 text-[10px]";
|
||||
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
|
||||
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
|
||||
|
||||
/** `GET /api/users/tmux`; `available` false without one Linux user per account. */
|
||||
export function useMemberTmux(enabled = true) {
|
||||
const [data, setData] = useState<MemberTmux | null>(null);
|
||||
const reload = useCallback(
|
||||
() =>
|
||||
api.users
|
||||
.tmux()
|
||||
.then(setData)
|
||||
.catch(() => setData(null)),
|
||||
[]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (enabled) void reload();
|
||||
}, [enabled, reload]);
|
||||
return { data, reload };
|
||||
}
|
||||
|
||||
/** One member's tmux server as a users-table cell: version, badges, sessions, agents. */
|
||||
export function TmuxCell({ row }: { row?: MemberTmuxRow }) {
|
||||
const { t } = useTranslation("users");
|
||||
if (!row?.running) return <span className="text-sm text-fg-muted">—</span>;
|
||||
return (
|
||||
<div className="text-sm text-fg-secondary">
|
||||
<span className="font-mono">{row.stuck ? "?" : (row.version ?? "?")}</span>
|
||||
{row.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
|
||||
{row.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
|
||||
<div
|
||||
className="text-[11px] text-fg-muted"
|
||||
title={
|
||||
row.startedAt
|
||||
? t("tmux.since", { at: formatDateTime(new Date(row.startedAt).toISOString()) })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t("tmux.sessionsCount", { count: row.sessions ?? 0 })} ·{" "}
|
||||
{row.busy > 0 ? t("tmux.working", { count: row.busy }) : t("tmux.idle")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Above the users table: the installed tmux, the not-yet-installed tmux-member
|
||||
* warning, and (an admin) "Restart N idle members". Nothing without S3.
|
||||
*/
|
||||
export function MemberTmuxBar({
|
||||
data,
|
||||
isAdmin,
|
||||
onRestart,
|
||||
}: {
|
||||
data: MemberTmux | null;
|
||||
isAdmin: boolean;
|
||||
onRestart: (target: "idle") => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
if (!data?.available) return null;
|
||||
const idle = data.users.filter((u) => u.running && u.outdated && u.busy === 0);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs" data-testid="member-tmux">
|
||||
<span className="inline-flex items-center gap-1.5 text-fg-muted">
|
||||
<TerminalSquare className="w-3.5 h-3.5" />
|
||||
{t("tmux.description", { version: data.installed ?? "?" })}
|
||||
</span>
|
||||
{!data.matched && <span className="text-status-warning">{t("tmux.notMatched")}</span>}
|
||||
{isAdmin && idle.length > 0 && (
|
||||
<button type="button" onClick={() => onRestart("idle")} className="btn-ghost text-xs">
|
||||
<RotateCcw className="w-3.5 h-3.5" /> {t("tmux.restartIdle", { count: idle.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An admin's restart of one member's tmux, or of every idle outdated one. */
|
||||
export function TmuxRestartDialog({
|
||||
target,
|
||||
data,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
target: MemberTmuxRow | "idle";
|
||||
data: MemberTmux | null;
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const idle = (data?.users ?? []).filter((u) => u.running && u.outdated && u.busy === 0);
|
||||
return <RestartDialog target={target} idle={idle} onClose={onClose} onDone={onDone} />;
|
||||
}
|
||||
|
||||
function RestartDialog({
|
||||
target,
|
||||
idle,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
target: MemberTmuxRow | "idle";
|
||||
idle: MemberTmuxRow[];
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const one = target === "idle" ? null : target;
|
||||
|
||||
const submit = async () => {
|
||||
if (!current || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (one) {
|
||||
await api.users.tmuxRestart(one.id, current);
|
||||
onDone(t("tmux.done", { name: one.username }));
|
||||
} else {
|
||||
const r = await api.users.tmuxRestartIdle(current);
|
||||
// A server that would not stop needs root: say which, the rest went.
|
||||
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
|
||||
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
|
||||
message={
|
||||
one
|
||||
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
|
||||
name: one.username,
|
||||
count: one.sessions ?? 0,
|
||||
})
|
||||
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
|
||||
}
|
||||
confirmLabel={t("tmux.restartButton")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
||||
<div className="mt-1">{children}</div>
|
||||
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
/**
|
||||
* @file MemberTmuxCard.tsx
|
||||
* @description The Administration page's Server-tab tmux card (one Linux user per account only,
|
||||
* `GET /api/users/tmux`): each member's own tmux server — the version it runs
|
||||
* beside the installed one, its sessions, whether an agent of theirs is
|
||||
* working — so an admin sees who still runs an old tmux after an upgrade.
|
||||
* An admin restarts one member's server, or every idle outdated one at once
|
||||
* (`POST /api/users/:id/tmux-restart`, `/api/users/tmux-restart-idle`), each
|
||||
* time with their own password again; a restart ends that member's panes,
|
||||
* and the next start runs the installed tmux. An analyst only reads.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { RefreshCw, RotateCcw, TerminalSquare } from "lucide-react";
|
||||
import { api, type MemberTmux, type MemberTmuxRow } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { ConfirmModal } from "../ConfirmModal";
|
||||
|
||||
function errorText(t: TFunction<"users">, e: unknown): string {
|
||||
const err = e as Error & { code?: string };
|
||||
return err.code && t(`tmux.errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`tmux.errors.${err.code}`)
|
||||
: err.message;
|
||||
}
|
||||
|
||||
const BADGE = "badge ml-1.5 py-0 text-[10px]";
|
||||
const WARN = `${BADGE} bg-status-warning/10 text-status-warning border-status-warning/20`;
|
||||
const DANGER = `${BADGE} bg-status-danger/10 text-status-danger border-status-danger/20`;
|
||||
|
||||
export function MemberTmuxCard({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { t } = useTranslation("users");
|
||||
const [data, setData] = useState<MemberTmux | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// A member row = restart that one; "idle" = every idle outdated member.
|
||||
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
api.users
|
||||
.tmux()
|
||||
.then((r) => {
|
||||
setData(r);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (!data?.available) return null;
|
||||
const rows = data.users.filter((u) => u.running);
|
||||
const idleOutdated = rows.filter((u) => u.outdated && u.busy === 0);
|
||||
const th = "px-5 py-2.5 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
|
||||
return (
|
||||
<section className="card" data-testid="member-tmux">
|
||||
<div className="flex flex-wrap items-center gap-2 px-5 py-4 border-b border-border">
|
||||
<TerminalSquare className="w-4 h-4 text-fg-muted" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-sm font-semibold text-fg-primary">{t("tmux.title")}</h2>
|
||||
<p className="text-xs text-fg-muted">
|
||||
{t("tmux.description", { version: data.installed ?? "?" })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
title={t("tmux.refresh")}
|
||||
aria-label={t("tmux.refresh")}
|
||||
className="btn-icon"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
{isAdmin && idleOutdated.length > 0 && (
|
||||
<button type="button" onClick={() => setRestarting("idle")} className="btn-primary">
|
||||
<RotateCcw className="w-4 h-4" />{" "}
|
||||
{t("tmux.restartIdle", { count: idleOutdated.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!data.matched && (
|
||||
<p className="px-5 py-2.5 text-xs text-status-warning border-b border-border">
|
||||
{t("tmux.notMatched")}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="px-5 py-3 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{rows.length === 0 ? (
|
||||
<p className="px-5 py-8 text-center text-sm text-fg-muted">{t("tmux.empty")}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[560px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("tmux.user")}</th>
|
||||
<th className={th}>{t("tmux.version")}</th>
|
||||
<th className={`${th} text-right`}>{t("tmux.sessions")}</th>
|
||||
<th className={th}>{t("tmux.agents")}</th>
|
||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{rows.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td className="px-5 py-2.5 text-sm text-fg-primary">{u.username}</td>
|
||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||
<span className="font-mono">{u.stuck ? "?" : (u.version ?? "?")}</span>
|
||||
{u.stuck && <span className={DANGER}>{t("tmux.stuck")}</span>}
|
||||
{u.outdated && <span className={WARN}>{t("tmux.outdated")}</span>}
|
||||
{u.startedAt && (
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("tmux.since", {
|
||||
at: formatDateTime(new Date(u.startedAt).toISOString()),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{u.sessions ?? "—"}
|
||||
</td>
|
||||
<td className="px-5 py-2.5 text-sm text-fg-secondary">
|
||||
{u.busy > 0 ? t("tmux.working", { count: u.busy }) : t("tmux.idle")}
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="px-5 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRestarting(u)}
|
||||
title={t("tmux.restart", { name: u.username })}
|
||||
aria-label={t("tmux.restart", { name: u.username })}
|
||||
className="btn-icon"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{restarting && (
|
||||
<RestartDialog
|
||||
target={restarting}
|
||||
idle={idleOutdated}
|
||||
onClose={() => setRestarting(null)}
|
||||
onDone={(msg) => {
|
||||
toast.success(msg);
|
||||
setRestarting(null);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RestartDialog({
|
||||
target,
|
||||
idle,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
target: MemberTmuxRow | "idle";
|
||||
idle: MemberTmuxRow[];
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const one = target === "idle" ? null : target;
|
||||
|
||||
const submit = async () => {
|
||||
if (!current || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (one) {
|
||||
await api.users.tmuxRestart(one.id, current);
|
||||
onDone(t("tmux.done", { name: one.username }));
|
||||
} else {
|
||||
const r = await api.users.tmuxRestartIdle(current);
|
||||
// A server that would not stop needs root: say which, the rest went.
|
||||
if (r.failed.length) toast.error(r.failed.map((f) => f.message).join("\n"));
|
||||
onDone(t("tmux.doneIdle", { names: r.restarted.join(", ") || "—" }));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(errorText(t, e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={one ? t("tmux.restart", { name: one.username }) : t("tmux.restartIdleTitle")}
|
||||
message={
|
||||
one
|
||||
? t(one.busy > 0 ? "tmux.restartHintBusy" : "tmux.restartHint", {
|
||||
name: one.username,
|
||||
count: one.sessions ?? 0,
|
||||
})
|
||||
: t("tmux.restartIdleHint", { names: idle.map((u) => u.username).join(", ") })
|
||||
}
|
||||
confirmLabel={t("tmux.restartButton")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
busy={busy}
|
||||
disabled={!current}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div
|
||||
className="mt-3 space-y-3"
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="whitespace-pre-line text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
||||
<div className="mt-1">{children}</div>
|
||||
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* @file UserDetail.tsx
|
||||
* @description One account, opened IN PLACE under its row of the
|
||||
* Administration page's users table (`?user=<id>`, `GET /api/users/:id/details`).
|
||||
* The row already shows identity, sessions, spend, home and tmux, so
|
||||
* this keeps only what the row cannot: one summary line (last sign-in, last
|
||||
* activity, active sessions and live panes), then three short columns — spend
|
||||
* (30-day bars, by AI tool, top models), the account pool (per tool: pin or
|
||||
* group, each account's quota left beside this user's own 7-day share), and
|
||||
* what runs now (live panes, owned workspaces) — and the five newest sessions. Read-only; Edit stays on
|
||||
* the row.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, Play, RefreshCw } from "lucide-react";
|
||||
import { api, type PoolTool, type UserDetails } from "../../lib/api";
|
||||
import { fmtCost, formatDateTime } from "../../lib/format";
|
||||
import { effectiveSessionStatus, sessionAwaitingReason } from "../../lib/types";
|
||||
import { useSpin } from "../../hooks/useSpin";
|
||||
import { useAiTools } from "../../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../AiToolIcon";
|
||||
import { SessionStatusBadge } from "../StatusBadge";
|
||||
import { MiniChart } from "../admin/MiniChart";
|
||||
import { QuotaBar, windowsOf } from "./AccountPoolCard";
|
||||
|
||||
const POOL_LABEL: Record<PoolTool, string> = {
|
||||
claude: "Claude Code",
|
||||
codex: "Codex",
|
||||
cursor: "Cursor CLI",
|
||||
};
|
||||
const pctText = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
|
||||
|
||||
export function UserDetail({ userId }: { userId: number }) {
|
||||
const { t } = useTranslation("users");
|
||||
const { label: toolLabel } = useAiTools();
|
||||
const [data, setData] = useState<UserDetails | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
api.users
|
||||
.details(userId)
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
[userId]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
useEffect(() => {
|
||||
setData(null);
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (error)
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="user-detail"
|
||||
className="flex flex-wrap items-center gap-3 text-sm text-status-danger"
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 min-w-0">
|
||||
{t("detail.failed")}: {error}
|
||||
</span>
|
||||
<button type="button" onClick={refresh} className="btn-ghost text-xs">
|
||||
{t("common:retry")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
if (!data)
|
||||
return (
|
||||
<p data-testid="user-detail" className="py-4 text-center text-sm text-fg-muted">
|
||||
{t("common:loading")}
|
||||
</p>
|
||||
);
|
||||
|
||||
const u = data.user;
|
||||
const total30 = data.usage.last30d.cost;
|
||||
const pools = (Object.keys(POOL_LABEL) as PoolTool[]).filter(
|
||||
(tool) => (data.pool[tool]?.accounts.length ?? 0) > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="user-detail">
|
||||
{/* One line of what the row does not show. */}
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-xs">
|
||||
<Inline label={t("detail.lastLogin")}>
|
||||
{u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("detail.never")}
|
||||
</Inline>
|
||||
<Inline label={t("detail.lastSession")}>
|
||||
{data.sessions.lastAt ? formatDateTime(data.sessions.lastAt) : "—"}
|
||||
</Inline>
|
||||
<Inline label={t("detail.activeLabel")}>
|
||||
{t("detail.activeValue", { sessions: data.sessions.active, panes: data.panes.length })}
|
||||
</Inline>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
className="btn-ghost ml-auto py-1 text-xs"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />{" "}
|
||||
{t("common:refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
{/* Spend: the month at a glance, then where it went. */}
|
||||
<Panel title={t("detail.spendDaily")}>
|
||||
<MiniChart
|
||||
kind="bar"
|
||||
label={t("detail.spendDaily")}
|
||||
points={data.usage.daily.map((d) => ({ x: d.date.slice(5), y: d.cost }))}
|
||||
format={(v) => fmtCost(v)}
|
||||
/>
|
||||
{data.usage.byTool.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("detail.noUsage")}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5 pt-1">
|
||||
{data.usage.byTool.map((r) => (
|
||||
<ShareRow
|
||||
key={r.key}
|
||||
label={
|
||||
<span className="flex items-center gap-1.5">
|
||||
<AiToolIcon
|
||||
tool={{ id: r.key, label: toolLabel(r.key) }}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
{toolLabel(r.key)}
|
||||
</span>
|
||||
}
|
||||
cost={r.cost}
|
||||
total={total30}
|
||||
/>
|
||||
))}
|
||||
{data.usage.byModel.slice(0, 3).map((r) => (
|
||||
<ShareRow
|
||||
key={`m:${r.key}`}
|
||||
label={<span className="font-mono text-[11px] text-fg-muted">{r.key}</span>}
|
||||
cost={r.cost}
|
||||
total={total30}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Pool: what they may run on, and how much of it is theirs. */}
|
||||
<Panel title={t("detail.pool")}>
|
||||
{pools.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("detail.poolNone")}</p>
|
||||
) : (
|
||||
pools.map((tool) => {
|
||||
const p = data.pool[tool]!;
|
||||
return (
|
||||
<div key={tool} className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<AiToolIcon tool={{ id: tool, label: POOL_LABEL[tool] }} className="h-4 w-4" />
|
||||
<span className="font-medium text-fg-primary">{POOL_LABEL[tool]}</span>
|
||||
<span className="text-fg-muted">
|
||||
·{" "}
|
||||
{p.pin
|
||||
? t("detail.poolPinned", { account: p.pin })
|
||||
: p.group
|
||||
? t("detail.poolGroup", { name: p.group.name })
|
||||
: t("detail.poolShared")}
|
||||
</span>
|
||||
</div>
|
||||
{p.accounts.map((a) => {
|
||||
const share = a.spend7d > 0 ? (a.userSpend7d / a.spend7d) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={a.name}
|
||||
className={`rounded-md border border-border px-2.5 py-2 space-y-1 ${a.allowed ? "" : "opacity-60"}`}
|
||||
data-testid="user-pool-account"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate font-medium text-fg-primary">
|
||||
{a.name}
|
||||
{a.expired && (
|
||||
<span className="ml-1.5 text-status-danger">{t("pool.expired")}</span>
|
||||
)}
|
||||
{!a.allowed && (
|
||||
<span className="ml-1.5 text-fg-muted">
|
||||
{t("detail.poolNotAllowed")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{tool !== "cursor" && (
|
||||
<span
|
||||
className="flex-shrink-0 tabular-nums text-fg-secondary"
|
||||
title={t("detail.poolTotal7d", { cost: fmtCost(a.spend7d) })}
|
||||
>
|
||||
{t("detail.poolTheirs7d", { cost: fmtCost(a.userSpend7d) })}
|
||||
{a.spend7d > 0 && (
|
||||
<span className="text-fg-muted"> ({pctText(share)})</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{tool !== "cursor" &&
|
||||
windowsOf(a).map((w) => <QuotaBar key={w.key} window={w} />)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Now: live panes and the workspaces they own. */}
|
||||
<Panel title={t("detail.nowTitle")}>
|
||||
{data.panes.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("detail.noPanes")}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{data.panes.map((p) => (
|
||||
<li key={p.id} className="text-xs">
|
||||
<Link
|
||||
to={`/workspace?lane=${p.laneId}&run=${encodeURIComponent(p.id)}`}
|
||||
className="inline-flex items-center gap-1.5 text-fg-primary hover:text-accent"
|
||||
>
|
||||
<Play className="h-3 w-3 text-status-success" />
|
||||
{p.laneTitle || p.laneCwd}
|
||||
</Link>
|
||||
<span className="text-fg-muted">
|
||||
{" "}
|
||||
· {toolLabel(p.tool)}
|
||||
{p.account ? ` · ${p.account}` : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{data.workspaces.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 border-t border-border pt-2">
|
||||
{data.workspaces.map((w) => (
|
||||
<Link
|
||||
key={w.id}
|
||||
to={`/workspace?lane=${w.id}`}
|
||||
title={w.cwd}
|
||||
className="badge py-0.5 text-[11px] hover:text-accent"
|
||||
>
|
||||
{w.livePanes > 0 && (
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-status-success" />
|
||||
)}
|
||||
{w.title || w.cwd.split("/").pop()}
|
||||
<span className="ml-1 text-fg-muted">{w.sessions}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{data.recentSessions.length > 0 && (
|
||||
<Panel title={t("detail.recentTitle")}>
|
||||
<ul className="divide-y divide-border">
|
||||
{data.recentSessions.slice(0, 5).map((s) => (
|
||||
<li key={s.id} className="flex items-center gap-3 py-1.5 text-xs">
|
||||
<SessionStatusBadge
|
||||
status={effectiveSessionStatus(s)}
|
||||
reason={sessionAwaitingReason(s)}
|
||||
/>
|
||||
<Link
|
||||
to={`/sessions/${s.id}`}
|
||||
className="min-w-0 flex-1 truncate text-fg-primary hover:text-accent"
|
||||
>
|
||||
{s.name || s.id.slice(0, 8)}
|
||||
</Link>
|
||||
<span className="flex-shrink-0 text-fg-muted">{toolLabel(s.tool || "claude")}</span>
|
||||
<span className="w-32 flex-shrink-0 text-right text-fg-muted">
|
||||
{formatDateTime(s.last_activity || s.started_at)}
|
||||
</span>
|
||||
<span className="w-16 flex-shrink-0 text-right font-mono text-fg-secondary">
|
||||
{s.cost ? fmtCost(s.cost) : "—"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Inline({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<span>
|
||||
<span className="text-fg-muted">{label}: </span>
|
||||
<span className="text-fg-primary">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="card p-3 space-y-2.5 min-w-0">
|
||||
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** One slice of the 30-day spend: label, $, and a thin bar of its share. */
|
||||
function ShareRow({ label, cost, total }: { label: ReactNode; cost: number; total: number }) {
|
||||
const share = total > 0 ? (cost / total) * 100 : 0;
|
||||
return (
|
||||
<li className="space-y-0.5">
|
||||
<div className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="min-w-0 truncate text-fg-secondary">{label}</span>
|
||||
<span className="flex-shrink-0 tabular-nums text-fg-primary">
|
||||
{fmtCost(cost)} <span className="text-fg-muted">{Math.round(share)}%</span>
|
||||
</span>
|
||||
</div>
|
||||
<span className="block h-1 overflow-hidden rounded-full bg-surface-4">
|
||||
<span
|
||||
className="block h-full rounded-full bg-accent-solid"
|
||||
style={{ width: `${Math.min(100, share)}%` }}
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
+57
-21
@@ -1,18 +1,20 @@
|
||||
/**
|
||||
* @file MemberTmuxCard.test.tsx
|
||||
* @description The Users page's members' tmux card: hidden without one Linux
|
||||
* user per account, lists each running member server with its version and a
|
||||
* badge when it is older than the installed tmux, warns until tmux-member is
|
||||
* installed, and an admin restarts one member — or every idle outdated one,
|
||||
* the working member left out — only with their own password typed; an
|
||||
* analyst sees no restart control.
|
||||
* @file MemberTmux.test.tsx
|
||||
* @description Each member's tmux server as part of the users table
|
||||
* (`MemberTmux.tsx`): the bar above the table is hidden without one Linux
|
||||
* user per account, names the installed tmux and warns until tmux-member is
|
||||
* installed; a row's cell shows the version with a badge when it is older than
|
||||
* the installed tmux, and "—" for a member with no server; an admin restarts
|
||||
* one member — or every idle outdated one, the working member left out — only
|
||||
* with their own password typed; an analyst gets no "Restart idle".
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { MemberTmux } from "../../../lib/api";
|
||||
import type { MemberTmux, MemberTmuxRow } from "../../../lib/api";
|
||||
|
||||
const users = vi.hoisted(() => ({
|
||||
tmux: vi.fn(),
|
||||
@@ -25,7 +27,41 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
});
|
||||
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { MemberTmuxCard } from "../MemberTmuxCard";
|
||||
import { MemberTmuxBar, TmuxCell, TmuxRestartDialog, useMemberTmux } from "../MemberTmux";
|
||||
|
||||
/** The users table's use of it, reduced: the bar, one cell and restart per row. */
|
||||
function Harness({ isAdmin }: { isAdmin: boolean }) {
|
||||
const { data, reload } = useMemberTmux();
|
||||
const [target, setTarget] = useState<MemberTmuxRow | "idle" | null>(null);
|
||||
return (
|
||||
<>
|
||||
<MemberTmuxBar data={data} isAdmin={isAdmin} onRestart={setTarget} />
|
||||
{data?.available &&
|
||||
data.users.map((u) => (
|
||||
<div key={u.id} data-testid={`row-${u.username}`}>
|
||||
<span>{u.username}</span>
|
||||
<TmuxCell row={u} />
|
||||
{isAdmin && u.running && (
|
||||
<button type="button" onClick={() => setTarget(u)}>
|
||||
Restart {u.username}'s tmux
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{target && (
|
||||
<TmuxRestartDialog
|
||||
target={target}
|
||||
data={data}
|
||||
onClose={() => setTarget(null)}
|
||||
onDone={() => {
|
||||
setTarget(null);
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DATA: MemberTmux = {
|
||||
available: true,
|
||||
@@ -52,33 +88,33 @@ beforeEach(() => {
|
||||
users.tmux.mockResolvedValue(DATA);
|
||||
});
|
||||
|
||||
describe("MemberTmuxCard", () => {
|
||||
it("renders nothing without one Linux user per account", async () => {
|
||||
describe("MemberTmux", () => {
|
||||
it("shows no bar without one Linux user per account", async () => {
|
||||
users.tmux.mockResolvedValue({ available: false, users: [] });
|
||||
const { container } = render(<MemberTmuxCard isAdmin />);
|
||||
render(<Harness isAdmin />);
|
||||
await waitFor(() => expect(users.tmux).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(screen.queryByTestId("member-tmux")).toBeNull();
|
||||
});
|
||||
|
||||
it("lists running servers and marks the ones older than the installed tmux", async () => {
|
||||
render(<MemberTmuxCard isAdmin={false} />);
|
||||
expect(await screen.findByText("mia")).toBeInTheDocument();
|
||||
expect(screen.queryByText("bob")).toBeNull();
|
||||
it("marks the servers older than the installed tmux, and a member with none", async () => {
|
||||
render(<Harness isAdmin={false} />);
|
||||
expect(await screen.findByText(/Installed: tmux 3\.8/)).toBeInTheDocument();
|
||||
expect(screen.getAllByText("older than installed")).toHaveLength(2);
|
||||
expect(screen.getByText(/Installed: tmux 3\.8/)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("row-mia").textContent).toContain("2 sessions");
|
||||
expect(screen.getByTestId("row-bob").textContent).toContain("—");
|
||||
// An analyst reads only.
|
||||
expect(screen.queryByRole("button", { name: /Restart/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("warns until tmux-member is installed", async () => {
|
||||
users.tmux.mockResolvedValue({ ...DATA, matched: false });
|
||||
render(<MemberTmuxCard isAdmin />);
|
||||
render(<Harness isAdmin />);
|
||||
expect(await screen.findByText(/install-system/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("restarts one member only with the admin's password", async () => {
|
||||
users.tmuxRestart.mockResolvedValue({});
|
||||
render(<MemberTmuxCard isAdmin />);
|
||||
render(<Harness isAdmin />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Restart mia's tmux" }));
|
||||
expect(screen.getByText(/Every pane of mia closes \(2 sessions\)/)).toBeInTheDocument();
|
||||
const go = screen.getByRole("button", { name: "Restart" });
|
||||
@@ -90,7 +126,7 @@ describe("MemberTmuxCard", () => {
|
||||
|
||||
it("restarts every idle outdated member at once, the working one left out", async () => {
|
||||
users.tmuxRestartIdle.mockResolvedValue({ restarted: ["mia"], skipped: ["zed"], failed: [] });
|
||||
render(<MemberTmuxCard isAdmin />);
|
||||
render(<Harness isAdmin />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Restart 1 idle member" }));
|
||||
expect(screen.getByText(/no agent working: mia\./)).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText("Your password"), "pw");
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @file useCcTarget — whose Claude Code config the Config Explorer manages for
|
||||
* this login (GET /api/cc-config/target). Local mode and an admin: the host's,
|
||||
* answered at once without a request (nothing changes for them). A non-admin
|
||||
* in server mode asks the server: `own` when they have their own Linux account
|
||||
* (S3) — Settings then shows "Your Claude Code config" — else `none` and the
|
||||
* page stays hidden. The server's refusal (403 ENOACCOUNT/EADMIN) is the rule;
|
||||
* this only hides what would be refused.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type CcTarget } from "../lib/api";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
|
||||
const HOST: CcTarget = { kind: "host", readOnly: false, claudeHome: null };
|
||||
|
||||
/** `null` while a non-admin's answer is on its way. */
|
||||
export function useCcTarget(): CcTarget | null {
|
||||
const { isAdmin } = usePermissions();
|
||||
const [target, setTarget] = useState<CcTarget | null>(isAdmin ? HOST : null);
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
setTarget(HOST);
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
api.ccConfig
|
||||
.target()
|
||||
.then((t) => {
|
||||
if (live) setTarget(t);
|
||||
})
|
||||
.catch(() => {
|
||||
if (live) setTarget({ kind: "none", readOnly: true, claudeHome: null });
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [isAdmin]);
|
||||
return target;
|
||||
}
|
||||
@@ -27,9 +27,9 @@ const FOCUSABLE =
|
||||
*/
|
||||
export function useDialog(
|
||||
open: boolean,
|
||||
panelRef: RefObject<HTMLElement>,
|
||||
panelRef: RefObject<HTMLElement | null>,
|
||||
onClose?: () => void,
|
||||
initialFocus?: RefObject<HTMLElement>
|
||||
initialFocus?: RefObject<HTMLElement | null>
|
||||
): void {
|
||||
// Read through a ref so an inline `onClose` does not re-run the effect, which
|
||||
// would steal focus back to the first control on every parent render.
|
||||
|
||||
@@ -63,7 +63,7 @@ type MessageHandler = (msg: WSMessage) => void;
|
||||
* auto-reconnects with capped exponential backoff on close - plus an
|
||||
* immediate reconnect attempt on tab focus/network-online/visibility-change
|
||||
* so the socket recovers quickly after a server restart or laptop sleep.
|
||||
* Guards against React 18 StrictMode's mount→cleanup→remount cycle opening a
|
||||
* Guards against React StrictMode's mount→cleanup→remount cycle opening a
|
||||
* duplicate socket (see the inline comment in `connect`).
|
||||
* @param onMessage Called with every message parsed from the socket; the
|
||||
* latest reference is used even across reconnects (no stale closures).
|
||||
@@ -73,7 +73,7 @@ export function useWebSocket(onMessage: MessageHandler) {
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const handlersRef = useRef<MessageHandler>(onMessage);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const mountedRef = useRef(true);
|
||||
const reconnectAttempts = useRef(0);
|
||||
|
||||
@@ -82,7 +82,7 @@ export function useWebSocket(onMessage: MessageHandler) {
|
||||
const connect = useCallback(() => {
|
||||
if (!mountedRef.current) return;
|
||||
// Don't open a second socket if one is already alive or in flight.
|
||||
// Without this, React 18 StrictMode (mount → cleanup → remount in dev)
|
||||
// Without this, React StrictMode (mount → cleanup → remount in dev)
|
||||
// and the close→reconnect race could leave two sockets connected at the
|
||||
// same time. Both would receive every server broadcast, producing
|
||||
// duplicate stream_event deltas (doubled text, duplicate assistant
|
||||
|
||||
@@ -1,37 +1,4 @@
|
||||
{
|
||||
"title": "Administration",
|
||||
"description": "Accounts and the server AgentHub runs on.",
|
||||
"tabs": {
|
||||
"users": "Users",
|
||||
"server": "Server"
|
||||
},
|
||||
"server": {
|
||||
"title": "Server",
|
||||
"uptime": "up {{v}}",
|
||||
"uptimeDays": "{{d}} d {{h}} h",
|
||||
"uptimeHours": "{{h}} h {{m}} min",
|
||||
"cpu": "CPU",
|
||||
"cores": "{{n}} cores · load {{load}}",
|
||||
"cpuHelp": "Share of the whole machine: 100% means every core is busy.",
|
||||
"memory": "Memory",
|
||||
"swap": "Swap",
|
||||
"homesDisk": "Homes disk",
|
||||
"homesDiskHelp": "The disk that holds the users' home folders.",
|
||||
"free": "free",
|
||||
"usageTitle": "Usage by user",
|
||||
"usageHelp": "Live, sampled over {{s}} s. A process counts for a user when it runs as their Linux account or inside one of their workspaces' sessions. Memory is resident (RSS); shared pages count once per process. Click a row for its busiest processes.",
|
||||
"usageUnavailable": "Per-user usage is read from /proc and is only available on Linux.",
|
||||
"who": "User",
|
||||
"processes": "Processes",
|
||||
"home": "Home",
|
||||
"sampling": "Sampling…",
|
||||
"hostGroup": "Host account (AgentHub and other host processes)",
|
||||
"systemGroup": "System (root and other accounts)",
|
||||
"disks": "Disks",
|
||||
"mount": "Mount",
|
||||
"device": "Device",
|
||||
"used": "Used",
|
||||
"homesBadge": "homes",
|
||||
"gpus": "GPUs"
|
||||
}
|
||||
"description": "Accounts and the server AgentHub runs on."
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"title": "Claude Config Explorer",
|
||||
"subtitle": "Your Claude Code setup at a glance - plugins, skills, subagents, MCP servers, hooks, and settings.",
|
||||
"own": {
|
||||
"label": "Your Claude Code config — {{home}}",
|
||||
"subtitle": "Only your own account's setup: every change here runs as your account and reaches only your own sessions."
|
||||
},
|
||||
"refresh": "Refresh",
|
||||
"refreshing": "Refreshing…",
|
||||
"lastUpdated": "Last updated {{time}}",
|
||||
@@ -30,6 +34,7 @@
|
||||
},
|
||||
"overview": {
|
||||
"rootsTitle": "Roots",
|
||||
"claudeHome": "Your .claude",
|
||||
"projectClaudeDir": "Project .claude",
|
||||
"projectRoot": "Project root",
|
||||
"claudeJson": "claude.json",
|
||||
|
||||
@@ -30,14 +30,6 @@
|
||||
"since": "waiting {{ago}}"
|
||||
},
|
||||
"health": {
|
||||
"runtime": "Runtime",
|
||||
"cores_one": "{{count}} core",
|
||||
"cores_other": "{{count}} cores",
|
||||
"uptime": "Uptime",
|
||||
"cpu": "CPU (1/5/15m)",
|
||||
"nodeRss": "Node RSS",
|
||||
"hostMemory": "Host Memory",
|
||||
"hostMemoryTip": "Host Memory: {{pct}}% used\n{{used}} / {{total}}",
|
||||
"heap": "V8 Heap",
|
||||
"heapTip": "V8 Heap: {{pct}}% used\n{{used}} / {{limit}} limit\nAllocated: {{allocated}}\nExternal: {{external}}",
|
||||
"storage": "Storage",
|
||||
@@ -50,12 +42,7 @@
|
||||
"events": "Events",
|
||||
"score": "Health Score",
|
||||
"formula": "Formula",
|
||||
"formulaTip": "Composite index formula:\n0.4 × Success rate ({{success}}%)\n+ 0.25 × Cache hit ({{cache}}%)\n+ 0.25 × (100 − Error rate) ({{noError}}%)\n+ 0.10 × (100 − Heap%) ({{noHeap}}%)\n= {{score}}",
|
||||
"scoreTip": "Score: {{score}} / 100\n\n• Success rate (40%): {{success}}%\n• Cache hit (25%): {{cache}}%\n• Error avoidance (25%): {{noError}}%\n• Memory health (10%): {{noHeap}}%",
|
||||
"cache": "Cache",
|
||||
"cacheTip": "Cache hit rate: {{pct}}%\nHits: {{hits}}\nMisses: {{misses}}",
|
||||
"errors": "Errors",
|
||||
"errorsTip": "Error rate: {{pct}}%\n<5% = healthy, 5–15% = warning, >15% = critical",
|
||||
"cacheTip": "Transcript cache hit rate: {{pct}}% (shown, not scored — it restarts empty with the server)",
|
||||
"compact": "Compact",
|
||||
"compactTip": "Transcript compactions: {{count}}\nReduces the context window by summarizing turns.",
|
||||
"saved": "Saved",
|
||||
@@ -65,9 +52,10 @@
|
||||
"modelTip": "{{model}}\nInput: {{input}}\nOutput: {{output}}\nCache read: {{cacheRead}}\nShare: {{pct}}%",
|
||||
"noModelData": "No model data",
|
||||
"concurrency": "Concurrency",
|
||||
"intervals_one": "{{count}} interval",
|
||||
"intervals_other": "{{count}} intervals",
|
||||
"intervals_one": "{{count}} agent type",
|
||||
"intervals_other": "{{count}} agent types",
|
||||
"concurrencyTip": "Peak: {{peak}} parallel sessions\nActive intervals: {{active}}\nAvg: {{avg}}",
|
||||
"laneTip": "{{name}}\n{{count}} agents\nRuns {{start}}–{{end}}% of the session (avg)",
|
||||
"noConcurrencyData": "No concurrency data",
|
||||
"peak": "Peak",
|
||||
"peakTip": "Peak: {{count}} sessions running simultaneously.",
|
||||
@@ -99,11 +87,101 @@
|
||||
"synchronous": "Synchronous",
|
||||
"autoVacuum": "Auto-vacuum",
|
||||
"foreignKeys": "Foreign keys",
|
||||
"busyTimeout": "Busy timeout"
|
||||
"busyTimeout": "Busy timeout",
|
||||
"groupApp": "AgentHub",
|
||||
"groupAppHint": "How the dashboard itself is doing: score, database, sessions at once",
|
||||
"groupMachine": "Machine",
|
||||
"groupMachineHint": "The computer AgentHub runs on: CPU, memory, disks, processes",
|
||||
"groupActivity": "Agent activity",
|
||||
"groupActivityHint": "What the agents used: tokens by model, tools, subagents",
|
||||
"scoreFormula": "100 minus what each check costs:",
|
||||
"scoreResult": "= {{score}} / 100",
|
||||
"check": {
|
||||
"loop": "AgentHub response",
|
||||
"cpu": "CPU wait",
|
||||
"memory": "Memory wait",
|
||||
"io": "Disk wait",
|
||||
"ram": "RAM in use",
|
||||
"disk": "Data disk",
|
||||
"heap": "V8 heap",
|
||||
"hooks": "Last hook",
|
||||
"agents": "Agent errors 24h"
|
||||
},
|
||||
"checkTip": {
|
||||
"loop": "How long AgentHub's event loop was held up (p99 over the last minute). Every page, hook and terminal keystroke waits this long.",
|
||||
"cpu": "CPU pressure (Linux PSI, last 10 s): share of time tasks waited for a free CPU — the machine feels slow above a few %.",
|
||||
"cpuLoad": "Load per core (1 min) — this machine has no PSI, so CPU wait is estimated from load.",
|
||||
"memory": "Memory pressure (Linux PSI, last 10 s): share of time tasks stalled reclaiming or swapping memory.",
|
||||
"io": "Disk I/O pressure (Linux PSI, last 10 s): share of time tasks waited on the disk — a slow or busy HDD shows here.",
|
||||
"ram": "Memory in use (total minus available).",
|
||||
"disk": "How full the disk holding AgentHub's database is ({{mount}}).",
|
||||
"heap": "AgentHub's V8 heap against its limit.",
|
||||
"hooks": "Minutes since the last hook event, counted only while an agent is working: silence then means the dashboard stopped seeing it.",
|
||||
"agents": "Agents that ended in the last 24 h with an error (errors / finished; needs at least 5)."
|
||||
},
|
||||
"checkNa": "Not measured here — costs nothing.",
|
||||
"checkThresholds": "Amber from {{warn}}{{unit}}, red from {{bad}}{{unit}}",
|
||||
"checkPenalty": "Costs {{n}} of up to {{max}} points",
|
||||
"hooksMissing": "not installed",
|
||||
"hooksIdle": "idle",
|
||||
"minutesAgo": "{{n}} min ago",
|
||||
"verdict": {
|
||||
"ok": "Healthy",
|
||||
"warn": "Degraded",
|
||||
"bad": "Unhealthy"
|
||||
},
|
||||
"allOk": "Every check is fine",
|
||||
"badCount_one": "{{count}} red",
|
||||
"badCount_other": "{{count}} red",
|
||||
"warnCount_one": "{{count}} amber",
|
||||
"warnCount_other": "{{count}} amber",
|
||||
"lagGroup": "Lag",
|
||||
"systemGroup": "Capacity & signals"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
"body": "This page failed to load. Reloading usually fixes it — for example after the dashboard was updated.",
|
||||
"reload": "Reload"
|
||||
},
|
||||
"host": {
|
||||
"uptimeDays": "{{d}} d {{h}} h",
|
||||
"uptimeHours": "{{h}} h {{m}} min",
|
||||
"cpu": "CPU",
|
||||
"cpuHelp": "Share of the whole machine: 100% means every core is busy.",
|
||||
"memory": "Memory",
|
||||
"swap": "Swap",
|
||||
"free": "free",
|
||||
"usageUnavailable": "Per-user usage is read from /proc and is only available on Linux.",
|
||||
"hostGroup": "Host account (AgentHub and other host processes)",
|
||||
"systemGroup": "System (root and other accounts)",
|
||||
"homesBadge": "homes",
|
||||
"historyTitle": "History",
|
||||
"historyHelp": "Kept in memory, one point every {{s}} s, since the server started at {{at}}.",
|
||||
"historyCpu": "CPU (% of the machine)",
|
||||
"historyMemory": "Memory used (%)",
|
||||
"range": "Range",
|
||||
"range1h": "1 h",
|
||||
"range6h": "6 h",
|
||||
"range24h": "24 h",
|
||||
"coreN": "CPU {{n}}",
|
||||
"threads": "{{n}} threads",
|
||||
"physical": "{{n}} cores",
|
||||
"perSecond": "{{v}}/s",
|
||||
"temperatures": "Temperatures",
|
||||
"topProcesses": "Busiest processes",
|
||||
"cpuModel": "CPU",
|
||||
"storageNetwork": "Disks & network",
|
||||
"processCount": "{{n}} processes",
|
||||
"title": "Server",
|
||||
"os": "System",
|
||||
"uptime": "Uptime",
|
||||
"load": "Load (1/5/15 min)",
|
||||
"agenthubTip": "pid {{pid}} · Node {{node}}",
|
||||
"sampling": "Sampling…",
|
||||
"sampled": "{{s}} s sample",
|
||||
"historyNone": "No history yet — the server keeps it in memory on Linux, from the moment it starts.",
|
||||
"agenthub": "AgentHub",
|
||||
"usersTitle": "Users · CPU / RAM now",
|
||||
"usersCount": "{{n}} users"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
"tableAgents": "Agents",
|
||||
"tableCost": "Cost",
|
||||
"tableDirectory": "Directory",
|
||||
"tableUser": "User",
|
||||
"tableTool": "AI Tool",
|
||||
"tableActions": "Actions",
|
||||
"noOwner": "No owner",
|
||||
"moreActions": "More actions",
|
||||
"viewDetails": "View details",
|
||||
"defaultName": "Session ",
|
||||
"detail": {
|
||||
"waitingBanner": {
|
||||
|
||||
@@ -759,6 +759,29 @@
|
||||
"description": "For example 9Router, OpenRouter or a self-hosted gateway. Save one here, then pick it for a session in the workspace prompt box.",
|
||||
"add": "Add profile",
|
||||
"config9router": "Config 9router",
|
||||
"update9router": "Update 9router",
|
||||
"update9routerAvailable": "A newer 9router is available",
|
||||
"update9routerCurrent": "Installed",
|
||||
"update9routerLatest": "Latest",
|
||||
"update9routerState": "State",
|
||||
"update9routerRunning": "Running",
|
||||
"update9routerRunningUnit": "Running (9router.service)",
|
||||
"update9routerStopped": "Not running",
|
||||
"update9routerExplain": "Every session using 9router is disconnected while it updates: 9router is shut down, updated with npm, then started again hidden (tray, no browser).",
|
||||
"update9routerExplainStopped": "9router is not running: it is updated with npm and left off.",
|
||||
"update9routerStep": {
|
||||
"shutdown": "Disconnect and shut down 9router",
|
||||
"update": "Run the update (npm install -g 9router@latest)",
|
||||
"relaunch": "Start 9router again, hidden"
|
||||
},
|
||||
"update9routerDone": "9router {{version}} is running.",
|
||||
"update9routerDoneStopped": "9router {{version}} is installed and left off, as it was.",
|
||||
"update9routerFailed": "The update did not finish — see the steps and the log.",
|
||||
"update9routerLog": "npm output",
|
||||
"update9routerUpToDate": "9router is already the latest version.",
|
||||
"update9routerCopy": "Copy command",
|
||||
"update9routerClose": "Close",
|
||||
"update9routerStart": "Update now",
|
||||
"edit": "Edit",
|
||||
"remove": "Remove",
|
||||
"empty": "No proxy profile saved yet. Native runs against the vendor's own API.",
|
||||
@@ -884,7 +907,6 @@
|
||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||
"gitHubSub": "ED25519 key — the git@… SSH address",
|
||||
"gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
|
||||
"gitHubStepClone": "Clone with the SSH address the repository page shows:",
|
||||
"awsTitle": "AWS CodeCommit",
|
||||
"awsSub": "RSA 4096 key — AWS accepts only RSA",
|
||||
"stepCopy": "Copy the public key.",
|
||||
@@ -894,7 +916,6 @@
|
||||
"awsRegion": "Region",
|
||||
"awsSave": "Save",
|
||||
"awsSaved": "SSH key ID saved for {{region}}",
|
||||
"awsReady": "Ready. Clone with the plain address CodeCommit shows — in a terminal or in Add workspace → Clone:",
|
||||
"otherTitle": "Other SSH hosts (advanced)",
|
||||
"otherHint": "Only for a host that logs you in with a user name other than git. Then a plain ssh://<host>/… address logs in as that user.",
|
||||
"otherHost": "Host",
|
||||
@@ -1035,6 +1056,10 @@
|
||||
"title": "Claude Code",
|
||||
"description": "Claude Code's own configuration, and what AgentHub reads from it."
|
||||
},
|
||||
"claudeCodeOwn": {
|
||||
"title": "Your Claude Code config",
|
||||
"description": "The skills, agents, plugins, MCP servers and settings your own sessions use."
|
||||
},
|
||||
"codex": {
|
||||
"title": "Codex",
|
||||
"description": "Codex's own configuration, and what AgentHub reads from it."
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
"autoFixHint": "Open a Claude Code session in the install folder with this error, so an AI agent fixes it and finishes the update.",
|
||||
"autoFixWorkspace": "AgentHub update",
|
||||
"autoFixError": "Could not start the Auto fix session",
|
||||
"backgroundNote": "The update keeps running in the background — the page reloads itself when the new version is up.",
|
||||
"chipTitle": "Updating to {{version}}…",
|
||||
"chipHint": "The update runs in the background — click to show it",
|
||||
"pluginStep.marketplace_update": "Step {{index}}/{{total}}: refreshing the marketplace",
|
||||
|
||||
@@ -315,7 +315,9 @@
|
||||
"doneIdle": "Restarted: {{names}}",
|
||||
"errors": {
|
||||
"ETMUXSTUCK": "The server did not stop. Run the `sudo kill -9` command shown, as root, on the server."
|
||||
}
|
||||
},
|
||||
"sessionsCount_one": "{{count}} session",
|
||||
"sessionsCount_other": "{{count}} sessions"
|
||||
},
|
||||
"rowMenu": {
|
||||
"openHome": "Open home in Files",
|
||||
@@ -331,5 +333,33 @@
|
||||
"digit": "A digit",
|
||||
"symbol": "A symbol (e.g. ! - _ @)",
|
||||
"username": "Does not contain the username"
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"open": "Open {{name}}'s details",
|
||||
"lastLogin": "Last sign-in",
|
||||
"lastSession": "Last activity",
|
||||
"never": "never",
|
||||
"spendDaily": "Spend · last 30 days",
|
||||
"noUsage": "No spend in the last 30 days.",
|
||||
"pool": "Account pool & quota",
|
||||
"poolPinned": "pinned to {{account}}",
|
||||
"poolGroup": "group {{name}}",
|
||||
"poolShared": "shared pool",
|
||||
"poolNone": "No account in this tool's pool.",
|
||||
"poolNotAllowed": "no longer allowed",
|
||||
"poolTotal7d": "Account total 7 days: {{cost}}",
|
||||
"noPanes": "Nothing running.",
|
||||
"recentTitle": "Recent sessions",
|
||||
"failed": "Could not load this user",
|
||||
"activeLabel": "Running now",
|
||||
"activeValue": "{{sessions}} sessions · {{panes}} panes",
|
||||
"nowTitle": "Running now",
|
||||
"poolTheirs7d": "theirs 7 d {{cost}}"
|
||||
},
|
||||
"activity": "Activity",
|
||||
"spend": "Spend today",
|
||||
"spendSub": "7 d {{week}} · 30 d {{month}}",
|
||||
"sessionsN": "{{n}} sessions",
|
||||
"workspacesN": "{{n}} workspaces",
|
||||
"tmuxCol": "tmux"
|
||||
}
|
||||
|
||||
@@ -1,37 +1,4 @@
|
||||
{
|
||||
"title": "Quản trị",
|
||||
"description": "Tài khoản và máy chủ AgentHub đang chạy.",
|
||||
"tabs": {
|
||||
"users": "Người dùng",
|
||||
"server": "Máy chủ"
|
||||
},
|
||||
"server": {
|
||||
"title": "Máy chủ",
|
||||
"uptime": "đã chạy {{v}}",
|
||||
"uptimeDays": "{{d}} ngày {{h}} giờ",
|
||||
"uptimeHours": "{{h}} giờ {{m}} phút",
|
||||
"cpu": "CPU",
|
||||
"cores": "{{n}} lõi · tải {{load}}",
|
||||
"cpuHelp": "Tỷ lệ trên toàn máy: 100% nghĩa là mọi lõi đều bận.",
|
||||
"memory": "Bộ nhớ",
|
||||
"swap": "Swap",
|
||||
"homesDisk": "Ổ chứa thư mục người dùng",
|
||||
"homesDiskHelp": "Ổ đĩa chứa thư mục home của các người dùng.",
|
||||
"free": "trống",
|
||||
"usageTitle": "Mức sử dụng theo người dùng",
|
||||
"usageHelp": "Số liệu trực tiếp, lấy mẫu trong {{s}} giây. Một tiến trình được tính cho người dùng khi nó chạy bằng tài khoản Linux của họ hoặc trong một phiên thuộc workspace của họ. Bộ nhớ là RSS; trang nhớ dùng chung được tính cho mỗi tiến trình. Bấm vào một dòng để xem các tiến trình nặng nhất.",
|
||||
"usageUnavailable": "Mức dùng theo người dùng được đọc từ /proc nên chỉ có trên Linux.",
|
||||
"who": "Người dùng",
|
||||
"processes": "Tiến trình",
|
||||
"home": "Thư mục home",
|
||||
"sampling": "Đang lấy mẫu…",
|
||||
"hostGroup": "Tài khoản máy chủ (AgentHub và tiến trình khác của host)",
|
||||
"systemGroup": "Hệ thống (root và tài khoản khác)",
|
||||
"disks": "Ổ đĩa",
|
||||
"mount": "Điểm gắn",
|
||||
"device": "Thiết bị",
|
||||
"used": "Đã dùng",
|
||||
"homesBadge": "home",
|
||||
"gpus": "GPU"
|
||||
}
|
||||
"description": "Tài khoản và máy chủ AgentHub đang chạy."
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"title": "Trình khám phá cấu hình Claude",
|
||||
"subtitle": "Toàn cảnh thiết lập Claude Code của bạn - plugin, skill, subagent, máy chủ MCP, hook và cài đặt.",
|
||||
"own": {
|
||||
"label": "Cấu hình Claude Code của bạn — {{home}}",
|
||||
"subtitle": "Chỉ thiết lập của tài khoản bạn: mọi thay đổi ở đây chạy dưới tài khoản của bạn và chỉ áp dụng cho các phiên của chính bạn."
|
||||
},
|
||||
"refresh": "Làm mới",
|
||||
"refreshing": "Đang làm mới…",
|
||||
"lastUpdated": "Cập nhật lần cuối {{time}}",
|
||||
@@ -30,6 +34,7 @@
|
||||
},
|
||||
"overview": {
|
||||
"rootsTitle": "Thư mục gốc",
|
||||
"claudeHome": ".claude của bạn",
|
||||
"projectClaudeDir": "Dự án .claude",
|
||||
"projectRoot": "Thư mục dự án",
|
||||
"claudeJson": "claude.json",
|
||||
|
||||
@@ -30,14 +30,6 @@
|
||||
"since": "đang chờ {{ago}}"
|
||||
},
|
||||
"health": {
|
||||
"runtime": "Môi trường chạy",
|
||||
"cores_one": "{{count}} lõi",
|
||||
"cores_other": "{{count}} lõi",
|
||||
"uptime": "Thời gian chạy",
|
||||
"cpu": "CPU (1/5/15 phút)",
|
||||
"nodeRss": "Bộ nhớ Node (RSS)",
|
||||
"hostMemory": "Bộ nhớ máy",
|
||||
"hostMemoryTip": "Bộ nhớ máy: đã dùng {{pct}}%\n{{used}} / {{total}}",
|
||||
"heap": "Heap V8",
|
||||
"heapTip": "Heap V8: đã dùng {{pct}}%\n{{used}} / giới hạn {{limit}}\nĐã cấp phát: {{allocated}}\nNgoài heap: {{external}}",
|
||||
"storage": "Lưu trữ",
|
||||
@@ -50,12 +42,7 @@
|
||||
"events": "Sự kiện",
|
||||
"score": "Điểm sức khỏe",
|
||||
"formula": "Công thức",
|
||||
"formulaTip": "Công thức chỉ số tổng hợp:\n0.4 × Tỉ lệ thành công ({{success}}%)\n+ 0.25 × Trúng cache ({{cache}}%)\n+ 0.25 × (100 − Tỉ lệ lỗi) ({{noError}}%)\n+ 0.10 × (100 − Heap%) ({{noHeap}}%)\n= {{score}}",
|
||||
"scoreTip": "Điểm: {{score}} / 100\n\n• Tỉ lệ thành công (40%): {{success}}%\n• Trúng cache (25%): {{cache}}%\n• Tránh lỗi (25%): {{noError}}%\n• Sức khỏe bộ nhớ (10%): {{noHeap}}%",
|
||||
"cache": "Cache",
|
||||
"cacheTip": "Tỉ lệ trúng cache: {{pct}}%\nTrúng: {{hits}}\nTrượt: {{misses}}",
|
||||
"errors": "Lỗi",
|
||||
"errorsTip": "Tỉ lệ lỗi: {{pct}}%\n<5% = tốt, 5–15% = cảnh báo, >15% = nghiêm trọng",
|
||||
"cacheTip": "Tỉ lệ trúng cache transcript: {{pct}}% (chỉ để xem, không tính điểm — cache trống lại mỗi lần server khởi động)",
|
||||
"compact": "Nén",
|
||||
"compactTip": "Số lần nén transcript: {{count}}\nThu gọn cửa sổ ngữ cảnh bằng cách tóm tắt các lượt.",
|
||||
"saved": "Tiết kiệm",
|
||||
@@ -65,9 +52,10 @@
|
||||
"modelTip": "{{model}}\nĐầu vào: {{input}}\nĐầu ra: {{output}}\nĐọc cache: {{cacheRead}}\nTỉ trọng: {{pct}}%",
|
||||
"noModelData": "Chưa có dữ liệu mô hình",
|
||||
"concurrency": "Đồng thời",
|
||||
"intervals_one": "{{count}} khoảng",
|
||||
"intervals_other": "{{count}} khoảng",
|
||||
"intervals_one": "{{count}} loại agent",
|
||||
"intervals_other": "{{count}} loại agent",
|
||||
"concurrencyTip": "Đỉnh: {{peak}} phiên song song\nKhoảng có hoạt động: {{active}}\nTrung bình: {{avg}}",
|
||||
"laneTip": "{{name}}\n{{count}} agent\nChạy từ {{start}}–{{end}}% thời lượng phiên (trung bình)",
|
||||
"noConcurrencyData": "Chưa có dữ liệu đồng thời",
|
||||
"peak": "Đỉnh",
|
||||
"peakTip": "Đỉnh: {{count}} phiên chạy cùng lúc.",
|
||||
@@ -99,11 +87,101 @@
|
||||
"synchronous": "Đồng bộ",
|
||||
"autoVacuum": "Tự dọn dẹp",
|
||||
"foreignKeys": "Khóa ngoại",
|
||||
"busyTimeout": "Thời gian chờ khóa"
|
||||
"busyTimeout": "Thời gian chờ khóa",
|
||||
"groupApp": "AgentHub",
|
||||
"groupAppHint": "Tình trạng của chính dashboard: điểm, cơ sở dữ liệu, số phiên đồng thời",
|
||||
"groupMachine": "Máy chủ",
|
||||
"groupMachineHint": "Máy đang chạy AgentHub: CPU, bộ nhớ, ổ đĩa, tiến trình",
|
||||
"groupActivity": "Hoạt động agent",
|
||||
"groupActivityHint": "Agent đã dùng gì: token theo model, công cụ, subagent",
|
||||
"scoreFormula": "100 trừ đi phần mỗi mục bị trừ:",
|
||||
"scoreResult": "= {{score}} / 100",
|
||||
"check": {
|
||||
"loop": "Phản hồi AgentHub",
|
||||
"cpu": "Chờ CPU",
|
||||
"memory": "Chờ bộ nhớ",
|
||||
"io": "Chờ ổ đĩa",
|
||||
"ram": "RAM đang dùng",
|
||||
"disk": "Ổ chứa dữ liệu",
|
||||
"heap": "V8 heap",
|
||||
"hooks": "Hook gần nhất",
|
||||
"agents": "Agent lỗi 24h"
|
||||
},
|
||||
"checkTip": {
|
||||
"loop": "Event loop của AgentHub bị nghẽn bao lâu (p99 trong phút vừa qua). Mọi trang, hook và phím gõ trong terminal đều phải chờ chừng ấy.",
|
||||
"cpu": "Áp lực CPU (Linux PSI, 10 s gần nhất): tỉ lệ thời gian tác vụ phải chờ CPU rảnh — vài % trở lên là máy bắt đầu chậm.",
|
||||
"cpuLoad": "Tải mỗi lõi (1 phút) — máy này không có PSI nên ước lượng chờ CPU từ load.",
|
||||
"memory": "Áp lực bộ nhớ (Linux PSI, 10 s gần nhất): tỉ lệ thời gian tác vụ bị treo để thu hồi hoặc swap bộ nhớ.",
|
||||
"io": "Áp lực I/O ổ đĩa (Linux PSI, 10 s gần nhất): tỉ lệ thời gian tác vụ phải chờ ổ đĩa — HDD chậm hoặc bận sẽ hiện ở đây.",
|
||||
"ram": "Bộ nhớ đang dùng (tổng trừ phần còn khả dụng).",
|
||||
"disk": "Mức đầy của ổ chứa cơ sở dữ liệu AgentHub ({{mount}}).",
|
||||
"heap": "V8 heap của AgentHub so với giới hạn.",
|
||||
"hooks": "Số phút từ hook gần nhất, chỉ tính khi có agent đang chạy: im lặng lúc đó nghĩa là dashboard không còn thấy agent.",
|
||||
"agents": "Agent kết thúc với lỗi trong 24 giờ qua (lỗi / đã kết thúc; cần ít nhất 5)."
|
||||
},
|
||||
"checkNa": "Không đo được trên máy này — không trừ điểm.",
|
||||
"checkThresholds": "Vàng từ {{warn}}{{unit}}, đỏ từ {{bad}}{{unit}}",
|
||||
"checkPenalty": "Trừ {{n}} / tối đa {{max}} điểm",
|
||||
"hooksMissing": "chưa cài",
|
||||
"hooksIdle": "nghỉ",
|
||||
"minutesAgo": "{{n}} phút trước",
|
||||
"verdict": {
|
||||
"ok": "Khỏe",
|
||||
"warn": "Suy giảm",
|
||||
"bad": "Có vấn đề"
|
||||
},
|
||||
"allOk": "Mọi mục đều ổn",
|
||||
"badCount_one": "{{count}} đỏ",
|
||||
"badCount_other": "{{count}} đỏ",
|
||||
"warnCount_one": "{{count}} vàng",
|
||||
"warnCount_other": "{{count}} vàng",
|
||||
"lagGroup": "Độ trễ",
|
||||
"systemGroup": "Tài nguyên & tín hiệu"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Đã xảy ra lỗi",
|
||||
"body": "Không thể tải trang này. Tải lại thường sẽ khắc phục — ví dụ sau khi dashboard vừa được cập nhật.",
|
||||
"reload": "Tải lại"
|
||||
},
|
||||
"host": {
|
||||
"uptimeDays": "{{d}} ngày {{h}} giờ",
|
||||
"uptimeHours": "{{h}} giờ {{m}} phút",
|
||||
"cpu": "CPU",
|
||||
"cpuHelp": "Tỷ lệ trên toàn máy: 100% nghĩa là mọi lõi đều bận.",
|
||||
"memory": "Bộ nhớ",
|
||||
"swap": "Swap",
|
||||
"free": "trống",
|
||||
"usageUnavailable": "Mức dùng theo người dùng được đọc từ /proc nên chỉ có trên Linux.",
|
||||
"hostGroup": "Tài khoản máy chủ (AgentHub và tiến trình khác của host)",
|
||||
"systemGroup": "Hệ thống (root và tài khoản khác)",
|
||||
"homesBadge": "home",
|
||||
"historyTitle": "Lịch sử",
|
||||
"historyHelp": "Lưu trong bộ nhớ, mỗi {{s}} giây một điểm, từ khi máy chủ khởi động lúc {{at}}.",
|
||||
"historyCpu": "CPU (% toàn máy)",
|
||||
"historyMemory": "Bộ nhớ đã dùng (%)",
|
||||
"range": "Khoảng thời gian",
|
||||
"range1h": "1 giờ",
|
||||
"range6h": "6 giờ",
|
||||
"range24h": "24 giờ",
|
||||
"coreN": "CPU {{n}}",
|
||||
"threads": "{{n}} luồng",
|
||||
"physical": "{{n}} lõi",
|
||||
"perSecond": "{{v}}/giây",
|
||||
"temperatures": "Nhiệt độ",
|
||||
"topProcesses": "Tiến trình nặng nhất",
|
||||
"cpuModel": "CPU",
|
||||
"storageNetwork": "Ổ đĩa & mạng",
|
||||
"processCount": "{{n}} tiến trình",
|
||||
"title": "Máy chủ",
|
||||
"os": "Hệ điều hành",
|
||||
"uptime": "Thời gian chạy",
|
||||
"load": "Tải (1/5/15 phút)",
|
||||
"agenthubTip": "pid {{pid}} · Node {{node}}",
|
||||
"sampling": "Đang lấy mẫu…",
|
||||
"sampled": "mẫu {{s}} giây",
|
||||
"historyNone": "Chưa có lịch sử — server chỉ giữ trong bộ nhớ trên Linux, tính từ lúc khởi động.",
|
||||
"agenthub": "AgentHub",
|
||||
"usersTitle": "Người dùng · CPU / RAM hiện tại",
|
||||
"usersCount": "{{n}} người dùng"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,12 @@
|
||||
"tableAgents": "Agent",
|
||||
"tableCost": "Chi phí",
|
||||
"tableDirectory": "Thư mục",
|
||||
"tableUser": "Người dùng",
|
||||
"tableTool": "AI Tool",
|
||||
"tableActions": "Thao tác",
|
||||
"noOwner": "Không có chủ",
|
||||
"moreActions": "Thao tác khác",
|
||||
"viewDetails": "Xem chi tiết",
|
||||
"defaultName": "Phiên ",
|
||||
"detail": {
|
||||
"waitingBanner": {
|
||||
|
||||
@@ -747,6 +747,29 @@
|
||||
"description": "Ví dụ 9Router, OpenRouter hoặc gateway tự dựng. Lưu ở đây rồi chọn cho từng session ở ô nhập prompt của workspace.",
|
||||
"add": "Thêm profile",
|
||||
"config9router": "Config 9router",
|
||||
"update9router": "Cập nhật 9router",
|
||||
"update9routerAvailable": "Đã có bản 9router mới",
|
||||
"update9routerCurrent": "Đang cài",
|
||||
"update9routerLatest": "Mới nhất",
|
||||
"update9routerState": "Trạng thái",
|
||||
"update9routerRunning": "Đang chạy",
|
||||
"update9routerRunningUnit": "Đang chạy (9router.service)",
|
||||
"update9routerStopped": "Không chạy",
|
||||
"update9routerExplain": "Mọi session đang dùng 9router sẽ bị ngắt kết nối trong lúc cập nhật: 9router được tắt, cập nhật bằng npm rồi mở lại ở chế độ ẩn (khay hệ thống, không mở trình duyệt).",
|
||||
"update9routerExplainStopped": "9router đang không chạy: chỉ cập nhật bằng npm và giữ nguyên trạng thái tắt.",
|
||||
"update9routerStep": {
|
||||
"shutdown": "Ngắt kết nối và tắt 9router",
|
||||
"update": "Chạy cập nhật (npm install -g 9router@latest)",
|
||||
"relaunch": "Mở lại 9router ở chế độ ẩn"
|
||||
},
|
||||
"update9routerDone": "9router {{version}} đang chạy.",
|
||||
"update9routerDoneStopped": "Đã cài 9router {{version}}, giữ nguyên trạng thái tắt như trước.",
|
||||
"update9routerFailed": "Cập nhật chưa hoàn tất — xem các bước và log.",
|
||||
"update9routerLog": "Output của npm",
|
||||
"update9routerUpToDate": "9router đã là bản mới nhất.",
|
||||
"update9routerCopy": "Sao chép lệnh",
|
||||
"update9routerClose": "Đóng",
|
||||
"update9routerStart": "Cập nhật ngay",
|
||||
"edit": "Sửa",
|
||||
"remove": "Xoá",
|
||||
"empty": "Chưa lưu profile proxy nào. Native là chạy thẳng vào API của hãng.",
|
||||
@@ -872,7 +895,6 @@
|
||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
|
||||
"gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
|
||||
"gitHubStepClone": "Clone bằng địa chỉ SSH mà trang repository hiển thị:",
|
||||
"awsTitle": "AWS CodeCommit",
|
||||
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
|
||||
"stepCopy": "Copy public key.",
|
||||
@@ -882,7 +904,6 @@
|
||||
"awsRegion": "Region",
|
||||
"awsSave": "Lưu",
|
||||
"awsSaved": "Đã lưu SSH key ID cho {{region}}",
|
||||
"awsReady": "Sẵn sàng. Clone bằng địa chỉ trơn mà CodeCommit hiển thị — trong terminal hoặc Add workspace → Clone:",
|
||||
"otherTitle": "Host SSH khác (nâng cao)",
|
||||
"otherHint": "Chỉ dùng cho host đăng nhập bằng tên user khác git. Sau khi lưu, địa chỉ ssh://<host>/… trơn sẽ đăng nhập bằng user đó.",
|
||||
"otherHost": "Host",
|
||||
@@ -1023,6 +1044,10 @@
|
||||
"title": "Claude Code",
|
||||
"description": "Cấu hình của Claude Code và những gì AgentHub đọc từ đó."
|
||||
},
|
||||
"claudeCodeOwn": {
|
||||
"title": "Cấu hình Claude Code của bạn",
|
||||
"description": "Skill, agent, plugin, MCP server và thiết lập mà các phiên của chính bạn dùng."
|
||||
},
|
||||
"codex": {
|
||||
"title": "Codex",
|
||||
"description": "Cấu hình của Codex và những gì AgentHub đọc từ đó."
|
||||
|
||||
@@ -36,7 +36,6 @@
|
||||
"autoFixHint": "Mở một phiên Claude Code trong thư mục cài đặt kèm lỗi này, để AI agent sửa và hoàn tất cập nhật.",
|
||||
"autoFixWorkspace": "Cập nhật AgentHub",
|
||||
"autoFixError": "Không mở được phiên Tự sửa",
|
||||
"backgroundNote": "Bản cập nhật vẫn chạy ngầm — trang sẽ tự tải lại khi phiên bản mới sẵn sàng.",
|
||||
"chipTitle": "Đang cập nhật lên {{version}}…",
|
||||
"chipHint": "Bản cập nhật đang chạy ngầm — bấm để xem",
|
||||
"pluginStep.marketplace_update": "Bước {{index}}/{{total}}: làm mới marketplace",
|
||||
|
||||
@@ -315,7 +315,9 @@
|
||||
"doneIdle": "Đã khởi động lại: {{names}}",
|
||||
"errors": {
|
||||
"ETMUXSTUCK": "Server không dừng. Chạy lệnh `sudo kill -9` được nêu, bằng quyền root, trên máy server."
|
||||
}
|
||||
},
|
||||
"sessionsCount_one": "{{count}} session",
|
||||
"sessionsCount_other": "{{count}} session"
|
||||
},
|
||||
"rowMenu": {
|
||||
"openHome": "Mở home trong Files",
|
||||
@@ -331,5 +333,33 @@
|
||||
"digit": "Có chữ số",
|
||||
"symbol": "Có ký tự đặc biệt (vd. ! - _ @)",
|
||||
"username": "Không chứa tên đăng nhập"
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"open": "Xem chi tiết {{name}}",
|
||||
"lastLogin": "Đăng nhập gần nhất",
|
||||
"lastSession": "Hoạt động gần nhất",
|
||||
"never": "chưa bao giờ",
|
||||
"spendDaily": "Chi phí · 30 ngày qua",
|
||||
"noUsage": "Không có chi phí trong 30 ngày qua.",
|
||||
"pool": "Account pool & quota",
|
||||
"poolPinned": "ghim vào {{account}}",
|
||||
"poolGroup": "nhóm {{name}}",
|
||||
"poolShared": "pool dùng chung",
|
||||
"poolNone": "Pool của tool này chưa có tài khoản nào.",
|
||||
"poolNotAllowed": "không còn được dùng",
|
||||
"poolTotal7d": "Tổng tài khoản 7 ngày: {{cost}}",
|
||||
"noPanes": "Không có gì đang chạy.",
|
||||
"recentTitle": "Session gần đây",
|
||||
"failed": "Không tải được thông tin người dùng",
|
||||
"activeLabel": "Đang chạy",
|
||||
"activeValue": "{{sessions}} session · {{panes}} pane",
|
||||
"nowTitle": "Đang chạy",
|
||||
"poolTheirs7d": "của họ 7 ngày {{cost}}"
|
||||
},
|
||||
"activity": "Hoạt động",
|
||||
"spend": "Chi phí hôm nay",
|
||||
"spendSub": "7 ngày {{week}} · 30 ngày {{month}}",
|
||||
"sessionsN": "{{n}} session",
|
||||
"workspacesN": "{{n}} workspace",
|
||||
"tmuxCol": "tmux"
|
||||
}
|
||||
|
||||
@@ -717,6 +717,11 @@
|
||||
.xterm-viewport::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* xterm 6 draws its own scrollbar (VS Code's scrollable element) instead of
|
||||
the viewport's native one: the same dead affordance, hidden the same way. */
|
||||
.xterm .xterm-scrollable-element > .scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Settings → Appearance → Text size (lib/fontSize.ts) sets the ROOT font size.
|
||||
Tailwind's own sizes are rem and follow it; these are the `text-[Npx]`
|
||||
|
||||
@@ -49,6 +49,16 @@ describe("artifactView", () => {
|
||||
expect(isArtifact("server.js", true)).toBe(false);
|
||||
});
|
||||
|
||||
it("never takes the project's own HTML pages for artifacts", () => {
|
||||
expect(isArtifact("/tmp/out/report.html", true)).toBe(true);
|
||||
expect(isArtifact("/tmp/out/report.html", false)).toBe(false);
|
||||
expect(isArtifact("/repo/index.html", true)).toBe(false);
|
||||
expect(isArtifact("/repo/client/INDEX.HTM", true)).toBe(false);
|
||||
expect(isArtifact("/repo/public/about.html", true)).toBe(false);
|
||||
expect(isArtifact("C:\\repo\\templates\\mail.html", true)).toBe(false);
|
||||
expect(isArtifact("/repo/docs/chart.html", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("rebuilds a file's versions from Write, Edit and MultiEdit", () => {
|
||||
const msgs = [
|
||||
use("1", "Write", { file_path: "/w/a.md", content: "# A\nold line\n" }),
|
||||
|
||||
@@ -31,9 +31,9 @@ describe("git targets", () => {
|
||||
await api.lanes.gitAction(gitTarget(3, "s-1"), "stage", { paths: ["a"] });
|
||||
await api.lanes.git(gitTarget(3, null));
|
||||
const urls = spy.mock.calls.map((c) => String(c[0]));
|
||||
expect(urls[0]).toMatch(/\/lanes\/3\/git\/changes\?range=work$/);
|
||||
expect(urls[1]).toMatch(/\/lanes\/3\/git\/changes\?range=work&session=s-1$/);
|
||||
expect(urls[2]).toMatch(/\/lanes\/3\/git\/stage\?session=s-1$/);
|
||||
expect(urls[3]).toMatch(/\/lanes\/3\/git$/);
|
||||
expect(urls[0]).toMatch(/\/workspaces\/3\/git\/changes\?range=work$/);
|
||||
expect(urls[1]).toMatch(/\/workspaces\/3\/git\/changes\?range=work&session=s-1$/);
|
||||
expect(urls[2]).toMatch(/\/workspaces\/3\/git\/stage\?session=s-1$/);
|
||||
expect(urls[3]).toMatch(/\/workspaces\/3\/git$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* @file healthScore.test.ts
|
||||
* @description Tests for lib/healthScore.ts — the System Health score: a calm
|
||||
* machine scores 100, lag (event loop, CPU / memory / I/O pressure) pulls it
|
||||
* down hard, a check without data costs nothing, CPU falls back to load per
|
||||
* core without PSI, the data disk is the one holding the database, and hooks
|
||||
* count only while an agent works.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HostStats } from "../api";
|
||||
import { healthChecks, healthScore, scoreLevel, type HealthInput } from "../healthScore";
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
const NOW = Date.parse("2026-10-27T10:00:00Z");
|
||||
|
||||
const host = (over: Partial<HostStats> = {}): HostStats => ({
|
||||
at: "2026-10-27T10:00:00Z",
|
||||
hostname: "box",
|
||||
os: { platform: "linux", release: "7", distro: null, arch: "x64" },
|
||||
uptime: 1000,
|
||||
cpu: { model: null, cores: 4, load: [0.5, 0.5, 0.5] },
|
||||
memory: { total: 16 * GB, available: 12 * GB, used: 4 * GB, swapTotal: 0, swapUsed: 0 },
|
||||
disks: [
|
||||
{ device: "/dev/a", mount: "/", type: "ext4", kind: "SSD", total: 100, used: 10, free: 90 },
|
||||
{ device: "/dev/b", mount: "/data", type: "ext4", kind: "HDD", total: 100, used: 99, free: 1 },
|
||||
],
|
||||
homesMount: null,
|
||||
gpus: [],
|
||||
usage: null,
|
||||
pressure: {
|
||||
cpu: { some10: 1, some60: 1, full10: 0 },
|
||||
memory: { some10: 0, some60: 0, full10: 0 },
|
||||
io: { some10: 0, some60: 0, full10: 0 },
|
||||
},
|
||||
...over,
|
||||
});
|
||||
|
||||
const calm = (over: Partial<HealthInput> = {}): HealthInput => ({
|
||||
eventLoop: { p50: 10, p99: 20, max: 40 },
|
||||
heap: { heapUsed: 100, heapTotal: 200, heapLimit: 1000 },
|
||||
hooksInstalled: true,
|
||||
activity: {
|
||||
last_event_at: "2026-10-27T09:59:00Z",
|
||||
working_agents: 1,
|
||||
agents_24h: { completed: 50, error: 0 },
|
||||
},
|
||||
dbPath: "/home/me/.claude/agenthub/dashboard.db",
|
||||
host: host(),
|
||||
now: NOW,
|
||||
...over,
|
||||
});
|
||||
|
||||
const byKey = (input: HealthInput) => new Map(healthChecks(input).map((c) => [c.key, c]));
|
||||
|
||||
describe("healthScore", () => {
|
||||
it("scores a calm machine 100 with every check ok", () => {
|
||||
const checks = healthChecks(calm());
|
||||
expect(healthScore(checks)).toBe(100);
|
||||
expect(checks.every((c) => c.level === "ok")).toBe(true);
|
||||
expect(scoreLevel(100)).toBe("ok");
|
||||
});
|
||||
|
||||
it("drops the score when the machine lags", () => {
|
||||
const lagging = calm({
|
||||
eventLoop: { p50: 50, p99: 1200, max: 2000 },
|
||||
host: host({
|
||||
pressure: {
|
||||
cpu: { some10: 70, some60: 50, full10: 0 },
|
||||
memory: { some10: 0, some60: 0, full10: 0 },
|
||||
io: { some10: 60, some60: 40, full10: 30 },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const c = byKey(lagging);
|
||||
expect(c.get("loop")?.level).toBe("bad");
|
||||
expect(c.get("cpu")?.level).toBe("bad");
|
||||
expect(c.get("io")?.level).toBe("bad");
|
||||
const score = healthScore([...c.values()]);
|
||||
expect(score).toBe(100 - 25 - 20 - 20);
|
||||
expect(scoreLevel(score)).toBe("bad");
|
||||
});
|
||||
|
||||
it("charges part of the points between amber and red", () => {
|
||||
const c = byKey(calm({ eventLoop: { p50: 10, p99: 550, max: 600 } })).get("loop");
|
||||
expect(c?.level).toBe("warn");
|
||||
expect(c?.penalty).toBe(12.5);
|
||||
});
|
||||
|
||||
it("costs nothing for what was not measured, and estimates CPU from load without PSI", () => {
|
||||
const c = byKey(
|
||||
calm({
|
||||
eventLoop: undefined,
|
||||
host: host({ pressure: null, cpu: { model: null, cores: 2, load: [5, 1, 1] } }),
|
||||
})
|
||||
);
|
||||
expect(c.get("loop")?.level).toBe("na");
|
||||
expect(c.get("memory")?.level).toBe("na");
|
||||
expect(c.get("cpu")?.detail?.source).toBe("load");
|
||||
expect(c.get("cpu")?.value).toBe(250);
|
||||
expect(c.get("cpu")?.level).toBe("bad");
|
||||
});
|
||||
|
||||
it("reads the disk that holds the database", () => {
|
||||
const c = byKey(calm({ dbPath: "/data/agenthub/dashboard.db" })).get("disk");
|
||||
expect(c?.detail?.mount).toBe("/data");
|
||||
expect(c?.level).toBe("bad");
|
||||
expect(byKey(calm({ dbPath: null })).get("disk")?.detail?.mount).toBe("/");
|
||||
});
|
||||
|
||||
it("counts silent hooks only while an agent works; missing hooks are red", () => {
|
||||
const silent = calm({
|
||||
activity: {
|
||||
last_event_at: "2026-10-27T09:20:00Z",
|
||||
working_agents: 2,
|
||||
agents_24h: { completed: 0, error: 0 },
|
||||
},
|
||||
});
|
||||
expect(byKey(silent).get("hooks")?.level).toBe("bad");
|
||||
const idle = calm({
|
||||
activity: {
|
||||
last_event_at: "2026-10-27T05:00:00Z",
|
||||
working_agents: 0,
|
||||
agents_24h: { completed: 0, error: 0 },
|
||||
},
|
||||
});
|
||||
expect(byKey(idle).get("hooks")).toMatchObject({ level: "ok", penalty: 0 });
|
||||
expect(byKey(calm({ hooksInstalled: false })).get("hooks")).toMatchObject({
|
||||
level: "bad",
|
||||
penalty: 30,
|
||||
});
|
||||
});
|
||||
|
||||
it("rates agent errors only with enough finished agents", () => {
|
||||
const few = calm({
|
||||
activity: {
|
||||
last_event_at: null,
|
||||
working_agents: 0,
|
||||
agents_24h: { completed: 1, error: 2 },
|
||||
},
|
||||
});
|
||||
expect(byKey(few).get("agents")?.level).toBe("na");
|
||||
const many = calm({
|
||||
activity: {
|
||||
last_event_at: null,
|
||||
working_agents: 0,
|
||||
agents_24h: { completed: 6, error: 4 },
|
||||
},
|
||||
});
|
||||
expect(byKey(many).get("agents")).toMatchObject({ level: "bad", value: 40 });
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,7 @@ function stubFetch(body: unknown) {
|
||||
}
|
||||
|
||||
describe("api.lanes.git", () => {
|
||||
it("requests /api/lanes/<id>/git and returns the parsed facts", async () => {
|
||||
it("requests /api/workspaces/<id>/git and returns the parsed facts", async () => {
|
||||
const facts = {
|
||||
available: true,
|
||||
branch: "feat/x",
|
||||
@@ -41,7 +41,7 @@ describe("api.lanes.git", () => {
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
const call = spy.mock.calls[0] as [string, RequestInit | undefined];
|
||||
// endsWith, not toContain: `/gitt` contains `/git` and would slip through.
|
||||
expect(String(call[0]).endsWith("/api/lanes/3/git")).toBe(true);
|
||||
expect(String(call[0]).endsWith("/api/workspaces/3/git")).toBe(true);
|
||||
expect(call[1]?.method ?? "GET").toBe("GET");
|
||||
expect(result).toEqual(facts);
|
||||
});
|
||||
|
||||
+250
-46
@@ -546,6 +546,83 @@ export type ServerUserUsage = AuthUser & {
|
||||
logins?: number;
|
||||
lastLoginAt?: string | null;
|
||||
};
|
||||
/** A priced slice of a user's spend (`GET /api/users/:id/details`). */
|
||||
export type UsageSlice = { key: string; cost: number; tokens: number; sessions: number };
|
||||
/** One pool account as one user's detail sees it: the account's totals and their share. */
|
||||
export type UserPoolAccount = {
|
||||
name: string;
|
||||
kind: string;
|
||||
email: string | null;
|
||||
expired: boolean;
|
||||
/** Their group/pin lets them run on it (false = they ran on it before). */
|
||||
allowed: boolean;
|
||||
pinned: boolean;
|
||||
quota: PoolAccount["quota"] | CodexPoolAccount["quota"];
|
||||
quotaPercent: number | null;
|
||||
full: boolean;
|
||||
livePanes: number;
|
||||
spend5h: number;
|
||||
spend7d: number;
|
||||
userSpend5h: number;
|
||||
userSpend7d: number;
|
||||
userTokens5h: number;
|
||||
userTokens7d: number;
|
||||
userRuns7d: number;
|
||||
userLivePanes: number;
|
||||
userLastAt: string | null;
|
||||
};
|
||||
/** `GET /api/users/:id/details` — the Administration page's user detail. */
|
||||
export type UserDetails = {
|
||||
user: AuthUser & { disabled: boolean; logins: number; lastLoginAt: string | null };
|
||||
sessions: { total: number; active: number; firstAt: string | null; lastAt: string | null };
|
||||
usage: {
|
||||
today: { cost: number; tokens: number };
|
||||
last7d: { cost: number; tokens: number };
|
||||
last30d: { cost: number; tokens: number };
|
||||
/** 30 local days, the oldest first. */
|
||||
daily: { date: string; cost: number; tokens: number }[];
|
||||
byTool: UsageSlice[];
|
||||
byModel: UsageSlice[];
|
||||
};
|
||||
workspaces: {
|
||||
id: number;
|
||||
title: string;
|
||||
cwd: string;
|
||||
tool: string;
|
||||
kind: string;
|
||||
branch: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
sessions: number;
|
||||
lastActiveAt: string | null;
|
||||
livePanes: number;
|
||||
}[];
|
||||
panes: {
|
||||
id: string;
|
||||
laneId: number;
|
||||
laneTitle: string;
|
||||
laneCwd: string;
|
||||
tool: string;
|
||||
model: string | null;
|
||||
account: string | null;
|
||||
proxy: string | null;
|
||||
sessionId: string | null;
|
||||
startedAt: string;
|
||||
status: string;
|
||||
}[];
|
||||
recentSessions: (Session & { tokens?: number })[];
|
||||
pool: Partial<
|
||||
Record<
|
||||
PoolTool,
|
||||
{
|
||||
pin: string | null;
|
||||
group: { id: number; name: string } | null;
|
||||
accounts: UserPoolAccount[];
|
||||
}
|
||||
>
|
||||
>;
|
||||
audit: AuditEntry[];
|
||||
};
|
||||
/** One member's own tmux server (`GET /api/users/tmux`). */
|
||||
export interface MemberTmuxRow {
|
||||
id: number;
|
||||
@@ -578,6 +655,8 @@ export interface HostUsageGroup {
|
||||
kind: "user" | "host" | "system";
|
||||
userId?: number;
|
||||
username?: string;
|
||||
/** The user's display name, null when none is set (absent from an older server). */
|
||||
displayName?: string | null;
|
||||
/** % of the whole machine's CPU (100 = every core busy) over the sample. */
|
||||
cpu: number;
|
||||
/** Resident memory, bytes (shared pages count in every process). */
|
||||
@@ -587,13 +666,31 @@ export interface HostUsageGroup {
|
||||
/** Busiest processes, by the kernel's short name only. */
|
||||
top: { pid: number; name: string; cpu: number; memory: number }[];
|
||||
}
|
||||
/** One `/proc/pressure/<resource>` reading, in % of the window. */
|
||||
export interface HostPressure {
|
||||
some10: number;
|
||||
some60: number;
|
||||
full10: number;
|
||||
}
|
||||
|
||||
/** `GET /api/host` — hardware, load, disks and per-user usage. */
|
||||
export interface HostStats {
|
||||
at: string;
|
||||
hostname: string;
|
||||
os: { platform: string; release: string; distro: string | null; arch: string };
|
||||
uptime: number;
|
||||
cpu: { model: string | null; cores: number; load: number[] };
|
||||
/** When the machine booted (ISO); absent from an older server. */
|
||||
bootTime?: string;
|
||||
cpu: {
|
||||
model: string | null;
|
||||
/** Logical CPUs (threads). */
|
||||
cores: number;
|
||||
load: number[];
|
||||
/** Physical cores from /proc/cpuinfo; null when it cannot tell. */
|
||||
physicalCores?: number | null;
|
||||
/** Busy % of each logical CPU over the sample; [] off Linux. */
|
||||
perCore?: number[];
|
||||
};
|
||||
memory: { total: number; available: number; used: number; swapTotal: number; swapUsed: number };
|
||||
disks: {
|
||||
device: string;
|
||||
@@ -609,6 +706,46 @@ export interface HostStats {
|
||||
gpus: { name: string; utilization: number; memoryUsed: number; memoryTotal: number }[];
|
||||
/** null off Linux. */
|
||||
usage: { cpu: number; sampleMs: number; groups: HostUsageGroup[] } | null;
|
||||
/** Bytes/s per network interface over the sample (`lo` left out). */
|
||||
network?: { name: string; rx: number; tx: number }[];
|
||||
/** Bytes/s per whole disk over the sample. */
|
||||
diskIo?: { name: string; read: number; write: number }[];
|
||||
/** `/sys/class/hwmon` sensors; [] when none. */
|
||||
temperatures?: { chip: string; label: string; celsius: number }[];
|
||||
/** Linux PSI (`/proc/pressure/*`): % of the last 10 / 60 s tasks stalled on
|
||||
* each resource; null without PSI (or off Linux). */
|
||||
pressure?: {
|
||||
cpu: HostPressure | null;
|
||||
memory: HostPressure | null;
|
||||
io: HostPressure | null;
|
||||
} | null;
|
||||
/** The ten busiest processes machine-wide; `group` is a `HostUsageGroup` key. */
|
||||
topProcesses?: { pid: number; name: string; cpu: number; memory: number; group: string }[];
|
||||
/** The dashboard's own process. */
|
||||
process?: { pid: number; node: string; uptime: number; rss: number; heapUsed: number };
|
||||
}
|
||||
/** One point of `GET /api/host/history`. */
|
||||
export interface HostHistoryPoint {
|
||||
at: string;
|
||||
/** Machine CPU %, null when it could not be sampled. */
|
||||
cpu: number | null;
|
||||
/** Memory used, % of total. */
|
||||
memory: number | null;
|
||||
memoryUsed: number;
|
||||
swapUsed: number;
|
||||
load1: number | null;
|
||||
/** Per user id: CPU % and resident bytes (users with any use only). */
|
||||
users: Record<string, { cpu: number; memory: number }>;
|
||||
}
|
||||
export type HostHistoryRange = "1h" | "6h" | "24h";
|
||||
/** `GET /api/host/history` — kept in memory since the server started. */
|
||||
export interface HostHistory {
|
||||
range: HostHistoryRange;
|
||||
intervalMs: number;
|
||||
/** False when this server keeps no history (local mode, not Linux). */
|
||||
running: boolean;
|
||||
startedAt: string | null;
|
||||
points: HostHistoryPoint[];
|
||||
}
|
||||
|
||||
/** One user's home folder on disk (`GET /api/users/storage`); null = not measured. */
|
||||
@@ -915,7 +1052,7 @@ function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
||||
if (typeof target !== "number" && target.source) params.set("source", String(target.source));
|
||||
if (typeof target !== "number" && target.repo) params.set("repo", target.repo);
|
||||
const qs = params.toString();
|
||||
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
|
||||
return `/workspaces/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
|
||||
/** Where the File Explorer works: a workspace (as {@link GitTarget}) or a user's home. */
|
||||
@@ -929,7 +1066,7 @@ function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
|
||||
const qs = new URLSearchParams(q).toString();
|
||||
return `/homes/${encodeURIComponent(place.home)}/fs${sub}${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
return gitUrl(place, sub, q).replace(/^(\/lanes\/\d+)\/git/, "$1/fs");
|
||||
return gitUrl(place, sub, q).replace(/^(\/workspaces\/\d+)\/git/, "$1/fs");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1090,9 +1227,11 @@ export const api = {
|
||||
/** DELETE /api/me/avatar — back to the initials. */
|
||||
removeAvatar: () => request<MyAccount>("/me/avatar", { method: "DELETE" }),
|
||||
},
|
||||
/** GET /api/host — the Server tab: hardware and each user's CPU/memory share (staff). */
|
||||
/** GET /api/host — System Health's machine cards and the Administration users table's CPU/RAM (local mode, or staff). */
|
||||
host: {
|
||||
stats: () => request<HostStats>("/host"),
|
||||
history: (range: HostHistoryRange = "1h") =>
|
||||
request<HostHistory>(`/host/history?range=${range}`),
|
||||
},
|
||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||
users: {
|
||||
@@ -1102,6 +1241,9 @@ export const api = {
|
||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||
list: () =>
|
||||
request<{ users: ServerUserUsage[] }>(`/users?tz_offset=${new Date().getTimezoneOffset()}`),
|
||||
/** GET /api/users/:id/details — one account in full (admin/analyst). */
|
||||
details: (id: number) =>
|
||||
request<UserDetails>(`/users/${id}/details?tz_offset=${new Date().getTimezoneOffset()}`),
|
||||
/** GET /api/users/audit — staff reads of members' sessions and admins'
|
||||
* account changes, newest first. */
|
||||
audit: () => request<{ entries: AuditEntry[] }>("/users/audit"),
|
||||
@@ -2010,10 +2152,13 @@ export const api = {
|
||||
purged_agents: number;
|
||||
}>("/settings/cleanup", { method: "POST", body: JSON.stringify(params) }),
|
||||
gc: (body: { dryRun?: boolean } = {}) =>
|
||||
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>("/lanes/gc", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
request<{ reaped: number[]; capped: { path: string; sizeBefore: number }[] }>(
|
||||
"/workspaces/gc",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
),
|
||||
},
|
||||
|
||||
// ─────────────────────────────── Workflows API ──────────────────────────────
|
||||
@@ -2297,6 +2442,13 @@ export const api = {
|
||||
* CLAUDE.md/auto-memory, marketplaces, keybindings, and the statusline
|
||||
* script - for the dashboard's "CC Config" explorer/editor pages. */
|
||||
ccConfig: {
|
||||
/**
|
||||
* GET /api/cc-config/target - whose Claude Code config this login manages
|
||||
* here: `own` (server mode, their own account's), `host` (an admin, local
|
||||
* mode, or an analyst's read-only view), `none` (no account: the page is
|
||||
* not theirs).
|
||||
*/
|
||||
target: () => request<CcTarget>("/cc-config/target"),
|
||||
/**
|
||||
* GET /api/cc-config/overview - counts of every artifact kind, for the
|
||||
* explorer's landing page.
|
||||
@@ -3274,6 +3426,12 @@ export const api = {
|
||||
list: () => request<{ items: AiProxy[] }>("/ai-proxies"),
|
||||
/** GET /api/ai-proxies/9router - whether a `9router` binary is installed on the server. */
|
||||
nineRouter: () => request<{ installed: boolean }>("/ai-proxies/9router"),
|
||||
/** GET /api/ai-proxies/9router/update - versions, whether it runs, why an update would be refused, the job. */
|
||||
nineRouterUpdateStatus: (fresh = false) =>
|
||||
request<NineRouterUpdateStatus>(`/ai-proxies/9router/update${fresh ? "?fresh=1" : ""}`),
|
||||
/** POST /api/ai-proxies/9router/update - shut 9router down, update it, start it again hidden (admin). */
|
||||
nineRouterUpdate: () =>
|
||||
request<NineRouterUpdateJob>("/ai-proxies/9router/update", { method: "POST" }),
|
||||
/** POST /api/ai-proxies - save a new profile. */
|
||||
create: (body: AiProxyInput) =>
|
||||
request<AiProxy>("/ai-proxies", { method: "POST", body: JSON.stringify(body) }),
|
||||
@@ -3790,7 +3948,7 @@ export const api = {
|
||||
* GET /api/lanes — list all lanes with pipeline progress and liveness.
|
||||
* @returns `{ lanes, counts }` — all {@link Lane} records and aggregate counts.
|
||||
*/
|
||||
list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/lanes"),
|
||||
list: () => request<{ lanes: Lane[]; counts: LaneCounts }>("/workspaces"),
|
||||
/**
|
||||
* POST /api/lanes/ensure — ensure a lane exists for the given cwd.
|
||||
* @param body The cwd and optional title. When `cwd` is omitted the server
|
||||
@@ -3806,7 +3964,7 @@ export const api = {
|
||||
/** Only a lane on THIS directory counts, not one on a parent of it. */
|
||||
exact?: boolean;
|
||||
}) =>
|
||||
request<{ lane: Lane; created: boolean }>("/lanes/ensure", {
|
||||
request<{ lane: Lane; created: boolean }>("/workspaces/ensure", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
@@ -3829,7 +3987,7 @@ export const api = {
|
||||
/** This clone's name for {@link lanes.cloneProgress}. */
|
||||
progressToken?: string;
|
||||
}) =>
|
||||
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", {
|
||||
request<{ lane: Lane; created: boolean; path: string }>("/workspaces/clone", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
@@ -3839,7 +3997,7 @@ export const api = {
|
||||
* carrying `progressToken` arrives, and a minute after it ends.
|
||||
*/
|
||||
cloneProgress: (token: string) =>
|
||||
request<CloneProgressView>(`/lanes/clone/progress/${encodeURIComponent(token)}`),
|
||||
request<CloneProgressView>(`/workspaces/clone/progress/${encodeURIComponent(token)}`),
|
||||
/**
|
||||
* GET /api/lanes/pipelines — every pipeline template the server can render
|
||||
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
|
||||
@@ -3847,7 +4005,7 @@ export const api = {
|
||||
*/
|
||||
pipelines: () =>
|
||||
request<{ pipelines: { id: string; name: string; nodes: { id: string }[] }[] }>(
|
||||
"/lanes/pipelines"
|
||||
"/workspaces/pipelines"
|
||||
),
|
||||
/**
|
||||
* GET /api/lanes/pipeline-stats — per-node pass counts aggregated over every
|
||||
@@ -3857,7 +4015,7 @@ export const api = {
|
||||
*/
|
||||
pipelineStats: (laneId?: number | null) =>
|
||||
request<PipelineStats>(
|
||||
`/lanes/pipeline-stats${laneId ? `?lane=${encodeURIComponent(laneId)}` : ""}`
|
||||
`/workspaces/pipeline-stats${laneId ? `?lane=${encodeURIComponent(laneId)}` : ""}`
|
||||
),
|
||||
/**
|
||||
* GET /api/lanes/branches — a candidate source repo's local branches.
|
||||
@@ -3869,7 +4027,7 @@ export const api = {
|
||||
*/
|
||||
branches: (repo: string) =>
|
||||
request<{ branches: string[]; current: string | null; worktrees: LaneWorktree[] }>(
|
||||
`/lanes/branches?repo=${encodeURIComponent(repo)}`
|
||||
`/workspaces/branches?repo=${encodeURIComponent(repo)}`
|
||||
),
|
||||
/**
|
||||
* POST /api/lanes/worktree — provision a dashboard-managed git worktree as
|
||||
@@ -3887,7 +4045,7 @@ export const api = {
|
||||
pipeline?: string;
|
||||
tool?: LaneTool;
|
||||
}) =>
|
||||
request<{ lane: Lane }>("/lanes/worktree", {
|
||||
request<{ lane: Lane }>("/workspaces/worktree", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
@@ -3922,7 +4080,7 @@ export const api = {
|
||||
file?: boolean;
|
||||
mtime?: number | null;
|
||||
}[];
|
||||
}>(`/lanes/browse${qs ? `?${qs}` : ""}`);
|
||||
}>(`/workspaces/browse${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
/**
|
||||
* POST /api/lanes/browse/mkdir — the folder browser's New folder: one
|
||||
@@ -3930,7 +4088,7 @@ export const api = {
|
||||
* `EFOLDER` (outside the caller's own folder).
|
||||
*/
|
||||
mkdir: (parent: string, name: string) =>
|
||||
request<{ path: string }>("/lanes/browse/mkdir", {
|
||||
request<{ path: string }>("/workspaces/browse/mkdir", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ parent, name }),
|
||||
}),
|
||||
@@ -3944,7 +4102,7 @@ export const api = {
|
||||
* @returns `{ path }`, or `{ cancelled: true }` when the user cancelled.
|
||||
*/
|
||||
pickPath: (kind: "dir" | "file", start?: string) =>
|
||||
request<{ path?: string; cancelled?: boolean }>("/lanes/pick-path", {
|
||||
request<{ path?: string; cancelled?: boolean }>("/workspaces/pick-path", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ kind, start }),
|
||||
}),
|
||||
@@ -3953,7 +4111,7 @@ export const api = {
|
||||
* @param id The lane id.
|
||||
* @returns `{ lane }` — the {@link Lane} record.
|
||||
*/
|
||||
get: (id: number) => request<{ lane: Lane }>(`/lanes/${id}`),
|
||||
get: (id: number) => request<{ lane: Lane }>(`/workspaces/${id}`),
|
||||
/**
|
||||
* GET /api/lanes/:id/git — the lane's working-copy facts.
|
||||
* @param id The lane id.
|
||||
@@ -4087,7 +4245,7 @@ export const api = {
|
||||
*/
|
||||
removeSessionWorktree: (id: number, session: string) =>
|
||||
request<{ removed: true; branch: string | null; branchKept: boolean }>(
|
||||
`/lanes/${id}/session-worktree/remove`,
|
||||
`/workspaces/${id}/session-worktree/remove`,
|
||||
{ method: "POST", body: JSON.stringify({ session, confirm: true }) }
|
||||
),
|
||||
gitBranches: (id: GitTarget) =>
|
||||
@@ -4181,7 +4339,7 @@ export const api = {
|
||||
* @param id The lane id.
|
||||
* @returns {@link LaneRuntime}
|
||||
*/
|
||||
runtime: (id: number) => request<LaneRuntime>(`/lanes/${id}/runtime`),
|
||||
runtime: (id: number) => request<LaneRuntime>(`/workspaces/${id}/runtime`),
|
||||
/**
|
||||
* POST /api/lanes/:id/up — boot the lane's stack through its profile hooks.
|
||||
* Returns 202: the boot runs in the background and streams progress as
|
||||
@@ -4190,7 +4348,7 @@ export const api = {
|
||||
* @param body `build: false` reuses an existing build.
|
||||
*/
|
||||
up: (id: number, body: { build?: boolean } = {}) =>
|
||||
request<{ ok: true; laneId: number }>(`/lanes/${id}/up`, {
|
||||
request<{ ok: true; laneId: number }>(`/workspaces/${id}/up`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
@@ -4201,7 +4359,7 @@ export const api = {
|
||||
* @returns The pids stopped and the resulting runtime facts.
|
||||
*/
|
||||
down: (id: number) =>
|
||||
request<{ ok: true; killed: number[]; runtime: LaneRuntime }>(`/lanes/${id}/down`, {
|
||||
request<{ ok: true; killed: number[]; runtime: LaneRuntime }>(`/workspaces/${id}/down`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
@@ -4211,7 +4369,7 @@ export const api = {
|
||||
* @returns `{ lane }` — the created {@link Lane}.
|
||||
*/
|
||||
create: (body: { title?: string; cwd: string; branch?: string; pipeline?: string }) =>
|
||||
request<{ lane: Lane }>("/lanes", { method: "POST", body: JSON.stringify(body) }),
|
||||
request<{ lane: Lane }>("/workspaces", { method: "POST", body: JSON.stringify(body) }),
|
||||
/**
|
||||
* PATCH /api/lanes/:id — partial update of a lane.
|
||||
* @param id The lane id.
|
||||
@@ -4220,17 +4378,17 @@ export const api = {
|
||||
*/
|
||||
/** GET /api/lanes/:id/sources — the workspace's instructions, extra
|
||||
* folders and context files, and whether its tool can take them. */
|
||||
sources: (id: number) => request<LaneSourcesView>(`/lanes/${id}/sources`),
|
||||
sources: (id: number) => request<LaneSourcesView>(`/workspaces/${id}/sources`),
|
||||
/** GET /api/lanes/:id/repos — the git repos inside the workspace folder
|
||||
* (a parent folder of several repos), each a Changes section read with
|
||||
* `?repo=<path>`; `root` = the folder itself is in a repo. */
|
||||
repos: (id: number, refresh = false) =>
|
||||
request<{ root: boolean; items: { path: string; name: string }[]; truncated: boolean }>(
|
||||
`/lanes/${id}/repos${refresh ? "?refresh=1" : ""}`
|
||||
`/workspaces/${id}/repos${refresh ? "?refresh=1" : ""}`
|
||||
),
|
||||
/** PUT /api/lanes/:id/instructions — appended to every new pane's system prompt. */
|
||||
setInstructions: (id: number, instructions: string | null) =>
|
||||
request<LaneSourcesView>(`/lanes/${id}/instructions`, {
|
||||
request<LaneSourcesView>(`/workspaces/${id}/instructions`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ instructions }),
|
||||
}),
|
||||
@@ -4248,19 +4406,19 @@ export const api = {
|
||||
progressToken?: string;
|
||||
}
|
||||
) =>
|
||||
request<LaneSourcesView & { path: string }>(`/lanes/${id}/sources/clone`, {
|
||||
request<LaneSourcesView & { path: string }>(`/workspaces/${id}/sources/clone`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/** POST /api/lanes/:id/sources/folder — an extra folder the agent may use. */
|
||||
addFolder: (id: number, path: string) =>
|
||||
request<LaneSourcesView>(`/lanes/${id}/sources/folder`, {
|
||||
request<LaneSourcesView>(`/workspaces/${id}/sources/folder`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
/** POST /api/lanes/:id/sources/file — upload one context file (≤ 10 MB). */
|
||||
addFile: (id: number, file: File) =>
|
||||
request<LaneSourcesView>(`/lanes/${id}/sources/file`, {
|
||||
request<LaneSourcesView>(`/workspaces/${id}/sources/file`, {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: {
|
||||
@@ -4271,11 +4429,14 @@ export const api = {
|
||||
/** DELETE /api/lanes/:id/sources/:sourceId — a folder stays on disk; an
|
||||
* uploaded file's AgentHub copy is deleted. */
|
||||
removeSource: (id: number, sourceId: number) =>
|
||||
request<LaneSourcesView>(`/lanes/${id}/sources/${sourceId}`, { method: "DELETE" }),
|
||||
request<LaneSourcesView>(`/workspaces/${id}/sources/${sourceId}`, { method: "DELETE" }),
|
||||
/** GET /api/lanes/:id/memory — CLAUDE.md/AGENTS.md and Claude's auto memory, read-only. */
|
||||
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/lanes/${id}/memory`),
|
||||
memory: (id: number) => request<{ files: LaneMemoryFile[] }>(`/workspaces/${id}/memory`),
|
||||
update: (id: number, patch: Partial<Lane>) =>
|
||||
request<{ lane: Lane }>(`/lanes/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
request<{ lane: Lane }>(`/workspaces/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
}),
|
||||
/**
|
||||
* POST /api/lanes/:id/stage — advance the pipeline stage.
|
||||
* @param id The lane id.
|
||||
@@ -4286,14 +4447,17 @@ export const api = {
|
||||
id: number,
|
||||
body: { stage: string; status?: string; evidence?: string; note?: string; result?: string }
|
||||
) =>
|
||||
request<{ lane: Lane }>(`/lanes/${id}/stage`, { method: "POST", body: JSON.stringify(body) }),
|
||||
request<{ lane: Lane }>(`/workspaces/${id}/stage`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* GET /api/lanes/:id/preflight?action=reset|remove|purge — read the
|
||||
* action-specific facts that a destructive request must echo as `expect`.
|
||||
*/
|
||||
preflight: (id: number, action: "reset" | "remove" | "purge") =>
|
||||
request<import("./types").LanePreflight>(
|
||||
`/lanes/${id}/preflight?${new URLSearchParams({ action }).toString()}`
|
||||
`/workspaces/${id}/preflight?${new URLSearchParams({ action }).toString()}`
|
||||
),
|
||||
/**
|
||||
* POST /api/lanes/:id/:action — trigger a lane action (start, stop, clear, remove, etc.).
|
||||
@@ -4305,7 +4469,7 @@ export const api = {
|
||||
action: (id: number, action: string, body: Record<string, unknown> = {}) =>
|
||||
// `run_id` answers a `start`: the session it opened, which for a split
|
||||
// pane's slot (`body.slot` 1–3) is not the lane's own `run_id`.
|
||||
request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/lanes/${id}/${action}`, {
|
||||
request<{ lane?: Lane; ok?: boolean; run_id?: string }>(`/workspaces/${id}/${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
@@ -4316,7 +4480,7 @@ export const api = {
|
||||
*/
|
||||
features: {
|
||||
list: (id: number) =>
|
||||
request<{ features: import("./types").LaneFeature[] }>(`/lanes/${id}/features`),
|
||||
request<{ features: import("./types").LaneFeature[] }>(`/workspaces/${id}/features`),
|
||||
/**
|
||||
* GET /api/lanes/:id/features/:slug — fetch an archived feature snapshot.
|
||||
* @param id The lane id.
|
||||
@@ -4325,7 +4489,7 @@ export const api = {
|
||||
*/
|
||||
show: (id: number, slug: string) =>
|
||||
request<{ feature: import("./types").LaneFeature }>(
|
||||
`/lanes/${id}/features/${encodeURIComponent(slug)}`
|
||||
`/workspaces/${id}/features/${encodeURIComponent(slug)}`
|
||||
),
|
||||
},
|
||||
/**
|
||||
@@ -4335,7 +4499,7 @@ export const api = {
|
||||
*/
|
||||
proof: {
|
||||
list: (id: number) =>
|
||||
request<{ features: import("./types").ProofFeature[] }>(`/lanes/${id}/proof`),
|
||||
request<{ features: import("./types").ProofFeature[] }>(`/workspaces/${id}/proof`),
|
||||
/**
|
||||
* URL for a proof image, ready to use in an <img src>.
|
||||
* @param id The lane id.
|
||||
@@ -4345,7 +4509,7 @@ export const api = {
|
||||
* @returns A URL string pointing to the image.
|
||||
*/
|
||||
imageUrl: (id: number, slug: string, group: string, file: string): string =>
|
||||
`/api/lanes/${id}/proof/${encodeURIComponent(slug)}/${encodeURIComponent(group)}/${encodeURIComponent(file)}`,
|
||||
`/api/workspaces/${id}/proof/${encodeURIComponent(slug)}/${encodeURIComponent(group)}/${encodeURIComponent(file)}`,
|
||||
/**
|
||||
* DELETE /api/lanes/:id/proof/:slug — delete proof entries for a feature.
|
||||
* @param id The lane id.
|
||||
@@ -4354,19 +4518,19 @@ export const api = {
|
||||
* @returns `{ deleted }` — count of deleted images.
|
||||
*/
|
||||
delete: (id: number, slug: string, body: { group?: string; images?: string[] }) =>
|
||||
request<{ deleted: number }>(`/lanes/${id}/proof/${encodeURIComponent(slug)}`, {
|
||||
request<{ deleted: number }>(`/workspaces/${id}/proof/${encodeURIComponent(slug)}`, {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
},
|
||||
mcpSync: (id: number) =>
|
||||
request<{ servers: string[]; profilesSeeded: string[] }>(`/lanes/${id}/mcp/sync`, {
|
||||
request<{ servers: string[]; profilesSeeded: string[] }>(`/workspaces/${id}/mcp/sync`, {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
}),
|
||||
profileInit: (id: number, force = false) =>
|
||||
request<{ scaffolded: boolean; written?: string[]; todos?: string[]; reason?: string }>(
|
||||
`/lanes/${id}/profile/init`,
|
||||
`/workspaces/${id}/profile/init`,
|
||||
{ method: "POST", body: JSON.stringify({ force }) }
|
||||
),
|
||||
},
|
||||
@@ -4627,6 +4791,13 @@ export interface CcProfileUsage {
|
||||
* user + project scope in one response. */
|
||||
export type CcScope = "user" | "project" | "all";
|
||||
|
||||
/** GET /api/cc-config/target — whose Claude Code config the page manages. */
|
||||
export interface CcTarget {
|
||||
kind: "own" | "host" | "none";
|
||||
readOnly: boolean;
|
||||
claudeHome: string | null;
|
||||
}
|
||||
|
||||
/** The per-tier model names an AI Proxy profile carries. Each maps to one env
|
||||
* var Claude Code reads; a proxy that names its models once fills them all
|
||||
* from the profile's `model`. */
|
||||
@@ -4822,6 +4993,38 @@ export interface AiProxy {
|
||||
* error is still a successful HTTP call: `ok` is the PROXY's verdict, `status`
|
||||
* is its HTTP code (null when nothing answered at all), and `message` is its
|
||||
* own words — truncated server-side, never the key. */
|
||||
/** One step of the 9router update job, in order: shutdown → update → relaunch. */
|
||||
export interface NineRouterUpdateStep {
|
||||
id: "shutdown" | "update" | "relaunch";
|
||||
status: "pending" | "running" | "done" | "failed" | "skipped";
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
/** The server's one 9router update job (in memory; null before the first). */
|
||||
export interface NineRouterUpdateJob {
|
||||
status: "running" | "done" | "failed";
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
managedBy: "systemd" | "process" | null;
|
||||
install: "direct" | "sudo";
|
||||
steps: NineRouterUpdateStep[];
|
||||
log: string;
|
||||
}
|
||||
|
||||
/** GET /api/ai-proxies/9router/update. `blocker` is why a start would be refused now. */
|
||||
export interface NineRouterUpdateStatus {
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
latest: string | null;
|
||||
updateAvailable: boolean;
|
||||
running: boolean;
|
||||
managedBy: "systemd" | "process" | null;
|
||||
blocker: { code: string; message: string; command?: string } | null;
|
||||
job: NineRouterUpdateJob | null;
|
||||
}
|
||||
|
||||
export interface AiProxyProbe {
|
||||
ok: boolean;
|
||||
status: number | null;
|
||||
@@ -5207,8 +5410,9 @@ export interface CcOverview {
|
||||
/** Key filesystem locations the explorer reads from. */
|
||||
roots: {
|
||||
claudeHome: string;
|
||||
projectClaudeDir: string;
|
||||
projectRoot: string;
|
||||
/** Null for a member's own config with no workspace named: no project layer. */
|
||||
projectClaudeDir: string | null;
|
||||
projectRoot: string | null;
|
||||
/** Path to the top-level `.claude.json` (marketplaces/global settings). */
|
||||
claudeJson: string;
|
||||
};
|
||||
|
||||
@@ -43,13 +43,40 @@ export const RENDERED: ReadonlySet<ArtifactKind> = new Set([
|
||||
"image",
|
||||
]);
|
||||
|
||||
/** An edit worth a card in the conversation: a page, a document, a diagram, a
|
||||
* table — and a React component the model wrote whole (a `.tsx` it merely
|
||||
* edited is project code, not an artifact). */
|
||||
/** Folders an app keeps its own pages in: an HTML file under one of them is
|
||||
* the project's code, never a page the model made to show. */
|
||||
const PROJECT_DIRS = new Set([
|
||||
"src",
|
||||
"public",
|
||||
"templates",
|
||||
"views",
|
||||
"pages",
|
||||
"app",
|
||||
"static",
|
||||
"dist",
|
||||
"build",
|
||||
"components",
|
||||
"layouts",
|
||||
]);
|
||||
|
||||
/** An HTML file the project serves itself: an `index.html`, or one under a
|
||||
* folder in {@link PROJECT_DIRS}. */
|
||||
function isProjectPage(path: string): boolean {
|
||||
const parts = path.split(/[\\/]/);
|
||||
const name = (parts.pop() || "").toLowerCase();
|
||||
if (name === "index.html" || name === "index.htm") return true;
|
||||
return parts.some((d) => PROJECT_DIRS.has(d.toLowerCase()));
|
||||
}
|
||||
|
||||
/** An edit worth a card in the conversation: a document, a diagram, a table —
|
||||
* and a page or React component the model wrote whole (a `.tsx` or `.html` it
|
||||
* merely edited, and an HTML page of the project itself, are project code,
|
||||
* not an artifact). */
|
||||
export function isArtifact(path: string, created: boolean): boolean {
|
||||
const k = artifactKind(path);
|
||||
if (k === "react") return created;
|
||||
return k === "html" || k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
|
||||
if (k === "html") return created && !isProjectPage(path);
|
||||
return k === "svg" || k === "markdown" || k === "mermaid" || k === "csv";
|
||||
}
|
||||
|
||||
/** A code fence language for the Code tab. */
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* @file healthScore.ts
|
||||
* @description The Dashboard System Health score: a list of checks, each read
|
||||
* against two thresholds (`warnAt` → amber, `badAt` → red) and costing up to
|
||||
* `max` points as it climbs between them; the score is 100 minus every
|
||||
* deduction (never below 0). Most of the weight is on what makes the machine
|
||||
* or the dashboard feel slow — the event loop's p99 delay
|
||||
* (`/api/settings/info` `server.event_loop`) and Linux pressure stall
|
||||
* information for CPU, memory and disk I/O (`/api/host` `pressure`; the CPU
|
||||
* falls back to load per core without PSI) — then RAM, the data disk, the V8
|
||||
* heap, hooks going silent while agents work, and agent errors in the last
|
||||
* 24 h. A check whose input is missing is `na` and costs nothing. Pure — the
|
||||
* card in `Dashboard.tsx` only renders the result.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import type { HostStats } from "./api";
|
||||
|
||||
export type CheckLevel = "ok" | "warn" | "bad" | "na";
|
||||
export type CheckKey =
|
||||
"loop" | "cpu" | "memory" | "io" | "ram" | "disk" | "heap" | "hooks" | "agents";
|
||||
|
||||
export interface HealthCheck {
|
||||
key: CheckKey;
|
||||
/** "lag" checks are what a user feels as a slow machine or dashboard. */
|
||||
group: "lag" | "system";
|
||||
/** The measured value in `unit`; null when not measured. */
|
||||
value: number | null;
|
||||
unit: "ms" | "%" | "min";
|
||||
level: CheckLevel;
|
||||
warnAt: number;
|
||||
badAt: number;
|
||||
/** Points this check took off the score. */
|
||||
penalty: number;
|
||||
/** The most it can take. */
|
||||
max: number;
|
||||
/** Extra facts for the row (e.g. which mount, how many agents). */
|
||||
detail?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export interface HealthInput {
|
||||
/** `server.event_loop` (ms); absent on an older server. */
|
||||
eventLoop?: { p50: number; p99: number; max: number };
|
||||
heap: { heapUsed: number; heapTotal: number; heapLimit?: number };
|
||||
hooksInstalled: boolean;
|
||||
activity?: {
|
||||
last_event_at: string | null;
|
||||
working_agents: number;
|
||||
agents_24h: { completed: number; error: number };
|
||||
};
|
||||
/** The database file, to find the disk it lives on; null for a developer. */
|
||||
dbPath: string | null;
|
||||
host: HostStats | null;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
/** Fewest finished agents before an error rate means anything. */
|
||||
const MIN_AGENTS = 5;
|
||||
|
||||
function check(
|
||||
key: CheckKey,
|
||||
group: HealthCheck["group"],
|
||||
value: number | null,
|
||||
unit: HealthCheck["unit"],
|
||||
warnAt: number,
|
||||
badAt: number,
|
||||
max: number,
|
||||
detail?: HealthCheck["detail"]
|
||||
): HealthCheck {
|
||||
if (value == null || !Number.isFinite(value)) {
|
||||
return { key, group, value: null, unit, level: "na", warnAt, badAt, penalty: 0, max, detail };
|
||||
}
|
||||
const level: CheckLevel = value >= badAt ? "bad" : value >= warnAt ? "warn" : "ok";
|
||||
const t = Math.max(0, Math.min(1, (value - warnAt) / (badAt - warnAt)));
|
||||
return {
|
||||
key,
|
||||
group,
|
||||
value,
|
||||
unit,
|
||||
level,
|
||||
warnAt,
|
||||
badAt,
|
||||
penalty: Math.round(max * t * 10) / 10,
|
||||
max,
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
/** The mount holding `path`: the longest mount point that prefixes it, else `/`. */
|
||||
function diskOf(host: HostStats, path: string | null) {
|
||||
const disks = host.disks ?? [];
|
||||
if (path) {
|
||||
const hit = disks
|
||||
.filter((d) => path === d.mount || path.startsWith(d.mount === "/" ? "/" : `${d.mount}/`))
|
||||
.sort((a, b) => b.mount.length - a.mount.length)[0];
|
||||
if (hit) return hit;
|
||||
}
|
||||
return disks.find((d) => d.mount === "/") ?? disks[0] ?? null;
|
||||
}
|
||||
|
||||
export function healthChecks(input: HealthInput): HealthCheck[] {
|
||||
const { host } = input;
|
||||
const now = input.now ?? Date.now();
|
||||
const psi = host?.pressure ?? null;
|
||||
const out: HealthCheck[] = [];
|
||||
|
||||
// ── What lag is made of ──
|
||||
out.push(check("loop", "lag", input.eventLoop?.p99 ?? null, "ms", 100, 1000, 25));
|
||||
if (psi?.cpu) {
|
||||
out.push(check("cpu", "lag", psi.cpu.some10, "%", 20, 60, 20, { source: "psi" }));
|
||||
} else {
|
||||
const load =
|
||||
host && host.cpu.cores > 0 ? ((host.cpu.load[0] ?? 0) / host.cpu.cores) * 100 : null;
|
||||
out.push(check("cpu", "lag", load, "%", 100, 200, 20, { source: "load" }));
|
||||
}
|
||||
out.push(check("memory", "lag", psi?.memory?.some10 ?? null, "%", 5, 25, 25));
|
||||
out.push(check("io", "lag", psi?.io?.some10 ?? null, "%", 15, 50, 20));
|
||||
|
||||
// ── Capacity and the dashboard's own signals ──
|
||||
const mem = host?.memory;
|
||||
out.push(
|
||||
check(
|
||||
"ram",
|
||||
"system",
|
||||
mem && mem.total > 0 ? (mem.used / mem.total) * 100 : null,
|
||||
"%",
|
||||
90,
|
||||
98,
|
||||
10
|
||||
)
|
||||
);
|
||||
const disk = host ? diskOf(host, input.dbPath) : null;
|
||||
out.push(
|
||||
check(
|
||||
"disk",
|
||||
"system",
|
||||
disk && disk.total > 0 ? (disk.used / disk.total) * 100 : null,
|
||||
"%",
|
||||
90,
|
||||
98,
|
||||
15,
|
||||
disk ? { mount: disk.mount } : undefined
|
||||
)
|
||||
);
|
||||
const heapMax = input.heap.heapLimit || input.heap.heapTotal;
|
||||
out.push(
|
||||
check(
|
||||
"heap",
|
||||
"system",
|
||||
heapMax > 0 ? (input.heap.heapUsed / heapMax) * 100 : null,
|
||||
"%",
|
||||
70,
|
||||
90,
|
||||
15
|
||||
)
|
||||
);
|
||||
|
||||
// Hooks: not installed is always red; installed, they matter only while an
|
||||
// agent works — then minutes of silence mean the dashboard went blind.
|
||||
const a = input.activity;
|
||||
if (!input.hooksInstalled) {
|
||||
out.push({
|
||||
key: "hooks",
|
||||
group: "system",
|
||||
value: null,
|
||||
unit: "min",
|
||||
level: "bad",
|
||||
warnAt: 10,
|
||||
badAt: 30,
|
||||
penalty: 30,
|
||||
max: 30,
|
||||
detail: { state: "missing" },
|
||||
});
|
||||
} else if (!a || a.working_agents === 0 || !a.last_event_at) {
|
||||
out.push({
|
||||
...check("hooks", "system", null, "min", 10, 30, 30, { state: "idle" }),
|
||||
level: "ok",
|
||||
});
|
||||
} else {
|
||||
const age = Math.max(0, (now - Date.parse(a.last_event_at)) / 60000);
|
||||
out.push(
|
||||
check("hooks", "system", age, "min", 10, 30, 30, {
|
||||
state: "working",
|
||||
working: a.working_agents,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const done = a ? a.agents_24h.completed + a.agents_24h.error : 0;
|
||||
out.push(
|
||||
check(
|
||||
"agents",
|
||||
"system",
|
||||
a && done >= MIN_AGENTS ? (a.agents_24h.error / done) * 100 : null,
|
||||
"%",
|
||||
10,
|
||||
30,
|
||||
10,
|
||||
a ? { error: a.agents_24h.error, total: done } : undefined
|
||||
)
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function healthScore(checks: HealthCheck[]): number {
|
||||
const lost = checks.reduce((s, c) => s + c.penalty, 0);
|
||||
return Math.max(0, Math.min(100, Math.round((100 - lost) * 10) / 10));
|
||||
}
|
||||
|
||||
/** Green from 90, amber from 70, red below. */
|
||||
export function scoreLevel(score: number): Exclude<CheckLevel, "na"> {
|
||||
return score >= 90 ? "ok" : score >= 70 ? "warn" : "bad";
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
*
|
||||
* The order lives in the browser rather than the lanes table on purpose: it is
|
||||
* a per-viewer preference, and nothing
|
||||
* server-side (the CLI's `agenthub lanes`, the pipeline-stats picker) has an
|
||||
* server-side (the CLI's `agenthub workspaces`, the pipeline-stats picker) has an
|
||||
* opinion about it. Ceiling: the order does not follow the user to another
|
||||
* browser or to the CLI — move it to a `lanes.sort_order` column if it ever
|
||||
* must.
|
||||
|
||||
+10
-1
@@ -746,6 +746,15 @@ export interface Session {
|
||||
/** ISO time the user pinned the session to the top of its workspace's
|
||||
* sidebar list; null when not pinned. Maps to `sessions.pinned_at`. */
|
||||
pinned_at?: string | null;
|
||||
/** Server mode, `GET /api/sessions` only: the account owning the session's
|
||||
* workspace (null when no workspace owns it). Absent in local mode. */
|
||||
owner_id?: number | null;
|
||||
/** That owner's username (`users.username`). */
|
||||
owner_name?: string | null;
|
||||
/** That owner's display name (`users.display_name`); null when none is set. */
|
||||
owner_display_name?: string | null;
|
||||
/** When that owner's avatar was last set (`users.avatar_at`); null = none. */
|
||||
owner_avatar_at?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2633,7 +2642,7 @@ export interface LaneNode {
|
||||
icon: string;
|
||||
gate: boolean;
|
||||
/** The one command that drives this node — a slash command
|
||||
* (`/superpowers:writing-plans`) or a shell one (`agenthub lanes hook ci-gate`),
|
||||
* (`/superpowers:writing-plans`) or a shell one (`agenthub workspace hook ci-gate`),
|
||||
* from the pipeline template. Empty string for a node with no single driver
|
||||
* (`done`, or a gate another agent declares). Display text only; the
|
||||
* dashboard never runs it. Optional in the type only because the archived
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file main.tsx
|
||||
* @description Vite client entry point. Bootstraps React 18, loads global styles,
|
||||
* @description Vite client entry point. Bootstraps React 19, loads global styles,
|
||||
* registers the service worker, prefetches the Workspace chunk on a `/workspace`
|
||||
* deep link, and mounts {@link App} into `#root` once the active locale is loaded.
|
||||
*
|
||||
|
||||
@@ -1,42 +1,32 @@
|
||||
/**
|
||||
* @file Administration.tsx
|
||||
* @description The Administration page (`/admin`, a sidebar item; `/users`
|
||||
* redirects here), server mode only and only for an admin or analyst: a tab
|
||||
* shell like `History.tsx`, the active tab in `?tab=` — **Users** (the
|
||||
* accounts, the account pool and the audit log, `Users.tsx`) and **Server**
|
||||
* (the machine's hardware and each user's share of it, `ServerPanel`).
|
||||
* Local mode and a developer are sent home, like the Users page always did.
|
||||
* redirects here), server mode only and only for an admin or analyst — ONE
|
||||
* page, no tabs: ONE users table holding every per-user account fact (account,
|
||||
* spend, home, tmux; each row expands in
|
||||
* place into that user's detail), the account pool, then the audit log — all
|
||||
* from `Users.tsx`. The machine and each user's live CPU / RAM live on the
|
||||
* Dashboard's System Health tab (`HostHealth.tsx`). Local mode and a developer are sent home, like the
|
||||
* Users page always did.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import type { ComponentType } from "react";
|
||||
import { Navigate, useSearchParams } from "react-router-dom";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Server, ShieldCheck, Users as UsersIcon } from "lucide-react";
|
||||
import { PageTabs } from "../components/PageTabs";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { ServerPanel } from "../components/admin/ServerPanel";
|
||||
import { useHostStats } from "../components/health/HostHealth";
|
||||
import { Users } from "./Users";
|
||||
|
||||
interface AdminTab {
|
||||
id: string;
|
||||
label: string;
|
||||
Icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
export function Administration() {
|
||||
const { t } = useTranslation("admin");
|
||||
const auth = useAuth();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const me = auth.state.mode === "server" ? auth.state.user : null;
|
||||
const staff = !!me && me.role !== "developer";
|
||||
// Read once, for each home's share of the homes disk. The machine and each
|
||||
// user's live CPU / RAM are on the Dashboard's System Health tab.
|
||||
const host = useHostStats(staff, 0);
|
||||
|
||||
if (!me || me.role === "developer") return <Navigate to="/" replace />;
|
||||
|
||||
const tabs: AdminTab[] = [
|
||||
{ id: "users", label: t("tabs.users"), Icon: UsersIcon },
|
||||
{ id: "server", label: t("tabs.server"), Icon: Server },
|
||||
];
|
||||
const first = tabs[0]!;
|
||||
const active = tabs.find((x) => x.id === params.get("tab")) ?? first;
|
||||
if (!staff) return <Navigate to="/" replace />;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
@@ -49,18 +39,7 @@ export function Administration() {
|
||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<PageTabs
|
||||
ariaLabel={t("title")}
|
||||
activeId={active.id}
|
||||
onSelect={(id) => setParams(id === first.id ? {} : { tab: id }, { replace: true })}
|
||||
tabs={tabs}
|
||||
>
|
||||
{active.id === "server" ? (
|
||||
<ServerPanel isAdmin={me.role === "admin"} />
|
||||
) : (
|
||||
<Users embedded />
|
||||
)}
|
||||
</PageTabs>
|
||||
<Users embedded host={host} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
* — on the DASHBOARD's host at `NINE_ROUTER_PORT` (`nineRouterUrl`), never the
|
||||
* viewer's own localhost: a teammate on the LAN has no 9router of their own.
|
||||
* It is shown only when the server finds a `9router` binary installed.
|
||||
* Beside it an admin gets **Update 9router** (`NineRouterUpdate`): shut it
|
||||
* down, `npm install -g 9router@latest`, start it again hidden.
|
||||
*
|
||||
* **Test** asks the endpoint itself, once as Claude Code would
|
||||
* (`/v1/messages`) and once as Codex would (`/v1/responses`), one line each.
|
||||
@@ -54,6 +56,7 @@ import { confirmDialog } from "../lib/confirm";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { NineRouterUpdate } from "../components/settings/NineRouterUpdate";
|
||||
|
||||
const NINE_ROUTER_PORT = 20128;
|
||||
|
||||
@@ -271,6 +274,7 @@ export function AiProxyConfig() {
|
||||
{t("aiProxy.config9router")}
|
||||
</a>
|
||||
)}
|
||||
{nineRouter && isAdmin && <NineRouterUpdate />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={startAdd}
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
* files, and the `MdItemList` artifacts) also opens a
|
||||
* right-click menu that repeats those buttons — same handlers, same
|
||||
* confirm path — plus Copy name/path.
|
||||
* Server mode, a non-admin with their own Linux account (S3): the page is
|
||||
* THEIR config (`useCcTarget`, labelled as such, no project layer unless a
|
||||
* workspace is named), and the host-wide controls — Switch profile, Update
|
||||
* all, AgentHub's own Claude settings — are an admin's and hidden (the server
|
||||
* refuses them anyway).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -126,6 +131,8 @@ import { ScopeBadge } from "../components/config/ScopeBadge";
|
||||
import { ConfigTabBar } from "../components/config/ConfigTabBar";
|
||||
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
||||
import { ClaudeAgentHubSettings } from "../components/settings/ClaudeAgentHubSettings";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
import { useCcTarget } from "../hooks/useCcTarget";
|
||||
import {
|
||||
Empty,
|
||||
SkeletonRows,
|
||||
@@ -516,8 +523,14 @@ export function CcConfig() {
|
||||
|
||||
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
|
||||
const projectRoot = data.overview?.roots.projectRoot ?? null;
|
||||
// Project files under the plugin cache are wiped by the next plugin update.
|
||||
const projectDisabled = isPluginCachePath(projectRoot);
|
||||
// Server mode: whose config this is. The host-wide controls are an admin's.
|
||||
const { isAdmin } = usePermissions();
|
||||
const ccTarget = useCcTarget();
|
||||
const ownHome = ccTarget?.kind === "own" ? ccTarget.claudeHome : null;
|
||||
// Project files under the plugin cache are wiped by the next plugin update;
|
||||
// a member's own config has no project layer at all.
|
||||
const projectDisabled =
|
||||
isPluginCachePath(projectRoot) || (!!data.overview && projectRoot === null);
|
||||
useEffect(() => {
|
||||
if (projectDisabled && scope === "project") setScope("all");
|
||||
}, [projectDisabled, scope]);
|
||||
@@ -681,6 +694,8 @@ export function CcConfig() {
|
||||
}}
|
||||
onOpenBackups={() => setBackupsOpen(true)}
|
||||
wsConnected={wsConnected}
|
||||
showProfiles={isAdmin}
|
||||
ownHome={ownHome}
|
||||
/>
|
||||
|
||||
<Tabs current={tab} onSelect={(k) => void setTab(k)} counts={data.overview?.counts} />
|
||||
@@ -777,7 +792,7 @@ export function CcConfig() {
|
||||
|
||||
{/* AgentHub's own Claude Code settings follow the overview, outside the
|
||||
list's search box and scope. */}
|
||||
{tab === "overview" && (
|
||||
{tab === "overview" && isAdmin && (
|
||||
<ClaudeAgentHubSettings key={agenthubKey} onHomeChanged={() => void fetchAll()} />
|
||||
)}
|
||||
|
||||
@@ -820,10 +835,22 @@ interface HeaderProps {
|
||||
onRefresh: () => void;
|
||||
onOpenBackups: () => void;
|
||||
wsConnected: boolean;
|
||||
/** The host's login profiles link: an admin's (and local mode's). */
|
||||
showProfiles?: boolean;
|
||||
/** A member's own Claude home (server mode, S3): the page says whose it is. */
|
||||
ownHome?: string | null;
|
||||
}
|
||||
|
||||
/** The page's toolbar. No title of its own: the Settings hub already draws one. */
|
||||
function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }: HeaderProps) {
|
||||
function Header({
|
||||
loading,
|
||||
lastUpdated,
|
||||
onRefresh,
|
||||
onOpenBackups,
|
||||
wsConnected,
|
||||
showProfiles = true,
|
||||
ownHome = null,
|
||||
}: HeaderProps) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const settingsHref = useSettingsHref();
|
||||
const { t: tCommon } = useTranslation("common");
|
||||
@@ -848,18 +875,30 @@ function Header({ loading, lastUpdated, onRefresh, onOpenBackups, wsConnected }:
|
||||
{tCommon("offline")}
|
||||
</span>
|
||||
)}
|
||||
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
|
||||
{ownHome ? (
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-fg-primary" data-testid="cc-own-config">
|
||||
{t("own.label", { home: ownHome })}
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted max-w-2xl">{t("own.subtitle")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-fg-muted max-w-2xl">{t("subtitle")}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 justify-end flex-wrap">
|
||||
{/* Every AI tool's login profiles live in one place: Settings → AI tools. */}
|
||||
<Link
|
||||
to={settingsHref("agents#tool-profiles")}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
<UserCog className="w-3.5 h-3.5" />
|
||||
{t("profiles.openButton")}
|
||||
</Link>
|
||||
{/* Every AI tool's login profiles live in one place: Settings → AI tools.
|
||||
The host's logins: an admin's. */}
|
||||
{showProfiles && (
|
||||
<Link
|
||||
to={settingsHref("agents#tool-profiles")}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
|
||||
>
|
||||
<UserCog className="w-3.5 h-3.5" />
|
||||
{t("profiles.openButton")}
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenBackups}
|
||||
@@ -1290,18 +1329,31 @@ function OverviewPanel({ overview }: { overview: CcOverview | null }) {
|
||||
{t("overview.rootsTitle")}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<RootRow
|
||||
icon={FolderTree}
|
||||
tone="emerald"
|
||||
label={t("overview.projectClaudeDir")}
|
||||
value={roots.projectClaudeDir}
|
||||
/>
|
||||
<RootRow
|
||||
icon={FolderTree}
|
||||
tone="violet"
|
||||
label={t("overview.projectRoot")}
|
||||
value={roots.projectRoot}
|
||||
/>
|
||||
{/* A member's own config has no project layer: their Claude home instead. */}
|
||||
{roots.projectRoot === null && (
|
||||
<RootRow
|
||||
icon={FolderTree}
|
||||
tone="emerald"
|
||||
label={t("overview.claudeHome")}
|
||||
value={roots.claudeHome}
|
||||
/>
|
||||
)}
|
||||
{roots.projectClaudeDir !== null && (
|
||||
<RootRow
|
||||
icon={FolderTree}
|
||||
tone="emerald"
|
||||
label={t("overview.projectClaudeDir")}
|
||||
value={roots.projectClaudeDir}
|
||||
/>
|
||||
)}
|
||||
{roots.projectRoot !== null && (
|
||||
<RootRow
|
||||
icon={FolderTree}
|
||||
tone="violet"
|
||||
label={t("overview.projectRoot")}
|
||||
value={roots.projectRoot}
|
||||
/>
|
||||
)}
|
||||
<RootRow
|
||||
icon={FileText}
|
||||
tone="amber"
|
||||
@@ -1718,6 +1770,7 @@ function PluginsPanel({
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const { isAdmin } = usePermissions();
|
||||
const { busy, run } = useConfigAction(onToast, onChanged);
|
||||
const [input, setInput] = useState("");
|
||||
const [available, setAvailable] = useState<CcAvailablePlugin[] | null>(null);
|
||||
@@ -1785,7 +1838,8 @@ function PluginsPanel({
|
||||
commands={[{ cmd: t("explain.plugins.installCmd"), note: "" }]}
|
||||
/>
|
||||
|
||||
<UpdateAllCard onToast={onToast} onChanged={onChanged} />
|
||||
{/* Update all runs on the host's plugins (AgentHub's own included): an admin's. */}
|
||||
{isAdmin && <UpdateAllCard onToast={onToast} onChanged={onChanged} />}
|
||||
|
||||
<div className="rounded-lg border border-accent/25 bg-accent/5 px-3 py-2.5">
|
||||
<div className="text-[11px] font-semibold text-fg-secondary mb-1.5">
|
||||
|
||||
+798
-791
File diff suppressed because it is too large
Load Diff
+175
-96
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Sessions.tsx
|
||||
* @description Displays a list of all recorded sessions with filtering, searching, and pagination features; a row's right-click opens `SessionRowMenu`. Sessions are updated in real-time based on events received from the event bus.
|
||||
* @description Displays a list of all recorded sessions with filtering, searching, and pagination features; columns include the AI tool and (server-mode admins) the owning user, and the last column holds the row's actions (open in workspace, `SessionRowMenu` — also on right-click — and details). Sessions are updated in real-time based on events received from the event bus.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -66,6 +66,8 @@ import {
|
||||
Server,
|
||||
Download,
|
||||
AlertCircle,
|
||||
MoreHorizontal,
|
||||
SquareArrowOutUpRight,
|
||||
} from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import type { RemoteSource, RunHandle } from "../lib/api";
|
||||
@@ -80,7 +82,8 @@ import { eventBus } from "../lib/eventBus";
|
||||
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { useDataScope } from "../lib/dataScope";
|
||||
import { SessionStatusBadge } from "../components/StatusBadge";
|
||||
import { SessionToolBadge } from "../components/SessionToolBadge";
|
||||
import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { UserAvatar } from "../components/users/UserAvatar";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { TableRowSkeleton } from "../components/Skeleton";
|
||||
import { useContextMenu } from "../components/ContextMenu";
|
||||
@@ -91,12 +94,16 @@ import type { Session, DashboardEvent } from "../lib/types";
|
||||
import { usePermissions } from "../components/AuthGate";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const TH =
|
||||
"px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider whitespace-nowrap";
|
||||
const ACTION_ICON =
|
||||
"inline-flex items-center justify-center w-7 h-7 rounded-md text-fg-muted hover:text-fg-primary hover:bg-surface-3 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent";
|
||||
|
||||
export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
const navigate = useNavigate();
|
||||
const rowCtx = useContextMenu<Session>();
|
||||
const { t } = useTranslation("sessions");
|
||||
const { tools } = useAiTools();
|
||||
const { tools, label: toolLabel } = useAiTools();
|
||||
const { lanes } = useLanes();
|
||||
// Filters, sort and page live in the URL (replace, no history spam), so a
|
||||
// reload, the back button and a shared link all land on the same view.
|
||||
@@ -185,7 +192,11 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
// Load remote-source labels so remote-origin rows can show a friendly badge
|
||||
// instead of a raw `src_…` id. Refreshed when a source's status changes.
|
||||
// Staff only: a developer's /api/remote-sources is a 403 (STAFF_ONLY_PREFIXES).
|
||||
const { seesAll } = usePermissions();
|
||||
const { seesAll, isAdmin, local } = usePermissions();
|
||||
// Who owns each session: an admin's column only, and only where there are
|
||||
// accounts (server mode; the API sends no owner in local mode).
|
||||
const showUser = isAdmin && !local;
|
||||
const columns = showUser ? 10 : 9;
|
||||
const loadSourceLabels = useCallback(() => {
|
||||
if (!seesAll) return;
|
||||
api.remoteSources
|
||||
@@ -581,31 +592,19 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
role="region"
|
||||
aria-label={t("tableRegion")}
|
||||
>
|
||||
<table className="w-full min-w-[800px]">
|
||||
<table className="w-full min-w-[960px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("tableSession")}
|
||||
</th>
|
||||
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("tableStatus")}
|
||||
</th>
|
||||
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("tableLastActive")}
|
||||
</th>
|
||||
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("tableDuration")}
|
||||
</th>
|
||||
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("tableAgents")}
|
||||
</th>
|
||||
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("tableCost")}
|
||||
</th>
|
||||
<th className="px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
||||
{t("tableDirectory")}
|
||||
</th>
|
||||
<th className="w-10"></th>
|
||||
<tr className="border-b border-border text-left bg-surface-2/40">
|
||||
<th className={TH}>{t("tableSession")}</th>
|
||||
{showUser && <th className={TH}>{t("tableUser")}</th>}
|
||||
<th className={TH}>{t("tableTool")}</th>
|
||||
<th className={TH}>{t("tableStatus")}</th>
|
||||
<th className={TH}>{t("tableLastActive")}</th>
|
||||
<th className={`${TH} text-right`}>{t("tableDuration")}</th>
|
||||
<th className={`${TH} text-right`}>{t("tableAgents")}</th>
|
||||
<th className={`${TH} text-right`}>{t("tableCost")}</th>
|
||||
<th className={TH}>{t("tableDirectory")}</th>
|
||||
<th className={`${TH} text-right`}>{t("tableActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
@@ -613,94 +612,174 @@ export function Sessions({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
? Array.from({ length: 8 }).map((_, i) => (
|
||||
<TableRowSkeleton
|
||||
key={`sk-${i}`}
|
||||
columns={8}
|
||||
widths={["w-40", "w-20", "w-28", "w-20", "w-10", "w-16", "w-44", "w-4"]}
|
||||
columns={columns}
|
||||
widths={[
|
||||
"w-40",
|
||||
...(showUser ? ["w-24"] : []),
|
||||
"w-20",
|
||||
"w-20",
|
||||
"w-28",
|
||||
"w-16",
|
||||
"w-8",
|
||||
"w-14",
|
||||
"w-40",
|
||||
"w-20",
|
||||
]}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
{paged.map((session) => (
|
||||
<tr
|
||||
key={session.id}
|
||||
// Mouse convenience; the session name below is the real,
|
||||
// focusable link (a click on a link or button inside wins).
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("a,button")) return;
|
||||
navigate(`/sessions/${session.id}`);
|
||||
}}
|
||||
onContextMenu={(e) => rowCtx.open(e, session)}
|
||||
className="hover:bg-surface-4 transition-colors cursor-pointer group"
|
||||
>
|
||||
<td className="px-5 py-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{paged.map((session) => {
|
||||
const toolId = session.tool || "claude";
|
||||
// A live dashboard pane holds it: open that pane; else its
|
||||
// workspace's replay, when a workspace owns it.
|
||||
const live = runs.some((r) => r.sessionId === session.id);
|
||||
const href = workspaceHref(session, runs);
|
||||
const owner = session.owner_display_name || session.owner_name;
|
||||
return (
|
||||
<tr
|
||||
key={session.id}
|
||||
// Mouse convenience; the session name below is the real,
|
||||
// focusable link (a click on a link or button inside wins).
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("a,button")) return;
|
||||
navigate(`/sessions/${session.id}`);
|
||||
}}
|
||||
onContextMenu={(e) => rowCtx.open(e, session)}
|
||||
className="hover:bg-surface-3/60 transition-colors cursor-pointer group"
|
||||
>
|
||||
<td className="px-4 py-3 max-w-[22rem]">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Link
|
||||
to={`/sessions/${session.id}`}
|
||||
className="text-sm font-medium text-fg-secondary hover:text-fg-primary focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent rounded"
|
||||
title={session.name || undefined}
|
||||
className="truncate text-sm font-medium text-fg-primary hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent rounded"
|
||||
>
|
||||
{session.name || `${t("defaultName")}${session.id.slice(0, 8)}`}
|
||||
</Link>
|
||||
<SessionToolBadge tool={session.tool} />
|
||||
{session.source && session.source !== "local" && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-syntax-builtin bg-surface-3 border border-border px-1.5 py-0.5 rounded-full"
|
||||
className="inline-flex flex-shrink-0 items-center gap-1 text-[10px] font-semibold text-syntax-builtin bg-surface-3 border border-border px-1.5 py-0.5 rounded-full"
|
||||
title={t("remoteSourceBadgeTitle", "Collected from a remote machine")}
|
||||
>
|
||||
<Server className="w-2.5 h-2.5" />
|
||||
{sourceLabels.get(session.source) || session.source}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
// A live dashboard pane holds it: open that pane.
|
||||
const run = runs.find((r) => r.sessionId === session.id);
|
||||
const href = run ? workspaceHref(session, runs) : null;
|
||||
return href ? (
|
||||
<Link
|
||||
to={href}
|
||||
className="inline-flex items-center gap-1 text-[10px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-1.5 py-0.5 rounded-full transition-colors"
|
||||
title={t("openInWorkspaceTitle")}
|
||||
>
|
||||
<Play className="w-2.5 h-2.5" />
|
||||
{t("openInWorkspace")}
|
||||
</Link>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted font-mono">
|
||||
{session.id.slice(0, 12)}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<SessionStatusBadge
|
||||
status={effectiveSessionStatus(session)}
|
||||
reason={sessionAwaitingReason(session)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-fg-secondary">
|
||||
{formatDateTime(session.last_activity || session.started_at)}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-fg-secondary font-mono">
|
||||
{session.ended_at
|
||||
? formatDuration(session.started_at, session.ended_at)
|
||||
: t("common:running")}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-fg-secondary">
|
||||
{session.agent_count ?? "-"}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-fg-secondary font-mono">
|
||||
{session.cost != null && session.cost > 0 ? fmtCost(session.cost) : "-"}
|
||||
</td>
|
||||
<td
|
||||
className="px-5 py-4 text-[11px] text-fg-muted font-mono"
|
||||
title={session.cwd || undefined}
|
||||
>
|
||||
{session.cwd ? truncate(session.cwd, 30) : "-"}
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<ChevronRight className="w-4 h-4 text-fg-muted group-hover:text-fg-secondary transition-colors" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</td>
|
||||
{showUser && (
|
||||
<td className="px-4 py-3">
|
||||
{owner ? (
|
||||
<div
|
||||
className="flex items-center gap-2 min-w-0"
|
||||
title={session.owner_name || undefined}
|
||||
>
|
||||
<UserAvatar
|
||||
name={owner}
|
||||
userId={session.owner_id ?? undefined}
|
||||
avatarAt={session.owner_avatar_at}
|
||||
className="h-6 w-6 text-[10px]"
|
||||
/>
|
||||
<span className="truncate text-sm text-fg-secondary max-w-[9rem]">
|
||||
{owner}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-fg-muted" title={t("noOwner")}>
|
||||
-
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3">
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
title={t("toolBadgeTitle", { tool: toolLabel(toolId) })}
|
||||
>
|
||||
<AiToolIcon
|
||||
tool={{ id: toolId, label: toolLabel(toolId) }}
|
||||
className="h-6 w-6"
|
||||
/>
|
||||
<span className="text-sm text-fg-secondary whitespace-nowrap">
|
||||
{toolLabel(toolId)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<SessionStatusBadge
|
||||
status={effectiveSessionStatus(session)}
|
||||
reason={sessionAwaitingReason(session)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary whitespace-nowrap">
|
||||
{formatDateTime(session.last_activity || session.started_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary font-mono text-right whitespace-nowrap">
|
||||
{session.ended_at
|
||||
? formatDuration(session.started_at, session.ended_at)
|
||||
: t("common:running")}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary text-right tabular-nums">
|
||||
{session.agent_count ?? "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-fg-secondary font-mono text-right">
|
||||
{session.cost != null && session.cost > 0 ? fmtCost(session.cost) : "-"}
|
||||
</td>
|
||||
<td
|
||||
className="px-4 py-3 text-[11px] text-fg-muted font-mono"
|
||||
title={session.cwd || undefined}
|
||||
>
|
||||
{session.cwd ? truncate(session.cwd, 30) : "-"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{href &&
|
||||
(live ? (
|
||||
<Link
|
||||
to={href}
|
||||
className="inline-flex items-center gap-1 whitespace-nowrap text-[11px] font-semibold text-status-success bg-status-success/10 border border-status-success/25 hover:bg-status-success/20 hover:text-status-success px-2 py-1 rounded-md transition-colors"
|
||||
title={t("openInWorkspaceTitle")}
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
{t("openInWorkspace")}
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
to={href}
|
||||
className={ACTION_ICON}
|
||||
title={t("menu.inWorkspace")}
|
||||
aria-label={t("menu.inWorkspace")}
|
||||
>
|
||||
<SquareArrowOutUpRight className="w-4 h-4" />
|
||||
</Link>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => rowCtx.open(e, session)}
|
||||
className={ACTION_ICON}
|
||||
title={t("moreActions")}
|
||||
aria-label={t("moreActions")}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
<Link
|
||||
to={`/sessions/${session.id}`}
|
||||
className={ACTION_ICON}
|
||||
title={t("viewDetails")}
|
||||
aria-label={t("viewDetails")}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
* /workspace, `lib/settingsLink.ts`): the same pages, every link kept in the
|
||||
* tab (replaced, as a tab click), no Back link, and Ctrl/Cmd+F left alone
|
||||
* while the tab is hidden behind a session.
|
||||
* Server mode hides every tool page from a non-admin, except Claude Code for a
|
||||
* non-admin with their own Linux account (`useCcTarget`): "Your Claude Code
|
||||
* config", which they manage as themselves.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import {
|
||||
@@ -75,6 +78,7 @@ import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
import { ChangesPanelSettings } from "../components/settings/ChangesPanelSettings";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { useCcTarget } from "../hooks/useCcTarget";
|
||||
import {
|
||||
rankSettings,
|
||||
rankSettingsEntries,
|
||||
@@ -248,10 +252,14 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
};
|
||||
}, [location.key, location.hash]);
|
||||
|
||||
const { isAdmin, local } = usePermissions();
|
||||
const { isAdmin, local, seesAll } = usePermissions();
|
||||
const authState = useAuth().state;
|
||||
const signedIn = authState.mode === "server" && !!authState.user;
|
||||
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
||||
// Server mode: a non-admin with their own Linux account (S3) manages their
|
||||
// OWN Claude Code config here; the host's stays an admin's.
|
||||
const ccTarget = useCcTarget();
|
||||
const ownClaude = !isAdmin && ccTarget?.kind === "own";
|
||||
const s = (key: string) => ({
|
||||
title: t(`settingsNav.section.${key}.title`),
|
||||
description: t(`settingsNav.section.${key}.description`),
|
||||
@@ -299,8 +307,9 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
<AgentInstructionsSettings />
|
||||
<SessionDefaultsSettings />
|
||||
<AiToolsSettings settingsHref={toolHref} />
|
||||
{/* Every AI tool's login profiles, linked to the account pool. */}
|
||||
<ToolProfilesSettings />
|
||||
{/* Every AI tool's login profiles, linked to the account pool: the
|
||||
host's logins, a staff-only read (a developer would get 403s). */}
|
||||
{seesAll && <ToolProfilesSettings />}
|
||||
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
||||
<ArtifactsHubSettings />
|
||||
{/* The host's omp login for members: server mode + S3, an admin's. */}
|
||||
@@ -315,7 +324,7 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
group: "agents" as const,
|
||||
tool: "claude",
|
||||
Icon: toolNavIcon("claude", "Claude Code"),
|
||||
...s("claudeCode"),
|
||||
...s(ownClaude ? "claudeCodeOwn" : "claudeCode"),
|
||||
render: () => <CcConfig />,
|
||||
},
|
||||
]
|
||||
@@ -439,7 +448,10 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
// server answers 403 EADMIN), so its pages — every tool's too — are hidden.
|
||||
const sections = isAdmin
|
||||
? allSections
|
||||
: allSections.filter((x) => !x.tool && !ADMIN_SECTIONS.has(x.id));
|
||||
: allSections.filter(
|
||||
(x) =>
|
||||
(!x.tool && !ADMIN_SECTIONS.has(x.id)) || (ownClaude && x.id === "agents/claude-code")
|
||||
);
|
||||
const navSections = sections.filter((x) => !x.tool);
|
||||
const toolTabs = sections.filter((x) => x.tool);
|
||||
function toolHref(id: string) {
|
||||
|
||||
+252
-111
@@ -1,38 +1,56 @@
|
||||
/**
|
||||
* @file Users.tsx
|
||||
* @description The Users tab of the Administration page (`/admin`, which
|
||||
* @description The users section of the Administration page (`/admin`, which
|
||||
* `/users` redirects to — `Administration.tsx`), server mode only and
|
||||
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
|
||||
* role, state, workspaces, sessions, the tokens/$ it used today (the
|
||||
* browser's local day) and in the last 7 and 30 days (`GET /api/users`) and how much disk its home folder takes
|
||||
* role, state, workspaces, sessions, the $ it used today (the browser's local
|
||||
* day) with 7 and 30 days under it (`GET /api/users`),
|
||||
* how much disk its home folder takes and its share of the homes disk (from
|
||||
* `host`, the page's one `GET /api/host` read; each user's live CPU / RAM is on
|
||||
* the Dashboard's System Health tab)
|
||||
* (`GET /api/users/storage`, measured in the background — polled while the
|
||||
* server says `measuring`; Refresh re-measures), the audit log (`GET /api/users/audit`), and — for
|
||||
* server says `measuring`; Refresh re-measures), and — one Linux user per
|
||||
* account — its own tmux server (`MemberTmux.tsx`: version, outdated badge,
|
||||
* sessions, agents; an admin's restart in the row's actions and "Restart idle"
|
||||
* above the table): every per-user fact in ONE table. Also the audit log
|
||||
* (`GET /api/users/audit`), and — for
|
||||
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
||||
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
|
||||
* as one change), and the Claude/Codex account pool every user's sessions run
|
||||
* on (`AccountPoolCard`). (Each member's own tmux server — one Linux user per
|
||||
* account — moved to the Server tab, `MemberTmuxCard` in `ServerPanel`.)
|
||||
* on (`AccountPoolCard`).
|
||||
* Every change asks for the admin's OWN password again (the
|
||||
* server refuses without it); an analyst only reads. A row shows the display
|
||||
* name and email under the username when set, and "New password due" while
|
||||
* the account still has a password an admin gave; right-clicking a row offers
|
||||
* Edit, Open home (in /files), and copying the username or email.
|
||||
* Edit, Open home (in /files), and copying the username or email. Clicking a
|
||||
* row (or its name) expands that account in full right under it —
|
||||
* `UserDetail`, one at a time, kept in `?user=<id>` so a reload or a shared
|
||||
* link lands on it. `children` go between the account pool and the audit log.
|
||||
* Laid out like the other list pages: icon header, a row of totals
|
||||
* (`StatCard`), the accounts in a `card` table, the audit log in a card.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { Navigate, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Navigate, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronRight,
|
||||
DollarSign,
|
||||
Eye,
|
||||
FolderOpen,
|
||||
MessagesSquare,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
ScrollText,
|
||||
UserPlus,
|
||||
Users as UsersIcon,
|
||||
@@ -41,6 +59,7 @@ import {
|
||||
api,
|
||||
type AuditEntry,
|
||||
type AuthUser,
|
||||
type MemberTmuxRow,
|
||||
type ServerUserUsage,
|
||||
type UserStorage,
|
||||
} from "../lib/api";
|
||||
@@ -53,6 +72,15 @@ import { ConfirmModal } from "../components/ConfirmModal";
|
||||
import { StatCard } from "../components/StatCard";
|
||||
import { ROLE_BADGE, UserEditDialog } from "../components/users/UserEditDialog";
|
||||
import { AccountPoolCard } from "../components/users/AccountPoolCard";
|
||||
import { UserDetail } from "../components/users/UserDetail";
|
||||
import { UserAvatar } from "../components/users/UserAvatar";
|
||||
import {
|
||||
MemberTmuxBar,
|
||||
TmuxCell,
|
||||
TmuxRestartDialog,
|
||||
useMemberTmux,
|
||||
} from "../components/users/MemberTmux";
|
||||
import { Meter, fmtPct, pct, type HostState } from "../components/health/HostHealth";
|
||||
import { TableRowSkeleton } from "../components/Skeleton";
|
||||
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../components/ContextMenu";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
@@ -60,7 +88,16 @@ import { copyText } from "../lib/clipboard";
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "developer"];
|
||||
|
||||
export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
export function Users({
|
||||
embedded = false,
|
||||
host,
|
||||
children,
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
/** The Administration page's one `GET /api/host` read: each user's CPU/RAM now. */
|
||||
host?: HostState;
|
||||
children?: ReactNode;
|
||||
} = {}) {
|
||||
const { t } = useTranslation("users");
|
||||
const auth = useAuth();
|
||||
const me = auth.state.mode === "server" ? auth.state.user : null;
|
||||
@@ -75,6 +112,24 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
const navigate = useNavigate();
|
||||
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
|
||||
const [measuring, setMeasuring] = useState(false);
|
||||
const tmux = useMemberTmux(!!me && me.role !== "developer");
|
||||
// A member's tmux restart: one member, or every idle outdated one.
|
||||
const [restarting, setRestarting] = useState<MemberTmuxRow | "idle" | null>(null);
|
||||
const [params, setParams] = useSearchParams();
|
||||
const detailId = Number(params.get("user")) || null;
|
||||
const openDetail = useCallback(
|
||||
(id: number | null) =>
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (id == null) next.delete("user");
|
||||
else next.set("user", String(id));
|
||||
return next;
|
||||
},
|
||||
{ replace: true }
|
||||
),
|
||||
[setParams]
|
||||
);
|
||||
|
||||
const loadStorage = useCallback(
|
||||
(remeasure = false) =>
|
||||
@@ -108,7 +163,14 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
);
|
||||
// The Refresh button also re-measures every home; a plain load reuses the
|
||||
// server's cached numbers.
|
||||
const [spinning, refresh] = useSpin(useCallback(() => load(true), [load]));
|
||||
const reloadHost = host?.reload;
|
||||
const reloadTmux = tmux.reload;
|
||||
const [spinning, refresh] = useSpin(
|
||||
useCallback(
|
||||
() => Promise.all([load(true), reloadTmux(), reloadHost?.()]),
|
||||
[load, reloadTmux, reloadHost]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (me && me.role !== "developer") void load();
|
||||
@@ -127,7 +189,12 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
const sum = (pick: (u: ServerUserUsage) => number) =>
|
||||
(users ?? []).reduce((n, u) => n + pick(u), 0);
|
||||
const active = (users ?? []).filter((u) => !u.disabled).length;
|
||||
const th = "px-5 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
const th = "px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider";
|
||||
const stats = host?.stats ?? null;
|
||||
const homesDisk = stats?.disks.find((d) => d.mount === stats.homesMount) ?? null;
|
||||
const showTmux = !!tmux.data?.available;
|
||||
const tmuxById = new Map((tmux.data?.users ?? []).map((r) => [r.id, r]));
|
||||
const columns = 5 + (showTmux ? 1 : 0) + (isAdmin ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
@@ -139,10 +206,12 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{/* Under Administration's tab bar the tab already names the page. */}
|
||||
<h1 className={embedded ? "sr-only" : "text-lg font-semibold text-fg-primary"}>
|
||||
{t("title")}
|
||||
</h1>
|
||||
{/* On the Administration page this is one section of it: a section heading. */}
|
||||
{embedded ? (
|
||||
<h2 className="text-base font-semibold text-fg-primary">{t("title")}</h2>
|
||||
) : (
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
)}
|
||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,18 +282,18 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<MemberTmuxBar data={tmux.data} isAdmin={isAdmin} onRestart={setRestarting} />
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full min-w-[960px]" data-testid="users-table">
|
||||
<table className="w-full min-w-[1040px]" data-testid="users-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("name")}</th>
|
||||
<th className={th}>{t("role")}</th>
|
||||
<th className={`${th} text-right`}>{t("workspaces")}</th>
|
||||
<th className={`${th} text-right`}>{t("sessions")}</th>
|
||||
<th className={`${th} text-right`}>{t("today")}</th>
|
||||
<th className={`${th} text-right`}>{t("last7d")}</th>
|
||||
<th className={`${th} text-right`}>{t("last30d")}</th>
|
||||
<th className={`${th} text-right`}>{t("storage")}</th>
|
||||
<th className={th}>{t("activity")}</th>
|
||||
<th className={`${th} text-right`}>{t("spend")}</th>
|
||||
<th className={`${th} w-[12%]`}>{t("storage")}</th>
|
||||
{showTmux && <th className={th}>{t("tmuxCol")}</th>}
|
||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -234,87 +303,140 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<TableRowSkeleton
|
||||
key={i}
|
||||
columns={isAdmin ? 8 : 7}
|
||||
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-14", "w-28"]}
|
||||
columns={columns}
|
||||
widths={["w-36", "w-20", "w-24", "w-16", "w-24", "w-24", "w-16", "w-10"].slice(
|
||||
0,
|
||||
columns
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{users?.map((u) => {
|
||||
const self = u.id === me.id;
|
||||
const open = detailId === u.id;
|
||||
const toggle = () => openDetail(open ? null : u.id);
|
||||
const tmuxRow = tmuxById.get(u.id);
|
||||
const home = storage ? (storage[u.id] ?? null) : undefined;
|
||||
return (
|
||||
<tr
|
||||
key={u.id}
|
||||
onContextMenu={(e) => rowMenu.open(e, u)}
|
||||
className={`hover:bg-surface-4/40 transition-colors ${u.disabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
aria-hidden
|
||||
className="w-8 h-8 flex-shrink-0 rounded-full bg-surface-4 flex items-center justify-center text-xs font-semibold uppercase text-fg-secondary"
|
||||
>
|
||||
{(u.displayName || u.username).slice(0, 2)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-fg-primary">
|
||||
{u.displayName || u.username}
|
||||
{u.displayName && (
|
||||
<span className="ml-1.5 font-normal text-fg-muted">@{u.username}</span>
|
||||
<Fragment key={u.id}>
|
||||
<tr
|
||||
onContextMenu={(e) => rowMenu.open(e, u)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("a,button")) return;
|
||||
toggle();
|
||||
}}
|
||||
className={`cursor-pointer hover:bg-surface-4/40 transition-colors ${open ? "bg-surface-3/50" : ""} ${u.disabled ? "opacity-60" : ""}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={`w-4 h-4 flex-shrink-0 text-fg-muted transition-transform ${open ? "rotate-90" : ""}`}
|
||||
/>
|
||||
<UserAvatar
|
||||
name={u.displayName || u.username}
|
||||
userId={u.id}
|
||||
avatarAt={u.avatarAt}
|
||||
className="h-8 w-8 text-xs"
|
||||
initialsClassName="bg-surface-4 text-fg-secondary"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded={open}
|
||||
title={t("detail.open", { name: u.displayName || u.username })}
|
||||
className="block max-w-full truncate text-left text-sm font-medium text-fg-primary hover:text-accent"
|
||||
>
|
||||
{u.displayName || u.username}
|
||||
{u.displayName && (
|
||||
<span className="ml-1.5 font-normal text-fg-muted">
|
||||
@{u.username}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{u.email && (
|
||||
<div className="truncate text-[11px] text-fg-muted">{u.email}</div>
|
||||
)}
|
||||
</div>
|
||||
{u.email && (
|
||||
<div className="truncate text-[11px] text-fg-muted">{u.email}</div>
|
||||
{self && (
|
||||
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
|
||||
{t("you")}
|
||||
</span>
|
||||
)}
|
||||
{u.disabled && (
|
||||
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||
{t("disabled")}
|
||||
</span>
|
||||
)}
|
||||
{!u.disabled && u.mustChangePassword && (
|
||||
<span
|
||||
className="badge py-0.5 text-[11px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={t("mustChangeHint")}
|
||||
data-testid="user-must-change"
|
||||
>
|
||||
{t("mustChange")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{self && (
|
||||
<span className="badge py-0.5 text-[11px] bg-accent/15 text-accent border-accent/20">
|
||||
{t("you")}
|
||||
</span>
|
||||
)}
|
||||
{u.disabled && (
|
||||
<span className="badge py-0.5 text-[11px] bg-status-danger/10 text-status-danger border-status-danger/20">
|
||||
{t("disabled")}
|
||||
</span>
|
||||
)}
|
||||
{!u.disabled && u.mustChangePassword && (
|
||||
<span
|
||||
className="badge py-0.5 text-[11px] bg-status-warning/10 text-status-warning border-status-warning/20"
|
||||
title={t("mustChangeHint")}
|
||||
data-testid="user-must-change"
|
||||
>
|
||||
{t("mustChange")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<span
|
||||
className={`badge py-0.5 text-[11px] ${ROLE_BADGE[u.role]}`}
|
||||
title={t(`roleHint.${u.role}`)}
|
||||
>
|
||||
{t(`roles.${u.role}`)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{u.workspaces}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-sm tabular-nums text-fg-secondary">
|
||||
{u.sessions}
|
||||
</td>
|
||||
<UsageCell usage={u.today ?? { cost: 0, tokens: 0 }} />
|
||||
<UsageCell usage={u.last7d} />
|
||||
<UsageCell usage={u.last30d} />
|
||||
<StorageCell
|
||||
storage={storage ? (storage[u.id] ?? null) : undefined}
|
||||
measuring={measuring}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<td className="px-5 py-2 text-right whitespace-nowrap">
|
||||
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</RowAction>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`badge py-0.5 text-[11px] ${ROLE_BADGE[u.role]}`}
|
||||
title={t(`roleHint.${u.role}`)}
|
||||
>
|
||||
{t(`roles.${u.role}`)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm tabular-nums">
|
||||
<div className="text-fg-primary">{t("sessionsN", { n: u.sessions })}</div>
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("workspacesN", { n: u.workspaces })}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className="px-4 py-3 text-right tabular-nums"
|
||||
title={t("tokens", { n: fmt((u.today ?? { tokens: 0 }).tokens) })}
|
||||
>
|
||||
<div className="text-sm text-fg-primary">
|
||||
{fmtCost((u.today ?? { cost: 0 }).cost)}
|
||||
</div>
|
||||
<div className="whitespace-nowrap text-[11px] text-fg-muted">
|
||||
{t("spendSub", {
|
||||
week: fmtCost(u.last7d.cost),
|
||||
month: fmtCost(u.last30d.cost),
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
<StorageCell storage={home} measuring={measuring} disk={homesDisk} />
|
||||
{showTmux && (
|
||||
<td className="px-4 py-3">
|
||||
<TmuxCell row={tmuxRow} />
|
||||
</td>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<td className="px-4 py-2 text-right whitespace-nowrap">
|
||||
{tmuxRow?.running && (
|
||||
<RowAction
|
||||
label={t("tmux.restart", { name: u.username })}
|
||||
onClick={() => setRestarting(tmuxRow)}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</RowAction>
|
||||
)}
|
||||
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</RowAction>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{open && (
|
||||
<tr className="bg-surface-2/40">
|
||||
<td colSpan={columns} className="px-5 py-4 cursor-default">
|
||||
<UserDetail userId={u.id} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
@@ -335,6 +457,9 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
onClose={rowMenu.close}
|
||||
testId="users-row-menu"
|
||||
>
|
||||
<MenuItem onSelect={pick(() => openDetail(detailId === u.id ? null : u.id))}>
|
||||
{t("detail.open", { name: u.displayName || u.username })}
|
||||
</MenuItem>
|
||||
{isAdmin && (
|
||||
<MenuItem onSelect={pick(() => setEditing(u))}>{t("edit.button")}</MenuItem>
|
||||
)}
|
||||
@@ -364,6 +489,8 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
|
||||
<AccountPoolCard isAdmin={isAdmin} />
|
||||
|
||||
{children}
|
||||
|
||||
<section className="card">
|
||||
<div className="flex items-center gap-2 px-5 py-4 border-b border-border">
|
||||
<ScrollText className="w-4 h-4 text-fg-muted" />
|
||||
@@ -417,6 +544,18 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{restarting && (
|
||||
<TmuxRestartDialog
|
||||
target={restarting}
|
||||
data={tmux.data}
|
||||
onClose={() => setRestarting(null)}
|
||||
onDone={(message) => {
|
||||
toast.success(message);
|
||||
setRestarting(null);
|
||||
void tmux.reload();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{editing && (
|
||||
<UserEditDialog
|
||||
user={editing}
|
||||
@@ -434,47 +573,49 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A period's cost, with its tokens underneath. */
|
||||
function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
|
||||
const { t } = useTranslation("users");
|
||||
return (
|
||||
<td className="px-5 py-3 text-right tabular-nums">
|
||||
<div className="text-sm text-fg-primary">{fmtCost(usage.cost)}</div>
|
||||
<div className="text-[11px] text-fg-muted">{t("tokens", { n: fmt(usage.tokens) })}</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A user's home folder size (`undefined` = not loaded yet). `null` while the
|
||||
* server is still measuring reads "Measuring…", otherwise "—" (no home of
|
||||
* their own).
|
||||
*/
|
||||
function StorageCell({ storage, measuring }: { storage?: UserStorage; measuring: boolean }) {
|
||||
function StorageCell({
|
||||
storage,
|
||||
measuring,
|
||||
disk,
|
||||
}: {
|
||||
storage?: UserStorage;
|
||||
measuring: boolean;
|
||||
/** The disk holding the homes: the home is also shown as a share of it. */
|
||||
disk: { total: number } | null;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
if (!storage) {
|
||||
const pending = storage === undefined || measuring;
|
||||
return (
|
||||
<td
|
||||
className="px-5 py-3 text-right text-sm tabular-nums text-fg-muted"
|
||||
className="px-4 py-3 text-sm tabular-nums text-fg-muted"
|
||||
title={pending ? undefined : t("storageNone")}
|
||||
>
|
||||
{pending ? t("storageMeasuring") : "—"}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
const share = disk ? pct(storage.bytes, disk.total) : null;
|
||||
return (
|
||||
<td
|
||||
className="px-5 py-3 text-right tabular-nums"
|
||||
title={storage.partial ? t("storagePartial") : undefined}
|
||||
className="px-4 py-3 tabular-nums"
|
||||
title={
|
||||
storage.partial
|
||||
? t("storagePartial")
|
||||
: t("storageHint", { files: fmt(storage.files), at: formatDateTime(storage.measuredAt) })
|
||||
}
|
||||
>
|
||||
<div className="text-sm text-fg-primary">
|
||||
{storage.partial ? "≥ " : ""}
|
||||
{formatBytes(storage.bytes)}
|
||||
{share != null && <span className="text-[11px] text-fg-muted"> ({fmtPct(share)})</span>}
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("storageHint", { files: fmt(storage.files), at: formatDateTime(storage.measuredAt) })}
|
||||
</div>
|
||||
{share != null && <Meter value={share} label={t("storage")} />}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1251,7 +1251,7 @@ export function Workspace() {
|
||||
}
|
||||
};
|
||||
|
||||
// Mirrors `agenthub lanes pipeline <template> <id>`: same PATCH, same
|
||||
// Mirrors `agenthub workspace pipeline <template> <id>`: same PATCH, same
|
||||
// stage-mismatch warning when the current declared stage matches no node
|
||||
// in the newly chosen template.
|
||||
const handlePipelineChange = async (id: number, pipeline: string) => {
|
||||
@@ -1420,7 +1420,7 @@ export function Workspace() {
|
||||
<div data-testid="proof-gallery" className="mt-2">
|
||||
{proofFeature.ticket_report && (
|
||||
<a
|
||||
href={`/api/lanes/${currentLane.id}/proof/${proofFeature.ticket_report}`}
|
||||
href={`/api/workspaces/${currentLane.id}/proof/${proofFeature.ticket_report}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-fg-muted"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* @file Administration.test.tsx
|
||||
* @description The Administration page (`/admin`): local mode and a developer
|
||||
* are sent home; staff land on the Users tab; the Server tab shows the
|
||||
* machine's hardware, every disk, and each user's CPU/memory/home share from
|
||||
* `GET /api/host`, with a user's busiest processes on click.
|
||||
* are sent home; staff see ONE page, no tabs, and no machine section (that is
|
||||
* the Dashboard's System Health tab now, each user's live CPU / RAM included)
|
||||
* — the users table carries each user's home share (`GET /api/host`) and tmux.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import type { AuthState, HostStats } from "../../lib/api";
|
||||
|
||||
@@ -73,7 +73,7 @@ const api = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
storage: vi.fn(),
|
||||
tmux: vi.fn(async () => ({ available: false, users: [] })),
|
||||
tmux: vi.fn(),
|
||||
avatarUrl: () => null,
|
||||
},
|
||||
}));
|
||||
@@ -137,6 +137,7 @@ beforeEach(() => {
|
||||
],
|
||||
});
|
||||
api.users.audit.mockResolvedValue({ entries: [] });
|
||||
api.users.tmux.mockResolvedValue({ available: false, users: [] });
|
||||
api.users.storage.mockResolvedValue({
|
||||
storage: { 2: { bytes: 20 * GB, files: 10, partial: false, measuredAt: "x" } },
|
||||
measuring: false,
|
||||
@@ -153,27 +154,40 @@ describe("Administration", () => {
|
||||
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("opens on the Users tab", async () => {
|
||||
it("shows the users on one page, with no tabs and no machine section", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
renderAt("/admin");
|
||||
expect(await screen.findByTestId("users-table")).toBeTruthy();
|
||||
expect(screen.getByRole("tab", { name: /users/i }).getAttribute("aria-selected")).toBe("true");
|
||||
expect(screen.queryByTestId("server-facts")).toBeNull();
|
||||
expect(screen.queryByRole("tab", { name: "Server" })).toBeNull();
|
||||
expect(screen.queryByRole("tab", { name: "Users" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows hardware and each user's share on the Server tab", async () => {
|
||||
it("puts each user's home and tmux in the users table, but not their CPU", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
renderAt("/admin?tab=server");
|
||||
const table = await screen.findByTestId("server-usage-table");
|
||||
expect(await screen.findByText("box")).toBeTruthy();
|
||||
expect(screen.getByTestId("server-disks-table").textContent).toContain("HDD");
|
||||
const mia = await within(table).findByText("Mia");
|
||||
const row = mia.closest("tr")!;
|
||||
expect(row.textContent).toContain("25%");
|
||||
expect(row.textContent).toContain("(13%)");
|
||||
// 20 GB home on the 200 GB homes disk.
|
||||
expect(row.textContent).toContain("10%");
|
||||
expect(within(table).queryByText("claude")).toBeNull();
|
||||
fireEvent.click(mia.closest("button")!);
|
||||
expect(within(table).getByText("claude")).toBeTruthy();
|
||||
api.users.tmux.mockResolvedValue({
|
||||
available: true,
|
||||
installed: "3.8",
|
||||
matched: true,
|
||||
users: [
|
||||
{
|
||||
id: 2,
|
||||
username: "mia",
|
||||
running: true,
|
||||
version: "3.6",
|
||||
sessions: 2,
|
||||
busy: 1,
|
||||
outdated: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
renderAt("/admin");
|
||||
const users = await screen.findByTestId("users-table");
|
||||
const row = (await within(users).findByRole("button", { name: /^Mia/ })).closest("tr")!;
|
||||
// 20 GB home on the 200 GB homes disk (the host read, once).
|
||||
await waitFor(() => expect(row.textContent).toContain("(10%)"));
|
||||
expect(row.textContent).not.toContain("25%");
|
||||
expect(row.textContent).toContain("older than installed");
|
||||
expect(within(row).getByRole("button", { name: "Restart mia's tmux" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,12 +238,17 @@ describe("Artifacts", () => {
|
||||
within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Share" })
|
||||
).toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
// One merged list by default; Hub is a filter over it.
|
||||
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "all" }));
|
||||
// One merged list by default; Hub is a filter over it. The Suggested
|
||||
// count's own `unowned` list runs whenever settings land, so it is left
|
||||
// out: only the order of the LIST loads is under test.
|
||||
const listScopes = () =>
|
||||
vi
|
||||
.mocked(api.artifacts.list)
|
||||
.mock.calls.map(([opts]) => opts?.scope)
|
||||
.filter((s) => s !== "unowned");
|
||||
expect(listScopes().pop()).toBe("all");
|
||||
await user.click(screen.getByRole("button", { name: "Hub" }));
|
||||
await waitFor(() =>
|
||||
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "hub" }))
|
||||
);
|
||||
await waitFor(() => expect(listScopes().pop()).toBe("hub"));
|
||||
});
|
||||
|
||||
it("splits the Hub and My library into yours and others'", async () => {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @file Covers the Claude Code config page as a MEMBER's own config (server
|
||||
* mode, a non-admin with their own Linux account): it says whose config it is,
|
||||
* and the host-wide controls — Switch profile, Update all, AgentHub's own
|
||||
* Claude settings — are not shown (the server refuses them anyway). An admin
|
||||
* still sees all three.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
const perms = vi.hoisted(() => ({ isAdmin: false }));
|
||||
vi.mock("../../components/AuthGate", async (importOriginal) => {
|
||||
const m = await importOriginal<typeof import("../../components/AuthGate")>();
|
||||
return {
|
||||
...m,
|
||||
usePermissions: () => ({ ...m.usePermissions(), local: false, isAdmin: perms.isAdmin }),
|
||||
};
|
||||
});
|
||||
vi.mock("../../components/settings/ClaudeAgentHubSettings", () => ({
|
||||
ClaudeAgentHubSettings: () => <div data-testid="agenthub-claude-settings" />,
|
||||
}));
|
||||
|
||||
const HOME = "/srv/users/mia/.claude";
|
||||
const ccConfigMock = vi.hoisted(() => {
|
||||
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
||||
return {
|
||||
target: r({ kind: "own", readOnly: false, claudeHome: "/srv/users/mia/.claude" }),
|
||||
overview: r({
|
||||
roots: {
|
||||
claudeHome: "/srv/users/mia/.claude",
|
||||
projectClaudeDir: null,
|
||||
projectRoot: null,
|
||||
claudeJson: "/srv/users/mia/.claude.json",
|
||||
},
|
||||
counts: {
|
||||
skills: { user: 0, project: 0 },
|
||||
agents: { user: 0, project: 0 },
|
||||
commands: { user: 0, project: 0 },
|
||||
outputStyles: { user: 0, project: 0 },
|
||||
mcpServers: { user: 0, project: 0 },
|
||||
hooks: {},
|
||||
plugins: 0,
|
||||
marketplaces: 0,
|
||||
keybindings: 0,
|
||||
settingsFiles: 0,
|
||||
memory: 0,
|
||||
},
|
||||
}),
|
||||
skills: r({ items: [] }),
|
||||
agents: r({ items: [] }),
|
||||
commands: r({ items: [] }),
|
||||
outputStyles: r({ items: [] }),
|
||||
memory: r({ items: [] }),
|
||||
plugins: r({ manifestPath: null, manifestExists: false, plugins: [] }),
|
||||
marketplaces: r({ items: [] }),
|
||||
mcp: r({ user: [], projectScoped: [] }),
|
||||
hooks: r({ items: [] }),
|
||||
settings: r({ items: [] }),
|
||||
keybindings: r({ groups: [] }),
|
||||
statusline: r({}),
|
||||
hookScripts: r({ items: [] }),
|
||||
backups: r({ items: [] }),
|
||||
pluginsAvailable: r({ installed: [], available: [] }),
|
||||
updateAllStatus: r({ batch: null }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: { ...(actual.api as Record<string, unknown>), ccConfig: ccConfigMock },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../lib/eventBus", () => ({
|
||||
eventBus: {
|
||||
subscribe: () => () => {},
|
||||
publish: () => {},
|
||||
onConnection: () => () => {},
|
||||
connected: true,
|
||||
setConnected: () => {},
|
||||
},
|
||||
}));
|
||||
|
||||
import { CcConfig } from "../CcConfig";
|
||||
|
||||
async function settle() {
|
||||
await act(async () => {
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
await new Promise((res) => setTimeout(res, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function open() {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<CcConfig />
|
||||
</MemoryRouter>
|
||||
);
|
||||
await settle();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
perms.isAdmin = false;
|
||||
});
|
||||
|
||||
describe("CcConfig — a member's own config", () => {
|
||||
it("says whose config it is and hides the host-wide controls", async () => {
|
||||
await open();
|
||||
expect(screen.getByTestId("cc-own-config")).toHaveTextContent(HOME);
|
||||
expect(screen.queryByRole("link", { name: /switch profile/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("agenthub-claude-settings")).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("tab", { name: /plugins/i }));
|
||||
await settle();
|
||||
expect(screen.queryByRole("button", { name: /^update all$/i })).not.toBeInTheDocument();
|
||||
expect(ccConfigMock.updateAllStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps all three for an admin", async () => {
|
||||
perms.isAdmin = true;
|
||||
await open();
|
||||
expect(screen.queryByTestId("cc-own-config")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /switch profile/i })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("agenthub-claude-settings")).toBeInTheDocument();
|
||||
expect(ccConfigMock.target).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -21,10 +21,15 @@ import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import type { AiTool, AiToolsView } from "../../lib/api";
|
||||
|
||||
// Local mode unless a test says otherwise; still an admin either way.
|
||||
const perms = vi.hoisted(() => ({ local: true }));
|
||||
const perms = vi.hoisted(() => ({ local: true, isAdmin: true }));
|
||||
// What GET /api/cc-config/target answers a non-admin.
|
||||
const ccTarget = vi.hoisted(() => ({ kind: "none" as "own" | "host" | "none" }));
|
||||
vi.mock("../../components/AuthGate", async (importOriginal) => {
|
||||
const m = await importOriginal<typeof import("../../components/AuthGate")>();
|
||||
return { ...m, usePermissions: () => ({ ...m.usePermissions(), local: perms.local }) };
|
||||
return {
|
||||
...m,
|
||||
usePermissions: () => ({ ...m.usePermissions(), local: perms.local, isAdmin: perms.isAdmin }),
|
||||
};
|
||||
});
|
||||
vi.mock("../../components/settings/GitIdentitySettings", () => ({
|
||||
GitIdentitySettings: () => <div data-testid="git-identity" />,
|
||||
@@ -88,7 +93,14 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
// AI tools → Profiles.
|
||||
toolProfiles: { list: vi.fn(async () => ({ tools: [] })) },
|
||||
ccConfig: { profiles: vi.fn(async () => ({ items: [], active: null })) },
|
||||
ccConfig: {
|
||||
profiles: vi.fn(async () => ({ items: [], active: null })),
|
||||
target: vi.fn(async () => ({
|
||||
kind: ccTarget.kind,
|
||||
readOnly: ccTarget.kind !== "own",
|
||||
claudeHome: ccTarget.kind === "own" ? "/srv/users/mia/.claude" : null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -124,6 +136,8 @@ const nav = () => screen.getByRole("navigation", { name: "Settings sections" });
|
||||
|
||||
beforeEach(() => {
|
||||
perms.local = true;
|
||||
perms.isAdmin = true;
|
||||
ccTarget.kind = "none";
|
||||
__resetAiTools();
|
||||
view = {
|
||||
default: "claude",
|
||||
@@ -202,6 +216,29 @@ describe("SettingsHub", () => {
|
||||
expect(screen.getByTestId("ai-tools")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("gives a non-admin with their own account their own Claude Code page, and no other tool's", async () => {
|
||||
perms.local = false;
|
||||
perms.isAdmin = false;
|
||||
ccTarget.kind = "own";
|
||||
renderAt("/settings/agents/claude-code");
|
||||
expect(await screen.findByTestId("cc-config")).toBeInTheDocument();
|
||||
const tabs = within(screen.getByRole("tablist", { name: "AI tool settings" }))
|
||||
.getAllByRole("tab")
|
||||
.map((a) => a.textContent);
|
||||
expect(tabs).toEqual(["All tools", "Your Claude Code config"]);
|
||||
});
|
||||
|
||||
it("keeps the Claude Code page from a non-admin with no account of their own", async () => {
|
||||
perms.local = false;
|
||||
perms.isAdmin = false;
|
||||
ccTarget.kind = "none";
|
||||
renderAt("/settings/agents/claude-code");
|
||||
expect(
|
||||
await screen.findByRole("heading", { level: 1, name: "Appearance" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("cc-config")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates from the mobile select", async () => {
|
||||
renderAt("/settings/data");
|
||||
await screen.findByTestId("settings-data");
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
* a wrong password reported in the edit dialog, opens ONE Edit per row (no
|
||||
* separate role/password/workspace/disable buttons any more), and sees the
|
||||
* display name and email under a username; right-clicking a row opens that
|
||||
* user's home in Files (an admin) — an analyst's menu only copies.
|
||||
* user's home in Files (an admin) — an analyst's menu only copies; clicking a
|
||||
* user's name expands their detail under the row (`?user=`), with spend and
|
||||
* pool share, and a second click folds it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -41,6 +43,8 @@ const users = vi.hoisted(() => ({
|
||||
setPassword: vi.fn(),
|
||||
assignWorkspace: vi.fn(),
|
||||
get: vi.fn(),
|
||||
details: vi.fn(),
|
||||
avatarUrl: vi.fn(),
|
||||
// No S3 here: the members' tmux card stays hidden.
|
||||
tmux: vi.fn(async () => ({ available: false, users: [] })),
|
||||
}));
|
||||
@@ -96,7 +100,76 @@ beforeEach(() => {
|
||||
}));
|
||||
});
|
||||
|
||||
const details = {
|
||||
user: {
|
||||
id: 2,
|
||||
username: "mia",
|
||||
role: "developer",
|
||||
disabled: false,
|
||||
displayName: "Mia Tran",
|
||||
logins: 1,
|
||||
lastLoginAt: null,
|
||||
},
|
||||
sessions: { total: 3, active: 1, firstAt: null, lastAt: null },
|
||||
usage: {
|
||||
today: { cost: 1, tokens: 10 },
|
||||
last7d: { cost: 2, tokens: 20 },
|
||||
last30d: { cost: 4, tokens: 40 },
|
||||
daily: [{ date: "2026-10-27", cost: 4, tokens: 40 }],
|
||||
byTool: [{ key: "codex", cost: 4, tokens: 40, sessions: 1 }],
|
||||
byModel: [{ key: "gpt-5.5", cost: 4, tokens: 40, sessions: 1 }],
|
||||
},
|
||||
workspaces: [],
|
||||
panes: [],
|
||||
recentSessions: [],
|
||||
pool: {
|
||||
claude: {
|
||||
pin: "team-1",
|
||||
group: null,
|
||||
accounts: [
|
||||
{
|
||||
name: "team-1",
|
||||
kind: "token",
|
||||
email: null,
|
||||
expired: false,
|
||||
allowed: true,
|
||||
pinned: true,
|
||||
quota: null,
|
||||
quotaPercent: null,
|
||||
full: false,
|
||||
livePanes: 0,
|
||||
spend5h: 0,
|
||||
spend7d: 10,
|
||||
userSpend5h: 0,
|
||||
userSpend7d: 2.5,
|
||||
userTokens5h: 0,
|
||||
userTokens7d: 99,
|
||||
userRuns7d: 2,
|
||||
userLivePanes: 0,
|
||||
userLastAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
audit: [],
|
||||
};
|
||||
|
||||
describe("Users page", () => {
|
||||
it("expands a user's detail under their row, and folds it again", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
users.details.mockResolvedValue(details);
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "mia" }));
|
||||
const pool = await screen.findByTestId("user-pool-account");
|
||||
expect(users.details).toHaveBeenCalledWith(2);
|
||||
const page = screen.getByTestId("user-detail");
|
||||
expect(within(screen.getByTestId("users-table")).getByTestId("user-detail")).toBe(page);
|
||||
expect(within(pool).getByText("team-1")).toBeTruthy();
|
||||
expect(within(pool).getByText(/25%/)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "mia" }));
|
||||
await waitFor(() => expect(screen.queryByTestId("user-detail")).toBeNull());
|
||||
});
|
||||
|
||||
it("sends local mode and a member home", async () => {
|
||||
authState = { mode: "local" };
|
||||
renderPage();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,12 @@ export default defineConfig({
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
// ES2021 or newer, never Vite's es2020 default: esbuild 0.25 lowers
|
||||
// xterm 6's `let r; …(r||={})` (requestMode, DECRQM) to `void 0||(i={})`
|
||||
// and drops the `let`, so every DECRQM query tmux sends threw
|
||||
// "i is not defined" inside the terminal's parser. React 19 needs these
|
||||
// browsers anyway.
|
||||
target: ["es2021", "chrome100", "edge100", "firefox100", "safari15"],
|
||||
// Maps are still written (for debugging a build), but no bundle points at
|
||||
// them, so a browser never downloads them.
|
||||
sourcemap: "hidden",
|
||||
|
||||
+20
-6
@@ -89,7 +89,7 @@ A dashboard shared by several people runs in **server mode** ([SERVER-MODE.md](S
|
||||
| `POST /api/auth/login` `{username, password}` | `{user}` + the cookie; `401 EBADLOGIN`, `429 ETOOMANY`, `404 ENOTSERVER` in local mode |
|
||||
| `POST /api/auth/logout` | `{ok: true}`, cookie cleared |
|
||||
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null}` — read-only, cached 3 s, `usage` null off Linux), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
In server mode a **developer** sees only rows of the workspaces they own (`lanes.owner_id`): every list, count, chart and cost answer is limited to them, and a request naming another user's session, agent, workspace or run (in the path, query or body) answers `404 ENOTFOUND`. Admin and analyst see everything; `GET /api/users` lists every account (`displayName`, `email`, `createdAt`, `logins`, `lastLoginAt` — the last login ever, kept after it ends —, `mustChangePassword` included) with its workspaces, sessions and `today` (since the viewer's local midnight — `?tz_offset=` is the browser's `getTimezoneOffset()`, UTC without it) / 7 / 30-day `{cost, tokens}`, `GET /api/users/storage[?refresh=1]` each user's home size (`{storage: {<id>: {bytes, files, partial, measuredAt} | null}, measuring}` — measured in the background, see [SERVER-MODE.md](SERVER-MODE.md)), `GET /api/users/tmux` each member's own tmux server under one Linux user per account (`{available, installed, matched, users: [{id, username, running, stuck?, version, pid, startedAt, sessions, busy, outdated}]}`; an admin's `POST /api/users/:id/tmux-restart` and `POST /api/users/tmux-restart-idle` restart them with `currentPassword`, audited, `409 ETMUXSTUCK` for a server that will not stop), `GET /api/host` the machine and each user's live share of it (`{at, hostname, os, uptime, cpu: {model, cores, load}, memory: {total, available, used, swapTotal, swapUsed}, disks: [{device, mount, type, kind: "HDD"|"SSD"|null, total, used, free}], homesMount, gpus: [{name, utilization, memoryUsed, memoryTotal}], usage: {cpu, sampleMs, groups: [{key, kind: "user"|"host"|"system", userId?, username?, cpu, memory, memoryPercent, processes, top: [{pid, name, cpu, memory}]}]} | null, bootTime, network: [{name, rx, tx}], diskIo: [{name, read, write}], temperatures: [{chip, label, celsius}], pressure: {cpu, memory, io: {some10, some60, full10} | null} | null (Linux PSI, `/proc/pressure/*`: % of the last 10 / 60 s tasks stalled on it), topProcesses: [{pid, name, cpu, memory, group}], process: {pid, node, uptime, rss, heapUsed}}`, `cpu` also carrying `physicalCores` and `perCore` (busy % per thread), rates in bytes/s over the sample — read-only, cached 3 s, `usage` null and the lists empty off Linux), `GET /api/host/history?range=1h|6h|24h` the CPU/memory history every server on Linux keeps in memory since it started (both read by the Dashboard's System Health tab and open to every signed-in user — `/host` is not staff-only) (`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<id>: {cpu, memory}}}]}`, one point per 30 s, averaged to ≤ 360), `GET /api/users/:id/details[?tz_offset=]` one account in full for the Administration page's user detail (`{user (with logins, lastLoginAt), sessions: {total, active, firstAt, lastAt}, usage: {today, last7d, last30d, daily: [{date, cost, tokens}] (30 local days, oldest first), byTool, byModel: [{key, cost, tokens, sessions}]}, workspaces: [{id, title, cwd, tool, kind, branch, status, createdAt, sessions, lastActiveAt, livePanes}], panes: [{id, laneId, laneTitle, laneCwd, tool, model, account, proxy, sessionId, startedAt, status}], recentSessions (15, with cost/tokens), pool: {claude|codex|cursor: {pin, group, accounts: [{name, kind, email, expired, allowed, pinned, quota, quotaPercent, full, livePanes, spend5h, spend7d, userSpend5h, userSpend7d, userTokens5h, userTokens7d, userRuns7d, userLivePanes, userLastAt}]}}, audit}` — `user*` fields are only the runs on that user's workspaces; never a token; `404 ENOUSER`), and `GET /api/users/audit[?limit=]` the staff reads of developers' sessions and admins' account changes (kept `AGENTHUB_AUDIT_DAYS` days, default 180, `0` = forever) (`reader`/`owner` are usernames, `reader_display`/`owner_display` their display names or null) (all `403 EADMIN` for a developer). See [SERVER-MODE.md](SERVER-MODE.md#what-each-user-sees).
|
||||
|
||||
An admin changes accounts with `POST /api/users` (`{username, password, role}` → `201 {user}`; the password may be any non-empty starting one and the account gets `mustChangePassword: true`), `GET /api/users/:id` (`{user, workspaces: [{id, title, cwd}]}`, the account with `logins` and `lastLoginAt`, for the Edit form; admin or analyst), `GET /api/users/:id/avatar` (that account's picture for the sidebar's owner groups — admin or analyst, `nosniff` + `sandbox` CSP, `404 ENOAVATAR`; a workspace in `GET /api/lanes` carries `owner_avatar_at` to version its URL), `PATCH /api/users/:id` (any of `{displayName?, email?, role?, disabled?, password?, addWorkspaces?: [laneId], removeWorkspaces?: [laneId], signOut?: true}` — every field validated before any is written; `removeWorkspaces` leaves the workspace with no owner and answers `409 ENOTOWNED` for one the user does not own; an admin's own `password`/`signOut` keeps the login it came from; a password for SOMEONE ELSE may be anything non-empty and sets their `mustChangePassword`, the admin's OWN must pass every rule below, `400 EWEAKPASSWORD` with `rules`), `POST /api/users/:id/password` (`{password}`; that user's logins end; the same two bars) `POST /api/users/:id/workspaces` (`{laneId}`; the workspace's owner becomes that user) and `DELETE /api/users/:id` (delete for good: the user's logins, panes, workspaces' records, prefs and — under S3 — their Linux account and whole home, removed FIRST so a refusal (`502 EPROVISION`) leaves every row; `409 ESELF` for yourself, `409 ELASTADMIN` for the last admin; session history and the audit log stay). No path — this API or the `agenthub users` CLI — may demote, disable or delete the last enabled admin (`409 ELASTADMIN`). Every one carries `currentPassword`, the admin's own password: `403 EREAUTH` when it is wrong or missing, `429 ETOOMANY` after 10 wrong tries in 15 minutes. `409 ESELF` for an admin's own role or disable, `409 EEXISTS` for a taken name, `400 EBADUSER`/`EBADPASSWORD`/`EBADROLE`/`EBADEMAIL`/`EBADFIELD`, `404 ENOUSER`/`ENOLANE`; a developer or analyst gets `403 EADMIN`, and local mode `403 ELOCALMODE`. No answer carries a password hash; each change is written to the audit log.
|
||||
|
||||
@@ -117,7 +117,7 @@ An admin (server mode) adds an account with `POST /api/accounts` `{tool: "claude
|
||||
|
||||
The keys are the browser's own localStorage names: `theme`, `agenthub.theme.dark`, `agenthub.theme.light`, `i18nextLng`, `agenthub.terminal.font`, `agenthub.terminal.fontSize`, `agenthub.ui.fontSize`, `agenthub.pet`, `agenthub.pet.size`, `agenthub.pet.names`, `agent-dashboard-sagi-enabled`, `agent-dashboard-sagi-muted`, `agent-monitor-notifications`, `agenthub.defaultTool`, `agenthub.permissionMode` (`bypassPermissions` allowed — set only from Settings → AI tools → Session defaults, behind a confirm), `agenthub.codex.sandbox`, `agenthub.codex.approval`, `agenthub.codex.bypass` (`"true"`/`"false"`: a new Codex session's defaults), `agenthub.tour`, and `agenthub.chatUi` (`"1"` = Claude/Codex sessions open as a chat; read and written on the server only, by `client/src/lib/chatUi.ts`). The request never names an account, and writes are same-origin. An account's save writes only their own row, and under S3 a `theme` of `dark` or `light` also becomes the `theme` in their own `~/.claude/settings.json`, written as them.
|
||||
|
||||
`GET /api/settings/info` includes `server.version` (the running dashboard release). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
|
||||
`GET /api/settings/info` includes `server.version` (the running dashboard release). It also carries what the Dashboard's System Health score reads beyond `/api/host`: `server.event_loop` `{p50, p99, max}` (event-loop delay over the last minute, ms — the same figures as `agenthub_event_loop_delay_seconds` in `/api/metrics`) and `activity` `{last_event_at, working_agents, agents_24h: {completed, error}}` (newest event, agents working now, agents that ended in the last 24 h — scoped to the caller's rows). Pair with `agenthub version` or Settings → System to confirm client and server builds match after deploy.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -1345,7 +1345,7 @@ POST /api/lanes/:id/mcp/sync
|
||||
GET /api/lanes/:id/integrations/:name
|
||||
```
|
||||
|
||||
`profile/init` (same-origin) detects a Node.js project in the workspace's OWN folder and scaffolds `.agenthub/profile/` there — the HTTP twin of `agenthub lanes profile init`. `{ force?: true }` overwrites; answers `{ scaffolded: true, written, todos }`, or `{ scaffolded: false, reason }` (200) when no Node.js project is found; `400 EPROFILEEXISTS` without `force`. `mcp/sync` (same-origin) copies the source repo's `mcpServers` from `~/.claude.json` into `<workspace>/.mcp.json` (paths relocated) and seeds Playwright profiles → `{ servers, profilesSeeded }`; `400 ENOMCPCONFIG` when the source has none. Never automatic — a session calls it. `integrations/:name` → `{ enabled }`, read from `.agenthub/profile/integrations.env`. All three `404 ENOLANE` for an unknown workspace; under S3 the two writes refuse a member's folder the way the runtime routes do.
|
||||
`profile/init` (same-origin) detects a Node.js project in the workspace's OWN folder and scaffolds `.agenthub/profile/` there — the HTTP twin of `agenthub workspace profile init`. `{ force?: true }` overwrites; answers `{ scaffolded: true, written, todos }`, or `{ scaffolded: false, reason }` (200) when no Node.js project is found; `400 EPROFILEEXISTS` without `force`. `mcp/sync` (same-origin) copies the source repo's `mcpServers` from `~/.claude.json` into `<workspace>/.mcp.json` (paths relocated) and seeds Playwright profiles → `{ servers, profilesSeeded }`; `400 ENOMCPCONFIG` when the source has none. Never automatic — a session calls it. `integrations/:name` → `{ enabled }`, read from `.agenthub/profile/integrations.env`. All three `404 ENOLANE` for an unknown workspace; under S3 the two writes refuse a member's folder the way the runtime routes do.
|
||||
|
||||
#### CI status and base sync
|
||||
|
||||
@@ -1866,6 +1866,10 @@ classDiagram
|
||||
+string pinned_at "null unless pinned"
|
||||
+number cost
|
||||
+number agent_count
|
||||
+number owner_id "server mode: workspace owner; null if none"
|
||||
+string owner_name "server mode: owner's username"
|
||||
+string owner_display_name "server mode"
|
||||
+string owner_avatar_at "server mode"
|
||||
}
|
||||
|
||||
SessionListResponse --> Session
|
||||
@@ -2820,6 +2824,7 @@ curl "http://localhost:4820/api/sessions?sources=local,4d1f0e2a-7b9c-4c33-8a21-9
|
||||
The `/api/cc-config/*` namespace powers the Claude Config Explorer, now the **Claude Code** tab of Settings (`/settings/agents/claude-code`). All read endpoints are pure file reads under `CLAUDE_HOME` and the project's `.claude/` dir. File mutations cover the low-risk text-file artifacts (skills, subagents, slash commands, output styles, memory), `keybindings.json`, and `settings.json` layers **via a per-key merge only** — each always creates a timestamped backup before writing. Plugin/marketplace management and MCP server add/remove are delegated to the `claude plugin …` / `claude mcp …` CLI, because `~/.claude.json` and the plugin manifests are rewritten concurrently by the running Claude Code CLI.
|
||||
|
||||
```http
|
||||
GET /api/cc-config/target
|
||||
GET /api/cc-config/overview
|
||||
GET /api/cc-config/skills?scope=user|project|all&cwd=<dir>
|
||||
GET /api/cc-config/agents
|
||||
@@ -2861,6 +2866,8 @@ POST /api/cc-config/mcp/remove Body: { name, scope? }
|
||||
|
||||
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a workspace's own project commands and skills are the ones offered. `skills`, `commands` and `plugins` read their USER scope from the account the pane will run as: under S3 the `cwd` folder's account (a member's `~/.claude`, where a Hub install puts a skill), a non-admin with no `cwd` their own, an admin otherwise the host's Claude home, and a non-admin with no account of their own NOTHING (never the host's or another member's) — so a skill a developer installed shows up in their `/` list, and an admin's install into the host's home no longer shows up in a member's. In server mode a non-admin's `cwd` (on `/api/cc-config/*` and `/api/codex-config/*`) must lie inside one of their own workspaces, else `403 EFOLDER`. `GET /api/cc-config/file` and `GET /api/codex-config/file` never serve a credential file (`.credentials.json`, `auth.json`, any non-Markdown name matching the AI Tools secret pattern, anything under `<CLAUDE_HOME>/agenthub-*`): `400 READ_DENIED`.
|
||||
|
||||
**Whose config (server mode).** The router gates itself (`server/lib/cc-target.js`): a non-admin reaches only the reads listed above and the writes that act on their own config (`PUT/DELETE /file`, `PATCH /settings`, `PUT /keybindings`, `POST /project-memory`, `/plugins/install|action`, `/marketplaces/add|action`, `/mcp/add|remove`, `/reload`); `/profiles*`, `/plugins/update-all` and any unlisted endpoint answer `403 EADMIN` (an analyst may still `GET /profiles` and `GET /plugins/update-all`). `GET /target` answers `{kind: "own"|"host"|"none", readOnly, claudeHome}`. Every other handler acts on one target: the host's Claude home for an admin and in local mode (unchanged); under S3 a non-admin's OWN `<home>/.claude` + `<home>/.claude.json` — reads through the guarded reader, writes and backups as them through `write-as`, `claude plugin|mcp` as `sudo -n -u ah-<name> HOME=… DISABLE_AUTOUPDATER=1 /opt/agenthub/tools/bin/claude …` (scope `user` only, never `-y`, no MCP `env`/`headers`: `400 EBADARG`; `503 ENOTSHARED` without a shared `claude`); `403 ENOACCOUNT` for a non-admin with no account (an analyst's reads fall back to the host's, read-only, as before; `?view=host` gives an analyst with an account that view). A member's project layer exists only with `?cwd=` naming one of their own workspaces in their own account (writes: `403 EFOLDER` otherwise; no `cwd` = no project layer, `roots.projectRoot: null`). `POST /project-memory` from a member takes only such a folder; under S3 the file is written as the folder's account, an admin's create in a member's folder included. A member's `cc_config_changed` carries `ownerId` and reaches only that user; their `/reload` (and a plugin action's `reload`) types only into panes that run as them.
|
||||
|
||||
`scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`.
|
||||
|
||||
`GET /api/cc-config/plugins` returns each installed plugin's `contributes` block: the `skills` / `agents` / `commands` / `outputStyles` / `hooks` counts, plus `contributes.items` listing the first four surfaces **by name** (`[{ name, description, file }]`) so the UI can show *which* artifacts a plugin ships, not only how many. `name` is the file's frontmatter `name` when it declares one — the string Claude Code actually addresses it by — otherwise the filename. Each surface covers both the conventional directory (`skills/`, `agents/`, `commands/`, `output-styles/`) **and** any path `plugin.json` declares under that key: a plugin that lists its commands explicitly and keeps nothing in `commands/` (this repo's own `agenthub` does) previously reported zero. A declared path that resolves outside the plugin's install directory is ignored — a third-party manifest must not make the dashboard read arbitrary files. The counts are always the matching list's length. `hooks` has no `items` list: a plugin's hooks are matcher/command pairs declared inline in `plugin.json`, not named artifacts.
|
||||
@@ -2960,7 +2967,7 @@ POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 M
|
||||
POST /api/run/clipboard-files → {files:[{path,size,dir}]}: the files a file manager's Copy put on THIS machine's clipboard (Windows GetFileDropList, macOS NSPasteboard, Linux text/uri-list via wl-paste/xclip), each that exists here (`size` null for a folder) — local mode + loopback only, else 403 `EREMOTE`; a pasted file whose name and size match is attached by that path, no upload and no size limit
|
||||
POST /api/run/selected-files → {files:[{path,size,dir}]}: the same for a DROP — the items selected in THIS machine's file manager, which is what a drag from it carries (Windows Explorer windows and the desktop via Shell.Application, macOS Finder's selection — macOS asks once to let AgentHub control Finder; Linux file managers publish none, so a Linux drop uses the browser's own `file://` list or uploads). Nothing is searched for on disk
|
||||
GET /api/run/native-paste → {native, key}: whether a `claude` pane can read this machine's clipboard itself (see drop-file below), and `key` — `ctrl+v`, or `alt+v` on Windows — the chord Claude Code binds its image paste to there, which the terminal sends
|
||||
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB; ≤2 GB from a loopback browser outside server mode, streamed to disk — Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here, and an image PASTED into a `claude` pane by a loopback browser of a local-mode dashboard, which is sent as ^V (Alt+V on Windows, Claude Code's binding there) so `claude` reads the desktop clipboard itself (Settings → Appearance → **Paste images natively**, on by default, per browser — off for an SSH tunnel) and only while `GET /api/run/native-paste` answers `{native:true}`: local mode, a loopback request, and on Linux a reachable display plus `wl-paste` (Wayland) or `xclip` (X11) — without one the `^V` pasted nothing, so the image is uploaded instead. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body, and close the connection; of several files only the refused ones are lost
|
||||
POST /api/run/drop-file?name=&laneId= Any raw file body, streamed to disk as it arrives (S3: through `write-as` as the member) — ≤500 MB, like a Files upload; from a loopback browser outside server mode no cap, only the data dir's free space less a 1 GB reserve (Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here, and an image PASTED into a `claude` pane by a loopback browser of a local-mode dashboard, which is sent as ^V (Alt+V on Windows, Claude Code's binding there) so `claude` reads the desktop clipboard itself (Settings → Appearance → **Paste images natively**, on by default, per browser — off for an SSH tunnel) and only while `GET /api/run/native-paste` answers `{native:true}`: local mode, a loopback request, and on Linux a reachable display plus `wl-paste` (Wayland) or `xclip` (X11) — without one the `^V` pasted nothing, so the image is uploaded instead. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body (a loopback drop: when it would not fit on the disk), and close the connection; of several files only the refused ones are lost
|
||||
POST /api/run/local-paths {paths:[<absolute>…]} (1–100) → {sizes:[bytes|null…]}: each path's size when it is a regular file on this machine, else null. The composer and the terminal use a dropped file's own `file://` path only when every size matches the dropped bytes' — "localhost" in the address bar is no proof (an SSH tunnel's browser is on another machine). 403 EREMOTE in server mode (a pane runs as an account that cannot read the host's files) and to anything but a loopback socket without X-Forwarded-For/Forwarded; 400 EBADFIELD otherwise
|
||||
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise
|
||||
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
|
||||
@@ -3030,6 +3037,8 @@ The login profiles of the AI tools other than Claude Code (`server/lib/tool-prof
|
||||
```
|
||||
GET /api/ai-proxies Every saved profile, the built-in `native` one first
|
||||
GET /api/ai-proxies/9router `{ installed }` — is a `9router` binary on the server's PATH (nothing is run)
|
||||
GET /api/ai-proxies/9router/update versions, running state, `blocker`, current job (`?fresh=1` re-asks npm)
|
||||
POST /api/ai-proxies/9router/update shut down → npm update → start hidden; 202 + job (admin)
|
||||
POST /api/ai-proxies Create — Body: { label, baseUrl, apiKey?, model, models?, behavesAs?, scope? }
|
||||
POST /api/ai-proxies/test Probe an endpoint — Body: { baseUrl, model, apiKey?, name? }
|
||||
PATCH /api/ai-proxies/:name Update the same fields, all optional
|
||||
@@ -3052,6 +3061,11 @@ A **proxy profile** is an endpoint that stands in front of Claude Code or Codex
|
||||
|
||||
**`GET /api/ai-proxies/9router`** answers `{ installed: boolean }` from a PATH lookup only (the server's and the login shell's PATH); the AI Proxy page shows a **Config 9router** link beside **Add profile**, opening 9router's own UI in a new tab on the dashboard's own host, port 20128 (`http://<the host the page was opened on>:20128` — e.g. `http://10.1.13.7:20128` from a LAN browser, so the viewer needs no 9router of their own; 9router must listen beyond loopback for that), only when it is `true`.
|
||||
|
||||
**Update 9router** (`server/lib/nine-router-update.js`; the AI Proxy page's button beside Config 9router, an admin's only — `/ai-proxies/9router` and `/ai-routers/9router` are in `ADMIN_WRITE_PREFIXES`). `GET /api/ai-proxies/9router/update` answers `{ installed, version, latest, updateAvailable, running, managedBy: "systemd"|"process"|null, blocker: {code, message, command?}|null, job }`. `POST` starts one job (202; `EBUSY` while one runs, else 409 with the blocker's code — checked BEFORE anything is stopped) of three steps, each `pending|running|done|failed|skipped`:
|
||||
1. **shutdown** — a running `9router.service` user unit is `systemctl --user stop`ped; otherwise this account's processes whose argv names the install's `cli.js` get SIGTERM, then they and their descendants SIGKILL after 5 s; the step waits until the port is free. Skipped when nothing ran.
|
||||
2. **update** — `npm install -g --prefix <the install's own prefix> --prefer-online 9router@latest`; a prefix this account cannot write runs it under `sudo -n` with `--cache /var/cache/agenthub-9router-npm` (root's HOME is inaccessible inside the AgentHub unit; the sudoers rule must name that exact argv), only when `sudo -n <npm> --version` succeeds (else `ENOPERM` with `command: "sudo npm install -g 9router@latest"`).
|
||||
3. **relaunch** — only when 9router was running before: `systemctl --user start 9router.service`, or `node cli.js --tray --no-browser --skip-update -p <old port> [-H <old host>]` detached in its own `systemd-run --user --scope` (plain detached spawn without one); done once the port answers. Runs even after a failed update.
|
||||
|
||||
**`POST /api/ai-proxies/test`** answers the question saving cannot: does this endpoint, with this key, actually serve this model — for each CLI? It sends, in parallel and with a 15 s timeout each, one Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages` (what Claude Code calls) and one OpenAI Responses call (`max_output_tokens: 16`) to `<baseUrl>/v1/responses` (what Codex calls). A proxy that answers is a `200` here whatever it replies: `{ claude: { ok, status, message }, codex: { ok, status, message } }`, each carrying the proxy's own words (truncated to 300 characters), `status` `null` when nothing answered at all. A proxy that serves only one wire shape shows one green line and one red. Only bad input is a 4xx.
|
||||
|
||||
The stored key is only ever sent to the endpoint it was stored against. A blank `apiKey` falls back to the key of the profile named by `name`, but **only** while the submitted `baseUrl` still equals the saved one (both compared without `/v1`) — so testing a retyped endpoint needs a retyped key (`400 EBADKEY`). Without that rule this route would be a way to make the server POST a saved API key to any URL a browser names.
|
||||
@@ -3450,11 +3464,11 @@ Sent when a notification is created.
|
||||
|
||||
#### /ws-pty/:runId — PTY frames
|
||||
|
||||
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`, else 80×24): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. On Windows every attach is also sent `{"type":"wheel"}`: psmux never turns mouse tracking on for its client, so the browser sends the SGR wheel report (`ESC[<64;1;1M` up, `ESC[<65;1;1M` down) itself while xterm reports no tracking — psmux scrolls, or forwards it to a pane app that tracks the mouse (Claude Code fullscreen). Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
|
||||
A dedicated binary WebSocket stream (not JSON-framed) for tmux-backed PTY transport. Established by the Workspace TerminalView component on workspace load; endpoint is `/ws-pty/:runId` where `runId` comes from `POST /api/run`. Frames are raw PTY output (stdin echoes, command output, prompt updates, terminal control sequences) as binary blobs; the client feeds each frame to xterm.js for live rendering. The same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub workspace shell`, other tools), all receiving the same frames live-synced. **The attach URL carries the client's size** (`/ws-pty/:runId?cols=<n>&rows=<n>`, integers in `[2, 1000]`). A terminal with no size to give — one on a hidden tab, whose box is 0×0 — names none and reports none until it is shown, and its attach is spawned at the window's CURRENT size (`tmux.windowSize`, 80×24 only when tmux cannot answer), so a background tab never shrinks the pane (it used to fit to xterm's 12×6 minimum and resize tmux to that): tmux resizes the window to whichever client attached most recently, so spawning the attach at a default size and correcting it from the first `resize` message a beat later made the pane's program redraw its whole UI at 80 columns — and everything painted at that width stayed in the scrollback, surfacing as torn left-margin fragments the moment the user scrolled back (issue #33). Resize events after that: the client sends a `{"type":"resize","cols","rows"}` text frame and the server applies it as a `TIOCSWINSZ` ioctl down the pane's pty, so window-sensitive commands (e.g. pagers, text editors) adapt to the viewport size. The client reports its size from **xterm's own `onResize`**, not from the container's `ResizeObserver` — cols also move when the webfont finishes loading or the page zooms, neither of which resizes the observed element — and takes the lead whenever the terminal regains focus. **Several browsers on one pane share ONE size** (`server/lib/pty-attach.js`): tmux's `window-size latest` snapped the pane to whoever typed last, so two viewers on screens of different sizes kept resizing it under each other and one saw it cropped. The server names a DRIVER per pane — the first attach, then whichever sends `{"type":"resize","cols","rows","drive":true}` (the client does on focus and on a keystroke, never on a mouse report). Every other attach is a follower: its PTY is held at the driver's size, a follower's own `resize` without `drive` is remembered but not applied, and the server tells it `{"type":"size","cols","rows"}` — the client resizes xterm to that grid and scales it down with CSS to fit, so the browser in use decides the size and the others just shrink. A follower attaches at the driver's size, so joining never resizes the pane. When the driver's socket closes the rest get `{"type":"lead"}` and size themselves again. On Windows every attach is also sent `{"type":"wheel"}`: psmux never turns mouse tracking on for its client, so the browser sends the SGR wheel report (`ESC[<64;1;1M` up, `ESC[<65;1;1M` down) itself while xterm reports no tracking — psmux scrolls, or forwards it to a pane app that tracks the mouse (Claude Code fullscreen). Driver tracking is per server process: a real terminal (`agenthub workspace shell`) or a second dashboard on the same tmux still follows tmux's own `latest` rule. Keeping the two widths equal is not cosmetic: a tmux pane **wider** than xterm makes every over-long row autowrap its tail onto the next row's left margin, and tmux never repaints a row it believes unchanged — so stray characters stay glued to the left edge of blank lines, visible as soon as a scroll repaints history. A zero size (the console collapses with CSS, leaving the container 0×0) is never reported. The WebSocket connection inherits the same loopback same-origin guard and optional token auth as other `/api/*` routes. The client re-attaches automatically 1.5 s after an unexpected close (server restart, network blip) — tmux keeps the session alive, so the re-attach repaints the pane in full. It stops retrying only after the server's one-shot `{"type":"exit"}` frame, which means the pane process itself ended — the Workspace takes that frame as the signal to restore the run setup form, since there is nothing left to attach to. Without that retry a dropped socket left the pane frozen on its last painted frame: it still looked live, but no keystroke and no mouse report (wheel scroll included) reached tmux again.
|
||||
|
||||
#### cc_config_changed
|
||||
|
||||
Broadcast whenever Claude Code configuration changes — either by dashboard mutations on `PUT/DELETE /api/cc-config/file` (`source: "dashboard"`) or by `lib/cc-watcher.js` picking up external `fs.watch` events on `~/.claude/` and `~/.claude.json` (`source: "fs"`, debounced at 500 ms). The Config Explorer page subscribes and refetches automatically.
|
||||
Broadcast whenever Claude Code configuration changes — either by dashboard mutations on `PUT/DELETE /api/cc-config/file` (`source: "dashboard"`) or by `lib/cc-watcher.js` picking up external `fs.watch` events on `~/.claude/` and `~/.claude.json` (`source: "fs"`, debounced at 500 ms). The Config Explorer page subscribes and refetches automatically. In server mode a change to the host's config reaches a developer as the bare type (`data: {}`); a change to a member's OWN config carries `ownerId` and reaches that user alone, admins and analysts included (`scope.exactAudience`).
|
||||
|
||||
```json
|
||||
{ "type": "cc_config_changed", "data": { "source": "dashboard", "action": "write", "scope": "user", "type": "skill", "name": "my-skill" } }
|
||||
|
||||
+2
-2
@@ -239,7 +239,7 @@ Manage the remote (SSH) machines this dashboard pulls Claude Code history from
|
||||
|
||||
A workspace is a durable unit of parallel agent work — one working directory, many sessions over time. Full guide: [`docs/LANES.md`](LANES.md).
|
||||
|
||||
`agenthub workspace …` (or `agenthub workspaces` for the list) is the name to use. `agenthub lanes …` and `--lane <id>` are the old names and keep working — the same commands as `agenthub workspace …` and `--workspace <id>` — so scripts and skills written before the rename need no change.
|
||||
`agenthub workspace …` (or `agenthub workspaces` for the list) is the name to use. The old names `agenthub lanes …` and `--lane <id>` were removed (step 4 of the rename): they exit 1 with `renamed: use agenthub workspace …` / `renamed: use --workspace <id>`. Your workspaces, sessions and history are unchanged, and the API still answers under both `/api/workspaces` and `/api/lanes`.
|
||||
|
||||
| Command | Description |
|
||||
| ------- | ----------- |
|
||||
@@ -271,7 +271,7 @@ A workspace is a durable unit of parallel agent work — one working directory,
|
||||
|
||||
Omit `<id>` and the command addresses the workspace owning the current directory, so a session running inside a workspace never needs to know its own id. Only a leading all-digits argument is read as an id — `agenthub workspace logs web --tail 4096` addresses the workspace by directory, not workspace 4096.
|
||||
|
||||
Resolution order, shared by every cwd-resolving command: `--workspace` (old name `--lane`)/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool <id>`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the workspace owning the directory. One directory can back a `claude` workspace and a `codex` workspace at once, so when two candidates remain the command fails and names both instead of picking one — see [Which workspace a CLI command addresses](LANES.md#which-workspace-a-cli-command-addresses).
|
||||
Resolution order, shared by every cwd-resolving command: `--workspace`/leading id → `AGENTHUB_LANE_ID` (exported into every tmux pane the dashboard starts) → `--tool <id>`, else the harness's marker (`CLAUDECODE` means claude; Codex has no equivalent marker, so a codex session outside a dashboard pane passes `--tool codex`) → the workspace owning the directory. One directory can back a `claude` workspace and a `codex` workspace at once, so when two candidates remain the command fails and names both instead of picking one — see [Which workspace a CLI command addresses](LANES.md#which-workspace-a-cli-command-addresses).
|
||||
|
||||
Because a workspace's services are fully detached, restarting the dashboard (or applying an update — `agenthub update-check` reports one, the dashboard's Update button applies it) never stops a running stack.
|
||||
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -374,7 +374,7 @@ both full suites.
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `server/` | Express API, SQLite schema, hook ingest, workspace and worktree libraries |
|
||||
| `client/` | React 18 + Vite + Tailwind dashboard |
|
||||
| `client/` | React 19 + Vite + Tailwind dashboard |
|
||||
| `bin/agenthub.js` | CLI |
|
||||
| `mcp/`, `statusline/` | Read-only MCP server and Claude Code statusline (opt-in) |
|
||||
| `monitoring/` | Prometheus config/rules and Grafana boards for `/api/metrics` |
|
||||
|
||||
+1
-1
@@ -293,7 +293,7 @@ chạy cả hai bộ đầy đủ.
|
||||
| Đường dẫn | Là gì |
|
||||
|---|---|
|
||||
| `server/` | Express API, schema SQLite, hook ingest, thư viện workspace và worktree |
|
||||
| `client/` | Dashboard React 18 + Vite + Tailwind |
|
||||
| `client/` | Dashboard React 19 + Vite + Tailwind |
|
||||
| `bin/agenthub.js` | CLI |
|
||||
| `docs/` | Architecture, API, workspaces, database |
|
||||
| `plugins/` | Các Claude Code plugin đi kèm dashboard |
|
||||
|
||||
+2
-2
@@ -194,8 +194,8 @@ graph TB
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| **Frontend** | React 18, TypeScript 5.7, Vite 6, Tailwind CSS |
|
||||
| **Backend** | Node.js 20+, Express 4.21, WebSocket |
|
||||
| **Frontend** | React 19, TypeScript 7, Vite 6, Tailwind CSS |
|
||||
| **Backend** | Node.js 22.5+, Express 5, WebSocket |
|
||||
| **Database** | SQLite 3 (better-sqlite3 or node:sqlite) |
|
||||
| **Integration** | Claude Code Hooks, MCP Server |
|
||||
|
||||
|
||||
+92
-15
@@ -65,7 +65,7 @@ The proxy has to:
|
||||
- forward WebSocket upgrades (`/ws` for live updates, `/ws-pty/*` for terminals);
|
||||
- set `X-Forwarded-For` and `X-Forwarded-Proto`;
|
||||
- accept request bodies as large as AgentHub's own limits (Files uploads up to
|
||||
500 MB, prompt drops 20 MB). nginx's default `client_max_body_size` is 1 MB,
|
||||
500 MB, files dropped on a terminal or a prompt box 500 MB, dropped images 20 MB). nginx's default `client_max_body_size` is 1 MB,
|
||||
and its own 413 is an HTML page the UI can only report as a failed upload.
|
||||
|
||||
nginx:
|
||||
@@ -106,11 +106,13 @@ The login cookie gets `Secure` when the request came over HTTPS
|
||||
start (`server/db.js`), and `member` is no longer accepted.
|
||||
|
||||
"The host's configuration" means a write (anything but GET/HEAD) under
|
||||
`/api/settings`, `/updates`, `/cc-config`, `/codex-config`,
|
||||
`/api/settings`, `/updates`, `/codex-config`,
|
||||
`/git-credentials`, `/ai-tools`, `/import`, `/pricing`, `/budgets`,
|
||||
`/webhooks`, `/remote-sources`, `/reports`, `/pets`, `/users` and
|
||||
`/alerts/rules` (`ADMIN_WRITE_PREFIXES` in `server/lib/auth.js`), plus a write
|
||||
to a shared AI Proxy (see [AI Proxy profiles](#ai-proxy-profiles)). A
|
||||
to a shared AI Proxy (see [AI Proxy profiles](#ai-proxy-profiles)) and the
|
||||
host-wide parts of `/cc-config` (see
|
||||
[Your own Claude Code config](#your-own-claude-code-config)). A
|
||||
developer or analyst who tries one gets `403 EADMIN`, shown in the UI as "Only
|
||||
an admin can do this". An analyst's other writes are scoped like a
|
||||
developer's: one naming another user's row gets `404 ENOTFOUND` (`memberId` in
|
||||
@@ -120,7 +122,8 @@ exactly like a developer's.
|
||||
|
||||
The UI hides what a role cannot use (`usePermissions` in
|
||||
`client/src/components/AuthGate.tsx`): for anyone but an admin, Settings shows
|
||||
no Data, Pricing or per-tool pages, no scheduled reports or git credentials,
|
||||
no Data, Pricing or per-tool pages (except their own Claude Code config,
|
||||
for a user with their own Linux account), no scheduled reports or git credentials,
|
||||
no start-at-login/icon/Quit/Uninstall on System, no alert rules or channels,
|
||||
no install/update/enable on AI tools, no pet upload or delete, and no update
|
||||
check; the AI proxy page shows them only their personal profiles' Edit. An
|
||||
@@ -163,20 +166,48 @@ Workspaces made before server mode, or by the host account, have none.
|
||||
sessions, and the tokens and $ it used today (the browser's local day) and in the last 7 and 30 days
|
||||
(booked like the cost routes' daily split, so Codex/omp/Antigravity and sub-agent usage count) — the
|
||||
**Administration** page in the sidebar (`/admin`; the old `/users` link
|
||||
redirects there) shows it on its **Users** tab, with the audit log under it.
|
||||
- `GET /api/host` (admin/analyst, `/host` is staff-only) is Administration's
|
||||
**Server** tab: the machine's hostname, OS, uptime, CPU model, cores and
|
||||
redirects there) shows it — one page, no tabs: the users table, the account
|
||||
pool, then the audit log. The machine itself is on the Dashboard's
|
||||
**System Health** tab (below).
|
||||
The table's spend column is today's (the browser's local day), with 7 and 30
|
||||
days under it. Clicking a user expands a short detail right under their row
|
||||
(`/admin?user=<id>`, `GET /api/users/:id/details`): last sign-in, last
|
||||
activity and what runs now, then spend over 30 days by AI tool and top
|
||||
models, the account pool per tool (pin or group, each account's quota left
|
||||
beside their own 7-day share of its spend), their live panes and
|
||||
workspaces, and their five newest sessions.
|
||||
- `GET /api/host` (every signed-in user — `/host` is deliberately NOT
|
||||
staff-only, so a developer also sees other users' usernames, CPU / RAM share
|
||||
and busiest process names) is the machine on the Dashboard's **System
|
||||
Health** tab — its **Machine** group, between **AgentHub** (score, storage,
|
||||
concurrency) and **Agent activity** (tokens by model, tools, subagents): the machine's hostname, OS, uptime, CPU model, cores and
|
||||
load, memory and swap, every real disk (HDD or SSD, how full; the one
|
||||
holding the users' homes is marked), any NVIDIA GPU, and each user's live
|
||||
share of it — CPU % of the whole machine (100 % = every core busy) and
|
||||
holding the users' homes is marked), any NVIDIA GPU. Each user's live
|
||||
share of it is a **Users · CPU / RAM now** card on the same tab (a tile per
|
||||
user, their busiest processes on click; their home share stays in
|
||||
Administration's users table), and the processes of no user are listed apart — CPU % of the whole machine (100 % = every core busy) and
|
||||
resident memory over a 1 s `/proc` sample, a process count, the busiest
|
||||
processes by their kernel name (never argv or env), and their home's size
|
||||
as a share of the homes disk. A process counts for a user when it runs as
|
||||
their Linux account (S3) or, for the host account, when it inherited a
|
||||
workspace's `AGENTHUB_LANE_ID` — so without S3 a user's share is only what
|
||||
runs in their workspaces' panes; the rest is **Host account** or
|
||||
**System**. Read-only (`server/lib/host-stats.js`); Linux only for the
|
||||
per-user part. The page re-reads every 5 s while shown.
|
||||
**System**. The same 1 s sample also gives every CPU thread's busy %,
|
||||
network download/upload per interface (no `lo`), read/write per whole
|
||||
disk, and the ten busiest processes machine-wide with who they count for;
|
||||
plus `/sys/class/hwmon` temperatures, physical cores, boot time and the
|
||||
AgentHub process's own pid, Node version, uptime and RSS — laid out in the
|
||||
tab's own card style (`client/src/components/health/HostHealth.tsx`): the
|
||||
machine card (with the V8 heap from `/api/settings/info`), then CPU threads,
|
||||
temperatures / GPUs, disks with their I/O and network, then history and the
|
||||
busiest processes. Read-only (`server/lib/host-stats.js`); Linux only for
|
||||
the per-user part. The tab re-reads it every 5 s while shown.
|
||||
- `GET /api/host/history?range=1h|6h|24h` (every signed-in user) is the tab's
|
||||
**History** charts: machine CPU % and memory % (plus swap, load and each
|
||||
user's CPU/memory in the payload), one point every 30 s for 24 h. Every
|
||||
server on Linux (local or server mode) keeps it IN MEMORY from the moment it starts
|
||||
(`startHistory`, server/index.js) — a restart starts it empty, and nothing
|
||||
is written to disk.
|
||||
- `GET /api/users/storage` (admin/analyst) is the page's **Storage** column:
|
||||
how much disk each user's home takes (their Linux home under S3, their
|
||||
folder under `AGENTHUB_USERS_ROOT` otherwise; an admin with none reads
|
||||
@@ -774,6 +805,52 @@ folder under it), and the dark/light mode picked in AgentHub becomes Claude's
|
||||
theme. Accepting Bypass Permissions mode stays the member's own choice, made
|
||||
in their terminal.
|
||||
|
||||
### Your own Claude Code config
|
||||
|
||||
With S3, a developer or analyst who has their own Linux account manages THEIR
|
||||
OWN Claude Code configuration in Settings → AI tools → **Your Claude Code
|
||||
config** — skills, subagents, commands, output styles, `CLAUDE.md` and
|
||||
auto-memory, plugins, marketplaces, MCP servers, `settings.json` (hooks
|
||||
included) and `keybindings.json` — the same Config Explorer an admin uses for
|
||||
the host's, pointed at `<USERS_ROOT>/<name>/.claude` and
|
||||
`<USERS_ROOT>/<name>/.claude.json`. How it runs:
|
||||
|
||||
- `/api/cc-config` gates itself (`server/lib/cc-target.js`): a non-admin
|
||||
reaches only an allowlist of reads and of the writes that run as them.
|
||||
`/profiles*` (the host's login switching), `/plugins/update-all` (the host's
|
||||
Update all, AgentHub's own plugin included) and any endpoint not on the list
|
||||
stay an admin's (`403 EADMIN`; an analyst may still READ the profiles and the
|
||||
Update-all journal, as before). Every handler then resolves ONE target: the
|
||||
host's Claude home for an admin (and in local mode) exactly as before, the
|
||||
member's own home otherwise, and `403 ENOACCOUNT` for a non-admin with no
|
||||
enabled, provisioned account — never the host's. An analyst with no account
|
||||
keeps the read-only view of the host's config they had; with one, `?view=host`
|
||||
gives them that view, and the file reader serves either.
|
||||
- Every file write is the member's own: `write-as` (content on stdin), the
|
||||
timestamped backup a `write-as copy` inside their home; their files are read
|
||||
only through the guarded reader (a regular file they own, size capped), and
|
||||
a directory, plugin `installPath` or marketplace `installLocation` that leads
|
||||
out of their home is not followed.
|
||||
- `claude plugin …`, `claude plugin marketplace …` and `claude mcp …` run as
|
||||
them: `sudo -n -u ah-<name> HOME=<home> DISABLE_AUTOUPDATER=1
|
||||
/opt/agenthub/tools/bin/claude …` in their home, with a timeout and the same
|
||||
argument checks. User scope only, never `-y` (a plugin whose marketplace
|
||||
declares an install command fails; install it from your own terminal), and
|
||||
no MCP `env` values or `headers` (sudo logs its argv: add such a server with
|
||||
`claude mcp add` in your own terminal). `503 ENOTSHARED` until `claude` is
|
||||
in the shared tools.
|
||||
- A project layer only in one of their own workspaces, named with `?cwd=`
|
||||
(`403 EFOLDER` otherwise); with none named there is no project layer at all,
|
||||
never the dashboard's own folder. An admin's "new CLAUDE.md in a project"
|
||||
in a member's folder is written as that member too.
|
||||
- A change is broadcast (`cc_config_changed`) to that member alone, and their
|
||||
**Reload** (`POST /api/cc-config/reload`, or a plugin update with reload)
|
||||
types `/reload-plugins` / `/reload-skills` only into the panes that run as
|
||||
them. Their own config is theirs, so these writes are not audited (like
|
||||
their own File Explorer writes).
|
||||
|
||||
Not verified on a live S3 machine yet.
|
||||
|
||||
### Plugin baseline (opt-in)
|
||||
|
||||
Write `/etc/agenthub/plugin-baseline.json` (as
|
||||
@@ -867,12 +944,12 @@ a pane really is. Each case below is refused or bounded, with a test in
|
||||
an older server. Until `agenthub server install-system` has installed
|
||||
it, calls use `TMUX_BIN` directly, as before. A member's server keeps its
|
||||
version until their last pane closes; the next start runs `TMUX_BIN`.
|
||||
- **Administration → Server → Members' tmux** (admin/analyst read, admin acts): each member's
|
||||
- **Administration → users table, tmux column** (admin/analyst read, admin acts): each member's
|
||||
running server with its version beside the installed one (an *older than
|
||||
installed* badge), its sessions and whether an agent of theirs is working.
|
||||
**Restart** ends one member's server — every pane of theirs closes, their
|
||||
The row's **Restart** button ends one member's server — every pane of theirs closes, their
|
||||
Claude sessions resume from the workspace — and **Restart N idle members**
|
||||
does it for every outdated member with no agent working; both take the
|
||||
(above the table) does it for every outdated member with no agent working; both take the
|
||||
admin's password again and are audited per member. A server that will not
|
||||
stop answers `409 ETMUXSTUCK` naming its pid: root has to `kill -9` it.
|
||||
- **A member's huge or sparse files.** Transcripts and journals are read line
|
||||
@@ -950,7 +1027,7 @@ agenthub server install-system
|
||||
|
||||
A member server that a newer tmux already reached before `tmux-member` was
|
||||
installed can stay wedged: every call on that socket — `tmux-member`'s too —
|
||||
prints `server exited unexpectedly`. If Administration → Server → Members' tmux shows it with
|
||||
prints `server exited unexpectedly`. If the users table's tmux column on Administration shows it with
|
||||
no panes, stop that one server (its pid is the `ah-<name>` process running
|
||||
`tmux -S <USERS_ROOT>/<name>/.agenthub/tmux/sock`) with `sudo kill -9 <pid>`;
|
||||
the next start runs a fresh server on `TMUX_BIN`. Never `tmux kill-server`.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- [NEVER tmux kill-server](never-tmux-kill-server.md) — $TMUX beats TMUX_TMPDIR; it killed the real server + this session twice
|
||||
- [Analytics parity plan](analytics-parity-plan.md) — phases 2–7 shipped in v2.4.0; ../agenthub-analytics now redundant; OpenAPI for new routes still missing
|
||||
- [Dev/prod ports share one DB](agenthub-dev-prod-ports.md) — 4820 prod (systemd), 4830 user's dev in Warp; same dashboard.db, dev migrations hit prod
|
||||
- [Workspace rename step 4](workspace-rename-step4.md) — remove `agenthub lanes`/`--lane` only ≥3 releases after aliases ship; never rename stored data
|
||||
- [Workspace rename step 4](workspace-rename-step4.md) — DONE 10-26: old CLI names refused, clients call /api/workspaces, /api/lanes kept; release needs EN+VI note
|
||||
- [No git stash here](no-git-stash-shared-checkout.md) — parallel sessions share this checkout; use a worktree to test clean HEAD
|
||||
- [Update never kills panes](update-never-kills-panes.md) — updates/restarts keep tmux lane panes; only Quit AgentHub (#65) closes them; KillMode=process
|
||||
- [Claude Code changelog ≤2.1.295](claude-code-changelog-2-1-293.md) — 2.1.285–295 (to 10-08): hooks onFailure:block (keep AgentHub fail-open), OSC 7501 tested (no use), Workflow scriptPath rules
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
---
|
||||
name: workspace-rename-step4
|
||||
description: "Removing the old `agenthub lanes`/`--lane` CLI names is deferred to ≥3 releases after the aliases ship; never touch stored data"
|
||||
description: "Rename step 4 DONE 2026-10-26: `agenthub lanes`/`--lane` now exit 1 'renamed'; client/CLI/MCP call /api/workspaces; /api/lanes kept; never touch stored data"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 47dc0201-8e49-4fce-9407-f087d3107588
|
||||
modified: 2026-10-06T04:07:56.796Z
|
||||
modified: 2026-10-26T00:00:00.000Z
|
||||
---
|
||||
|
||||
On 2026-10-06 the user had steps 1–3 of the lane→workspace rename built (aliases `/api/workspaces`, `agenthub workspace`, `--workspace`; help/docs on the new name; a stderr deprecation note for `agenthub lanes`/`--lane`). Step 4 (removing the old CLI names, moving client+CLI requests to `/api/workspaces`) is deferred until the THIRD release after the first one that ships the aliases (the first release after v2.0.1). The plan, checklist and "never touch" list are in `docs/plans/workspace-rename/README.md`.
|
||||
Step 4 of the lane→workspace rename was built on 2026-10-26 (after v2.8.25): `agenthub lanes …` and `--lane` exit 1 with `renamed: use …`; client `api.ts`, `bin/agenthub.js` and `mcp/server.js` request `/api/workspaces`; the `/api/lanes` mount is KEPT (4c default). Plan: `docs/plans/workspace-rename/README.md`.
|
||||
|
||||
**Why:** the user wants existing installs to update gradually and NOT lose any data — DB tables/columns, response keys, tmux names `agenthub-lane-<id>`, env vars, on-disk paths and browser storage keys must never be renamed.
|
||||
**Why:** existing installs update gradually and must not lose data — DB tables/columns, response keys, tmux names `agenthub-lane-<id>`, env vars, on-disk paths and browser storage keys are never renamed.
|
||||
|
||||
**How to apply:** when cutting a release, check whether it is ≥ R+3 (R is recorded in that plan file — fill it in when R is cut) and remind the user that step 4 is due; keep `/api/lanes` unless the user decides otherwise (step 4c, default keep).
|
||||
|
||||
R = v2.1.0 (released 2026-10-06), so step 4 is allowed from the 3rd release after v2.1.0.
|
||||
**How to apply:** the release that ships this needs the EN+VI note from the plan's release checklist ("`agenthub lanes` and `--lane` are removed — use `agenthub workspace` and `--workspace`…"). Do not remove `/api/lanes` unless the user asks (then 410 ERENAMED for one release first).
|
||||
|
||||
@@ -32,5 +32,5 @@ Decisions (user, 2026-10-08):
|
||||
- [x] analyst alert feed: `can_ack` per alert, Ack hidden on others' (server test + client test)
|
||||
- [x] Settings search: admin-only entries (autostart, quit, tray, reports, webhooks, git creds) hidden
|
||||
- [x] SessionDetail: an unstamped session's owner resolved by folder (`owningLane`)
|
||||
- [ ] prod/dev share one DB: restart prod on the new build together with the first run (migration)
|
||||
- [ ] `agenthub stop` in server mode answers EAUTH (fix was blocked; user to decide)
|
||||
- [x] prod/dev share one DB: restart prod on the new build together with the first run (migration) — done with the v2.5.x rollout
|
||||
- [x] `agenthub stop` in server mode answers EAUTH — resolved: `cmdStop` (`bin/agenthub.js`) turns EAUTH/EADMIN into a clear message naming the admin's Quit and the `systemctl` stop commands; a host shell carries no login, so that is the intended answer
|
||||
|
||||
@@ -21,7 +21,7 @@ Decided: own Linux user per account; skills/plugins = admin baseline + user's ow
|
||||
- [x] Per-user AI Proxy store (+ admin shared) — S3-PLAN P1/P1b
|
||||
- [x] S3 provisioning + spawn/attach as the account — S3-PLAN P2/P3
|
||||
- [x] S4 shared tools in /opt — S3-PLAN P5
|
||||
- [ ] Per-user skills/plugins (cc-config per account HOME, admin baseline) — seed + opt-in baseline built (P6); managed-settings spike not run, member Settings not built
|
||||
- [ ] Per-user skills/plugins (cc-config per account HOME, admin baseline) — seed + opt-in baseline built (P6); managed-settings spike run 2026-10-09 (works from the first interactive session, S3-PLAN P6); member Settings built 2026-10-26 ("Your Claude Code config", writes as the member) — open until verified live on the S3 box
|
||||
- [x] Codex account pool — S3-PLAN P7 (fake accounts only; no real Codex login tried)
|
||||
- [x] Security review, docs, suites — P8 F1–F11, second review F12–F20 (2026-10-09), docs/SERVER-MODE.md runbook
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ Branch: `server-s3`. Commits happen only when the user asks.
|
||||
- Not checked: skills actually listed inside a logged-in session (no login on the test user).
|
||||
- [x] Fallback (built, opt-in): with `/etc/agenthub/plugin-baseline.json` (`{"marketplaces": ["owner/repo", …], "plugins": ["name@marketplace", …]}`, root's, the admin edits it), creating a user runs `claude plugin marketplace add <source>` for each marketplace, then `claude plugin install --scope user <plugin>` for each plugin, AS the new member (`sudo -n -u ah-<name> HOME=… /opt/agenthub/tools/bin/claude …`, in the background; `agenthub users provision` runs it too, in the foreground, and is the retry). No file = nothing runs. An entry starting with `-` is skipped; `-y` is never passed. Sudoers: `/opt/agenthub/tools/bin/claude` joins the run-as-member list (no new power: the host already runs any command as a member through tmux). `server/lib/member-claude.js`, test `server-s3-provision.test.js` (fake sudo). Not verified live.
|
||||
- [ ] A member's own plugins work from their terminal (`/plugin`) as soon as P3 lands, with no code.
|
||||
- [ ] Later, optional: Settings for members, using `cc-config` with `home` = `req.user`'s and writes via `sudo -u`.
|
||||
- [x] Settings for members, using `cc-config` with `home` = `req.user`'s and writes via `sudo -u`. Done: one target resolver + the router's own allowlist gate (`server/lib/cc-target.js`; `/cc-config` left `STAFF_ONLY_PREFIXES`/`ADMIN_WRITE_PREFIXES`), the readers/writers take the member's home (`cc-discovery`/`cc-mutate` `home`, `noProject`, `ioFor` → write-as; backups via `write-as copy`), `claude plugin|mcp` as the member's sudo argv (user scope, no `-y`, no env/headers), project writes only in their own workspaces, an admin's project-memory create in a member's folder written as the member, `cc_config_changed` to the owner only (`scope.exactAudience`), `/reload` into their own panes; UI "Your Claude Code config" (`useCcTarget`). Test `server-s3-cc-config.test.js` (fake sudo, fake `claude` spawn). Not verified live.
|
||||
|
||||
#### P6 spike: how to run it (a human, on the S3 box)
|
||||
`scripts/server/spike-managed-plugins.sh` (never run by AgentHub). It changes `/etc/claude-code/managed-settings.json`, which EVERY Claude Code on the machine reads, the host's own sessions included, so run it in a quiet moment and undo right after.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Workspace redesign — overview and phase plan
|
||||
|
||||
_Status: specs written 2026-10-01, nothing implemented yet. Each phase is one PR, executed in order; a later phase assumes the earlier ones are merged._
|
||||
_Status: specs written 2026-10-01; all four phases are built (sidebar tree + status bar + workbench `ba2c4e1`, grouped Settings with search `eb90244`, Orca themes + notification sounds `b71024f`, the lane → workspace copy rename). Kept as the design record. Each phase was one PR, executed in order._
|
||||
|
||||
## Why
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ version here when it is cut: R = `v2.1.0`).
|
||||
| 1 | Aliases: `/api/workspaces` = `/api/lanes` (same two routers mounted twice, `server/index.js` `LANE_MOUNTS`); `agenthub workspace`/`workspaces` = `agenthub lanes` (`COMMAND_ALIASES`/`canonicalArgv` in `bin/agenthub.js`); `--workspace <id>` = `--lane <id>` (`resolveLaneArg`). | R | done |
|
||||
| 2 | Help, usage and hint text, the UI's command hints and the user docs say `agenthub workspace …` / `--workspace`; `docs/API.md` documents both paths. | R | done |
|
||||
| 3 | Deprecation: `agenthub lanes` or `--lane` prints ONE stderr line naming the new spelling (`warnOldNames`), stdout unchanged; the help footer and `docs/CLI.md` call them the old names. | R | done |
|
||||
| 4 | Remove the old CLI names (below). | **not before R+3** | open |
|
||||
| 4 | Remove the old CLI names (below). | **not before R+3** | done 2026-10-26 (after v2.8.25): 4a + 4b built, 4c kept `/api/lanes` |
|
||||
|
||||
Three releases is the window for an install to update gradually: anyone who
|
||||
runs the CLI in that time sees the step-3 note on every old-name call, and a
|
||||
|
||||
+1
-1
@@ -142,7 +142,7 @@ const TOOLS = {
|
||||
list_lanes: {
|
||||
description: "Workspaces (lanes): id, title, folder, AI tool, status and liveness.",
|
||||
properties: {},
|
||||
path: () => "/api/lanes",
|
||||
path: () => "/api/workspaces",
|
||||
},
|
||||
list_pricing: {
|
||||
description: "Model pricing rules (USD per million tokens) the cost estimates use.",
|
||||
|
||||
+281
-17
@@ -3,7 +3,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Agent Dashboard for Claude Code API
|
||||
version: 2.8.24
|
||||
version: 2.8.28
|
||||
description: HTTP API for real-time Claude Code session monitoring, agent lifecycle tracking, analytics, pricing, hooks ingestion, and workflow intelligence.
|
||||
contact:
|
||||
name: Nguyễn Ngọc Trí Vĩ
|
||||
@@ -71,7 +71,7 @@ tags:
|
||||
- name: Artifacts
|
||||
description: 'What agents produced, shared as a SNAPSHOT: a claude.ai page link, a pinned or uploaded file, a skill or a plugin. A viewer reads only the snapshot copy (sandbox CSP, nosniff); an install writes into the caller''s own account only. Every write is same-origin.'
|
||||
- name: Host
|
||||
description: 'Administration → Server: the machine''s hardware, load, disks and each user''s share of CPU and memory. Read-only, staff only in server mode.'
|
||||
description: 'Dashboard → System Health: the machine''s hardware, load, disks and each user''s share of CPU and memory. Read-only, every signed-in user.'
|
||||
- name: Locks
|
||||
description: Cross-lane named locks (server/lib/named-lock.js). Single-shot — the server never waits for a lock; `agenthub lock acquire` owns the polling loop.
|
||||
- name: Projects
|
||||
@@ -262,6 +262,21 @@ components:
|
||||
agent_count:
|
||||
type: integer
|
||||
nullable: true
|
||||
owner_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: 'Server mode, list only: the account owning the session''s workspace (its lane, else the lane of a dashboard run holding it); null when none. Absent in local mode.'
|
||||
owner_name:
|
||||
type: string
|
||||
nullable: true
|
||||
description: That owner's username.
|
||||
owner_display_name:
|
||||
type: string
|
||||
nullable: true
|
||||
owner_avatar_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
last_activity:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -1578,6 +1593,32 @@ components:
|
||||
type: string
|
||||
ws_connections:
|
||||
type: integer
|
||||
event_loop:
|
||||
type: object
|
||||
description: Event-loop delay over the last minute in ms (p50, p99, max) — how long requests, hooks and terminal echo waited.
|
||||
properties:
|
||||
p50:
|
||||
type: number
|
||||
p99:
|
||||
type: number
|
||||
max:
|
||||
type: number
|
||||
activity:
|
||||
type: object
|
||||
description: 'For the System Health score: when the newest event (hook) landed, agents working now, and how the agents that ended in the last 24 h ended (scoped to the caller''s rows).'
|
||||
properties:
|
||||
last_event_at:
|
||||
type: string
|
||||
nullable: true
|
||||
working_agents:
|
||||
type: integer
|
||||
agents_24h:
|
||||
type: object
|
||||
properties:
|
||||
completed:
|
||||
type: integer
|
||||
error:
|
||||
type: integer
|
||||
transcript_cache:
|
||||
type: object
|
||||
required:
|
||||
@@ -2178,7 +2219,7 @@ components:
|
||||
type: string
|
||||
description: Command arguments — present only for `stdio` servers.
|
||||
example:
|
||||
- '-y'
|
||||
- -y
|
||||
- '@modelcontextprotocol/server-github'
|
||||
envNames:
|
||||
type: array
|
||||
@@ -2340,7 +2381,7 @@ components:
|
||||
project:
|
||||
type: string
|
||||
description: 'Auto-memory only: the ~/.claude/projects/<slug> directory name the file belongs to.'
|
||||
example: '-Users-son-repo'
|
||||
example: -Users-son-repo
|
||||
name:
|
||||
type: string
|
||||
description: 'Auto-memory only: the markdown filename.'
|
||||
@@ -2868,7 +2909,7 @@ components:
|
||||
project:
|
||||
type: string
|
||||
description: 'Required for `auto-memory`: the ~/.claude/projects/<slug> directory name. Validated against /^[A-Za-z0-9_-][A-Za-z0-9._-]{0,255}$/ and rejected if it contains `..`.'
|
||||
example: '-Users-son-repo'
|
||||
example: -Users-son-repo
|
||||
CcConfigFileWriteResponse:
|
||||
type: object
|
||||
description: Result of a successful create-or-overwrite.
|
||||
@@ -2933,7 +2974,7 @@ components:
|
||||
project:
|
||||
type: string
|
||||
description: 'Required for `auto-memory`: the projects/<slug> directory name.'
|
||||
example: '-Users-son-repo'
|
||||
example: -Users-son-repo
|
||||
CcConfigFileDeleteResponse:
|
||||
type: object
|
||||
description: Result of a successful delete (a mandatory backup is created first).
|
||||
@@ -2984,7 +3025,7 @@ components:
|
||||
project:
|
||||
type: string
|
||||
description: 'Auto-memory only: the projects/<slug> directory name.'
|
||||
example: '-Users-son-repo'
|
||||
example: -Users-son-repo
|
||||
type:
|
||||
type: string
|
||||
enum:
|
||||
@@ -4362,8 +4403,8 @@ components:
|
||||
items:
|
||||
type: string
|
||||
example:
|
||||
- '-Users-son-code-foo'
|
||||
- '-Users-son-code-bar'
|
||||
- -Users-son-code-foo
|
||||
- -Users-son-code-bar
|
||||
RemoteSourceSyncResponse:
|
||||
type: object
|
||||
description: Counters from a pull-now sync against the remote source.
|
||||
@@ -8432,6 +8473,52 @@ paths:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
/api/redoc/redoc.standalone.js:
|
||||
get:
|
||||
tags:
|
||||
- Documentation
|
||||
summary: ReDoc bundle
|
||||
description: The ReDoc standalone script the `/api/redoc` page loads, sent from the server's own vendored copy (no CDN).
|
||||
operationId: getRedocBundle
|
||||
responses:
|
||||
'200':
|
||||
description: JavaScript bundle
|
||||
content:
|
||||
application/javascript:
|
||||
schema:
|
||||
type: string
|
||||
'500':
|
||||
description: The bundle could not be read
|
||||
/api/cc-config/target:
|
||||
get:
|
||||
tags:
|
||||
- CcConfig
|
||||
summary: Whose Claude Code config this login manages
|
||||
description: 'Server mode: `own` for a non-admin with their own Linux account (S3) — every other endpoint then reads and writes THEIR `~/.claude`, writes running as them; `host` for an admin (and in local mode), or an analyst''s read-only view of the host''s; `none` for a non-admin with no account (the other endpoints answer 403 ENOACCOUNT). Never an error.'
|
||||
operationId: ccConfigGetTarget
|
||||
responses:
|
||||
'200':
|
||||
description: The target.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- own
|
||||
- host
|
||||
- none
|
||||
readOnly:
|
||||
type: boolean
|
||||
claudeHome:
|
||||
type: string
|
||||
nullable: true
|
||||
example:
|
||||
kind: own
|
||||
readOnly: false
|
||||
claudeHome: /srv/agenthub/mia/.claude
|
||||
/api/cc-config/overview:
|
||||
get:
|
||||
tags:
|
||||
@@ -8833,7 +8920,7 @@ paths:
|
||||
kind: stdio
|
||||
command: npx
|
||||
args:
|
||||
- '-y'
|
||||
- -y
|
||||
- '@modelcontextprotocol/server-github'
|
||||
envNames:
|
||||
- GITHUB_TOKEN
|
||||
@@ -8892,7 +8979,7 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
example:
|
||||
- '-y'
|
||||
- -y
|
||||
- server-github
|
||||
env:
|
||||
type: object
|
||||
@@ -9116,7 +9203,7 @@ paths:
|
||||
truncated: false
|
||||
preview: '# Workspace & Productivity System...'
|
||||
- scope: auto-memory
|
||||
project: '-Users-son-repo'
|
||||
project: -Users-son-repo
|
||||
name: MEMORY.md
|
||||
isIndex: true
|
||||
file: /Users/son/.claude/projects/-Users-son-repo/memory/MEMORY.md
|
||||
@@ -13040,8 +13127,8 @@ paths:
|
||||
ok: true
|
||||
message: Connected; found 24 project directories under ~/.claude/projects.
|
||||
remoteProjects:
|
||||
- '-Users-son-code-foo'
|
||||
- '-Users-son-code-bar'
|
||||
- -Users-son-code-foo
|
||||
- -Users-son-code-bar
|
||||
failure:
|
||||
summary: Unreachable / auth failed
|
||||
value:
|
||||
@@ -14968,6 +15055,38 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}/details:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: getServerUserDetails
|
||||
summary: 'One account in full for the Administration page (admin/analyst): sign-ins, session counts, spend today/7d/30d, per local day (`tz_offset`), per AI tool and per model, its workspaces and live panes, newest sessions, the audit entries naming it, and per pool tool its pin, group and the accounts it may run on — each account''s quota beside this user''s own 5 h / 7 d share. Never a token'
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: tz_offset
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
description: The browser's getTimezoneOffset(), for the daily buckets
|
||||
responses:
|
||||
'200':
|
||||
description: Account details
|
||||
'403':
|
||||
description: EADMIN — a developer
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOUSER
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}:
|
||||
get:
|
||||
tags:
|
||||
@@ -15088,6 +15207,74 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
delete:
|
||||
tags:
|
||||
- Auth
|
||||
operationId: deleteServerUser
|
||||
summary: 'Admin (Delete user): the account for good — its logins, panes, workspaces'' records and prefs, and under S3 its Linux account and whole home, removed FIRST so a refusal leaves every row. Session history and the audit log stay. Never yourself (ESELF) or the last enabled admin (ELASTADMIN)'
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- currentPassword
|
||||
properties:
|
||||
currentPassword:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Deleted — the account as it was
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
user:
|
||||
$ref: '#/components/schemas/AuthUser'
|
||||
'400':
|
||||
description: EBADPASSWORD / EBADROLE / EBADFIELD
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: EREAUTH (own password wrong) / EADMIN / ELOCALMODE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: ENOUSER / ENOLANE
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: ESELF — yourself / ELASTADMIN — the last enabled admin
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'429':
|
||||
description: ETOOMANY — 10 wrong passwords in 15 minutes
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'502':
|
||||
description: EPROVISION — the Linux account or home could not be removed; nothing deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/users/{id}/password:
|
||||
post:
|
||||
tags:
|
||||
@@ -16979,16 +17166,17 @@ paths:
|
||||
description: 'The identity; `sshUsers: [{host, user, alias, key}]`.'
|
||||
'400':
|
||||
description: EBADHOST / EBADSSHUSER / EBADALIAS / ENOKEY
|
||||
/api/me/git/ssh-user/{from}:
|
||||
/api/me/git/ssh-user/{host}:
|
||||
delete:
|
||||
tags:
|
||||
- Git identity
|
||||
summary: Drop the SSH login of a host or alias (and its key link)
|
||||
operationId: deleteSshUser
|
||||
parameters:
|
||||
- name: from
|
||||
- name: host
|
||||
in: path
|
||||
required: true
|
||||
description: The host or alias the login was saved under.
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
@@ -17634,13 +17822,37 @@ paths:
|
||||
description: 'Cached 3 s. `usage` is null off Linux. Server mode: admin/analyst only (403 EADMIN for a developer).'
|
||||
responses:
|
||||
'200':
|
||||
description: '`{at, hostname, os, uptime, cpu, memory, disks, homesMount, gpus, usage}` — see docs/API.md.'
|
||||
description: '`{at, hostname, os, uptime, bootTime, cpu (+physicalCores, perCore), memory, disks, homesMount, gpus, usage, network, diskIo, temperatures, pressure, topProcesses, process}` — see docs/API.md.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
'500':
|
||||
description: EHOST
|
||||
/api/host/history:
|
||||
get:
|
||||
tags:
|
||||
- Host
|
||||
summary: The machine's CPU/memory history since the server started
|
||||
operationId: getHostHistory
|
||||
description: 'In memory only: one point every 30 s for 24 h, kept by a server-mode server on Linux (empty otherwise, `running: false`). Averaged down to at most 360 points. Server mode: admin/analyst only.'
|
||||
parameters:
|
||||
- name: range
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- 1h
|
||||
- 6h
|
||||
- 24h
|
||||
default: 1h
|
||||
responses:
|
||||
'200':
|
||||
description: '`{range, intervalMs, running, startedAt, points: [{at, cpu, memory, memoryUsed, swapUsed, load1, users: {<userId>: {cpu, memory}}}]}`.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
/api/lanes:
|
||||
get:
|
||||
tags:
|
||||
@@ -18988,6 +19200,58 @@ paths:
|
||||
properties:
|
||||
installed:
|
||||
type: boolean
|
||||
/api/ai-proxies/9router/update:
|
||||
get:
|
||||
tags:
|
||||
- AI Proxies
|
||||
summary: 9router update status
|
||||
description: 'Installed vs latest npm version of the host''s 9router, whether it runs (`managedBy`: the `9router.service` user unit or a plain process), `blocker` — why a start would be refused now (`ENOTINSTALLED`, `EUPTODATE`, `ENOLATEST`, `ENONPM`, `ENOPERM` with the `command` to run by hand, `EUNSUPPORTED`) — and the current/last job. `?fresh=1` re-asks the registry (cached 10 min).'
|
||||
operationId: getAiProxy9routerUpdate
|
||||
parameters:
|
||||
- name: fresh
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- '1'
|
||||
responses:
|
||||
'200':
|
||||
description: Status.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
post:
|
||||
tags:
|
||||
- AI Proxies
|
||||
summary: Update 9router
|
||||
description: 'Starts the one in-memory job: shut 9router down (stop the unit, or SIGTERM→SIGKILL its processes until the port is free), `npm install -g --prefix <its prefix> 9router@latest` (under `sudo -n` only when the prefix is not writable and sudo needs no password), then — only if it was running — start it again hidden (`--tray --no-browser --skip-update`). A failed update still relaunches. Admin only; same-origin guarded. Poll the GET for the steps.'
|
||||
operationId: updateAiProxy9router
|
||||
responses:
|
||||
'202':
|
||||
description: The job, running.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
'403':
|
||||
description: '`EADMIN`.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: '`EBUSY` or the status `blocker` code.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'502':
|
||||
description: '`ENOLATEST` — the npm registry did not answer.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/api/ai-proxies/test:
|
||||
post:
|
||||
tags:
|
||||
|
||||
Generated
+429
-788
File diff suppressed because it is too large
Load Diff
+6
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.25",
|
||||
"version": "2.8.28",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
@@ -71,21 +71,20 @@
|
||||
"link-cli": "npm link || node -e \"console.warn('agenthub CLI not linked globally (no permission?). Link manually with: npm link')\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@lydell/node-pty": "^1.2.0-beta.15",
|
||||
"@lydell/node-pty": "^1.2.0-beta.18",
|
||||
"adm-zip": "^0.6.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"multer": "^2.0.0",
|
||||
"express": "^5.3.0",
|
||||
"js-yaml": "^5.5.0",
|
||||
"multer": "^2.5.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"systray2": "^2.1.4",
|
||||
"tar": "^7.4.3",
|
||||
"uuid": "^11.1.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"better-sqlite3": "^12.0.0"
|
||||
"better-sqlite3": "^13.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2",
|
||||
|
||||
@@ -239,7 +239,7 @@ function systemdStatus() {
|
||||
/**
|
||||
* WARN (never FAIL) when `tmux` is absent: every run the dashboard starts is a
|
||||
* tmux session (`server/lib/tmux.js`), so without the binary the run console,
|
||||
* `agenthub lanes shell` and the session-reload keystrokes all fail — while
|
||||
* `agenthub workspace shell` and the session-reload keystrokes all fail — while
|
||||
* sessions, analytics, lanes and the CLI keep working. A partial feature
|
||||
* loss must not fail a doctor run.
|
||||
*
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
const { randomUUID: uuidv4 } = require("crypto");
|
||||
const { db, stmts } = require("../server/db");
|
||||
|
||||
// ── Stable fixture IDs ─────────────────────────────────────────────────────
|
||||
|
||||
+3
-2
@@ -441,7 +441,7 @@ All endpoints return JSON unless noted. Error responses use:
|
||||
| `GET` | `/api/redoc` | **ReDoc** reference — clean, read-optimized three-panel rendering of the same spec |
|
||||
| `GET` | `/api/redoc/redoc.standalone.js` | Self-hosted ReDoc bundle (vendored in `server/vendor/redoc/`, never a CDN — works offline) |
|
||||
|
||||
The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`), merged with supplementary fragments under `server/openapi-extra/`, and is the source of truth for request/response contracts. It now documents every backend route (75 path entries). Both Swagger UI and ReDoc (`server/lib/redoc.js`) render the same spec; the ReDoc bundle is served locally so the reference works offline / air-gapped. A committed `openapi.yaml` at the repo root mirrors the live spec — regenerate it after API changes with `npm run openapi:yaml` (never hand-edit it).
|
||||
The OpenAPI spec is generated from `server/openapi.js` (`createOpenApiSpec()`), merged with supplementary fragments under `server/openapi-extra/`, and is the source of truth for request/response contracts. It documents every `/api` route (332 paths, 399 operations); `server/__tests__/openapi-coverage.test.js` fails when a registered route has no operation in the spec, or an operation has no route (the `/api/workspaces` and `/api/ai-routers` alias mounts are documented once, under `/api/lanes` and `/api/ai-proxies`). Both Swagger UI and ReDoc (`server/lib/redoc.js`) render the same spec; the ReDoc bundle is served locally so the reference works offline / air-gapped. A committed `openapi.yaml` at the repo root mirrors the live spec — regenerate it after API changes with `npm run openapi:yaml` (never hand-edit it).
|
||||
|
||||
### Core Endpoints
|
||||
|
||||
@@ -747,10 +747,11 @@ Because sync runs non-interactively (`ssh -o BatchMode=yes`), the connection mus
|
||||
|
||||
### Claude Config Explorer (`/api/cc-config`)
|
||||
|
||||
Reads — and carefully gated mutations for low-risk text-file artifacts — for every Claude Code configuration surface. Mutations always create timestamped backups under `<root>/cc-config-backups/<type>/` before writing.
|
||||
Reads — and carefully gated mutations for low-risk text-file artifacts — for every Claude Code configuration surface. Mutations always create timestamped backups under `<root>/cc-config-backups/<type>/` before writing. In server mode the router gates itself (`lib/cc-target.js`): an admin works on the host's Claude home, a non-admin with their own Linux account (S3) on THEIR `~/.claude` (writes as them through `write-as` / sudo `claude`), anyone else gets `403 ENOACCOUNT`; `/profiles*` and `/plugins/update-all` stay an admin's. See `docs/SERVER-MODE.md` → "Your own Claude Code config".
|
||||
|
||||
| Method | Path | Description |
|
||||
| -------- | ------------------------------------- | ----------- |
|
||||
| `GET` | `/api/cc-config/target` | Whose config this login manages: `own` / `host` / `none` |
|
||||
| `GET` | `/api/cc-config/overview` | Roots + counts for every surface (used by the Overview tab) |
|
||||
| `GET` | `/api/cc-config/skills` | Skills with parsed frontmatter, `?scope=user\|project\|all` |
|
||||
| `GET` | `/api/cc-config/agents` | Subagents under `<scope>/.claude/agents/*.md` |
|
||||
|
||||
@@ -2040,6 +2040,16 @@ describe("Transcript cache integration", () => {
|
||||
assert.ok(heapLimit >= heapTotal && heapLimit > heapUsed);
|
||||
});
|
||||
|
||||
it("reports event-loop delay and the last hook / agents for the health score", async () => {
|
||||
const res = await fetch("/api/settings/info");
|
||||
const loop = res.body.server.event_loop;
|
||||
for (const k of ["p50", "p99", "max"]) assert.ok(Number.isFinite(loop[k]), `event_loop.${k}`);
|
||||
const a = res.body.activity;
|
||||
assert.ok(a.last_event_at === null || typeof a.last_event_at === "string");
|
||||
assert.ok(Number.isInteger(a.working_agents));
|
||||
assert.ok(Number.isInteger(a.agents_24h.completed) && Number.isInteger(a.agents_24h.error));
|
||||
});
|
||||
|
||||
it("should evict cache entry on SessionEnd", async () => {
|
||||
const tmpTranscript = path.join(os.tmpdir(), `test-evict-${Date.now()}.jsonl`);
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -98,6 +98,17 @@ describe("reloadableLanes", () => {
|
||||
stubTmux([]);
|
||||
assert.deepEqual(reloadableLanes(), []);
|
||||
});
|
||||
|
||||
it("keeps the host's panes and one member's apart (S3)", (t) => {
|
||||
stubTmux(["agenthub-lane-1", "agenthub-lane-2", "agenthub-lane-3"]);
|
||||
const owners = { "agenthub-lane-2": { name: "mia" }, "agenthub-lane-3": { name: "bob" } };
|
||||
t.mock.method(require("../lib/run-as"), "accountForSession", (id) => owners[id] || null);
|
||||
const ids = (list) => list.map((l) => l.id);
|
||||
// The host's reload never types into a member's pane…
|
||||
assert.deepEqual(ids(reloadableLanes()), ["agenthub-lane-1"]);
|
||||
// …and a member's reaches their own panes only.
|
||||
assert.deepEqual(ids(reloadableLanes({ name: "mia" })), ["agenthub-lane-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloadOpenSessions", () => {
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
/**
|
||||
* @file fake-sudo.js
|
||||
* @description Test helper for the S3 suites: an `execImpl` for
|
||||
* `run-as.__setExecImpl` that plays `sudo -n -u <login> [VAR=v…] <bin> …`
|
||||
* without root — it records the argv (so a test can assert nothing secret
|
||||
* `run-as.__setExecImpl` (and a `spawn` for `__setSpawnImpl`) that plays
|
||||
* `sudo -n -u <login> [VAR=v…] <bin> …` without root — it records the argv (so a test can assert nothing secret
|
||||
* crossed sudo) and runs the real `scripts/server/write-as.sh` for
|
||||
* `/opt/agenthub/libexec/write-as`, as this account.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const path = require("node:path");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const { execFileSync, spawn: spawnChild } = require("node:child_process");
|
||||
|
||||
const WRITE_AS = path.join(__dirname, "..", "..", "..", "scripts", "server", "write-as.sh");
|
||||
|
||||
/** `{ exec, calls }`: pass `exec` to `runAs.__setExecImpl`; `calls` = every sudo argv. */
|
||||
/** `{ exec, spawn, calls }`: pass `exec` to `runAs.__setExecImpl` and `spawn`
|
||||
* to `runAs.__setSpawnImpl`; `calls` = every sudo argv. */
|
||||
function fakeSudo() {
|
||||
const calls = [];
|
||||
const exec = (cmd, argv, opts = {}) => {
|
||||
// The command sudo would run, as [file, args].
|
||||
const unwrap = (cmd, argv) => {
|
||||
calls.push([cmd, ...argv]);
|
||||
if (cmd !== "sudo") return execFileSync(cmd, argv, opts);
|
||||
if (cmd !== "sudo") return [cmd, argv];
|
||||
let i = argv.indexOf("-u") + 2; // past the login
|
||||
while (/^[A-Z_][A-Z0-9_]*=/.test(argv[i])) i++;
|
||||
const [bin, ...rest] = argv.slice(i);
|
||||
if (bin === "/opt/agenthub/libexec/write-as")
|
||||
return execFileSync("sh", [WRITE_AS, ...rest], opts);
|
||||
return execFileSync(bin, rest, opts);
|
||||
return bin === "/opt/agenthub/libexec/write-as" ? ["sh", [WRITE_AS, ...rest]] : [bin, rest];
|
||||
};
|
||||
return { exec, calls };
|
||||
const exec = (cmd, argv, opts = {}) => execFileSync(...unwrap(cmd, argv), opts);
|
||||
const spawn = (cmd, argv, opts = {}) => spawnChild(...unwrap(cmd, argv), opts);
|
||||
return { exec, spawn, calls };
|
||||
}
|
||||
|
||||
module.exports = { fakeSudo };
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
"label": "CI gates + preflight",
|
||||
"icon": "🧪",
|
||||
"gate": true,
|
||||
"command": "agenthub lanes hook ci-gate",
|
||||
"command": "agenthub workspace hook ci-gate",
|
||||
"aliases": ["pre-push-gate", "tests", "migration-collision", "sync-conflict"],
|
||||
"detect": [
|
||||
{
|
||||
@@ -95,7 +95,7 @@
|
||||
"label": "E2e on feature branch",
|
||||
"icon": "🧪",
|
||||
"gate": false,
|
||||
"command": "agenthub lanes up --qc",
|
||||
"command": "agenthub workspace up --qc",
|
||||
"aliases": ["e2e", "e2e-scoped", "booting", "live"],
|
||||
"detect": [
|
||||
{
|
||||
@@ -143,7 +143,7 @@
|
||||
"label": "Browser QC",
|
||||
"icon": "🔍",
|
||||
"gate": true,
|
||||
"command": "agenthub lanes proof-link",
|
||||
"command": "agenthub workspace proof-link",
|
||||
"aliases": [],
|
||||
"detect": [
|
||||
{
|
||||
@@ -152,7 +152,7 @@
|
||||
},
|
||||
{
|
||||
"tool": "Bash",
|
||||
"match": "\\bagenthub\\b[^;&|]*\\blanes proof-link\\b"
|
||||
"match": "\\bagenthub\\b[^;&|]*\\b(?:lanes|workspace) proof-link\\b"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -219,7 +219,7 @@
|
||||
"label": "Watching PR",
|
||||
"icon": "👁",
|
||||
"gate": false,
|
||||
"command": "agenthub lanes ci status",
|
||||
"command": "agenthub workspace ci status",
|
||||
"aliases": ["pr-comment-fix"],
|
||||
"detect": [
|
||||
{
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
/**
|
||||
* @file host-stats.test.js
|
||||
* @description Tests for server/lib/host-stats.js — the Server tab's hardware
|
||||
* snapshot and per-user CPU/memory attribution (`GET /api/host`).
|
||||
* snapshot and per-user CPU/memory attribution (`GET /api/host`), the
|
||||
* /proc and /sys parsers behind its per-core, network, disk I/O and
|
||||
* temperature figures, the PSI pressure parser, and the in-memory history (`GET /api/host/history`).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { hostStats, mountOf, __setDeps } = require("../lib/host-stats");
|
||||
const {
|
||||
hostStats,
|
||||
hostHistory,
|
||||
mountOf,
|
||||
parseCpuStat,
|
||||
parsePressure,
|
||||
parseNetDev,
|
||||
parseDiskstats,
|
||||
physicalCores,
|
||||
temperatures,
|
||||
downsample,
|
||||
__setDeps,
|
||||
__setHistory,
|
||||
} = require("../lib/host-stats");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const linux = process.platform === "linux";
|
||||
|
||||
@@ -68,3 +86,132 @@ test(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test("parsePressure reads some/full averages and refuses a missing file", () => {
|
||||
assert.deepEqual(
|
||||
parsePressure(
|
||||
"some avg10=12.50 avg60=3.00 avg300=1.00 total=1\nfull avg10=4.25 avg60=1.00 avg300=0.50 total=1\n"
|
||||
),
|
||||
{ some10: 12.5, some60: 3, full10: 4.25 }
|
||||
);
|
||||
// The cpu file of an older kernel has no `full` line.
|
||||
assert.deepEqual(parsePressure("some avg10=1.00 avg60=2.00 avg300=0.00 total=1\n"), {
|
||||
some10: 1,
|
||||
some60: 2,
|
||||
full10: 0,
|
||||
});
|
||||
assert.equal(parsePressure(null), null);
|
||||
assert.equal(parsePressure("garbage"), null);
|
||||
});
|
||||
|
||||
test("parseCpuStat reads the machine line and every core", () => {
|
||||
const r = parseCpuStat(
|
||||
"cpu 10 0 10 70 10 0 0 0 0 0\ncpu0 5 0 5 35 5 0 0 0\ncpu1 5 0 5 35 5 0 0 0\nintr 1 2\n"
|
||||
);
|
||||
assert.deepEqual(r.all, { total: 100, idle: 80 });
|
||||
assert.equal(r.cores.length, 2);
|
||||
assert.deepEqual(r.cores[1], { total: 50, idle: 40 });
|
||||
assert.deepEqual(parseCpuStat(""), { all: null, cores: [] });
|
||||
});
|
||||
|
||||
test("parseNetDev skips lo and reads rx/tx bytes", () => {
|
||||
const text = [
|
||||
"Inter-| Receive | Transmit",
|
||||
" face |bytes packets|bytes packets",
|
||||
" lo: 100 1 0 0 0 0 0 0 100 1 0 0 0 0 0 0",
|
||||
"enp4s0: 5000 10 0 0 0 0 0 0 2000 6 0 0 0 0 0 0",
|
||||
].join("\n");
|
||||
const m = parseNetDev(text);
|
||||
assert.equal(m.has("lo"), false);
|
||||
assert.deepEqual(m.get("enp4s0"), { rx: 5000, tx: 2000 });
|
||||
});
|
||||
|
||||
test("parseDiskstats keeps whole disks and converts sectors to bytes", () => {
|
||||
const text = [
|
||||
" 7 0 loop0 14 0 34 12 0 0 0 0 0 11 12",
|
||||
" 8 0 sda 100 0 2048 50 20 0 1024 9 0 30 40",
|
||||
" 8 1 sda1 90 0 2000 40 20 0 1000 9 0 30 40",
|
||||
].join("\n");
|
||||
const m = parseDiskstats(text, (n) => n === "sda");
|
||||
assert.deepEqual([...m.keys()], ["sda"]);
|
||||
assert.deepEqual(m.get("sda"), { read: 2048 * 512, write: 1024 * 512 });
|
||||
});
|
||||
|
||||
test("physicalCores counts distinct physical/core id pairs", () => {
|
||||
const info = [0, 1].flatMap((core) =>
|
||||
[0, 1].map((ht) => `processor\t: ${core * 2 + ht}\nphysical id\t: 0\ncore id\t\t: ${core}\n`)
|
||||
);
|
||||
assert.equal(physicalCores(info.join("\n")), 2);
|
||||
assert.equal(physicalCores(""), null);
|
||||
});
|
||||
|
||||
test("temperatures reads hwmon inputs with their labels", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "hwmon-"));
|
||||
try {
|
||||
const chip = path.join(root, "hwmon0");
|
||||
fs.mkdirSync(chip);
|
||||
fs.writeFileSync(path.join(chip, "name"), "coretemp\n");
|
||||
fs.writeFileSync(path.join(chip, "temp1_input"), "45500\n");
|
||||
fs.writeFileSync(path.join(chip, "temp1_label"), "Package id 0\n");
|
||||
fs.writeFileSync(path.join(chip, "temp2_input"), "38000\n");
|
||||
assert.deepEqual(await temperatures(root), [
|
||||
{ chip: "coretemp", label: "Package id 0", celsius: 45.5 },
|
||||
{ chip: "coretemp", label: "temp2", celsius: 38 },
|
||||
]);
|
||||
assert.deepEqual(await temperatures(path.join(root, "missing")), []);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("hostHistory filters by range and downsample averages buckets", () => {
|
||||
const now = Date.now();
|
||||
const pt = (minsAgo, cpu) => ({
|
||||
at: new Date(now - minsAgo * 60e3).toISOString(),
|
||||
cpu,
|
||||
memory: 50,
|
||||
memoryUsed: 100,
|
||||
swapUsed: 0,
|
||||
load1: 1,
|
||||
users: { 3: { cpu, memory: 10 } },
|
||||
});
|
||||
__setHistory([pt(300, 90), pt(30, 10), pt(1, 20)]);
|
||||
const h1 = hostHistory("1h");
|
||||
assert.equal(h1.range, "1h");
|
||||
assert.deepEqual(
|
||||
h1.points.map((p) => p.cpu),
|
||||
[10, 20]
|
||||
);
|
||||
assert.equal(hostHistory("6h").points.length, 3);
|
||||
assert.equal(hostHistory("bogus").range, "1h");
|
||||
const d = downsample([pt(3, 10), pt(2, 20), pt(1, 30), pt(0, 40)], 2);
|
||||
assert.deepEqual(
|
||||
d.map((p) => p.cpu),
|
||||
[15, 35]
|
||||
);
|
||||
assert.equal(d[0].users[3].cpu, 15);
|
||||
__setHistory([]);
|
||||
});
|
||||
|
||||
test(
|
||||
"hostStats adds per-core, network, disk I/O, top processes and process facts",
|
||||
{ skip: !linux },
|
||||
async () => {
|
||||
__setDeps({
|
||||
users: () => [],
|
||||
memberNameForUid: () => null,
|
||||
laneOwner: () => null,
|
||||
homesRoot: () => "/",
|
||||
wait: () => new Promise((r) => setTimeout(r, 20)),
|
||||
});
|
||||
const r = await hostStats();
|
||||
assert.equal(r.cpu.perCore.length, r.cpu.cores);
|
||||
assert.ok(Array.isArray(r.network) && Array.isArray(r.diskIo));
|
||||
assert.ok(r.network.every((n) => n.name !== "lo"));
|
||||
assert.ok(r.topProcesses.length > 0 && r.topProcesses.length <= 10);
|
||||
assert.ok(r.topProcesses.every((p) => typeof p.group === "string" && !("argv" in p)));
|
||||
assert.equal(r.process.pid, process.pid);
|
||||
assert.ok(r.process.rss > 0);
|
||||
assert.ok(typeof r.bootTime === "string");
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @file Tests for the `agenthub stage` / `agenthub lanes` CLI subcommands: lane
|
||||
* @file Tests for the `agenthub stage` / `agenthub workspace` CLI subcommands: lane
|
||||
* resolution from the current directory, the stage round-trip through the HTTP
|
||||
* API, and the non-zero exit when the cwd belongs to no lane.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -194,20 +194,26 @@ after(() => {
|
||||
});
|
||||
|
||||
describe("agenthub workspaces", () => {
|
||||
it("is the lanes command under the UI's word", async () => {
|
||||
const [lanes, workspaces, workspace] = await Promise.all([
|
||||
cli(["lanes"], LANE_DIR),
|
||||
it("is the workspace command under its plural", async () => {
|
||||
const [workspaces, workspace] = await Promise.all([
|
||||
cli(["workspaces"], LANE_DIR),
|
||||
cli(["workspace"], LANE_DIR),
|
||||
]);
|
||||
assert.equal(workspaces.status, 0, workspaces.stderr);
|
||||
assert.match(workspaces.stdout, /CLI lane/);
|
||||
assert.equal(workspaces.stdout, lanes.stdout);
|
||||
assert.equal(workspace.stdout, lanes.stdout);
|
||||
// The old name still works but says so — on stderr, never in stdout.
|
||||
assert.match(lanes.stderr, /old name.*agenthub workspace/);
|
||||
assert.doesNotMatch(workspaces.stderr, /old name/);
|
||||
assert.doesNotMatch(lanes.stdout, /old name/);
|
||||
assert.equal(workspace.stdout, workspaces.stdout);
|
||||
});
|
||||
|
||||
it("refuses the removed old names, naming the new spelling", async () => {
|
||||
const [lanes, flag] = await Promise.all([
|
||||
cli(["lanes"], LANE_DIR),
|
||||
cli(["stage", "review", "--lane", "1"], LANE_DIR),
|
||||
]);
|
||||
assert.equal(lanes.status, 1);
|
||||
assert.match(lanes.stderr, /renamed: use agenthub workspace/);
|
||||
assert.equal(lanes.stdout, "");
|
||||
assert.equal(flag.status, 1);
|
||||
assert.match(flag.stderr, /renamed: use --workspace <id>/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -216,7 +222,7 @@ describe("agenthub stage", () => {
|
||||
const r = await cli(["stage", "review", "--evidence", "3 findings"], LANE_DIR);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /review/);
|
||||
const list = await cli(["lanes"], LANE_DIR);
|
||||
const list = await cli(["workspace"], LANE_DIR);
|
||||
assert.match(list.stdout, /review/);
|
||||
});
|
||||
|
||||
@@ -298,7 +304,7 @@ describe("agenthub stage in a directory shared by two lanes", () => {
|
||||
assert.notEqual(await stageOf(codexLane.id), "implement");
|
||||
});
|
||||
|
||||
it("--workspace names the lane like --lane", async () => {
|
||||
it("--workspace names the lane", async () => {
|
||||
const r = await cli(
|
||||
["stage", "review", "--workspace", String(codexLane.id)],
|
||||
DUAL_DIR,
|
||||
@@ -328,18 +334,18 @@ describe("agenthub stage in a directory shared by two lanes", () => {
|
||||
assert.match(`${r.stdout}${r.stderr}`, /no codex workspace/i);
|
||||
});
|
||||
|
||||
it("--lane flag with explicit ID beats cwd resolution and tool ambiguity", async () => {
|
||||
// Even with two lanes at the same cwd, --lane <id> bypasses all resolution
|
||||
const r = await cli(["stage", "review", "--lane", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
|
||||
it("--workspace flag with explicit ID beats cwd resolution and tool ambiguity", async () => {
|
||||
// Even with two lanes at the same cwd, --workspace <id> bypasses all resolution
|
||||
const r = await cli(["stage", "review", "--workspace", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(await stageOf(codexLane.id), "review");
|
||||
assert.notEqual(await stageOf(claudeLane.id), "review");
|
||||
});
|
||||
|
||||
it("positional numeric ID acts like --lane for commands that support it (e.g. agenthub lanes pipeline)", async () => {
|
||||
// Commands like `agenthub lanes pipeline` treat a leading numeric arg as the lane ID.
|
||||
it("positional numeric ID acts like --workspace for commands that support it (e.g. agenthub workspace pipeline)", async () => {
|
||||
// Commands like `agenthub workspace pipeline` treat a leading numeric arg as the lane ID.
|
||||
// Other commands like `agenthub stage` have their own positional argument and don't support it.
|
||||
const r = await cli(["lanes", "pipeline", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
|
||||
const r = await cli(["workspace", "pipeline", String(codexLane.id)], DUAL_DIR, NO_HARNESS);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, new RegExp(`workspace #${codexLane.id}`));
|
||||
});
|
||||
@@ -386,15 +392,15 @@ describe("agenthub stage in a directory shared by two lanes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("agenthub lanes add", () => {
|
||||
describe("agenthub workspace add", () => {
|
||||
it("creates a lane and it appears in lanes list", async () => {
|
||||
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-add-"));
|
||||
try {
|
||||
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "CLI added"], addDir);
|
||||
const r = await cli(["workspace", "add", "--cwd", addDir, "--title", "CLI added"], addDir);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /Created workspace/i);
|
||||
|
||||
const list = await cli(["lanes"], addDir);
|
||||
const list = await cli(["workspace"], addDir);
|
||||
assert.equal(list.status, 0, list.stderr);
|
||||
assert.match(list.stdout, /CLI added/);
|
||||
} finally {
|
||||
@@ -405,7 +411,7 @@ describe("agenthub lanes add", () => {
|
||||
it("defaults to 'default' pipeline when --pipeline is omitted", async () => {
|
||||
const addDir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-no-pipeline-"));
|
||||
try {
|
||||
const r = await cli(["lanes", "add", "--cwd", addDir, "--title", "Default pipeline"], addDir);
|
||||
const r = await cli(["workspace", "add", "--cwd", addDir, "--title", "Default pipeline"], addDir);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /Created workspace/i);
|
||||
|
||||
@@ -423,7 +429,7 @@ describe("agenthub lanes add", () => {
|
||||
try {
|
||||
const r = await cli(
|
||||
[
|
||||
"lanes",
|
||||
"workspace",
|
||||
"add",
|
||||
"--cwd",
|
||||
addDir,
|
||||
@@ -449,17 +455,17 @@ describe("agenthub lanes add", () => {
|
||||
it("switches an existing lane's pipeline, and shows it when given no target", async () => {
|
||||
// `--pipeline` was creation-only, so every lane added from the dashboard
|
||||
// was pinned to `default`'s 8 nodes with no way to reach a longer template.
|
||||
const show = await cli(["lanes", "pipeline"], LANE_DIR);
|
||||
const show = await cli(["workspace", "pipeline"], LANE_DIR);
|
||||
assert.equal(show.status, 0, show.stderr);
|
||||
assert.match(show.stdout, /default/);
|
||||
assert.match(show.stdout, /available:.*ship-feature/);
|
||||
|
||||
const set = await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
|
||||
const set = await cli(["workspace", "pipeline", "ship-feature"], LANE_DIR);
|
||||
assert.equal(set.status, 0, set.stderr);
|
||||
assert.match(set.stdout, /ship-feature/);
|
||||
assert.match(set.stdout, /16 nodes/);
|
||||
|
||||
const back = await cli(["lanes", "pipeline", "default"], LANE_DIR);
|
||||
const back = await cli(["workspace", "pipeline", "default"], LANE_DIR);
|
||||
assert.equal(back.status, 0, back.stderr);
|
||||
assert.match(back.stdout, /8 nodes/);
|
||||
});
|
||||
@@ -467,17 +473,17 @@ describe("agenthub lanes add", () => {
|
||||
it("refuses an unknown pipeline id instead of silently falling back to default", async () => {
|
||||
// getPipeline() returns the default template for an unknown id, so without
|
||||
// a write-side check a typo would store, render `default`, and look fine.
|
||||
const r = await cli(["lanes", "pipeline", "no-such-pipeline"], LANE_DIR);
|
||||
const r = await cli(["workspace", "pipeline", "no-such-pipeline"], LANE_DIR);
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(`${r.stdout}${r.stderr}`, /unknown pipeline/i);
|
||||
|
||||
const after = await cli(["lanes", "pipeline"], LANE_DIR);
|
||||
const after = await cli(["workspace", "pipeline"], LANE_DIR);
|
||||
assert.match(after.stdout, /default/);
|
||||
});
|
||||
|
||||
it("provisions a managed worktree lane and reports it ready", async () => {
|
||||
const r = await cli(
|
||||
["lanes", "add", "--repo", SOURCE_REPO, "--title", "CLI worktree", "--slug", "cli-worktree"],
|
||||
["workspace", "add", "--repo", SOURCE_REPO, "--title", "CLI worktree", "--slug", "cli-worktree"],
|
||||
SOURCE_REPO
|
||||
);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
@@ -504,7 +510,7 @@ describe("agenthub lanes add", () => {
|
||||
);
|
||||
await git(["branch", "feat"], emptyRepo);
|
||||
const r = await cli(
|
||||
["lanes", "add", "--repo", emptyRepo, "--title", "CLI Unborn"],
|
||||
["workspace", "add", "--repo", emptyRepo, "--title", "CLI Unborn"],
|
||||
emptyRepo
|
||||
);
|
||||
assert.notEqual(r.status, 0);
|
||||
@@ -517,7 +523,7 @@ describe("agenthub lanes add", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("agenthub lanes destructive lifecycle", () => {
|
||||
describe("agenthub workspace destructive lifecycle", () => {
|
||||
// Every test provisions its OWN lane. These tests used to share one mutable
|
||||
// `managedLane` across describe blocks, which meant they only ever exercised
|
||||
// whatever state the previous test happened to leave behind — the same
|
||||
@@ -526,7 +532,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
||||
async function ownLane(title) {
|
||||
const slug = `cli-destructive-${(laneSeq += 1)}`;
|
||||
const added = await cli(
|
||||
["lanes", "add", "--repo", SOURCE_REPO, "--title", title, "--slug", slug],
|
||||
["workspace", "add", "--repo", SOURCE_REPO, "--title", title, "--slug", slug],
|
||||
SOURCE_REPO
|
||||
);
|
||||
assert.equal(added.status, 0, added.stderr);
|
||||
@@ -542,7 +548,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
||||
const dirtyFile = path.join(lane.cwd, "dirty.txt");
|
||||
fs.writeFileSync(dirtyFile, "must survive\n");
|
||||
|
||||
const r = await cli(["lanes", "reset", String(lane.id)], SOURCE_REPO);
|
||||
const r = await cli(["workspace", "reset", String(lane.id)], SOURCE_REPO);
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(r.stdout, /Preflight for reset workspace #\d+:/);
|
||||
assert.match(r.stdout, /dirty\s+0/);
|
||||
@@ -555,7 +561,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
||||
const lane = await ownLane("CLI reset applied");
|
||||
fs.writeFileSync(path.join(lane.cwd, "dirty.txt"), "to be cleaned\n");
|
||||
|
||||
const r = await cli(["lanes", "reset", String(lane.id), "--yes"], SOURCE_REPO);
|
||||
const r = await cli(["workspace", "reset", String(lane.id), "--yes"], SOURCE_REPO);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, new RegExp(`Reset workspace #${lane.id}`));
|
||||
assert.equal(fs.existsSync(path.join(lane.cwd, "dirty.txt")), false);
|
||||
@@ -577,8 +583,8 @@ describe("agenthub lanes destructive lifecycle", () => {
|
||||
const lane = await ownLane("CLI reset race");
|
||||
fs.writeFileSync(path.join(lane.cwd, "race.txt"), "one more untracked file\n");
|
||||
const [a, b] = await Promise.all([
|
||||
cli(["lanes", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
|
||||
cli(["lanes", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
|
||||
cli(["workspace", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
|
||||
cli(["workspace", "reset", String(lane.id), "--yes", "--force"], SOURCE_REPO),
|
||||
]);
|
||||
const [winner, loser] = a.status === 0 ? [a, b] : [b, a];
|
||||
assert.equal(winner.status, 0, winner.stderr);
|
||||
@@ -591,7 +597,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
||||
it("removes a managed lane's record with --yes, leaving its directory on disk", async () => {
|
||||
const lane = await ownLane("CLI remove managed");
|
||||
assert.equal(fs.existsSync(lane.cwd), true);
|
||||
const r = await cli(["lanes", "remove", String(lane.id), "--yes"], SOURCE_REPO);
|
||||
const r = await cli(["workspace", "remove", String(lane.id), "--yes"], SOURCE_REPO);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, new RegExp(`Removed workspace #${lane.id}`));
|
||||
assert.match(r.stdout, /left for you to delete yourself/);
|
||||
@@ -610,14 +616,14 @@ describe("agenthub lanes destructive lifecycle", () => {
|
||||
});
|
||||
const id = created.body.lane.id;
|
||||
|
||||
const reset = await cli(["lanes", "reset", String(id), "--yes"], adoptedDir);
|
||||
const reset = await cli(["workspace", "reset", String(id), "--yes"], adoptedDir);
|
||||
assert.notEqual(reset.status, 0);
|
||||
assert.match(`${reset.stdout}${reset.stderr}`, /points at a directory you own/i);
|
||||
|
||||
// The server permits `remove` for an adopted lane — it drops the dashboard
|
||||
// record only — so the CLI must not refuse it, or the capability is
|
||||
// unreachable from the terminal.
|
||||
const removed = await cli(["lanes", "remove", String(id), "--yes"], adoptedDir);
|
||||
const removed = await cli(["workspace", "remove", String(id), "--yes"], adoptedDir);
|
||||
assert.equal(removed.status, 0, removed.stderr);
|
||||
assert.match(removed.stdout, new RegExp(`Removed workspace #${id}`));
|
||||
assert.equal((await get(`/api/lanes/${id}`)).status, 404);
|
||||
@@ -634,7 +640,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
||||
assert.equal(preflight.status, 200);
|
||||
const { sessions, events, tokenRows } = preflight.body;
|
||||
|
||||
const r = await cli(["lanes", "purge", String(lane.id), "--yes"], SOURCE_REPO);
|
||||
const r = await cli(["workspace", "purge", String(lane.id), "--yes"], SOURCE_REPO);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(
|
||||
r.stdout,
|
||||
@@ -645,7 +651,7 @@ describe("agenthub lanes destructive lifecycle", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("agenthub lanes — inferred stage detection", () => {
|
||||
describe("agenthub workspace — inferred stage detection", () => {
|
||||
it("prints the inferred stage when detection leads the declared stage", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-lane-detect-lead-"));
|
||||
try {
|
||||
@@ -666,10 +672,10 @@ describe("agenthub lanes — inferred stage detection", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const r = await cli(["lanes"], dir);
|
||||
const r = await cli(["workspace"], dir);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
|
||||
assert.ok(line, "lane row not found in agenthub lanes output");
|
||||
assert.ok(line, "lane row not found in agenthub workspaces output");
|
||||
assert.match(line, /⇢ detected:tests/);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
@@ -685,7 +691,7 @@ describe("agenthub lanes — inferred stage detection", () => {
|
||||
// Declare 'ship' (past 'tests' in the default pipeline) first, so the
|
||||
// detection below is behind the declaration and recordDetection's
|
||||
// declared-wins guard refuses to write it.
|
||||
const staged = await cli(["stage", "ship", "--lane", String(id)], dir);
|
||||
const staged = await cli(["stage", "ship", "--workspace", String(id)], dir);
|
||||
assert.equal(staged.status, 0, staged.stderr);
|
||||
|
||||
await post("/api/hooks/event", {
|
||||
@@ -698,10 +704,10 @@ describe("agenthub lanes — inferred stage detection", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const r = await cli(["lanes"], dir);
|
||||
const r = await cli(["workspace"], dir);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const line = r.stdout.split("\n").find((l) => l.startsWith(`#${id}`));
|
||||
assert.ok(line, "lane row not found in agenthub lanes output");
|
||||
assert.ok(line, "lane row not found in agenthub workspaces output");
|
||||
assert.doesNotMatch(line, /detected:/);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
@@ -709,9 +715,9 @@ describe("agenthub lanes — inferred stage detection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("agenthub lanes node", () => {
|
||||
describe("agenthub workspace node", () => {
|
||||
it("with no node id, lists every node and its command, and exits non-zero with usage", async () => {
|
||||
const r = await cli(["lanes", "node"], LANE_DIR);
|
||||
const r = await cli(["workspace", "node"], LANE_DIR);
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(r.stdout, /\bintake\b/);
|
||||
assert.match(r.stdout, /\bdone\b/);
|
||||
@@ -719,17 +725,17 @@ describe("agenthub lanes node", () => {
|
||||
});
|
||||
|
||||
it("--print shows a slash-command node as a claude prompt, extra args appended", async () => {
|
||||
await cli(["lanes", "pipeline", "default"], LANE_DIR);
|
||||
const r = await cli(["lanes", "node", "plan", "the", "export", "button", "--print"], LANE_DIR);
|
||||
await cli(["workspace", "pipeline", "default"], LANE_DIR);
|
||||
const r = await cli(["workspace", "node", "plan", "the", "export", "button", "--print"], LANE_DIR);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /"claude" "\/superpowers:writing-plans the export button"/);
|
||||
});
|
||||
|
||||
it("--print shows a shell node split into argv, never a shell string", async () => {
|
||||
await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
|
||||
const r = await cli(["lanes", "node", "gates", "--print"], LANE_DIR);
|
||||
await cli(["workspace", "pipeline", "ship-feature"], LANE_DIR);
|
||||
const r = await cli(["workspace", "node", "gates", "--print"], LANE_DIR);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /"agenthub" "lanes" "hook" "ci-gate"/);
|
||||
assert.match(r.stdout, /"agenthub" "workspace" "hook" "ci-gate"/);
|
||||
});
|
||||
|
||||
it("every node of both shipped templates declares a runnable command", async () => {
|
||||
@@ -744,26 +750,26 @@ describe("agenthub lanes node", () => {
|
||||
it("matches a node by alias, so one slash command works on either template", async () => {
|
||||
// `default` calls the step `implement`, `ship-feature` calls it
|
||||
// `implementing` — /agenthub:implement must not depend on which one is active.
|
||||
await cli(["lanes", "pipeline", "ship-feature"], LANE_DIR);
|
||||
const ship = await cli(["lanes", "node", "implement", "--print"], LANE_DIR);
|
||||
await cli(["workspace", "pipeline", "ship-feature"], LANE_DIR);
|
||||
const ship = await cli(["workspace", "node", "implement", "--print"], LANE_DIR);
|
||||
assert.equal(ship.status, 0, ship.stderr);
|
||||
assert.match(ship.stdout, /test-driven-development/);
|
||||
|
||||
await cli(["lanes", "pipeline", "default"], LANE_DIR);
|
||||
const def = await cli(["lanes", "node", "implementing", "--print"], LANE_DIR);
|
||||
await cli(["workspace", "pipeline", "default"], LANE_DIR);
|
||||
const def = await cli(["workspace", "node", "implementing", "--print"], LANE_DIR);
|
||||
assert.equal(def.status, 0, def.stderr);
|
||||
assert.match(def.stdout, /test-driven-development/);
|
||||
});
|
||||
|
||||
it("names the pipeline's nodes when asked for one that is not in it", async () => {
|
||||
const r = await cli(["lanes", "node", "not-a-node", "--print"], LANE_DIR);
|
||||
const r = await cli(["workspace", "node", "not-a-node", "--print"], LANE_DIR);
|
||||
assert.notEqual(r.status, 0);
|
||||
assert.match(r.stderr, /no node "not-a-node"/);
|
||||
assert.match(r.stdout, /\bintake\b/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agenthub lanes ci status", () => {
|
||||
describe("agenthub workspace ci status", () => {
|
||||
let restorePath;
|
||||
let bin;
|
||||
|
||||
@@ -807,7 +813,7 @@ exit 1
|
||||
});
|
||||
|
||||
it("exits 0 and prints success state", async () => {
|
||||
const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR);
|
||||
const r = await cli(["workspace", "ci", "status", "development"], LANE_DIR);
|
||||
assert.equal(r.status, 0);
|
||||
assert.match(r.stdout, /"state": "success"/);
|
||||
});
|
||||
@@ -816,7 +822,7 @@ exit 1
|
||||
const orig = process.env.CI_STATUS_FIXTURE;
|
||||
try {
|
||||
process.env.CI_STATUS_FIXTURE = "failure";
|
||||
const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR, {
|
||||
const r = await cli(["workspace", "ci", "status", "development"], LANE_DIR, {
|
||||
CI_STATUS_FIXTURE: "failure",
|
||||
});
|
||||
assert.equal(r.status, 1);
|
||||
@@ -830,7 +836,7 @@ exit 1
|
||||
const orig = process.env.CI_STATUS_FIXTURE;
|
||||
try {
|
||||
process.env.CI_STATUS_FIXTURE = "pending";
|
||||
const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR, {
|
||||
const r = await cli(["workspace", "ci", "status", "development"], LANE_DIR, {
|
||||
CI_STATUS_FIXTURE: "pending",
|
||||
});
|
||||
assert.equal(r.status, 3);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user