mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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.
361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
"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 (0–1), display as percentage (0–100)
|
||
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 (0–1); UI shows percentage (0–100)
|
||
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>
|
||
);
|
||
}
|