- Up to three chats side by side (#/s/a+b+c), "Open beside" in the sidebar menu
- File manager tabs of a chat stored per user on the server (/sessions/{id}/tabs)
- Personal modes for every user (Settings → My modes, /account/modes)
- Budget meters (total, provider, images, opencode Go windows) with push warnings
for admins at the warning share and 100 %, shown in the status overlay
- scripts/move-server.sh: move to another Docker host with bind mounts (Unraid);
scripts/smoke-test.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
5.5 KiB
TypeScript
134 lines
5.5 KiB
TypeScript
import { Fragment } from "react";
|
||
import { Icon } from "./Icon";
|
||
import { useFetch } from "../hooks";
|
||
import { fmtCost, fmtPercent, useT } from "../i18n";
|
||
|
||
interface BudgetRow {
|
||
provider: string;
|
||
budget: number | null;
|
||
spent: number;
|
||
remaining: number | null;
|
||
share: number | null;
|
||
warning: boolean;
|
||
exceeded: boolean;
|
||
images?: boolean;
|
||
plan?: Plan;
|
||
remote?: { currency?: string; remaining?: number | null; limit?: number | null; used?: number | null; spent_month?: number; scope?: string; error?: string };
|
||
}
|
||
|
||
/** opencode Go: usage windows against the plan's limits (USD). */
|
||
export interface Plan {
|
||
limit: number;
|
||
windows: { window: "5h" | "week" | "month"; used: number; limit: number; ratio: number }[];
|
||
}
|
||
|
||
/** Three small meters (5 hours, week, month) for a subscription plan. */
|
||
export function PlanUsage({ plan }: { plan: Plan }) {
|
||
const t = useT();
|
||
return (
|
||
<div className="plan-usage">
|
||
{plan.windows.map((w) => (
|
||
<div key={w.window} className="plan-window" title={`${fmtCost(w.used, "USD")} / ${fmtCost(w.limit, "USD")}`}>
|
||
<span className="plan-label">{t(`budget.window.${w.window}`)}</span>
|
||
<Bar share={w.ratio} warning={w.ratio >= 0.8} exceeded={w.ratio >= 1} />
|
||
<span className="plan-pct">{fmtPercent(w.ratio)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface BudgetData {
|
||
currency: string;
|
||
total: BudgetRow;
|
||
providers: BudgetRow[];
|
||
}
|
||
|
||
export function Bar({ share, warning, exceeded }: { share: number; warning: boolean; exceeded: boolean }) {
|
||
const color = exceeded ? "var(--red)" : warning ? "var(--accent2)" : "var(--green)";
|
||
return (
|
||
<div className="budget-bar" role="meter" aria-valuenow={Math.round(share * 100)} aria-valuemin={0} aria-valuemax={100}>
|
||
<div style={{ width: `${Math.min(share, 1) * 100}%`, background: color }} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Remote({ r, t }: { r: BudgetRow["remote"]; t: ReturnType<typeof useT> }) {
|
||
if (!r) return <span className="muted">–</span>;
|
||
if (r.error) return <span className="muted" title={r.error}><Icon name="warning" /> {t("budget.remoteError")}</span>;
|
||
const cur = r.currency ?? "USD";
|
||
if (r.spent_month !== undefined) return <span title={t("budget.orgWide")}>{t("budget.providerSpent", { v: fmtCost(r.spent_month, cur) })}</span>;
|
||
if (r.remaining !== null && r.remaining !== undefined) return <span>{t("budget.balance", { v: fmtCost(r.remaining, cur) })}</span>;
|
||
if (r.used !== null && r.used !== undefined) return <span>{t("budget.providerSpent", { v: fmtCost(r.used, cur) })}</span>;
|
||
return <span className="muted">–</span>;
|
||
}
|
||
|
||
/** Monthly budgets per provider (admins). */
|
||
export function BudgetPanel() {
|
||
const t = useT();
|
||
const { data, error } = useFetch<BudgetData>("/budget");
|
||
if (error || !data) return null;
|
||
const rows = [data.total, ...data.providers];
|
||
return (
|
||
<div className="panel">
|
||
<h3>{t("budget.title")}</h3>
|
||
<div style={{ overflowX: "auto" }}>
|
||
<table className="grid">
|
||
<thead>
|
||
<tr>
|
||
<th>{t("stats.provider")}</th>
|
||
<th className="num">{t("budget.spent")}</th>
|
||
<th className="num">{t("budget.budget")}</th>
|
||
<th style={{ minWidth: 140 }} />
|
||
<th className="num">{t("budget.remaining")}</th>
|
||
<th>{t("budget.provider")}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r) => (
|
||
<Fragment key={r.provider}>
|
||
<tr>
|
||
<td>
|
||
{r.provider === "total" ? <b>{t("stats.total")}</b> : r.provider}
|
||
{r.images && <span className="chip" style={{ marginLeft: 6 }}>{t("nav.images")}</span>}
|
||
</td>
|
||
<td className="num">{fmtCost(r.spent, data.currency)}</td>
|
||
<td className="num">{r.budget ? fmtCost(r.budget, data.currency) : "–"}</td>
|
||
<td>{r.share !== null && <Bar share={r.share} warning={r.warning} exceeded={r.exceeded} />}</td>
|
||
<td className="num" style={{ color: r.exceeded ? "var(--red)" : r.warning ? "var(--accent2)" : undefined }}>
|
||
{r.remaining !== null ? fmtCost(r.remaining, data.currency) : "–"}
|
||
{r.share !== null && <span className="muted"> ({fmtPercent(r.share)})</span>}
|
||
</td>
|
||
<td>{r.provider === "total" ? null : <Remote r={r.remote} t={t} />}</td>
|
||
</tr>
|
||
{r.plan && (
|
||
<tr className="plan-row">
|
||
<td className="muted">{t("budget.plan", { v: fmtCost(r.plan.limit, "USD") })}</td>
|
||
<td colSpan={5}>
|
||
<PlanUsage plan={r.plan} />
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</Fragment>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 6 }}>{t("budget.hint")}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Small header chip when the overall budget is nearly used up. */
|
||
export function BudgetChip() {
|
||
const t = useT();
|
||
const { data } = useFetch<BudgetData>("/budget");
|
||
const worst = data ? [data.total, ...data.providers].filter((r) => r.warning).sort((a, b) => (b.share ?? 0) - (a.share ?? 0))[0] : undefined;
|
||
if (!worst) return null;
|
||
return (
|
||
<span className="chip warn hide-mobile" title={t("budget.chipHint")}>
|
||
{t("budget.chip", { name: worst.provider === "total" ? t("stats.total") : worst.provider, p: fmtPercent(worst.share ?? 0) })}
|
||
</span>
|
||
);
|
||
}
|