chore: v2.8.21 — update progress steps, clone in background, extra SSH keys inline, today's spend

- Plugin update reports its running step (plugin-update-progress.json →
  plugin_update_progress); a hidden update or clone leaves a progress chip.
- Clone here can run in the background.
- Git identity: extra SSH keys are added inline per host (no More keys tab).
- Users: spend today (viewer's local day, ?tz_offset=).
- Prettier on the files touched since v2.8.20.
This commit is contained in:
2026-10-23 10:44:11 +07:00
parent 62b4520507
commit dbd24850a1
33 changed files with 906 additions and 393 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
# Claude Code Working Guide
_Last synced: v2.8.20 (2026-10-22) — a Cursor account pool (`CURSOR_API_KEY` via `-e`) and pool groups (a team runs only on its group's accounts); the pool card has a tab per tool._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
_Last synced: v2.8.21 (2026-10-23) — Claude Docs docs and session documents (.pptx/.docx/.xlsx/.pdf/.html) land in Artifacts, unowned rows are claimed; a plugin update reports its running step (`plugin_update_progress`); a hidden update or clone leaves a progress chip._ A change that makes any statement in this file, `AGENTS.md`, or a README wrong — a moved path, a new or changed extension point, a retired rule — updates that file in the same change-set and bumps this marker. **Bump = replace the version/date and a ≤ 1-line summary; never append a changelog here** — details belong in the commit message and `docs/`, because this whole file is loaded into every session.
## Project mission
- Maintain a reliable local-first dashboard for Claude Code session monitoring.
@@ -9,7 +9,7 @@ _Last synced: v2.8.20 (2026-10-22) — a Cursor account pool (`CURSOR_API_KEY` v
## Repo map
- `server/`: Express API, hook ingestion, SQLite access, websocket broadcast (includes release-based update detection — `lib/update-check.js` reads the repo's own releases API and `routes/updates.js` exposes status/check/apply, refusing to self-update a `~/.claude/plugins/cache/` install, plus `lib/workflow-ingest.js` which ingests on-disk Workflow-tool run journals — fleets that emit no hooks — and `lib/codex-ingest.js`, which mirrors a `codex` lane's running/idle from the mtime of Codex's own rollout journals (skipping a lane whose hooks are current), `lib/codex-usage.js`, which reads the same journals into `sessions` (`tool = 'codex'`) and `token_usage`, and `lib/codex-hooks.js`, which installs AgentHub's opt-in hooks into Codex's `hooks.json`, and their Antigravity counterparts `lib/antigravity-usage.js` (agy's conversation files → `tool = 'antigravity'` sessions and tokens, and its step log as their conversation view) and `lib/antigravity-hooks.js` (the named hook `agenthub` in `~/.gemini/config/hooks.json`), and `lib/cursor-usage.js` (Cursor's agent transcripts → `tool = 'cursor'` sessions and their conversation view, no tokens), and `lib/omp-usage.js`, `lib/opencode-usage.js` (omp's journals, OpenCode's `opencode.db` → `tool = 'omp'|'opencode'` sessions, tokens and subagent agents)).
- `client/`: React + Vite UI.
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` — through our own unit when this process runs in one (`systemctl restart --no-block`, launchd's KeepAlive; a detached replacement beside a `Restart=always` unit crash-loops it), else the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
- `scripts/`: hook installer/handler, import, seed, cleanup utilities. (Update detection lives server-side in `server/lib/update-check.js`, keyed on the forge's **newest release** rather than a commit count. `POST /api/updates/apply` has two modes and picks by install kind, never from the request: a fast-forwardable **checkout** merges the release tag `--ff-only`, rebuilds, and self-restarts via `server/lib/self-restart.js` — through our own unit when this process runs in one (`systemctl restart --no-block`, launchd's KeepAlive; a detached replacement beside a `Restart=always` unit crash-loops it), else the detached `scripts/restart-helper.js`; a **plugin-cache** install is handed to `server/lib/plugin-update.js` + the detached `scripts/plugin-update-helper.js`, which runs `claude plugin marketplace update` → `claude plugin update -y` → `plugin-bootstrap.js --force` from the NEW cache dir. Both write the HTTP response before anything stops the process, and every plugin-chain outcome is recorded to `plugin-update.json` in the runtime dir — outside the cache, so it survives the swap and the next server reports it as `last_plugin_update`. While it runs, the step it is on is `plugin-update-progress.json` → `plugin_update_progress` (the update modal's step line and the chip it leaves when hidden). Neither mode can run `/reload-plugins`: it is REPL-scoped to the session that invokes it, so an open Claude Code session still reloads by hand for new skills/commands. Elsewhere users run the printed manual command, surfaced in the UI and by `agenthub update-check`. `scripts/cc-update-all-helper.js` is the same detached pattern for the config panel's **Update all** button — marketplaces → every plugin → AgentHub LAST → one reload — journalling each step to `cc-update-all.json` in the runtime dir.)
- `skills/`: this repo's skills. Because the repo root **is** the `agenthub` plugin root, this is also the plugin's skills directory — every skill here ships to anyone who installs the plugin, addressed as `/agenthub:<name>` (bare `/<name>` works as an alias). Put a skill here only if it should reach plugin users. The plugin ships no subagents and no pipeline skill, and depends on no other plugin (Superpowers included) — do not add an auto-install of one back into the bootstrap.
- `mcp/`, `statusline/`, `monitoring/`: opt-in integrations that only READ the running dashboard over HTTP — the read-only MCP server (`agenthub mcp`, docs/MCP.md), the Claude Code statusline (`agenthub statusline install|uninstall`, docs/STATUSLINE.md) and Prometheus/Grafana files for `/api/metrics` (`docker-compose.monitoring.yml`, docs/MONITORING.md). Neither the MCP server nor the statusline is ever registered by the bootstrap; only the user's own command writes `statusLine`, never over a foreign one without `--force`, always with a backup. The MCP server and the statusline find the dashboard through `server/lib/dashboard-url.js`, the CLI's own discovery.
- `.claude-plugin/`: the marketplace plus the root `agenthub` plugin manifest (`"source": "./"` — the whole repo is the plugin). Its hooks are inline in `plugin.json`; its commands live in `plugins/agenthub/commands/`, which is NOT a subdirectory plugin. `scripts/plugin-bootstrap.js` runs from `SessionStart` and owns the writable runtime under `~/.claude/agenthub/runtime/` — it never writes into the plugin cache, which Claude Code replaces on every update. See `docs/PLUGINS.md`.
+62 -2
View File
@@ -19,7 +19,9 @@
* background process can do for it. Escape, the backdrop and × close it for
* this page load only — also while an update applies: it keeps running in the
* background (the page still reloads itself once the new server is up), and a
* failure re-opens the modal with its error; "Skip this version" is the one
* failure re-opens the modal with its error. While hidden, a small chip in the
* corner keeps showing the update — with the plugin chain's current step from
* `plugin_update_progress` — and a click on it shows the modal again; "Skip this version" is the one
* persistent, per-version dismiss (never while applying). After a failed update, "Auto fix" hands the
* error to an AI agent: it opens (or adopts) a workspace on the install folder
* and starts a Claude Code session there with the error as its prompt — the
@@ -184,6 +186,8 @@ export function UpdateNotifier() {
const [applying, setApplying] = useState(false);
const [restarting, setRestarting] = useState(false);
const [fixing, setFixing] = useState(false);
/** The running plugin chain's step, from the polls while it runs. */
const [progress, setProgress] = useState<UpdateStatusPayload["plugin_update_progress"]>(null);
const navigate = useNavigate();
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
@@ -312,6 +316,7 @@ export function UpdateNotifier() {
api.updates
.status()
.then((s) => {
setProgress(s.plugin_update_progress ?? null);
const v = s.last_plugin_update;
const mine = !!v?.finished_at && Date.parse(v.finished_at) >= since;
// A failed step, or nothing to pull, leaves the OLD server up: it
@@ -342,6 +347,7 @@ export function UpdateNotifier() {
const applyNow = async () => {
if (applying || restarting) return;
setError(null);
setProgress(null);
setApplying(true);
try {
const result = await api.updates.apply();
@@ -392,6 +398,53 @@ export function UpdateNotifier() {
}
};
const stepText = progress?.step
? t(`pluginStep.${progress.step}`, {
index: progress.index,
total: progress.total,
defaultValue: progress.step,
})
: null;
// Hidden while it runs: a chip keeps it in sight and brings the modal back.
if (!show && isAdmin && (applying || restarting) && dismissKey && closedKey === dismissKey) {
return (
<button
type="button"
data-testid="update-chip"
onClick={() => setClosedKey(null)}
title={t("chipHint")}
className="fixed bottom-24 right-4 z-[60] w-72 rounded-lg border border-accent/30 bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
>
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
<RefreshCw className="h-3.5 w-3.5 shrink-0 animate-spin text-accent" aria-hidden />
<span className="truncate">
{status?.latest_release
? t("chipTitle", { version: status.latest_release })
: t("updating")}
</span>
</span>
<span className="mt-1 block truncate text-[11px] text-fg-muted">
{stepText || (applying ? t("updating") : t("restartingShort"))}
</span>
{progress && progress.total > 0 ? (
<span
className="mt-1.5 block h-1 overflow-hidden rounded bg-surface-2"
role="progressbar"
aria-valuenow={progress.index}
aria-valuemin={0}
aria-valuemax={progress.total}
>
<span
className="block h-full bg-accent-solid transition-all"
style={{ width: `${(Math.max(progress.index - 1, 0) / progress.total) * 100}%` }}
/>
</span>
) : null}
</button>
);
}
if (!show || !status) return null;
const refLabel = status.remote_ref || "origin";
@@ -520,7 +573,14 @@ export function UpdateNotifier() {
{/* The plugin chain pulls a new cache directory and reinstalls
dependencies before it restarts, so it takes minutes, not
seconds — saying "restarting" there would look hung. */}
{pluginManaged ? t("pluginUpdating") : t("restarting")}
<span>
{pluginManaged ? t("pluginUpdating") : t("restarting")}
{stepText ? (
<span className="block mt-0.5 font-medium" data-testid="update-step">
{stepText}
</span>
) : null}
</span>
</div>
) : null}
@@ -312,6 +312,58 @@ describe("UpdateNotifier — closing vs skipping", () => {
});
});
describe("UpdateNotifier — hidden while it runs", () => {
it("keeps a chip with the chain's step in sight and re-opens from it", async () => {
const PLUGIN: UpdateStatusPayload = {
...BASE_STATUS,
situation: "plugin_managed",
install_kind: "plugin",
plugin_marketplace: "sg",
plugin_name: "agenthub",
commits_behind: 0,
local_version: "1.5.0",
release_check: "ok",
latest_release: "v1.6.0",
};
statusMock.mockResolvedValue(PLUGIN);
applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" });
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
await screen.findAllByText(i18n.t("updates:pluginUpdating"));
statusMock.mockResolvedValue({
...PLUGIN,
plugin_update_progress: {
step: "plugin_update",
index: 2,
total: 3,
started_at: new Date().toISOString(),
},
});
const step = i18n.t("updates:pluginStep.plugin_update", { index: 2, total: 3 });
expect(await screen.findByTestId("update-step", {}, { timeout: 4000 })).toHaveTextContent(step);
await userEvent.keyboard("{Escape}");
const chip = await screen.findByTestId("update-chip");
expect(chip).toHaveTextContent(i18n.t("updates:chipTitle", { version: "v1.6.0" }));
expect(chip).toHaveTextContent(step);
await userEvent.click(chip);
expect(
await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" }))
).toBeInTheDocument();
expect(screen.queryByTestId("update-chip")).toBeNull();
});
it("shows no chip when nothing is running", async () => {
render(<UpdateNotifier />);
await screen.findByText(i18n.t("updates:title"));
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument()
);
expect(screen.queryByTestId("update-chip")).toBeNull();
});
});
describe("UpdateNotifier — Auto fix", () => {
it("offers no Not now button, and Auto fix only once an update failed", async () => {
applyMock.mockRejectedValue(new Error("Command failed: git merge --ff-only v2.8.0"));
+78 -12
View File
@@ -7,6 +7,10 @@
* folder's account with that account's own SSH key / HTTPS login — and opens
* a workspace on the new folder. A 409 `ECREDENTIALS` opens a login row (sent
* once, optionally remembered), `ESSHAUTH` points to Settings → Git.
* A running clone shows `CloneProgress` and never holds the page: closing the
* dialog only hides it behind a small progress chip (click to reopen), the
* request keeps going, and its outcome arrives as a toast — a failure is kept
* for the next open so a login row is still there to fill in.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -18,6 +22,8 @@ import { api } from "../../lib/api";
import type { Lane } from "../../lib/types";
import { useDialog } from "../../hooks/useDialog";
import { cloneDirName } from "./AddLaneModal";
import { CloneProgress, newCloneToken } from "./CloneProgress";
import { toast } from "../../lib/toast";
type Login = { host: string; username: string; token: string; remember: boolean };
@@ -26,12 +32,15 @@ export function CloneHereDialog({
parent,
onClose,
onCloned,
onReopen,
}: {
open: boolean;
/** The absolute folder the clone goes into. */
parent: string;
onClose: () => void;
onCloned: (lane: Lane, path: string) => void;
/** Show the dialog again from the background chip. */
onReopen: () => void;
}) {
const { t } = useTranslation(["lanes"]);
const panelRef = useRef<HTMLDivElement>(null);
@@ -42,10 +51,23 @@ export function CloneHereDialog({
const [sshRefused, setSshRefused] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useDialog(open, panelRef, busy ? () => {} : onClose);
const [token, setToken] = useState<string | null>(null);
/** Where the running clone goes (the open folder may change meanwhile). */
const [target, setTarget] = useState("");
// Read by the request's own continuation and by the reset below.
const openRef = useRef(open);
openRef.current = open;
const busyRef = useRef(false);
/** A clone failed while hidden: the next open shows that, not a blank form. */
const keepRef = useRef(false);
useDialog(open, panelRef, onClose);
useEffect(() => {
if (!open) return;
if (!open || busyRef.current) return;
if (keepRef.current) {
keepRef.current = false;
return;
}
setUrl("");
setName("");
setLogin(null);
@@ -54,14 +76,39 @@ export function CloneHereDialog({
setError(null);
}, [open, parent]);
if (!open) return null;
const folder = name.trim() || cloneDirName(url);
const into = `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
const into = busy ? target : `${parent.replace(/\/+$/, "")}/${folder || "…"}`;
if (!open) {
if (!busy || !token) return null;
return createPortal(
<button
type="button"
data-testid="fx-clone-chip"
onClick={onReopen}
title={into}
className="fixed bottom-10 right-4 z-[60] w-72 space-y-1 rounded-lg border border-border bg-surface-1 p-2.5 text-left shadow-xl shadow-black/40 hover:bg-surface-2"
>
<span className="flex items-center gap-2 text-xs font-medium text-fg-primary">
<FolderGit2 className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">
{t("clone.cloning")} {into.split("/").pop()}
</span>
</span>
<CloneProgress token={token} />
</button>,
document.body
);
}
const submit = async () => {
const u = url.trim();
if (!u || busy) return;
const progressToken = newCloneToken();
busyRef.current = true;
setBusy(true);
setToken(progressToken);
setTarget(into);
setError(null);
setSshRefused(false);
try {
@@ -69,6 +116,7 @@ export function CloneHereDialog({
url: u,
parent,
name: name.trim() || undefined,
progressToken,
...(login?.username.trim() && login.token
? {
credentials: {
@@ -79,12 +127,16 @@ export function CloneHereDialog({
}
: {}),
});
busyRef.current = false;
setBusy(false);
setToken(null);
onCloned(result.lane, result.path);
} catch (err) {
const e = err as { code?: string; auth?: { host?: string }; message?: string };
const host = e.auth?.host || u;
let message: string;
if (e.code === "ECREDENTIALS") {
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
message = t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host });
setLogin((l) => ({
host,
username: l?.username ?? "",
@@ -93,18 +145,25 @@ export function CloneHereDialog({
}));
} else if (e.code === "ESSHAUTH") {
setSshRefused(true);
setError(t("clone.sshRefused", { host }));
message = t("clone.sshRefused", { host });
} else {
setError(err instanceof Error ? err.message : String(err));
message = err instanceof Error ? err.message : String(err);
}
setError(message);
busyRef.current = false;
setBusy(false);
setToken(null);
if (!openRef.current) {
keepRef.current = true;
toast.error(message);
}
}
};
return createPortal(
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
onClick={busy ? undefined : onClose}
onClick={onClose}
// A portal still bubbles through React: keep keys away from the File
// Explorer's shortcuts (Backspace = up a folder, Delete = delete).
onKeyDown={(e) => e.stopPropagation()}
@@ -119,7 +178,10 @@ export function CloneHereDialog({
className="w-full max-w-lg rounded-xl border border-border bg-surface-1 p-4 shadow-2xl shadow-black/50"
onClick={(e) => e.stopPropagation()}
>
<h2 id={titleId} className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary">
<h2
id={titleId}
className="mb-3 flex items-center gap-2 text-sm font-semibold text-fg-primary"
>
<FolderGit2 className="h-4 w-4" />
{t("fx.cloneRepo")}
</h2>
@@ -162,7 +224,10 @@ export function CloneHereDialog({
</p>
</div>
{login && (
<div data-testid="fx-clone-login" className="space-y-2 rounded-md border border-border p-2">
<div
data-testid="fx-clone-login"
className="space-y-2 rounded-md border border-border p-2"
>
<input
aria-label={t("clone.username")}
placeholder={t("clone.username")}
@@ -193,6 +258,7 @@ export function CloneHereDialog({
</label>
</div>
)}
{busy && token && <CloneProgress token={token} />}
{error && (
<p role="alert" className="text-xs text-status-danger">
{error}{" "}
@@ -204,8 +270,8 @@ export function CloneHereDialog({
</p>
)}
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} disabled={busy} className="btn-ghost text-xs">
{t("fx.cancel")}
<button type="button" onClick={onClose} className="btn-ghost text-xs">
{busy ? t("clone.background") : t("fx.cancel")}
</button>
<button
type="submit"
@@ -974,6 +974,7 @@ export function FileBrowser({
open={cloning}
parent={cloneParent}
onClose={() => setCloning(false)}
onReopen={() => setCloning(true)}
onCloned={(lane, at) => {
setCloning(false);
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
@@ -30,6 +30,7 @@ vi.mock("../../../lib/api", () => ({
lanes: {
list: vi.fn(),
clone: vi.fn(),
cloneProgress: vi.fn(),
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
},
files: {
@@ -553,12 +554,72 @@ describe("FileExplorer — New → Clone repository", () => {
url: "git@host:o/repo.git",
parent: "/w/one/src",
name: undefined,
progressToken: expect.stringMatching(/^[0-9a-f]{32}$/),
})
);
expect(toast.success).toHaveBeenCalled();
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
});
it("shows progress and lets the page go on while the clone runs", async () => {
let finish: (v: unknown) => void = () => {};
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((r) => (finish = r)) as never);
vi.mocked(api.lanes.cloneProgress).mockResolvedValue({
state: "running",
position: null,
running: 1,
waiting: 0,
limit: 2,
phase: "receiving",
label: null,
percent: 45,
detail: "450/1000",
} as never);
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "git@host:o/repo.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
expect(await within(dialog).findByRole("progressbar")).toHaveAttribute("aria-valuenow", "45");
await user.click(within(dialog).getByRole("button", { name: "Run in background" }));
expect(screen.queryByTestId("fx-clone-dialog")).toBeNull();
const chip = await screen.findByTestId("fx-clone-chip");
expect(within(chip).getByText(/repo/)).toBeInTheDocument();
await user.click(chip);
expect(screen.getByTestId("fx-clone-dialog")).toBeInTheDocument();
await user.keyboard("{Escape}");
finish({
lane: { id: 9, title: "repo", cwd: "/w/one/repo" },
created: true,
path: "/w/one/repo",
});
await waitFor(() => expect(toast.success).toHaveBeenCalled());
expect(screen.queryByTestId("fx-clone-chip")).toBeNull();
});
it("toasts a failure that lands while hidden and keeps it for the next open", async () => {
let fail: (e: unknown) => void = () => {};
vi.mocked(api.lanes.clone).mockReturnValue(new Promise((_, r) => (fail = r)) as never);
vi.mocked(api.lanes.cloneProgress).mockRejectedValue(new Error("404"));
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const dialog = await openClone(user);
await user.type(within(dialog).getByLabelText("Repository URL"), "https://git.example/o/r.git");
await user.click(within(dialog).getByTestId("fx-clone-submit"));
await user.keyboard("{Escape}");
fail(
Object.assign(new Error("login"), { code: "ECREDENTIALS", auth: { host: "git.example" } })
);
await waitFor(() => expect(toast.error).toHaveBeenCalled());
const again = await openClone(user);
expect(within(again).getByTestId("fx-clone-login")).toBeInTheDocument();
expect(within(again).getByLabelText("Repository URL")).toHaveValue(
"https://git.example/o/r.git"
);
});
it("asks for a login on ECREDENTIALS and sends it once", async () => {
vi.mocked(api.lanes.clone)
.mockRejectedValueOnce(
@@ -14,7 +14,9 @@
* SEVERAL accounts (two AWS accounts, a second GitHub user) named keys made
* beside the default ones plus a login per account NAME (`ssh://<name>/…`),
* each tied to the one key it uses (the SSH part is split into tabs: one per
* kind of host, the extra keys, the other hosts) — and, for
* kind of host, then the other hosts; a named key is a card of its own under
* its type's default one, with the same steps, and "Add another key" makes
* the next one below) — and, for
* a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials
* table above it, so this lists them only for a member. The private key and
@@ -24,17 +26,7 @@
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Cloud,
Copy,
GitBranch,
KeyRound,
KeySquare,
Plus,
RefreshCw,
Server,
Trash2,
} from "lucide-react";
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
import { api, type GitIdentity } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
@@ -96,17 +88,19 @@ function Code({ children }: { children: ReactNode }) {
}
type Key = NonNullable<GitIdentity["sshKeys"]>[number];
type SshUser = NonNullable<GitIdentity["sshUsers"]>[number];
/**
* One kind of git host and the key it takes: no key → a Generate button;
* a key → step 1 (copy its public half) followed by the host's own steps.
* One SSH key and the steps of the host it is for: no key → a Generate
* button; a key → step 1 (copy its public half) followed by the host's own
* steps. The default key of a type and every named one share it.
*/
function KeyCard({
title,
sub,
type,
label,
k,
id,
writable,
busy,
onGenerate,
onRemove,
@@ -114,12 +108,13 @@ function KeyCard({
}: {
title: string;
sub: string;
type: "ed25519" | "rsa";
/** What names the public key field ("RSA", "ED25519" or the key's name). */
label: string;
k: Key | undefined;
id: GitIdentity;
writable: boolean;
busy: boolean;
onGenerate: (type: "ed25519" | "rsa") => Promise<void>;
onRemove: (type: "ed25519" | "rsa") => Promise<void>;
onGenerate: () => Promise<void>;
onRemove: () => Promise<void>;
children: ReactNode;
}) {
const { t } = useTranslation("settings");
@@ -130,23 +125,19 @@ function KeyCard({
<div className="text-xs font-medium text-fg-primary">{title}</div>
<div className="text-[10px] text-fg-muted">{sub}</div>
</div>
{id.writable && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => void onGenerate(type)}
>
{writable && (
<button type="button" className={BTN} disabled={busy} onClick={() => void onGenerate()}>
{k ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{k ? t("gitIdentity.replace") : t("gitIdentity.generate")}
</button>
)}
{k && id.writable && (
{k && writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
onClick={() => void onRemove(type)}
aria-label={k.name ? t("gitIdentity.removeKeyNamed", { name: k.name }) : undefined}
onClick={() => void onRemove()}
>
<Trash2 className="h-3.5 w-3.5" />
{t("gitIdentity.remove")}
@@ -165,9 +156,9 @@ function KeyCard({
</div>
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${type === "rsa" ? "RSA" : "ED25519"}`}
aria-label={`${t("gitIdentity.publicKey")} ${label}`}
value={k.publicKey}
rows={type === "rsa" ? 3 : 2}
rows={k.type === "rsa" ? 3 : 2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} mt-1 resize-none break-all font-mono text-[10px]`}
/>
@@ -182,6 +173,104 @@ function KeyCard({
);
}
/**
* Step 3 of an AWS key: the SSH key ID AWS shows for it, in one region. The
* default key's login is the region's plain host; a named key's is saved
* under an account name (its own name unless another is typed), tied to it.
*/
function AwsLoginForm({
busy,
named,
onSave,
}: {
busy: boolean;
/** The named key's name; undefined for the default key. */
named?: string;
onSave: (v: { keyId: string; region: string; alias: string }) => Promise<boolean>;
}) {
const { t } = useTranslation("settings");
const [v, setV] = useState({ keyId: "", region: "ap-southeast-1", alias: "" });
return (
<form
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void onSave(v).then((ok) => ok && setV({ ...v, keyId: "", alias: "" }));
}}
>
<input
aria-label={t("gitIdentity.awsKeyId")}
placeholder="APKA…"
autoComplete="off"
value={v.keyId}
onChange={(e) => setV({ ...v, keyId: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.awsRegion")}
value={v.region}
onChange={(e) => setV({ ...v, region: e.target.value })}
className="select !py-1 !text-xs"
>
{AWS_REGIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
aria-label={t("gitIdentity.alias")}
title={t("gitIdentity.aliasHint")}
placeholder={named ?? t("gitIdentity.aliasPlaceholder")}
autoComplete="off"
value={v.alias}
onChange={(e) => setV({ ...v, alias: e.target.value })}
className={`${INPUT} font-mono`}
/>
<button type="submit" className={BTN} disabled={busy || !v.keyId.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
);
}
/**
* Step 3 of a named GitHub/GitLab key: the host it is for. Saved as the login
* `ssh://<key name>/` → `git@<host>`, tied to this key, so a clone through it
* offers only this key — the default key would log in as the other account.
*/
function GitHostForm({
busy,
onSave,
}: {
busy: boolean;
onSave: (host: string) => Promise<boolean>;
}) {
const { t } = useTranslation("settings");
const [host, setHost] = useState("github.com");
return (
<form
className="mt-1 flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void onSave(host.trim());
}}
>
<input
aria-label={t("gitIdentity.otherHost")}
placeholder="github.com"
autoComplete="off"
value={host}
onChange={(e) => setHost(e.target.value)}
className={`${INPUT} font-mono`}
/>
<button type="submit" className={BTN} disabled={busy || !host.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
);
}
export function GitIdentitySettings() {
const { t } = useTranslation("settings");
const [id, setId] = useState<GitIdentity | null>(null);
@@ -229,19 +318,42 @@ export function GitIdentitySettings() {
const keyOf = (type: "ed25519" | "rsa") =>
keys.find((k) => (k.type ?? "ed25519") === type && !k.name);
const named = keys.filter((k) => k.name);
const namedOf = (type: "ed25519" | "rsa") => named.filter((k) => (k.type ?? "ed25519") === type);
const keyLabel = (keyId: string | null | undefined) => {
const k = keyId ? keys.find((x) => x.id === keyId) : undefined;
if (!k) return t("gitIdentity.anyKey");
return k.name ?? (k.type === "rsa" ? t("gitIdentity.defaultRsa") : t("gitIdentity.defaultEd"));
};
const [newKey, setNewKey] = useState<{ name: string; type: "ed25519" | "rsa" } | null>(null);
const makeNamed = async () => {
if (!newKey) return;
const ok = await run(
() => api.gitIdentity.generateKey(false, newKey.type, newKey.name.trim().toLowerCase()),
// "Add another key" in a tab makes the next key of that tab's type at once,
// named `aws-2`, `aws-3`… / `git-2`… — the key is what step 1 needs, and an
// account name of the user's own goes in its step 3.
const makeNamed = (type: "ed25519" | "rsa") => {
const prefix = type === "rsa" ? "aws" : "git";
let n = 2;
while (keys.some((k) => k.name === `${prefix}-${n}`)) n++;
return run(
() => api.gitIdentity.generateKey(false, type, `${prefix}-${n}`),
t("gitIdentity.keyMade")
);
};
const addKey = (type: "ed25519" | "rsa") => (
<button type="button" className={BTN} disabled={busy} onClick={() => void makeNamed(type)}>
<Plus className="h-3.5 w-3.5" />
{t("gitIdentity.addKey")}
</button>
);
const replaceNamed = async (k: Key) => {
if (
!(await confirmDialog({
title: t("gitIdentity.replaceConfirm"),
confirmLabel: t("gitIdentity.replace"),
}))
)
return;
await run(
() => api.gitIdentity.generateKey(true, k.type ?? "ed25519", k.name ?? undefined),
t("gitIdentity.keyMade")
);
if (ok) setNewKey(null);
};
const removeNamed = async (keyId: string) => {
if (
@@ -304,29 +416,60 @@ export function GitIdentitySettings() {
return m ? [{ ...r, region: m[1] ?? "" }] : [];
});
const otherRows = sshUsers.filter((r) => !CODECOMMIT_RE.test(r.host));
const [aws, setAws] = useState({ keyId: "", region: "ap-southeast-1", alias: "", key: "" });
const saveAws = async () => {
let user = aws.keyId.trim();
let host = `git-codecommit.${aws.region}.amazonaws.com`;
// A login is shown under the key it names; one naming no key (or one gone)
// under the default key.
const namedIds = new Set(named.map((k) => k.id));
const rowsOf = <R extends { key?: string | null }>(rows: R[], k?: Key) =>
k ? rows.filter((r) => r.key === k.id) : rows.filter((r) => !r.key || !namedIds.has(r.key));
const saveAws = (v: { keyId: string; region: string; alias: string }, k?: Key) => {
let user = v.keyId.trim();
let host = `git-codecommit.${v.region}.amazonaws.com`;
// A pasted `ssh://APKA…@git-codecommit.<region>…/…` URL fills both.
const m = /^ssh:\/\/([^@/]+)@([^/]+)/i.exec(user);
if (m?.[1] && m[2]) {
user = m[1];
host = m[2].toLowerCase();
}
const alias = aws.alias.trim().toLowerCase();
const ok = await run(
// A named key's account goes by the key's name unless another is typed.
const alias = v.alias.trim().toLowerCase() || (k?.name ?? "");
return run(
() =>
api.gitIdentity.setSshUser({
host,
user,
...(alias ? { alias } : {}),
...(aws.key ? { key: aws.key } : {}),
...(k?.id ? { key: k.id } : {}),
}),
t("gitIdentity.awsSaved", { region: alias || (CODECOMMIT_RE.exec(host)?.[1] ?? host) })
);
if (ok) setAws({ ...aws, keyId: "", alias: "" });
};
const saveGitHost = (host: string, k: Key) => {
const h = /^(?:ssh:\/\/)?(?:[^@/]+@)?([^/:]+)/i.exec(host)?.[1]?.toLowerCase() ?? host;
return run(
() => api.gitIdentity.setSshUser({ host: h, user: "git", alias: k.name!, key: k.id! }),
t("gitIdentity.otherSaved", { host: k.name })
);
};
/** The logins of one key, each with its delete button. */
const loginRows = <R extends SshUser>(rows: R[], text: (r: R) => string) =>
rows.map((r) => (
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{text(r)}
</span>
{id?.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
onClick={() => void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
));
const [other, setOther] = useState({ host: "", user: "", alias: "", key: "" });
const saveOther = async () => {
let host = other.host.trim();
@@ -346,7 +489,7 @@ export function GitIdentitySettings() {
if (ok) setOther({ host: "", user: "", alias: "", key: "" });
};
/** The key picker of a login form: every key, or one. */
const keySelect = (value: string, onChange: (v: string) => void, only?: "rsa") => (
const keySelect = (value: string, onChange: (v: string) => void) => (
<select
aria-label={t("gitIdentity.loginKey")}
value={value}
@@ -355,7 +498,7 @@ export function GitIdentitySettings() {
>
<option value="">{t("gitIdentity.anyKey")}</option>
{keys
.filter((k) => k.id && (!only || k.type === only))
.filter((k) => k.id)
.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k.id)}
@@ -396,17 +539,6 @@ export function GitIdentitySettings() {
const sshTabs = [
{ id: "git", label: t("gitIdentity.tabGit"), Icon: GitBranch },
{ id: "aws", label: t("gitIdentity.awsTitle"), Icon: Cloud },
...(named.length > 0 || id.writable
? [
{
id: "more",
label: named.length
? `${t("gitIdentity.tabMore")} (${named.length})`
: t("gitIdentity.tabMore"),
Icon: KeySquare,
},
]
: []),
...(id.sshUsers !== undefined && (otherRows.length > 0 || id.writable)
? [{ id: "other", label: t("gitIdentity.tabOther"), Icon: Server }]
: []),
@@ -498,228 +630,116 @@ export function GitIdentitySettings() {
onSelect={setSshTab}
>
{activeSshTab === "git" && (
<KeyCard
title={t("gitIdentity.gitHubTitle")}
sub={t("gitIdentity.gitHubSub")}
type="ed25519"
k={keyOf("ed25519")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.gitHubStepClone")}
<Code>git clone git@github.com:&lt;org&gt;/&lt;repo&gt;.git</Code>
</Step>
</KeyCard>
<div className="space-y-2">
<KeyCard
title={t("gitIdentity.gitHubTitle")}
sub={t("gitIdentity.gitHubSub")}
label="ED25519"
k={keyOf("ed25519")}
writable={id.writable}
busy={busy}
onGenerate={() => generate("ed25519")}
onRemove={() => removeKey("ed25519")}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.gitHubStepClone")}
<Code>git clone git@github.com:&lt;org&gt;/&lt;repo&gt;.git</Code>
</Step>
</KeyCard>
{namedOf("ed25519").map((k) => {
const rows = rowsOf(otherRows, k);
return (
<KeyCard
key={k.id}
title={k.name!}
sub={t("gitIdentity.namedGitSub")}
label={k.name!}
k={k}
writable={id.writable}
busy={busy}
onGenerate={() => replaceNamed(k)}
onRemove={() => removeNamed(k.id!)}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
<Step n={3}>
{t("gitIdentity.namedGitStepHost", { name: k.name })}
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
{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}/&lt;org&gt;/&lt;repo&gt;.git
</Code>
))}
</Step>
</KeyCard>
);
})}
{id.writable &&
keys.some((k) => (k.type ?? "ed25519") === "ed25519") &&
addKey("ed25519")}
</div>
)}
{activeSshTab === "aws" && (
<KeyCard
title={t("gitIdentity.awsTitle")}
sub={t("gitIdentity.awsSub")}
type="rsa"
k={keyOf("rsa")}
id={id}
busy={busy}
onGenerate={generate}
onRemove={removeKey}
>
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
<Step n={3}>
{t("gitIdentity.awsStepKeyId")}
{id.sshUsers === undefined ? null : (
<>
{awsRows.map((r) => (
<div key={r.alias ?? r.host} className="mt-1 flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono text-[11px] text-fg-secondary">
{r.alias ? `${r.alias} · ` : ""}
{r.region} · {r.user} · {keyLabel(r.key)}
</span>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${r.alias ?? r.host}`}
onClick={() =>
void run(() => api.gitIdentity.removeSshUser(r.alias ?? r.host))
}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
<div className="space-y-2">
{[keyOf("rsa"), ...namedOf("rsa")].map((k, i) => {
const isNamed = i > 0 && !!k;
const rows = rowsOf(awsRows, isNamed ? k : undefined);
return (
<KeyCard
key={k?.id ?? "id_rsa"}
title={isNamed ? k!.name! : t("gitIdentity.awsTitle")}
sub={isNamed ? t("gitIdentity.namedAwsSub") : t("gitIdentity.awsSub")}
label={isNamed ? k!.name! : "RSA"}
k={k}
writable={id.writable}
busy={busy}
onGenerate={() => (isNamed ? replaceNamed(k!) : generate("rsa"))}
onRemove={() => (isNamed ? removeNamed(k!.id!) : removeKey("rsa"))}
>
<Step n={2}>{t("gitIdentity.awsStepUpload")}</Step>
<Step n={3}>
{t("gitIdentity.awsStepKeyId")}
{id.sshUsers === undefined ? null : (
<>
{loginRows(
rows,
(r) =>
`${r.alias ? `${r.alias} · ` : ""}${r.region} · ${r.user} · ${keyLabel(r.key)}`
)}
{id.writable && (
<AwsLoginForm
busy={busy}
named={isNamed ? k!.name! : undefined}
onSave={(v) => saveAws(v, isNamed ? k : undefined)}
/>
)}
</>
)}
</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/&lt;repo&gt;
</Code>
))}
</div>
))}
{id.writable && (
<form
className="mt-1 grid gap-2 sm:grid-cols-[2fr_1fr_1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault();
void saveAws();
}}
>
<input
aria-label={t("gitIdentity.awsKeyId")}
placeholder="APKA…"
autoComplete="off"
value={aws.keyId}
onChange={(e) => setAws({ ...aws, keyId: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.awsRegion")}
value={aws.region}
onChange={(e) => setAws({ ...aws, region: e.target.value })}
className="select !py-1 !text-xs"
>
{AWS_REGIONS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
aria-label={t("gitIdentity.alias")}
title={t("gitIdentity.aliasHint")}
placeholder={t("gitIdentity.aliasPlaceholder")}
autoComplete="off"
value={aws.alias}
onChange={(e) => setAws({ ...aws, alias: e.target.value })}
className={`${INPUT} font-mono`}
/>
{keySelect(aws.key, (key) => setAws({ ...aws, key }), "rsa")}
<button type="submit" className={BTN} disabled={busy || !aws.keyId.trim()}>
{t("gitIdentity.awsSave")}
</button>
</form>
)}
<div className="mt-1 text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
</>
)}
</Step>
{awsRows.length > 0 && (
<div className="rounded border border-status-success/40 bg-status-success/10 p-2 text-[11px] text-fg-primary">
{t("gitIdentity.awsReady")}
{awsRows.map((r) => (
<Code key={r.alias ?? r.host}>
git clone ssh://{r.alias ?? r.host}/v1/repos/&lt;repo&gt;
</Code>
))}
</div>
</KeyCard>
);
})}
{id.writable && keys.some((k) => k.type === "rsa") && (
<>
{addKey("rsa")}
<div className="text-[10px] text-fg-muted">{t("gitIdentity.awsMany")}</div>
</>
)}
</KeyCard>
)}
{activeSshTab === "more" && (
<section
aria-label={t("gitIdentity.moreKeysTitle")}
className="space-y-2 rounded border border-border p-2.5"
>
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<div className="text-xs font-medium text-fg-primary">
{t("gitIdentity.moreKeysTitle")}
</div>
<div className="text-[10px] text-fg-muted">{t("gitIdentity.moreKeysSub")}</div>
</div>
{id.writable && !newKey && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => setNewKey({ name: "", type: "rsa" })}
>
<Plus className="h-3.5 w-3.5" />
{t("gitIdentity.newKey")}
</button>
)}
</div>
{newKey && (
<form
className="grid gap-2 sm:grid-cols-[2fr_1fr_auto_auto]"
onSubmit={(e) => {
e.preventDefault();
void makeNamed();
}}
>
<input
aria-label={t("gitIdentity.keyName")}
placeholder={t("gitIdentity.keyNamePlaceholder")}
autoComplete="off"
autoFocus
value={newKey.name}
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
className={`${INPUT} font-mono`}
/>
<select
aria-label={t("gitIdentity.keyType")}
value={newKey.type}
onChange={(e) =>
setNewKey({ ...newKey, type: e.target.value === "rsa" ? "rsa" : "ed25519" })
}
className="select !py-1 !text-xs"
>
<option value="rsa">RSA 4096 (AWS)</option>
<option value="ed25519">ED25519</option>
</select>
<button type="submit" className={BTN} disabled={busy || !newKey.name.trim()}>
{t("gitIdentity.generate")}
</button>
<button type="button" className={BTN} onClick={() => setNewKey(null)}>
{t("gitCreds.cancel")}
</button>
</form>
)}
{named.map((k) => (
<div
key={k.id}
className="space-y-1 rounded border border-border-light p-2"
data-testid="git-named-key"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[11px] font-medium text-fg-primary">
{k.name}
</span>
<span className="rounded bg-surface-3 px-1.5 py-0.5 text-[10px] text-fg-secondary">
{k.type === "rsa" ? "RSA" : "ED25519"}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
{k.fingerprint}
</span>
<button
type="button"
className={BTN}
onClick={() => void copyText(k.publicKey)}
>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
{id.writable && (
<button
type="button"
className={`${BTN} hover:text-status-danger`}
disabled={busy}
aria-label={`${t("gitIdentity.remove")} ${k.name}`}
onClick={() => void removeNamed(k.id!)}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<textarea
readOnly
aria-label={`${t("gitIdentity.publicKey")} ${k.name}`}
value={k.publicKey}
rows={2}
onFocus={(e) => e.currentTarget.select()}
className={`${INPUT} resize-none break-all font-mono text-[10px]`}
/>
</div>
))}
</section>
</div>
)}
{activeSshTab === "other" && (
@@ -5,9 +5,9 @@
* view leaves HTTPS to the shared Git credentials table, and an SSH user per
* host is saved — AWS CodeCommit's key ID inside its own RSA section, any
* other host under "Other SSH hosts" — each kind of host in its own tab, so
* the sections never mix; a named key
* is made beside the default ones, and a second AWS account is saved under
* its own name with the one key it uses.
* the sections never mix; "Add another key" makes a named key as one more
* card in its own tab, a second AWS account is saved from that card under
* the key's name with that key, and a second GitHub key is tied to its host.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -216,9 +216,9 @@ describe("GitIdentitySettings", () => {
fingerprint: "SHA256:r",
};
const PROD = {
id: "id_rsa_aws-prod",
id: "id_rsa_aws-2",
type: "rsa" as const,
name: "aws-prod",
name: "aws-2",
publicKey: "ssh-rsa B prod",
fingerprint: "SHA256:p",
};
@@ -230,45 +230,78 @@ describe("GitIdentitySettings", () => {
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({
sshKeys: [RSA, PROD],
sshUsers: [{ host: H, user: "APKA2", alias: "aws-prod", key: "id_rsa_aws-prod" }],
sshUsers: [{ host: H, user: "APKA2", alias: "aws-2", key: "id_rsa_aws-2" }],
})
);
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
view({ sshKeys: [RSA], sshUsers: [] })
);
render(<GitIdentitySettings />);
// Only an RSA key: the AWS tab opens first.
// Only an RSA key: the AWS tab opens first; the new key is made inside it.
expect(await screen.findByRole("region", { name: "AWS CodeCommit" })).toBeInTheDocument();
await tab("More keys");
let more = screen.getByRole("region", { name: "More SSH keys" });
await userEvent.click(within(more).getByRole("button", { name: "New key" }));
await userEvent.type(within(more).getByLabelText("Key name"), "AWS-Prod");
await userEvent.click(within(more).getByRole("button", { name: "Generate key" }));
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-prod");
expect(await within(more).findByLabelText("Public key aws-prod")).toHaveValue("ssh-rsa B prod");
expect(screen.queryByRole("tab", { name: /More keys/ })).toBeNull();
// One click makes the key — nothing to type before it.
await userEvent.click(screen.getByRole("button", { name: "Add another key" }));
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "rsa", "aws-2");
const prod = await screen.findByRole("region", { name: "aws-2" });
expect(within(prod).getByLabelText("Public key aws-2")).toHaveValue("ssh-rsa B prod");
// Another key can be added below it.
expect(screen.getByRole("button", { name: "Add another key" })).toBeInTheDocument();
await tab("AWS CodeCommit");
const aws = screen.getByRole("region", { name: "AWS CodeCommit" });
await userEvent.type(within(aws).getByLabelText("SSH key ID"), "APKA2");
await userEvent.type(within(aws).getByLabelText("Account name"), "aws-prod");
await userEvent.selectOptions(within(aws).getByLabelText("Key"), "id_rsa_aws-prod");
await userEvent.click(within(aws).getByRole("button", { name: "Save" }));
// Its own step 3: the account name defaults to the key's name.
await userEvent.type(within(prod).getByLabelText("SSH key ID"), "APKA2");
await userEvent.click(within(prod).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: H,
user: "APKA2",
alias: "aws-prod",
key: "id_rsa_aws-prod",
alias: "aws-2",
key: "id_rsa_aws-2",
});
expect(
await within(aws).findByText("aws-prod · ap-southeast-1 · APKA2 · aws-prod")
await within(prod).findByText("aws-2 · ap-southeast-1 · APKA2 · aws-2")
).toBeInTheDocument();
expect(within(aws).getByText("git clone ssh://aws-prod/v1/repos/<repo>")).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();
await tab("More keys (1)");
more = screen.getByRole("region", { name: "More SSH keys" });
await userEvent.click(within(more).getByRole("button", { name: "Delete aws-prod" }));
await waitFor(() =>
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-prod")
await userEvent.click(within(prod).getByRole("button", { name: "Delete key aws-2" }));
await waitFor(() => expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith("id_rsa_aws-2"));
});
it("adds a second GitHub key in its tab and ties it to its host", async () => {
const ED = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
const WORK = {
id: "id_ed25519_git-2",
type: "ed25519" as const,
name: "git-2",
publicKey: "ssh-ed25519 W work",
fingerprint: "SHA256:w",
};
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ sshKeys: [ED], sshUsers: [] }));
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(
view({ sshKeys: [ED, WORK], sshUsers: [] })
);
vi.mocked(api.gitIdentity.setSshUser).mockResolvedValue(
view({
sshKeys: [ED, WORK],
sshUsers: [{ host: "github.com", user: "git", alias: "git-2", key: "id_ed25519_git-2" }],
})
);
render(<GitIdentitySettings />);
await userEvent.click(await screen.findByRole("button", { name: "Add another key" }));
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false, "ed25519", "git-2");
const work = await screen.findByRole("region", { name: "git-2" });
expect(within(work).getByLabelText("Public key git-2")).toHaveValue("ssh-ed25519 W work");
await userEvent.click(within(work).getByRole("button", { name: "Save" }));
expect(api.gitIdentity.setSshUser).toHaveBeenCalledWith({
host: "github.com",
user: "git",
alias: "git-2",
key: "id_ed25519_git-2",
});
expect(
await within(work).findByText("git clone ssh://git-2/<org>/<repo>.git")
).toBeInTheDocument();
});
});
+1
View File
@@ -213,6 +213,7 @@
"clone.parentHint": "The repository is cloned to {{path}}",
"clone.nameLabel": "Folder name",
"clone.cloning": "Cloning…",
"clone.background": "Run in background",
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
"clone.loginRejected": "{{host}} rejected that username or token.",
"clone.username": "Username",
+7 -9
View File
@@ -908,16 +908,14 @@
"alias": "Account name",
"aliasPlaceholder": "name (optional)",
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
"awsMany": "Several AWS accounts? Make one more RSA key in the More keys tab for each, upload it to that account, then save its SSH key ID here with an account name and that key. Clone each with ssh://<account name>/v1/repos/<repo>.",
"moreKeysTitle": "More SSH keys",
"moreKeysSub": "One per extra account — another AWS account, a second GitHub user. A login that names its key uses only that key.",
"newKey": "New key",
"keyName": "Key name",
"keyNamePlaceholder": "e.g. aws-prod",
"keyType": "Key type",
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3 (the account name is the key’s unless you type one). Clone each with ssh://<account name>/v1/repos/<repo>.",
"tabGit": "GitHub · GitLab",
"tabMore": "More keys",
"tabOther": "Other hosts"
"tabOther": "Other hosts",
"addKey": "Add another key",
"namedGitSub": "ED25519 · for one more GitHub/GitLab account.",
"namedGitStepHost": "Tie it to its host, so a clone through ssh://{{name}}/… offers only this key (git@host would log in with the default key’s account):",
"namedAwsSub": "RSA · for one more AWS account.",
"removeKeyNamed": "Delete key {{name}}"
},
"gitCreds": {
"title": "Git credentials",
+6 -1
View File
@@ -36,5 +36,10 @@
"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."
"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",
"pluginStep.plugin_update": "Step {{index}}/{{total}}: downloading the new version",
"pluginStep.bootstrap": "Step {{index}}/{{total}}: installing it and restarting the dashboard"
}
+1
View File
@@ -5,6 +5,7 @@
"role": "Role",
"workspaces": "Workspaces",
"sessions": "Sessions",
"today": "Today",
"last7d": "7 days",
"last30d": "30 days",
"storage": "Storage",
+1
View File
@@ -213,6 +213,7 @@
"clone.parentHint": "Repository sẽ được clone vào {{path}}",
"clone.nameLabel": "Tên thư mục",
"clone.cloning": "Đang clone…",
"clone.background": "Chạy nền",
"clone.loginNeeded": "{{host}} cần đăng nhập: username và mật khẩu hoặc access token.",
"clone.loginRejected": "{{host}} từ chối username hoặc token này.",
"clone.username": "Username",
+7 -9
View File
@@ -896,16 +896,14 @@
"alias": "Tên tài khoản",
"aliasPlaceholder": "tên (tuỳ chọn)",
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
"awsMany": "Nhiều tài khoản AWS? Tạo thêm một RSA key ở tab Key khác cho mỗi tài khoản, upload lên tài khoản đó, rồi lưu SSH key ID ở đây kèm tên tài khoản và key đó. Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
"moreKeysTitle": "Thêm SSH key",
"moreKeysSub": "Mỗi key cho một tài khoản phụ — tài khoản AWS khác, user GitHub thứ hai. Tài khoản nào gắn key thì chỉ dùng đúng key đó.",
"newKey": "Key mới",
"keyName": "Tên key",
"keyNamePlaceholder": "vd aws-prod",
"keyType": "Loại key",
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó (tên tài khoản lấy theo tên key nếu bạn không nhập). Clone từng cái bằng ssh://<tên tài khoản>/v1/repos/<repo>.",
"tabGit": "GitHub · GitLab",
"tabMore": "Key khác",
"tabOther": "Host khác"
"tabOther": "Host khác",
"addKey": "Thêm key khác",
"namedGitSub": "ED25519 · cho thêm một tài khoản GitHub/GitLab.",
"namedGitStepHost": "Gắn key với host của nó, để clone qua ssh://{{name}}/… chỉ dùng đúng key này (git@host sẽ đăng nhập bằng tài khoản của key mặc định):",
"namedAwsSub": "RSA · cho thêm một tài khoản AWS.",
"removeKeyNamed": "Xoá key {{name}}"
},
"gitCreds": {
"title": "Git credentials",
+6 -1
View File
@@ -36,5 +36,10 @@
"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."
"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",
"pluginStep.plugin_update": "Bước {{index}}/{{total}}: tải phiên bản mới",
"pluginStep.bootstrap": "Bước {{index}}/{{total}}: cài đặt và khởi động lại bảng điều khiển"
}
+1
View File
@@ -5,6 +5,7 @@
"role": "Vai trò",
"workspaces": "Workspace",
"sessions": "Phiên",
"today": "Hôm nay",
"last7d": "7 ngày",
"last30d": "30 ngày",
"storage": "Dung lượng",
+4 -1
View File
@@ -538,6 +538,8 @@ export type ServerUserUsage = AuthUser & {
disabled: boolean;
workspaces: number;
sessions: number;
/** Since the viewer's local midnight (absent from an older server). */
today?: { cost: number; tokens: number };
last7d: { cost: number; tokens: number };
last30d: { cost: number; tokens: number };
/** Active login sessions (cookies not expired). */
@@ -1083,7 +1085,8 @@ export const api = {
avatarUrl: (id: number, avatarAt?: string | null) =>
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
/** GET /api/users — every account with its recent usage (admin/analyst). */
list: () => request<{ users: ServerUserUsage[] }>("/users"),
list: () =>
request<{ users: ServerUserUsage[] }>(`/users?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"),
+8
View File
@@ -1458,6 +1458,14 @@ export interface UpdateStatusPayload {
plugin_root?: string;
finished_at?: string;
} | null;
/** The step a running plugin-update chain is on (null between runs or on
* a server that predates it). Polled while an update runs. */
plugin_update_progress?: {
step: string;
index: number;
total: number;
started_at: string;
} | null;
}
// ───── Terminal run status ─────
+5 -3
View File
@@ -3,8 +3,8 @@
* @description The Users tab 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 in the last 7 and
* 30 days (`GET /api/users`) and how much disk its home folder takes
* 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
* (`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
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
@@ -214,13 +214,14 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
)}
<div className="card overflow-x-auto">
<table className="w-full min-w-[860px]" data-testid="users-table">
<table className="w-full min-w-[960px]" 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>
@@ -299,6 +300,7 @@ export function Users({ embedded = false }: { embedded?: boolean } = {}) {
<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
@@ -7244,7 +7244,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.8.20 (rebuild client to match)
UI build v2.8.21 (rebuild client to match)
</span>
</p>
</div>
+6 -3
View File
@@ -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 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}` — 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).
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.
@@ -395,7 +395,10 @@ Clone repository** (`client/src/components/lanes/CloneHereDialog.tsx`) clones
into the folder it has open — of the user's own home or of a workspace's own
folder, never a session worktree, an extra folder or another user's home —
through `POST /api/lanes/clone` below with that folder as `parent`, so every
check of that route applies, and opens a workspace on the clone.
check of that route applies, and opens a workspace on the clone. It sends a
`progressToken` and shows the queue place / git progress while the clone runs;
closing the dialog then only hides it behind a progress chip (click to reopen)
— the request keeps going and its outcome arrives as a toast.
#### Clone a repository into a new workspace
@@ -3222,7 +3225,7 @@ POST /api/updates/check
POST /api/updates/apply
```
Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, repo_root, … }` (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only`, rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`.
Release-based update detection (`server/lib/update-check.js`). `status` and `check` answer the same object — `{ git_repo, update_available, install_kind: "checkout"|"plugin", local_version, latest_release, latest_release_url, latest_release_notes, release_check, commits_behind, commits_behind_release, manual_command, situation, last_plugin_update, plugin_update_progress, repo_root, … }` (`plugin_update_progress` is `{ step, index, total, started_at }` while a plugin-update chain runs — `marketplace_update` → `plugin_update` → `bootstrap`, from `plugin-update-progress.json` in the runtime dir — else `null`) (`repo_root` is `null` for a developer); `check` also broadcasts it as `update_status`. `apply` (admin-only in server mode) picks its mode by install kind, never from the request: a fast-forwardable checkout merges the release `--ff-only`, rebuilds, answers `{ …, applied: true }` and then restarts the server; a plugin-cache install with a newer release hands off to the detached plugin-update chain → `{ …, applied: true, mode: "plugin" }`. Nothing to apply is `409 UPDATE_NOT_APPLICABLE` (`reason`: `up_to_date`, `plugin_managed`, `not_fast_forwardable`). `500 UPDATE_STATUS_FAILED` / `UPDATE_CHECK_FAILED` / `UPDATE_APPLY_FAILED`.
### Import history
+2 -2
View File
@@ -160,7 +160,7 @@ Workspaces made before server mode, or by the host account, have none.
An account's last login is kept in `users.last_login_at`, so it still shows
after the login itself has ended.
- `GET /api/users` (admin/analyst) lists every account with its workspaces,
sessions, and the tokens and $ it used in the last 7 and 30 days — the
sessions, and the tokens and $ it used today (the browser's local day) and in the last 7 and 30 days — 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
@@ -738,7 +738,7 @@ What runs as the member, and how:
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **More keys** (named keys for extra accounts) and **Other hosts** (advanced); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` and pick the region), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — clone `ssh://<name>/v1/repos/<repo>`; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
- the workspace prompt and its context files;
- the AI Proxy settings: no key, only that pane's own token for the gateway;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.20",
"version": "2.8.21",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.20",
"version": "2.8.21",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.20",
"version": "2.8.21",
"bin": {
"agenthub": "bin/agenthub.js"
},
+42 -7
View File
@@ -28,6 +28,10 @@
* refused on STDOUT (`bootstrap: locked`), and execFileSync's own message is
* only "Command failed: <argv>".
*
* While a step runs, `plugin-update-progress.json` beside it names that step
* (`progress`), so the dashboard can say how far the chain got rather than
* only that it is busy; a final verdict removes it.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -43,6 +47,10 @@ const [, , marketplace, plugin, oldRoot, runtimeDirArg] = process.argv;
// main() — which a require never runs — writes these.
const RESULT_FILE = path.join(runtimeDirArg || "", "plugin-update.json");
const LOG_FILE = path.join(runtimeDirArg || "", "plugin-update.log");
const PROGRESS_FILE = path.join(runtimeDirArg || "", "plugin-update-progress.json");
/** The chain's steps, in order, as `progress` reports them. */
const STEPS = ["marketplace_update", "plugin_update", "bootstrap"];
// Names come from the local cache PATH, never from an HTTP body, but they still
// reach an argv — so they are re-validated here rather than trusted across the
@@ -59,13 +67,34 @@ function log(line) {
}
}
function record(result) {
/** Every verdict but the pre-written handover one (`final: false`) ends the
* chain, so its progress record goes with it. */
function record(result, { final = true } = {}) {
try {
fs.mkdirSync(runtimeDirArg, { recursive: true });
fs.writeFileSync(
RESULT_FILE,
JSON.stringify({ ...result, finished_at: new Date().toISOString() }, null, 2)
);
if (final) fs.rmSync(PROGRESS_FILE, { force: true });
} catch {
/* best effort */
}
}
/** Say which step is running now — a courtesy for the UI, never a verdict. */
function progress(step) {
try {
fs.mkdirSync(runtimeDirArg, { recursive: true });
fs.writeFileSync(
PROGRESS_FILE,
JSON.stringify({
step,
index: STEPS.indexOf(step) + 1,
total: STEPS.length,
started_at: new Date().toISOString(),
})
);
} catch {
/* best effort */
}
@@ -182,6 +211,7 @@ function main() {
return;
}
progress("marketplace_update");
try {
run(claude, ["plugin", "marketplace", "update", marketplace], 300_000);
} catch (err) {
@@ -189,6 +219,7 @@ function main() {
return;
}
progress("plugin_update");
try {
run(claude, ["plugin", "update", `${plugin}@${marketplace}`, "-y"], 600_000);
} catch (err) {
@@ -219,12 +250,16 @@ function main() {
// `last_plugin_update: null` for an update that had in fact succeeded. A
// pre-written record is overwritten by the real outcome when this survives,
// and is the outcome when it does not.
record({
ok: true,
step: "bootstrap",
plugin_root: newRoot,
note: "the new plugin version is installed; the server is being handed over",
});
record(
{
ok: true,
step: "bootstrap",
plugin_root: newRoot,
note: "the new plugin version is installed; the server is being handed over",
},
{ final: false }
);
progress("bootstrap");
try {
// Foreground and blocking: this stops the old server (us), waits for the
// ports, then starts the new one. It must finish before we record success.
+57 -1
View File
@@ -19,7 +19,12 @@ const os = require("os");
const path = require("path");
const { execFileSync } = require("child_process");
const { readLastPluginUpdate, schedulePluginUpdate, runtimeDir } = require("../lib/plugin-update");
const {
readLastPluginUpdate,
readPluginUpdateProgress,
schedulePluginUpdate,
runtimeDir,
} = require("../lib/plugin-update");
const HELPER = path.join(__dirname, "..", "..", "scripts", "plugin-update-helper.js");
@@ -84,6 +89,43 @@ describe("readLastPluginUpdate", () => {
});
});
describe("readPluginUpdateProgress", () => {
const write = (name, body) => {
const rt = path.join(tmpDir, name);
fs.mkdirSync(rt, { recursive: true });
if (body !== undefined) fs.writeFileSync(path.join(rt, "plugin-update-progress.json"), body);
return rt;
};
it("is null with no file and for malformed JSON", () => {
assert.equal(readPluginUpdateProgress(write("prog-none")), null);
assert.equal(readPluginUpdateProgress(write("prog-bad", "{nope")), null);
});
it("reads the running step", () => {
const started_at = new Date().toISOString();
const rt = write(
"prog-ok",
JSON.stringify({ step: "plugin_update", index: 2, total: 3, started_at })
);
assert.deepEqual(readPluginUpdateProgress(rt), {
step: "plugin_update",
index: 2,
total: 3,
started_at,
});
});
it("ignores a file left by a helper that died long ago", () => {
const old = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const rt = write(
"prog-stale",
JSON.stringify({ step: "bootstrap", index: 3, total: 3, started_at: old })
);
assert.equal(readPluginUpdateProgress(rt), null);
});
});
describe("schedulePluginUpdate", () => {
it("clears a stale verdict before spawning, so an old success can't be read as this run's result", () => {
// Redirect the runtime dir into the temp tree — this must not touch the
@@ -152,6 +194,20 @@ describe("plugin-update-helper name guard", () => {
assert.equal(v.step, "validate");
});
it("removes a progress record with its final verdict", () => {
const rt = path.join(tmpDir, "guard-progress");
fs.mkdirSync(rt, { recursive: true });
fs.writeFileSync(
path.join(rt, "plugin-update-progress.json"),
JSON.stringify({ step: "plugin_update", started_at: new Date().toISOString() })
);
execFileSync(process.execPath, [HELPER, "../evil", "agenthub", "/nonexistent/root", rt], {
stdio: "ignore",
timeout: 30_000,
});
assert.equal(fs.existsSync(path.join(rt, "plugin-update-progress.json")), false);
});
it("stamps every verdict with a finish time", () => {
// The failure mode this file exists to prevent is a helper that dies
// silently: the tab would poll a server that never returns and say nothing.
@@ -78,6 +78,10 @@ describe("staff views", () => {
assert.equal(mia.workspaces, 1);
assert.equal(mia.sessions, 1);
assert.ok(mia.last7d.cost > 0 && mia.last30d.cost >= mia.last7d.cost);
// This hour's spend is today's in every timezone.
assert.equal(mia.today.cost, mia.last7d.cost);
const east = await call("/api/users?tz_offset=-840", admin);
assert.equal(east.body.users.find((u) => u.username === "mia").today.tokens, mia.today.tokens);
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
assert.equal((await call("/api/users", await login("ana", "analyst-pass-1"))).status, 200);
const member = await call("/api/users", await login("mia", "member-pass-1"));
+3 -4
View File
@@ -598,10 +598,9 @@ async function captureOutputsNow(laneId, sessionId) {
const since = Date.parse(started?.started_at || "") || Date.now() - 24 * 3600 * 1000;
const done = [];
for (const { rel, mtimeMs } of await outputCandidates(lane, since)) {
const rows = sql("SELECT * FROM artifacts WHERE kind = 'file' AND lane_id = ? AND source = ?").all(
lane.id,
rel
);
const rows = sql(
"SELECT * FROM artifacts WHERE kind = 'file' AND lane_id = ? AND source = ?"
).all(lane.id, rel);
if (rows.some((r) => r.auto !== 1)) continue;
const row = rows[0];
const kept = row?.files ? JSON.parse(row.files)[0] : null;
+38 -1
View File
@@ -28,6 +28,9 @@ const path = require("path");
const { spawn } = require("child_process");
const { getDataDir } = require("./claude-home");
/** Longer than any one step's timeout in the helper (bootstrap: 15 min). */
const PROGRESS_STALE_MS = 20 * 60 * 1000;
/** Runtime directory the helper writes its verdict into. Outside the plugin
* cache on purpose — that directory is replaced by the update itself. */
function runtimeDir() {
@@ -53,6 +56,34 @@ function readLastPluginUpdate(rt = runtimeDir()) {
}
}
/**
* The step a running chain is on (`plugin-update-progress.json`, written by the
* helper as each step starts and removed with its final verdict), or null.
* Surfaced as `plugin_update_progress` so the dashboard can show how far an
* update got. A file older than the chain's longest run is left over from a
* helper that died, and reads as nothing.
*
* @returns {{step: string, index: number, total: number, started_at: string} | null}
*/
function readPluginUpdateProgress(rt = runtimeDir()) {
try {
const parsed = JSON.parse(
fs.readFileSync(path.join(rt, "plugin-update-progress.json"), "utf8")
);
if (!parsed || typeof parsed.step !== "string") return null;
const started = Date.parse(parsed.started_at);
if (!Number.isFinite(started) || Date.now() - started > PROGRESS_STALE_MS) return null;
return {
step: parsed.step,
index: Number(parsed.index) || 0,
total: Number(parsed.total) || 0,
started_at: parsed.started_at,
};
} catch {
return null;
}
}
/**
* Spawn the detached update chain. Returns immediately; the caller must have
* already written its HTTP response, because this process is about to be
@@ -69,6 +100,7 @@ function schedulePluginUpdate({ marketplace, plugin, root }) {
try {
fs.mkdirSync(rt, { recursive: true });
fs.rmSync(path.join(rt, "plugin-update.json"), { force: true });
fs.rmSync(path.join(rt, "plugin-update-progress.json"), { force: true });
} catch {
/* best effort — a stale verdict is better than failing the update */
}
@@ -86,4 +118,9 @@ function schedulePluginUpdate({ marketplace, plugin, root }) {
return { spawned: true, runtime_dir: rt };
}
module.exports = { schedulePluginUpdate, readLastPluginUpdate, runtimeDir };
module.exports = {
schedulePluginUpdate,
readLastPluginUpdate,
readPluginUpdateProgress,
runtimeDir,
};
+12 -3
View File
@@ -36,7 +36,7 @@
const fs = require("fs");
const path = require("path");
const { execFile } = require("child_process");
const { readLastPluginUpdate } = require("./plugin-update");
const { readLastPluginUpdate, readPluginUpdateProgress } = require("./plugin-update");
const { getClaudeHome } = require("./claude-home");
const DEFAULT_ROOT = path.join(__dirname, "..", "..");
@@ -115,10 +115,16 @@ function execNpm(cwd, args, opts = {}) {
if (!err) return resolve(String(stdout).trim());
if (err.killed) {
return reject(
new Error(`${step} did not finish within ${Math.round(timeout / 60_000)} min and was stopped`)
new Error(
`${step} did not finish within ${Math.round(timeout / 60_000)} min and was stopped`
)
);
}
const tail = String(stderr || err.message || err).trim().split(/\r?\n/).slice(-15).join("\n");
const tail = String(stderr || err.message || err)
.trim()
.split(/\r?\n/)
.slice(-15)
.join("\n");
reject(new Error(`${step} failed:\n${tail}`));
}
);
@@ -491,6 +497,7 @@ async function pluginStatus(root, pluginInstall, options = {}) {
plugin_marketplace: pluginInstall.marketplace,
plugin_name: pluginInstall.plugin,
last_plugin_update: readLastPluginUpdate(),
plugin_update_progress: readPluginUpdateProgress(),
local_version: localVersion,
// Not a clone, so there is nothing to be shallow about and no distances to
@@ -785,6 +792,8 @@ async function getUpdatesStatus(gitRoot = DEFAULT_ROOT, options = {}) {
// every consumer (route, WS broadcast, CLI) gets it from one place — and so
// a chain that died is visible instead of looking like a slow restart.
last_plugin_update: pluginInstall ? readLastPluginUpdate() : null,
// The step a running chain is on, while it runs.
plugin_update_progress: pluginInstall ? readPluginUpdateProgress() : null,
local_version: localVersion,
// True when commit distances are unusable and version strings are the only
// signal — the UI must not render an "N commits" badge in that case.
+55 -21
View File
@@ -36,9 +36,17 @@ const schemas = {
laneId: { type: "integer", nullable: true },
sessionId: { type: "string", nullable: true },
source: { type: "string", nullable: true, description: "Plugin: `name@marketplace`." },
market: { type: "string", nullable: true, description: "Plugin: owner/repo, git URL or marketplace.json URL." },
market: {
type: "string",
nullable: true,
description: "Plugin: owner/repo, git URL or marketplace.json URL.",
},
url: { type: "string", nullable: true, description: "Page: a https://claude.ai/… link." },
files: { type: "array", items: { type: "object" }, description: "The files the snapshot recorded." },
files: {
type: "array",
items: { type: "object" },
description: "The files the snapshot recorded.",
},
size: { type: "integer", nullable: true },
shared: { type: "boolean" },
sharedAt: { type: "string", nullable: true },
@@ -87,12 +95,18 @@ const paths = {
in: "query",
schema: { type: "string", enum: ["all", "mine", "hub", "unowned"] },
},
{ name: "kind", in: "query", schema: { type: "string", enum: ["page", "file", "skill", "plugin"] } },
{
name: "kind",
in: "query",
schema: { type: "string", enum: ["page", "file", "skill", "plugin"] },
},
{ name: "q", in: "query", schema: { type: "string" } },
laneQuery,
{ name: "sessionId", in: "query", schema: { type: "string" } },
],
responses: { 200: { description: "`{items}`.", content: { "application/json": { schema: items } } } },
responses: {
200: { description: "`{items}`.", content: { "application/json": { schema: items } } },
},
},
},
"/api/artifacts/settings": {
@@ -190,11 +204,14 @@ const paths = {
tags: ["Artifacts"],
summary: "Keep one uploaded file (≤ 25 MB)",
operationId: "uploadArtifactFile",
requestBody: multipart({
files: { type: "string", format: "binary" },
title: { type: "string" },
description: { type: "string" },
}, ["files"]),
requestBody: multipart(
{
files: { type: "string", format: "binary" },
title: { type: "string" },
description: { type: "string" },
},
["files"]
),
responses: created(item),
},
},
@@ -222,13 +239,16 @@ const paths = {
operationId: "uploadArtifactSkills",
description:
"`paths` is a JSON array of each file's relative path. Zip sizes are read before inflating; nothing is stored if any skill fails.",
requestBody: multipart({
files: { type: "array", items: { type: "string", format: "binary" } },
paths: { type: "string" },
name: { type: "string" },
title: { type: "string" },
description: { type: "string" },
}, ["files"]),
requestBody: multipart(
{
files: { type: "array", items: { type: "string", format: "binary" } },
paths: { type: "string" },
name: { type: "string" },
title: { type: "string" },
description: { type: "string" },
},
["files"]
),
responses: created(items),
},
},
@@ -246,7 +266,10 @@ const paths = {
},
["name", "description", "content"]
),
responses: created({ type: "object", properties: { item: item.properties.item, items: items.properties.items } }),
responses: created({
type: "object",
properties: { item: item.properties.item, items: items.properties.items },
}),
},
},
"/api/artifacts/plugin/inspect": {
@@ -254,10 +277,14 @@ const paths = {
tags: ["Artifacts"],
summary: "Read a marketplace's plugin list",
operationId: "inspectArtifactMarket",
description: "Clones a user-named https link only to read `marketplace.json`; no credential is ever sent.",
description:
"Clones a user-named https link only to read `marketplace.json`; no credential is ever sent.",
requestBody: json({ market: { type: "string" } }, ["market"]),
responses: {
200: { description: "`{market, name, description, plugins: [{name, key, description, version, category}]}`." },
200: {
description:
"`{market, name, description, plugins: [{name, key, description, version, category}]}`.",
},
502: errs,
},
},
@@ -285,7 +312,10 @@ const paths = {
summary: "One artifact",
operationId: "getArtifact",
parameters: [idParam],
responses: { 200: { description: "`{item}`.", content: { "application/json": { schema: item } } }, 404: errs },
responses: {
200: { description: "`{item}`.", content: { "application/json": { schema: item } } },
404: errs,
},
},
patch: {
tags: ["Artifacts"],
@@ -299,7 +329,11 @@ const paths = {
source: { type: "string", description: "Plugin only." },
market: { type: "string", description: "Plugin only." },
}),
responses: { 200: { description: "`{item}`.", content: { "application/json": { schema: item } } }, 403: errs, 404: errs },
responses: {
200: { description: "`{item}`.", content: { "application/json": { schema: item } } },
403: errs,
404: errs,
},
},
delete: {
tags: ["Artifacts"],
+5
View File
@@ -393,6 +393,11 @@ const paths = {
properties: {
workspaces: { type: "integer" },
sessions: { type: "integer" },
today: {
type: "object",
description: "Spent since the viewer's local midnight (`tz_offset`).",
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
},
last7d: {
type: "object",
properties: { cost: { type: "number" }, tokens: { type: "integer" } },
+19 -4
View File
@@ -2,9 +2,10 @@
* @file users.js
* @description `/api/users` — server mode's per-user view for an admin or
* analyst (members are refused by STAFF_ONLY_PREFIXES in server/lib/auth.js):
* `GET /` lists every account with its workspaces, sessions and $ spent in the
* last 7 and 30 days (from `token_usage_hourly`, sessions owned through their
* workspace — server/lib/scope.js); `GET /audit` the newest audit entries
* `GET /` lists every account with its workspaces, sessions and $ spent today
* (the viewer's local day, `?tz_offset=` = the browser's getTimezoneOffset())
* and in the last 7 and 30 days (from `token_usage_hourly`, sessions owned
* through their workspace — server/lib/scope.js); `GET /audit` the newest audit entries
* (server/lib/audit.js); `GET /storage` how much disk each user's home takes
* (server/lib/user-storage.js — measured in a worker, `?refresh=1` re-measures).
*
@@ -99,10 +100,22 @@ function usageSince(sinceHour, rules) {
const hourAgo = (days) => new Date(Date.now() - days * 864e5).toISOString().slice(0, 13);
/**
* The UTC hour the viewer's local day began in — `offset` is the browser's
* getTimezoneOffset() (minutes local time is BEHIND UTC; UTC when missing).
* Buckets are whole UTC hours, so a half-hour zone's day starts up to 30 min early.
*/
function todayHour(offset) {
const off = Number.isFinite(offset) && Math.abs(offset) <= 840 ? offset : 0;
const local = new Date(Date.now() - off * 60e3);
local.setUTCHours(0, 0, 0, 0);
return new Date(local.getTime() + off * 60e3).toISOString().slice(0, 13);
}
/** Map(key → n) from rows of `{k, n}`. */
const counts = (rows) => new Map(rows.map((r) => [r.k, r.n]));
router.get("/", (_req, res) => {
router.get("/", (req, res) => {
const rules = stmts.listPricing.all();
// A fixed number of grouped queries, however many accounts there are.
const logins = auth.allLoginStats();
@@ -112,6 +125,7 @@ router.get("/", (_req, res) => {
const sessions = counts(
db.prepare(`SELECT uid AS k, COUNT(*) AS n FROM (${OWNED_SESSIONS}) GROUP BY uid`).all()
);
const today = usageSince(todayHour(parseInt(req.query.tz_offset, 10)), rules);
const last7d = usageSince(hourAgo(7), rules);
const last30d = usageSince(hourAgo(30), rules);
const none = { cost: 0, tokens: 0 };
@@ -120,6 +134,7 @@ router.get("/", (_req, res) => {
...(logins.get(u.id) || { logins: 0, lastLoginAt: null }),
workspaces: workspaces.get(u.id) || 0,
sessions: sessions.get(u.id) || 0,
today: today.get(u.id) || none,
last7d: last7d.get(u.id) || none,
last30d: last30d.get(u.id) || none,
}));