mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 12:11:45 +00:00
- 0034's backfill set target_id to the acting admin for instance- and team-scoped quota policy changes, which are filed under the actor and have no user target. It now returns NULL for those, matching what quotas.py records going forward, with a test covering all three scopes. - The CSV export wrote every cell verbatim. user_agent is an attacker's raw header, recorded without authenticating on a denied login, and the export is opened in a spreadsheet by an admin -- a cell starting "=" would be evaluated there. Every cell now has a leading formula trigger neutralized. - The activity feed applied every response it received, so a slow reply for an old filter could overwrite the current one. Guarded by a request id, the same way settings/Analytics already does. - The activity detail expander and the top-user drill-down were row onClick handlers, unreachable without a mouse. Both are real buttons now, the expander carrying aria-expanded and a label naming its event. - FLOW_LABELS was applied to both breakdowns in the spend modal, so a model named "fallback" or "workflow" rendered as a flow description. Only the flow table maps keys now. - Changing the range in that modal left the previous range's totals and chart on screen while the new request was in flight.
218 lines
6.3 KiB
TypeScript
218 lines
6.3 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { useSelector } from 'react-redux';
|
|
|
|
import adminService from '../api/services/adminService';
|
|
import { Button } from '../components/ui/button';
|
|
import { Modal } from '../components/ui/modal';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '../components/ui/table';
|
|
import { useDarkTheme } from '../hooks';
|
|
import { selectToken } from '../preferences/preferenceSlice';
|
|
import { formatDate } from '../utils/dateTimeUtils';
|
|
import { Loading, LoadError, StatCard, fmtNumber, fmtUsd } from './AdminUI';
|
|
import UsageChart, { usageColors } from './UsageChart';
|
|
|
|
type Bucket = {
|
|
bucket: string;
|
|
prompt_tokens: number;
|
|
generated_tokens: number;
|
|
cost: number;
|
|
};
|
|
type Split = { key: string; tokens: number; cost: number };
|
|
|
|
const RANGES = [7, 30, 90];
|
|
|
|
/** Label for a token_usage.source value — what the spend was for. */
|
|
const FLOW_LABELS: Record<string, string> = {
|
|
agent_stream: 'Chat',
|
|
schedule: 'Scheduled run',
|
|
webhook: 'Webhook',
|
|
workflow: 'Workflow',
|
|
title: 'Title generation',
|
|
compression: 'History compression',
|
|
rag_condense: 'Question condensing',
|
|
fallback: 'Provider fallback',
|
|
};
|
|
|
|
function SplitTable({
|
|
title,
|
|
rows,
|
|
labels,
|
|
}: {
|
|
title: string;
|
|
rows: Split[];
|
|
/** Only the flow table maps keys; a model named `fallback` is a model. */
|
|
labels?: Record<string, string>;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<p className="text-muted-foreground mb-2 text-sm font-medium">{title}</p>
|
|
{rows.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm">No usage.</p>
|
|
) : (
|
|
<TableContainer>
|
|
<Table>
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableHeader>{title}</TableHeader>
|
|
<TableHeader align="right">Tokens</TableHeader>
|
|
<TableHeader align="right">Cost</TableHeader>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{rows.map((row) => (
|
|
<TableRow key={row.key}>
|
|
<TableCell className="text-[13px] break-all">
|
|
{labels?.[row.key] ?? row.key}
|
|
</TableCell>
|
|
<TableCell align="right" className="tabular-nums">
|
|
{fmtNumber(row.tokens)}
|
|
</TableCell>
|
|
<TableCell align="right" className="tabular-nums">
|
|
{fmtUsd(row.cost)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Per-user spend drill-down. The user detail view carried a single
|
|
* `tokens_30d` number, which answers neither question an operator has: what is
|
|
* this person costing, and what is driving it.
|
|
*/
|
|
export default function UserUsageModal({
|
|
userId,
|
|
onClose,
|
|
}: {
|
|
userId: string | null;
|
|
onClose: () => void;
|
|
}) {
|
|
const token = useSelector(selectToken);
|
|
const [isDarkTheme] = useDarkTheme();
|
|
const [days, setDays] = useState(30);
|
|
const [data, setData] = useState<any | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!userId) {
|
|
setData(null);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
// Drop the previous range's numbers rather than showing them under the
|
|
// newly selected one.
|
|
setData(null);
|
|
adminService
|
|
.getUserUsage(userId, { days }, token)
|
|
.then((res) => res.json())
|
|
.then((json) => {
|
|
if (!cancelled) setData(json);
|
|
})
|
|
.catch(() => !cancelled && setData({ success: false }))
|
|
.finally(() => !cancelled && setLoading(false));
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [userId, days, token]);
|
|
|
|
const series: Bucket[] = data?.series ?? [];
|
|
const chartData = useMemo(() => {
|
|
const colors = usageColors();
|
|
return {
|
|
labels: series.map((bucket) => formatDate(bucket.bucket)),
|
|
datasets: [
|
|
{
|
|
label: 'Prompt',
|
|
data: series.map((bucket) => bucket.prompt_tokens),
|
|
backgroundColor: colors.prompt,
|
|
},
|
|
{
|
|
label: 'Generated',
|
|
data: series.map((bucket) => bucket.generated_tokens),
|
|
backgroundColor: colors.generated,
|
|
},
|
|
],
|
|
};
|
|
// isDarkTheme re-resolves the canvas colors when the theme toggles.
|
|
}, [series, isDarkTheme]);
|
|
|
|
const totals = data?.totals ?? {};
|
|
|
|
return (
|
|
<Modal
|
|
open={userId !== null}
|
|
onOpenChange={(open) => !open && onClose()}
|
|
title={userId ? `Usage · ${userId}` : 'Usage'}
|
|
size="lg"
|
|
>
|
|
<div className="mb-4 flex items-center gap-1">
|
|
{RANGES.map((range) => (
|
|
<Button
|
|
key={range}
|
|
variant={range === days ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="rounded-3xl"
|
|
onClick={() => setDays(range)}
|
|
>
|
|
{range}d
|
|
</Button>
|
|
))}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<Loading />
|
|
) : data && !data.success ? (
|
|
<LoadError message="Failed to load usage." />
|
|
) : (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<StatCard label="Cost" value={fmtUsd(totals.cost)} />
|
|
<StatCard label="Tokens" value={fmtNumber(totals.tokens)} />
|
|
<StatCard label="Calls" value={fmtNumber(totals.calls)} />
|
|
</div>
|
|
|
|
<div className="border-border h-64 rounded-2xl border px-4 py-3">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-foreground text-sm font-bold">Daily tokens</p>
|
|
<div id="admin-user-usage-legend" className="flex" />
|
|
</div>
|
|
<div className="relative mt-px h-48 w-full">
|
|
{series.length === 0 ? (
|
|
<p className="text-muted-foreground mt-8 text-sm">
|
|
No usage in this period.
|
|
</p>
|
|
) : (
|
|
<UsageChart
|
|
data={chartData}
|
|
legendID="admin-user-usage-legend"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<SplitTable title="Model" rows={data?.by_model ?? []} />
|
|
<SplitTable
|
|
title="Flow"
|
|
rows={data?.by_source ?? []}
|
|
labels={FLOW_LABELS}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
);
|
|
}
|