feat(frontend): 100% backend API coverage — 7 batches

Batch A: Fix Breadcrumbs (usePathname), rewrite ComplianceTab (DataTable+FilterBar+ColumnToggle), delete dead code (a11yAudit.js, policyEngine.js), wire toast notifications (Combos, Providers)
Batch B: ModelAvailabilityPanel — cooldown badges, clear action, auto-refresh
Batch C: BudgetTab — spend cards, progress bars, budget limits form
Batch D: FallbackChainsEditor — color-coded provider chains, create/delete
Batch E: PoliciesPanel — circuit breaker states, locked identifiers, force unlock
Batch F: EvalsTab — expandable suites, run eval, DataTable results
Batch G: TokenHealthBadge + /api/token-health — OAuth health in header

8 new files, 9 modified, 2 deleted. Build passes (exit 0).
This commit is contained in:
diegosouzapw
2026-02-14 21:04:51 -03:00
parent b87e04db22
commit 443e5b7b62
17 changed files with 1341 additions and 385 deletions

View File

@@ -12,6 +12,7 @@ import {
EmptyState,
} from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useNotificationStore } from "@/store/notificationStore";
// Validate combo name: letters, numbers, -, _, /, .
const VALID_NAME_REGEX = /^[a-zA-Z0-9_/.-]+$/;
@@ -41,6 +42,7 @@ export default function CombosPage() {
const [testResults, setTestResults] = useState(null);
const [testingCombo, setTestingCombo] = useState(null);
const { copied, copy } = useCopyToClipboard();
const notify = useNotificationStore();
const [proxyTargetCombo, setProxyTargetCombo] = useState(null);
const [proxyConfig, setProxyConfig] = useState(null);
@@ -88,12 +90,13 @@ export default function CombosPage() {
if (res.ok) {
await fetchData();
setShowCreateModal(false);
notify.success("Combo created successfully");
} else {
const err = await res.json();
alert(err.error?.message || err.error || "Failed to create combo");
notify.error(err.error?.message || err.error || "Failed to create combo");
}
} catch (error) {
console.log("Error creating combo:", error);
notify.error("Error creating combo");
}
};
@@ -107,12 +110,13 @@ export default function CombosPage() {
if (res.ok) {
await fetchData();
setEditingCombo(null);
notify.success("Combo updated successfully");
} else {
const err = await res.json();
alert(err.error?.message || err.error || "Failed to update combo");
notify.error(err.error?.message || err.error || "Failed to update combo");
}
} catch (error) {
console.log("Error updating combo:", error);
notify.error("Error updating combo");
}
};
@@ -122,9 +126,10 @@ export default function CombosPage() {
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
if (res.ok) {
setCombos(combos.filter((c) => c.id !== id));
notify.success("Combo deleted");
}
} catch (error) {
console.log("Error deleting combo:", error);
notify.error("Error deleting combo");
}
};
@@ -161,6 +166,7 @@ export default function CombosPage() {
setTestResults(data);
} catch (error) {
setTestResults({ error: "Test request failed" });
notify.error("Test request failed");
}
};

View File

@@ -0,0 +1,183 @@
"use client";
/**
* ModelAvailabilityPanel — Batch B
*
* Shows real-time model availability and cooldown status.
* Fetched from /api/models/availability.
*/
import { useState, useEffect, useCallback } from "react";
import { Card, Button, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
const STATUS_CONFIG = {
available: { icon: "check_circle", color: "#22c55e", label: "Available" },
cooldown: { icon: "schedule", color: "#f59e0b", label: "Cooldown" },
unavailable: { icon: "error", color: "#ef4444", label: "Unavailable" },
unknown: { icon: "help", color: "#6b7280", label: "Unknown" },
};
export default function ModelAvailabilityPanel() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [clearing, setClearing] = useState(null);
const notify = useNotificationStore();
const fetchStatus = useCallback(async () => {
try {
const res = await fetch("/api/models/availability");
if (res.ok) {
const json = await res.json();
setData(json);
}
} catch {
// silent fail — will retry
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
const interval = setInterval(fetchStatus, 30000);
return () => clearInterval(interval);
}, [fetchStatus]);
const handleClearCooldown = async (provider, model) => {
setClearing(`${provider}:${model}`);
try {
const res = await fetch("/api/models/availability", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "clearCooldown", provider, model }),
});
if (res.ok) {
notify.success(`Cooldown cleared for ${model}`);
await fetchStatus();
} else {
notify.error("Failed to clear cooldown");
}
} catch {
notify.error("Failed to clear cooldown");
} finally {
setClearing(null);
}
};
if (loading) {
return (
<Card className="p-6 mt-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">monitoring</span>
Loading model availability...
</div>
</Card>
);
}
const models = data?.models || [];
const unavailableCount =
data?.unavailableCount || models.filter((m) => m.status !== "available").length;
if (models.length === 0 || unavailableCount === 0) {
return (
<Card className="p-6 mt-6">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<span className="material-symbols-outlined text-[20px]">verified</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Model Availability</h3>
<p className="text-sm text-text-muted">All models operational</p>
</div>
</div>
</Card>
);
}
// Group by provider
const byProvider = {};
models.forEach((m) => {
if (m.status === "available") return;
const key = m.provider || "unknown";
if (!byProvider[key]) byProvider[key] = [];
byProvider[key].push(m);
});
return (
<Card className="p-6 mt-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
<span className="material-symbols-outlined text-[20px]">warning</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Model Availability</h3>
<p className="text-sm text-text-muted">
{unavailableCount} model{unavailableCount !== 1 ? "s" : ""} with issues
</p>
</div>
</div>
<Button size="sm" variant="ghost" onClick={fetchStatus} className="text-text-muted">
<span className="material-symbols-outlined text-[16px]">refresh</span>
</Button>
</div>
<div className="flex flex-col gap-3">
{Object.entries(byProvider).map(([provider, provModels]) => (
<div key={provider} className="border border-border/30 rounded-lg p-3">
<p className="text-sm font-medium text-text-main mb-2 capitalize">{provider}</p>
<div className="flex flex-col gap-1.5">
{provModels.map((m) => {
const status = STATUS_CONFIG[m.status] || STATUS_CONFIG.unknown;
const isClearing = clearing === `${m.provider}:${m.model}`;
return (
<div
key={`${m.provider}-${m.model}`}
className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface/30"
>
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[16px]"
style={{ color: status.color }}
>
{status.icon}
</span>
<span className="font-mono text-sm text-text-main">{m.model}</span>
<span
className="text-xs px-1.5 py-0.5 rounded-full"
style={{
backgroundColor: `${status.color}15`,
color: status.color,
}}
>
{status.label}
</span>
{m.cooldownUntil && (
<span className="text-xs text-text-muted">
until {new Date(m.cooldownUntil).toLocaleTimeString()}
</span>
)}
</div>
{m.status === "cooldown" && (
<Button
size="sm"
variant="ghost"
onClick={() => handleClearCooldown(m.provider, m.model)}
disabled={isClearing}
className="text-xs"
>
{isClearing ? "Clearing..." : "Clear"}
</Button>
)}
</div>
);
})}
</div>
</div>
))}
</div>
</Card>
);
}

