Files
codecraft/web/src/components/Budget.tsx
T
Richard ZinkandClaude Opus 5.5 d152ad836d Split view, personal modes, budget warnings, server move script
- 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>
2026-11-09 12:06:20 +01:00

134 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}