feat: choose your own pool account; skills with "token" in a doc name upload; System Health off Linux

- Skill snapshot/upload: a key file's name (.env, *.pem, id_rsa, .netrc,
  .credentials.json…) is always refused, but a word like token/secret/
  password/credential only in a data file's name — design-tokens.md or
  count_tokens.py is skill content, not a key.
- System Health: off Linux the History, CPU threads and Busiest processes
  cards (all read from /proc) are left out instead of shown empty; the CPU
  bar without a sample and Windows' always-zero Load row go too.
- Users pick their own pool account per tool (/api/me/pool-accounts,
  Settings → AI tools), within their groups, refused while pinned.
- Admin user detail, 9router update, session replay and Antigravity
  catalog changes from parallel sessions.
This commit is contained in:
2026-10-28 10:41:53 +07:00
parent 4999325210
commit 0f17efe3b4
39 changed files with 1306 additions and 230 deletions
@@ -5,7 +5,8 @@
* Default badge or Set default, and Update whenever the catalog names an
* updater — no version lookup, the updater itself knows whether it has work.
* The ones available to install fold behind a disclosure, with Install where
* the catalog knows how. Install/Update/Update all confirm the exact command
* the catalog knows how (an admin's; Update all is anyone's — auth.js
* MEMBER_WRITE_EXCEPTIONS). Install/Update/Update all confirm the exact command
* first, run as ONE server job at a time and show its log under the row
* (polled from GET /api/ai-tools/job; a finished job folds to one line).
* A right-click on an installed row opens its Update / Set default /
@@ -231,7 +232,8 @@ export function AiToolsSettings({
{t("aiTools.installed", { count: installed.length })}
</p>
<div className="flex items-center gap-2">
{isAdmin && updateAllTools.length > 0 && (
{/* Every signed-in user may run Update all (auth.js MEMBER_WRITE_EXCEPTIONS). */}
{updateAllTools.length > 0 && (
<button
type="button"
onClick={() => setConfirm({ kind: "update-all" })}
@@ -1,6 +1,6 @@
/**
* @file NineRouterUpdate.tsx
* @description **Update 9router** on the AI Proxy page (an admin's; beside
* @description **Update 9router** on the AI Proxy page (every user's; beside
* Config 9router): a button — dotted when npm has a newer 9router — and a
* dialog that runs the server's three-step job (`server/lib/nine-router-update.js`):
* disconnect and shut 9router down, `npm install -g 9router@latest`, start it
@@ -0,0 +1,120 @@
/**
* @file PoolAccountSettings — Settings → AI tools → Pool account: in server
* mode, every user picks which account of the shared account pool their own
* new sessions run on, per tool, among the accounts their pool group may use
* (`GET/PUT /api/me/pool-accounts`, server/lib/account-pins.js `setChoice`).
* "Automatic" leaves it to the balancer; an admin's pin locks the choice. A
* Claude login goes into the user's home at once, so running panes follow it;
* Codex and Cursor take it at the next session. Renders nothing in local mode
* or when the pool holds no account the user may use.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Lock } from "lucide-react";
import { api, type MyPoolTool, type PoolTool } from "../../lib/api";
import { toast } from "../../lib/toast";
import { usePermissions } from "../AuthGate";
import { AiToolIcon } from "../AiToolIcon";
import { SettingsRow, SettingsSection } from "./SettingsRow";
const LABEL: Record<PoolTool, string> = { claude: "Claude Code", codex: "Codex", cursor: "Cursor" };
export function PoolAccountSettings() {
const { t } = useTranslation("settings");
const { local } = usePermissions();
const [tools, setTools] = useState<MyPoolTool[] | null>(null);
const [busy, setBusy] = useState<PoolTool | null>(null);
useEffect(() => {
if (local) return;
api.me
.poolAccounts()
.then((r) => setTools(r.tools))
.catch(() => setTools(null));
}, [local]);
if (local || !tools?.length) return null;
const choose = async (tool: PoolTool, account: string | null) => {
setBusy(tool);
try {
const r = await api.me.choosePoolAccount(tool, account);
setTools((list) =>
(list ?? []).map((x) => (x.tool === tool ? { ...x, choice: r.choice } : x))
);
toast.success(
!r.choice
? t("poolAccount.automaticSaved")
: r.switched === "switched" || r.switched === "kept"
? t("poolAccount.switchedNow", { account: r.choice })
: t("poolAccount.nextSession", { account: r.choice })
);
} catch (err) {
toast.error(err);
} finally {
setBusy(null);
}
};
return (
<SettingsSection
id="pool-account"
title={t("poolAccount.title")}
description={t("poolAccount.description")}
>
{tools.map((x) => (
<SettingsRow
key={x.tool}
id={`pool-account-${x.tool}`}
label={
<span className="inline-flex items-center gap-1.5">
<AiToolIcon tool={{ id: x.tool, label: LABEL[x.tool] }} className="h-4 w-4" />
{LABEL[x.tool]}
</span>
}
description={
x.pin ? (
<span className="inline-flex items-center gap-1">
<Lock className="h-3 w-3" />
{t("poolAccount.pinned", { account: x.pin })}
</span>
) : (
t("poolAccount.count", { count: x.accounts.length })
)
}
disabled={!!x.pin}
control={
<select
aria-label={t("poolAccount.select", { tool: LABEL[x.tool] })}
value={x.pin ?? x.choice ?? ""}
disabled={!!x.pin || busy === x.tool}
onChange={(e) => void choose(x.tool, e.target.value || null)}
className="select !py-1 !text-xs max-w-[16rem]"
>
{!x.pin && <option value="">{t("poolAccount.automatic")}</option>}
{x.accounts.map((a) => (
<option key={a.name} value={a.name} disabled={a.expired && a.name !== x.choice}>
{[
a.name,
a.email && a.email !== a.name ? `(${a.email})` : null,
a.expired
? `— ${t("poolAccount.expired")}`
: a.full
? `— ${t("poolAccount.full")}`
: a.quotaPercent != null
? `— ${Math.round(a.quotaPercent)}%`
: null,
]
.filter(Boolean)
.join(" ")}
</option>
))}
</select>
}
/>
))}
</SettingsSection>
);
}
@@ -0,0 +1,85 @@
/**
* @file Tests for PoolAccountSettings — Settings → AI tools → Pool account:
* hidden in local mode and with no usable account; one select per tool that
* starts on the user's choice (or Automatic) and saves a pick through
* `PUT /api/me/pool-accounts`; locked when an admin pinned the user.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "../../../i18n";
import { PoolAccountSettings } from "../PoolAccountSettings";
const perms = vi.hoisted(() => ({ local: false }));
vi.mock("../../AuthGate", () => ({ usePermissions: () => perms }));
const m = vi.hoisted(() => ({ poolAccounts: vi.fn(), choosePoolAccount: vi.fn() }));
vi.mock("../../../lib/api", () => ({
api: { me: m, aiTools: { iconUrl: (id: string) => `/icons/${id}.png` } },
}));
const account = (name: string) => ({
name,
kind: "login",
email: `${name}@x.io`,
expired: false,
quotaPercent: 20,
full: false,
});
async function show() {
render(<PoolAccountSettings />);
await act(async () => {
await new Promise((res) => setTimeout(res, 0));
});
}
beforeEach(() => {
perms.local = false;
m.poolAccounts.mockReset();
m.choosePoolAccount.mockReset();
});
describe("PoolAccountSettings", () => {
it("renders nothing in local mode or with no account to choose", async () => {
perms.local = true;
await show();
expect(m.poolAccounts).not.toHaveBeenCalled();
expect(screen.queryByRole("combobox")).toBeNull();
perms.local = false;
m.poolAccounts.mockResolvedValue({ tools: [] });
await show();
expect(screen.queryByRole("combobox")).toBeNull();
});
it("starts on the user's choice and saves a new pick", async () => {
m.poolAccounts.mockResolvedValue({
tools: [
{ tool: "claude", pin: null, choice: "two", accounts: [account("one"), account("two")] },
],
});
m.choosePoolAccount.mockResolvedValue({ tool: "claude", choice: "one", switched: "switched" });
await show();
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.value).toBe("two");
await userEvent.selectOptions(select, "one");
expect(m.choosePoolAccount).toHaveBeenCalledWith("claude", "one");
await userEvent.selectOptions(select, "");
expect(m.choosePoolAccount).toHaveBeenLastCalledWith("claude", null);
});
it("is locked on an admin's pin", async () => {
m.poolAccounts.mockResolvedValue({
tools: [
{ tool: "codex", pin: "one", choice: null, accounts: [account("one"), account("two")] },
],
});
await show();
const select = screen.getByRole("combobox") as HTMLSelectElement;
expect(select.disabled).toBe(true);
expect(select.value).toBe("one");
expect(screen.getByText(/one/, { selector: "span" })).toBeTruthy();
});
});