View File

@@ -12,6 +12,8 @@ import {
} from "@/shared/constants/providers";
import Link from "next/link";
import { getErrorCode, getRelativeTime } from "@/shared/utils";
import { useNotificationStore } from "@/store/notificationStore";
import ModelAvailabilityPanel from "./components/ModelAvailabilityPanel";
// Shared helper function to avoid code duplication between ProviderCard and ApiKeyProviderCard
function getStatusDisplay(connected, error, errorCode) {
@@ -85,6 +87,7 @@ export default function ProvidersPage() {
const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false);
const [testingMode, setTestingMode] = useState(null);
const [testResults, setTestResults] = useState(null);
const notify = useNotificationStore();
useEffect(() => {
const fetchData = async () => {
@@ -153,8 +156,14 @@ export default function ProvidersPage() {
});
const data = await res.json();
setTestResults(data);
if (data.summary) {
const { passed, failed, total } = data.summary;
if (failed === 0) notify.success(`All ${total} tests passed`);
else notify.warning(`${passed}/${total} passed, ${failed} failed`);
}
} catch (error) {
setTestResults({ error: "Test request failed" });
notify.error("Provider test failed");
} finally {
setTestingMode(null);
}
@@ -387,6 +396,9 @@ export default function ProvidersPage() {
</div>
</div>
)}
{/* Model Availability */}
<ModelAvailabilityPanel />
</div>
);
}

View File

