From 443e5b7b62bab082699bc13146ffbe82db58b87b Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 14 Feb 2026 21:04:51 -0300 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20100%=20backend=20API=20covera?= =?UTF-8?q?ge=20=E2=80=94=207=20batches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/app/(dashboard)/dashboard/combos/page.js | 16 +- .../components/ModelAvailabilityPanel.js | 183 ++++++++++++++ .../(dashboard)/dashboard/providers/page.js | 12 + .../settings/components/ComplianceTab.js | 148 +++++++---- .../components/FallbackChainsEditor.js | 215 ++++++++++++++++ .../settings/components/PoliciesPanel.js | 200 +++++++++++++++ .../settings/components/RoutingTab.js | 26 +- .../settings/components/SecurityTab.js | 2 + .../dashboard/usage/components/BudgetTab.js | 239 ++++++++++++++++++ .../dashboard/usage/components/EvalsTab.js | 204 +++++++++++++++ src/app/(dashboard)/dashboard/usage/page.js | 7 +- src/app/api/token-health/route.js | 36 +++ src/lib/policies/policyEngine.js | 183 -------------- src/shared/components/Breadcrumbs.js | 7 +- src/shared/components/Header.js | 4 + src/shared/components/TokenHealthBadge.js | 109 ++++++++ src/shared/utils/a11yAudit.js | 135 ---------- 17 files changed, 1341 insertions(+), 385 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityPanel.js create mode 100644 src/app/(dashboard)/dashboard/settings/components/FallbackChainsEditor.js create mode 100644 src/app/(dashboard)/dashboard/settings/components/PoliciesPanel.js create mode 100644 src/app/(dashboard)/dashboard/usage/components/BudgetTab.js create mode 100644 src/app/(dashboard)/dashboard/usage/components/EvalsTab.js create mode 100644 src/app/api/token-health/route.js delete mode 100644 src/lib/policies/policyEngine.js create mode 100644 src/shared/components/TokenHealthBadge.js delete mode 100644 src/shared/utils/a11yAudit.js diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index 0846dafbe8..634ef15543 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -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"); } }; diff --git a/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityPanel.js b/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityPanel.js new file mode 100644 index 0000000000..83498e1e2c --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/ModelAvailabilityPanel.js @@ -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 ( + +
+ monitoring + Loading model availability... +
+
+ ); + } + + const models = data?.models || []; + const unavailableCount = + data?.unavailableCount || models.filter((m) => m.status !== "available").length; + + if (models.length === 0 || unavailableCount === 0) { + return ( + +
+
+ verified +
+
+

Model Availability

+

All models operational

+
+
+
+ ); + } + + // 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 ( + +
+
+
+ warning +
+
+

Model Availability

+

+ {unavailableCount} model{unavailableCount !== 1 ? "s" : ""} with issues +

