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
+
+
+
+
+ refresh
+
+
+
+
+ {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" && (
+
handleClearCooldown(m.provider, m.model)}
+ disabled={isClearing}
+ className="text-xs"
+ >
+ {isClearing ? "Clearing..." : "Clear"}
+
+ )}
+
+ );
+ })}
+
+
+ ))}
+
+
+ );
+}
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.
- ) : (
-
-
-
-
- Time
- Action
- Actor
- Details
-
-
-
- {filtered.map((log, i) => (
-
-
- {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
+
+
setShowCreate(!showCreate)}>
+ {showCreate ? "Cancel" : "+ Add Chain"}
+
+
+
+ {/* Create Form */}
+ {showCreate && (
+
+ )}
+
+ {/* Chains List */}
+
+ {chainEntries.length === 0 ? (
+
+ ) : (
+
+ {chainEntries.map(([model, chain]) => (
+
+
+
+ {model}
+
+
+ arrow_forward
+
+
+ {(Array.isArray(chain) ? chain : []).map((entry, i) => (
+
+ {i + 1}. {entry.provider}
+
+ ))}
+
+
+
handleDelete(model)}
+ className="text-text-muted hover:text-red-400 transition-colors ml-2"
+ title="Delete chain"
+ >
+ close
+
+
+ ))}
+
+ )}
+
+
+ );
+}
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
+
+
+
+ refresh
+
+
+
+ {/* 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()}
+
+ )}
+
+
handleUnlock(identifier)}
+ disabled={unlocking === identifier}
+ className="text-xs"
+ >
+ {unlocking === identifier ? "Unlocking..." : "Force Unlock"}
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
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}
+
+ {/* Fallback Chains */}
+
);
}
diff --git a/src/app/(dashboard)/dashboard/settings/components/SecurityTab.js b/src/app/(dashboard)/dashboard/settings/components/SecurityTab.js
index 112fb57ff6..8929568e04 100644
--- a/src/app/(dashboard)/dashboard/settings/components/SecurityTab.js
+++ b/src/app/(dashboard)/dashboard/settings/components/SecurityTab.js
@@ -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() {
+
);
}
diff --git a/src/app/(dashboard)/dashboard/usage/components/BudgetTab.js b/src/app/(dashboard)/dashboard/usage/components/BudgetTab.js
new file mode 100644
index 0000000000..7dee9340fd
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/usage/components/BudgetTab.js
@@ -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 (
+
+
+ ${value.toFixed(2)}
+ ${max.toFixed(2)}
+
+
+
+ );
+}
+
+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 (
+
+ account_balance_wallet
+ Loading budget data...
+
+ );
+ }
+
+ if (keys.length === 0) {
+ return (
+
+ );
+ }
+
+ 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 (
+
+ {/* Key Selector */}
+
+
+
+ account_balance_wallet
+
+
Budget Management
+
+
+
+ API Key
+ 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) => (
+
+ {k.name || k.id} {k.provider ? `(${k.provider})` : ""}
+
+ ))}
+
+
+
+ {/* Current Spend */}
+
+
+
Today's Spend
+
${dailyCost.toFixed(2)}
+ {dailyLimit > 0 && (
+
+ )}
+
+
+
This Month
+
${monthlyCost.toFixed(2)}
+ {monthlyLimit > 0 && (
+
+ )}
+
+
+
+ {/* Budget Form */}
+
+
+
+ {/* 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} ❌
+
+ )}
+
+
+
+
{
+ e.stopPropagation();
+ handleRunEval(suite);
+ }}
+ loading={isRunning}
+ disabled={isRunning}
+ >
+ {isRunning ? "Running..." : "Run Eval"}
+
+
+
+ {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 */}
{
+ 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 (
+ setShowTooltip(true)}
+ onMouseLeave={() => setShowTooltip(false)}
+ >
+
+
+ {status.icon}
+
+ {health.errored > 0 && (
+
+ {health.errored}
+
+ )}
+
+
+ {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
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,
- };
-}