Users, file manager, image generation, budgets and agent workflow
- User management: local accounts (Argon2) and OpenID Connect, admin/user roles, per-user folders plus admin-shared folders, read-only session sharing, event filtering per user - File manager with CodeMirror editor and git client (status, diff, stage, commit, branches, fetch/pull/push, log, clone/init) - Image generation via fal.ai: generate_image tool and image page - Budgets: monthly limits per provider, provider balances/costs (OpenRouter, Anthropic admin cost report, fal.ai), price discovery - Subagents on other models, background tasks, concurrency limits per host/model/subagent - MCP tools loaded on demand (mcp_search/mcp_call), read-only hints - Permission mode "auto", safe read-only commands always allowed - Persistent message queue with attachments, deletable - Live reasoning incl. <think> tags, activity animations, CSS themes, mobile layout, statistics per user, upload size handling - Docker: named volume for state, localhost-bound port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3a2fe3816
commit
49262c612c
70 files changed
+8495
-714
No files matched your search
@@ -2,8 +2,11 @@ import { useMemo, useState } from "react";
|
||||
import { useFetch } from "../hooks";
|
||||
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, getLang, useT, weekdayNames, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import type { User } from "../types";
|
||||
import type { Aggregate } from "../types";
|
||||
import { Heatmap, Legend, SERIES, StackedBars, type Series } from "./Charts";
|
||||
import { BudgetPanel } from "./Budget";
|
||||
|
||||
type Row<K extends string> = Aggregate & Record<K, string>;
|
||||
|
||||
@@ -14,6 +17,7 @@ interface StatsData {
|
||||
by_model: Row<"model">[];
|
||||
by_provider: Row<"provider">[];
|
||||
by_agent: Row<"agent">[];
|
||||
by_user: Row<"user">[];
|
||||
heatmap: (Aggregate & { slot: number })[];
|
||||
top_sessions: (Aggregate & { session_id: string; title: string })[];
|
||||
currency: string;
|
||||
@@ -55,6 +59,12 @@ function Kpi({ label, value, sub }: { label: string; value: string; sub?: string
|
||||
);
|
||||
}
|
||||
|
||||
/** Effective cost per 1M tokens (input incl. cache + output). */
|
||||
function perMillion(r: Aggregate, currency: string) {
|
||||
const tokens = r.input_tokens + r.cache_read_tokens + r.cache_write_tokens + r.output_tokens;
|
||||
return tokens > 0 && r.cost > 0 ? fmtCost((r.cost / tokens) * 1_000_000, currency) : "–";
|
||||
}
|
||||
|
||||
function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate & Record<string, any>)[]; keyName: string; label: string; currency: string; link?: (r: any) => void }) {
|
||||
const t = useT();
|
||||
return (
|
||||
@@ -71,6 +81,7 @@ function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate &
|
||||
<th className="num">{t("stats.outputSpeed")}</th>
|
||||
<th className="num">{t("stats.prefillSpeed")}</th>
|
||||
<th className="num">{t("stats.cost")}</th>
|
||||
<th className="num" title={t("stats.perMillionHint")}>{t("stats.perMillion")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -85,6 +96,7 @@ function AggTable({ rows, keyName, label, currency, link }: { rows: (Aggregate &
|
||||
<td className="num">{fmtNumber(r.output_tps, 1)}</td>
|
||||
<td className="num">{fmtNumber(r.prefill_tps)}</td>
|
||||
<td className="num">{fmtCost(r.cost, currency)}</td>
|
||||
<td className="num">{perMillion(r, currency)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -98,12 +110,15 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
const [range, setRange] = useState("30");
|
||||
const [heat, setHeat] = useState<HeatMetric>("requests");
|
||||
const [tableView, setTableView] = useState(false);
|
||||
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||||
const users = useFetch<User[]>(isAdmin ? "/users" : null).data ?? [];
|
||||
const [userFilter, setUserFilter] = useState("");
|
||||
const days = RANGES.find((r) => r.key === range)!.days;
|
||||
const tz = -new Date().getTimezoneOffset();
|
||||
// Stable per range: rounded to the minute so the URL doesn't change on every render.
|
||||
const now = Math.floor(Date.now() / 60000) * 60000 + 60000;
|
||||
const from = days ? now - days * 86400000 : 0;
|
||||
const { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${now}&tz=${tz}`);
|
||||
const { data, error } = useFetch<StatsData>(`/stats?from=${from}&to=${now}&tz=${tz}${userFilter ? `&user=${userFilter}` : ""}`);
|
||||
|
||||
const view = useMemo(() => {
|
||||
if (!data) return undefined;
|
||||
@@ -165,6 +180,16 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<select value={userFilter} onChange={(e) => setUserFilter(e.target.value)} aria-label={t("stats.user")}>
|
||||
<option value="">{t("stats.allUsers")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button className="small" onClick={() => setTableView(!tableView)}>
|
||||
{tableView ? t("stats.chartView") : t("stats.tableView")}
|
||||
</button>
|
||||
@@ -173,6 +198,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
<div className="page-inner">
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
{!data && !error && <div className="empty">{t("common.loading")}</div>}
|
||||
{isAdmin && <BudgetPanel />}
|
||||
{data && tot && view && (
|
||||
<>
|
||||
<div className="kpis">
|
||||
@@ -183,7 +209,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
<Kpi label={t("stats.cacheHit")} value={fmtPercent(tot.cache_hit_rate)} />
|
||||
<Kpi label={t("stats.ttft")} value={`${fmtNumber(tot.avg_ttft_ms)} ms`} sub={t("stats.ttftHint")} />
|
||||
<Kpi label={t("stats.outputSpeed")} value={`${fmtNumber(tot.output_tps, 1)} tok/s`} />
|
||||
<Kpi label={t("stats.prefillSpeed")} value={`${fmtNumber(tot.prefill_tps)} tok/s`} />
|
||||
<Kpi label={t("stats.prefillSpeed")} value={`${fmtCompact(tot.prefill_tps)} tok/s`} />
|
||||
</div>
|
||||
|
||||
{tot.requests === 0 ? (
|
||||
@@ -236,6 +262,12 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
||||
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && !userFilter && data.by_user.length > 1 && (
|
||||
<div className="panel">
|
||||
<h3>{t("stats.byUser")}</h3>
|
||||
<AggTable rows={data.by_user} keyName="user" label={t("stats.user")} currency={cur} />
|
||||
</div>
|
||||
)}
|
||||
<div className="panel">
|
||||
<h3>{t("stats.topSessions")}</h3>
|
||||
<AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => navigate(`/s/${r.session_id}`)} />
|
||||
|
||||
Reference in new issue
Block a user