0f17efe3b4
- 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.
334 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|