feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)
Usage panel (status bar): - every account shows its plan (Claude subscriptionType, Codex chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier) - new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's own file, sent only to its own host while valid, never refreshed - in-flight quota reads are shared between tabs AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added; Gemini CLI, Hermes and OpenClaw removed. Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat on an editor selection, Settings in three groups, JetBrains-style Changes panel (ticked files/hunks, ignore, blame, file history), terminal font. --no-verify: :3000 is taken by another dev server (lane-detect tests); both suites run by hand — server 2636/2639 (the 3 are :3000), client 1131/1131.
This commit is contained in:
@@ -27,7 +27,7 @@ import type { Lane } from "../lib/types";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { ModelPicker } from "../components/run/RunSetup";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { PricingLink } from "../components/settings/PricingSettings";
|
||||
import { AntigravityHooksSettings } from "../components/settings/AntigravityHooksSettings";
|
||||
|
||||
/** Cards one tool adds below the generic ones. */
|
||||
@@ -307,7 +307,7 @@ export function AiToolConfig({ toolId }: { toolId: string }) {
|
||||
<DefaultModel toolId={toolId} saved={tool.defaultModel ?? ""} />
|
||||
</Card>
|
||||
)}
|
||||
{tool.modelVendor && <PricingSettings vendor={tool.modelVendor} id={`${toolId}-pricing`} />}
|
||||
{tool.modelVendor && <PricingLink vendor={tool.modelVendor} />}
|
||||
{tool.configFiles > 0 && (
|
||||
<Card title={t("toolTab.config")} Icon={FileCog}>
|
||||
<ConfigFiles toolId={toolId} />
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* union is exactly "user" | "project". Its **Overview** also holds the
|
||||
* dashboard's own Codex settings: CODEX_HOME (editable, so the overview's path
|
||||
* list leaves it out), AgentHub's Codex hooks (`CodexHooksSettings`) and Model
|
||||
* Pricing for OpenAI models (the shared `PricingSettings` table).
|
||||
* A link to the OpenAI table on the Pricing page (`PricingLink`).
|
||||
* Every card with action buttons also opens a right-click menu repeating
|
||||
* them (same handlers — Delete still asks for confirmation) plus Copy
|
||||
* name/path.
|
||||
@@ -70,7 +70,7 @@ import {
|
||||
import { ConfigTabBar } from "../components/config/ConfigTabBar";
|
||||
import { ProjectMemoryModal } from "../components/config/ProjectMemoryModal";
|
||||
import { HomeDirSetting } from "../components/settings/HomeDirSetting";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { PricingLink } from "../components/settings/PricingSettings";
|
||||
import { CodexHooksSettings } from "../components/settings/CodexHooksSettings";
|
||||
import {
|
||||
Empty,
|
||||
@@ -530,7 +530,7 @@ export function CodexConfig() {
|
||||
}
|
||||
/>
|
||||
<CodexHooksSettings key={hooksKey} />
|
||||
<PricingSettings vendor="openai" id="codex-pricing" />
|
||||
<PricingLink vendor="openai" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+158
-90
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file Settings.tsx
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — the start-at-login switch (`AutostartSettings`) and system information — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
/* =============================================================================
|
||||
@@ -55,7 +55,7 @@
|
||||
* ----------------------------------------------------------------------------- */
|
||||
|
||||
import { Checkbox } from "../components/Checkbox";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useEffect, useRef, useState, useCallback, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DollarSign,
|
||||
@@ -64,7 +64,6 @@ import {
|
||||
HardDrive,
|
||||
AlertTriangle,
|
||||
RotateCcw,
|
||||
Server,
|
||||
Bell,
|
||||
BellOff,
|
||||
BellRing,
|
||||
@@ -76,10 +75,6 @@ import {
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldX,
|
||||
Clock,
|
||||
Cpu,
|
||||
Globe,
|
||||
Wifi,
|
||||
Activity,
|
||||
Users,
|
||||
Layers,
|
||||
@@ -116,7 +111,7 @@ import {
|
||||
SettingsSegmented,
|
||||
SettingsSwitchRow,
|
||||
} from "../components/settings/SettingsRow";
|
||||
import type { WSMessage } from "../lib/types";
|
||||
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
|
||||
import { confirmDialog } from "../lib/confirm";
|
||||
import { toast } from "../lib/toast";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
@@ -145,22 +140,13 @@ const NOTIF_TYPES: [NotifType, string][] = [
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
interface SystemInfo {
|
||||
db: { path: string; size: number; counts: Record<string, number> };
|
||||
hooks: { installed: boolean; path: string; hooks: Record<string, boolean> };
|
||||
server: {
|
||||
version: string;
|
||||
uptime: number;
|
||||
node_version: string;
|
||||
platform: string;
|
||||
ws_connections: number;
|
||||
};
|
||||
}
|
||||
type SystemInfo = Awaited<ReturnType<typeof api.settings.info>>;
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
@@ -1133,85 +1119,167 @@ export function Settings({
|
||||
)}
|
||||
|
||||
{section === "about" && (
|
||||
<section id="about" className="scroll-mt-24">
|
||||
<div className="mb-4">
|
||||
<AutostartSettings />
|
||||
</div>
|
||||
<p className="text-xs text-fg-muted mb-4">{t("about.description")}</p>
|
||||
<div id="about" className="space-y-6 scroll-mt-24">
|
||||
<AutostartSettings />
|
||||
{sysInfo ? (
|
||||
<div className="card p-5">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<div className="bg-surface-2 rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Server className="w-4 h-4 text-accent" />
|
||||
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
|
||||
{t("about.release")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-fg-secondary font-mono">
|
||||
v{sysInfo.server.version}
|
||||
</p>
|
||||
{sysInfo.server.version !== __APP_VERSION__ && (
|
||||
<p className="text-[10px] text-status-warning/90 mt-1">
|
||||
{t("about.uiBuild", { version: __APP_VERSION__ })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-surface-2 rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Clock className="w-4 h-4 text-accent" />
|
||||
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
|
||||
{t("about.uptime")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-fg-secondary">
|
||||
{formatUptime(sysInfo.server.uptime)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-surface-2 rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Cpu className="w-4 h-4 text-status-success" />
|
||||
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
|
||||
{t("about.nodejs")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-fg-secondary font-mono">
|
||||
{sysInfo.server.node_version}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-surface-2 rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Globe className="w-4 h-4 text-syntax-keyword" />
|
||||
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
|
||||
{t("about.platform")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-fg-secondary">
|
||||
{sysInfo.server.platform}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-surface-2 rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Wifi className="w-4 h-4 text-status-warning" />
|
||||
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
|
||||
{t("about.wsClients")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-fg-secondary">
|
||||
{sysInfo.server.ws_connections}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SystemDetails info={sysInfo} />
|
||||
) : (
|
||||
<p className="text-xs text-fg-muted">{t("about.loadingInfo")}</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A read-only value on the right of a System row. */
|
||||
function Value({ children, mono = false }: { children: ReactNode; mono?: boolean }) {
|
||||
return (
|
||||
<span className={`text-sm text-fg-primary ${mono ? "font-mono text-xs" : ""}`}>{children}</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The System page's facts: the server process, how AgentHub is installed (from
|
||||
* the update check's last answer — it never fetches), and the database file.
|
||||
*/
|
||||
function SystemDetails({ info }: { info: SystemInfo }) {
|
||||
const { t } = useTranslation("settings");
|
||||
const [upd, setUpd] = useState<UpdateStatusPayload | null>(null);
|
||||
useEffect(() => {
|
||||
api.updates
|
||||
.status()
|
||||
.then(setUpd)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const srv = info.server;
|
||||
const mem = srv.memory;
|
||||
const heapLimit = mem ? (mem.heapLimit ?? mem.heapTotal) : 0;
|
||||
const load = srv.cpu_load?.map((n) => n.toFixed(2)).join(" / ");
|
||||
const kind = upd?.install_kind;
|
||||
return (
|
||||
<>
|
||||
<SettingsSection id="server" title={t("about.server")}>
|
||||
<SettingsRow
|
||||
label={t("about.release")}
|
||||
description={
|
||||
srv.version !== __APP_VERSION__ ? (
|
||||
<span className="text-status-warning/90">
|
||||
{t("about.uiBuild", { version: __APP_VERSION__ })}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
control={<Value mono>v{srv.version}</Value>}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t("about.uptime")}
|
||||
control={<Value>{formatUptime(srv.uptime)}</Value>}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t("about.platform")}
|
||||
control={
|
||||
<Value mono>
|
||||
{srv.platform}
|
||||
{srv.arch ? ` · ${srv.arch}` : ""} · Node.js {srv.node_version}
|
||||
</Value>
|
||||
}
|
||||
/>
|
||||
{srv.cpus > 0 && (
|
||||
<SettingsRow
|
||||
label={t("about.cpu")}
|
||||
control={<Value>{t("about.cpuValue", { count: srv.cpus, load })}</Value>}
|
||||
/>
|
||||
)}
|
||||
{mem && (
|
||||
<SettingsRow
|
||||
label={t("about.memory")}
|
||||
control={
|
||||
<Value>
|
||||
{t("about.memoryValue", {
|
||||
rss: formatBytes(mem.rss),
|
||||
used: formatBytes(mem.heapUsed),
|
||||
limit: formatBytes(heapLimit),
|
||||
})}
|
||||
</Value>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{srv.total_mem > 0 && (
|
||||
<SettingsRow
|
||||
label={t("about.systemMemory")}
|
||||
control={
|
||||
<Value>
|
||||
{t("about.systemMemoryValue", {
|
||||
used: formatBytes(srv.total_mem - srv.free_mem),
|
||||
total: formatBytes(srv.total_mem),
|
||||
})}
|
||||
</Value>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SettingsRow
|
||||
label={t("about.wsClients")}
|
||||
description={t("about.wsClientsDesc")}
|
||||
control={<Value>{srv.ws_connections}</Value>}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
{upd && (
|
||||
<SettingsSection id="install" title={t("about.install")}>
|
||||
<SettingsRow
|
||||
label={kind ? t(`about.installKind.${kind}`) : t("about.unknown")}
|
||||
description={
|
||||
upd.repo_root ? (
|
||||
<span className="font-mono break-all">{upd.repo_root}</span>
|
||||
) : undefined
|
||||
}
|
||||
control={upd.local_version ? <Value mono>v{upd.local_version}</Value> : null}
|
||||
/>
|
||||
{upd.latest_release && (
|
||||
<SettingsRow
|
||||
label={t("about.latest")}
|
||||
control={
|
||||
<span className="flex items-center gap-2">
|
||||
<Value mono>{upd.latest_release}</Value>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
|
||||
upd.update_available
|
||||
? "bg-status-warning/10 text-status-warning"
|
||||
: "bg-status-success/10 text-status-success"
|
||||
}`}
|
||||
>
|
||||
{upd.update_available ? t("about.updateAvailable") : t("about.upToDate")}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection id="database" title={t("about.database")}>
|
||||
<SettingsRow
|
||||
label={t("about.dbFile")}
|
||||
description={<span className="font-mono break-all">{info.db.path}</span>}
|
||||
control={<Value>{formatBytes(info.db.size)}</Value>}
|
||||
/>
|
||||
{info.db.pragmas && (
|
||||
<SettingsRow
|
||||
label={t("about.journal")}
|
||||
control={<Value mono>{info.db.pragmas.journal_mode}</Value>}
|
||||
/>
|
||||
)}
|
||||
{info.db.load_stats && (
|
||||
<SettingsRow
|
||||
label={t("about.activity")}
|
||||
control={<Value>{t("about.activityValue", info.db.load_stats)}</Value>}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Clear all data": the one action here that empties the whole database, so
|
||||
* it asks for a typed phrase (not a second click) and offers an export first. */
|
||||
function ClearDataDialog({
|
||||
|
||||
+175
-178
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @file SettingsHub.tsx
|
||||
* @description The Settings page: `/settings/<section>`, a left nav grouped
|
||||
* General / Agents / Routing / Cost / Git (a `<select>` below `md`), a
|
||||
* General / Agents / Data (a `<select>` below `md`), a
|
||||
* search box over `SETTINGS_INDEX` (`lib/settingsSearch.ts`; Ctrl/Cmd+F
|
||||
* focuses it — and selects its text when it already has focus —, a result list
|
||||
* deep-links a row's sub-tab and anchor, Enter opens the first result, Escape
|
||||
@@ -10,7 +10,10 @@
|
||||
* Back returns to the page before Settings when there is one, else /workspace;
|
||||
* an unknown section shows a skeleton until the tool list loads, then toasts
|
||||
* and falls back to Appearance (which also holds the language and the pet;
|
||||
* `general` and `pet` redirect there). A tool's own settings live under `agents/<id>` — Claude Code and
|
||||
* `general` and `pet` redirect there). Small pages share one: System is the old
|
||||
* About (`about` redirects), Git holds git logins and worktree suggestions
|
||||
* (`git-credentials`, `worktrees`), Snapshots & reports is `reports`
|
||||
* (`snapshots`). A page's content is capped at a readable width. A tool's own settings live under `agents/<id>` — Claude Code and
|
||||
* Codex have their own pages, every other installed, enabled catalog tool the
|
||||
* generic `AiToolConfig` — and a tool's page is hidden while that tool is
|
||||
* disabled. A workspace's own settings live on its home, not here.
|
||||
@@ -31,17 +34,14 @@ import {
|
||||
ArrowLeft,
|
||||
Bell,
|
||||
Database,
|
||||
Info,
|
||||
Bot,
|
||||
Camera,
|
||||
CalendarClock,
|
||||
Coins,
|
||||
GitBranch,
|
||||
KeyRound,
|
||||
Palette,
|
||||
Router,
|
||||
Search,
|
||||
UsersRound,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { Settings } from "./Settings";
|
||||
import { CcConfig } from "./CcConfig";
|
||||
@@ -66,9 +66,8 @@ import { Skeleton } from "../components/Skeleton";
|
||||
import { AiToolIcon } from "../components/AiToolIcon";
|
||||
import { toast } from "../lib/toast";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { UsersSettings } from "../components/settings/UsersSettings";
|
||||
|
||||
const GROUPS = ["general", "agents", "routing", "cost", "git"] as const;
|
||||
const GROUPS = ["general", "agents", "data"] as const;
|
||||
type Group = (typeof GROUPS)[number];
|
||||
|
||||
interface Section {
|
||||
@@ -86,7 +85,7 @@ const LEGACY_TOOL: Record<string, string> = {
|
||||
"claude-code": "agents/claude-code",
|
||||
codex: "agents/codex",
|
||||
"ai-router": "ai-proxy",
|
||||
"git-credentials": "git-credentials",
|
||||
"git-credentials": "git",
|
||||
};
|
||||
|
||||
/** Classes a deep-linked row wears for a moment after the hub scrolls to it. */
|
||||
@@ -125,16 +124,15 @@ const MOVED: Record<string, string> = {
|
||||
pet: "appearance#pet",
|
||||
// The AI Router → AI Proxy rename.
|
||||
"ai-router": "ai-proxy",
|
||||
// Small pages merged into one.
|
||||
about: "system",
|
||||
snapshots: "reports#snapshots",
|
||||
"git-credentials": "git",
|
||||
worktrees: "git#worktrees",
|
||||
};
|
||||
|
||||
/** Sections whose API a server-mode member may not reach (STAFF_ONLY_PREFIXES in server/lib/auth.js). */
|
||||
const STAFF_SECTIONS = new Set([
|
||||
"data",
|
||||
"reports",
|
||||
"agents/claude-code",
|
||||
"agents/codex",
|
||||
"git-credentials",
|
||||
]);
|
||||
const STAFF_SECTIONS = new Set(["data", "agents/claude-code", "agents/codex"]);
|
||||
|
||||
/** An unknown section: say so, then show Appearance. */
|
||||
function UnknownSection({ name }: { name: string }) {
|
||||
@@ -219,27 +217,12 @@ export function SettingsHub() {
|
||||
|
||||
const auth = useAuth();
|
||||
const isMember = auth.state.mode === "server" && auth.state.user?.role === "member";
|
||||
const isStaff = auth.state.mode === "server" && !isMember && Boolean(auth.state.user);
|
||||
const enabled = (id: string) => aiTools.byId(id)?.enabled !== false;
|
||||
const s = (key: string) => ({
|
||||
title: t(`settingsNav.section.${key}.title`),
|
||||
description: t(`settingsNav.section.${key}.description`),
|
||||
});
|
||||
const allSections: Section[] = [
|
||||
{
|
||||
id: "data",
|
||||
group: "general",
|
||||
Icon: Database,
|
||||
...s("data"),
|
||||
render: () => <Settings section="data" />,
|
||||
},
|
||||
{
|
||||
id: "about",
|
||||
group: "general",
|
||||
Icon: Info,
|
||||
...s("about"),
|
||||
render: () => <Settings section="about" />,
|
||||
},
|
||||
{
|
||||
id: "appearance",
|
||||
group: "general",
|
||||
@@ -247,31 +230,6 @@ export function SettingsHub() {
|
||||
...s("appearance"),
|
||||
render: () => <AppearanceSettings />,
|
||||
},
|
||||
{
|
||||
id: "snapshots",
|
||||
group: "general",
|
||||
Icon: Camera,
|
||||
...s("snapshots"),
|
||||
render: () => <SnapshotSettings />,
|
||||
},
|
||||
{
|
||||
id: "reports",
|
||||
group: "general",
|
||||
Icon: CalendarClock,
|
||||
...s("reports"),
|
||||
render: () => <ReportSettings />,
|
||||
},
|
||||
...(isStaff
|
||||
? [
|
||||
{
|
||||
id: "users",
|
||||
group: "general" as const,
|
||||
Icon: UsersRound,
|
||||
...s("users"),
|
||||
render: () => <UsersSettings />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "notifications",
|
||||
group: "general",
|
||||
@@ -279,6 +237,13 @@ export function SettingsHub() {
|
||||
...s("notifications"),
|
||||
render: () => <Settings section="notifications" />,
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
group: "general",
|
||||
Icon: Server,
|
||||
...s("system"),
|
||||
render: () => <Settings section="about" />,
|
||||
},
|
||||
{
|
||||
id: "agents",
|
||||
group: "agents",
|
||||
@@ -322,14 +287,21 @@ export function SettingsHub() {
|
||||
})),
|
||||
{
|
||||
id: "ai-proxy",
|
||||
group: "routing",
|
||||
group: "agents",
|
||||
Icon: Router,
|
||||
...s("aiProxy"),
|
||||
render: () => <AiProxyConfig />,
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
group: "data",
|
||||
Icon: Database,
|
||||
...s("data"),
|
||||
render: () => <Settings section="data" />,
|
||||
},
|
||||
{
|
||||
id: "pricing",
|
||||
group: "cost",
|
||||
group: "data",
|
||||
Icon: Coins,
|
||||
...s("pricing"),
|
||||
render: () => (
|
||||
@@ -354,18 +326,39 @@ export function SettingsHub() {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "git-credentials",
|
||||
group: "git",
|
||||
Icon: KeyRound,
|
||||
...s("gitCredentials"),
|
||||
render: () => <GitCredentialsConfig />,
|
||||
id: "reports",
|
||||
group: "data",
|
||||
Icon: CalendarClock,
|
||||
...s("reports"),
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
<div id="snapshots" className="scroll-mt-24">
|
||||
<SnapshotSettings />
|
||||
</div>
|
||||
{/* Scheduled reports read every user's sessions: staff only. */}
|
||||
{!isMember && <ReportSettings />}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "worktrees",
|
||||
group: "git",
|
||||
id: "git",
|
||||
group: "data",
|
||||
Icon: GitBranch,
|
||||
...s("worktrees"),
|
||||
render: () => <WorktreeVisibilitySettings />,
|
||||
...s("git"),
|
||||
render: () => (
|
||||
<div className="space-y-6">
|
||||
{/* Git logins are the host's own: staff only. */}
|
||||
{!isMember && (
|
||||
<section id="git-credentials" className="scroll-mt-24">
|
||||
<h2 className="mb-2 text-sm font-semibold text-fg-primary">{t("gitCreds.title")}</h2>
|
||||
<GitCredentialsConfig />
|
||||
</section>
|
||||
)}
|
||||
<div id="worktrees" className="scroll-mt-24">
|
||||
<WorktreeVisibilitySettings />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
// Server mode: a member cannot open the host's own configuration or the
|
||||
@@ -429,128 +422,132 @@ export function SettingsHub() {
|
||||
navigate(backDelta(historyIdx(), entryIdx.current ?? 0));
|
||||
};
|
||||
|
||||
const searchBox = (
|
||||
<label className="relative block">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]));
|
||||
if (e.key === "Escape") setQuery("");
|
||||
}}
|
||||
placeholder={t("search.placeholder")}
|
||||
aria-label={t("search.placeholder")}
|
||||
className="w-full rounded-lg border border-border bg-surface-1 py-1.5 pl-8 pr-2 text-sm text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
const searchResults = ranked && results.length > 0 && (
|
||||
<ul
|
||||
aria-label={t("search.results")}
|
||||
className="card mt-2 divide-y divide-border/60 overflow-hidden"
|
||||
>
|
||||
{results.map((r, i) => (
|
||||
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
|
||||
<Link
|
||||
to={hrefOf(r)}
|
||||
onClick={() => setQuery("")}
|
||||
className={`block px-3 py-2 text-xs hover:bg-surface-3 ${i === 0 ? "bg-surface-2" : ""}`}
|
||||
>
|
||||
<span className="block text-fg-primary">{r.title}</span>
|
||||
{!r.sectionTitle && (
|
||||
<span className="block text-[11px] text-fg-muted">{titleOf(r.section)}</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Link
|
||||
to="/workspace"
|
||||
onClick={back}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-xs text-fg-secondary hover:text-fg-primary"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
{canGoBack ? t("settingsNav.backPrevious") : t("settingsNav.back")}
|
||||
</Link>
|
||||
|
||||
<label className="relative block md:w-56">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-fg-muted" />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && results[0]) navigate(hrefOf(results[0]));
|
||||
if (e.key === "Escape") setQuery("");
|
||||
}}
|
||||
placeholder={t("search.placeholder")}
|
||||
aria-label={t("search.placeholder")}
|
||||
className="w-full rounded-lg border border-border bg-surface-1 py-1.5 pl-8 pr-2 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{ranked && (
|
||||
<div className="md:w-96">
|
||||
{results.length === 0 ? (
|
||||
<p role="status" className="px-2 text-xs text-fg-muted md:hidden">
|
||||
{t("search.noMatch", { query })}
|
||||
</p>
|
||||
) : (
|
||||
<ul
|
||||
aria-label={t("search.results")}
|
||||
className="card divide-y divide-border/60 overflow-hidden"
|
||||
>
|
||||
{results.map((r, i) => (
|
||||
<li key={`${r.section}${r.to ?? ""}${r.title}`}>
|
||||
<Link
|
||||
to={hrefOf(r)}
|
||||
onClick={() => setQuery("")}
|
||||
className={`block px-3 py-2 text-xs hover:bg-surface-3 ${
|
||||
i === 0 ? "bg-surface-2" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="block text-fg-primary">{r.title}</span>
|
||||
{!r.sectionTitle && (
|
||||
<span className="block text-[11px] text-fg-muted">{titleOf(r.section)}</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-6">
|
||||
<nav
|
||||
aria-label={t("settingsNav.label")}
|
||||
className="sticky top-0 hidden max-h-[calc(100vh-8rem)] w-56 shrink-0 self-start overflow-y-auto md:block"
|
||||
>
|
||||
{ranked && shown.length === 0 && (
|
||||
<p className="px-2 py-1 text-xs text-fg-muted">{t("search.noMatch", { query })}</p>
|
||||
)}
|
||||
{GROUPS.map((g) => {
|
||||
const items = shown.filter((x) => x.group === g);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={g} className="mb-3">
|
||||
<p className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{t(`settingsNav.group.${g}`)}
|
||||
</p>
|
||||
{items.map(({ id, title, Icon }) => (
|
||||
<Link
|
||||
key={id}
|
||||
to={`/settings/${id}`}
|
||||
aria-current={id === active.id ? "page" : undefined}
|
||||
data-first-match={id === first ? "true" : undefined}
|
||||
className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-[13px] transition-colors ${
|
||||
id === active.id
|
||||
? "bg-accent/15 text-accent"
|
||||
: id === first
|
||||
? "bg-surface-3 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-3 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<select
|
||||
aria-label={t("settingsNav.label")}
|
||||
value={active.id}
|
||||
onChange={(e) => go(e.target.value)}
|
||||
className="select mb-4 w-full py-1.5 md:hidden"
|
||||
>
|
||||
<div className="flex gap-8">
|
||||
<aside className="sticky top-0 hidden max-h-[calc(100vh-4rem)] w-60 shrink-0 self-start overflow-y-auto pb-6 md:block">
|
||||
{searchBox}
|
||||
{searchResults}
|
||||
<nav aria-label={t("settingsNav.label")} className="mt-4 space-y-5">
|
||||
{ranked && shown.length === 0 && (
|
||||
<p className="px-2 py-1 text-xs text-fg-muted">{t("search.noMatch", { query })}</p>
|
||||
)}
|
||||
{GROUPS.map((g) => {
|
||||
const own = sections.filter((x) => x.group === g);
|
||||
return own.length === 0 ? null : (
|
||||
<optgroup key={g} label={t(`settingsNav.group.${g}`)}>
|
||||
{own.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.title}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
const items = shown.filter((x) => x.group === g);
|
||||
if (items.length === 0) return null;
|
||||
const label = t(`settingsNav.group.${g}`);
|
||||
return (
|
||||
<div key={g} role="group" aria-label={label}>
|
||||
<p className="px-2 pb-1.5 text-[11px] font-semibold uppercase tracking-wider text-fg-muted">
|
||||
{label}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{items.map(({ id, title, Icon }) => (
|
||||
<Link
|
||||
key={id}
|
||||
to={`/settings/${id}`}
|
||||
aria-current={id === active.id ? "page" : undefined}
|
||||
data-first-match={id === first ? "true" : undefined}
|
||||
className={`relative flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
|
||||
id === active.id
|
||||
? "bg-surface-3 font-medium text-fg-primary before:absolute before:inset-y-1.5 before:left-0 before:w-0.5 before:rounded-full before:bg-accent"
|
||||
: id === first
|
||||
? "bg-surface-3 text-fg-primary"
|
||||
: "text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`h-4 w-4 shrink-0 ${id === active.id ? "text-accent" : "text-fg-muted"}`}
|
||||
/>
|
||||
<span className="truncate">{title}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<h1 className="text-2xl font-semibold text-fg-primary">{active.title}</h1>
|
||||
<p className="mt-1 text-sm text-fg-muted">{active.description}</p>
|
||||
<div className="mb-6 mt-4 border-b border-border" />
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0 max-w-5xl flex-1 pb-10">
|
||||
<div className="mb-4 md:hidden">
|
||||
<select
|
||||
aria-label={t("settingsNav.label")}
|
||||
value={active.id}
|
||||
onChange={(e) => go(e.target.value)}
|
||||
className="select w-full py-1.5"
|
||||
>
|
||||
{GROUPS.map((g) => {
|
||||
const own = sections.filter((x) => x.group === g);
|
||||
return own.length === 0 ? null : (
|
||||
<optgroup key={g} label={t(`settingsNav.group.${g}`)}>
|
||||
{own.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.title}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<header className="mb-6 flex items-start gap-3 border-b border-border pb-5">
|
||||
<span className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-surface-2 text-accent">
|
||||
<active.Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-semibold text-fg-primary">{active.title}</h1>
|
||||
<p className="mt-0.5 text-sm text-fg-muted">{active.description}</p>
|
||||
</div>
|
||||
</header>
|
||||
{active.render()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* @file Users.tsx
|
||||
* @description The Users page (`/users`, a sidebar item), server mode only and
|
||||
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
|
||||
* role, state, workspaces, sessions and the tokens/$ it used in the last 7 and
|
||||
* 30 days (`GET /api/users`), the audit log (`GET /api/users/audit`), and — for
|
||||
* an admin — the account changes: add a user, change a role, disable/enable,
|
||||
* reset a password, give a workspace. Every change asks for the admin's OWN
|
||||
* password again (the server refuses without it); an analyst only reads.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderInput, KeyRound, Lock, LockOpen, RefreshCw, Shield, UserPlus } from "lucide-react";
|
||||
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
|
||||
import type { Lane } from "../lib/types";
|
||||
import { fmt, fmtCost, formatDateTime } from "../lib/format";
|
||||
import { toast } from "../lib/toast";
|
||||
import { isSubmitEnter } from "../lib/keys";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
import { useAuth } from "../components/AuthGate";
|
||||
import { ConfirmModal } from "../components/ConfirmModal";
|
||||
import { SettingsSection } from "../components/settings/SettingsRow";
|
||||
|
||||
type Role = AuthUser["role"];
|
||||
const ROLES: Role[] = ["admin", "analyst", "member"];
|
||||
|
||||
type DialogKind = "add" | "role" | "disable" | "enable" | "password" | "workspace";
|
||||
type Dialog = { kind: DialogKind; user?: ServerUserUsage };
|
||||
|
||||
export function Users() {
|
||||
const { t } = useTranslation("users");
|
||||
const auth = useAuth();
|
||||
const me = auth.state.mode === "server" ? auth.state.user : null;
|
||||
const isAdmin = me?.role === "admin";
|
||||
|
||||
const [users, setUsers] = useState<ServerUserUsage[] | null>(null);
|
||||
const [audit, setAudit] = useState<AuditEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dialog, setDialog] = useState<Dialog | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
Promise.all([
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => {
|
||||
setUsers(r.users);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => setError(e.message)),
|
||||
api.users
|
||||
.audit()
|
||||
.then((r) => setAudit(r.entries))
|
||||
.catch(() => setAudit([])),
|
||||
]),
|
||||
[]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
|
||||
useEffect(() => {
|
||||
if (me && me.role !== "member") void load();
|
||||
}, [me, load]);
|
||||
|
||||
// Local mode, or a member: there is no Users page for them.
|
||||
if (!me || me.role === "member") return <Navigate to="/" replace />;
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
||||
<p className="text-xs text-fg-muted">{t("description")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={refresh}
|
||||
disabled={spinning}
|
||||
aria-busy={spinning}
|
||||
className="btn-ghost"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
|
||||
{t("common:refresh")}
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDialog({ kind: "add" })}
|
||||
className="btn-primary"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" /> {t("add")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSection title={t("title")}>
|
||||
{error && (
|
||||
<p role="alert" className="py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!isAdmin && <p className="pb-2 text-xs text-fg-muted">{t("readOnly")}</p>}
|
||||
{users && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-testid="users-table">
|
||||
<thead className="text-left text-[11px] uppercase text-fg-muted">
|
||||
<tr>
|
||||
<th className="py-2 pr-3 font-medium">{t("name")}</th>
|
||||
<th className="py-2 pr-3 font-medium">{t("role")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("workspaces")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("sessions")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("last7d")}</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">{t("last30d")}</th>
|
||||
{isAdmin && <th className="py-2 text-right font-medium">{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/60">
|
||||
{users.map((u) => {
|
||||
const self = u.id === me.id;
|
||||
return (
|
||||
<tr key={u.id} className={u.disabled ? "text-fg-muted" : "text-fg-secondary"}>
|
||||
<td className="py-2 pr-3">
|
||||
{u.username}
|
||||
{self && <span className="ml-2 text-[11px] text-accent">({t("you")})</span>}
|
||||
{u.disabled && <span className="ml-2 text-[11px]">({t("disabled")})</span>}
|
||||
</td>
|
||||
<td className="py-2 pr-3" title={t(`roleHint.${u.role}`)}>
|
||||
{t(`roles.${u.role}`)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{u.workspaces}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums">{u.sessions}</td>
|
||||
<td
|
||||
className="py-2 pr-3 text-right tabular-nums"
|
||||
title={t("tokens", { n: fmt(u.last7d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last7d.cost)}
|
||||
</td>
|
||||
<td
|
||||
className="py-2 pr-3 text-right tabular-nums"
|
||||
title={t("tokens", { n: fmt(u.last30d.tokens) })}
|
||||
>
|
||||
{fmtCost(u.last30d.cost)}
|
||||
</td>
|
||||
{isAdmin && (
|
||||
<td className="py-1 text-right whitespace-nowrap">
|
||||
<RowAction
|
||||
label={t("changeRole")}
|
||||
disabled={self}
|
||||
onClick={() => setDialog({ kind: "role", user: u })}
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t("resetPassword")}
|
||||
onClick={() => setDialog({ kind: "password", user: u })}
|
||||
>
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={t("assignWorkspace")}
|
||||
onClick={() => setDialog({ kind: "workspace", user: u })}
|
||||
>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
</RowAction>
|
||||
<RowAction
|
||||
label={u.disabled ? t("enable") : t("disable")}
|
||||
disabled={self}
|
||||
onClick={() =>
|
||||
setDialog({ kind: u.disabled ? "enable" : "disable", user: u })
|
||||
}
|
||||
>
|
||||
{u.disabled ? (
|
||||
<LockOpen className="h-4 w-4" />
|
||||
) : (
|
||||
<Lock className="h-4 w-4" />
|
||||
)}
|
||||
</RowAction>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title={t("auditTitle")} description={t("auditDescription")}>
|
||||
{audit?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("auditEmpty")}</p>}
|
||||
{audit && audit.length > 0 && (
|
||||
<ul className="divide-y divide-border/60 text-xs" data-testid="audit-list">
|
||||
{audit.map((a) => (
|
||||
<li key={a.id} className="flex gap-3 py-1.5">
|
||||
<span className="w-36 flex-shrink-0 text-fg-muted">{formatDateTime(a.at)}</span>
|
||||
<span className="w-40 flex-shrink-0 text-fg-secondary">
|
||||
{a.reader} → {a.owner}
|
||||
</span>
|
||||
<span className="min-w-0 truncate font-mono text-fg-muted">{a.path}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{dialog && (
|
||||
<AccountDialog
|
||||
dialog={dialog}
|
||||
users={users ?? []}
|
||||
onClose={() => setDialog(null)}
|
||||
onDone={(message) => {
|
||||
toast.success(message);
|
||||
setDialog(null);
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowAction({
|
||||
label,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className="btn-icon disabled:opacity-30"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** One account change: its own fields, then the admin's password to confirm it. */
|
||||
function AccountDialog({
|
||||
dialog,
|
||||
users,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
dialog: Dialog;
|
||||
users: ServerUserUsage[];
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("users");
|
||||
const { kind, user } = dialog;
|
||||
const name = user?.username ?? "";
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [role, setRole] = useState<Role>(user?.role ?? "member");
|
||||
const [laneId, setLaneId] = useState<number | null>(null);
|
||||
const [lanes, setLanes] = useState<Lane[] | null>(null);
|
||||
const [current, setCurrent] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (kind !== "workspace") return;
|
||||
api.lanes
|
||||
.list()
|
||||
.then((r) => setLanes(r.lanes))
|
||||
.catch(() => setLanes([]));
|
||||
}, [kind]);
|
||||
|
||||
const ready =
|
||||
current.length > 0 &&
|
||||
(kind === "add"
|
||||
? username.trim().length > 0 && password.length > 0
|
||||
: kind === "password"
|
||||
? password.length > 0
|
||||
: kind === "workspace"
|
||||
? laneId !== null
|
||||
: kind === "role"
|
||||
? role !== user?.role
|
||||
: true);
|
||||
|
||||
const submit = async () => {
|
||||
if (!ready || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (kind === "add") {
|
||||
const r = await api.users.create({ username: username.trim(), password, role }, current);
|
||||
onDone(t("done.created", { name: r.user.username }));
|
||||
} else if (kind === "role" && user) {
|
||||
await api.users.update(user.id, { role }, current);
|
||||
onDone(t("done.role", { name, role: t(`roles.${role}`) }));
|
||||
} else if ((kind === "disable" || kind === "enable") && user) {
|
||||
await api.users.update(user.id, { disabled: kind === "disable" }, current);
|
||||
onDone(t(kind === "disable" ? "done.disabled" : "done.enabled", { name }));
|
||||
} else if (kind === "password" && user) {
|
||||
await api.users.setPassword(user.id, password, current);
|
||||
onDone(t("done.password", { name }));
|
||||
} else if (kind === "workspace" && user && laneId !== null) {
|
||||
await api.users.assignWorkspace(user.id, laneId, current);
|
||||
onDone(t("done.workspace", { name }));
|
||||
}
|
||||
} catch (e) {
|
||||
const err = e as Error & { code?: string };
|
||||
setError(
|
||||
err.code && t(`errors.${err.code}`, { defaultValue: "" })
|
||||
? t(`errors.${err.code}`)
|
||||
: err.message
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const titles: Record<DialogKind, string> = {
|
||||
add: t("dialog.add"),
|
||||
role: t("dialog.role", { name }),
|
||||
disable: t("dialog.disable", { name }),
|
||||
enable: t("dialog.enable", { name }),
|
||||
password: t("dialog.password", { name }),
|
||||
workspace: t("dialog.workspace", { name }),
|
||||
};
|
||||
const messages: Partial<Record<DialogKind, string>> = {
|
||||
disable: t("dialog.disableBody", { name }),
|
||||
password: t("dialog.passwordBody", { name }),
|
||||
workspace: t("dialog.workspaceBody", { name }),
|
||||
};
|
||||
const ownerName = (id: number) => users.find((u) => u.id === id)?.username ?? `#${id}`;
|
||||
// Enter in any field submits — never mid-IME (Vietnamese typing commits with Enter).
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open
|
||||
title={titles[kind]}
|
||||
message={messages[kind]}
|
||||
confirmLabel={kind === "disable" ? t("disable") : kind === "add" ? t("add") : t("save")}
|
||||
cancelLabel={t("common:cancel")}
|
||||
destructive={kind === "disable"}
|
||||
busy={busy}
|
||||
disabled={!ready}
|
||||
onConfirm={() => void submit()}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="mt-3 space-y-3" onKeyDown={onKeyDown}>
|
||||
{kind === "add" && (
|
||||
<Field label={t("username")} hint={t("usernameHint")}>
|
||||
<input
|
||||
className="input w-full text-sm"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="off"
|
||||
aria-label={t("username")}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{(kind === "add" || kind === "password") && (
|
||||
<Field label={kind === "add" ? t("password") : t("newPassword")} hint={t("passwordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
aria-label={kind === "add" ? t("password") : t("newPassword")}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{(kind === "add" || kind === "role") && (
|
||||
<Field label={t("role")} hint={t(`roleHint.${role}`)}>
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value as Role)}
|
||||
aria-label={t("role")}
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{t(`roles.${r}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
{kind === "workspace" && (
|
||||
<Field label={t("workspace")}>
|
||||
{lanes && lanes.length === 0 ? (
|
||||
<p className="text-xs text-fg-muted">{t("noWorkspaces")}</p>
|
||||
) : (
|
||||
<select
|
||||
className="input w-full text-sm"
|
||||
value={laneId ?? ""}
|
||||
onChange={(e) => setLaneId(e.target.value ? Number(e.target.value) : null)}
|
||||
aria-label={t("workspace")}
|
||||
>
|
||||
<option value="" />
|
||||
{(lanes ?? []).map((l) => (
|
||||
<option key={l.id} value={l.id}>
|
||||
{(l.title || l.cwd) +
|
||||
" — " +
|
||||
(l.owner_id == null
|
||||
? t("noOwner")
|
||||
: t("ownedBy", { name: ownerName(l.owner_id) }))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t("yourPassword")} hint={t("yourPasswordHint")}>
|
||||
<input
|
||||
type="password"
|
||||
className="input w-full text-sm"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
aria-label={t("yourPassword")}
|
||||
/>
|
||||
</Field>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-fg-secondary">{label}</span>
|
||||
<div className="mt-1">{children}</div>
|
||||
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -67,7 +67,7 @@ import { api } from "../lib/api";
|
||||
import type { CwdSuggestion, RunHandle, RunListResponse } from "../lib/api";
|
||||
import type { Lane, LaneFeature, ProofFeature, Session } from "../lib/types";
|
||||
import { toast } from "../lib/toast";
|
||||
import { confirmDiscard } from "../lib/confirm";
|
||||
import { confirmDialog, confirmDiscard } from "../lib/confirm";
|
||||
import { keepIfSame } from "../lib/keepIfSame";
|
||||
import { useLanes } from "../hooks/useLanes";
|
||||
import { LaneConsolePane } from "../components/run/LaneConsolePane";
|
||||
@@ -347,6 +347,29 @@ function ConsoleArea({
|
||||
else onNewSession?.();
|
||||
};
|
||||
|
||||
// A pane header's X: stops that one session, as a one-pane tab's X does —
|
||||
// an AI session may be mid-turn, so it asks first; a shell holds no agent.
|
||||
const stopPane = async (slot: number) => {
|
||||
const run = runOf(slot);
|
||||
if (!run) return;
|
||||
if (
|
||||
run.tool !== "shell" &&
|
||||
!(await confirmDialog({
|
||||
title: t("tabs.stopTitle", { name: label(run) }),
|
||||
message: t("tabs.stopBody"),
|
||||
confirmLabel: t("tabs.stopConfirm"),
|
||||
destructive: true,
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
await api.run.kill(run.id);
|
||||
ended(slot);
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
// A split menu's "New terminal" or AI tool (bare, as the tab bar's `+`
|
||||
// starts it) in the first free slot, placed on that side of the pane —
|
||||
// unless the workspace changed while it started.
|
||||
@@ -466,6 +489,7 @@ function ConsoleArea({
|
||||
toolLabel={aiTools.label}
|
||||
onNewSplit={(target, edge, tool) => void newSplit(target, edge, tool)}
|
||||
stateOf={stateOf}
|
||||
onStop={(slot) => void stopPane(slot)}
|
||||
renderPane={(slot) => (
|
||||
<LaneConsolePane
|
||||
lanes={lanes}
|
||||
|
||||
@@ -34,7 +34,7 @@ vi.mock("../../lib/api", () => ({
|
||||
RUN_MODEL_CHOICES: [],
|
||||
}));
|
||||
vi.mock("../../components/settings/PricingSettings", () => ({
|
||||
PricingSettings: ({ vendor }: { vendor: string }) => <div>pricing:{vendor}</div>,
|
||||
PricingLink: ({ vendor }: { vendor: string }) => <div>pricing:{vendor}</div>,
|
||||
}));
|
||||
|
||||
const GEMINI: AiTool = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* @file Settings.test.tsx
|
||||
* @description The install-wide Settings pages: General and Notifications never
|
||||
* @description The install-wide Settings pages: System shows the server's
|
||||
* runtime, install and database facts; General and Notifications never
|
||||
* read `/api/settings/info` (it COUNTs whole tables), Data reads it once and
|
||||
* coalesces a burst of activity instead of re-reading per event, a purge takes
|
||||
* a whole number of days and runs only after a dry-run count is confirmed, and
|
||||
@@ -38,9 +39,21 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
...actual,
|
||||
api: {
|
||||
pricing: { totalCost: vi.fn(async () => ({ total_cost: 1 })) },
|
||||
updates: {
|
||||
status: vi.fn(async () => ({
|
||||
git_repo: false,
|
||||
update_available: true,
|
||||
install_kind: "plugin",
|
||||
repo_root: "/home/u/.claude/plugins/cache/agenthub/9.9.8",
|
||||
local_version: "9.9.8",
|
||||
latest_release: "v9.9.9",
|
||||
})),
|
||||
},
|
||||
settings: {
|
||||
info: (...a: unknown[]) => info(...(a as [])),
|
||||
cleanup: (p: { dry_run?: boolean }) => cleanup(p),
|
||||
autostart: { get: vi.fn(async () => ({ available: false })) },
|
||||
tray: { get: vi.fn(async () => ({ available: false })) },
|
||||
clearData: () => clearData(),
|
||||
gc: vi.fn(async () => ({ reaped: [], capped: [] })),
|
||||
exportData: () => "/api/settings/export",
|
||||
@@ -81,6 +94,47 @@ describe("Settings", () => {
|
||||
expect(info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the server's runtime, install and database facts on System", async () => {
|
||||
info.mockImplementationOnce(
|
||||
async () =>
|
||||
({
|
||||
db: {
|
||||
path: "/data/dashboard.db",
|
||||
size: 5 * 1024 * 1024,
|
||||
counts: {},
|
||||
pragmas: { journal_mode: "wal" },
|
||||
load_stats: { m5: 3, m15: 9, h1: 40 },
|
||||
},
|
||||
hooks: { installed: true, path: "", hooks: {} },
|
||||
server: {
|
||||
version: "1",
|
||||
uptime: 3700,
|
||||
node_version: "v26.0.0",
|
||||
platform: "linux",
|
||||
arch: "x64",
|
||||
cpus: 8,
|
||||
cpu_load: [0.5, 0.25, 0.125],
|
||||
total_mem: 16 * 1024 ** 3,
|
||||
free_mem: 4 * 1024 ** 3,
|
||||
memory: { rss: 200 * 1024 ** 2, heapUsed: 50 * 1024 ** 2, heapLimit: 4 * 1024 ** 3 },
|
||||
ws_connections: 2,
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
render(<Settings section="about" />);
|
||||
expect(await screen.findByText("/data/dashboard.db")).toBeInTheDocument();
|
||||
expect(screen.getByText("1h 1m")).toBeInTheDocument();
|
||||
expect(screen.getByText(/linux · x64 · Node.js v26.0.0/)).toBeInTheDocument();
|
||||
expect(screen.getByText("8 cores · load 0.50 / 0.25 / 0.13")).toBeInTheDocument();
|
||||
expect(screen.getByText("200.0 MB RSS · heap 50.0 MB of 4.0 GB")).toBeInTheDocument();
|
||||
expect(screen.getByText("12.0 GB used of 16.0 GB")).toBeInTheDocument();
|
||||
expect(screen.getByText("wal")).toBeInTheDocument();
|
||||
expect(screen.getByText("3 in 5 min · 9 in 15 min · 40 in 1 h")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Claude Code plugin")).toBeInTheDocument();
|
||||
expect(screen.getByText("v9.9.9")).toBeInTheDocument();
|
||||
expect(screen.getByText("Update available")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reads info once on Data and coalesces a burst of activity", async () => {
|
||||
render(<Settings section="data" />);
|
||||
await screen.findByText("/tmp/x.db");
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
* @file SettingsHub.test.tsx
|
||||
* @description The Settings page shell: a path picks its section, `/settings`
|
||||
* and the legacy `?tool=` / `#ai-tools` links redirect to the path form, an
|
||||
* unknown section falls back to General, a disabled tool has no page while an
|
||||
* unknown section falls back to Appearance, a disabled tool has no page while an
|
||||
* installed, enabled catalog tool gets one, the mobile
|
||||
* select navigates, and the search filters the nav, opens the first match on
|
||||
* Enter, clears on Escape and is focused by Ctrl+F (a second press selects its
|
||||
* text), its result list deep-links a row's sub-tab, an anchor in the URL is
|
||||
* highlighted, Pricing sits in its own Cost group, and an unknown section toasts.
|
||||
* highlighted, the nav has three groups (General, Agents, Data) with no
|
||||
* one-page group, Git and Snapshots & reports are one page each (their old
|
||||
* paths redirect there), and an unknown section toasts.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -31,7 +33,18 @@ vi.mock("../AiToolConfig", () => ({
|
||||
AiToolConfig: ({ toolId }: { toolId: string }) => <div data-testid={`tool-${toolId}`} />,
|
||||
}));
|
||||
vi.mock("../AiProxyConfig", () => ({ AiProxyConfig: () => null }));
|
||||
vi.mock("../GitCredentialsConfig", () => ({ GitCredentialsConfig: () => null }));
|
||||
vi.mock("../GitCredentialsConfig", () => ({
|
||||
GitCredentialsConfig: () => <div data-testid="git-credentials" />,
|
||||
}));
|
||||
vi.mock("../../components/settings/WorktreeVisibilitySettings", () => ({
|
||||
WorktreeVisibilitySettings: () => <div data-testid="worktree-visibility" />,
|
||||
}));
|
||||
vi.mock("../../components/settings/SnapshotSettings", () => ({
|
||||
SnapshotSettings: () => <div data-testid="snapshot-settings" />,
|
||||
}));
|
||||
vi.mock("../../components/settings/ReportSettings", () => ({
|
||||
ReportSettings: () => <div data-testid="report-settings" />,
|
||||
}));
|
||||
vi.mock("../../components/settings/AiToolsSettings", () => ({
|
||||
AiToolsSettings: () => <div data-testid="ai-tools" />,
|
||||
}));
|
||||
@@ -124,6 +137,10 @@ describe("SettingsHub", () => {
|
||||
["/settings?tool=claude-code", "/settings/agents/claude-code"],
|
||||
["/settings?tool=gemini", "/settings/agents/gemini"],
|
||||
["/settings#ai-tools", "/settings/agents"],
|
||||
["/settings/about", "/settings/system"],
|
||||
["/settings/snapshots", "/settings/reports"],
|
||||
["/settings/worktrees", "/settings/git"],
|
||||
["/settings/git-credentials", "/settings/git"],
|
||||
])("redirects %s to %s", async (from, to) => {
|
||||
renderAt(from);
|
||||
await screen.findByRole("heading", { level: 1 });
|
||||
@@ -170,7 +187,7 @@ describe("SettingsHub", () => {
|
||||
const links = within(nav())
|
||||
.getAllByRole("link")
|
||||
.map((a) => a.textContent);
|
||||
expect(links).toEqual(["Claude Code", "Codex", "Pricing"]);
|
||||
expect(links).toEqual(["Pricing"]);
|
||||
expect(within(nav()).getByRole("link", { name: "Pricing" })).toHaveAttribute(
|
||||
"data-first-match",
|
||||
"true"
|
||||
@@ -234,18 +251,38 @@ describe("SettingsHub", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("puts Pricing in its own Cost group and folds language and pet into Appearance", async () => {
|
||||
it("groups the nav into General, Agents and Data, with no one-page group", async () => {
|
||||
renderAt("/settings/data");
|
||||
expect(await screen.findByTestId("settings-data")).toBeInTheDocument();
|
||||
expect(within(nav()).getByText("Cost")).toBeInTheDocument();
|
||||
const groups = within(nav())
|
||||
.getAllByRole("group")
|
||||
.map((g) => [g.getAttribute("aria-label"), within(g).getAllByRole("link").length]);
|
||||
expect(groups.map(([name]) => name)).toEqual(["General", "Agents", "Data"]);
|
||||
for (const [, count] of groups) expect(count).toBeGreaterThan(1);
|
||||
const links = within(nav())
|
||||
.getAllByRole("link")
|
||||
.map((a) => a.textContent);
|
||||
expect(links.slice(0, 3)).toEqual(["Data", "About", "Appearance"]);
|
||||
expect(links).not.toContain("General");
|
||||
expect(links.slice(0, 3)).toEqual(["Appearance", "Notifications", "System"]);
|
||||
expect(links).toEqual(expect.arrayContaining(["AI Proxy", "Pricing", "Git"]));
|
||||
expect(links).not.toContain("About");
|
||||
expect(links).not.toContain("Worktrees");
|
||||
expect(links).not.toContain("Pet");
|
||||
});
|
||||
|
||||
it("shows git logins and worktree suggestions on one Git page", async () => {
|
||||
renderAt("/settings/git");
|
||||
expect(await screen.findByRole("heading", { level: 1, name: "Git" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("git-credentials")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("worktree-visibility")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows snapshots and scheduled reports on one page", async () => {
|
||||
renderAt("/settings/reports");
|
||||
await screen.findByRole("heading", { level: 1, name: "Snapshots & reports" });
|
||||
expect(screen.getByTestId("snapshot-settings")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("report-settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("scrolls to and highlights the anchor the URL names", async () => {
|
||||
renderAt("/settings/data#data-row");
|
||||
const row = await screen.findByTestId("settings-data");
|
||||
@@ -272,7 +309,9 @@ describe("SettingsHub", () => {
|
||||
const off = subscribeToasts((i) => (seen = i));
|
||||
renderAt("/settings/nope");
|
||||
await screen.findByTestId("pet-settings");
|
||||
expect(seen.some((x) => x.message.includes("nope"))).toBe(true);
|
||||
const msg = seen.find((x) => x.message.includes("nope"))?.message ?? "";
|
||||
expect(msg).toContain("Appearance");
|
||||
expect(msg).not.toContain("General");
|
||||
off();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* @file Users.test.tsx
|
||||
* @description The Users page: local mode and a member are sent home; an
|
||||
* analyst reads the table with no account controls; an admin adds a user only
|
||||
* with their own password typed (the confirm stays disabled until then), sees
|
||||
* a wrong password reported in the dialog, and cannot change their own role or
|
||||
* disable themselves.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import type { AuthState, ServerUserUsage } from "../../lib/api";
|
||||
|
||||
let authState: AuthState = { mode: "local" };
|
||||
vi.mock("../../components/AuthGate", () => ({
|
||||
useAuth: () => ({ state: authState, logout: async () => {} }),
|
||||
}));
|
||||
|
||||
const usage = { cost: 0, tokens: 0 };
|
||||
const row = (id: number, username: string, role: ServerUserUsage["role"]): ServerUserUsage => ({
|
||||
id,
|
||||
username,
|
||||
role,
|
||||
disabled: false,
|
||||
workspaces: 0,
|
||||
sessions: 0,
|
||||
last7d: usage,
|
||||
last30d: usage,
|
||||
});
|
||||
const users = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
setPassword: vi.fn(),
|
||||
assignWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
users,
|
||||
lanes: { list: vi.fn(async () => ({ lanes: [], counts: {} })) },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { Users } from "../Users";
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={["/users"]}>
|
||||
<Routes>
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/" element={<div data-testid="home" />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(users)) fn.mockReset();
|
||||
users.list.mockResolvedValue({
|
||||
users: [row(1, "boss", "admin"), row(2, "mia", "member")],
|
||||
});
|
||||
users.audit.mockResolvedValue({ entries: [] });
|
||||
});
|
||||
|
||||
describe("Users page", () => {
|
||||
it("sends local mode and a member home", async () => {
|
||||
authState = { mode: "local" };
|
||||
renderPage();
|
||||
expect(await screen.findByTestId("home")).toBeTruthy();
|
||||
authState = { mode: "server", user: { id: 2, username: "mia", role: "member" } };
|
||||
renderPage();
|
||||
expect((await screen.findAllByTestId("home")).length).toBeGreaterThan(0);
|
||||
expect(users.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an analyst the accounts with no controls", async () => {
|
||||
authState = { mode: "server", user: { id: 3, username: "ana", role: "analyst" } };
|
||||
renderPage();
|
||||
expect(await screen.findByText("mia")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Add user" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Reset password" })).toBeNull();
|
||||
expect(screen.getByText("Analysts can view accounts but not change them.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("adds a user only with the admin's own password", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
users.create.mockResolvedValue({
|
||||
user: { id: 9, username: "neo", role: "member", disabled: false },
|
||||
});
|
||||
const ue = userEvent.setup();
|
||||
renderPage();
|
||||
await ue.click(await screen.findByRole("button", { name: "Add user" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await ue.type(screen.getByLabelText("Username"), "neo");
|
||||
await ue.type(screen.getByLabelText("Password"), "neo-pass-123");
|
||||
const confirm = Array.from(dialog.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Add user"
|
||||
)!;
|
||||
expect(confirm.hasAttribute("disabled")).toBe(true);
|
||||
await ue.type(screen.getByLabelText("Your password"), "boss-pass");
|
||||
expect(confirm.hasAttribute("disabled")).toBe(false);
|
||||
await ue.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(users.create).toHaveBeenCalledWith(
|
||||
{ username: "neo", password: "neo-pass-123", role: "member" },
|
||||
"boss-pass"
|
||||
)
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(users.list).toHaveBeenCalledTimes(2); // reloaded after the change
|
||||
});
|
||||
|
||||
it("reports a wrong password in the dialog and keeps it open", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
users.setPassword.mockRejectedValue(
|
||||
Object.assign(new Error("your password is wrong"), { code: "EREAUTH" })
|
||||
);
|
||||
const ue = userEvent.setup();
|
||||
renderPage();
|
||||
await screen.findByText("mia");
|
||||
await ue.click(screen.getAllByRole("button", { name: "Reset password" })[1]!);
|
||||
await ue.type(screen.getByLabelText("New password"), "mia-new-pass-1");
|
||||
await ue.type(screen.getByLabelText("Your password"), "wrong{Enter}");
|
||||
expect(await screen.findByRole("alert")).toHaveProperty(
|
||||
"textContent",
|
||||
"Your password is wrong."
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
expect(users.setPassword).toHaveBeenCalledWith(2, "mia-new-pass-1", "wrong");
|
||||
});
|
||||
|
||||
it("never offers an admin a role change or disable on their own row", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
renderPage();
|
||||
await screen.findByText("mia");
|
||||
const [ownRole, miaRole] = screen.getAllByRole("button", { name: "Change role" });
|
||||
const [ownLock, miaLock] = screen.getAllByRole("button", { name: "Disable" });
|
||||
expect(ownRole!.hasAttribute("disabled")).toBe(true);
|
||||
expect(ownLock!.hasAttribute("disabled")).toBe(true);
|
||||
expect(miaRole!.hasAttribute("disabled")).toBe(false);
|
||||
expect(miaLock!.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -758,6 +758,21 @@ describe("split panes", () => {
|
||||
expect(screen.getByTestId("run-param").textContent).toBe("agenthub-lane-1-1");
|
||||
});
|
||||
|
||||
it("a pane's X stops just its session, without taking it out of the tab first", async () => {
|
||||
withRuns([live(0), live(1), live(2)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
await ctrlClick(screen.getByTestId("console-tab-2"));
|
||||
|
||||
withRuns([live(0), live(2)]);
|
||||
await userEvent.setup().click(screen.getByTestId("pane-stop-1"));
|
||||
await settle();
|
||||
expect(api.run.kill).toHaveBeenCalledTimes(1);
|
||||
expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1-1");
|
||||
expect(shownPanes()).toEqual(["pane-0", "pane-2"]);
|
||||
expect(screen.queryByTestId("console-tab-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a remembered pane whose session is gone", async () => {
|
||||
window.localStorage.setItem(
|
||||
"agenthub.workspace.paneLayout",
|
||||
|
||||
@@ -2765,137 +2765,71 @@ exports[`screen snapshots > Claude Config 1`] = `
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
id="pricing"
|
||||
<div
|
||||
class="card flex items-center justify-between gap-4 p-5"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 mb-4"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div>
|
||||
<h3
|
||||
class="text-sm font-medium text-fg-secondary flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-dollar-sign w-4 h-4 text-fg-muted"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="2"
|
||||
y2="22"
|
||||
/>
|
||||
<path
|
||||
d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"
|
||||
/>
|
||||
</svg>
|
||||
Model Pricing
|
||||
<button
|
||||
aria-label="How model pricing works"
|
||||
class="inline-flex items-center justify-center rounded-full p-0.5 text-fg-muted hover:text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-info w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<path
|
||||
d="M12 16v-4"
|
||||
/>
|
||||
<path
|
||||
d="M12 8h.01"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</h3>
|
||||
<p
|
||||
class="text-xs text-fg-muted mt-0.5"
|
||||
>
|
||||
Rates per million tokens (USD). Use % as wildcard in patterns. Some models have a time-limited introductory rate (shown in violet); usage on or before its cutoff date is billed at that rate, and usage after it at the standard rate.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
<h3
|
||||
class="flex items-center gap-2 text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
<button
|
||||
class="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
|
||||
<svg
|
||||
class="lucide lucide-dollar-sign h-4 w-4 text-fg-muted"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-rotate-ccw w-3 h-3"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
|
||||
/>
|
||||
<path
|
||||
d="M3 3v5h5"
|
||||
/>
|
||||
</svg>
|
||||
Reset Defaults
|
||||
</button>
|
||||
<button
|
||||
class="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-plus w-3.5 h-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5 12h14"
|
||||
/>
|
||||
<path
|
||||
d="M12 5v14"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
Add Model
|
||||
</button>
|
||||
</div>
|
||||
<line
|
||||
x1="12"
|
||||
x2="12"
|
||||
y1="2"
|
||||
y2="22"
|
||||
/>
|
||||
<path
|
||||
d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"
|
||||
/>
|
||||
</svg>
|
||||
Model pricing
|
||||
</h3>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
Prices for every vendor's models live on one page.
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
class="card mt-4 px-4 py-6 text-center text-xs text-fg-muted"
|
||||
<a
|
||||
class="btn-ghost shrink-0 text-xs"
|
||||
href="/settings/pricing#pricing-claude"
|
||||
>
|
||||
No Claude model prices. Reset Defaults restores them.
|
||||
</p>
|
||||
</section>
|
||||
Open Pricing
|
||||
<svg
|
||||
class="lucide lucide-arrow-right h-3.5 w-3.5"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5 12h14"
|
||||
/>
|
||||
<path
|
||||
d="m12 5 7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7142,333 +7076,404 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<div
|
||||
class="animate-fade-in space-y-8"
|
||||
>
|
||||
<section
|
||||
class="scroll-mt-24"
|
||||
<div
|
||||
class="space-y-6 scroll-mt-24"
|
||||
id="about"
|
||||
>
|
||||
<div
|
||||
class="mb-4"
|
||||
<section
|
||||
class="card p-5 scroll-mt-24"
|
||||
id="autostart"
|
||||
>
|
||||
<section
|
||||
class="card p-5 scroll-mt-24"
|
||||
id="autostart"
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Startup
|
||||
</h3>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Startup
|
||||
</h3>
|
||||
<div
|
||||
class="mt-2"
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Start AgentHub automatically
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
Start AgentHub automatically
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-label="Start AgentHub automatically"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
Run the dashboard as a systemd --user service, so it starts at boot without Claude Code being opened. Turning it off keeps the running server up.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<button
|
||||
aria-checked="false"
|
||||
aria-label="Start AgentHub automatically"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-border-light ring-1 ring-inset ring-fg-muted/80"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-0.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-fg-muted mb-4"
|
||||
>
|
||||
Server runtime information.
|
||||
</p>
|
||||
<div
|
||||
class="card p-5"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3"
|
||||
>
|
||||
<div
|
||||
class="bg-surface-2 rounded-lg px-4 py-3"
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 mb-1.5"
|
||||
class="min-w-0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-server w-4 h-4 text-accent"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="8"
|
||||
rx="2"
|
||||
ry="2"
|
||||
width="20"
|
||||
x="2"
|
||||
y="2"
|
||||
/>
|
||||
<rect
|
||||
height="8"
|
||||
rx="2"
|
||||
ry="2"
|
||||
width="20"
|
||||
x="2"
|
||||
y="14"
|
||||
/>
|
||||
<line
|
||||
x1="6"
|
||||
x2="6.01"
|
||||
y1="6"
|
||||
y2="6"
|
||||
/>
|
||||
<line
|
||||
x1="6"
|
||||
x2="6.01"
|
||||
y1="18"
|
||||
y2="18"
|
||||
/>
|
||||
</svg>
|
||||
<p
|
||||
class="text-[11px] text-fg-muted uppercase tracking-wider"
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Show AgentHub icon in the status area
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
An AgentHub icon beside the clock (top bar, menu bar or notification area) to open the dashboard, quit it, and start it again after a quit.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<button
|
||||
aria-checked="true"
|
||||
aria-label="Show AgentHub icon in the status area"
|
||||
class="relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface-0 disabled:cursor-not-allowed disabled:opacity-50 bg-accent-solid"
|
||||
role="switch"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="inline-block h-4 w-4 rounded-full bg-white shadow transition-transform translate-x-[18px]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Quit AgentHub
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
Stop the dashboard so nothing runs in the background. Opening Claude Code no longer starts it; run \`agenthub start\` to bring it back.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<button
|
||||
class="btn-ghost text-xs text-status-danger disabled:opacity-40"
|
||||
data-testid="quit-agenthub"
|
||||
type="button"
|
||||
>
|
||||
Quit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="card p-5 scroll-mt-24"
|
||||
id="server"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Server
|
||||
</h3>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Release
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.5.12 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm font-semibold text-fg-secondary font-mono"
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
v
|
||||
</p>
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v2.5.11 (rebuild client to match)
|
||||
</p>
|
||||
<span
|
||||
class="text-sm text-fg-primary font-mono text-xs"
|
||||
>
|
||||
v
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-surface-2 rounded-lg px-4 py-3"
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 mb-1.5"
|
||||
class="min-w-0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-clock w-4 h-4 text-accent"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<polyline
|
||||
points="12 6 12 12 16 14"
|
||||
/>
|
||||
</svg>
|
||||
<p
|
||||
class="text-[11px] text-fg-muted uppercase tracking-wider"
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Uptime
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm font-semibold text-fg-secondary"
|
||||
>
|
||||
0m
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="bg-surface-2 rounded-lg px-4 py-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 mb-1.5"
|
||||
class="shrink-0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-cpu w-4 h-4 text-status-success"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
<span
|
||||
class="text-sm text-fg-primary "
|
||||
>
|
||||
<rect
|
||||
height="16"
|
||||
rx="2"
|
||||
width="16"
|
||||
x="4"
|
||||
y="4"
|
||||
/>
|
||||
<rect
|
||||
height="6"
|
||||
rx="1"
|
||||
width="6"
|
||||
x="9"
|
||||
y="9"
|
||||
/>
|
||||
<path
|
||||
d="M15 2v2"
|
||||
/>
|
||||
<path
|
||||
d="M15 20v2"
|
||||
/>
|
||||
<path
|
||||
d="M2 15h2"
|
||||
/>
|
||||
<path
|
||||
d="M2 9h2"
|
||||
/>
|
||||
<path
|
||||
d="M20 15h2"
|
||||
/>
|
||||
<path
|
||||
d="M20 9h2"
|
||||
/>
|
||||
<path
|
||||
d="M9 2v2"
|
||||
/>
|
||||
<path
|
||||
d="M9 20v2"
|
||||
/>
|
||||
</svg>
|
||||
<p
|
||||
class="text-[11px] text-fg-muted uppercase tracking-wider"
|
||||
>
|
||||
Node.js
|
||||
</p>
|
||||
0m
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm font-semibold text-fg-secondary font-mono"
|
||||
>
|
||||
v22.0.0
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="bg-surface-2 rounded-lg px-4 py-3"
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 mb-1.5"
|
||||
class="min-w-0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-globe w-4 h-4 text-syntax-keyword"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/>
|
||||
<path
|
||||
d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"
|
||||
/>
|
||||
<path
|
||||
d="M2 12h20"
|
||||
/>
|
||||
</svg>
|
||||
<p
|
||||
class="text-[11px] text-fg-muted uppercase tracking-wider"
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Platform
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm font-semibold text-fg-secondary"
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
linux
|
||||
</p>
|
||||
<span
|
||||
class="text-sm text-fg-primary font-mono text-xs"
|
||||
>
|
||||
linux
|
||||
· x64
|
||||
· Node.js
|
||||
v22.0.0
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-surface-2 rounded-lg px-4 py-3"
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 mb-1.5"
|
||||
class="min-w-0"
|
||||
>
|
||||
<svg
|
||||
class="lucide lucide-wifi w-4 h-4 text-status-warning"
|
||||
fill="none"
|
||||
height="24"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 20h.01"
|
||||
/>
|
||||
<path
|
||||
d="M2 8.82a15 15 0 0 1 20 0"
|
||||
/>
|
||||
<path
|
||||
d="M5 12.859a10 10 0 0 1 14 0"
|
||||
/>
|
||||
<path
|
||||
d="M8.5 16.429a5 5 0 0 1 7 0"
|
||||
/>
|
||||
</svg>
|
||||
<p
|
||||
class="text-[11px] text-fg-muted uppercase tracking-wider"
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
WS Clients
|
||||
CPU
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
class="text-sm font-semibold text-fg-secondary"
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
0
|
||||
</p>
|
||||
<span
|
||||
class="text-sm text-fg-primary "
|
||||
>
|
||||
1 cores · load 0.00 / 0.00 / 0.00
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Process memory
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-fg-primary "
|
||||
>
|
||||
0 B RSS · heap 0 B of 0 B
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Live connections
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
Browser tabs and tools receiving live updates
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-fg-primary "
|
||||
>
|
||||
0
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
<section
|
||||
class="card p-5 scroll-mt-24"
|
||||
id="install"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Installation
|
||||
</h3>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Unknown
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="card p-5 scroll-mt-24"
|
||||
id="database"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold text-fg-primary"
|
||||
>
|
||||
Database
|
||||
</h3>
|
||||
<div
|
||||
class="mt-2"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-3 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Database file
|
||||
</p>
|
||||
<p
|
||||
class="mt-0.5 text-xs text-fg-muted"
|
||||
>
|
||||
<span
|
||||
class="font-mono break-all"
|
||||
>
|
||||
/tmp/test.db
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-fg-primary "
|
||||
>
|
||||
0 B
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Journal mode
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-fg-primary font-mono text-xs"
|
||||
>
|
||||
wal
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex justify-between gap-4 border-b border-border/60 last:border-b-0 py-2 items-center"
|
||||
>
|
||||
<div
|
||||
class="min-w-0"
|
||||
>
|
||||
<p
|
||||
class="text-sm text-fg-secondary"
|
||||
>
|
||||
Events recorded
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0"
|
||||
>
|
||||
<span
|
||||
class="text-sm text-fg-primary "
|
||||
>
|
||||
0 in 5 min · 0 in 15 min · 0 in 1 h
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -338,6 +338,10 @@ vi.mock("../../lib/api", async (importOriginal) => {
|
||||
get: r({ available: true, kind: "systemd", enabled: false, foreign: false }),
|
||||
set: r({ available: true, kind: "systemd", enabled: true, foreign: false }),
|
||||
},
|
||||
tray: {
|
||||
get: r({ available: true, enabled: true, running: true }),
|
||||
set: r({ available: true, enabled: false, running: false }),
|
||||
},
|
||||
privacy: {
|
||||
get: r({
|
||||
redact_secrets: false,
|
||||
@@ -639,11 +643,11 @@ describe("screen snapshots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Pricing, hook status and CLAUDE_HOME belong to Claude Code (#40 part B):
|
||||
// they sit under the Claude Code tab's Overview, not on Dashboard or on the
|
||||
// other sub-tabs.
|
||||
// Hook status and CLAUDE_HOME belong to Claude Code (#40 part B): they sit
|
||||
// under the Claude Code tab's Overview, not on Dashboard or on the other
|
||||
// sub-tabs. Its prices live on the Pricing page; the Overview only links there.
|
||||
describe("Claude Code's AgentHub settings", () => {
|
||||
it("shows pricing, hooks and CLAUDE_HOME on the Overview only", async () => {
|
||||
it("shows hooks and CLAUDE_HOME on the Overview only", async () => {
|
||||
const dashboard = render(
|
||||
<MemoryRouter initialEntries={["/settings"]}>
|
||||
<Settings />
|
||||
@@ -662,12 +666,14 @@ describe("Claude Code's AgentHub settings", () => {
|
||||
);
|
||||
await settle();
|
||||
expect(screen.queryByTestId("cc-tab-agenthub")).toBeNull();
|
||||
for (const id of ["pricing", "hooks", "claude-home"]) {
|
||||
for (const id of ["hooks", "claude-home"]) {
|
||||
expect(container.querySelector(`section#${id}`)).not.toBeNull();
|
||||
}
|
||||
expect(container.querySelector("section#pricing")).toBeNull();
|
||||
expect(container.querySelector('a[href="/settings/pricing#pricing-claude"]')).not.toBeNull();
|
||||
fireEvent.click(screen.getByTestId("cc-tab-skills"));
|
||||
await settle();
|
||||
expect(container.querySelector("section#pricing")).toBeNull();
|
||||
expect(container.querySelector("section#hooks")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user