@@ -1,12 +1,28 @@
"use client";
import { useState, useEffect } from "react";
import { Card, DataTable } from "@/shared/components";
import { useState, useEffect, useCallback } from "react";
import { Card, DataTable, FilterBar, ColumnToggle } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
const ALL_COLUMNS = [
{ key: "timestamp", label: "Time" },
{ key: "action", label: "Action" },
{ key: "actor", label: "Actor" },
{ key: "details", label: "Details" },
];
export default function ComplianceTab() {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("");
const [search, setSearch] = useState("");
const [filters, setFilters] = useState({});
const [visibleCols, setVisibleCols] = useState({
timestamp: true,
action: true,
actor: true,
details: true,
});
const notify = useNotificationStore();
useEffect(() => {
fetch("/api/compliance/audit-log?limit=100")
@@ -15,16 +31,61 @@ export default function ComplianceTab() {
setLogs(Array.isArray(data) ? data : data.logs || []);
setLoading(false);
})
.catch(() => setLoading(false));
.catch(() => {
setLoading(false);
notify.error("Failed to load audit log");
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const actionOptions = [...new Set(logs.map((l) => l.action).filter(Boolean))];
const actorOptions = [...new Set(logs.map((l) => l.actor).filter(Boolean))];
const filtered = logs.filter((l) => {
if (search) {
const q = search.toLowerCase();
const matchesSearch =
l.action?.toLowerCase().includes(q) ||
l.actor?.toLowerCase().includes(q) ||
(l.details && JSON.stringify(l.details).toLowerCase().includes(q));
if (!matchesSearch) return false;
}
if (filters.action && l.action !== filters.action) return false;
if (filters.actor && l.actor !== filters.actor) return false;
return true;
});
const columns = ALL_COLUMNS.filter((c) => visibleCols[c.key]);
const handleToggleCol = useCallback((key) => {
setVisibleCols((prev) => ({ ...prev, [key]: !prev[key] }));
}, []);
const filtered = filter
? logs.filter(
(l) =>
l.action?.toLowerCase().includes(filter.toLowerCase()) ||
l.actor?.toLowerCase().includes(filter.toLowerCase())
)
: logs;
const renderCell = useCallback((row, col) => {
switch (col.key) {
case "timestamp":
return (
<span className="font-mono text-xs text-text-muted whitespace-nowrap">
{row.timestamp ? new Date(row.timestamp).toLocaleString() : "—"}
</span>
);
case "action":
return (
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">
{row.action || "—"}
</span>
);
case "actor":
return <span className="text-text-main">{row.actor || "system"}</span>;
case "details":
return (
<span className="text-text-muted text-xs max-w-xs truncate block">
{row.details ? JSON.stringify(row.details) : "—"}
</span>
);
default:
return row[col.key] || "—";
}
}, []);
return (
<Card className="p-6">
@@ -33,51 +94,30 @@ export default function ComplianceTab() {
<span className="material-symbols-outlined text-[20px]">policy</span>
Audit Log
</h3>
<input
type="text"
placeholder="Filter by action or actor..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="px-3 py-1.5 text-sm rounded-lg bg-black/5 dark:bg-white/5 border border-border text-text-main placeholder:text-text-muted focus:outline-none focus:ring-1 focus:ring-accent"
/>
<ColumnToggle columns={ALL_COLUMNS} visible={visibleCols} onToggle={handleToggleCol} />
</div>
{loading ? (
<p className="text-sm text-text-muted">Loading audit log</p>
) : filtered.length === 0 ? (
<p className="text-sm text-text-muted">No audit events found.</p>
) : (
<div className="overflow-auto max-h-96 rounded-lg border border-border">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-bg border-b border-border">
<tr>
<th className="text-left px-3 py-2 text-text-muted font-medium">Time</th>
<th className="text-left px-3 py-2 text-text-muted font-medium">Action</th>
<th className="text-left px-3 py-2 text-text-muted font-medium">Actor</th>
<th className="text-left px-3 py-2 text-text-muted font-medium">Details</th>
</tr>
</thead>
<tbody>
{filtered.map((log, i) => (
<tr key={i} className="border-b border-border/50 hover:bg-black/5 dark:hover:bg-white/5">
<td className="px-3 py-2 text-text-muted font-mono text-xs whitespace-nowrap">
{log.timestamp ? new Date(log.timestamp).toLocaleString() : "—"}
</td>
<td className="px-3 py-2">
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-accent/10 text-accent">
{log.action || "—"}
</span>
</td>
<td className="px-3 py-2 text-text-main">{log.actor || "system"}</td>
<td className="px-3 py-2 text-text-muted text-xs max-w-xs truncate">
{log.details ? JSON.stringify(log.details) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<FilterBar
searchValue={search}
onSearchChange={setSearch}
placeholder="Search audit logs..."
filters={[
{ key: "action", label: "Action", options: actionOptions },
{ key: "actor", label: "Actor", options: actorOptions },
]}
activeFilters={filters}
onFilterChange={(key, val) => setFilters((prev) => ({ ...prev, [key]: val }))}
/>
<DataTable
columns={columns}
data={filtered}
renderCell={renderCell}
loading={loading}
maxHeight="400px"
emptyIcon="📋"
emptyMessage="No audit events found"
/>
</Card>
);
}

View File

@@ -0,0 +1,215 @@
"use client";
/**
* FallbackChainsEditor — Batch D
*
* Editor for model fallback chains. Each chain maps a model name
* to a prioritized list of providers that can serve it.
* API: /api/fallback/chains
*/
import { useState, useEffect, useCallback } from "react";
import { Card, Button, Input, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
const CHAIN_COLORS = [
"#6366f1",
"#22c55e",
"#f59e0b",
"#ef4444",
"#8b5cf6",
"#06b6d4",
"#ec4899",
"#14b8a6",
];
export default function FallbackChainsEditor() {
const [chains, setChains] = useState({});
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [newModel, setNewModel] = useState("");
const [newProviders, setNewProviders] = useState("");
const [saving, setSaving] = useState(false);
const notify = useNotificationStore();
const fetchChains = useCallback(async () => {
try {
const res = await fetch("/api/fallback/chains");
if (res.ok) {
const data = await res.json();
setChains(data.chains || data || {});
}
} catch {
// silent
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchChains();
}, [fetchChains]);
const handleCreate = async () => {
if (!newModel.trim() || !newProviders.trim()) {
notify.warning("Please fill model name and providers");
return;
}
const providers = newProviders
.split(",")
.map((p) => p.trim())
.filter(Boolean)
.map((provider, i) => ({ provider, priority: i + 1, enabled: true }));
if (providers.length === 0) {
notify.warning("Add at least one provider");
return;
}
setSaving(true);
try {
const res = await fetch("/api/fallback/chains", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: newModel.trim(), chain: providers }),
});
if (res.ok) {
notify.success(`Chain created for ${newModel.trim()}`);
setNewModel("");
setNewProviders("");
setShowCreate(false);
await fetchChains();
} else {
notify.error("Failed to create chain");
}
} catch {
notify.error("Failed to create chain");
} finally {
setSaving(false);
}
};
const handleDelete = async (model) => {
if (!confirm(`Delete fallback chain for "${model}"?`)) return;
try {
const res = await fetch("/api/fallback/chains", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model }),
});
if (res.ok) {
notify.success(`Chain deleted for ${model}`);
await fetchChains();
} else {
notify.error("Failed to delete chain");
}
} catch {
notify.error("Failed to delete chain");
}
};
if (loading) {
return (
<Card className="p-6 mt-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">timeline</span>
Loading fallback chains...
</div>
</Card>
);
}
const chainEntries = Object.entries(chains);
return (
<Card className="mt-6">
<div className="flex items-center gap-3 mb-4 p-6 pb-0">
<div className="p-2 rounded-lg bg-cyan-500/10 text-cyan-500">
<span className="material-symbols-outlined text-[20px]">timeline</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">Fallback Chains</h3>
<p className="text-sm text-text-muted">Define provider fallback order per model</p>
</div>
<Button size="sm" variant="primary" onClick={() => setShowCreate(!showCreate)}>
{showCreate ? "Cancel" : "+ Add Chain"}
</Button>
</div>
{/* Create Form */}
{showCreate && (
<div className="mx-6 p-4 rounded-lg border border-border/30 bg-surface/20 mb-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
<Input
label="Model Name"
placeholder="claude-sonnet-4-20250514"
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
/>
<Input
label="Providers (comma-separated, in priority order)"
placeholder="anthropic, openai, gemini"
value={newProviders}
onChange={(e) => setNewProviders(e.target.value)}
/>
</div>
<Button variant="primary" size="sm" onClick={handleCreate} loading={saving}>
Create Chain
</Button>
</div>
)}
{/* Chains List */}
<div className="px-6 pb-6">
{chainEntries.length === 0 ? (
<EmptyState
icon="timeline"
title="No Fallback Chains"
description="Create a chain to define provider fallback order for a model."
/>
) : (
<div className="flex flex-col gap-2">
{chainEntries.map(([model, chain]) => (
<div
key={model}
className="flex items-center justify-between px-4 py-3 rounded-lg border border-border/20 bg-surface/20 hover:bg-surface/40 transition-colors"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<span className="font-mono text-sm text-text-main truncate max-w-[200px]">
{model}
</span>
<span className="material-symbols-outlined text-[14px] text-text-muted">
arrow_forward
</span>
<div className="flex gap-1.5 flex-wrap">
{(Array.isArray(chain) ? chain : []).map((entry, i) => (
<span
key={`${entry.provider}-${i}`}
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{
backgroundColor: `${CHAIN_COLORS[i % CHAIN_COLORS.length]}20`,
color: CHAIN_COLORS[i % CHAIN_COLORS.length],
border: `1px solid ${CHAIN_COLORS[i % CHAIN_COLORS.length]}40`,
}}
>
{i + 1}. {entry.provider}
</span>
))}
</div>
</div>
<button
onClick={() => handleDelete(model)}
className="text-text-muted hover:text-red-400 transition-colors ml-2"
title="Delete chain"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
))}
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,200 @@
"use client";
/**
* PoliciesPanel — Batch E
*
* Shows circuit breaker states and locked identifiers.
* Allows force-unlocking locked identifiers.
* API: /api/policies
*/
import { useState, useEffect, useCallback } from "react";
import { Card, Button, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
const CB_STATUS = {
closed: { icon: "check_circle", color: "#22c55e", label: "Closed" },
"half-open": { icon: "pending", color: "#f59e0b", label: "Half-Open" },
open: { icon: "error", color: "#ef4444", label: "Open" },
};
export default function PoliciesPanel() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [unlocking, setUnlocking] = useState(null);
const notify = useNotificationStore();
const fetchPolicies = useCallback(async () => {
try {
const res = await fetch("/api/policies");
if (res.ok) {
const json = await res.json();
setData(json);
}
} catch {
// silent
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchPolicies();
const interval = setInterval(fetchPolicies, 15000);
return () => clearInterval(interval);
}, [fetchPolicies]);
const handleUnlock = async (identifier) => {
setUnlocking(identifier);
try {
const res = await fetch("/api/policies", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "unlock", identifier }),
});
if (res.ok) {
notify.success(`Unlocked: ${identifier}`);
await fetchPolicies();
} else {
notify.error("Failed to unlock");
}
} catch {
notify.error("Failed to unlock");
} finally {
setUnlocking(null);
}
};
if (loading) {
return (
<Card className="p-6 mt-6">
<div className="flex items-center gap-2 text-text-muted animate-pulse">
<span className="material-symbols-outlined text-[20px]">security</span>
Loading policies...
</div>
</Card>
);
}
const circuitBreakers = data?.circuitBreakers || [];
const lockedIds = data?.lockedIdentifiers || [];
const hasIssues = circuitBreakers.some((cb) => cb.state !== "closed") || lockedIds.length > 0;
if (!hasIssues) {
return (
<Card className="p-6 mt-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<span className="material-symbols-outlined text-[20px]">verified_user</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Policies & Circuit Breakers</h3>
<p className="text-sm text-text-muted">
All systems operational no lockouts or tripped breakers
</p>
</div>
</div>
</Card>
);
}
return (
<Card className="p-6 mt-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-red-500/10 text-red-500">
<span className="material-symbols-outlined text-[20px]">gpp_maybe</span>
</div>
<div>
<h3 className="text-lg font-semibold text-text-main">Policies & Circuit Breakers</h3>
<p className="text-sm text-text-muted">Active issues detected</p>
</div>
</div>
<Button size="sm" variant="ghost" onClick={fetchPolicies}>
<span className="material-symbols-outlined text-[16px]">refresh</span>
</Button>
</div>
{/* Circuit Breakers */}
{circuitBreakers.filter((cb) => cb.state !== "closed").length > 0 && (
<div className="mb-4">
<p className="text-sm font-medium text-text-muted mb-2">Circuit Breakers</p>
<div className="flex flex-col gap-1.5">
{circuitBreakers
.filter((cb) => cb.state !== "closed")
.map((cb, i) => {
const status = CB_STATUS[cb.state] || CB_STATUS.open;
return (
<div
key={cb.name || i}
className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface/30 border border-border/20"
>
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[16px]"
style={{ color: status.color }}
>
{status.icon}
</span>
<span className="text-sm text-text-main font-medium">
{cb.name || cb.provider || "Unknown"}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded-full"
style={{
backgroundColor: `${status.color}15`,
color: status.color,
}}
>
{status.label}
</span>
{cb.failures > 0 && (
<span className="text-xs text-text-muted">{cb.failures} failures</span>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Locked Identifiers */}
{lockedIds.length > 0 && (
<div>
<p className="text-sm font-medium text-text-muted mb-2">Locked Identifiers</p>
<div className="flex flex-col gap-1.5">
{lockedIds.map((id, i) => {
const identifier = typeof id === "string" ? id : id.identifier || id.id;
return (
<div
key={identifier || i}
className="flex items-center justify-between px-3 py-2 rounded-lg bg-surface/30 border border-border/20"
>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-red-400">lock</span>
<span className="font-mono text-sm text-text-main">{identifier}</span>
{typeof id === "object" && id.lockedAt && (
<span className="text-xs text-text-muted">
since {new Date(id.lockedAt).toLocaleString()}
</span>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleUnlock(identifier)}
disabled={unlocking === identifier}
className="text-xs"
>
{unlocking === identifier ? "Unlocking..." : "Force Unlock"}
</Button>
</div>
);
})}
</div>
</div>
)}
</Card>
);
}

View File

@@ -2,9 +2,15 @@
import { useState, useEffect } from "react";
import { Card, Input, Toggle, Button } from "@/shared/components";
import FallbackChainsEditor from "./FallbackChainsEditor";
const STRATEGIES = [
{ value: "fill-first", label: "Fill First", desc: "Use accounts in priority order", icon: "vertical_align_top" },
{
value: "fill-first",
label: "Fill First",
desc: "Use accounts in priority order",
icon: "vertical_align_top",
},
{ value: "round-robin", label: "Round Robin", desc: "Cycle through all accounts", icon: "loop" },
{ value: "p2c", label: "P2C", desc: "Pick 2 random, use the healthier one", icon: "balance" },
];
@@ -90,7 +96,9 @@ export default function RoutingTab() {
{s.icon}
</span>
<div>
<p className={`text-sm font-medium ${settings.fallbackStrategy === s.value ? "text-blue-400" : ""}`}>
<p
className={`text-sm font-medium ${settings.fallbackStrategy === s.value ? "text-blue-400" : ""}`}
>
{s.label}
</p>
<p className="text-xs text-text-muted mt-0.5">{s.desc}</p>
@@ -120,7 +128,8 @@ export default function RoutingTab() {
<p className="text-xs text-text-muted italic pt-3 border-t border-border/30 mt-3">
{settings.fallbackStrategy === "round-robin" &&
`Distributing requests across accounts with ${settings.stickyRoundRobinLimit || 3} calls per account.`}
{settings.fallbackStrategy === "fill-first" && "Using accounts in priority order (Fill First)."}
{settings.fallbackStrategy === "fill-first" &&
"Using accounts in priority order (Fill First)."}
{settings.fallbackStrategy === "p2c" &&
"Power of Two Choices: picks 2 random accounts and routes to the healthier one."}
</p>
@@ -136,7 +145,9 @@ export default function RoutingTab() {
</div>
<div>
<h3 className="text-lg font-semibold">Model Aliases</h3>
<p className="text-sm text-text-muted">Wildcard patterns to remap model names Use * and ?</p>
<p className="text-sm text-text-muted">
Wildcard patterns to remap model names Use * and ?
</p>
</div>
</div>
@@ -149,7 +160,9 @@ export default function RoutingTab() {
>
<div className="flex items-center gap-3 text-sm">
<span className="font-mono text-purple-400">{a.pattern}</span>
<span className="material-symbols-outlined text-[14px] text-text-muted">arrow_forward</span>
<span className="material-symbols-outlined text-[14px] text-text-muted">
arrow_forward
</span>
<span className="font-mono text-text-main">{a.target}</span>
</div>
<button
@@ -185,6 +198,9 @@ export default function RoutingTab() {
</Button>
</div>
</Card>
{/* Fallback Chains */}
<FallbackChainsEditor />
</div>
);
}

View File

@@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { Card, Button, Input, Toggle } from "@/shared/components";
import IPFilterSection from "./IPFilterSection";
import PoliciesPanel from "./PoliciesPanel";
export default function SecurityTab() {
const [settings, setSettings] = useState({ requireLogin: false, hasPassword: false });
@@ -146,6 +147,7 @@ export default function SecurityTab() {
</div>
</Card>
<IPFilterSection />
<PoliciesPanel />
</div>
);
}

View File

@@ -0,0 +1,239 @@
"use client";
/**
* 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 }) {
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">${value.toFixed(2)}</span>
<span className="text-text-muted">${max.toFixed(2)}</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 [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: "",
monthlyLimitUsd: "",
warningThreshold: "80",
});
const notify = useNotificationStore();
// 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.monthlyLimitUsd)
setForm((f) => ({ ...f, monthlyLimitUsd: String(data.monthlyLimitUsd) }));
if (data.warningThreshold)
setForm((f) => ({ ...f, warningThreshold: String(data.warningThreshold) }));
}
} 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) : null,
monthlyLimitUsd: form.monthlyLimitUsd ? parseFloat(form.monthlyLimitUsd) : null,
warningThreshold: parseInt(form.warningThreshold) || 80,
}),
});
if (res.ok) {
notify.success("Budget limits saved");
await fetchBudget();
} else {
notify.error("Failed to save budget");
}
} catch {
notify.error("Failed to save budget");
} 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]">account_balance_wallet</span>
Loading budget data...
</div>
);
}
if (keys.length === 0) {
return (
<EmptyState
icon="vpn_key"
title="No API Keys"
description="Add API keys first to set up budget limits."
/>
);
}
const dailyLimit = budget?.dailyLimitUsd || parseFloat(form.dailyLimitUsd) || 0;
const monthlyLimit = budget?.monthlyLimitUsd || parseFloat(form.monthlyLimitUsd) || 0;
const dailyCost = budget?.totalCostToday || 0;
const monthlyCost = budget?.totalCostMonth || 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]">account_balance_wallet</span>
</div>
<h3 className="text-lg font-semibold">Budget Management</h3>
</div>
<div className="mb-4">
<label className="text-sm text-text-muted mb-1 block">API Key</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-2 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">Today&apos;s Spend</p>
<p className="text-2xl font-bold text-text-main">${dailyCost.toFixed(2)}</p>
{dailyLimit > 0 && (
<ProgressBar value={dailyCost} max={dailyLimit} warningAt={warnPct} />
)}
</div>
<div className="p-4 rounded-lg border border-border/30 bg-surface/20">
<p className="text-sm text-text-muted mb-2">This Month</p>
<p className="text-2xl font-bold text-text-main">${monthlyCost.toFixed(2)}</p>
{monthlyLimit > 0 && (
<ProgressBar value={monthlyCost} max={monthlyLimit} warningAt={warnPct} />
)}
</div>
</div>
{/* Budget Form */}
<div className="border-t border-border/30 pt-4">
<p className="text-sm font-medium mb-3">Set Limits</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<Input
label="Daily Limit (USD)"
type="number"
step="0.01"
min="0"
placeholder="e.g. 5.00"
value={form.dailyLimitUsd}
onChange={(e) => setForm({ ...form, dailyLimitUsd: e.target.value })}
/>
<Input
label="Monthly Limit (USD)"
type="number"
step="0.01"
min="0"
placeholder="e.g. 50.00"
value={form.monthlyLimitUsd}
onChange={(e) => setForm({ ...form, monthlyLimitUsd: e.target.value })}
/>
<Input
label="Warning Threshold (%)"
type="number"
min="1"
max="100"
placeholder="80"
value={form.warningThreshold}
onChange={(e) => setForm({ ...form, warningThreshold: e.target.value })}
/>
</div>
<Button variant="primary" onClick={handleSave} loading={saving}>
Save Limits
</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]"
style={{ color: budget.budgetCheck.allowed ? "#22c55e" : "#ef4444" }}
>
{budget.budgetCheck.allowed ? "check_circle" : "block"}
</span>
<span className="text-sm">
{budget.budgetCheck.allowed
? `Budget OK — $${(budget.budgetCheck.remaining || 0).toFixed(2)} remaining`
: "Budget exceeded — requests may be blocked"}
</span>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,204 @@
"use client";
/**
* EvalsTab — Batch F
*
* Lists evaluation suites, runs evals, and shows results.
* API: GET/POST /api/evals, GET /api/evals/[suiteId]
*/
import { useState, useEffect, useCallback } from "react";
import { Card, Button, EmptyState, DataTable, FilterBar } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
export default function EvalsTab() {
const [suites, setSuites] = useState([]);
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(null);
const [results, setResults] = useState({});
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState(null);
const notify = useNotificationStore();
const fetchSuites = useCallback(async () => {
try {
const res = await fetch("/api/evals");
if (res.ok) {
const data = await res.json();
setSuites(Array.isArray(data) ? data : data.suites || []);
}
} catch {
// silent
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchSuites();
}, [fetchSuites]);
const handleRunEval = async (suite) => {
setRunning(suite.id);
try {
const res = await fetch("/api/evals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
suiteId: suite.id,
outputs: {},
}),
});
const data = await res.json();
setResults((prev) => ({ ...prev, [suite.id]: data }));
if (data.passed !== undefined) {
const total = (data.passed || 0) + (data.failed || 0);
if (data.failed === 0) {
notify.success(`All ${total} cases passed`, `Eval: ${suite.name}`);
} else {
notify.warning(
`${data.passed}/${total} passed, ${data.failed} failed`,
`Eval: ${suite.name}`
);
}
}
} catch {
notify.error("Eval run failed");
} finally {
setRunning(null);
}
};
const filtered = suites.filter((s) => {
if (!search) return true;
return (
s.name?.toLowerCase().includes(search.toLowerCase()) ||
s.id?.toLowerCase().includes(search.toLowerCase())
);
});
if (loading) {
return (
<div className="flex items-center gap-2 text-text-muted p-8 animate-pulse">
<span className="material-symbols-outlined text-[20px]">science</span>
Loading eval suites...
</div>
);
}
if (suites.length === 0) {
return (
<EmptyState
icon="science"
title="No Eval Suites"
description="Eval suites can be defined via the API to test model outputs against expected results."
/>
);
}
const RESULT_COLUMNS = [
{ key: "caseId", label: "Case" },
{ key: "status", label: "Status" },
{ key: "expected", label: "Expected" },
{ key: "actual", label: "Actual" },
];
return (
<div className="flex flex-col gap-6">
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-lg bg-violet-500/10 text-violet-500">
<span className="material-symbols-outlined text-[20px]">science</span>
</div>
<h3 className="text-lg font-semibold">Evaluation Suites</h3>
</div>
<FilterBar
searchValue={search}
onSearchChange={setSearch}
placeholder="Search suites..."
filters={[]}
activeFilters={{}}
onFilterChange={() => {}}
/>
<div className="flex flex-col gap-3 mt-4">
{filtered.map((suite) => {
const suiteResult = results[suite.id];
const isRunning = running === suite.id;
const isExpanded = expanded === suite.id;
const caseCount = suite.cases?.length || 0;
return (
<div key={suite.id} className="border border-border/30 rounded-lg overflow-hidden">
<div
className="flex items-center justify-between px-4 py-3 cursor-pointer hover:bg-surface/30 transition-colors"
onClick={() => setExpanded(isExpanded ? null : suite.id)}
>
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-[16px] text-text-muted">
{isExpanded ? "expand_more" : "chevron_right"}
</span>
<div>
<p className="text-sm font-medium text-text-main">{suite.name || suite.id}</p>
<p className="text-xs text-text-muted">
{caseCount} case{caseCount !== 1 ? "s" : ""}
{suiteResult && (
<span className="ml-2">
Last run: {suiteResult.passed || 0} {suiteResult.failed || 0}
</span>
)}
</p>
</div>
</div>
<Button
size="sm"
variant="primary"
onClick={(e) => {
e.stopPropagation();
handleRunEval(suite);
}}
loading={isRunning}
disabled={isRunning}
>
{isRunning ? "Running..." : "Run Eval"}
</Button>
</div>
{isExpanded && suiteResult?.results && (
<div className="border-t border-border/20 p-4">
<DataTable
columns={RESULT_COLUMNS}
data={suiteResult.results.map((r, i) => ({
...r,
id: r.caseId || i,
}))}
renderCell={(row, col) => {
if (col.key === "status") {
return row.passed ? (
<span className="text-emerald-400"> Passed</span>
) : (
<span className="text-red-400"> Failed</span>
);
}
return (
<span className="text-text-muted text-xs truncate max-w-[200px] block">
{typeof row[col.key] === "object"
? JSON.stringify(row[col.key])
: row[col.key] || "—"}
</span>
);
}}
maxHeight="300px"
emptyMessage="No results yet"
/>
</div>
)}
</div>
);
})}
</div>
</Card>
</div>
);
}

View File

@@ -11,8 +11,9 @@ import {
import ProviderLimits from "./components/ProviderLimits";
import SessionsTab from "./components/SessionsTab";
import RateLimitStatus from "./components/RateLimitStatus";
import BudgetTelemetryCards from "./components/BudgetTelemetryCards";
import BudgetTab from "./components/BudgetTab";
import EvalsTab from "./components/EvalsTab";
export default function UsagePage() {
const [activeTab, setActiveTab] = useState("overview");
@@ -26,6 +27,8 @@ export default function UsagePage() {
{ value: "proxy-logs", label: "Proxy" },
{ value: "limits", label: "Limits" },
{ value: "sessions", label: "Sessions" },
{ value: "budget", label: "Budget" },
{ value: "evals", label: "Evals" },
]}
value={activeTab}
onChange={setActiveTab}
@@ -49,6 +52,8 @@ export default function UsagePage() {
</div>
)}
{activeTab === "sessions" && <SessionsTab />}
{activeTab === "budget" && <BudgetTab />}
{activeTab === "evals" && <EvalsTab />}
</div>
);
}

View File

@@ -0,0 +1,36 @@
/**
* Token Health API Route — Batch G
*
* Exposes aggregate health status of OAuth tokens.
* Used by TokenHealthBadge in the Header.
*/
import { getProviderConnections } from "@/lib/localDb";
export async function GET() {
try {
const connections = await getProviderConnections({ authType: "oauth" });
const oauthConns = (connections || []).filter((c) => c.isActive && c.refreshToken);
const total = oauthConns.length;
const healthy = oauthConns.filter((c) => c.testStatus === "active" || !c.lastError).length;
const errored = oauthConns.filter(
(c) => c.testStatus === "error" || c.lastErrorType === "token_refresh_failed"
).length;
const lastCheck = oauthConns.reduce((latest, c) => {
if (!c.lastHealthCheckAt) return latest;
return latest && latest > c.lastHealthCheckAt ? latest : c.lastHealthCheckAt;
}, null);
return Response.json({
total,
healthy,
errored,
warning: total - healthy - errored,
lastCheckAt: lastCheck,
status: errored > 0 ? "error" : healthy < total ? "warning" : "healthy",
});
} catch (err) {
return Response.json({ error: err.message, status: "unknown" }, { status: 500 });
}
}

View File

@@ -1,183 +0,0 @@
// @ts-check
/**
* Policy Engine — FASE-08 LLM Proxy Advanced
*
* Declarative policy engine for routing, budget, and access control decisions
* in the LLM proxy pipeline. Policies are evaluated before provider selection.
*
* @module lib/policies/policyEngine
*/
/**
* @typedef {'routing'|'budget'|'access'} PolicyType
*/
/**
* @typedef {Object} Policy
* @property {string} id - Unique policy ID
* @property {string} name - Display name
* @property {PolicyType} type - Policy type
* @property {boolean} enabled - Whether the policy is active
* @property {number} priority - Evaluation order (lower = first)
* @property {Object} conditions - Matching conditions
* @property {string} [conditions.model_pattern] - Glob pattern for model names
* @property {string} [conditions.provider] - Provider ID
* @property {string} [conditions.api_key_id] - API key ID
* @property {Object} actions - Actions to take when matched
* @property {string[]} [actions.prefer_provider] - Preferred providers
* @property {string[]} [actions.block_provider] - Blocked providers
* @property {string[]} [actions.block_model] - Blocked models
* @property {number} [actions.max_cost_per_1k] - Max cost per 1000 tokens
* @property {number} [actions.max_tokens] - Max tokens per request
* @property {number} [actions.daily_budget] - Daily budget in USD
* @property {string} createdAt - ISO timestamp
* @property {string} updatedAt - ISO timestamp
*/
/**
* Simple glob pattern matching (supports * wildcard only).
* @param {string} pattern
* @param {string} value
* @returns {boolean}
*/
function matchGlob(pattern, value) {
if (!pattern || pattern === "*") return true;
const regex = new RegExp(
"^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$",
"i"
);
return regex.test(value);
}
export class PolicyEngine {
/** @type {Policy[]} */
#policies = [];
/**
* Load policies from an array.
* @param {Policy[]} policies
*/
loadPolicies(policies) {
this.#policies = [...policies].sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
}
/**
* Add a single policy.
* @param {Policy} policy
*/
addPolicy(policy) {
this.#policies.push(policy);
this.#policies.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
}
/**
* Remove a policy by ID.
* @param {string} id
* @returns {boolean}
*/
removePolicy(id) {
const idx = this.#policies.findIndex((p) => p.id === id);
if (idx === -1) return false;
this.#policies.splice(idx, 1);
return true;
}
/**
* Get all policies.
* @returns {Policy[]}
*/
getPolicies() {
return [...this.#policies];
}
/**
* Evaluate all policies against a request context.
*
* @param {{ model: string, provider?: string, apiKeyId?: string }} context
* @returns {{ allowed: boolean, reason?: string, preferredProviders?: string[], blockedProviders?: string[], maxTokens?: number }}
*/
evaluate(context) {
const result = {
allowed: true,
preferredProviders: [],
blockedProviders: [],
blockedModels: [],
maxTokens: undefined,
maxCostPer1k: undefined,
appliedPolicies: [],
};
for (const policy of this.#policies) {
if (!policy.enabled) continue;
// Check conditions
const conditions = policy.conditions || {};
let matches = true;
if (conditions.model_pattern && !matchGlob(conditions.model_pattern, context.model)) {
matches = false;
}
if (conditions.provider && conditions.provider !== context.provider) {
matches = false;
}
if (conditions.api_key_id && conditions.api_key_id !== context.apiKeyId) {
matches = false;
}
if (!matches) continue;
// Apply actions
const actions = policy.actions || {};
result.appliedPolicies.push(policy.name);
if (actions.prefer_provider) {
result.preferredProviders.push(...actions.prefer_provider);
}
if (actions.block_provider) {
result.blockedProviders.push(...actions.block_provider);
}
if (actions.block_model) {
const isBlocked = actions.block_model.some((pattern) =>
matchGlob(pattern, context.model)
);
if (isBlocked) {
result.allowed = false;
result.reason = `Model "${context.model}" blocked by policy "${policy.name}"`;
break;
}
}
if (actions.max_tokens !== undefined) {
result.maxTokens =
result.maxTokens !== undefined
? Math.min(result.maxTokens, actions.max_tokens)
: actions.max_tokens;
}
if (actions.max_cost_per_1k !== undefined) {
result.maxCostPer1k =
result.maxCostPer1k !== undefined
? Math.min(result.maxCostPer1k, actions.max_cost_per_1k)
: actions.max_cost_per_1k;
}
}
return result;
}
}
// Singleton instance
let engineInstance;
/**
* Get the global policy engine instance.
* @returns {PolicyEngine}
*/
export function getPolicyEngine() {
if (!engineInstance) {
engineInstance = new PolicyEngine();
}
return engineInstance;
}

View File

@@ -5,11 +5,13 @@
*
* Dashboard breadcrumb navigation component. Automatically generates
* breadcrumbs from the current path with friendly labels.
* Uses usePathname() internally — no props needed.
*
* Usage:
* <Breadcrumbs pathname="/dashboard/providers/add" />
* <Breadcrumbs />
*/
import { usePathname } from "next/navigation";
import Link from "next/link";
const PATH_LABELS = {
@@ -37,7 +39,8 @@ function getLabel(segment) {
return PATH_LABELS[segment] || segment.charAt(0).toUpperCase() + segment.slice(1);
}
export default function Breadcrumbs({ pathname }) {
export default function Breadcrumbs() {
const pathname = usePathname();
if (!pathname || pathname === "/dashboard") return null;
const segments = pathname.split("/").filter(Boolean);

View File

@@ -5,6 +5,7 @@ import Link from "next/link";
import Image from "next/image";
import PropTypes from "prop-types";
import { ThemeToggle } from "@/shared/components";
import TokenHealthBadge from "./TokenHealthBadge";
import {
OAUTH_PROVIDERS,
APIKEY_PROVIDERS,
@@ -170,6 +171,9 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
{/* Theme toggle */}
<ThemeToggle />
{/* Token health */}
<TokenHealthBadge />
{/* Logout button */}
<button
onClick={handleLogout}

View File

@@ -0,0 +1,109 @@
"use client";
/**
* TokenHealthBadge — Batch G
*
* Small badge in the Header showing token health status.
* Polls /api/token-health every 60s.
*/
import { useState, useEffect } from "react";
const STATUS_MAP = {
healthy: { icon: "check_circle", color: "#22c55e", tooltip: "All tokens healthy" },
warning: { icon: "warning", color: "#f59e0b", tooltip: "Some tokens need attention" },
error: { icon: "error", color: "#ef4444", tooltip: "Token refresh failures detected" },
unknown: { icon: "help", color: "#6b7280", tooltip: "Health status unknown" },
};
export default function TokenHealthBadge() {
const [health, setHealth] = useState(null);
const [showTooltip, setShowTooltip] = useState(false);
useEffect(() => {
const fetchHealth = async () => {
try {
const res = await fetch("/api/token-health");
if (res.ok) {
const data = await res.json();
setHealth(data);
}
} catch {
// silent
}
};
fetchHealth();
const interval = setInterval(fetchHealth, 60000);
return () => clearInterval(interval);
}, []);
if (!health || health.total === 0) return null;
const status = STATUS_MAP[health.status] || STATUS_MAP.unknown;
return (
<div
className="relative"
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
<button
className="flex items-center gap-1 px-2 py-1.5 rounded-lg hover:bg-surface/30 transition-colors"
title={status.tooltip}
>
<span className="material-symbols-outlined text-[18px]" style={{ color: status.color }}>
{status.icon}
</span>
{health.errored > 0 && (
<span className="text-xs font-medium" style={{ color: status.color }}>
{health.errored}
</span>
)}
</button>
{showTooltip && (
<div
className="absolute top-full right-0 mt-1 z-50 min-w-[200px] p-3 rounded-lg shadow-lg"
style={{
background: "rgba(15, 15, 25, 0.95)",
border: "1px solid rgba(255,255,255,0.1)",
backdropFilter: "blur(12px)",
}}
>
<p className="text-xs font-medium text-text-main mb-2">Token Health</p>
<div className="flex flex-col gap-1 text-xs">
<div className="flex justify-between">
<span className="text-text-muted">Total OAuth</span>
<span className="text-text-main">{health.total}</span>
</div>
<div className="flex justify-between">
<span className="text-emerald-400">Healthy</span>
<span className="text-text-main">{health.healthy}</span>
</div>
{health.errored > 0 && (
<div className="flex justify-between">
<span className="text-red-400">Errored</span>
<span className="text-text-main">{health.errored}</span>
</div>
)}
{health.warning > 0 && (
<div className="flex justify-between">
<span className="text-amber-400">Warning</span>
<span className="text-text-main">{health.warning}</span>
</div>
)}
{health.lastCheckAt && (
<div className="flex justify-between mt-1 pt-1 border-t border-white/5">
<span className="text-text-muted">Last check</span>
<span className="text-text-muted">
{new Date(health.lastCheckAt).toLocaleTimeString()}
</span>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,135 +0,0 @@
/**
* Accessibility Audit Utility — T-35
*
* Provides utilities for running accessibility audits
* using axe-core in automated tests or manual checks.
*
* Usage:
* import { auditPage, WCAG_RULES } from "@/shared/utils/a11yAudit";
*
* @module shared/utils/a11yAudit
*/
// @ts-check
/**
* WCAG AA rules to check against.
* These are the most impactful accessibility issues.
*/
export const WCAG_RULES = {
/** All interactive elements must have accessible names */
ARIA_LABEL: "aria-label",
/** Dialog elements must have role="dialog" and aria-modal */
DIALOG_ROLE: "dialog-role",
/** Focus must be trapped within modal dialogs */
FOCUS_TRAP: "focus-trap",
/** Color contrast must meet WCAG AA ratio (4.5:1 for normal text) */
COLOR_CONTRAST: "color-contrast",
/** Form inputs must have associated labels */
LABEL: "label",
/** Images must have alt text */
IMAGE_ALT: "image-alt",
/** Keyboard navigation must work for all interactive elements */
KEYBOARD: "keyboard",
/** Heading levels should not skip */
HEADING_ORDER: "heading-order",
};
/**
* @typedef {Object} A11yViolation
* @property {string} id - Rule ID
* @property {string} description - What the rule checks
* @property {string} impact - "critical" | "serious" | "moderate" | "minor"
* @property {string} help - How to fix
* @property {string[]} nodes - CSS selectors of affected elements
*/
/**
* Audit a component's HTML for accessibility violations.
* This is a lightweight check that works without a full browser.
*
* @param {string} html - HTML string to audit
* @returns {A11yViolation[]} List of violations found
*/
export function auditHTML(html) {
const violations = [];
// Check: Interactive elements without aria-label
const interactiveWithoutLabel = html.match(
/<(button|a|input|select|textarea)(?![^>]*(?:aria-label|aria-labelledby|title))[^>]*>/gi
);
if (interactiveWithoutLabel) {
// Filter out elements that have visible text content or labels
const problematic = interactiveWithoutLabel.filter(
(el) => !el.includes("type=\"hidden\"") && !el.includes("type='hidden'")
);
if (problematic.length > 0) {
violations.push({
id: WCAG_RULES.ARIA_LABEL,
description: "Interactive elements should have accessible names",
impact: "serious",
help: "Add aria-label, aria-labelledby, or title attribute",
nodes: problematic.slice(0, 5).map((el) => el.substring(0, 80)),
});
}
}
// Check: Dialogs without role="dialog"
/** @type {string[]} */
const modals = html.match(/<div[^>]*(?:modal|dialog|overlay)[^>]*>/gi) || [];
const modalsWithoutRole = modals.filter((m) => !m.includes('role="dialog"'));
if (modalsWithoutRole.length > 0) {
violations.push({
id: WCAG_RULES.DIALOG_ROLE,
description: "Modal elements should have role=\"dialog\"",
impact: "serious",
help: "Add role=\"dialog\" and aria-modal=\"true\" to modal containers",
nodes: modalsWithoutRole.map((m) => m.substring(0, 80)),
});
}
// Check: Images without alt text
const imgsWithoutAlt = html.match(/<img(?![^>]*alt=)[^>]*>/gi);
if (imgsWithoutAlt) {
violations.push({
id: WCAG_RULES.IMAGE_ALT,
description: "Images must have alt text",
impact: "critical",
help: "Add alt attribute to all <img> elements",
nodes: imgsWithoutAlt.slice(0, 5).map((el) => el.substring(0, 80)),
});
}
// Check: Form inputs without labels
const inputsWithoutLabel = html.match(
/<input(?![^>]*(?:aria-label|aria-labelledby|id="[^"]*"))[^>]*type="(?:text|email|password|number|search|tel|url)"[^>]*>/gi
);
if (inputsWithoutLabel) {
violations.push({
id: WCAG_RULES.LABEL,
description: "Form inputs should have associated labels",
impact: "serious",
help: "Add aria-label or associate a <label> element",
nodes: inputsWithoutLabel.slice(0, 5).map((el) => el.substring(0, 80)),
});
}
return violations;
}
/**
* Generate an accessibility report summary.
*
* @param {A11yViolation[]} violations
* @returns {{ total: number, critical: number, serious: number, moderate: number, minor: number, passed: boolean }}
*/
export function generateReport(violations) {
return {
total: violations.length,
critical: violations.filter((v) => v.impact === "critical").length,
serious: violations.filter((v) => v.impact === "serious").length,
moderate: violations.filter((v) => v.impact === "moderate").length,
minor: violations.filter((v) => v.impact === "minor").length,
passed: violations.length === 0,
};
}