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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 16:54:08 +01:00
1 parent f3a2fe3816
commit 49262c612c
70 files changed
+8495 -714

No files matched your search

+34 -2
View File
@@ -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}`)} />