mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
Merge pull request #32 from diegosouzapw/feature/frontend-100-coverage
feat(frontend): 100% backend API coverage — 7 batches
This commit is contained in:
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
239
src/app/(dashboard)/dashboard/usage/components/BudgetTab.js
Normal file
239
src/app/(dashboard)/dashboard/usage/components/BudgetTab.js
Normal 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'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>
|
||||
);
|
||||
}
|
||||
204
src/app/(dashboard)/dashboard/usage/components/EvalsTab.js
Normal file
204
src/app/(dashboard)/dashboard/usage/components/EvalsTab.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
36
src/app/api/token-health/route.js
Normal file
36
src/app/api/token-health/route.js
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
109
src/shared/components/TokenHealthBadge.js
Normal file
109
src/shared/components/TokenHealthBadge.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user