+
+
+ +
+ +
+ {Object.entries(byProvider).map(([provider, provModels]) => ( +
+

{provider}

+
+ {provModels.map((m) => { + const status = STATUS_CONFIG[m.status] || STATUS_CONFIG.unknown; + const isClearing = clearing === `${m.provider}:${m.model}`; + return ( +
+
+ + {status.icon} + + {m.model} + + {status.label} + + {m.cooldownUntil && ( + + until {new Date(m.cooldownUntil).toLocaleTimeString()} + + )} +
+ {m.status === "cooldown" && ( + + )} +
+ ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 174cd13e21..3babdb84ad 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -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() { )} + + {/* Model Availability */} + ); } diff --git a/src/app/(dashboard)/dashboard/settings/components/ComplianceTab.js b/src/app/(dashboard)/dashboard/settings/components/ComplianceTab.js index e47fc5166c..5ef098176b 100644 --- a/src/app/(dashboard)/dashboard/settings/components/ComplianceTab.js +++ b/src/app/(dashboard)/dashboard/settings/components/ComplianceTab.js @@ -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 ( + + {row.timestamp ? new Date(row.timestamp).toLocaleString() : "—"} + + ); + case "action": + return ( + + {row.action || "—"} + + ); + case "actor": + return {row.actor || "system"}; + case "details": + return ( + + {row.details ? JSON.stringify(row.details) : "—"} + + ); + default: + return row[col.key] || "—"; + } + }, []); return ( @@ -33,51 +94,30 @@ export default function ComplianceTab() { policy Audit Log - 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" - /> + - {loading ? ( -

Loading audit log…

- ) : filtered.length === 0 ? ( -

No audit events found.

- ) : ( -
- - - - - - - - - - - {filtered.map((log, i) => ( - - - - - - - ))} - -
TimeActionActorDetails
- {log.timestamp ? new Date(log.timestamp).toLocaleString() : "—"} - - - {log.action || "—"} - - {log.actor || "system"} - {log.details ? JSON.stringify(log.details) : "—"} -
-
- )} + setFilters((prev) => ({ ...prev, [key]: val }))} + /> + +
); } diff --git a/src/app/(dashboard)/dashboard/settings/components/FallbackChainsEditor.js b/src/app/(dashboard)/dashboard/settings/components/FallbackChainsEditor.js new file mode 100644 index 0000000000..b44ffb3612 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/FallbackChainsEditor.js @@ -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 ( + +
+ timeline + Loading fallback chains... +
+
+ ); + } + + const chainEntries = Object.entries(chains); + + return ( + +
+
+ timeline +
+
+

Fallback Chains

+

Define provider fallback order per model

+
+ +
+ + {/* Create Form */} + {showCreate && ( +
+
+ setNewModel(e.target.value)} + /> + setNewProviders(e.target.value)} + /> +
+ +
+ )} + + {/* Chains List */} +
+ {chainEntries.length === 0 ? ( + + ) : ( +
+ {chainEntries.map(([model, chain]) => ( +
+
+ + {model} + + + arrow_forward + +
+ {(Array.isArray(chain) ? chain : []).map((entry, i) => ( + + {i + 1}. {entry.provider} + + ))} +
+
+ +
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/PoliciesPanel.js b/src/app/(dashboard)/dashboard/settings/components/PoliciesPanel.js new file mode 100644 index 0000000000..459ea8544a --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/PoliciesPanel.js @@ -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 ( + +
+ security + Loading policies... +
+
+ ); + } + + const circuitBreakers = data?.circuitBreakers || []; + const lockedIds = data?.lockedIdentifiers || []; + const hasIssues = circuitBreakers.some((cb) => cb.state !== "closed") || lockedIds.length > 0; + + if (!hasIssues) { + return ( + +
+
+ verified_user +
+
+

Policies & Circuit Breakers

+

+ All systems operational — no lockouts or tripped breakers +

+
+
+
+ ); + } + + return ( + +
+
+
+ gpp_maybe +
+
+

Policies & Circuit Breakers

+

Active issues detected

+
+
+ +
+ + {/* Circuit Breakers */} + {circuitBreakers.filter((cb) => cb.state !== "closed").length > 0 && ( +
+

Circuit Breakers

+
+ {circuitBreakers + .filter((cb) => cb.state !== "closed") + .map((cb, i) => { + const status = CB_STATUS[cb.state] || CB_STATUS.open; + return ( +
+
+ + {status.icon} + + + {cb.name || cb.provider || "Unknown"} + + + {status.label} + + {cb.failures > 0 && ( + {cb.failures} failures + )} +
+
+ ); + })} +
+
+ )} + + {/* Locked Identifiers */} + {lockedIds.length > 0 && ( +
+

Locked Identifiers

+
+ {lockedIds.map((id, i) => { + const identifier = typeof id === "string" ? id : id.identifier || id.id; + return ( +
+
+ lock + {identifier} + {typeof id === "object" && id.lockedAt && ( + + since {new Date(id.lockedAt).toLocaleString()} + + )} +
+ +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.js b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.js index dfc74a450c..c6dd851ea2 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.js +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.js @@ -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}
-

+

{s.label}

{s.desc}

@@ -120,7 +128,8 @@ export default function RoutingTab() {

{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."}

@@ -136,7 +145,9 @@ export default function RoutingTab() {

Model Aliases

-

Wildcard patterns to remap model names • Use * and ?

+

+ Wildcard patterns to remap model names • Use * and ? +

@@ -149,7 +160,9 @@ export default function RoutingTab() { >
{a.pattern} - arrow_forward + + arrow_forward + {a.target}
+ + + + {/* Budget Check Status */} + {budget?.budgetCheck && ( + +
+ + {budget.budgetCheck.allowed ? "check_circle" : "block"} + + + {budget.budgetCheck.allowed + ? `Budget OK — $${(budget.budgetCheck.remaining || 0).toFixed(2)} remaining` + : "Budget exceeded — requests may be blocked"} + +
+
+ )} + + ); +} diff --git a/src/app/(dashboard)/dashboard/usage/components/EvalsTab.js b/src/app/(dashboard)/dashboard/usage/components/EvalsTab.js new file mode 100644 index 0000000000..bbc726e450 --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/EvalsTab.js @@ -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 ( +
+ science + Loading eval suites... +
+ ); + } + + if (suites.length === 0) { + return ( + + ); + } + + const RESULT_COLUMNS = [ + { key: "caseId", label: "Case" }, + { key: "status", label: "Status" }, + { key: "expected", label: "Expected" }, + { key: "actual", label: "Actual" }, + ]; + + return ( +
+ +
+
+ science +
+

Evaluation Suites

+
+ + {}} + /> + +
+ {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 ( +
+
setExpanded(isExpanded ? null : suite.id)} + > +
+ + {isExpanded ? "expand_more" : "chevron_right"} + +
+

{suite.name || suite.id}

+

+ {caseCount} case{caseCount !== 1 ? "s" : ""} + {suiteResult && ( + + • Last run: {suiteResult.passed || 0} ✅ {suiteResult.failed || 0} ❌ + + )} +

+
+
+ +
+ + {isExpanded && suiteResult?.results && ( +
+ ({ + ...r, + id: r.caseId || i, + }))} + renderCell={(row, col) => { + if (col.key === "status") { + return row.passed ? ( + ✅ Passed + ) : ( + ❌ Failed + ); + } + return ( + + {typeof row[col.key] === "object" + ? JSON.stringify(row[col.key]) + : row[col.key] || "—"} + + ); + }} + maxHeight="300px" + emptyMessage="No results yet" + /> +
+ )} +
+ ); + })} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/usage/page.js b/src/app/(dashboard)/dashboard/usage/page.js index d693f306da..ab5796c98e 100644 --- a/src/app/(dashboard)/dashboard/usage/page.js +++ b/src/app/(dashboard)/dashboard/usage/page.js @@ -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() { )} {activeTab === "sessions" && } + {activeTab === "budget" && } + {activeTab === "evals" && } ); } diff --git a/src/app/api/token-health/route.js b/src/app/api/token-health/route.js new file mode 100644 index 0000000000..f21abcc5e7 --- /dev/null +++ b/src/app/api/token-health/route.js @@ -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 }); + } +} diff --git a/src/lib/policies/policyEngine.js b/src/lib/policies/policyEngine.js deleted file mode 100644 index 128d7fa4e1..0000000000 --- a/src/lib/policies/policyEngine.js +++ /dev/null @@ -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; -} diff --git a/src/shared/components/Breadcrumbs.js b/src/shared/components/Breadcrumbs.js index 3b1a461d86..e5e7b6b6aa 100644 --- a/src/shared/components/Breadcrumbs.js +++ b/src/shared/components/Breadcrumbs.js @@ -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: - * + * */ +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); diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index 29234feb5c..ff1e30d342 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -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 */} + {/* Token health */} + + {/* Logout button */} + + {showTooltip && ( +
+

Token Health

+
+
+ Total OAuth + {health.total} +
+
+ Healthy + {health.healthy} +
+ {health.errored > 0 && ( +
+ Errored + {health.errored} +
+ )} + {health.warning > 0 && ( +
+ Warning + {health.warning} +
+ )} + {health.lastCheckAt && ( +
+ Last check + + {new Date(health.lastCheckAt).toLocaleTimeString()} + +
+ )} +
+
+ )} + + ); +} diff --git a/src/shared/utils/a11yAudit.js b/src/shared/utils/a11yAudit.js deleted file mode 100644 index 076fecce48..0000000000 --- a/src/shared/utils/a11yAudit.js +++ /dev/null @@ -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(/]*(?: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(/]*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 elements", - nodes: imgsWithoutAlt.slice(0, 5).map((el) => el.substring(0, 80)), - }); - } - - // Check: Form inputs without labels - const inputsWithoutLabel = html.match( - /]*(?: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