diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e637d8451d..11d7f50a89 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.3.3 + version: 3.3.4 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/open-sse/services/__tests__/volumeDetector.test.ts b/open-sse/services/__tests__/volumeDetector.test.ts index e01607399a..e29684f912 100644 --- a/open-sse/services/__tests__/volumeDetector.test.ts +++ b/open-sse/services/__tests__/volumeDetector.test.ts @@ -2,9 +2,9 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { detectVolumeSignals, recommendStrategyOverride } from "../volumeDetector"; -describe("volumeDetector", () => { - describe("detectVolumeSignals", () => { - it("detects simple single-message request", () => { +describe("volumeDetector", async () => { + describe("detectVolumeSignals", async () => { + it("detects simple single-message request", async () => { const body = { messages: [{ role: "user", content: "Hello" }], }; @@ -16,7 +16,7 @@ describe("volumeDetector", () => { assert.equal(signals.complexity, "trivial"); }); - it("detects tool-heavy request as high complexity", () => { + it("detects tool-heavy request as high complexity", async () => { const body = { messages: [{ role: "user", content: "Deploy the app to production" }], tools: [ @@ -31,17 +31,15 @@ describe("volumeDetector", () => { assert.equal(signals.complexity, "critical"); }); - it("detects browser keywords", () => { + it("detects browser keywords", async () => { const body = { - messages: [ - { role: "user", content: "Navigate to the page and take a screenshot" }, - ], + messages: [{ role: "user", content: "Navigate to the page and take a screenshot" }], }; const signals = detectVolumeSignals(body); assert.equal(signals.hasBrowser, true); }); - it("detects batch from multi-part content", () => { + it("detects batch from multi-part content", async () => { const parts = Array.from({ length: 20 }, (_, i) => ({ type: "text", text: `Item ${i}`, @@ -53,11 +51,9 @@ describe("volumeDetector", () => { assert.equal(signals.batchSize, 20); }); - it("detects security keywords as high complexity", () => { + it("detects security keywords as high complexity", async () => { const body = { - messages: [ - { role: "user", content: "Refactor the authentication module for production" }, - ], + messages: [{ role: "user", content: "Refactor the authentication module for production" }], }; const signals = detectVolumeSignals(body); assert.ok( @@ -67,16 +63,16 @@ describe("volumeDetector", () => { }); }); - describe("recommendStrategyOverride", () => { - it("recommends round-robin for large batches", () => { + describe("recommendStrategyOverride", async () => { + it("recommends round-robin for large batches", async () => { const signals = detectVolumeSignals({ input: Array(60).fill("item") }); - const override = recommendStrategyOverride(signals, "priority"); + const override = await recommendStrategyOverride(signals, "priority"); assert.equal(override.shouldOverride, true); assert.equal(override.strategy, "round-robin"); assert.equal(override.preferEconomy, true); }); - it("recommends premium-first for browser tasks", () => { + it("recommends premium-first for browser tasks", async () => { const signals = { batchSize: 1, estimatedTokens: 500, @@ -85,13 +81,13 @@ describe("volumeDetector", () => { hasImages: false, complexity: "high" as const, }; - const override = recommendStrategyOverride(signals, "round-robin"); + const override = await recommendStrategyOverride(signals, "round-robin"); assert.equal(override.shouldOverride, true); assert.equal(override.strategy, "priority"); assert.equal(override.forcePremium, true); }); - it("flags economy for tiny requests without changing strategy", () => { + it("flags economy for tiny requests without changing strategy", async () => { const signals = { batchSize: 1, estimatedTokens: 100, @@ -100,12 +96,12 @@ describe("volumeDetector", () => { hasImages: false, complexity: "trivial" as const, }; - const override = recommendStrategyOverride(signals, "priority"); + const override = await recommendStrategyOverride(signals, "priority"); assert.equal(override.shouldOverride, false); assert.equal(override.preferEconomy, true); }); - it("no override for normal medium requests", () => { + it("no override for normal medium requests", async () => { const signals = { batchSize: 1, estimatedTokens: 1000, @@ -114,7 +110,7 @@ describe("volumeDetector", () => { hasImages: false, complexity: "low" as const, }; - const override = recommendStrategyOverride(signals, "priority"); + const override = await recommendStrategyOverride(signals, "priority"); assert.equal(override.shouldOverride, false); assert.equal(override.preferEconomy, false); }); diff --git a/open-sse/services/volumeDetector.ts b/open-sse/services/volumeDetector.ts index 1bdd806783..56a1e78450 100644 --- a/open-sse/services/volumeDetector.ts +++ b/open-sse/services/volumeDetector.ts @@ -141,10 +141,10 @@ export function detectVolumeSignals(body: Record): VolumeSignal * @param currentStrategy - The combo's configured strategy * @returns Override recommendation */ -export function recommendStrategyOverride( +export async function recommendStrategyOverride( signals: VolumeSignals, currentStrategy: string -): StrategyOverride { +): Promise { const noOverride: StrategyOverride = { shouldOverride: false, strategy: null, @@ -153,6 +153,18 @@ export function recommendStrategyOverride( reason: "no override needed", }; + // Check if adaptive routing is enabled globally + try { + const { getSettings } = await import("@/lib/localDb"); + const settings = await getSettings(); + if (!settings.adaptiveVolumeRouting) { + return noOverride; + } + } catch (error) { + console.error("Failed to check adaptiveVolumeRouting setting:", error); + return noOverride; + } + // Rule 1: Large batch → round-robin to distribute load if (signals.batchSize >= 50) { return { diff --git a/src/app/(dashboard)/dashboard/a2a/page.tsx b/src/app/(dashboard)/dashboard/a2a/page.tsx index d0853252a9..521bb922ae 100644 --- a/src/app/(dashboard)/dashboard/a2a/page.tsx +++ b/src/app/(dashboard)/dashboard/a2a/page.tsx @@ -433,43 +433,78 @@ export default function A2ADashboardPage() { {t("tableTask")} {t("tableSkill")} {t("tableState")} + {t("tablePhase") || "FSM Status"} {t("tableUpdated")} {t("tableActions")} - {tasksData.tasks.map((task) => ( - - {task.id} - {task.skill} - - - {t(`state.${task.state}`)} - - - - {new Date(task.updatedAt).toLocaleString()} - - - - - - - ))} + {tasksData.tasks.map((task) => { + const fsmPhase = + task.metadata?.fsmPhase || (task.metadata?.workflowFSM as any)?.currentPhase; + let fsmBadgeColor = "bg-gray-500/15 text-gray-500"; + if (fsmPhase === "plan" || fsmPhase === "plan_review") + fsmBadgeColor = "bg-purple-500/15 text-purple-500"; + else if (fsmPhase === "execute") fsmBadgeColor = "bg-blue-500/15 text-blue-500"; + else if ( + ["code_review", "quality_review", "security", "test", "output_review"].includes( + fsmPhase + ) + ) + fsmBadgeColor = "bg-amber-500/15 text-amber-500"; + else if (fsmPhase === "done") fsmBadgeColor = "bg-green-500/15 text-green-500"; + else if (fsmPhase === "failed") fsmBadgeColor = "bg-red-500/15 text-red-500"; + + return ( + + {task.id} + {task.skill} + + + {t(`state.${task.state}`)} + + + + {fsmPhase ? ( + + {fsmPhase} + + ) : ( + + )} + + + {new Date(task.updatedAt).toLocaleString()} + + + + + + + ); + })} diff --git a/src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx b/src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx new file mode 100644 index 0000000000..728897e1a7 --- /dev/null +++ b/src/app/(dashboard)/dashboard/audit/ConfigAuditViewer.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useTranslations } from "next-intl"; + +interface ConfigDiff { + added: string[]; + removed: string[]; + changed: Array<{ key: string; from: any; to: any }>; + isEmpty: boolean; +} + +interface AuditEntry { + id: string; + timestamp: string; + action: string; + target: string; + targetId: string; + targetName: string; + source: string; + before: any; + after: any; + diff: ConfigDiff; + note: string | null; +} + +export default function ConfigAuditViewer() { + const t = useTranslations("logs"); + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedEntry, setSelectedEntry] = useState(null); + + useEffect(() => { + fetchLogs(); + }, []); + + const fetchLogs = async () => { + setLoading(true); + try { + const res = await fetch("/api/audit"); + const data = await res.json(); + setEntries(data.entries || []); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + const getActionColor = (action: string) => { + switch (action) { + case "create": + return "text-green-400 bg-green-400/10 border-green-500/20"; + case "update": + return "text-blue-400 bg-blue-400/10 border-blue-500/20"; + case "delete": + return "text-red-400 bg-red-400/10 border-red-500/20"; + default: + return "text-gray-400 bg-gray-400/10 border-gray-500/20"; + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (entries.length === 0) { + return ( +
+ + + +

No Configuration Audit Logs found.

+
+ ); + } + + return ( +
+
+ + + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + + ))} + +
TimestampActionTargetResourceSourceDetails
+ {new Date(entry.timestamp).toLocaleString()} + + + {entry.action} + + + {entry.target} + + {entry.targetName} + + {entry.source} + + +
+
+ + {selectedEntry && ( +
+
+ {/* Modal Header */} +
+
+

+ {selectedEntry.action} {selectedEntry.target} +

+

+ ID: {selectedEntry.targetId} • {selectedEntry.targetName} +

+
+ +
+ + {/* Modal Content */} +
+ {selectedEntry.note && ( +
+ 📝 {selectedEntry.note} +
+ )} + + {selectedEntry.diff?.isEmpty ? ( +
+ No changes detected in Diff +
+ ) : ( +
+ {/* Added Keys */} + {selectedEntry.diff?.added?.length > 0 && ( +
+

+ ++ Added Properties +

+
+                        {JSON.stringify(
+                          selectedEntry.diff.added.reduce(
+                            (acc, key) => ({ ...acc, [key]: selectedEntry.after?.[key] }),
+                            {}
+                          ),
+                          null,
+                          2
+                        )}
+                      
+
+ )} + + {/* Removed Keys */} + {selectedEntry.diff?.removed?.length > 0 && ( +
+

+ -- Removed Properties +

+
+                        {JSON.stringify(
+                          selectedEntry.diff.removed.reduce(
+                            (acc, key) => ({ ...acc, [key]: selectedEntry.before?.[key] }),
+                            {}
+                          ),
+                          null,
+                          2
+                        )}
+                      
+
+ )} + + {/* Changed Keys */} + {selectedEntry.diff?.changed?.length > 0 && ( +
+

+ ~ Modified Properties +

+
+ {selectedEntry.diff.changed.map((change, idx) => ( +
+
+ {change.key} +
+
+
+
+ Before +
+
+                                  {JSON.stringify(change.from, null, 2)}
+                                
+
+
+
+ After +
+
+                                  {JSON.stringify(change.to, null, 2)}
+                                
+
+
+
+ ))} +
+
+ )} +
+ )} +
+
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/audit/page.tsx b/src/app/(dashboard)/dashboard/audit/page.tsx new file mode 100644 index 0000000000..1c52420069 --- /dev/null +++ b/src/app/(dashboard)/dashboard/audit/page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useState, useEffect } from "react"; +import ConfigAuditViewer from "./ConfigAuditViewer"; + +export default function ConfigAuditPage() { + const [summary, setSummary] = useState(null); + + useEffect(() => { + fetch("/api/audit?summary=true") + .then((res) => res.json()) + .then((data) => setSummary(data)) + .catch((err) => console.error(err)); + }, []); + + return ( +
+
+
+

+ Configuration Audit +

+

+ Track and diff changes made to routing policies, combos, and connections. +

+
+ {summary && ( +
+
+ Total Audits + + {summary.totalEntries} + +
+
+ )} +
+ +
+ +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/health/page.tsx b/src/app/(dashboard)/dashboard/health/page.tsx index bf25185f62..804d0c9214 100644 --- a/src/app/(dashboard)/dashboard/health/page.tsx +++ b/src/app/(dashboard)/dashboard/health/page.tsx @@ -48,6 +48,7 @@ export default function HealthPage() { const [telemetry, setTelemetry] = useState(null); const [cache, setCache] = useState(null); const [signatureCache, setSignatureCache] = useState(null); + const [degradation, setDegradation] = useState(null); const [resetting, setResetting] = useState(false); const fetchHealth = useCallback(async () => { @@ -69,12 +70,14 @@ export default function HealthPage() { fetch("/api/telemetry/summary").then((r) => r.json()), fetch("/api/cache/stats").then((r) => r.json()), fetch("/api/rate-limits").then((r) => r.json()), + fetch("/api/health/degradation").then((r) => r.json()), ]); if (results[0].status === "fulfilled") setTelemetry(results[0].value); if (results[1].status === "fulfilled") setCache(results[1].value); if (results[2].status === "fulfilled" && results[2].value.cacheStats) { setSignatureCache(results[2].value.cacheStats); } + if (results[3].status === "fulfilled") setDegradation(results[3].value); }, []); useEffect(() => { @@ -270,6 +273,80 @@ export default function HealthPage() { + {/* Graceful Degradation Status */} + {degradation && degradation.features && degradation.features.length > 0 && ( + +
+

+ healing + Graceful Degradation Status +

+
+ + Full: {degradation.summary.full} + + + Reduced: {degradation.summary.reduced} + + + Minimal: {degradation.summary.minimal} + + + Default: {degradation.summary.default} + +
+
+
+ {degradation.features.map((feat: any) => { + const bg = + feat.level === "full" + ? "bg-green-500/5 border-green-500/10" + : feat.level === "reduced" + ? "bg-amber-500/5 border-amber-500/20" + : feat.level === "minimal" + ? "bg-orange-500/5 border-orange-500/20" + : "bg-red-500/5 border-red-500/20"; + const dot = + feat.level === "full" + ? "bg-green-500" + : feat.level === "reduced" + ? "bg-amber-500" + : feat.level === "minimal" + ? "bg-orange-500" + : "bg-red-500"; + return ( +
+
+ + + {feat.feature} + + + {feat.level} + +
+
{feat.capability}
+ {feat.reason && ( +
+ {feat.reason.length > 80 ? feat.reason.substring(0, 80) + "..." : feat.reason} +
+ )} +
+ Since {new Date(feat.since).toLocaleTimeString()} +
+
+ ); + })} +
+
+ )} + {/* Telemetry Cards — Latency & Prompt Cache */}
{/* Latency Card */} diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index ee53158198..7ea88f92ef 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -95,6 +95,7 @@ function getConnectionErrorTag(connection) { export default function ProvidersPage() { const [connections, setConnections] = useState([]); const [providerNodes, setProviderNodes] = useState([]); + const [expirations, setExpirations] = useState(null); const [loading, setLoading] = useState(true); const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false); const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false); @@ -108,14 +109,17 @@ export default function ProvidersPage() { useEffect(() => { const fetchData = async () => { try { - const [connectionsRes, nodesRes] = await Promise.all([ + const [connectionsRes, nodesRes, expirationsRes] = await Promise.all([ fetch("/api/providers"), fetch("/api/provider-nodes"), + fetch("/api/providers/expiration"), ]); const connectionsData = await connectionsRes.json(); const nodesData = await nodesRes.json(); + const expirationsData = await expirationsRes.json(); if (connectionsRes.ok) setConnections(connectionsData.connections || []); if (nodesRes.ok) setProviderNodes(nodesData.nodes || []); + if (expirationsRes.ok && expirationsData) setExpirations(expirationsData); } catch (error) { console.log("Error fetching data:", error); } finally { @@ -188,7 +192,16 @@ export default function ProvidersPage() { const errorCode = latestError ? getConnectionErrorTag(latestError) : null; const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null; - return { connected, error, total, errorCode, errorTime, allDisabled }; + // Check expirations + const providerExpirations = + expirations?.list?.filter((e: any) => e.provider === providerId) || []; + const hasExpired = providerExpirations.some((e: any) => e.status === "expired"); + const hasExpiringSoon = providerExpirations.some((e: any) => e.status === "expiring_soon"); + let expiryStatus = null; + if (hasExpired) expiryStatus = "expired"; + else if (hasExpiringSoon) expiryStatus = "expiring_soon"; + + return { connected, error, total, errorCode, errorTime, allDisabled, expiryStatus }; }; // Toggle all connections for a provider on/off @@ -289,6 +302,40 @@ export default function ProvidersPage() { return (
+ {/* Expiration Banner */} + {expirations?.summary && + (expirations.summary.expired > 0 || expirations.summary.expiringSoon > 0) && ( +
0 + ? "bg-red-500/10 border-red-500/20" + : "bg-amber-500/10 border-amber-500/20" + }`} + > + 0 ? "text-red-500" : "text-amber-500" + }`} + > + {expirations.summary.expired > 0 ? "error" : "warning"} + +
+

0 ? "text-red-500" : "text-amber-500"}`} + > + {expirations.summary.expired > 0 + ? `${expirations.summary.expired} Provider connection(s) expired` + : `${expirations.summary.expiringSoon} Provider connection(s) expiring soon`} +

+

+ {expirations.summary.expired > 0 + ? "Immediate action required. Expired connections will permanently fail." + : "Please review and renew expiring connections to avoid disruption."} +

+
+
+ )} + {/* OAuth Providers */}
@@ -582,6 +629,16 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) { ) : ( <> {getStatusDisplay(connected, error, errorCode, t)} + {stats.expiryStatus === "expired" && ( + + Expired + + )} + {stats.expiryStatus === "expiring_soon" && ( + + Expiring Soon + + )} {errorTime && • {errorTime}} )} @@ -709,6 +766,16 @@ function ApiKeyProviderCard({ providerId, provider, stats, authType, onToggle }) ) : ( <> {getStatusDisplay(connected, error, errorCode, t)} + {stats.expiryStatus === "expired" && ( + + Expired + + )} + {stats.expiryStatus === "expiring_soon" && ( + + Expiring Soon + + )} {isCompatible && ( {provider.apiType === "responses" ? t("responses") : t("chat")} diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index 697a69eb06..774552da93 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -152,6 +152,40 @@ export default function RoutingTab() {

+ {/* Adaptive Volume Routing */} + +
+
+
+ +
+
+

+ {t("adaptiveVolumeRouting") || "Adaptive Volume Routing"} +

+

+ {t("adaptiveVolumeRoutingDesc") || + "Automatically adjusts traffic volume between providers based on real-time latency and error rates."} +

+
+
+
+ +
+
+
+ {/* Wildcard Aliases */}
diff --git a/src/app/api/audit/route.ts b/src/app/api/audit/route.ts new file mode 100644 index 0000000000..5819553ffe --- /dev/null +++ b/src/app/api/audit/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; +import { + getAuditLog, + getAuditSummary, + AuditTarget, + AuditAction, + AuditSource, +} from "@/domain/configAudit"; + +export async function GET(req: Request) { + try { + const url = new URL(req.url); + const summary = url.searchParams.get("summary"); + + if (summary === "true") { + return NextResponse.json(getAuditSummary()); + } + + const limit = parseInt(url.searchParams.get("limit") || "50", 10); + const offset = parseInt(url.searchParams.get("offset") || "0", 10); + const target = url.searchParams.get("target") as AuditTarget | null; + const action = url.searchParams.get("action") as AuditAction | null; + const source = url.searchParams.get("source") as AuditSource | null; + const since = url.searchParams.get("since"); + + const options: any = { limit, offset }; + if (target) options.target = target; + if (action) options.action = action; + if (source) options.source = source; + if (since) options.since = since; + + const result = getAuditLog(options); + return NextResponse.json(result); + } catch (error) { + console.error("[API ERROR] /api/audit GET:", error); + return NextResponse.json({ error: "Failed to fetch audit log." }, { status: 500 }); + } +} diff --git a/src/app/api/health/degradation/route.ts b/src/app/api/health/degradation/route.ts new file mode 100644 index 0000000000..1a9bf0d0c7 --- /dev/null +++ b/src/app/api/health/degradation/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { + getDegradationReport, + getDegradationSummary, + hasAnyDegradation, +} from "@/domain/degradation"; + +export async function GET(req: Request) { + try { + const url = new URL(req.url); + const summaryStr = url.searchParams.get("summary"); + + if (summaryStr === "true") { + return NextResponse.json({ + summary: getDegradationSummary(), + isDegraded: hasAnyDegradation(), + }); + } + + const report = getDegradationReport(); + return NextResponse.json({ + active: hasAnyDegradation(), + summary: getDegradationSummary(), + features: report, + }); + } catch (error) { + console.error("[API ERROR] /api/health/degradation GET:", error); + return NextResponse.json({ error: "Failed to fetch degradation report." }, { status: 500 }); + } +} diff --git a/src/app/api/providers/expiration/route.ts b/src/app/api/providers/expiration/route.ts new file mode 100644 index 0000000000..c9d4f24f5d --- /dev/null +++ b/src/app/api/providers/expiration/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from "next/server"; +import { getAllExpirations, getExpirationSummary } from "@/domain/providerExpiration"; + +export async function GET() { + try { + const list = getAllExpirations(); + const summary = getExpirationSummary(); + + return NextResponse.json({ + summary, + list, + }); + } catch (error) { + console.error("[API ERROR] /api/providers/expiration GET:", error); + return NextResponse.json({ error: "Failed to fetch expiration metadata." }, { status: 500 }); + } +} diff --git a/src/shared/components/DegradationBadge.tsx b/src/shared/components/DegradationBadge.tsx new file mode 100644 index 0000000000..031cc92e1c --- /dev/null +++ b/src/shared/components/DegradationBadge.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; + +export default function DegradationBadge() { + const [isDegraded, setDegraded] = useState(false); + const t = useTranslations("common"); // Or a specific namespace if needed + + useEffect(() => { + const checkDegradation = async () => { + try { + const res = await fetch("/api/health/degradation?summary=true"); + if (res.ok) { + const data = await res.json(); + setDegraded(data.isDegraded); + } + } catch (err) { + // Ignore error + } + }; + + checkDegradation(); + const interval = setInterval(checkDegradation, 60000); + return () => clearInterval(interval); + }, []); + + if (!isDegraded) return null; + + return ( + + healing + Degraded + + ); +} diff --git a/src/shared/components/Header.tsx b/src/shared/components/Header.tsx index aaf2223148..9ffa39bbd6 100644 --- a/src/shared/components/Header.tsx +++ b/src/shared/components/Header.tsx @@ -6,6 +6,7 @@ import Image from "next/image"; import PropTypes from "prop-types"; import ThemeToggle from "./ThemeToggle"; import TokenHealthBadge from "./TokenHealthBadge"; +import DegradationBadge from "./DegradationBadge"; import LanguageSelector from "./LanguageSelector"; import ProviderIcon from "./ProviderIcon"; import { useTranslations } from "next-intl"; @@ -199,7 +200,8 @@ export default function Header({ onMenuClick, showMenuButton = true }) { {/* Theme toggle */} - {/* Token health */} + {/* Degradation & Token health */} + {/* Logout button */} diff --git a/src/shared/components/UsageAnalytics.tsx b/src/shared/components/UsageAnalytics.tsx index 34d55b825f..96f0103745 100644 --- a/src/shared/components/UsageAnalytics.tsx +++ b/src/shared/components/UsageAnalytics.tsx @@ -78,6 +78,26 @@ export default function UsageAnalytics() { return (analytics?.byProvider || []).length; }, [analytics]); + const providerDiversity = useMemo(() => { + const providers = analytics?.byProvider || []; + if (providers.length <= 1) return 0; + + let totalCalls = 0; + for (const p of providers) { + totalCalls += p.totalRequests || p.apiCalls || 0; + } + if (totalCalls === 0) return 0; + + let h = 0; + for (const p of providers) { + const p_i = (p.totalRequests || p.apiCalls || 0) / totalCalls; + if (p_i > 0) h -= p_i * Math.log2(p_i); + } + + const maxH = Math.log2(providers.length); + return maxH > 0 ? (h / maxH) * 100 : 0; + }, [analytics]); + if (loading && !analytics) return ; if (error) return Error: {error}; @@ -176,9 +196,9 @@ export default function UsageAnalytics() {
diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index fa73e228d7..b3dd15904f 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -16,6 +16,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "search-tools", "health", "logs", + "audit", "settings", "docs", "issues", @@ -74,6 +75,7 @@ const DEBUG_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ const SYSTEM_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [ { id: "health", href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" }, { id: "logs", href: "/dashboard/logs", i18nKey: "logs", icon: "description" }, + { id: "audit", href: "/dashboard/audit", i18nKey: "auditLog", icon: "history" }, { id: "settings", href: "/dashboard/settings", i18nKey: "settings", icon: "settings" }, ]; diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 4517b33d5a..9d56f2b394 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -50,6 +50,8 @@ export const updateSettingsSchema = z.object({ stripModelPrefix: z.boolean().optional(), // Cache control preservation mode alwaysPreserveClientCache: z.enum(["auto", "always", "never"]).optional(), + // Adaptive Volume Routing + adaptiveVolumeRouting: z.boolean().optional(), // Custom CLI agent definitions for ACP customAgents: z .array(