Files
AgentHub/client/src/components/users/UserDetail.tsx
T
nntrivi2001 0f17efe3b4 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.
2026-10-28 10:41:53 +07:00

334 lines
13 KiB
TypeScript

/**
* @file UserDetail.tsx
* @description One account, opened IN PLACE under its row of the
* Administration page's users table (`?user=<id>`, `GET /api/users/:id/details`).
* The row already shows identity, sessions, spend, home and tmux, so
* this keeps only what the row cannot: one summary line (last sign-in, last
* activity, active sessions and live panes), then three short columns — spend
* (30-day bars, by AI tool, top models), the account pool (per tool: pin or
* group, each account's quota left beside this user's own 7-day share), and
* what runs now (live panes, owned workspaces) — and the five newest sessions. Read-only; Edit stays on
* the row.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { AlertCircle, Play, RefreshCw } from "lucide-react";
import { api, type PoolTool, type UserDetails } from "../../lib/api";
import { fmtCost, formatDateTime } from "../../lib/format";
import { effectiveSessionStatus, sessionAwaitingReason } from "../../lib/types";
import { useSpin } from "../../hooks/useSpin";
import { useAiTools } from "../../hooks/useAiTools";
import { AiToolIcon } from "../AiToolIcon";
import { SessionStatusBadge } from "../StatusBadge";
import { MiniChart } from "../admin/MiniChart";
import { QuotaBar, windowsOf } from "./AccountPoolCard";
const POOL_LABEL: Record<PoolTool, string> = {
claude: "Claude Code",
codex: "Codex",
cursor: "Cursor CLI",
};
const pctText = (n: number) => `${n < 10 ? n.toFixed(1) : Math.round(n)}%`;
export function UserDetail({ userId }: { userId: number }) {
const { t } = useTranslation("users");
const { label: toolLabel } = useAiTools();
const [data, setData] = useState<UserDetails | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(
() =>
api.users
.details(userId)
.then((d) => {
setData(d);
setError(null);
})
.catch((e: Error) => setError(e.message)),
[userId]
);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
setData(null);
void load();
}, [load]);
if (error)
return (
<div
role="alert"
data-testid="user-detail"
className="flex flex-wrap items-center gap-3 text-sm text-status-danger"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 min-w-0">
{t("detail.failed")}: {error}
</span>
<button type="button" onClick={refresh} className="btn-ghost text-xs">
{t("common:retry")}
</button>
</div>
);
if (!data)
return (
<p data-testid="user-detail" className="py-4 text-center text-sm text-fg-muted">
{t("common:loading")}
</p>
);
const u = data.user;
const total30 = data.usage.last30d.cost;
const pools = (Object.keys(POOL_LABEL) as PoolTool[]).filter(
(tool) => (data.pool[tool]?.accounts.length ?? 0) > 0
);
return (
<div className="space-y-4" data-testid="user-detail">
{/* One line of what the row does not show. */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-1 text-xs">
<Inline label={t("detail.lastLogin")}>
{u.lastLoginAt ? formatDateTime(u.lastLoginAt) : t("detail.never")}
</Inline>
<Inline label={t("detail.lastSession")}>
{data.sessions.lastAt ? formatDateTime(data.sessions.lastAt) : "—"}
</Inline>
<Inline label={t("detail.activeLabel")}>
{t("detail.activeValue", { sessions: data.sessions.active, panes: data.panes.length })}
</Inline>
<button
type="button"
onClick={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost ml-auto py-1 text-xs"
>
<RefreshCw className={`w-3.5 h-3.5 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
{/* Spend: the month at a glance, then where it went. */}
<Panel title={t("detail.spendDaily")}>
<MiniChart
kind="bar"
label={t("detail.spendDaily")}
points={data.usage.daily.map((d) => ({ x: d.date.slice(5), y: d.cost }))}
format={(v) => fmtCost(v)}
/>
{data.usage.byTool.length === 0 ? (
<p className="text-xs text-fg-muted">{t("detail.noUsage")}</p>
) : (
<ul className="space-y-1.5 pt-1">
{data.usage.byTool.map((r) => (
<ShareRow
key={r.key}
label={
<span className="flex items-center gap-1.5">
<AiToolIcon
tool={{ id: r.key, label: toolLabel(r.key) }}
className="h-4 w-4"
/>
{toolLabel(r.key)}
</span>
}
cost={r.cost}
total={total30}
/>
))}
{data.usage.byModel.slice(0, 3).map((r) => (
<ShareRow
key={`m:${r.key}`}
label={<span className="font-mono text-[11px] text-fg-muted">{r.key}</span>}
cost={r.cost}
total={total30}
/>
))}
</ul>
)}
</Panel>
{/* Pool: what they may run on, and how much of it is theirs. */}
<Panel title={t("detail.pool")}>
{pools.length === 0 ? (
<p className="text-xs text-fg-muted">{t("detail.poolNone")}</p>
) : (
pools.map((tool) => {
const p = data.pool[tool]!;
return (
<div key={tool} className="space-y-2">
<div className="flex flex-wrap items-center gap-1.5 text-xs">
<AiToolIcon tool={{ id: tool, label: POOL_LABEL[tool] }} className="h-4 w-4" />
<span className="font-medium text-fg-primary">{POOL_LABEL[tool]}</span>
<span className="text-fg-muted">
·{" "}
{p.pin
? t("detail.poolPinned", { account: p.pin })
: p.choice
? t("detail.poolChosen", { account: p.choice })
: p.group
? t("detail.poolGroup", { name: p.group.name })
: t("detail.poolShared")}
</span>
</div>
{p.accounts.map((a) => {
const share = a.spend7d > 0 ? (a.userSpend7d / a.spend7d) * 100 : 0;
return (
<div
key={a.name}
className={`rounded-md border border-border px-2.5 py-2 space-y-1 ${a.allowed ? "" : "opacity-60"}`}
data-testid="user-pool-account"
>
<div className="flex items-center justify-between gap-2 text-xs">
<span className="truncate font-medium text-fg-primary">
{a.name}
{a.expired && (
<span className="ml-1.5 text-status-danger">{t("pool.expired")}</span>
)}
{!a.allowed && (
<span className="ml-1.5 text-fg-muted">
{t("detail.poolNotAllowed")}
</span>
)}
</span>
{tool !== "cursor" && (
<span
className="flex-shrink-0 tabular-nums text-fg-secondary"
title={t("detail.poolTotal7d", { cost: fmtCost(a.spend7d) })}
>
{t("detail.poolTheirs7d", { cost: fmtCost(a.userSpend7d) })}
{a.spend7d > 0 && (
<span className="text-fg-muted"> ({pctText(share)})</span>
)}
</span>
)}
</div>
{tool !== "cursor" &&
windowsOf(a).map((w) => <QuotaBar key={w.key} window={w} />)}
</div>
);
})}
</div>
);
})
)}
</Panel>
{/* Now: live panes and the workspaces they own. */}
<Panel title={t("detail.nowTitle")}>
{data.panes.length === 0 ? (
<p className="text-xs text-fg-muted">{t("detail.noPanes")}</p>
) : (
<ul className="space-y-1.5">
{data.panes.map((p) => (
<li key={p.id} className="text-xs">
<Link
to={`/workspace?lane=${p.laneId}&run=${encodeURIComponent(p.id)}`}
className="inline-flex items-center gap-1.5 text-fg-primary hover:text-accent"
>
<Play className="h-3 w-3 text-status-success" />
{p.laneTitle || p.laneCwd}
</Link>
<span className="text-fg-muted">
{" "}
· {toolLabel(p.tool)}
{p.account ? ` · ${p.account}` : ""}
</span>
</li>
))}
</ul>
)}
{data.workspaces.length > 0 && (
<div className="flex flex-wrap gap-1.5 border-t border-border pt-2">
{data.workspaces.map((w) => (
<Link
key={w.id}
to={`/workspace?lane=${w.id}`}
title={w.cwd}
className="badge py-0.5 text-[11px] hover:text-accent"
>
{w.livePanes > 0 && (
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-status-success" />
)}
{w.title || w.cwd.split("/").pop()}
<span className="ml-1 text-fg-muted">{w.sessions}</span>
</Link>
))}
</div>
)}
</Panel>
</div>
{data.recentSessions.length > 0 && (
<Panel title={t("detail.recentTitle")}>
<ul className="divide-y divide-border">
{data.recentSessions.slice(0, 5).map((s) => (
<li key={s.id} className="flex items-center gap-3 py-1.5 text-xs">
<SessionStatusBadge
status={effectiveSessionStatus(s)}
reason={sessionAwaitingReason(s)}
/>
<Link
to={`/sessions/${s.id}`}
className="min-w-0 flex-1 truncate text-fg-primary hover:text-accent"
>
{s.name || s.id.slice(0, 8)}
</Link>
<span className="flex-shrink-0 text-fg-muted">{toolLabel(s.tool || "claude")}</span>
<span className="w-32 flex-shrink-0 text-right text-fg-muted">
{formatDateTime(s.last_activity || s.started_at)}
</span>
<span className="w-16 flex-shrink-0 text-right font-mono text-fg-secondary">
{s.cost ? fmtCost(s.cost) : "—"}
</span>
</li>
))}
</ul>
</Panel>
)}
</div>
);
}
function Inline({ label, children }: { label: string; children: ReactNode }) {
return (
<span>
<span className="text-fg-muted">{label}: </span>
<span className="text-fg-primary">{children}</span>
</span>
);
}
function Panel({ title, children }: { title: string; children: ReactNode }) {
return (
<section className="card p-3 space-y-2.5 min-w-0">
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-muted">{title}</h3>
{children}
</section>
);
}
/** One slice of the 30-day spend: label, $, and a thin bar of its share. */
function ShareRow({ label, cost, total }: { label: ReactNode; cost: number; total: number }) {
const share = total > 0 ? (cost / total) * 100 : 0;
return (
<li className="space-y-0.5">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="min-w-0 truncate text-fg-secondary">{label}</span>
<span className="flex-shrink-0 tabular-nums text-fg-primary">
{fmtCost(cost)} <span className="text-fg-muted">{Math.round(share)}%</span>
</span>
</div>
<span className="block h-1 overflow-hidden rounded-full bg-surface-4">
<span
className="block h-full rounded-full bg-accent-solid"
style={{ width: `${Math.min(100, share)}%` }}
/>
</span>
</li>
);
}