feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars
- Artifacts hub: upload a file from the computer; make a skill by uploading a Markdown file, files, a folder or a .zip/.skill, or by writing it in the dialog; add plugins by picking from the list a marketplace link answers (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub. - Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid). - Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB, <data dir>/avatars/), shown with name and role at the sidebar's foot and on the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at). - Users page: per-user storage (GET /api/users/storage, measured in a worker) and account-pool spend/tokens per account (server/lib/pool-usage.js). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+81
-10
@@ -2,8 +2,10 @@
|
||||
* @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
|
||||
* role, state, workspaces, sessions, the tokens/$ it used in the last 7 and
|
||||
* 30 days (`GET /api/users`) and how much disk its home folder takes
|
||||
* (`GET /api/users/storage`, measured in the background — polled while the
|
||||
* server says `measuring`; Refresh re-measures), the audit log (`GET /api/users/audit`), and — for
|
||||
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
||||
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
|
||||
* as one change), and the Claude/Codex account pool every user's sessions run
|
||||
@@ -30,8 +32,14 @@ import {
|
||||
UserPlus,
|
||||
Users as UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
|
||||
import { fmt, fmtCost, formatDateTime } from "../lib/format";
|
||||
import {
|
||||
api,
|
||||
type AuditEntry,
|
||||
type AuthUser,
|
||||
type ServerUserUsage,
|
||||
type UserStorage,
|
||||
} from "../lib/api";
|
||||
import { fmt, fmtCost, formatBytes, formatDateTime } from "../lib/format";
|
||||
import { toast } from "../lib/toast";
|
||||
import { isSubmitEnter } from "../lib/keys";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
@@ -56,10 +64,25 @@ export function Users() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editing, setEditing] = useState<ServerUserUsage | null>(null);
|
||||
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
|
||||
const [measuring, setMeasuring] = useState(false);
|
||||
|
||||
const loadStorage = useCallback(
|
||||
(remeasure = false) =>
|
||||
api.users
|
||||
.storage(remeasure)
|
||||
.then((r) => {
|
||||
setStorage(r.storage);
|
||||
setMeasuring(r.measuring);
|
||||
})
|
||||
.catch(() => setMeasuring(false)),
|
||||
[]
|
||||
);
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
(remeasure = false) =>
|
||||
Promise.all([
|
||||
loadStorage(remeasure),
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => {
|
||||
@@ -72,14 +95,23 @@ export function Users() {
|
||||
.then((r) => setAudit(r.entries))
|
||||
.catch(() => setAudit([])),
|
||||
]),
|
||||
[]
|
||||
[loadStorage]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
// The Refresh button also re-measures every home; a plain load reuses the
|
||||
// server's cached numbers.
|
||||
const [spinning, refresh] = useSpin(useCallback(() => load(true), [load]));
|
||||
|
||||
useEffect(() => {
|
||||
if (me && me.role !== "developer") void load();
|
||||
}, [me, load]);
|
||||
|
||||
// Homes are measured in the background: ask again until every number is in.
|
||||
useEffect(() => {
|
||||
if (!measuring) return;
|
||||
const id = setTimeout(() => void loadStorage(), 4000);
|
||||
return () => clearTimeout(id);
|
||||
}, [measuring, storage, loadStorage]);
|
||||
|
||||
// Local mode, or a developer: there is no Users page for them.
|
||||
if (!me || me.role === "developer") return <Navigate to="/" replace />;
|
||||
|
||||
@@ -168,7 +200,7 @@ export function Users() {
|
||||
)}
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full min-w-[760px]" data-testid="users-table">
|
||||
<table className="w-full min-w-[860px]" data-testid="users-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("name")}</th>
|
||||
@@ -177,6 +209,7 @@ export function Users() {
|
||||
<th className={`${th} text-right`}>{t("sessions")}</th>
|
||||
<th className={`${th} text-right`}>{t("last7d")}</th>
|
||||
<th className={`${th} text-right`}>{t("last30d")}</th>
|
||||
<th className={`${th} text-right`}>{t("storage")}</th>
|
||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -186,8 +219,8 @@ export function Users() {
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<TableRowSkeleton
|
||||
key={i}
|
||||
columns={isAdmin ? 7 : 6}
|
||||
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-28"]}
|
||||
columns={isAdmin ? 8 : 7}
|
||||
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-14", "w-28"]}
|
||||
/>
|
||||
))}
|
||||
{users?.map((u) => {
|
||||
@@ -244,6 +277,10 @@ export function Users() {
|
||||
</td>
|
||||
<UsageCell usage={u.last7d} />
|
||||
<UsageCell usage={u.last30d} />
|
||||
<StorageCell
|
||||
storage={storage ? (storage[u.id] ?? null) : undefined}
|
||||
measuring={measuring}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<td className="px-5 py-2 text-right whitespace-nowrap">
|
||||
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
|
||||
@@ -341,6 +378,40 @@ function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A user's home folder size (`undefined` = not loaded yet). `null` while the
|
||||
* server is still measuring reads "Measuring…", otherwise "—" (no home of
|
||||
* their own).
|
||||
*/
|
||||
function StorageCell({ storage, measuring }: { storage?: UserStorage; measuring: boolean }) {
|
||||
const { t } = useTranslation("users");
|
||||
if (!storage) {
|
||||
const pending = storage === undefined || measuring;
|
||||
return (
|
||||
<td
|
||||
className="px-5 py-3 text-right text-sm tabular-nums text-fg-muted"
|
||||
title={pending ? undefined : t("storageNone")}
|
||||
>
|
||||
{pending ? t("storageMeasuring") : "—"}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<td
|
||||
className="px-5 py-3 text-right tabular-nums"
|
||||
title={storage.partial ? t("storagePartial") : undefined}
|
||||
>
|
||||
<div className="text-sm text-fg-primary">
|
||||
{storage.partial ? "≥ " : ""}
|
||||
{formatBytes(storage.bytes)}
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("storageHint", { files: fmt(storage.files), at: formatDateTime(storage.measuredAt) })}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function RowAction({
|
||||
label,
|
||||
disabled,
|
||||
|
||||
Reference in New Issue
Block a user