Files
OmniRoute/src/app/(dashboard)/dashboard/usage/components/BudgetTab.tsx
diegosouzapw dc6d9e2e4b feat(core): add payload rules, tag routing, and scheduled budgets
Introduce runtime-configurable payload mutation/filter rules with file
reload support and a settings API so upstream request bodies can be
customized per model and protocol without restarts.

Expand search support with Google PSE, Linkup, SearchAPI, and SearXNG,
including validation, routing, analytics costing, MCP schema updates,
and search-type-aware provider selection. Update Pollinations to support
anonymous access, endpoint failover, and the latest public model lineup.

Add OmniRoute response metadata headers/SSE comments, per-connection
model exclusion rules, combo tag-based routing, buffered spend writes,
and scheduled daily/weekly/monthly budget resets. Update model catalog
and dashboard UIs to surface source labels and hide models excluded by
all active connections.
2026-04-17 09:00:32 -03:00

361 lines
13 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.
"use client";
import { useLocale, useTranslations } from "next-intl";
/**
* BudgetTab — Batch C
*
* Budget management for API keys — set daily/monthly limits,
* view current spend, and monitor warning thresholds.
* API: /api/usage/budget
*/
import { useState, useEffect, useCallback } from "react";
import { Card, Button, Input, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
function ProgressBar({ value, max, warningAt = 0.8, formatCurrency }) {
const pct = max > 0 ? Math.min((value / max) * 100, 100) : 0;
const ratio = max > 0 ? value / max : 0;
const color = ratio >= 1 ? "#ef4444" : ratio >= warningAt ? "#f59e0b" : "#22c55e";
return (
<div className="w-full">
<div className="flex justify-between text-xs mb-1">
<span className="text-text-muted">{formatCurrency(value)}</span>
<span className="text-text-muted">{formatCurrency(max)}</span>
</div>
<div className="w-full h-2 rounded-full bg-surface/50 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: color }}
/>
</div>
</div>
);
}
export default function BudgetTab() {
const t = useTranslations("usage");
const locale = useLocale();
const [keys, setKeys] = useState([]);
const [selectedKey, setSelectedKey] = useState(null);
const [budget, setBudget] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
dailyLimitUsd: "",
weeklyLimitUsd: "",
monthlyLimitUsd: "",
warningThreshold: "80",
resetInterval: "daily",
resetTime: "00:00",
});
const notify = useNotificationStore();
const formatCurrency = (value) =>
new Intl.NumberFormat(locale, {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(Number(value || 0));
const formatDateTime = (value) =>
value
? new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
}).format(new Date(value))
: "—";
const getActiveLimitFromForm = () => {
const daily = parseFloat(form.dailyLimitUsd) || 0;
const weekly = parseFloat(form.weeklyLimitUsd) || 0;
const monthly = parseFloat(form.monthlyLimitUsd) || 0;
if (form.resetInterval === "monthly") {
return monthly || daily;
}
if (form.resetInterval === "weekly") {
return weekly || daily;
}
return daily;
};
// Load API keys
useEffect(() => {
fetch("/api/keys")
.then((r) => r.json())
.then((data) => {
const keyList = Array.isArray(data) ? data : data.keys || [];
setKeys(keyList);
if (keyList.length > 0) setSelectedKey(keyList[0].id);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
// Load budget for selected key
const fetchBudget = useCallback(async () => {
if (!selectedKey) return;
try {
const res = await fetch(`/api/usage/budget?apiKeyId=${selectedKey}`);
if (res.ok) {
const data = await res.json();
setBudget(data);
if (data.dailyLimitUsd)
setForm((f) => ({ ...f, dailyLimitUsd: String(data.dailyLimitUsd) }));
if (data.weeklyLimitUsd)
setForm((f) => ({ ...f, weeklyLimitUsd: String(data.weeklyLimitUsd) }));
if (data.monthlyLimitUsd)
setForm((f) => ({ ...f, monthlyLimitUsd: String(data.monthlyLimitUsd) }));
if (data.warningThreshold)
// stored as fraction (01), display as percentage (0100)
setForm((f) => ({
...f,
warningThreshold: String(Math.round(data.warningThreshold * 100)),
}));
if (data.resetInterval) {
setForm((f) => ({ ...f, resetInterval: data.resetInterval }));
}
if (data.resetTime) {
setForm((f) => ({ ...f, resetTime: data.resetTime }));
}
}
} catch {
// silent
}
}, [selectedKey]);
useEffect(() => {
fetchBudget();
}, [fetchBudget]);
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch("/api/usage/budget", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
apiKeyId: selectedKey,
dailyLimitUsd: form.dailyLimitUsd ? parseFloat(form.dailyLimitUsd) : undefined,
weeklyLimitUsd: form.weeklyLimitUsd ? parseFloat(form.weeklyLimitUsd) : undefined,
monthlyLimitUsd: form.monthlyLimitUsd ? parseFloat(form.monthlyLimitUsd) : undefined,
// schema expects a fraction (01); UI shows percentage (0100)
warningThreshold: (parseInt(form.warningThreshold) || 80) / 100,
resetInterval: form.resetInterval,
resetTime: form.resetTime || "00:00",
}),
});
if (res.ok) {
notify.success(t("budgetSaved"));
await fetchBudget();
} else {
notify.error(t("budgetSaveFailed"));
}
} catch {
notify.error(t("budgetSaveFailed"));
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center gap-2 text-text-muted p-8 animate-pulse">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
account_balance_wallet
</span>
{t("loadingBudgetData")}
</div>
);
}
if (keys.length === 0) {
return (
<EmptyState
icon="vpn_key"
title={t("noApiKeysTitle")}
description={t("noApiKeysDescription")}
/>
);
}
const dailyLimit = budget?.dailyLimitUsd || parseFloat(form.dailyLimitUsd) || 0;
const weeklyLimit = budget?.weeklyLimitUsd || parseFloat(form.weeklyLimitUsd) || 0;
const monthlyLimit = budget?.monthlyLimitUsd || parseFloat(form.monthlyLimitUsd) || 0;
const dailyCost = budget?.totalCostToday || 0;
const monthlyCost = budget?.totalCostMonth || 0;
const activeLimit = budget?.activeLimitUsd || getActiveLimitFromForm();
const activeCost = budget?.totalCostPeriod || 0;
const warnPct = (parseInt(form.warningThreshold) || 80) / 100;
return (
<div className="flex flex-col gap-6">
{/* Key Selector */}
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
account_balance_wallet
</span>
</div>
<h3 className="text-lg font-semibold">{t("budgetManagement")}</h3>
</div>
<div className="mb-4">
<label className="text-sm text-text-muted mb-1 block">{t("apiKey")}</label>
<select
value={selectedKey || ""}
onChange={(e) => setSelectedKey(e.target.value)}
className="w-full md:w-auto px-3 py-2 rounded-lg border border-border/50 bg-surface/30 text-text-main text-sm focus:outline-none focus:ring-1 focus:ring-primary"
>
{keys.map((k) => (
<option key={k.id} value={k.id}>
{k.name || k.id} {k.provider ? `(${k.provider})` : ""}
</option>
))}
</select>
</div>
{/* Current Spend */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div className="p-4 rounded-lg border border-border/30 bg-surface/20">
<p className="text-sm text-text-muted mb-2">{t("todaysSpend")}</p>
<p className="text-2xl font-bold text-text-main">{formatCurrency(dailyCost)}</p>
{dailyLimit > 0 && (
<ProgressBar
value={dailyCost}
max={dailyLimit}
warningAt={warnPct}
formatCurrency={formatCurrency}
/>
)}
</div>
<div className="p-4 rounded-lg border border-border/30 bg-surface/20">
<p className="text-sm text-text-muted mb-2">{t("thisMonth")}</p>
<p className="text-2xl font-bold text-text-main">{formatCurrency(monthlyCost)}</p>
{monthlyLimit > 0 && (
<ProgressBar
value={monthlyCost}
max={monthlyLimit}
warningAt={warnPct}
formatCurrency={formatCurrency}
/>
)}
</div>
<div className="p-4 rounded-lg border border-border/30 bg-surface/20">
<p className="text-sm text-text-muted mb-2">Active Period Spend</p>
<p className="text-2xl font-bold text-text-main">{formatCurrency(activeCost)}</p>
{activeLimit > 0 && (
<ProgressBar
value={activeCost}
max={activeLimit}
warningAt={warnPct}
formatCurrency={formatCurrency}
/>
)}
<div className="mt-3 space-y-1 text-xs text-text-muted">
<p>
Interval: {(budget?.resetInterval || form.resetInterval || "daily").toUpperCase()}
</p>
<p>Next reset (UTC): {formatDateTime(budget?.budgetResetAt)}</p>
</div>
</div>
</div>
{/* Budget Form */}
<div className="border-t border-border/30 pt-4">
<p className="text-sm font-medium mb-3">{t("setLimits")}</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<Input
label={t("dailyLimitUsd")}
type="number"
step="0.01"
min="0"
placeholder={t("dailyLimitPlaceholder")}
value={form.dailyLimitUsd}
onChange={(e) => setForm({ ...form, dailyLimitUsd: e.target.value })}
/>
<Input
label="Weekly Limit (USD)"
type="number"
step="0.01"
min="0"
placeholder="e.g. 25.00"
value={form.weeklyLimitUsd}
onChange={(e) => setForm({ ...form, weeklyLimitUsd: e.target.value })}
/>
<Input
label={t("monthlyLimitUsd")}
type="number"
step="0.01"
min="0"
placeholder={t("monthlyLimitPlaceholder")}
value={form.monthlyLimitUsd}
onChange={(e) => setForm({ ...form, monthlyLimitUsd: e.target.value })}
/>
<Input
label={t("warningThresholdPercent")}
type="number"
min="1"
max="100"
placeholder={t("warningThresholdPlaceholder")}
value={form.warningThreshold}
onChange={(e) => setForm({ ...form, warningThreshold: e.target.value })}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="space-y-1">
<label className="text-sm text-text-muted block">Reset Interval</label>
<select
value={form.resetInterval}
onChange={(e) => setForm({ ...form, resetInterval: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-border/50 bg-surface/30 text-text-main text-sm focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<Input
label="Reset Time (UTC)"
type="time"
value={form.resetTime}
onChange={(e) => setForm({ ...form, resetTime: e.target.value })}
/>
</div>
<div className="mb-4 rounded-lg border border-border/30 bg-surface/20 px-4 py-3 text-sm text-text-muted">
<p>Weekly limit: {weeklyLimit > 0 ? formatCurrency(weeklyLimit) : "Not configured"}</p>
<p>Next reset (UTC): {formatDateTime(budget?.budgetResetAt)}</p>
</div>
<Button variant="primary" onClick={handleSave} loading={saving}>
{t("saveLimits")}
</Button>
</div>
</Card>
{/* Budget Check Status */}
{budget?.budgetCheck && (
<Card className="p-4">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[20px]"
aria-hidden="true"
style={{ color: budget.budgetCheck.allowed ? "#22c55e" : "#ef4444" }}
>
{budget.budgetCheck.allowed ? "check_circle" : "block"}
</span>
<span className="text-sm">
{budget.budgetCheck.allowed
? t("budgetOk", { remaining: formatCurrency(budget.budgetCheck.remaining || 0) })
: t("budgetExceeded")}
</span>
</div>
</Card>
)}
</div>
);
}