mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
feat(i18n): comprehensive pt-BR localization and UI refactoring
This commit is contained in:
@@ -20,6 +20,8 @@ function ServiceToggle({
|
||||
onToggle: () => void;
|
||||
toggling: boolean;
|
||||
}) {
|
||||
const t = useTranslations("a2aDashboard");
|
||||
const tCommon = useTranslations("common");
|
||||
const online = enabled && status.online;
|
||||
const loading = enabled && status.loading;
|
||||
|
||||
@@ -52,7 +54,7 @@ function ServiceToggle({
|
||||
animation: online ? "pulse 2s infinite" : "none",
|
||||
}}
|
||||
/>
|
||||
{loading ? "..." : online ? "Online" : "Offline"}
|
||||
{loading ? "..." : online ? t("online") : t("offline")}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -65,7 +67,7 @@ function ServiceToggle({
|
||||
opacity: toggling ? 0.6 : 1,
|
||||
cursor: toggling ? "wait" : "pointer",
|
||||
}}
|
||||
title={enabled ? `Disable ${label}` : `Enable ${label}`}
|
||||
title={enabled ? t("disableLabel", { label }) : t("enableLabel", { label })}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-5 h-5 rounded-full shadow-md transition-all duration-300"
|
||||
@@ -80,13 +82,14 @@ function ServiceToggle({
|
||||
className="text-xs font-medium min-w-[24px]"
|
||||
style={{ color: enabled ? "rgb(34,197,94)" : "var(--color-text-muted)" }}
|
||||
>
|
||||
{toggling ? "..." : enabled ? "ON" : "OFF"}
|
||||
{toggling ? "..." : enabled ? tCommon("on") : tCommon("off")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisabledPanel() {
|
||||
const t = useTranslations("a2aDashboard");
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -107,10 +110,10 @@ function DisabledPanel() {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold" style={{ color: "var(--color-text)" }}>
|
||||
A2A is disabled
|
||||
{t("a2aDisabledTitle")}
|
||||
</h2>
|
||||
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
Enable A2A above to view task telemetry, agent details, and validation tools.
|
||||
{t("a2aDisabledDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,24 +186,29 @@ export default function A2APage() {
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
Agent2Agent JSON-RPC 2.0 endpoint — send tasks, stream responses, cancel in-flight
|
||||
jobs.
|
||||
{t("a2aIntro")}
|
||||
</p>
|
||||
<ol
|
||||
className="mt-2 text-sm space-y-0.5 list-decimal list-inside"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<li>
|
||||
Discover the agent card at <code className="text-xs">/.well-known/agent.json</code>.
|
||||
{t.rich("a2aStep1", {
|
||||
code: (chunks) => <code className="text-xs">{t("agentCardPath")}</code>,
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
Send JSON-RPC to <code className="text-xs">{t("rpcEndpoint")}</code> using{" "}
|
||||
<code className="text-xs">{t("rpcMethodSend")}</code> or{" "}
|
||||
<code className="text-xs">{t("rpcMethodStream")}</code>.
|
||||
{t.rich("a2aStep2", {
|
||||
code1: (chunks) => <code className="text-xs">{t("rpcEndpoint")}</code>,
|
||||
code2: (chunks) => <code className="text-xs">{t("rpcMethodSend")}</code>,
|
||||
code3: (chunks) => <code className="text-xs">{t("rpcMethodStream")}</code>,
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
Track and cancel tasks with <code className="text-xs">{t("rpcMethodGet")}</code> and{" "}
|
||||
<code className="text-xs">{t("rpcMethodCancel")}</code>.
|
||||
{t.rich("a2aStep3", {
|
||||
code1: (chunks) => <code className="text-xs">{t("rpcMethodGet")}</code>,
|
||||
code2: (chunks) => <code className="text-xs">{t("rpcMethodCancel")}</code>,
|
||||
})}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -126,20 +126,20 @@ export default function AgentSkillsPage() {
|
||||
</div>
|
||||
<ol className="space-y-1 text-xs text-text-muted">
|
||||
<li>
|
||||
1. Click <strong className="text-text-main">{t("copyUrl")}</strong> on the skill you
|
||||
want your agent to know about.
|
||||
1.{" "}
|
||||
{t.rich("howToUseStep1", {
|
||||
copyUrl: t("copyUrl"),
|
||||
bold: (chunks) => <strong className="text-text-main">{chunks}</strong>,
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
2. In your AI agent (Claude, Cursor, Cline…), say:
|
||||
2. {t("howToUseStep2")}
|
||||
<br />
|
||||
<code className="mt-1 block rounded border border-border bg-bg px-2 py-1 font-mono text-[11px]">
|
||||
Use the skill at <pasted-url>
|
||||
{t("howToUseStep2Code")}
|
||||
</code>
|
||||
</li>
|
||||
<li>
|
||||
3. The agent fetches the SKILL.md and learns OmniRoute's API or CLI — no manual
|
||||
docs needed.
|
||||
</li>
|
||||
<li>3. {t("howToUseStep3")}</li>
|
||||
</ol>
|
||||
<a
|
||||
href={AGENT_SKILLS_REPO_URL}
|
||||
@@ -156,13 +156,13 @@ export default function AgentSkillsPage() {
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<SkillSection
|
||||
title={t("apiSkills")}
|
||||
subtitle={`${apiSkills.length} skills — control OmniRoute via REST / HTTP`}
|
||||
subtitle={t("apiSkillsSubtitle", { count: apiSkills.length })}
|
||||
icon="api"
|
||||
skills={apiSkills}
|
||||
/>
|
||||
<SkillSection
|
||||
title={t("cliSkills")}
|
||||
subtitle={`${cliSkills.length} skills — control OmniRoute via the omniroute terminal binary`}
|
||||
subtitle={t("cliSkillsSubtitle", { count: cliSkills.length })}
|
||||
icon="terminal"
|
||||
skills={cliSkills}
|
||||
/>
|
||||
|
||||
@@ -78,7 +78,7 @@ export default function CachePerformance({
|
||||
<div data-testid="cache-performance" className="p-5 flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-medium text-sm">Performance</h2>
|
||||
<h2 className="font-medium text-sm">{t("performanceTitle")}</h2>
|
||||
</div>
|
||||
|
||||
{/* Error state */}
|
||||
@@ -91,7 +91,7 @@ export default function CachePerformance({
|
||||
className="self-start text-xs px-3 py-1.5 rounded bg-surface border border-border/50 hover:bg-surface/80 transition-colors"
|
||||
aria-label={t("cachePerformanceRetry")}
|
||||
>
|
||||
Retry
|
||||
{t("retry")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -127,15 +127,15 @@ export default function CachePerformance({
|
||||
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-border/30 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-semibold tabular-nums text-green-500">{hits}</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">Hits</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">{t("hits")}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold tabular-nums text-red-400">{misses}</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">Misses</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">{t("misses")}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-semibold tabular-nums">{totalRequests}</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">Total</div>
|
||||
<div className="text-xs text-text-muted mt-0.5">{t("total")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,17 +39,6 @@ interface ReasoningCacheData {
|
||||
|
||||
// ──────────────── Helpers ────────────────
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function formatChars(chars: number): string {
|
||||
if (chars >= 1_000_000) return `${(chars / 1_000_000).toFixed(1)}M`;
|
||||
if (chars >= 1_000) return `${(chars / 1_000).toFixed(1)}K`;
|
||||
@@ -142,6 +131,17 @@ export default function ReasoningCacheTab() {
|
||||
const [clearing, setClearing] = useState(false);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const timeAgo = (dateStr: string): string => {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return t("justNow");
|
||||
if (minutes < 60) return t("minutesAgo", { minutes });
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return t("hoursAgo", { hours });
|
||||
const days = Math.floor(hours / 24);
|
||||
return t("daysAgo", { days });
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cache/reasoning");
|
||||
@@ -281,10 +281,10 @@ export default function ReasoningCacheTab() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/20 text-left text-[11px] uppercase tracking-[0.12em] text-text-muted">
|
||||
<th className="px-4 py-3">Provider</th>
|
||||
<th className="px-4 py-3">{t("tableProvider")}</th>
|
||||
<th className="px-4 py-3">{t("reasoningEntries")}</th>
|
||||
<th className="px-4 py-3">{t("reasoningChars")}</th>
|
||||
<th className="px-4 py-3">Share</th>
|
||||
<th className="px-4 py-3">{t("tableShare")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -334,7 +334,7 @@ export default function ReasoningCacheTab() {
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/20 text-left text-[11px] uppercase tracking-[0.12em] text-text-muted">
|
||||
<th className="px-4 py-3">Model</th>
|
||||
<th className="px-4 py-3">{t("tableModel")}</th>
|
||||
<th className="px-4 py-3">{t("reasoningEntries")}</th>
|
||||
<th className="px-4 py-3">{t("reasoningAvgChars")}</th>
|
||||
<th className="px-4 py-3">{t("reasoningChars")}</th>
|
||||
@@ -379,8 +379,8 @@ export default function ReasoningCacheTab() {
|
||||
<div className="overflow-hidden rounded-2xl border border-border/20 bg-surface/35">
|
||||
<div className="grid grid-cols-[minmax(120px,1fr)_100px_minmax(100px,1fr)_80px_80px_60px] gap-3 border-b border-border/20 px-4 py-3 text-[11px] font-medium uppercase tracking-[0.12em] text-text-muted">
|
||||
<span>{t("reasoningToolCallId")}</span>
|
||||
<span>Provider</span>
|
||||
<span>Model</span>
|
||||
<span>{t("tableProvider")}</span>
|
||||
<span>{t("tableModel")}</span>
|
||||
<span>{t("reasoningChars")}</span>
|
||||
<span>{t("reasoningAge")}</span>
|
||||
<span />
|
||||
@@ -433,19 +433,20 @@ export default function ReasoningCacheTab() {
|
||||
</pre>
|
||||
<div className="mt-3 flex flex-wrap gap-4 text-xs text-text-muted">
|
||||
<span>
|
||||
Provider: <span className="text-text-main">{entry.provider}</span>
|
||||
{t("tableProvider")}:{" "}
|
||||
<span className="text-text-main">{entry.provider}</span>
|
||||
</span>
|
||||
<span>
|
||||
Model: <span className="text-text-main">{entry.model}</span>
|
||||
{t("tableModel")}: <span className="text-text-main">{entry.model}</span>
|
||||
</span>
|
||||
<span>
|
||||
Created:{" "}
|
||||
{t("created")}:{" "}
|
||||
<span className="text-text-main">
|
||||
{new Date(entry.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Expires:{" "}
|
||||
{t("expires")}:{" "}
|
||||
<span className="text-text-main">
|
||||
{new Date(entry.expiresAt).toLocaleString()}
|
||||
</span>
|
||||
|
||||
2
src/app/(dashboard)/dashboard/cache/page.tsx
vendored
2
src/app/(dashboard)/dashboard/cache/page.tsx
vendored
@@ -481,7 +481,7 @@ export default function CachePage() {
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="grid grid-cols-1 gap-6" aria-busy="true" aria-label="Loading cache">
|
||||
<div className="grid grid-cols-1 gap-6" aria-busy="true" aria-label={t("loadingCacheAria")}>
|
||||
<div className="h-96 rounded-3xl bg-surface-raised animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -233,10 +233,8 @@ export default function CavemanContextPageClient() {
|
||||
)}
|
||||
|
||||
<section className="rounded-lg border border-border bg-surface p-4">
|
||||
<h2 className="text-sm font-semibold text-text-main">Input compression</h2>
|
||||
<p className="mt-1 text-xs text-text-muted">
|
||||
Rewrite chat history with shorter wording. Reduces input tokens by ~50%.
|
||||
</p>
|
||||
<h2 className="text-sm font-semibold text-text-main">{t("inputCompressionTitle")}</h2>
|
||||
<p className="mt-1 text-xs text-text-muted">{t("inputCompressionDesc")}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-4 text-sm text-text-main">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
|
||||
@@ -181,10 +181,7 @@ export default function RtkContextPageClient() {
|
||||
{masterEnabled === false && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-700 dark:text-amber-300 flex items-start gap-2">
|
||||
<span className="material-symbols-outlined text-[18px]">info</span>
|
||||
<p>
|
||||
Token Saver master switch is OFF — these settings will not affect requests until you
|
||||
turn it on from the Endpoint page.
|
||||
</p>
|
||||
<p>{t("masterSwitchOffAlert")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ function ServiceToggle({
|
||||
onToggle: () => void;
|
||||
toggling: boolean;
|
||||
}) {
|
||||
const t = useTranslations("mcpDashboard");
|
||||
const online = enabled && status.online;
|
||||
const loading = enabled && status.loading;
|
||||
|
||||
@@ -54,7 +55,7 @@ function ServiceToggle({
|
||||
animation: online ? "pulse 2s infinite" : "none",
|
||||
}}
|
||||
/>
|
||||
{loading ? "..." : online ? "Online" : "Offline"}
|
||||
{loading ? "..." : online ? t("online") : t("offline")}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -67,7 +68,7 @@ function ServiceToggle({
|
||||
opacity: toggling ? 0.6 : 1,
|
||||
cursor: toggling ? "wait" : "pointer",
|
||||
}}
|
||||
title={enabled ? `Disable ${label}` : `Enable ${label}`}
|
||||
title={enabled ? t("disableLabel", { label }) : t("enableLabel", { label })}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-5 h-5 rounded-full shadow-md transition-all duration-300"
|
||||
@@ -101,12 +102,12 @@ function TransportSelector({
|
||||
}) {
|
||||
const t = useTranslations("mcpDashboard");
|
||||
const options: { value: McpTransport; label: string; desc: string }[] = [
|
||||
{ value: "stdio", label: "stdio", desc: "Local — IDE spawns process via omniroute --mcp" },
|
||||
{ value: "sse", label: "SSE", desc: "Remote — Server-Sent Events over HTTP" },
|
||||
{ value: "stdio", label: "stdio", desc: t("transportStdioDesc") },
|
||||
{ value: "sse", label: "SSE", desc: t("transportSseDesc") },
|
||||
{
|
||||
value: "streamable-http",
|
||||
label: "Streamable HTTP",
|
||||
desc: "Remote — Modern bidirectional HTTP",
|
||||
desc: t("transportStreamableHttpDesc"),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -129,7 +130,7 @@ function TransportSelector({
|
||||
swap_horiz
|
||||
</span>
|
||||
<span className="text-sm font-medium" style={{ color: "var(--color-text)" }}>
|
||||
Transport Mode
|
||||
{t("transportMode")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -185,7 +186,7 @@ function TransportSelector({
|
||||
onClick={() => void copyToClipboard(urlMap[value])}
|
||||
title={t("mcpDashboardCopyUrl")}
|
||||
>
|
||||
Copy
|
||||
{t("copy")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -194,6 +195,7 @@ function TransportSelector({
|
||||
}
|
||||
|
||||
function DisabledPanel() {
|
||||
const t = useTranslations("mcpDashboard");
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
@@ -214,10 +216,10 @@ function DisabledPanel() {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold" style={{ color: "var(--color-text)" }}>
|
||||
MCP is disabled
|
||||
{t("mcpDisabledTitle")}
|
||||
</h2>
|
||||
<p className="text-sm mt-1" style={{ color: "var(--color-text-muted)" }}>
|
||||
Enable MCP above to configure transport mode and view server telemetry.
|
||||
{t("mcpDisabledDesc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,6 +228,7 @@ function DisabledPanel() {
|
||||
}
|
||||
|
||||
export default function McpPage() {
|
||||
const t = useTranslations("mcpDashboard");
|
||||
const [mcpStatus, setMcpStatus] = useState<ServiceStatus>({ online: false, loading: true });
|
||||
const [mcpEnabled, setMcpEnabled] = useState(false);
|
||||
const [mcpToggling, setMcpToggling] = useState(false);
|
||||
@@ -313,20 +316,23 @@ export default function McpPage() {
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
||||
Model Context Protocol — 37 tools across 13 scopes, 3 transports (stdio / SSE /
|
||||
Streamable HTTP).
|
||||
{t("mcpIntro", { tools: 37, scopes: 13, transports: 3 })}
|
||||
</p>
|
||||
<ol
|
||||
className="mt-2 text-sm space-y-0.5 list-decimal list-inside"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<li>
|
||||
Run via <code className="text-xs">omniroute --mcp</code>
|
||||
{t.rich("mcpStep1", {
|
||||
code: (chunks) => <code className="text-xs">{chunks}</code>,
|
||||
})}
|
||||
</li>
|
||||
<li>Configure your MCP client to connect over stdio transport.</li>
|
||||
<li>{t("mcpStep2")}</li>
|
||||
<li>
|
||||
Invoke tools like <code className="text-xs">omniroute_get_health</code> and{" "}
|
||||
<code className="text-xs">omniroute_list_combos</code>.
|
||||
{t.rich("mcpStep3", {
|
||||
code1: (chunks) => <code className="text-xs">omniroute_get_health</code>,
|
||||
code2: (chunks) => <code className="text-xs">omniroute_list_combos</code>,
|
||||
})}
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Badge, Button, Card, Input, Modal, Toggle } from "@/shared/components";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type TierName = "LOCAL_ONLY" | "ALWAYS_PROTECTED" | "MANAGEMENT" | "CLIENT_API" | "PUBLIC";
|
||||
|
||||
interface TierEntry {
|
||||
name: TierName;
|
||||
prefixes: string[];
|
||||
description: string;
|
||||
bypassable: boolean;
|
||||
}
|
||||
|
||||
interface InventoryPayload {
|
||||
tiers: TierEntry[];
|
||||
bypassEnabled: boolean;
|
||||
bypassPrefixes: string[];
|
||||
spawnCapablePrefixes: string[];
|
||||
}
|
||||
|
||||
interface StatusMessage {
|
||||
type: "success" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
type ErrorCode =
|
||||
| "PASSWORD_REQUIRED"
|
||||
| "PASSWORD_MISMATCH"
|
||||
| "INSUFFICIENT_SCOPE"
|
||||
| "BYPASS_PREFIX_NOT_ALLOWED"
|
||||
| "GENERIC";
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function tierBadgeVariant(
|
||||
tier: TierName,
|
||||
prefix: string,
|
||||
spawnCapable: ReadonlyArray<string>,
|
||||
bypassPrefixes: ReadonlyArray<string>,
|
||||
bypassEnabled: boolean
|
||||
): { variant: "default" | "success" | "warning" | "error" | "info"; key: string } {
|
||||
if (spawnCapable.some((p) => prefix === p || prefix.startsWith(p))) {
|
||||
return { variant: "error", key: "spawn_capable" };
|
||||
}
|
||||
if (tier === "LOCAL_ONLY") {
|
||||
const isLive = bypassEnabled && bypassPrefixes.some((p) => p === prefix);
|
||||
return isLive ? { variant: "warning", key: "bypassable" } : { variant: "info", key: "strict" };
|
||||
}
|
||||
if (tier === "ALWAYS_PROTECTED") return { variant: "error", key: "always_protected" };
|
||||
if (tier === "PUBLIC") return { variant: "default", key: "public" };
|
||||
return { variant: "info", key: "auth_required" };
|
||||
}
|
||||
|
||||
function parseErrorCode(payload: unknown): ErrorCode {
|
||||
if (!payload || typeof payload !== "object") return "GENERIC";
|
||||
const errorField = (payload as { error?: unknown }).error;
|
||||
if (typeof errorField === "string") {
|
||||
if (errorField.toLowerCase().includes("manage")) return "INSUFFICIENT_SCOPE";
|
||||
return "GENERIC";
|
||||
}
|
||||
if (errorField && typeof errorField === "object") {
|
||||
const code = (errorField as { code?: unknown }).code;
|
||||
if (
|
||||
code === "PASSWORD_REQUIRED" ||
|
||||
code === "PASSWORD_MISMATCH" ||
|
||||
code === "INSUFFICIENT_SCOPE" ||
|
||||
code === "BYPASS_PREFIX_NOT_ALLOWED"
|
||||
) {
|
||||
return code;
|
||||
}
|
||||
// Zod validation surface (T-011 emits BYPASS_PREFIX_NOT_ALLOWED inside
|
||||
// `error.details[].message`).
|
||||
const details = (errorField as { details?: unknown }).details;
|
||||
if (Array.isArray(details)) {
|
||||
for (const d of details) {
|
||||
const m = (d as { message?: unknown }).message;
|
||||
if (typeof m === "string" && m.includes("BYPASS_PREFIX_NOT_ALLOWED")) {
|
||||
return "BYPASS_PREFIX_NOT_ALLOWED";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "GENERIC";
|
||||
}
|
||||
|
||||
// ─── component ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AuthzSection() {
|
||||
const t = useTranslations("settings");
|
||||
const [inventory, setInventory] = useState<InventoryPayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// Draft state — only persisted on Save (security-impacting fields require
|
||||
// a password re-prompt before the PATCH is fired).
|
||||
const [draftEnabled, setDraftEnabled] = useState<boolean>(true);
|
||||
const [draftPrefixes, setDraftPrefixes] = useState<string[]>([]);
|
||||
const [newPrefixInput, setNewPrefixInput] = useState("");
|
||||
|
||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [status, setStatus] = useState<StatusMessage | null>(null);
|
||||
|
||||
const loadInventory = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/authz-inventory", {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const code = parseErrorCode(await res.json().catch(() => null));
|
||||
setLoadError(t(`authz.error.${code}`));
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as InventoryPayload;
|
||||
setInventory(data);
|
||||
setDraftEnabled(data.bypassEnabled);
|
||||
setDraftPrefixes([...data.bypassPrefixes]);
|
||||
} catch {
|
||||
setLoadError(t("authz.loadError"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadInventory();
|
||||
}, [loadInventory]);
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
if (!inventory) return false;
|
||||
if (draftEnabled !== inventory.bypassEnabled) return true;
|
||||
if (draftPrefixes.length !== inventory.bypassPrefixes.length) return true;
|
||||
const a = [...draftPrefixes].sort();
|
||||
const b = [...inventory.bypassPrefixes].sort();
|
||||
return a.some((p, i) => p !== b[i]);
|
||||
}, [draftEnabled, draftPrefixes, inventory]);
|
||||
|
||||
const spawnCapable = useMemo(() => inventory?.spawnCapablePrefixes ?? [], [inventory]);
|
||||
|
||||
const isSpawnCapable = useCallback(
|
||||
(prefix: string) => spawnCapable.some((p) => prefix === p || prefix.startsWith(p)),
|
||||
[spawnCapable]
|
||||
);
|
||||
|
||||
const handleAddPrefix = () => {
|
||||
const trimmed = newPrefixInput.trim();
|
||||
if (!trimmed) return;
|
||||
if (draftPrefixes.includes(trimmed)) {
|
||||
setNewPrefixInput("");
|
||||
return;
|
||||
}
|
||||
setDraftPrefixes((prev) => [...prev, trimmed]);
|
||||
setNewPrefixInput("");
|
||||
};
|
||||
|
||||
const handleRemovePrefix = (prefix: string) => {
|
||||
if (isSpawnCapable(prefix)) return;
|
||||
setDraftPrefixes((prev) => prev.filter((p) => p !== prefix));
|
||||
};
|
||||
|
||||
const handleSaveRequest = () => {
|
||||
if (!dirty) return;
|
||||
setCurrentPassword("");
|
||||
setStatus(null);
|
||||
setPasswordModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!currentPassword) {
|
||||
setStatus({ type: "error", message: t("authz.error.PASSWORD_REQUIRED") });
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setStatus(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
localOnlyManageScopeBypassEnabled: draftEnabled,
|
||||
localOnlyManageScopeBypassPrefixes: draftPrefixes,
|
||||
currentPassword,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => null);
|
||||
const code = parseErrorCode(payload);
|
||||
setStatus({ type: "error", message: t(`authz.error.${code}`) });
|
||||
return;
|
||||
}
|
||||
setStatus({ type: "success", message: t("authz.saved") });
|
||||
setPasswordModalOpen(false);
|
||||
setCurrentPassword("");
|
||||
await loadInventory();
|
||||
} catch {
|
||||
setStatus({ type: "error", message: t("authz.error.GENERIC") });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── render ─────────────────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-info/10 text-info">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
shield_lock
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{t("authz.title")}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted">{t("authz.loading")}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError || !inventory) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-info/10 text-info">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
shield_lock
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{t("authz.title")}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-red-500">{loadError ?? t("authz.loadError")}</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Authz header + tier inventory */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-info/10 text-info">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
shield_lock
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">{t("authz.title")}</h3>
|
||||
<p className="text-sm text-text-muted">{t("authz.description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{inventory.tiers.map((tier) => (
|
||||
<div
|
||||
key={tier.name}
|
||||
className="rounded-lg border border-border/50 bg-black/[0.02] dark:bg-white/[0.02] p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="font-semibold">{t(`authz.tier.${tier.name}`)}</h4>
|
||||
{tier.bypassable && (
|
||||
<Badge variant="warning" size="sm">
|
||||
{t("authz.badge.bypassable")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mb-3">{tier.description}</p>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{tier.prefixes.map((prefix) => {
|
||||
const badge = tierBadgeVariant(
|
||||
tier.name,
|
||||
prefix,
|
||||
inventory.spawnCapablePrefixes,
|
||||
inventory.bypassPrefixes,
|
||||
inventory.bypassEnabled
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={`${tier.name}:${prefix}`}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-border/40 bg-black/[0.02] dark:bg-white/[0.02] px-3 py-2"
|
||||
>
|
||||
<code className="text-xs font-mono">{prefix}</code>
|
||||
<Badge variant={badge.variant} size="sm">
|
||||
{t(`authz.badge.${badge.key}`)}
|
||||
</Badge>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bypass policy editor */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
tune
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{t("authz.bypass.section")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<p className="font-medium">{t("authz.bypass.kill_switch.label")}</p>
|
||||
<p className="text-sm text-text-muted">{t("authz.bypass.kill_switch.desc")}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={draftEnabled}
|
||||
onChange={() => setDraftEnabled((prev) => !prev)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="font-medium">{t("authz.bypass.prefix.label")}</p>
|
||||
<p className="text-sm text-text-muted">{t("authz.bypass.prefix.desc")}</p>
|
||||
</div>
|
||||
|
||||
{draftPrefixes.length === 0 && (
|
||||
<p className="text-sm text-text-muted italic">{t("authz.bypass.prefix.empty")}</p>
|
||||
)}
|
||||
|
||||
<ul className="flex flex-col gap-2">
|
||||
{draftPrefixes.map((prefix) => {
|
||||
const locked = isSpawnCapable(prefix);
|
||||
return (
|
||||
<li
|
||||
key={prefix}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-border/40 bg-black/[0.02] dark:bg-white/[0.02] px-3 py-2"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<code className="text-xs font-mono">{prefix}</code>
|
||||
{locked && (
|
||||
<span className="text-[10px] text-red-500 mt-1">
|
||||
{t("authz.bypass.cli_tools_runtime_note")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemovePrefix(prefix)}
|
||||
disabled={locked || submitting}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{/* Static read-only rows for spawn-capable prefixes that are NOT
|
||||
in the draft list — surface them so the operator understands
|
||||
they are intentionally not toggleable. */}
|
||||
{spawnCapable
|
||||
.filter((p) => !draftPrefixes.includes(p))
|
||||
.map((prefix) => (
|
||||
<li
|
||||
key={`locked:${prefix}`}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-red-500/30 bg-red-500/[0.04] px-3 py-2 opacity-80"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<code className="text-xs font-mono">{prefix}</code>
|
||||
<span className="text-[10px] text-red-500 mt-1">
|
||||
{t("authz.bypass.cli_tools_runtime_note")}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="error" size="sm">
|
||||
{t("authz.badge.spawn_capable")}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex gap-2 items-end pt-2">
|
||||
<Input
|
||||
type="text"
|
||||
label={t("authz.bypass.prefix.add")}
|
||||
placeholder={t("authz.bypass.prefix.placeholder")}
|
||||
value={newPrefixInput}
|
||||
onChange={(e) => setNewPrefixInput(e.target.value)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleAddPrefix}
|
||||
disabled={!newPrefixInput.trim() || submitting}
|
||||
>
|
||||
{t("authz.bypass.prefix.add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save bar */}
|
||||
<div className="flex items-center justify-between gap-4 pt-4 mt-4 border-t border-border/50">
|
||||
<div className="text-sm">
|
||||
{dirty && (
|
||||
<span className="text-amber-600 dark:text-amber-400">{t("authz.pending")}</span>
|
||||
)}
|
||||
{status && (
|
||||
<span
|
||||
className={`ml-3 ${status.type === "error" ? "text-red-500" : "text-green-500"}`}
|
||||
>
|
||||
{status.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="primary" onClick={handleSaveRequest} disabled={!dirty || submitting}>
|
||||
{t("authz.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Password re-auth modal — fires for every security-impacting PATCH */}
|
||||
<Modal
|
||||
isOpen={passwordModalOpen}
|
||||
onClose={() => {
|
||||
if (!submitting) {
|
||||
setPasswordModalOpen(false);
|
||||
setCurrentPassword("");
|
||||
}
|
||||
}}
|
||||
title={t("authz.password.prompt.label")}
|
||||
footer={
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setPasswordModalOpen(false);
|
||||
setCurrentPassword("");
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t("authz.password.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={!currentPassword || submitting}
|
||||
>
|
||||
{t("authz.password.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-text-muted">{t("authz.password.prompt.desc")}</p>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t("authz.password.placeholder")}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{status?.type === "error" && <p className="text-sm text-red-500">{status.message}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -31,8 +31,8 @@ function getRuleSectionCount(value: unknown, keys: string[]): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getErrorMessage(payload: unknown): string {
|
||||
if (!isObjectRecord(payload)) return "Failed to save payload rules";
|
||||
function getErrorMessage(payload: unknown, fallback: string): string {
|
||||
if (!isObjectRecord(payload)) return fallback;
|
||||
|
||||
const nestedError = payload.error;
|
||||
if (typeof nestedError === "string" && nestedError.trim()) {
|
||||
@@ -52,11 +52,12 @@ function getErrorMessage(payload: unknown): string {
|
||||
}
|
||||
}
|
||||
|
||||
return "Failed to save payload rules";
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export default function PayloadRulesTab() {
|
||||
const t = useTranslations("settings");
|
||||
const tCommon = useTranslations("common");
|
||||
const [editorValue, setEditorValue] = useState(EMPTY_PAYLOAD_RULES_TEXT);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -66,16 +67,16 @@ export default function PayloadRulesTab() {
|
||||
try {
|
||||
const parsed = JSON.parse(editorValue);
|
||||
if (!isObjectRecord(parsed)) {
|
||||
return { value: null, error: "Payload rules must be a JSON object." };
|
||||
return { value: null, error: t("payloadMustBeObject") };
|
||||
}
|
||||
return { value: parsed, error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
value: null,
|
||||
error: error instanceof Error ? error.message : "Invalid JSON payload.",
|
||||
error: error instanceof Error ? error.message : t("payloadInvalidJson"),
|
||||
};
|
||||
}
|
||||
}, [editorValue]);
|
||||
}, [editorValue, t]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const source = parsedEditor.value;
|
||||
@@ -94,19 +95,19 @@ export default function PayloadRulesTab() {
|
||||
const response = await fetch("/api/settings/payload-rules");
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage(payload));
|
||||
throw new Error(getErrorMessage(payload, tCommon("failedToLoad")));
|
||||
}
|
||||
|
||||
setEditorValue(JSON.stringify(payload, null, 2));
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : "Failed to load payload rules",
|
||||
text: error instanceof Error ? error.message : tCommon("failedToLoad"),
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [tCommon]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
@@ -116,7 +117,7 @@ export default function PayloadRulesTab() {
|
||||
setEditorValue(EMPTY_PAYLOAD_RULES_TEXT);
|
||||
setMessage({
|
||||
type: "info",
|
||||
text: "Editor reset to the neutral template. Save to apply it.",
|
||||
text: t("payloadResetInfo"),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -124,7 +125,7 @@ export default function PayloadRulesTab() {
|
||||
if (parsedEditor.error || !parsedEditor.value) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: parsedEditor.error || "Payload rules must be valid JSON before saving.",
|
||||
text: parsedEditor.error || t("payloadValidJsonRequired"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -139,15 +140,15 @@ export default function PayloadRulesTab() {
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage(payload));
|
||||
throw new Error(getErrorMessage(payload, tCommon("error")));
|
||||
}
|
||||
|
||||
setEditorValue(JSON.stringify(payload, null, 2));
|
||||
setMessage({ type: "success", text: "Payload rules saved and hot reloaded." });
|
||||
setMessage({ type: "success", text: t("payloadSaveSuccess") });
|
||||
} catch (error) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : "Failed to save payload rules",
|
||||
text: error instanceof Error ? error.message : tCommon("error"),
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -165,53 +166,41 @@ export default function PayloadRulesTab() {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">{t("payloadRulesTitle")}</h3>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Configure request payload mutations by model and protocol. Changes are persisted in
|
||||
settings and hot reloaded into the runtime immediately after save.
|
||||
</p>
|
||||
<p className="text-sm text-text-muted mt-1">{t("payloadRulesDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3">
|
||||
<div className="rounded-lg border border-border bg-bg-secondary/40 p-3">
|
||||
<p className="text-sm font-medium">default</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Applies params only when the target path is missing from the outgoing payload.
|
||||
</p>
|
||||
<p className="text-sm font-medium">{t("payloadRuleDefaultTitle")}</p>
|
||||
<p className="text-xs text-text-muted mt-1">{t("payloadRuleDefaultDesc")}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-bg-secondary/40 p-3">
|
||||
<p className="text-sm font-medium">override</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Forces values onto the payload, replacing anything already present at that path.
|
||||
</p>
|
||||
<p className="text-sm font-medium">{t("payloadRuleOverrideTitle")}</p>
|
||||
<p className="text-xs text-text-muted mt-1">{t("payloadRuleOverrideDesc")}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-bg-secondary/40 p-3">
|
||||
<p className="text-sm font-medium">filter</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Removes blocked params from the payload before the upstream request is sent.
|
||||
</p>
|
||||
<p className="text-sm font-medium">{t("payloadRuleFilterTitle")}</p>
|
||||
<p className="text-xs text-text-muted mt-1">{t("payloadRuleFilterDesc")}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-bg-secondary/40 p-3">
|
||||
<p className="text-sm font-medium">defaultRaw</p>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Like <code>default</code>, but string values are parsed as JSON first when possible.
|
||||
The legacy input alias <code>default-raw</code> is also accepted on save.
|
||||
</p>
|
||||
<p className="text-sm font-medium">{t("payloadRuleDefaultRawTitle")}</p>
|
||||
<p className="text-xs text-text-muted mt-1">{t("payloadRuleDefaultRawDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-text-muted">
|
||||
<span className="rounded-full border border-border px-2.5 py-1">
|
||||
default: {summary.default}
|
||||
{t("payloadRuleDefaultTitle")}: {summary.default}
|
||||
</span>
|
||||
<span className="rounded-full border border-border px-2.5 py-1">
|
||||
override: {summary.override}
|
||||
{t("payloadRuleOverrideTitle")}: {summary.override}
|
||||
</span>
|
||||
<span className="rounded-full border border-border px-2.5 py-1">
|
||||
filter: {summary.filter}
|
||||
{t("payloadRuleFilterTitle")}: {summary.filter}
|
||||
</span>
|
||||
<span className="rounded-full border border-border px-2.5 py-1">
|
||||
defaultRaw: {summary.defaultRaw}
|
||||
{t("payloadRuleDefaultRawTitle")}: {summary.defaultRaw}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -239,14 +228,12 @@ export default function PayloadRulesTab() {
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-bg-secondary/30">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Editor</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Use the runtime schema shape: <code>default</code>, <code>override</code>,{" "}
|
||||
<code>filter</code>, <code>defaultRaw</code>. The API also accepts the legacy input
|
||||
key <code>default-raw</code>.
|
||||
</p>
|
||||
<p className="text-sm font-medium">{t("payloadEditorTitle")}</p>
|
||||
<p className="text-xs text-text-muted">{t("payloadEditorDesc")}</p>
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
{loading ? tCommon("loading") : t("payloadEditorReady")}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">{loading ? "Loading..." : "Ready"}</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={editorValue}
|
||||
@@ -263,19 +250,19 @@ export default function PayloadRulesTab() {
|
||||
|
||||
{parsedEditor.error && (
|
||||
<p className="text-sm text-red-500">
|
||||
JSON parse error: <span className="font-medium">{parsedEditor.error}</span>
|
||||
{t("payloadJsonParseError", { error: parsedEditor.error })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" onClick={loadConfig} disabled={loading || saving}>
|
||||
Reload
|
||||
{tCommon("refresh")}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleReset} disabled={loading || saving}>
|
||||
Reset Template
|
||||
{tCommon("reset")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={loading || saving || !!parsedEditor.error}>
|
||||
{saving ? "Saving..." : "Save Payload Rules"}
|
||||
{saving ? t("saving") : t("savePayloadRules")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -108,26 +108,26 @@ function parseBulkImportText(text: string): {
|
||||
const lineNum = i + 1;
|
||||
|
||||
if (!name) {
|
||||
errors.push({ line: lineNum, reason: "Missing NAME" });
|
||||
errors.push({ line: lineNum, reason: "bulkImportErrorMissingName" });
|
||||
continue;
|
||||
}
|
||||
if (!host) {
|
||||
errors.push({ line: lineNum, reason: "Missing HOST" });
|
||||
errors.push({ line: lineNum, reason: "bulkImportErrorMissingHost" });
|
||||
continue;
|
||||
}
|
||||
const port = Number(portStr);
|
||||
if (!portStr || isNaN(port) || port < 1 || port > 65535) {
|
||||
errors.push({ line: lineNum, reason: "Invalid PORT (must be 1-65535)" });
|
||||
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidPort" });
|
||||
continue;
|
||||
}
|
||||
const normalizedType = (type || "socks5").toLowerCase();
|
||||
if (!VALID_TYPES.has(normalizedType)) {
|
||||
errors.push({ line: lineNum, reason: `Invalid TYPE '${type}' (use http, https, or socks5)` });
|
||||
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidType" });
|
||||
continue;
|
||||
}
|
||||
const normalizedStatus = (status || "active").toLowerCase();
|
||||
if (!VALID_STATUSES.has(normalizedStatus)) {
|
||||
errors.push({ line: lineNum, reason: `Invalid STATUS '${status}' (use active or inactive)` });
|
||||
errors.push({ line: lineNum, reason: "bulkImportErrorInvalidStatus" });
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -251,7 +251,7 @@ export default function ProxyRegistryManager() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadHealth, loadAllUsage]);
|
||||
}, [loadHealth, loadAllUsage, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -535,7 +535,7 @@ export default function ProxyRegistryManager() {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data?.error?.message || "Failed to import proxies");
|
||||
setError(data?.error?.message || t("errorSaveFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -547,7 +547,7 @@ export default function ProxyRegistryManager() {
|
||||
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to import proxies");
|
||||
setError(e?.message || t("errorSaveFailed"));
|
||||
} finally {
|
||||
setBulkImporting(false);
|
||||
}
|
||||
@@ -651,7 +651,7 @@ export default function ProxyRegistryManager() {
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<span className="text-xs px-2 py-1 rounded border border-border bg-bg-subtle">
|
||||
{item.status || "active"}
|
||||
{item.status === "inactive" ? t("statusInactive") : t("statusActive")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-xs text-text-muted">
|
||||
@@ -668,7 +668,7 @@ export default function ProxyRegistryManager() {
|
||||
</>
|
||||
) : (
|
||||
<span className="text-red-400">
|
||||
✗ {testById[item.id]!.error || "failed"}
|
||||
✗ {testById[item.id]!.error || t("failed")}
|
||||
</span>
|
||||
)
|
||||
) : health ? (
|
||||
@@ -755,7 +755,7 @@ export default function ProxyRegistryManager() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Type</label>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("labelType")}</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={form.type}
|
||||
@@ -767,7 +767,7 @@ export default function ProxyRegistryManager() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Host</label>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("labelHost")}</label>
|
||||
<input
|
||||
data-testid="proxy-registry-host-input"
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
@@ -776,7 +776,7 @@ export default function ProxyRegistryManager() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Port</label>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("labelPort")}</label>
|
||||
<input
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={form.port}
|
||||
@@ -784,26 +784,26 @@ export default function ProxyRegistryManager() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Username</label>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("labelUsername")}</label>
|
||||
<input
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={form.username}
|
||||
placeholder={editingId ? "Leave blank to keep current username" : ""}
|
||||
placeholder={editingId ? t("usernamePlaceholderEdit") : ""}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, username: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Password</label>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("labelPassword")}</label>
|
||||
<input
|
||||
type="password"
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={form.password}
|
||||
placeholder={editingId ? "Leave blank to keep current password" : ""}
|
||||
placeholder={editingId ? t("passwordPlaceholderEdit") : ""}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Region</label>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("labelRegion")}</label>
|
||||
<input
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={form.region}
|
||||
@@ -811,20 +811,20 @@ export default function ProxyRegistryManager() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Status</label>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("labelStatus")}</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={form.status}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, status: e.target.value }))}
|
||||
>
|
||||
<option value="active">active</option>
|
||||
<option value="inactive">inactive</option>
|
||||
<option value="active">{t("statusActive")}</option>
|
||||
<option value="inactive">{t("statusInactive")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Notes</label>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("labelNotes")}</label>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={form.notes}
|
||||
@@ -835,10 +835,10 @@ export default function ProxyRegistryManager() {
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<Button size="sm" variant="secondary" onClick={() => setModalOpen(false)}>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button size="sm" icon="save" onClick={handleSave} loading={saving}>
|
||||
Save
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -855,20 +855,20 @@ export default function ProxyRegistryManager() {
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Scope</label>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("labelScope")}</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={bulkScope}
|
||||
onChange={(e) => setBulkScope(e.target.value)}
|
||||
>
|
||||
<option value="global">global</option>
|
||||
<option value="provider">provider</option>
|
||||
<option value="account">account</option>
|
||||
<option value="combo">combo</option>
|
||||
<option value="global">{t("scopeGlobal")}</option>
|
||||
<option value="provider">{t("scopeProvider")}</option>
|
||||
<option value="account">{t("scopeAccount")}</option>
|
||||
<option value="combo">{t("scopeCombo")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Proxy</label>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("labelProxy")}</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
value={bulkProxyId}
|
||||
@@ -886,23 +886,21 @@ export default function ProxyRegistryManager() {
|
||||
|
||||
{bulkScope !== "global" && (
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">
|
||||
Scope IDs (comma or newline)
|
||||
</label>
|
||||
<label className="text-xs text-text-muted mb-1 block">{t("bulkLabelScopeIds")}</label>
|
||||
<textarea
|
||||
data-testid="proxy-registry-bulk-scopeids-input"
|
||||
className="w-full px-3 py-2 rounded bg-bg-subtle border border-border"
|
||||
rows={5}
|
||||
value={bulkScopeIds}
|
||||
onChange={(e) => setBulkScopeIds(e.target.value)}
|
||||
placeholder="provider-openai,provider-anthropic"
|
||||
placeholder={t("bulkScopeIdsPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<Button size="sm" variant="secondary" onClick={() => setBulkOpen(false)}>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -911,7 +909,7 @@ export default function ProxyRegistryManager() {
|
||||
loading={bulkSaving}
|
||||
data-testid="proxy-registry-bulk-apply"
|
||||
>
|
||||
Apply
|
||||
{t("bulkApply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -978,7 +976,7 @@ export default function ProxyRegistryManager() {
|
||||
<div className="max-h-28 overflow-y-auto rounded border border-red-500/30 bg-red-500/10 p-2">
|
||||
{bulkImportErrors.map((err, idx) => (
|
||||
<div key={idx} className="text-xs text-red-400">
|
||||
{t("bulkImportErrorLine", { line: err.line, reason: err.reason })}
|
||||
{t("bulkImportErrorLine", { line: err.line, reason: t(err.reason as any) })}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -990,13 +988,13 @@ export default function ProxyRegistryManager() {
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-text-muted border-b border-border bg-bg-subtle sticky top-0">
|
||||
<th className="py-1.5 px-2">Name</th>
|
||||
<th className="py-1.5 px-2">Type</th>
|
||||
<th className="py-1.5 px-2">Host</th>
|
||||
<th className="py-1.5 px-2">Port</th>
|
||||
<th className="py-1.5 px-2">User</th>
|
||||
<th className="py-1.5 px-2">Region</th>
|
||||
<th className="py-1.5 px-2">Status</th>
|
||||
<th className="py-1.5 px-2">{t("tableName")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelType")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelHost")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelPort")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelUsername")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelRegion")}</th>
|
||||
<th className="py-1.5 px-2">{t("labelStatus")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1018,7 +1016,7 @@ export default function ProxyRegistryManager() {
|
||||
entry.status === "active" ? "text-emerald-400" : "text-text-muted"
|
||||
}
|
||||
>
|
||||
{entry.status}
|
||||
{entry.status === "active" ? t("statusActive") : t("statusInactive")}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -192,74 +192,50 @@ type TransformOpKind =
|
||||
| "obfuscate_words";
|
||||
|
||||
const OP_KIND_LABELS: Record<TransformOpKind, string> = {
|
||||
drop_paragraph_if_contains: "Drop paragraph (contains)",
|
||||
drop_paragraph_if_starts_with: "Drop paragraph (starts with)",
|
||||
replace_text: "Replace text",
|
||||
replace_regex: "Replace regex",
|
||||
drop_block_if_contains: "Drop block (contains)",
|
||||
prepend_system_block: "Prepend system block",
|
||||
append_system_block: "Append system block",
|
||||
inject_billing_header: "Inject billing header",
|
||||
obfuscate_words: "Obfuscate words (ZWJ)",
|
||||
drop_paragraph_if_contains: "routingOpDropParagraphContainsLabel",
|
||||
drop_paragraph_if_starts_with: "routingOpDropParagraphStartsWithLabel",
|
||||
replace_text: "routingOpReplaceTextLabel",
|
||||
replace_regex: "routingOpReplaceRegexLabel",
|
||||
drop_block_if_contains: "routingOpDropBlockContainsLabel",
|
||||
prepend_system_block: "routingOpPrependSystemBlockLabel",
|
||||
append_system_block: "routingOpAppendSystemBlockLabel",
|
||||
inject_billing_header: "routingOpInjectBillingHeaderLabel",
|
||||
obfuscate_words: "routingOpObfuscateWordsLabel",
|
||||
};
|
||||
|
||||
// Human-readable description shown above each op's editor. Explains in one
|
||||
// sentence what the op DOES (transformation effect) and one sentence WHEN
|
||||
// to use it (the typical fingerprint-sanitization use-case).
|
||||
const OP_KIND_DESCRIPTIONS: Record<TransformOpKind, string> = {
|
||||
drop_paragraph_if_contains:
|
||||
"Removes any paragraph (text block split on blank lines) inside the system prompt whose text contains ANY of the listed substrings. Use to strip third-party client fingerprints like 'github.com/anomalyco/opencode' or 'docs.openwebui.com' that Anthropic's classifier flags.",
|
||||
drop_paragraph_if_starts_with:
|
||||
"Removes any paragraph that STARTS WITH one of the listed prefixes. Use for identity lines like 'You are OpenCode' or 'You are Open WebUI' that announce the calling client.",
|
||||
replace_text:
|
||||
"Replaces a literal substring with another literal substring. Use for known trigger phrases — e.g. rewrite 'Here is some useful information about the environment you are running in:' to 'Environment context you are running in:' (an empirically-validated trigger phrase).",
|
||||
replace_regex:
|
||||
"Replaces text matching a regular expression. Use when you need patterns (character classes, optional whitespace, anchors) instead of literal substrings. A malformed pattern is caught at runtime when the op runs.",
|
||||
drop_block_if_contains:
|
||||
"Removes ENTIRE system blocks (not just paragraphs) whose text contains any of the listed substrings. Use when a whole block is fingerprint-bearing and you want it gone — e.g. an injected MCP-server description.",
|
||||
prepend_system_block:
|
||||
"Inserts a new text block at the FRONT of the system array. Use to add the SDK identity 'You are a Claude agent, built on Anthropic's Claude Agent SDK.' that Anthropic's classifier expects.",
|
||||
append_system_block:
|
||||
"Inserts a new text block at the END of the system array. Use for cosmetic additions that don't need to be at position [0].",
|
||||
inject_billing_header:
|
||||
"Prepends the special 'x-anthropic-billing-header: cc_version=...; cc_entrypoint=...; cch=...;' text block that Anthropic's classifier validates. Required for CC bridge relay endpoints; for the native claude provider OmniRoute already injects its own billing line so this op is usually redundant there.",
|
||||
obfuscate_words:
|
||||
"Inserts a Zero-Width-Joiner character after the first letter of each listed word, so 'opencode' becomes 'o\u200dpencode'. Reads identical to humans but bypasses classifier word matches. Targets system blocks, user/assistant messages, and tool descriptions.",
|
||||
drop_paragraph_if_contains: "routingOpDropParagraphContainsDesc",
|
||||
drop_paragraph_if_starts_with: "routingOpDropParagraphStartsWithDesc",
|
||||
replace_text: "routingOpReplaceTextDesc",
|
||||
replace_regex: "routingOpReplaceRegexDesc",
|
||||
drop_block_if_contains: "routingOpDropBlockContainsDesc",
|
||||
prepend_system_block: "routingOpPrependSystemBlockDesc",
|
||||
append_system_block: "routingOpAppendSystemBlockDesc",
|
||||
inject_billing_header: "routingOpInjectBillingHeaderDesc",
|
||||
obfuscate_words: "routingOpObfuscateWordsDesc",
|
||||
};
|
||||
|
||||
// Per-field hints rendered under each Input/Select/Toggle inside the
|
||||
// editor. Short, plain-English. Keep under ~120 chars each.
|
||||
const FIELD_HINTS = {
|
||||
needles:
|
||||
"List of substrings. A paragraph matches if it contains ANY one of them. Add one per line via 'Add entry'.",
|
||||
prefixes:
|
||||
"List of strings. A paragraph matches if it starts with any one of them (leading whitespace is trimmed before matching).",
|
||||
caseSensitive:
|
||||
"When ON, 'OpenCode' and 'opencode' are different strings. When OFF (default), the comparison ignores case.",
|
||||
matchLiteral:
|
||||
"Exact literal substring to find. No regex syntax — special chars like . * ? are treated as themselves.",
|
||||
replacementText:
|
||||
"Replacement string. Leave blank to delete the match. The output preserves surrounding text.",
|
||||
allOccurrences:
|
||||
"When ON (default), every instance is replaced. When OFF, only the first match is replaced.",
|
||||
pattern:
|
||||
"JavaScript regex source. Don't wrap in slashes — just 'foo(.*)bar'. Server rejects patterns that fail to compile.",
|
||||
regexFlags:
|
||||
"JavaScript regex flags (g = all matches, i = case-insensitive, s = dot matches newline, m = multiline). Default 'g'.",
|
||||
blockText:
|
||||
"Full text of the new system block. Use a literal string; the system block stores text only.",
|
||||
idempotencyKey:
|
||||
"Optional. If set, the op skips when a block whose text starts with this key is already present. Prevents double-prepend on retries.",
|
||||
billingEntrypoint:
|
||||
"Value injected as 'cc_entrypoint='. Anthropic accepts 'sdk-cli' (Agent SDK), 'cli' (Claude Code CLI), or other documented values.",
|
||||
billingVersionFormat:
|
||||
"How the 3-char build hash after cc_version= is computed. 'ex-machina' = sha256 of CCH_SALT+chars-from-first-user-msg+version (per-message). 'omniroute-daystamp' = sha256 of YYYY-MM-DD+version (stable per-day).",
|
||||
billingCchAlgo:
|
||||
"How the 5-char cch= token is computed. 'sha256-first-user' = sha256 of first user message text. 'xxhash64-body' = body-level signing fills it later. 'static-zero' = literal '00000' placeholder.",
|
||||
obfuscateWords:
|
||||
"Lowercase words to obfuscate. ZWJ insertion is applied case-insensitively, so 'opencode' also matches 'OpenCode' and 'OPENCODE'.",
|
||||
obfuscateTargets:
|
||||
"Which body regions to scan for the words: system blocks, user/assistant messages, and/or tool descriptions.",
|
||||
needles: "routingNeedlesHint",
|
||||
prefixes: "routingPrefixesHint",
|
||||
caseSensitive: "routingCaseSensitiveHint",
|
||||
matchLiteral: "routingMatchLiteralHint",
|
||||
replacementText: "routingReplacementTextHint",
|
||||
allOccurrences: "routingAllOccurrencesHint",
|
||||
pattern: "routingPatternHint",
|
||||
regexFlags: "routingRegexFlagsHint",
|
||||
blockText: "routingBlockTextHint",
|
||||
idempotencyKey: "routingIdempotencyKeyHint",
|
||||
billingEntrypoint: "routingBillingEntrypointHint",
|
||||
billingVersionFormat: "routingBillingVersionFormatHint",
|
||||
billingCchAlgo: "routingBillingCchAlgoHint",
|
||||
obfuscateWords: "routingObfuscateWordsHint",
|
||||
obfuscateTargets: "routingObfuscateTargetsHint",
|
||||
};
|
||||
|
||||
function makeDefaultOp(kind: TransformOpKind): any {
|
||||
@@ -304,6 +280,7 @@ function StringListEditor({
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("settings");
|
||||
const tCommon = useTranslations("common");
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-text-main">{label}</span>
|
||||
@@ -342,7 +319,7 @@ function StringListEditor({
|
||||
onClick={() => onChange([...items, ""])}
|
||||
className="self-start"
|
||||
>
|
||||
Add entry
|
||||
{tCommon("add") || "Add entry"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -360,7 +337,7 @@ function OpEditor({
|
||||
const t = useTranslations("settings");
|
||||
const updateField = (field: string, value: any) => onChange({ ...op, [field]: value });
|
||||
const kind = op?.kind as TransformOpKind | undefined;
|
||||
const opDescription = kind ? OP_KIND_DESCRIPTIONS[kind] : null;
|
||||
const opDescription = kind ? t(OP_KIND_DESCRIPTIONS[kind]) : null;
|
||||
|
||||
const wrap = (body: React.ReactNode) => (
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -379,14 +356,14 @@ function OpEditor({
|
||||
<div className="flex flex-col gap-2">
|
||||
<StringListEditor
|
||||
label={t("routingNeedlesSubstrings")}
|
||||
hint={FIELD_HINTS.needles}
|
||||
hint={t(FIELD_HINTS.needles)}
|
||||
items={op.needles || []}
|
||||
onChange={(next) => updateField("needles", next)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Toggle
|
||||
label={t("routingCaseSensitive")}
|
||||
description={FIELD_HINTS.caseSensitive}
|
||||
description={t(FIELD_HINTS.caseSensitive)}
|
||||
checked={op.caseSensitive !== false}
|
||||
onChange={(c) => updateField("caseSensitive", c)}
|
||||
size="sm"
|
||||
@@ -399,14 +376,14 @@ function OpEditor({
|
||||
<div className="flex flex-col gap-2">
|
||||
<StringListEditor
|
||||
label={t("routingPrefixes")}
|
||||
hint={FIELD_HINTS.prefixes}
|
||||
hint={t(FIELD_HINTS.prefixes)}
|
||||
items={op.prefixes || []}
|
||||
onChange={(next) => updateField("prefixes", next)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Toggle
|
||||
label={t("routingCaseSensitive")}
|
||||
description={FIELD_HINTS.caseSensitive}
|
||||
description={t(FIELD_HINTS.caseSensitive)}
|
||||
checked={op.caseSensitive !== false}
|
||||
onChange={(c) => updateField("caseSensitive", c)}
|
||||
size="sm"
|
||||
@@ -419,21 +396,21 @@ function OpEditor({
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
label={t("routingMatch")}
|
||||
hint={FIELD_HINTS.matchLiteral}
|
||||
hint={t(FIELD_HINTS.matchLiteral)}
|
||||
value={op.match || ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("match", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t("routingReplacement")}
|
||||
hint={FIELD_HINTS.replacementText}
|
||||
hint={t(FIELD_HINTS.replacementText)}
|
||||
value={op.replacement || ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("replacement", e.target.value)}
|
||||
/>
|
||||
<Toggle
|
||||
label={t("routingReplaceAllOccurrences")}
|
||||
description={FIELD_HINTS.allOccurrences}
|
||||
description={t(FIELD_HINTS.allOccurrences)}
|
||||
checked={op.allOccurrences !== false}
|
||||
onChange={(c) => updateField("allOccurrences", c)}
|
||||
size="sm"
|
||||
@@ -446,21 +423,21 @@ function OpEditor({
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
label={t("routingPatternRegex")}
|
||||
hint={FIELD_HINTS.pattern}
|
||||
hint={t(FIELD_HINTS.pattern)}
|
||||
value={op.pattern || ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("pattern", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t("routingFlags")}
|
||||
hint={FIELD_HINTS.regexFlags}
|
||||
hint={t(FIELD_HINTS.regexFlags)}
|
||||
value={op.flags || "g"}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("flags", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label={t("routingReplacement")}
|
||||
hint={FIELD_HINTS.replacementText}
|
||||
hint={t(FIELD_HINTS.replacementText)}
|
||||
value={op.replacement || ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("replacement", e.target.value)}
|
||||
@@ -471,7 +448,7 @@ function OpEditor({
|
||||
return wrap(
|
||||
<StringListEditor
|
||||
label={t("routingNeedles")}
|
||||
hint={FIELD_HINTS.needles}
|
||||
hint={t(FIELD_HINTS.needles)}
|
||||
items={op.needles || []}
|
||||
onChange={(next) => updateField("needles", next)}
|
||||
disabled={disabled}
|
||||
@@ -490,11 +467,11 @@ function OpEditor({
|
||||
onChange={(e) => updateField("text", e.target.value)}
|
||||
className="w-full rounded-md border border-black/10 dark:border-white/10 bg-white dark:bg-white/5 px-3 py-2 text-sm text-text-main font-mono focus:ring-1 focus:ring-primary/30 focus:border-primary/50 focus:outline-none transition-all shadow-inner disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<p className="text-xs text-text-muted">{FIELD_HINTS.blockText}</p>
|
||||
<p className="text-xs text-text-muted">{t(FIELD_HINTS.blockText)}</p>
|
||||
</div>
|
||||
<Input
|
||||
label={t("routingIdempotencyKey")}
|
||||
hint={FIELD_HINTS.idempotencyKey}
|
||||
hint={t(FIELD_HINTS.idempotencyKey)}
|
||||
value={op.idempotencyKey || ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("idempotencyKey", e.target.value)}
|
||||
@@ -506,14 +483,14 @@ function OpEditor({
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
label={t("routingEntrypoint")}
|
||||
hint={FIELD_HINTS.billingEntrypoint}
|
||||
hint={t(FIELD_HINTS.billingEntrypoint)}
|
||||
value={op.entrypoint || "sdk-cli"}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("entrypoint", e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
label={t("routingVersionFormat")}
|
||||
hint={FIELD_HINTS.billingVersionFormat}
|
||||
hint={t(FIELD_HINTS.billingVersionFormat)}
|
||||
value={op.versionFormat || "ex-machina"}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("versionFormat", e.target.value)}
|
||||
@@ -524,7 +501,7 @@ function OpEditor({
|
||||
/>
|
||||
<Select
|
||||
label={t("routingCchAlgorithm")}
|
||||
hint={FIELD_HINTS.billingCchAlgo}
|
||||
hint={t(FIELD_HINTS.billingCchAlgo)}
|
||||
value={op.cchAlgo || "sha256-first-user"}
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateField("cchAlgo", e.target.value)}
|
||||
@@ -541,14 +518,16 @@ function OpEditor({
|
||||
<div className="flex flex-col gap-2">
|
||||
<StringListEditor
|
||||
label={t("routingWordsToObfuscate")}
|
||||
hint={FIELD_HINTS.obfuscateWords}
|
||||
hint={t(FIELD_HINTS.obfuscateWords)}
|
||||
items={op.words || []}
|
||||
onChange={(next) => updateField("words", next)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-text-main">Targets</span>
|
||||
<p className="text-xs text-text-muted">{FIELD_HINTS.obfuscateTargets}</p>
|
||||
<span className="text-xs font-medium text-text-main">
|
||||
{t("routingObfuscateTargetsLabel")}
|
||||
</span>
|
||||
<p className="text-xs text-text-muted">{t(FIELD_HINTS.obfuscateTargets)}</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{(["system", "messages", "tools"] as const).map((target) => {
|
||||
const targets: string[] = op.targets || ["system", "messages", "tools"];
|
||||
@@ -576,26 +555,53 @@ function OpEditor({
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeTransformOp(op: any): string {
|
||||
function summarizeTransformOp(op: any, t: any): string {
|
||||
switch (op?.kind) {
|
||||
case "drop_paragraph_if_contains":
|
||||
return `drop paragraphs containing: ${(op.needles || []).slice(0, 3).join(", ")}${(op.needles || []).length > 3 ? "…" : ""}`;
|
||||
return t("routingSummarizeDropParagraphContains", {
|
||||
items:
|
||||
(op.needles || []).slice(0, 3).join(", ") + ((op.needles || []).length > 3 ? "…" : ""),
|
||||
});
|
||||
case "drop_paragraph_if_starts_with":
|
||||
return `drop paragraphs starting with: ${(op.prefixes || []).slice(0, 3).join(", ")}${(op.prefixes || []).length > 3 ? "…" : ""}`;
|
||||
return t("routingSummarizeDropParagraphStartsWith", {
|
||||
items:
|
||||
(op.prefixes || []).slice(0, 3).join(", ") + ((op.prefixes || []).length > 3 ? "…" : ""),
|
||||
});
|
||||
case "replace_text":
|
||||
return `replace "${(op.match || "").slice(0, 40)}${(op.match || "").length > 40 ? "…" : ""}" → "${(op.replacement || "").slice(0, 40)}${(op.replacement || "").length > 40 ? "…" : ""}"`;
|
||||
return t("routingSummarizeReplaceText", {
|
||||
match: (op.match || "").slice(0, 40) + ((op.match || "").length > 40 ? "…" : ""),
|
||||
replacement:
|
||||
(op.replacement || "").slice(0, 40) + ((op.replacement || "").length > 40 ? "…" : ""),
|
||||
});
|
||||
case "replace_regex":
|
||||
return `regex /${op.pattern}/${op.flags || ""} → "${(op.replacement || "").slice(0, 40)}"`;
|
||||
return t("routingSummarizeReplaceRegex", {
|
||||
pattern: op.pattern,
|
||||
flags: op.flags || "",
|
||||
replacement: (op.replacement || "").slice(0, 40),
|
||||
});
|
||||
case "drop_block_if_contains":
|
||||
return `drop blocks containing: ${(op.needles || []).slice(0, 3).join(", ")}`;
|
||||
return t("routingSummarizeDropBlockContains", {
|
||||
items: (op.needles || []).slice(0, 3).join(", "),
|
||||
});
|
||||
case "prepend_system_block":
|
||||
return `prepend block: "${(op.text || "").slice(0, 60)}${(op.text || "").length > 60 ? "…" : ""}"`;
|
||||
return t("routingSummarizePrependSystemBlock", {
|
||||
text: (op.text || "").slice(0, 60) + ((op.text || "").length > 60 ? "…" : ""),
|
||||
});
|
||||
case "append_system_block":
|
||||
return `append block: "${(op.text || "").slice(0, 60)}${(op.text || "").length > 60 ? "…" : ""}"`;
|
||||
return t("routingSummarizeAppendSystemBlock", {
|
||||
text: (op.text || "").slice(0, 60) + ((op.text || "").length > 60 ? "…" : ""),
|
||||
});
|
||||
case "inject_billing_header":
|
||||
return `inject billing header (entrypoint=${op.entrypoint}, version=${op.versionFormat}, cch=${op.cchAlgo})`;
|
||||
return t("routingSummarizeInjectBillingHeader", {
|
||||
entrypoint: op.entrypoint,
|
||||
versionFormat: op.versionFormat,
|
||||
cchAlgo: op.cchAlgo,
|
||||
});
|
||||
case "obfuscate_words":
|
||||
return `obfuscate ${(op.words || []).length} word(s) via ZWJ in ${(op.targets || ["system", "messages", "tools"]).join("+")}`;
|
||||
return t("routingSummarizeObfuscateWords", {
|
||||
count: (op.words || []).length,
|
||||
targets: (op.targets || ["system", "messages", "tools"]).join("+"),
|
||||
});
|
||||
default:
|
||||
return JSON.stringify(op);
|
||||
}
|
||||
@@ -658,6 +664,7 @@ export default function RoutingTab() {
|
||||
const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false);
|
||||
const [lkgpCacheStatus, setLkgpCacheStatus] = useState({ type: "", message: "" });
|
||||
const t = useTranslations("settings");
|
||||
const tCommon = useTranslations("common");
|
||||
const notify = useNotificationStore();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -997,10 +1004,7 @@ export default function RoutingTab() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("routingAntigravitySignatureTitle")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Control whether OmniRoute reuses only stored Gemini thought signatures or accepts
|
||||
validated client-provided signatures in Antigravity-compatible tool-call flows.
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">{t("routingAntigravitySignatureDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1008,18 +1012,18 @@ export default function RoutingTab() {
|
||||
{[
|
||||
{
|
||||
value: "enabled",
|
||||
label: "Enabled",
|
||||
desc: "Current behavior. Ignore client-provided signatures and keep using the stored OmniRoute flow.",
|
||||
label: t("routingAntigravitySignatureEnabledLabel"),
|
||||
desc: t("routingAntigravitySignatureEnabledDesc"),
|
||||
},
|
||||
{
|
||||
value: "bypass",
|
||||
label: "Bypass",
|
||||
desc: "Accept client-provided signatures after lightweight validation and fall back to the stored signature when invalid.",
|
||||
label: t("routingAntigravitySignatureBypassLabel"),
|
||||
desc: t("routingAntigravitySignatureBypassDesc"),
|
||||
},
|
||||
{
|
||||
value: "bypass-strict",
|
||||
label: "Bypass Strict",
|
||||
desc: "Require full protobuf validation before accepting a client-provided signature.",
|
||||
label: t("routingAntigravitySignatureBypassStrictLabel"),
|
||||
desc: t("routingAntigravitySignatureBypassStrictDesc"),
|
||||
},
|
||||
].map((option) => (
|
||||
<button
|
||||
@@ -1201,14 +1205,20 @@ export default function RoutingTab() {
|
||||
<span className="text-sm font-medium">{display.name}</span>
|
||||
</div>
|
||||
}
|
||||
subtitle={`${opCount} op${opCount === 1 ? "" : "s"} · ${enabled ? "enabled" : "disabled"}`}
|
||||
subtitle={
|
||||
t("routingOpSummaryCount", { count: opCount }) +
|
||||
t("routingOpStatusSeparator") +
|
||||
(enabled ? t("routingOpEnabled") : t("routingOpDisabled"))
|
||||
}
|
||||
trailing={
|
||||
<>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
onChange={(checked) => toggleProviderEnabled(providerId, checked)}
|
||||
disabled={loading}
|
||||
ariaLabel={`Enable ${display.name} transforms`}
|
||||
ariaLabel={
|
||||
tCommon("enable") + " " + display.name + " " + t("systemTransforms")
|
||||
}
|
||||
/>
|
||||
{!isBuiltin && (
|
||||
<Button
|
||||
@@ -1232,10 +1242,7 @@ export default function RoutingTab() {
|
||||
>
|
||||
<span className="font-medium">{t("routingServerRejectedSave")}</span>{" "}
|
||||
<span className="break-words font-mono">{providerSaveErrors[providerId]}</span>
|
||||
<p className="mt-1 text-[11px] text-red-200/80">
|
||||
Your local edits are kept. Fix the field above and the next change will
|
||||
re-save.
|
||||
</p>
|
||||
<p className="mt-1 text-[11px] text-red-200/80">{tCommon("error")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1252,9 +1259,12 @@ export default function RoutingTab() {
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-purple-500/10 text-[10px] font-semibold text-purple-400">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="font-mono text-purple-300 text-xs">{op?.kind}</span>
|
||||
<span className="font-mono text-purple-300 text-xs">
|
||||
{t(OP_KIND_LABELS[op?.kind as TransformOpKind] || op?.kind)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
subtitle={summarizeTransformOp(op, t)}
|
||||
trailing={
|
||||
<>
|
||||
<Button
|
||||
@@ -1313,7 +1323,7 @@ export default function RoutingTab() {
|
||||
disabled={loading}
|
||||
options={(Object.keys(OP_KIND_LABELS) as TransformOpKind[]).map((kind) => ({
|
||||
value: kind,
|
||||
label: OP_KIND_LABELS[kind],
|
||||
label: t(OP_KIND_LABELS[kind]),
|
||||
}))}
|
||||
/>
|
||||
<Button
|
||||
@@ -1323,7 +1333,7 @@ export default function RoutingTab() {
|
||||
size="sm"
|
||||
icon="add"
|
||||
>
|
||||
Add op
|
||||
{tCommon("add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1336,12 +1346,14 @@ export default function RoutingTab() {
|
||||
}
|
||||
className="text-[11px] text-primary hover:underline"
|
||||
>
|
||||
{isJsonOpen ? "▾ Hide JSON editor" : "▸ Import / export JSON"}
|
||||
{isJsonOpen
|
||||
? "▾ " + tCommon("hide") + " JSON editor"
|
||||
: "▸ Import / export JSON"}
|
||||
</button>
|
||||
{isJsonOpen && (
|
||||
<div className="mt-2">
|
||||
<label className="text-[11px] font-medium text-text-muted block mb-1">
|
||||
JSON (edit & Apply, or paste to import)
|
||||
JSON ({tCommon("edit")} & Apply, or paste to import)
|
||||
</label>
|
||||
<textarea
|
||||
value={draft}
|
||||
@@ -1374,7 +1386,7 @@ export default function RoutingTab() {
|
||||
size="sm"
|
||||
icon="restart_alt"
|
||||
>
|
||||
Reset to defaults
|
||||
{tCommon("reset")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1401,9 +1413,7 @@ export default function RoutingTab() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("routingClientCacheControlTitle")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Configure whether OmniRoute preserves client-provided cache_control markers
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">{t("routingClientCacheControlDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1411,18 +1421,18 @@ export default function RoutingTab() {
|
||||
{[
|
||||
{
|
||||
value: "auto",
|
||||
label: "Auto (Recommended)",
|
||||
desc: "For deterministic Claude-compatible flows, preserve client-provided cache_control as-is. If the request has no cache_control, OmniRoute does not inject any bridge-owned markers for CC-compatible third-party proxy compatibility.",
|
||||
label: tCommon("auto") + " (" + tCommon("recommended") + ")",
|
||||
desc: t("routingClientCacheControlAutoDesc"),
|
||||
},
|
||||
{
|
||||
value: "always",
|
||||
label: "Always Preserve",
|
||||
desc: "Always forward client-provided cache_control headers to upstream providers as-is.",
|
||||
label: t("routingClientCacheControlAlwaysLabel"),
|
||||
desc: t("routingClientCacheControlAlwaysDesc"),
|
||||
},
|
||||
{
|
||||
value: "never",
|
||||
label: "Never Preserve",
|
||||
desc: "Always remove client cache_control headers and let OmniRoute manage caching where native provider flows support it.",
|
||||
label: t("routingClientCacheControlNeverLabel"),
|
||||
desc: t("routingClientCacheControlNeverDesc"),
|
||||
},
|
||||
].map((option) => (
|
||||
<button
|
||||
@@ -1469,11 +1479,7 @@ export default function RoutingTab() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("routingZeroConfigTitle")}</h3>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Enable automatic provider selection using the auto/ prefix. When enabled, requests
|
||||
to auto, auto/coding, auto/fast, etc. will dynamically route across all connected
|
||||
providers.
|
||||
</p>
|
||||
<p className="text-sm text-text-muted mt-1">{t("routingZeroConfigDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
@@ -1493,12 +1499,36 @@ export default function RoutingTab() {
|
||||
<label className="block text-sm font-medium mb-2">{t("routingDefaultAutoVariant")}</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{[
|
||||
{ value: "lkgp", label: "LKGP", desc: "Last Known Good Provider" },
|
||||
{ value: "coding", label: "Coding", desc: "Quality-first for code" },
|
||||
{ value: "fast", label: "Fast", desc: "Low-latency routing" },
|
||||
{ value: "cheap", label: "Cheap", desc: "Cost-optimized" },
|
||||
{ value: "offline", label: "Offline", desc: "High availability" },
|
||||
{ value: "smart", label: "Smart", desc: "Best discovery (10% explore)" },
|
||||
{
|
||||
value: "lkgp",
|
||||
label: t("routingDefaultAutoVariantLKGP"),
|
||||
desc: t("routingDefaultAutoVariantLKGPDesc"),
|
||||
},
|
||||
{
|
||||
value: "coding",
|
||||
label: t("routingDefaultAutoVariantCoding"),
|
||||
desc: t("routingDefaultAutoVariantCodingDesc"),
|
||||
},
|
||||
{
|
||||
value: "fast",
|
||||
label: t("routingDefaultAutoVariantFast"),
|
||||
desc: t("routingDefaultAutoVariantFastDesc"),
|
||||
},
|
||||
{
|
||||
value: "cheap",
|
||||
label: t("routingDefaultAutoVariantCheap"),
|
||||
desc: t("routingDefaultAutoVariantCheapDesc"),
|
||||
},
|
||||
{
|
||||
value: "offline",
|
||||
label: t("routingDefaultAutoVariantOffline"),
|
||||
desc: t("routingDefaultAutoVariantOfflineDesc"),
|
||||
},
|
||||
{
|
||||
value: "smart",
|
||||
label: t("routingDefaultAutoVariantSmart"),
|
||||
desc: t("routingDefaultAutoVariantSmartDesc"),
|
||||
},
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Card, Button, Input, Toggle } from "@/shared/components";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
import IPFilterSection from "./IPFilterSection";
|
||||
import SessionInfoCard from "./SessionInfoCard";
|
||||
import AuthzSection from "./AuthzSection";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function SecurityTab() {
|
||||
@@ -274,6 +275,7 @@ export default function SecurityTab() {
|
||||
|
||||
<SessionInfoCard />
|
||||
<IPFilterSection />
|
||||
<AuthzSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,20 +66,15 @@ export default function VisionBridgeSettingsTab() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t("visionBridge")}</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Run an automatic vision-to-text fallback before routing image requests to text-only
|
||||
models.
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">{t("visionBridgeDesc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-medium">Enabled</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
Toggle the pre-call bridge that replaces image parts with extracted text.
|
||||
</p>
|
||||
<p className="font-medium">{t("visionBridgeEnabledLabel")}</p>
|
||||
<p className="text-sm text-text-muted">{t("visionBridgeEnabledDesc")}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={settings.visionBridgeEnabled}
|
||||
@@ -101,9 +96,7 @@ export default function VisionBridgeSettingsTab() {
|
||||
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"
|
||||
placeholder={t("visionBridgeModelPlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Any OmniRoute model ID that supports vision can be used here.
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">{t("visionBridgeModelHint")}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -119,10 +112,7 @@ export default function VisionBridgeSettingsTab() {
|
||||
className="min-h-[100px] w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm"
|
||||
placeholder={t("visionBridgePromptPlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Sent to the vision model before the extracted description is injected back into the
|
||||
original request.
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-1">{t("visionBridgePromptHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
@@ -75,6 +75,7 @@ export default function SkillsPage() {
|
||||
const [shInstallingId, setShInstallingId] = useState<string | null>(null);
|
||||
const [skillsProvider, setSkillsProvider] = useState<SkillsProvider>("skillsmp");
|
||||
const t = useTranslations("skills");
|
||||
const commonT = useTranslations("common");
|
||||
|
||||
const fetchSkills = async (page: number) => {
|
||||
const params = new URLSearchParams({ page: String(page), limit: "20" });
|
||||
@@ -168,19 +169,19 @@ export default function SkillsPage() {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
setInstallStatus({ type: "success", message: `Skill installed (${data.id})` });
|
||||
setInstallStatus({ type: "success", message: t("installSuccess", { id: data.id }) });
|
||||
setInstallJson("");
|
||||
await refreshSkills();
|
||||
} else {
|
||||
setInstallStatus({
|
||||
type: "error",
|
||||
message: data.error || data.message || "Install failed",
|
||||
message: data.error || data.message || t("installError"),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setInstallStatus({
|
||||
type: "error",
|
||||
message: err instanceof Error ? err.message : "Invalid JSON",
|
||||
message: err instanceof Error ? err.message : t("invalidJson"),
|
||||
});
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
@@ -205,12 +206,12 @@ export default function SkillsPage() {
|
||||
const res = await fetch(`/api/skills/marketplace?q=${encodeURIComponent(mpQuery)}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setMpError(data.error || "Search failed");
|
||||
setMpError(data.error || t("marketplaceError"));
|
||||
} else {
|
||||
setMpResults(Array.isArray(data) ? data : data.skills || []);
|
||||
}
|
||||
} catch (err) {
|
||||
setMpError(err instanceof Error ? err.message : "Search failed");
|
||||
setMpError(err instanceof Error ? err.message : t("marketplaceError"));
|
||||
} finally {
|
||||
setMpLoading(false);
|
||||
}
|
||||
@@ -241,11 +242,11 @@ export default function SkillsPage() {
|
||||
await refreshSkills();
|
||||
setMpInstallingId(null);
|
||||
} else {
|
||||
setMpError(data.error || "Install failed");
|
||||
setMpError(data.error || t("installError"));
|
||||
setMpInstallingId(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setMpError(err instanceof Error ? err.message : "Install failed");
|
||||
setMpError(err instanceof Error ? err.message : t("installError"));
|
||||
setMpInstallingId(null);
|
||||
}
|
||||
};
|
||||
@@ -258,12 +259,12 @@ export default function SkillsPage() {
|
||||
const res = await fetch(`/api/skills/skillssh?q=${encodeURIComponent(shQuery)}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setShError(data.error || "Search failed");
|
||||
setShError(data.error || t("marketplaceError"));
|
||||
} else {
|
||||
setShResults(data.skills || []);
|
||||
}
|
||||
} catch (err) {
|
||||
setShError(err instanceof Error ? err.message : "Search failed");
|
||||
setShError(err instanceof Error ? err.message : t("marketplaceError"));
|
||||
} finally {
|
||||
setShLoading(false);
|
||||
}
|
||||
@@ -293,11 +294,11 @@ export default function SkillsPage() {
|
||||
await refreshSkills();
|
||||
setShInstallingId(null);
|
||||
} else {
|
||||
setShError(data.error || "Install failed");
|
||||
setShError(data.error || t("installError"));
|
||||
setShInstallingId(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setShError(err instanceof Error ? err.message : "Install failed");
|
||||
setShError(err instanceof Error ? err.message : t("installError"));
|
||||
setShInstallingId(null);
|
||||
}
|
||||
};
|
||||
@@ -310,14 +311,41 @@ export default function SkillsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stats computation ────────────────────────────────────────────────────
|
||||
|
||||
const enabledCount = skills.filter((s) => s.enabled).length;
|
||||
const execSuccessCount = executions.filter((e) => e.status === "success").length;
|
||||
const successRate =
|
||||
executions.length > 0 ? Math.round((execSuccessCount / executions.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* ── Stats Cards ─────────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<p className="text-xs text-text-muted uppercase tracking-wide">{t("totalSkills")}</p>
|
||||
<p className="text-2xl font-bold text-text-main mt-1">{skillsTotal}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<p className="text-xs text-text-muted uppercase tracking-wide">{t("enabledSkills")}</p>
|
||||
<p className="text-2xl font-bold text-emerald-400 mt-1">{enabledCount}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<p className="text-xs text-text-muted uppercase tracking-wide">{t("totalExecutions")}</p>
|
||||
<p className="text-2xl font-bold text-violet-400 mt-1">{execTotal}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<p className="text-xs text-text-muted uppercase tracking-wide">{t("successRate")}</p>
|
||||
<p className="text-2xl font-bold text-amber-400 mt-1">{successRate}%</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowInstallModal(true)}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 transition-colors"
|
||||
>
|
||||
Install Skill
|
||||
{t("installSkillButton")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -360,7 +388,7 @@ export default function SkillsPage() {
|
||||
: "border-transparent text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
Marketplace
|
||||
{t("marketplaceTab")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -381,9 +409,9 @@ export default function SkillsPage() {
|
||||
className="px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
>
|
||||
<option value="all">{t("allModes")}</option>
|
||||
<option value="on">On</option>
|
||||
<option value="auto">Auto</option>
|
||||
<option value="off">Off</option>
|
||||
<option value="on">{t("onMode")}</option>
|
||||
<option value="auto">{t("autoMode")}</option>
|
||||
<option value="off">{t("offMode")}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -392,15 +420,13 @@ export default function SkillsPage() {
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 transition-colors"
|
||||
>
|
||||
Apply filters
|
||||
{t("applyFilters")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{popularDefaults.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-text-muted mb-2">
|
||||
Popular by default for selected provider:
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mb-2">{t("popularDefaultsLabel")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{popularDefaults.map((name) => (
|
||||
<span
|
||||
@@ -433,7 +459,7 @@ export default function SkillsPage() {
|
||||
{(skill.sourceProvider || "local").toUpperCase()}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-amber-500/10 text-amber-400">
|
||||
mode: {skill.mode || (skill.enabled ? "on" : "off")}
|
||||
{t("mode")}: {skill.mode || (skill.enabled ? "on" : "off")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mt-1">{skill.description}</p>
|
||||
@@ -460,7 +486,7 @@ export default function SkillsPage() {
|
||||
: "border-border text-text-muted"
|
||||
}`}
|
||||
>
|
||||
ON
|
||||
{t("onMode")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSkillMode(skill.id, "auto")}
|
||||
@@ -470,7 +496,7 @@ export default function SkillsPage() {
|
||||
: "border-border text-text-muted"
|
||||
}`}
|
||||
>
|
||||
AUTO
|
||||
{t("autoMode")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSkillMode(skill.id, "off")}
|
||||
@@ -480,14 +506,14 @@ export default function SkillsPage() {
|
||||
: "border-border text-text-muted"
|
||||
}`}
|
||||
>
|
||||
OFF
|
||||
{t("offMode")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteSkill(skill.id)}
|
||||
className="text-xs px-2 py-1 rounded text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
Uninstall
|
||||
{t("delete")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleSkill(skill.id, skill.enabled)}
|
||||
@@ -510,7 +536,11 @@ export default function SkillsPage() {
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border">
|
||||
<span className="text-sm text-text-muted">
|
||||
Page {skillsPage} of {skillsTotalPages} ({skillsTotal} total)
|
||||
{t("pageInfo", {
|
||||
page: skillsPage,
|
||||
totalPages: skillsTotalPages,
|
||||
total: skillsTotal,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -522,7 +552,7 @@ export default function SkillsPage() {
|
||||
disabled={skillsPage === 1}
|
||||
className="px-3 py-1 text-sm rounded border border-border text-text-muted hover:text-text-main disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Prev
|
||||
{t("previous")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -533,7 +563,7 @@ export default function SkillsPage() {
|
||||
disabled={skillsPage === skillsTotalPages || skillsTotalPages === 0}
|
||||
className="px-3 py-1 text-sm rounded border border-border text-text-muted hover:text-text-main disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Next
|
||||
{t("next")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -588,7 +618,8 @@ export default function SkillsPage() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border">
|
||||
<span className="text-sm text-text-muted">
|
||||
Page {execPage} of {execTotalPages} ({execTotal} total)
|
||||
{t("pageInfo", { page: execPage, totalPages: execTotalPages, total: execTotal }) ||
|
||||
`Page ${execPage} of ${execTotalPages} (${execTotal} total)`}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -600,7 +631,7 @@ export default function SkillsPage() {
|
||||
disabled={execPage === 1}
|
||||
className="px-3 py-1 text-sm rounded border border-border text-text-muted hover:text-text-main disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Prev
|
||||
{t("previous") || "Prev"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -611,7 +642,7 @@ export default function SkillsPage() {
|
||||
disabled={execPage === execTotalPages || execTotalPages === 0}
|
||||
className="px-3 py-1 text-sm rounded border border-border text-text-muted hover:text-text-main disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Next
|
||||
{t("next") || "Next"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -661,11 +692,11 @@ export default function SkillsPage() {
|
||||
<Card>
|
||||
<h3 className="font-semibold mb-2">{t("skillsMarketplace")}</h3>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Active provider:{" "}
|
||||
{t("activeProvider")}{" "}
|
||||
<span className="font-medium">
|
||||
{skillsProvider === "skillsmp" ? "SkillsMP" : "skills.sh"}
|
||||
</span>
|
||||
. Change this in Settings → Memory & Skills.
|
||||
. {t("changeInSettings")}
|
||||
</p>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
@@ -680,9 +711,7 @@ export default function SkillsPage() {
|
||||
e.key === "Enter" &&
|
||||
(skillsProvider === "skillsmp" ? searchMarketplace() : searchSkillsSh())
|
||||
}
|
||||
placeholder={
|
||||
skillsProvider === "skillsmp" ? "Search SkillsMP..." : "Search skills.sh..."
|
||||
}
|
||||
placeholder={t("searchMarketplacePlaceholder")}
|
||||
className="flex-1 px-3 py-2 rounded-lg bg-background border border-border text-sm focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
<button
|
||||
@@ -694,11 +723,11 @@ export default function SkillsPage() {
|
||||
>
|
||||
{skillsProvider === "skillsmp"
|
||||
? mpLoading
|
||||
? "Searching..."
|
||||
: "Search SkillsMP"
|
||||
? t("searching")
|
||||
: t("searchMarketplace")
|
||||
: shLoading
|
||||
? "Searching..."
|
||||
: "Search skills.sh"}
|
||||
? t("searching")
|
||||
: t("searchMarketplace")}
|
||||
</button>
|
||||
</div>
|
||||
{(skillsProvider === "skillsmp" ? mpError : shError) && (
|
||||
@@ -722,7 +751,7 @@ export default function SkillsPage() {
|
||||
disabled={mpInstallingId === skill.name}
|
||||
className="px-4 py-1.5 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{mpInstallingId === skill.name ? "Installing..." : "Install"}
|
||||
{mpInstallingId === skill.name ? t("installing") : t("installSkillButton")}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -738,7 +767,7 @@ export default function SkillsPage() {
|
||||
<div>
|
||||
<h4 className="font-semibold">{skill.name}</h4>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
{skill.source} · {skill.installs.toLocaleString()} installs
|
||||
{skill.source} · {skill.installs.toLocaleString()} {t("installs")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -746,7 +775,7 @@ export default function SkillsPage() {
|
||||
disabled={shInstallingId === skill.id}
|
||||
className="px-4 py-1.5 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{shInstallingId === skill.id ? "Installing..." : "Install"}
|
||||
{shInstallingId === skill.id ? t("installing") : t("installSkillButton")}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -756,16 +785,12 @@ export default function SkillsPage() {
|
||||
|
||||
{skillsProvider === "skillsmp" && !mpLoading && mpResults.length === 0 && !mpError && (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-text-muted">
|
||||
Configure your SkillsMP API key in Settings to browse the marketplace.
|
||||
</div>
|
||||
<div className="text-center py-8 text-text-muted">{t("marketplaceSkillsMpHint")}</div>
|
||||
</Card>
|
||||
)}
|
||||
{skillsProvider === "skillssh" && !shLoading && shResults.length === 0 && !shError && (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-text-muted">
|
||||
Search the skills.sh open directory to discover and install agent skills.
|
||||
</div>
|
||||
<div className="text-center py-8 text-text-muted">{t("marketplaceSkillsShHint")}</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
@@ -775,7 +800,7 @@ export default function SkillsPage() {
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-surface border border-border rounded-xl p-6 w-full max-w-lg mx-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">{t("installSkill")}</h2>
|
||||
<h2 className="text-lg font-semibold">{t("installSkillModalTitle")}</h2>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowInstallModal(false);
|
||||
@@ -787,13 +812,11 @@ export default function SkillsPage() {
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Paste a skill manifest JSON or upload a .json file.
|
||||
</p>
|
||||
<p className="text-sm text-text-muted mb-4">{t("installSkillModalDesc")}</p>
|
||||
<textarea
|
||||
value={installJson}
|
||||
onChange={(e) => setInstallJson(e.target.value)}
|
||||
placeholder='{"name": "my-skill", "version": "1.0.0", "description": "...", "schema": {"input": {}, "output": {}}, "handlerCode": "..."}'
|
||||
placeholder={t("installJsonPlaceholder")}
|
||||
className="w-full h-48 p-3 rounded-lg bg-background border border-border text-sm font-mono resize-none focus:outline-none focus:ring-1 focus:ring-violet-500"
|
||||
/>
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
@@ -808,7 +831,7 @@ export default function SkillsPage() {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-border text-text-muted hover:text-text-main transition-colors"
|
||||
>
|
||||
Upload JSON
|
||||
{t("uploadJson")}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
@@ -819,14 +842,14 @@ export default function SkillsPage() {
|
||||
}}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-border text-text-muted hover:text-text-main transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={installing || !installJson.trim()}
|
||||
className="px-4 py-1.5 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{installing ? "Installing..." : "Install"}
|
||||
{installing ? t("installing") : t("installSkillButton")}
|
||||
</button>
|
||||
</div>
|
||||
{installStatus && (
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
"none": "None",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"on": "ON",
|
||||
"off": "OFF",
|
||||
"warning": "Warning",
|
||||
"note": "Note",
|
||||
"free": "Free",
|
||||
@@ -705,7 +707,9 @@
|
||||
"batchFileDetailFailedToLoad": "Failed to load file contents",
|
||||
"batchFilesListSearchPlaceholder": "Search by ID or filename…",
|
||||
"batchFilesListFilesTable": "Files",
|
||||
"batchPageLoadingMore": "Loading more…"
|
||||
"batchPageLoadingMore": "Loading more…",
|
||||
"auto": "Auto",
|
||||
"recommended": "Recommended"
|
||||
},
|
||||
"sidebar": {
|
||||
"home": "Home",
|
||||
@@ -842,6 +846,7 @@
|
||||
"settingsAi": "AI Settings",
|
||||
"settingsSecurity": "Security",
|
||||
"settingsFeatureFlags": "Feature Flags",
|
||||
"settingsAuthz": "Authz",
|
||||
"settingsRouting": "Routing",
|
||||
"settingsResilience": "Resilience",
|
||||
"settingsAdvanced": "Advanced",
|
||||
@@ -875,6 +880,12 @@
|
||||
"proxySubtitle": "HTTP proxy settings",
|
||||
"mitmProxySubtitle": "MITM interception",
|
||||
"oneProxySubtitle": "Public proxy gateway",
|
||||
"leaderboard": "Leaderboard",
|
||||
"profile": "Profile",
|
||||
"tokens": "Tokens",
|
||||
"leaderboardSubtitle": "Gamification rankings",
|
||||
"profileSubtitle": "User achievements",
|
||||
"tokensSubtitle": "Manage token balance",
|
||||
"usageSubtitle": "Traffic and usage stats",
|
||||
"analyticsComboHealthSubtitle": "Combo target reliability",
|
||||
"analyticsUtilizationSubtitle": "Provider utilization",
|
||||
@@ -913,6 +924,7 @@
|
||||
"settingsAdvancedSubtitle": "Power user options",
|
||||
"settingsSecuritySubtitle": "Auth and encryption",
|
||||
"settingsFeatureFlagsSubtitle": "Toggle system capabilities",
|
||||
"settingsAuthzSubtitle": "Route inventory and bypass policy",
|
||||
"docsSubtitle": "Documentation",
|
||||
"issuesSubtitle": "Report a bug",
|
||||
"changelogSubtitle": "Release notes"
|
||||
@@ -1183,6 +1195,81 @@
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytics",
|
||||
"usageAnalyticsTitle": "Usage Analytics",
|
||||
"diversityScoreTitle": "Diversity Score",
|
||||
"diversityScoreDesc": "Provider concentration snapshot for the recent traffic window.",
|
||||
"diversityShannonEntropy": "Shannon entropy",
|
||||
"diversityWindow": "Window: {count} reqs · Last {mins} mins",
|
||||
"diversityHealthy": "Healthy Distribution",
|
||||
"diversityRiskHigh": "High Vendor Lock-in Risk",
|
||||
"diversityRiskModerate": "Moderate Distribution",
|
||||
"diversityScoreLabel": "score",
|
||||
"diversityHigherExplanation": "Higher values mean traffic is spread across multiple providers.",
|
||||
"diversityNoData": "No recent usage data available.",
|
||||
"chartRequests": "Requests",
|
||||
"chartInput": "Input",
|
||||
"chartOutput": "Output",
|
||||
"chartTotal": "Total",
|
||||
"chartCost": "Cost",
|
||||
"chartShare": "Share",
|
||||
"chartServiceTier": "Service Tier",
|
||||
"chartServiceTierSplit": "Fast / Standard cost split",
|
||||
"chartCostPct": "{pct}% of cost",
|
||||
"chartUsageDetail": "Usage Detail",
|
||||
"chartCacheRead": "Cache read",
|
||||
"chartCostByProvider": "Cost by Provider",
|
||||
"chartNoCostData": "No cost data",
|
||||
"chartModelUsageOverTime": "Model Usage Over Time",
|
||||
"chartNoData": "No data",
|
||||
"chartWeekly": "Weekly",
|
||||
"chartModelBreakdown": "Model Breakdown",
|
||||
"chartModel": "Model",
|
||||
"chartProvider": "Provider",
|
||||
"chartProviderBreakdown": "Provider Breakdown",
|
||||
"filterAllKeys": "All Keys",
|
||||
"filterSearchKeys": "Search keys…",
|
||||
"filterNoKeysMatch": "No keys match",
|
||||
"filterOneKey": "1 key",
|
||||
"filterMultipleKeys": "{count} keys",
|
||||
"rangeToday": "Today",
|
||||
"rangeYesterday": "Yesterday",
|
||||
"rangeLast3Days": "Last 3 days",
|
||||
"rangeThisWeek": "This week",
|
||||
"rangeLast14Days": "Last 14 days",
|
||||
"rangeThisMonth": "This month",
|
||||
"rangeQuickSelect": "Quick Select",
|
||||
"rangeStart": "Start",
|
||||
"rangeEnd": "End",
|
||||
"rangeCancel": "Cancel",
|
||||
"rangeApply": "Apply",
|
||||
"rangeErrorInvalid": "Start must be before end",
|
||||
"period1D": "1D",
|
||||
"period7D": "7D",
|
||||
"period30D": "30D",
|
||||
"period90D": "90D",
|
||||
"periodYTD": "YTD",
|
||||
"periodAll": "All",
|
||||
"totalTokens": "Total Tokens",
|
||||
"inputTokens": "Input Tokens",
|
||||
"outputTokens": "Output Tokens",
|
||||
"estCost": "Est. Cost",
|
||||
"infraTitle": "Infrastructure",
|
||||
"infraAccounts": "Accounts",
|
||||
"infraProviders": "Providers",
|
||||
"infraApiKeys": "API Keys",
|
||||
"infraModels": "Models",
|
||||
"perfTitle": "Performance",
|
||||
"perfAvgTokens": "Avg Tokens/Req",
|
||||
"perfCostReq": "Cost/Req",
|
||||
"perfIoRatio": "I/O Ratio",
|
||||
"perfFastReq": "Fast Requests",
|
||||
"highlightsTitle": "Highlights",
|
||||
"highlightsTopModel": "Top Model",
|
||||
"highlightsTopProvider": "Top Provider",
|
||||
"highlightsBusiestDay": "Busiest Day",
|
||||
"highlightsDiversity": "Diversity",
|
||||
"highlightsFallbackRate": "Fallback Rate",
|
||||
"customRange": "Custom",
|
||||
"overviewDescription": "Monitor your API usage patterns, token consumption, costs, and activity trends across all providers and models.",
|
||||
"evalsDescription": "Run evaluation suites to test and validate your LLM endpoints. Compare model quality, detect regressions, and benchmark latency.",
|
||||
"overview": "Overview",
|
||||
@@ -1288,6 +1375,7 @@
|
||||
"keyName": "Key Name",
|
||||
"keyNamePlaceholder": "e.g. Production Key",
|
||||
"keyNameDesc": "Choose a descriptive name to identify this key's purpose",
|
||||
"managementAccessDesc": "Allow this API key to manage OmniRoute configuration.",
|
||||
"keyCreated": "API Key Created",
|
||||
"keyCreatedSuccess": "Key created successfully!",
|
||||
"keyCreatedNote": "Copy and store this key now — it won't be shown again.",
|
||||
@@ -1625,6 +1713,7 @@
|
||||
"amp": "Use when you want Amp shorthand workflows but still need OmniRoute alias and routing rules enforcement.",
|
||||
"qwen": "Use when you need Alibaba Qwen Code CLI for coding tasks.",
|
||||
"hermes": "Use when you need a lightweight terminal-native AI assistant for quick tasks.",
|
||||
"hermes-agent": "Use when you need Hermes Agent (by Nousresearch) with default, delegation, vision and auxiliary models routed through OmniRoute.",
|
||||
"custom": "Use for custom tool implementations or generic OpenAI-compatible configurations."
|
||||
},
|
||||
"toolDescriptions": {
|
||||
@@ -1644,6 +1733,7 @@
|
||||
"qwen": "Alibaba Qwen Code CLI",
|
||||
"amp": "Sourcegraph Amp coding assistant CLI",
|
||||
"hermes": "Hermes AI Terminal Assistant",
|
||||
"hermes-agent": "Hermes Agent (by Nousresearch) - Advanced terminal AI with multi-model support (delegation, vision, compression, etc.)",
|
||||
"custom": "Generic OpenAI-compatible CLI or SDK configuration generator"
|
||||
},
|
||||
"guides": {
|
||||
@@ -2030,6 +2120,17 @@
|
||||
"recommendationsApplied": "Recommendations applied to this combo.",
|
||||
"intelligentPanelTitle": "Intelligent Routing Dashboard",
|
||||
"intelligentPanelDesc": "Real-time scoring and health status for this auto-routing combo.",
|
||||
"configOnlyStatus": "Configuration View",
|
||||
"configOnlyHint": "This panel shows routing inputs only. Live breaker state is available on the Health page.",
|
||||
"routingInputs": "Routing Inputs",
|
||||
"routingInputsHint": "Mode pack and weighting stay here; breaker runtime state stays on the Health page.",
|
||||
"emailVisibilityHint": "Account emails here follow the global privacy toggle.",
|
||||
"emailVisibilityTooltip": "Use the eye icon to reveal or hide account emails globally across combos, providers and quota screens.",
|
||||
"manualModel": "Manual model",
|
||||
"manualModelInvalid": "Enter a model as provider/model.",
|
||||
"manualModelUnknownProvider": "Unknown provider prefix.",
|
||||
"builderDynamicAccountShort": "Dynamic account",
|
||||
"builderNeedValidName": "Define a valid combo name before continuing.",
|
||||
"statusOverview": "Status Overview",
|
||||
"normalOperation": "Normal Operation",
|
||||
"allProvidersHealthy": "Providers are reporting healthy routing conditions.",
|
||||
@@ -2339,7 +2440,8 @@
|
||||
"previousPeriod": "Previous Half",
|
||||
"currentPeriod": "Current Half",
|
||||
"exportCSV": "Export as CSV",
|
||||
"exportJSON": "Export as JSON"
|
||||
"exportJSON": "Export as JSON",
|
||||
"legacyFreeLabel": "Legacy / Free"
|
||||
},
|
||||
"endpoint": {
|
||||
"title": "API Endpoint",
|
||||
@@ -2572,6 +2674,20 @@
|
||||
"processStatus": "Process status",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"disableLabel": "Disable {label}",
|
||||
"enableLabel": "Enable {label}",
|
||||
"transportMode": "Transport Mode",
|
||||
"transportStdioDesc": "Local — IDE spawns process via omniroute --mcp",
|
||||
"transportSseDesc": "Remote — Server-Sent Events over HTTP",
|
||||
"transportStreamableHttpDesc": "Remote — Modern bidirectional HTTP",
|
||||
"copy": "Copy",
|
||||
"mcpDashboardCopyUrl": "Copy URL to clipboard",
|
||||
"mcpDisabledTitle": "MCP is disabled",
|
||||
"mcpDisabledDesc": "Enable MCP above to configure transport mode and view server telemetry.",
|
||||
"mcpIntro": "Model Context Protocol — {tools} tools across {scopes} scopes, {transports} transports (stdio / SSE / Streamable HTTP).",
|
||||
"mcpStep1": "Run via {code}",
|
||||
"mcpStep2": "Configure your MCP client to connect over stdio transport.",
|
||||
"mcpStep3": "Invoke tools like {code1} and {code2}.",
|
||||
"pid": "PID",
|
||||
"sessionUptime": "Session uptime",
|
||||
"lastHeartbeat": "Last heartbeat",
|
||||
@@ -2654,6 +2770,7 @@
|
||||
"cancelled": "cancelled"
|
||||
},
|
||||
"agentCard": "Agent card",
|
||||
"agentCardPath": "/.well-known/agent.json",
|
||||
"version": "Version",
|
||||
"url": "URL",
|
||||
"capabilities": "Capabilities",
|
||||
@@ -2691,7 +2808,17 @@
|
||||
"rpcMethodStream": "message/stream",
|
||||
"rpcMethodGet": "tasks/get",
|
||||
"rpcMethodCancel": "tasks/cancel",
|
||||
"serviceLabel": "A2A"
|
||||
"serviceLabel": "A2A",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"disableLabel": "Disable {label}",
|
||||
"enableLabel": "Enable {label}",
|
||||
"a2aDisabledTitle": "A2A is disabled",
|
||||
"a2aDisabledDesc": "Enable A2A above to view task telemetry, agent details, and validation tools.",
|
||||
"a2aIntro": "Agent2Agent JSON-RPC 2.0 endpoint — send tasks, stream responses, cancel in-flight jobs.",
|
||||
"a2aStep1": "Discover the agent card at {code}.",
|
||||
"a2aStep2": "Send JSON-RPC to {code1} using {code2} or {code3}.",
|
||||
"a2aStep3": "Track and cancel tasks with {code1} and {code2}."
|
||||
},
|
||||
"memory": {
|
||||
"title": "Memory Management",
|
||||
@@ -2761,8 +2888,42 @@
|
||||
"q": "Q",
|
||||
"filterSkillsPlaceholder": "Filter skills by name, description, or tag",
|
||||
"allModes": "All modes",
|
||||
"totalSkills": "Total Skills",
|
||||
"enabledSkills": "Enabled",
|
||||
"totalExecutions": "Executions",
|
||||
"successRate": "Success Rate",
|
||||
"marketplaceTab": "Marketplace",
|
||||
"applyFilters": "Apply filters",
|
||||
"popularDefaultsLabel": "Popular by default for selected provider:",
|
||||
"onMode": "ON",
|
||||
"offMode": "OFF",
|
||||
"autoMode": "AUTO",
|
||||
"installSkillButton": "Install Skill",
|
||||
"installSkillModalTitle": "Install Skill",
|
||||
"installJsonPlaceholder": "Paste skill manifest JSON here...",
|
||||
"installing": "Installing...",
|
||||
"installSuccess": "Skill installed ({id})",
|
||||
"installError": "Install failed",
|
||||
"invalidJson": "Invalid JSON",
|
||||
"searchMarketplacePlaceholder": "Search skills...",
|
||||
"searchMarketplace": "Search Marketplace",
|
||||
"marketplaceEmpty": "No skills found in marketplace",
|
||||
"marketplaceError": "Search failed",
|
||||
"installingFromMarketplace": "Installing from marketplace...",
|
||||
"popularSkills": "Popular Skills",
|
||||
"skillsMarketplace": "Skills Marketplace",
|
||||
"installSkill": "Install Skill"
|
||||
"searching": "Searching...",
|
||||
"pageInfo": "Page {page} of {totalPages} ({total} total)",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"activeProvider": "Active provider:",
|
||||
"changeInSettings": "Change this in Settings → Memory & Skills.",
|
||||
"installs": "installs",
|
||||
"marketplaceSkillsMpHint": "Configure your SkillsMP API key in Settings to browse the marketplace.",
|
||||
"marketplaceSkillsShHint": "Search the skills.sh open directory to discover and install agent skills.",
|
||||
"installSkillModalDesc": "Paste a skill manifest JSON or upload a .json file.",
|
||||
"uploadJson": "Upload JSON",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"health": {
|
||||
"title": "System Health",
|
||||
@@ -3622,6 +3783,12 @@
|
||||
"geminiCliProjectIdLabel": "Google Cloud Project ID",
|
||||
"geminiCliProjectIdPlaceholder": "my-gcp-project-id",
|
||||
"antigravityProjectIdHint": "Optional override for Antigravity Cloud Code requests. Leave blank to use the project discovered during Google OAuth.",
|
||||
"antigravityClientProfileLabel": "Client profile",
|
||||
"antigravityClientProfileHint": "Choose which Antigravity client identity OmniRoute presents to the API.",
|
||||
"antigravityClientProfileIde": "IDE",
|
||||
"antigravityClientProfileHarness": "Harness / CLI",
|
||||
"codexFastTierActiveChip": "Codex Fast tier is active",
|
||||
"tierFast": "Fast",
|
||||
"antigravityProjectIdLabel": "Google Cloud Project ID",
|
||||
"antigravityProjectIdPlaceholder": "my-gcp-project-id",
|
||||
"grokWebCookieHint": "Grok Web Cookie Hint",
|
||||
@@ -3729,6 +3896,65 @@
|
||||
"cache": "Cache",
|
||||
"resilience": "Resilience",
|
||||
"routingSettingsIntro": "Controls how your requests are routed, transformed, and sent to AI providers.",
|
||||
"routingOpDropParagraphContainsLabel": "Drop paragraph (contains)",
|
||||
"routingOpDropParagraphStartsWithLabel": "Drop paragraph (starts with)",
|
||||
"routingOpReplaceTextLabel": "Replace text",
|
||||
"routingOpReplaceRegexLabel": "Replace regex",
|
||||
"routingOpDropBlockContainsLabel": "Drop block (contains)",
|
||||
"routingOpPrependSystemBlockLabel": "Prepend system block",
|
||||
"routingOpAppendSystemBlockLabel": "Append system block",
|
||||
"routingOpInjectBillingHeaderLabel": "Inject billing header",
|
||||
"routingOpObfuscateWordsLabel": "Obfuscate words (ZWJ)",
|
||||
"routingOpDropParagraphContainsDesc": "Removes any paragraph (text block split on blank lines) inside the system prompt whose text contains ANY of the listed substrings. Use to strip third-party client fingerprints like 'github.com/anomalyco/opencode' or 'docs.openwebui.com' that Anthropic's classifier flags.",
|
||||
"routingOpDropParagraphStartsWithDesc": "Removes any paragraph that STARTS WITH one of the listed prefixes. Use for identity lines like 'You are OpenCode' or 'You are Open WebUI' that announce the calling client.",
|
||||
"routingOpReplaceTextDesc": "Replaces a literal substring with another literal substring. Use for known trigger phrases — e.g. rewrite 'Here is some useful information about the environment you are running in:' to 'Environment context you are running in:' (an empirically-validated trigger phrase).",
|
||||
"routingOpReplaceRegexDesc": "Replaces text matching a regular expression. Use when you need patterns (character classes, optional whitespace, anchors) instead of literal substrings. A malformed pattern is caught at runtime when the op runs.",
|
||||
"routingOpDropBlockContainsDesc": "Removes ENTIRE system blocks (not just paragraphs) whose text contains any of the listed substrings. Use when a whole block is fingerprint-bearing and you want it gone — e.g. an injected MCP-server description.",
|
||||
"routingOpPrependSystemBlockDesc": "Inserts a new text block at the FRONT of the system array. Use to add the SDK identity 'You are a Claude agent, built on Anthropic's Claude Agent SDK.' that Anthropic's classifier expects.",
|
||||
"routingOpAppendSystemBlockDesc": "Inserts a new text block at the END of the system array. Use for cosmetic additions that don't need to be at position [0].",
|
||||
"routingOpInjectBillingHeaderDesc": "Prepends the special 'x-anthropic-billing-header: cc_version=...; cc_entrypoint=...; cch=...;' text block that Anthropic's classifier validates. Required for CC bridge relay endpoints; for the native claude provider OmniRoute already injects its own billing line so this op is usually redundant there.",
|
||||
"routingOpObfuscateWordsDesc": "Inserts a Zero-Width-Joiner character after the first letter of each listed word, so 'opencode' becomes 'o\u200dpencode'. Reads identical to humans but bypasses classifier word matches. Targets system blocks, user/assistant messages, and tool descriptions.",
|
||||
"routingNeedlesHint": "List of substrings. A paragraph matches if it contains ANY one of them. Add one per line via 'Add entry'.",
|
||||
"routingPrefixesHint": "List of strings. A paragraph matches if it starts with any one of them (leading whitespace is trimmed before matching).",
|
||||
"routingCaseSensitiveHint": "When ON, 'OpenCode' and 'opencode' are different strings. When OFF (default), the comparison ignores case.",
|
||||
"routingMatchLiteralHint": "Exact literal substring to find. No regex syntax — special chars like . * ? are treated as themselves.",
|
||||
"routingReplacementTextHint": "Replacement string. Leave blank to delete the match. The output preserves surrounding text.",
|
||||
"routingAllOccurrencesHint": "When ON (default), every instance is replaced. When OFF, only the first match is replaced.",
|
||||
"routingPatternHint": "JavaScript regex source. Don't wrap in slashes — just 'foo(.*)bar'. Server rejects patterns that fail to compile.",
|
||||
"routingRegexFlagsHint": "JavaScript regex flags (g = all matches, i = case-insensitive, s = dot matches newline, m = multiline). Default 'g'.",
|
||||
"routingBlockTextHint": "Full text of the new system block. Use a literal string; the system block stores text only.",
|
||||
"routingIdempotencyKeyHint": "Optional. If set, the op skips when a block whose text starts with this key is already present. Prevents double-prepend on retries.",
|
||||
"routingBillingEntrypointHint": "Value injected as 'cc_entrypoint='. Anthropic accepts 'sdk-cli' (Agent SDK), 'cli' (Claude Code CLI), or other documented values.",
|
||||
"routingBillingVersionFormatHint": "How the 3-char build hash after cc_version= is computed. 'ex-machina' = sha256 of CCH_SALT+chars-from-first-user-msg+version (per-message). 'omniroute-daystamp' = sha256 of YYYY-MM-DD+version (stable per-day).",
|
||||
"routingBillingCchAlgoHint": "How the 5-char cch= token is computed. 'sha256-first-user' = sha256 of first user message text. 'xxhash64-body' = body-level signing fills it later. 'static-zero' = literal '00000' placeholder.",
|
||||
"routingObfuscateWordsHint": "Lowercase words to obfuscate. ZWJ insertion is applied case-insensitively, so 'opencode' also matches 'OpenCode' and 'OPENCODE'.",
|
||||
"routingObfuscateTargetsHint": "Which body regions to scan for the words: system blocks, user/assistant messages, and/or tool descriptions.",
|
||||
"routingObfuscateTargetsLabel": "Targets",
|
||||
"routingSummarizeDropParagraphContains": "drop paragraphs containing: {items}",
|
||||
"routingSummarizeDropParagraphStartsWith": "drop paragraphs starting with: {items}",
|
||||
"routingSummarizeReplaceText": "replace \"{match}\" → \"{replacement}\"",
|
||||
"routingSummarizeReplaceRegex": "regex /{pattern}/{flags} → \"{replacement}\"",
|
||||
"routingSummarizeDropBlockContains": "drop blocks containing: {items}",
|
||||
"routingSummarizePrependSystemBlock": "prepend block: \"{text}\"",
|
||||
"routingSummarizeAppendSystemBlock": "append block: \"{text}\"",
|
||||
"routingSummarizeInjectBillingHeader": "inject billing header (entrypoint={entrypoint}, version={versionFormat}, cch={cchAlgo})",
|
||||
"routingSummarizeObfuscateWords": "obfuscate {count} word(s) via ZWJ in {targets}",
|
||||
"routingDefaultAutoVariantLKGP": "Last Known Good Provider",
|
||||
"routingDefaultAutoVariantLKGPDesc": "Last Known Good Provider",
|
||||
"routingDefaultAutoVariantCoding": "Quality-first for code",
|
||||
"routingDefaultAutoVariantCodingDesc": "Quality-first for code",
|
||||
"routingDefaultAutoVariantFast": "Low-latency routing",
|
||||
"routingDefaultAutoVariantFastDesc": "Low-latency routing",
|
||||
"routingDefaultAutoVariantCheap": "Cost-optimized",
|
||||
"routingDefaultAutoVariantCheapDesc": "Cost-optimized",
|
||||
"routingDefaultAutoVariantOffline": "High availability",
|
||||
"routingDefaultAutoVariantOfflineDesc": "High availability",
|
||||
"routingDefaultAutoVariantSmart": "Best discovery (10% explore)",
|
||||
"routingDefaultAutoVariantSmartDesc": "Best discovery (10% explore)",
|
||||
"routingOpSummaryCount": "{count, plural, =0 {no ops} one {# op} other {# ops}}",
|
||||
"routingOpEnabled": "enabled",
|
||||
"routingOpDisabled": "disabled",
|
||||
"routingOpStatusSeparator": " · ",
|
||||
"resilienceSettingsIntro": "Automatic retry, cooldown, and fallback when providers fail.",
|
||||
"aiSettingsIntro": "AI-specific settings for thinking budget, model behavior, and compression.",
|
||||
"systemPrompt": "System Prompt",
|
||||
@@ -4483,33 +4709,51 @@
|
||||
"oneproxySuccess": "Success",
|
||||
"oneproxyFailed": "Failed",
|
||||
"routingAntigravitySignatureTitle": "Antigravity Signature Cache Mode",
|
||||
"routingAntigravitySignatureDesc": "Control whether OmniRoute reuses only stored Gemini thought signatures or accepts validated client-provided signatures in Antigravity-compatible tool-call flows.",
|
||||
"routingAntigravitySignatureEnabledLabel": "Enabled",
|
||||
"routingAntigravitySignatureEnabledDesc": "Current behavior. Ignore client-provided signatures and keep using the stored OmniRoute flow.",
|
||||
"routingAntigravitySignatureBypassLabel": "Bypass",
|
||||
"routingAntigravitySignatureBypassDesc": "Accept client-provided signatures after lightweight validation and fall back to the stored signature when invalid.",
|
||||
"routingAntigravitySignatureBypassStrictLabel": "Bypass Strict",
|
||||
"routingAntigravitySignatureBypassStrictDesc": "Require full protobuf validation before accepting a client-provided signature.",
|
||||
"routingHeaderFingerprintTitle": "Header fingerprint (per provider)",
|
||||
"routingServerRejectedSave": "⚠ Server rejected save:",
|
||||
"routingAddTransformOp": "Add a transform op",
|
||||
"routingClientCacheControlTitle": "Client Cache Control",
|
||||
"visionBridge": "Vision Bridge",
|
||||
"routingClientCacheControlDesc": "Configure whether OmniRoute preserves client-provided cache_control markers",
|
||||
"routingClientCacheControlAutoDesc": "For deterministic Claude-compatible flows, preserve client-provided cache_control as-is. If the request has no cache_control, OmniRoute does not inject any bridge-owned markers for CC-compatible third-party proxy compatibility.",
|
||||
"routingClientCacheControlAlwaysLabel": "Always Preserve",
|
||||
"routingClientCacheControlAlwaysDesc": "Always forward client-provided cache_control headers to upstream providers as-is.",
|
||||
"routingClientCacheControlNeverLabel": "Never Preserve",
|
||||
"routingClientCacheControlNeverDesc": "Always remove client cache_control headers and let OmniRoute manage caching where native provider flows support it.",
|
||||
"routingZeroConfigTitle": "Zero-Config Auto-Routing",
|
||||
"routingZeroConfigDesc": "Enable automatic provider selection using the auto/ prefix. When enabled, requests to auto, auto/coding, auto/fast, etc. will dynamically route across all connected providers.",
|
||||
"routingDefaultAutoVariant": "Default Auto Variant",
|
||||
"visionBridge": "Vision Bridge",
|
||||
"visionBridgeDesc": "Run an automatic vision-to-text fallback before routing image requests to text-only models.",
|
||||
"visionBridgeEnabledLabel": "Enabled",
|
||||
"visionBridgeEnabledDesc": "Toggle the pre-call bridge that replaces image parts with extracted text.",
|
||||
"visionBridgeModel": "Bridge Model",
|
||||
"resilienceMaxBackoffSteps": "Max backoff steps",
|
||||
"resilienceBaseCooldownLabel": "Base cooldown",
|
||||
"resilienceUseUpstreamRetryHintsLabel": "Use upstream retry hints",
|
||||
"resilienceYes": "Yes",
|
||||
"resilienceNo": "No",
|
||||
"resilienceUseUpstream429BreakerLabel": "Use upstream 429 hints (breaker)",
|
||||
"resilienceDefault": "Default",
|
||||
"resilienceMaxBackoffStepsLabel": "Max backoff steps",
|
||||
"visionBridgeModelPlaceholder": "openai/gpt-4o-mini",
|
||||
"visionBridgeModelHint": "Any OmniRoute model ID that supports vision can be used here.",
|
||||
"visionBridgePrompt": "Bridge Prompt",
|
||||
"visionBridgePromptPlaceholder": "Describe this image concisely.",
|
||||
"visionBridgePromptHint": "Sent to the vision model before the extracted description is injected back into the original request.",
|
||||
"visionBridgeTimeoutMs": "Timeout (ms)",
|
||||
"resilienceConnectionCooldownTitle": "Connection Cooldown",
|
||||
"visionBridgeMaxImagesPerRequest": "Max Images Per Request",
|
||||
"resilienceMaxBackoffSteps": "Max backoff steps",
|
||||
"resilienceProviderBreakerTitle": "Circuit Breaker per Provider",
|
||||
"resilienceFailureThreshold": "Failure threshold",
|
||||
"resilienceResetTimeout": "Reset timeout",
|
||||
"resilienceFailureThresholdLabel": "Failure threshold",
|
||||
"resilienceResetTimeoutLabel": "Reset timeout",
|
||||
"visionBridgeModelPlaceholder": "openai/gpt-4o-mini",
|
||||
"visionBridgePromptPlaceholder": "Describe this image concisely.",
|
||||
"resilienceProviderBreakerTitle": "Circuit Breaker per Provider",
|
||||
"resilienceConnectionCooldownTitle": "Connection Cooldown",
|
||||
"resilienceUseUpstreamRetryHintsLabel": "Use upstream retry hints",
|
||||
"resilienceUseUpstream429BreakerLabel": "Use upstream 429 hints (breaker)",
|
||||
"resilienceMaxBackoffStepsLabel": "Max backoff steps",
|
||||
"resilienceYes": "Yes",
|
||||
"resilienceNo": "No",
|
||||
"resilienceDefault": "Default",
|
||||
"storageDatabaseBackupRetention": "Database backup retention",
|
||||
"storagePurgeData": "Purge Data",
|
||||
"retentionQuotaSnapshots": "Quota Snapshots (days)",
|
||||
@@ -4551,18 +4795,109 @@
|
||||
"cliproxyapiStatus": "CLIProxyAPI Status",
|
||||
"cliproxyapiNotDetected": "Not detected",
|
||||
"payloadRulesTitle": "Payload Rules",
|
||||
"payloadRulesDesc": "Configure request payload mutations by model and protocol. Changes are persisted in settings and hot reloaded into the runtime immediately after save.",
|
||||
"payloadRuleDefaultTitle": "default",
|
||||
"payloadRuleDefaultDesc": "Applies params only when the target path is missing from the outgoing payload.",
|
||||
"payloadRuleOverrideTitle": "override",
|
||||
"payloadRuleOverrideDesc": "Forces values onto the payload, replacing anything already present at that path.",
|
||||
"payloadRuleFilterTitle": "filter",
|
||||
"payloadRuleFilterDesc": "Removes blocked params from the payload before the upstream request is sent.",
|
||||
"payloadRuleDefaultRawTitle": "defaultRaw",
|
||||
"payloadRuleDefaultRawDesc": "Like default, but string values are parsed as JSON first when possible. The legacy input alias default-raw is also accepted on save.",
|
||||
"payloadEditorTitle": "Editor",
|
||||
"payloadEditorDesc": "Use the runtime schema shape: default, override, filter, defaultRaw. The API also accepts the legacy input key default-raw.",
|
||||
"payloadEditorReady": "Ready",
|
||||
"payloadResetInfo": "Editor reset to the neutral template. Save to apply it.",
|
||||
"payloadSaveSuccess": "Payload rules saved and hot reloaded.",
|
||||
"payloadJsonParseError": "JSON parse error: {error}",
|
||||
"payloadMustBeObject": "Payload rules must be a JSON object.",
|
||||
"payloadInvalidJson": "Invalid JSON payload.",
|
||||
"payloadValidJsonRequired": "Payload rules must be valid JSON before saving.",
|
||||
"savePayloadRules": "Save Payload Rules",
|
||||
"requestLimitsTitle": "Request Limits",
|
||||
"requestLimitsDesc": "Configure global request limits and concurrency guards.",
|
||||
"maxRequestSizeLabel": "Max Request Size (MB)",
|
||||
"maxRequestSizeDesc": "Maximum allowed size for incoming API requests.",
|
||||
"maxResponseSizeLabel": "Max Response Size (MB)",
|
||||
"maxResponseSizeDesc": "Maximum allowed size for outgoing API responses.",
|
||||
"maxRequestTokensLabel": "Max Request Tokens",
|
||||
"maxRequestTokensDesc": "Maximum total tokens allowed in a single request.",
|
||||
"maxResponseTokensLabel": "Max Response Tokens",
|
||||
"maxResponseTokensDesc": "Maximum total tokens allowed in a single response.",
|
||||
"modelCooldownsTitle": "Models in cooldown",
|
||||
"modelCooldownsEmpty": "No models in cooldown right now.",
|
||||
"codexFastTierTitle": "Codex Fast Tier",
|
||||
"codexFastTierDesc": "Globally inject service_tier=priority for OpenAI Codex requests.",
|
||||
"codexFastTierHint": "When enabled, OmniRoute adds service_tier=priority to outbound Codex requests for connections that don't already specify a tier. Priority tier requires an OpenAI Enterprise API key or the ChatGPT-auth Codex path; other key types will receive a tier-related error from OpenAI. Per-connection settings on the Codex provider page take precedence.",
|
||||
"codexFastTierSaveError": "Failed to update Codex Fast Tier setting",
|
||||
"codexFastTierTierLabel": "Service tier",
|
||||
"codexFastTierTierPriority": "Priority",
|
||||
"codexFastTierTierFlex": "Flex",
|
||||
"codexFastTierTierDefault": "Default",
|
||||
"codexFastTierModelsLabel": "Fast-tier models",
|
||||
"codexFastTierModelsHint": "Only checked models receive service_tier when Fast Tier is enabled.",
|
||||
"codexFastTierModelCheckbox": "Enable Fast Tier for {model}",
|
||||
"claudeFastModeTitle": "Claude Fast Mode",
|
||||
"claudeFastModeDesc": "Opt selected Claude requests into Anthropic Fast Mode (speed:\"fast\").",
|
||||
"claudeFastModeHint": "Anthropic does not officially support Fast Mode for SDK-style clients. When enabled, OmniRoute forwards an X-CPA-Force-Fast-Mode header so a paired CLIProxyAPI build can opt-in spoof the entrypoint. Only the listed Opus models are gated by Anthropic's client-side check. Subscription tier, Max plan, and Fast Mode credit balance are still enforced server-side — Anthropic may return out_of_credits even when the toggle is on.",
|
||||
"claudeFastModeModelsLabel": "Applied to models ({count})",
|
||||
"claudeFastModeModelCheckbox": "Enable Fast Mode for {model}",
|
||||
"claudeFastModeSaveError": "Failed to update Claude Fast Mode setting"
|
||||
"claudeFastModeSaveError": "Failed to update Claude Fast Mode setting",
|
||||
"authz": {
|
||||
"title": "Authz Inventory",
|
||||
"description": "5-tier route classification with live bypass policy. Read shows the full taxonomy; mutations re-prompt for the management password.",
|
||||
"loading": "Loading inventory…",
|
||||
"loadError": "Failed to load authz inventory",
|
||||
"tier": {
|
||||
"LOCAL_ONLY": "Local only",
|
||||
"ALWAYS_PROTECTED": "Always protected",
|
||||
"MANAGEMENT": "Management",
|
||||
"CLIENT_API": "Client API",
|
||||
"PUBLIC": "Public"
|
||||
},
|
||||
"bypass": {
|
||||
"section": "Manage-scope bypass",
|
||||
"kill_switch": {
|
||||
"label": "Bypass kill-switch",
|
||||
"desc": "Master toggle. When off, no LOCAL_ONLY prefix is reachable from non-loopback regardless of the per-prefix list."
|
||||
},
|
||||
"prefix": {
|
||||
"label": "Bypassable prefixes",
|
||||
"desc": "LOCAL_ONLY prefixes that manage-scope API keys (or dashboard sessions) may reach from non-loopback hosts.",
|
||||
"add": "Add prefix",
|
||||
"placeholder": "/api/mcp/v2/",
|
||||
"empty": "No prefixes configured. Bypass effectively off."
|
||||
},
|
||||
"cli_tools_runtime_note": "Spawn-capable prefix. Compile-time deny; cannot be made bypassable. Shown read-only."
|
||||
},
|
||||
"password": {
|
||||
"prompt": {
|
||||
"label": "Current password",
|
||||
"desc": "Re-confirm to apply security-impacting changes."
|
||||
},
|
||||
"placeholder": "Current management password",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Apply"
|
||||
},
|
||||
"save": "Save changes",
|
||||
"saved": "Authz settings updated",
|
||||
"pending": "Unsaved changes",
|
||||
"badge": {
|
||||
"bypassable": "Bypassable via manage scope",
|
||||
"strict": "Strict loopback",
|
||||
"auth_required": "Auth required",
|
||||
"public": "Public",
|
||||
"always_protected": "Always protected",
|
||||
"spawn_capable": "Spawn-capable"
|
||||
},
|
||||
"error": {
|
||||
"PASSWORD_REQUIRED": "Current password required to apply these changes.",
|
||||
"PASSWORD_MISMATCH": "Current password is incorrect.",
|
||||
"INSUFFICIENT_SCOPE": "API key lacks the manage scope.",
|
||||
"BYPASS_PREFIX_NOT_ALLOWED": "One or more prefixes target spawn-capable routes and cannot be bypassed.",
|
||||
"GENERIC": "Failed to update authz settings."
|
||||
}
|
||||
}
|
||||
},
|
||||
"contextRtk": {
|
||||
"title": "RTK Engine",
|
||||
@@ -4603,6 +4938,7 @@
|
||||
"result": "Result",
|
||||
"previewEmpty": "Run a sample to preview RTK output.",
|
||||
"detected": "Detected",
|
||||
"masterSwitchOffAlert": "Token Saver master switch is OFF — these settings will not affect requests until you turn it on from the Endpoint page.",
|
||||
"tokensFiltered": "Tokens filtered",
|
||||
"filtersActive": "Filters active",
|
||||
"requests": "Requests",
|
||||
@@ -4657,6 +4993,8 @@
|
||||
"enabled": "Enabled",
|
||||
"autoDetect": "Auto-detect language",
|
||||
"rulesCount": "{count} rules",
|
||||
"inputCompressionTitle": "Input compression",
|
||||
"inputCompressionDesc": "Rewrite chat history with shorter wording. Reduces input tokens by ~50%.",
|
||||
"analyticsTitle": "Compression Analytics",
|
||||
"noAnalytics": "No compression analytics yet.",
|
||||
"outputMode": "Output Mode",
|
||||
@@ -4951,6 +5289,19 @@
|
||||
"runStepDescription": "Execute test cases against your LLM endpoints through OmniRoute. Each case is sent as a real API request.",
|
||||
"evaluate": "Evaluate",
|
||||
"evaluateStepDescription": "Responses are compared against expected criteria. See pass/fail for each case with latency metrics and detailed feedback.",
|
||||
"evalsStrategyContainsLabel": "Contains",
|
||||
"evalsStrategyExactLabel": "Exact Match",
|
||||
"evalsStrategyRegexLabel": "Regex",
|
||||
"evalsStrategyCustomLabel": "Custom Logic",
|
||||
"evalsStrategyContainsDescription": "Checks if the LLM output contains the expected substring.",
|
||||
"evalsStrategyExactDescription": "Checks if the LLM output exactly matches the expected value.",
|
||||
"evalsStrategyRegexDescription": "Validates the LLM output against a regular expression pattern.",
|
||||
"evalsStrategyCustomDescription": "Custom evaluation logic (configured via JSON).",
|
||||
"historyColumnSuiteName": "Suite Name",
|
||||
"historyColumnTarget": "Target",
|
||||
"historyColumnPassRate": "Pass Rate",
|
||||
"historyColumnAvgLatencyMs": "Avg Latency",
|
||||
"historyColumnCreatedAt": "Executed",
|
||||
"evalSuites": "Evaluation Suites",
|
||||
"evalSuitesHint": "Click a suite to view test cases, then run to evaluate your LLM endpoints",
|
||||
"evalsLoading": "Loading eval suites...",
|
||||
@@ -5058,7 +5409,25 @@
|
||||
"tierPro": "Pro",
|
||||
"tierPlus": "Plus",
|
||||
"tierFree": "Free",
|
||||
"tierLite": "Lite",
|
||||
"tierUnknown": "Unknown",
|
||||
"statTotal": "Total",
|
||||
"statCritical": "Critical",
|
||||
"statAlert": "Alert",
|
||||
"statHealthy": "Healthy",
|
||||
"filterPurchaseTypeLabel": "Type",
|
||||
"filterTierLabel": "Tier",
|
||||
"purchaseAll": "All",
|
||||
"purchaseOauthSub": "Subscription",
|
||||
"purchaseOauthFree": "OAuth Free",
|
||||
"purchaseApiKey": "API Key",
|
||||
"creditsLabel": "Credits",
|
||||
"creditBalanceHint": "Remaining balance",
|
||||
"unlimitedLabel": "Unlimited",
|
||||
"refreshing": "Refreshing",
|
||||
"resetsIn": "Resets in",
|
||||
"editCutoffs": "Edit cutoffs",
|
||||
"forceRefresh": "Refresh now",
|
||||
"suiteBuilderSaveFailed": "Failed to save suite",
|
||||
"clone": "Clone",
|
||||
"exportSuite": "Export",
|
||||
@@ -5204,7 +5573,9 @@
|
||||
"budgetWarnAtPct": "Warn at %",
|
||||
"quotaAlerts": "Quota alerts",
|
||||
"quotaTableRefreshing": "⟳ Refreshing...",
|
||||
"noSpendLast30Days": "No spend in last 30 days"
|
||||
"noSpendLast30Days": "No spend in last 30 days",
|
||||
"updatedShort": "Updated",
|
||||
"lastRefreshed": "Last refreshed"
|
||||
},
|
||||
"modals": {
|
||||
"waitingAuth": "Waiting for Authorization",
|
||||
@@ -5760,7 +6131,13 @@
|
||||
"howToUse": "How to use",
|
||||
"browseAllSkillsOnGithub": "Browse all skills on GitHub",
|
||||
"apiSkills": "API Skills",
|
||||
"cliSkills": "CLI Skills"
|
||||
"cliSkills": "CLI Skills",
|
||||
"apiSkillsSubtitle": "{count} skills — control OmniRoute via REST / HTTP",
|
||||
"cliSkillsSubtitle": "{count} skills — control OmniRoute via the omniroute terminal binary",
|
||||
"howToUseStep1": "Click <bold>{copyUrl}</bold> on the skill you want your agent to know about.",
|
||||
"howToUseStep2": "In your AI agent (Claude, Cursor, Cline…), say:",
|
||||
"howToUseStep2Code": "Use the skill at [pasted-url]",
|
||||
"howToUseStep3": "The agent fetches the SKILL.md and learns OmniRoute's API or CLI — no manual docs needed."
|
||||
},
|
||||
"cloudAgents": {
|
||||
"title": "Cloud Agents",
|
||||
@@ -5779,6 +6156,28 @@
|
||||
"tasks": "Tasks",
|
||||
"taskDetail": "Task Detail",
|
||||
"noTasks": "No tasks yet. Create one to get started.",
|
||||
"noTasksTitle": "No tasks yet",
|
||||
"noTasksDesc": "Create your first task to get started.",
|
||||
"tasksTab": "Tasks",
|
||||
"agentsTab": "Agents",
|
||||
"settingsTab": "Settings",
|
||||
"agentsEnabled": "Enabled",
|
||||
"agentsDisabled": "Disabled",
|
||||
"filterAllProviders": "All Providers",
|
||||
"filterAll": "All",
|
||||
"autoRefreshing": "Auto-refreshing",
|
||||
"viewPR": "View Pull Request",
|
||||
"connected": "Connected",
|
||||
"notConnected": "Not connected",
|
||||
"configure": "Configure",
|
||||
"settingsTitle": "Cloud Agent Settings",
|
||||
"settingsDesc": "Configure local preferences for cloud agents.",
|
||||
"settingEnableAgents": "Enable cloud agents",
|
||||
"settingEnableAgentsDesc": "Allow OmniRoute to orchestrate autonomous coding agents.",
|
||||
"settingAutoPR": "Auto-create PR",
|
||||
"settingAutoPRDesc": "When a task is completed, automatically create a Pull Request with the changes.",
|
||||
"settingRequireApproval": "Require plan approval",
|
||||
"settingRequireApprovalDesc": "Always wait for manual approval before an agent executes a proposed plan.",
|
||||
"untitledTask": "Untitled Task",
|
||||
"created": "Created",
|
||||
"conversation": "Conversation",
|
||||
@@ -5879,6 +6278,7 @@
|
||||
"clearError": "Failed to clear cache.",
|
||||
"unavailable": "Cache unavailable",
|
||||
"unavailableDesc": "Could not fetch cache statistics. Make sure the server is running.",
|
||||
"loadingCacheAria": "Loading cache",
|
||||
"promptCache": "Prompt Cache (Provider-Side)",
|
||||
"semanticCache": "Semantic Cache",
|
||||
"promptCacheSectionDesc": "Shows provider-side prompt caching activity from usage history so you can see where cache control is active and how much input reuse you are getting.",
|
||||
@@ -5920,7 +6320,9 @@
|
||||
"peakCached": "Peak cached",
|
||||
"cached": "Cached",
|
||||
"overview": "Overview",
|
||||
"entries": "Entries",
|
||||
"tableProvider": "Provider",
|
||||
"tableModel": "Model",
|
||||
"performanceTitle": "Performance",
|
||||
"semanticCacheSectionDesc": "OmniRoute's own deterministic response cache. When enabled, repeated non-streaming temperature=0 requests can be served locally without hitting the upstream provider.",
|
||||
"semanticCacheDisabledDesc": "Semantic cache is disabled. OmniRoute will skip local response reuse until you turn it back on in Settings.",
|
||||
"semanticEntriesDesc": "Persisted semantic cache records currently stored in SQLite. Provider-side prompt cache activity is not listed here.",
|
||||
@@ -5971,7 +6373,12 @@
|
||||
"cachePerformanceAvgLatency": "Avg Latency (ms)",
|
||||
"cachePerformanceP95Latency": "p95 Latency (ms)",
|
||||
"retry": "Retry",
|
||||
"reasoningAvgChars": "Avg Chars"
|
||||
"reasoningAvgChars": "Avg Chars",
|
||||
"tableShare": "Share",
|
||||
"justNow": "just now",
|
||||
"minutesAgo": "{minutes}m ago",
|
||||
"hoursAgo": "{hours}h ago",
|
||||
"daysAgo": "{days}d ago"
|
||||
},
|
||||
"proxyConfigModal": {
|
||||
"levelGlobal": "Global",
|
||||
@@ -6115,7 +6522,20 @@
|
||||
"bulkClearAssignment": "(Clear assignment)",
|
||||
"bulkLabelScopeIds": "Scope IDs (comma or newline separated)",
|
||||
"bulkScopeIdsPlaceholder": "provider-openai,provider-anthropic",
|
||||
"bulkLabelScopeIds": "Scope IDs (comma or newline separated)",
|
||||
"bulkScopeIdsPlaceholder": "provider-openai,provider-anthropic",
|
||||
"bulkApply": "Apply",
|
||||
"labelScope": "Scope",
|
||||
"labelProxy": "Proxy",
|
||||
"scopeGlobal": "global",
|
||||
"scopeProvider": "provider",
|
||||
"scopeAccount": "account",
|
||||
"scopeCombo": "combo",
|
||||
"bulkImportErrorMissingName": "Missing NAME",
|
||||
"bulkImportErrorMissingHost": "Missing HOST",
|
||||
"bulkImportErrorInvalidPort": "Invalid PORT (must be 1-65535)",
|
||||
"bulkImportErrorInvalidType": "Invalid TYPE (use http, https, or socks5)",
|
||||
"bulkImportErrorInvalidStatus": "Invalid STATUS (use active or inactive)",
|
||||
"errorLoadFailed": "Failed to load proxy registry",
|
||||
"errorNameHostRequired": "Name and host are required",
|
||||
"errorSaveFailed": "Failed to save proxy",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Card from "./Card";
|
||||
import { CardSkeleton } from "./Loading";
|
||||
import { fmtCompact as fmt, fmtFull, fmtCost } from "@/shared/utils/formatting";
|
||||
@@ -28,6 +29,8 @@ import {
|
||||
// ============================================================================
|
||||
|
||||
export default function UsageAnalytics() {
|
||||
const t = useTranslations("analytics");
|
||||
const tCommon = useTranslations("common");
|
||||
const [range, setRange] = useState("30d");
|
||||
const [analytics, setAnalytics] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -56,7 +59,7 @@ export default function UsageAnalytics() {
|
||||
params.set("apiKeyIds", selectedApiKeys.join(","));
|
||||
}
|
||||
const res = await fetch(`/api/usage/analytics?${params.toString()}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch");
|
||||
if (!res.ok) throw new Error(tCommon("error"));
|
||||
const data = await res.json();
|
||||
setAnalytics(data);
|
||||
setError(null);
|
||||
@@ -66,8 +69,8 @@ export default function UsageAnalytics() {
|
||||
const seen = new Set<string>();
|
||||
const keys: { id: string; name: string }[] = [];
|
||||
for (const k of data.byApiKey) {
|
||||
const id = k.apiKeyId || k.apiKeyName || "unknown";
|
||||
const name = k.apiKeyName || k.apiKeyId || "unknown";
|
||||
const id = k.apiKeyId || k.apiKeyName || tCommon("unknownProvider");
|
||||
const name = k.apiKeyName || k.apiKeyId || tCommon("unknownProvider");
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
keys.push({ id, name });
|
||||
@@ -79,7 +82,7 @@ export default function UsageAnalytics() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [range, customStart, customEnd, selectedApiKeys]);
|
||||
}, [range, customStart, customEnd, selectedApiKeys, tCommon]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics();
|
||||
@@ -117,12 +120,12 @@ export default function UsageAnalytics() {
|
||||
}, [range, customStart, customEnd]);
|
||||
|
||||
const ranges = [
|
||||
{ value: "1d", label: "1D" },
|
||||
{ value: "7d", label: "7D" },
|
||||
{ value: "30d", label: "30D" },
|
||||
{ value: "90d", label: "90D" },
|
||||
{ value: "ytd", label: "YTD" },
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "1d", label: t("period1D") },
|
||||
{ value: "7d", label: t("period7D") },
|
||||
{ value: "30d", label: t("period30D") },
|
||||
{ value: "90d", label: t("period90D") },
|
||||
{ value: "ytd", label: t("periodYTD") },
|
||||
{ value: "all", label: t("periodAll") },
|
||||
];
|
||||
|
||||
const topModel = useMemo(() => {
|
||||
@@ -167,7 +170,12 @@ export default function UsageAnalytics() {
|
||||
}, [analytics]);
|
||||
|
||||
if (loading && !analytics) return <CardSkeleton />;
|
||||
if (error) return <Card className="p-6 text-center text-red-500">Error: {error}</Card>;
|
||||
if (error)
|
||||
return (
|
||||
<Card className="p-6 text-center text-red-500">
|
||||
{tCommon("errorShort")}: {error}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const s = analytics?.summary || {};
|
||||
|
||||
@@ -182,7 +190,7 @@ export default function UsageAnalytics() {
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary text-[22px]">analytics</span>
|
||||
Usage Analytics
|
||||
{t("usageAnalyticsTitle")}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{/* API Key Filter */}
|
||||
@@ -219,7 +227,7 @@ export default function UsageAnalytics() {
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[13px]">date_range</span>
|
||||
{customRangeLabel || "Custom"}
|
||||
{customRangeLabel || t("customRange")}
|
||||
{range === "custom" && customRangeLabel && (
|
||||
<span
|
||||
role="button"
|
||||
@@ -253,25 +261,25 @@ export default function UsageAnalytics() {
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatCard
|
||||
icon="generating_tokens"
|
||||
label="Total Tokens"
|
||||
label={t("totalTokens")}
|
||||
value={fmt(s.totalTokens)}
|
||||
subValue={`${fmtFull(s.totalRequests)} requests`}
|
||||
subValue={`${fmtFull(s.totalRequests)} ${t("chartRequests")}`}
|
||||
/>
|
||||
<StatCard
|
||||
icon="input"
|
||||
label="Input Tokens"
|
||||
label={t("inputTokens")}
|
||||
value={fmt(s.promptTokens)}
|
||||
color="text-primary"
|
||||
/>
|
||||
<StatCard
|
||||
icon="output"
|
||||
label="Output Tokens"
|
||||
label={t("outputTokens")}
|
||||
value={fmt(s.completionTokens)}
|
||||
color="text-emerald-500"
|
||||
/>
|
||||
<StatCard
|
||||
icon="payments"
|
||||
label="Est. Cost"
|
||||
label={t("estCost")}
|
||||
value={fmtCost(s.totalCost)}
|
||||
color="text-amber-500"
|
||||
/>
|
||||
@@ -281,59 +289,79 @@ export default function UsageAnalytics() {
|
||||
<CompactStatGrid
|
||||
sections={[
|
||||
{
|
||||
title: "Infrastructure",
|
||||
title: t("infraTitle"),
|
||||
items: [
|
||||
{ icon: "group", label: "Accounts", value: s.uniqueAccounts || 0 },
|
||||
{ icon: "dns", label: "Providers", value: providerCount, color: "text-indigo-500" },
|
||||
{ icon: "vpn_key", label: "API Keys", value: s.uniqueApiKeys || 0 },
|
||||
{ icon: "model_training", label: "Models", value: s.uniqueModels || 0 },
|
||||
{ icon: "group", label: t("infraAccounts"), value: s.uniqueAccounts || 0 },
|
||||
{
|
||||
icon: "dns",
|
||||
label: t("infraProviders"),
|
||||
value: providerCount,
|
||||
color: "text-indigo-500",
|
||||
},
|
||||
{ icon: "vpn_key", label: t("infraApiKeys"), value: s.uniqueApiKeys || 0 },
|
||||
{ icon: "model_training", label: t("infraModels"), value: s.uniqueModels || 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Performance",
|
||||
title: t("perfTitle"),
|
||||
items: [
|
||||
{
|
||||
icon: "speed",
|
||||
label: "Avg Tokens/Req",
|
||||
label: t("perfAvgTokens"),
|
||||
value: fmt(avgTokensPerReq),
|
||||
color: "text-cyan-500",
|
||||
},
|
||||
{
|
||||
icon: "request_quote",
|
||||
label: "Cost/Req",
|
||||
label: t("perfCostReq"),
|
||||
value: fmtCost(costPerReq),
|
||||
color: "text-orange-500",
|
||||
},
|
||||
{
|
||||
icon: "compare_arrows",
|
||||
label: "I/O Ratio",
|
||||
label: t("perfIoRatio"),
|
||||
value: `${ioRatio}x`,
|
||||
color: "text-violet-500",
|
||||
},
|
||||
{
|
||||
icon: "bolt",
|
||||
label: "Fast Requests",
|
||||
label: t("perfFastReq"),
|
||||
value: fmt(s.fastRequests || 0),
|
||||
color: "text-sky-500",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Highlights",
|
||||
title: t("highlightsTitle"),
|
||||
wideValues: true,
|
||||
items: [
|
||||
{ icon: "star", label: "Top Model", value: topModel, color: "text-pink-500" },
|
||||
{ icon: "cloud", label: "Top Provider", value: topProvider, color: "text-teal-500" },
|
||||
{ icon: "today", label: "Busiest Day", value: busiestDay, color: "text-rose-500" },
|
||||
{
|
||||
icon: "star",
|
||||
label: t("highlightsTopModel"),
|
||||
value: topModel,
|
||||
color: "text-pink-500",
|
||||
},
|
||||
{
|
||||
icon: "cloud",
|
||||
label: t("highlightsTopProvider"),
|
||||
value: topProvider,
|
||||
color: "text-teal-500",
|
||||
},
|
||||
{
|
||||
icon: "today",
|
||||
label: t("highlightsBusiestDay"),
|
||||
value: busiestDay,
|
||||
color: "text-rose-500",
|
||||
},
|
||||
{
|
||||
icon: "network_node",
|
||||
label: "Diversity",
|
||||
label: t("highlightsDiversity"),
|
||||
value: `${providerDiversity.toFixed(1)}%`,
|
||||
color: "text-sky-500",
|
||||
},
|
||||
{
|
||||
icon: "swap_horiz",
|
||||
label: "Fallback Rate",
|
||||
label: t("highlightsFallbackRate"),
|
||||
value: `${Number(s.fallbackRatePct || 0).toFixed(1)}%`,
|
||||
color: "text-amber-500",
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface ApiKeyInfo {
|
||||
id: string;
|
||||
@@ -23,6 +24,7 @@ export default function ApiKeyFilterDropdown({
|
||||
selected,
|
||||
onChange,
|
||||
}: ApiKeyFilterDropdownProps) {
|
||||
const t = useTranslations("analytics");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -76,13 +78,13 @@ export default function ApiKeyFilterDropdown({
|
||||
}, [onChange]);
|
||||
|
||||
const buttonLabel = useMemo(() => {
|
||||
if (isAllSelected) return "All Keys";
|
||||
if (isAllSelected) return t("filterAllKeys");
|
||||
if (selected.length === 1) {
|
||||
const key = available.find((k) => k.id === selected[0]);
|
||||
return key ? maskKeyName(key.name) : "1 key";
|
||||
return key ? maskKeyName(key.name) : t("filterOneKey");
|
||||
}
|
||||
return `${selected.length} keys`;
|
||||
}, [isAllSelected, selected, available]);
|
||||
return t("filterMultipleKeys", { count: selected.length });
|
||||
}, [isAllSelected, selected, available, t]);
|
||||
|
||||
if (available.length === 0) return null;
|
||||
|
||||
@@ -120,7 +122,7 @@ export default function ApiKeyFilterDropdown({
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search keys…"
|
||||
placeholder={t("filterSearchKeys")}
|
||||
className="w-full rounded-md border border-border/30 bg-black/[0.03] px-2.5 py-1.5 text-xs text-text-main placeholder:text-text-muted focus:outline-none focus:border-primary dark:bg-white/[0.03]"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -149,7 +151,7 @@ export default function ApiKeyFilterDropdown({
|
||||
<span className="material-symbols-outlined text-[12px]">check</span>
|
||||
)}
|
||||
</span>
|
||||
All Keys
|
||||
{t("filterAllKeys")}
|
||||
<span className="ml-auto text-[10px] text-text-muted font-normal">
|
||||
{available.length}
|
||||
</span>
|
||||
@@ -191,7 +193,9 @@ export default function ApiKeyFilterDropdown({
|
||||
})}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<p className="px-2.5 py-3 text-center text-[11px] text-text-muted">No keys match</p>
|
||||
<p className="px-2.5 py-3 text-center text-[11px] text-text-muted">
|
||||
{t("filterNoKeysMatch")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface CustomRangePickerProps {
|
||||
start: string;
|
||||
@@ -65,6 +66,7 @@ export default function CustomRangePicker({
|
||||
onApply,
|
||||
onClose,
|
||||
}: CustomRangePickerProps) {
|
||||
const t = useTranslations("analytics");
|
||||
const [localStart, setLocalStart] = useState(toLocalDatetime(start) || "");
|
||||
const [localEnd, setLocalEnd] = useState(toLocalDatetime(end) || "");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -104,12 +106,12 @@ export default function CustomRangePicker({
|
||||
const isValid = localStart && localEnd && new Date(localStart) <= new Date(localEnd);
|
||||
|
||||
const presets = [
|
||||
{ key: "today", label: "Today" },
|
||||
{ key: "yesterday", label: "Yesterday" },
|
||||
{ key: "last3d", label: "Last 3 days" },
|
||||
{ key: "thisWeek", label: "This week" },
|
||||
{ key: "last14d", label: "Last 14 days" },
|
||||
{ key: "thisMonth", label: "This month" },
|
||||
{ key: "today", label: t("rangeToday") },
|
||||
{ key: "yesterday", label: t("rangeYesterday") },
|
||||
{ key: "last3d", label: t("rangeLast3Days") },
|
||||
{ key: "thisWeek", label: t("rangeThisWeek") },
|
||||
{ key: "last14d", label: t("rangeLast14Days") },
|
||||
{ key: "thisMonth", label: t("rangeThisMonth") },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -121,7 +123,7 @@ export default function CustomRangePicker({
|
||||
{/* Quick presets */}
|
||||
<div className="mb-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-text-muted mb-1.5">
|
||||
Quick Select
|
||||
{t("rangeQuickSelect")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{presets.map((p) => (
|
||||
@@ -143,7 +145,7 @@ export default function CustomRangePicker({
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold uppercase tracking-wider text-text-muted mb-1 block">
|
||||
Start
|
||||
{t("rangeStart")}
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
@@ -154,7 +156,7 @@ export default function CustomRangePicker({
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold uppercase tracking-wider text-text-muted mb-1 block">
|
||||
End
|
||||
{t("rangeEnd")}
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
@@ -167,7 +169,7 @@ export default function CustomRangePicker({
|
||||
|
||||
{/* Validation hint */}
|
||||
{localStart && localEnd && !isValid && (
|
||||
<p className="mt-1.5 text-[11px] text-error">Start must be before end</p>
|
||||
<p className="mt-1.5 text-[11px] text-error">{t("rangeErrorInvalid")}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
@@ -177,7 +179,7 @@ export default function CustomRangePicker({
|
||||
onClick={onClose}
|
||||
className="rounded-lg px-3 py-1.5 text-xs font-medium text-text-muted hover:text-text-main transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t("rangeCancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -185,7 +187,7 @@ export default function CustomRangePicker({
|
||||
onClick={handleApply}
|
||||
className="rounded-lg bg-primary px-4 py-1.5 text-xs font-semibold text-white shadow-sm transition-colors hover:bg-primary-hover disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Apply
|
||||
{t("rangeApply")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import Card from "../Card";
|
||||
import { getModelColor } from "@/shared/constants/colors";
|
||||
import {
|
||||
@@ -174,6 +174,7 @@ export function CompactStatGrid({ sections }: { sections: CompactStatSection[] }
|
||||
// ── ActivityHeatmap ────────────────────────────────────────────────────────
|
||||
|
||||
export function ActivityHeatmap({ activityMap }) {
|
||||
const t = useTranslations("analytics");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const cells = useMemo(() => {
|
||||
@@ -259,7 +260,9 @@ export function ActivityHeatmap({ activityMap }) {
|
||||
return (
|
||||
<Card className="p-4 h-full min-w-0 overflow-hidden">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">Activity</h3>
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
|
||||
{t("overview")}
|
||||
</h3>
|
||||
<span className="text-xs text-text-muted">
|
||||
{Object.keys(activityMap || {}).length} active days ·{" "}
|
||||
{fmt(Object.values(activityMap || {}).reduce((a: number, b: number) => a + b, 0))} tokens
|
||||
@@ -327,24 +330,25 @@ export function ActivityHeatmap({ activityMap }) {
|
||||
// ── DailyTrendChart (Recharts) ─────────────────────────────────────────────
|
||||
|
||||
export function DailyTrendChart({ dailyTrend }) {
|
||||
const t = useTranslations("analytics");
|
||||
const chartData = useMemo(() => {
|
||||
return (dailyTrend || []).map((d) => ({
|
||||
date: d.date.slice(5),
|
||||
Input: d.promptTokens,
|
||||
Output: d.completionTokens,
|
||||
Cost: d.cost || 0,
|
||||
[t("chartInput")]: d.promptTokens,
|
||||
[t("chartOutput")]: d.completionTokens,
|
||||
[t("chartCost")]: d.cost || 0,
|
||||
}));
|
||||
}, [dailyTrend]);
|
||||
}, [dailyTrend, t]);
|
||||
|
||||
const hasCost = useMemo(() => chartData.some((d) => d.Cost > 0), [chartData]);
|
||||
const hasCost = useMemo(() => chartData.some((d) => d[t("chartCost")] > 0), [chartData, t]);
|
||||
|
||||
if (!chartData.length) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
Token Trend
|
||||
{t("chartModelUsageOverTime")}
|
||||
</h3>
|
||||
<div className="text-center text-text-muted text-sm py-8">No data</div>
|
||||
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -352,7 +356,7 @@ export function DailyTrendChart({ dailyTrend }) {
|
||||
return (
|
||||
<Card className="p-4 flex-1">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
Token & Cost Trend
|
||||
{t("chartModelUsageOverTime")}
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<ComposedChart
|
||||
@@ -379,7 +383,7 @@ export function DailyTrendChart({ dailyTrend }) {
|
||||
)}
|
||||
<Tooltip content={<CostTooltip />} cursor={{ fill: "rgba(255,255,255,0.04)" }} />
|
||||
<Bar
|
||||
dataKey="Input"
|
||||
dataKey={t("chartInput")}
|
||||
stackId="a"
|
||||
fill="var(--primary)"
|
||||
opacity={0.7}
|
||||
@@ -387,7 +391,7 @@ export function DailyTrendChart({ dailyTrend }) {
|
||||
animationDuration={600}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="Output"
|
||||
dataKey={t("chartOutput")}
|
||||
stackId="a"
|
||||
fill="#10b981"
|
||||
opacity={0.7}
|
||||
@@ -398,7 +402,7 @@ export function DailyTrendChart({ dailyTrend }) {
|
||||
<Line
|
||||
yAxisId="cost"
|
||||
type="monotone"
|
||||
dataKey="Cost"
|
||||
dataKey={t("chartCost")}
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
@@ -409,14 +413,14 @@ export function DailyTrendChart({ dailyTrend }) {
|
||||
</ResponsiveContainer>
|
||||
<div className="flex items-center gap-4 mt-2 text-[10px] text-text-muted">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-primary/70" /> Input
|
||||
<span className="w-2 h-2 rounded-full bg-primary/70" /> {t("chartInput")}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500/70" /> Output
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500/70" /> {t("chartOutput")}
|
||||
</span>
|
||||
{hasCost && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500/70" /> Cost ($)
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500/70" /> {t("chartCost")} ($)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -435,6 +439,7 @@ function CostTooltip({
|
||||
payload?: any[];
|
||||
label?: any;
|
||||
}) {
|
||||
const t = useTranslations("analytics");
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div className="rounded-lg border border-white/10 bg-surface px-3 py-2 text-xs shadow-lg">
|
||||
@@ -447,7 +452,7 @@ function CostTooltip({
|
||||
/>
|
||||
<span>{entry.name}:</span>
|
||||
<span className="font-mono font-medium text-text-main">
|
||||
{entry.name === "Cost" ? fmtCost(entry.value) : fmt(entry.value)}
|
||||
{entry.name === t("chartCost") ? fmtCost(entry.value) : fmt(entry.value)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -458,6 +463,7 @@ function CostTooltip({
|
||||
// ── AccountDonut (Recharts) ────────────────────────────────────────────────
|
||||
|
||||
export function AccountDonut({ byAccount }) {
|
||||
const t = useTranslations("analytics");
|
||||
const data = useMemo(() => byAccount || [], [byAccount]);
|
||||
const hasData = data.length > 0;
|
||||
|
||||
@@ -475,7 +481,7 @@ export function AccountDonut({ byAccount }) {
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
By Account
|
||||
</h3>
|
||||
<div className="text-center text-text-muted text-sm py-8">No data</div>
|
||||
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -530,6 +536,7 @@ export function AccountDonut({ byAccount }) {
|
||||
// ── ApiKeyDonut (Recharts) ─────────────────────────────────────────────────
|
||||
|
||||
export function ApiKeyDonut({ byApiKey }) {
|
||||
const t = useTranslations("analytics");
|
||||
const data = useMemo(() => byApiKey || [], [byApiKey]);
|
||||
const hasData = data.length > 0;
|
||||
|
||||
@@ -548,7 +555,7 @@ export function ApiKeyDonut({ byApiKey }) {
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
By API Key
|
||||
</h3>
|
||||
<div className="text-center text-text-muted text-sm py-8">No data</div>
|
||||
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -608,6 +615,7 @@ export function ApiKeyDonut({ byApiKey }) {
|
||||
// ── ApiKeyTable ────────────────────────────────────────────────────────────
|
||||
|
||||
export function ApiKeyTable({ byApiKey }) {
|
||||
const t = useTranslations("analytics");
|
||||
const [query, setQuery] = useState("");
|
||||
const [sortBy, setSortBy] = useState("totalTokens");
|
||||
const [sortOrder, setSortOrder] = useState("desc");
|
||||
@@ -657,7 +665,7 @@ export function ApiKeyTable({ byApiKey }) {
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
API Key Breakdown
|
||||
</h3>
|
||||
<div className="text-center text-text-muted text-sm py-8">No data</div>
|
||||
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -672,7 +680,7 @@ export function ApiKeyTable({ byApiKey }) {
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Filter API key..."
|
||||
placeholder={t("filterSearchKeys")}
|
||||
className="w-full max-w-[220px] px-3 py-1.5 rounded-lg bg-bg-subtle border border-border text-xs text-text-primary placeholder:text-text-muted focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
@@ -690,33 +698,35 @@ export function ApiKeyTable({ byApiKey }) {
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("requests")}
|
||||
>
|
||||
Requests <SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
|
||||
{t("chartRequests")}{" "}
|
||||
<SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("promptTokens")}
|
||||
>
|
||||
Input <SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} />
|
||||
{t("chartInput")}{" "}
|
||||
<SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("completionTokens")}
|
||||
>
|
||||
Output{" "}
|
||||
{t("chartOutput")}{" "}
|
||||
<SortIndicator active={sortBy === "completionTokens"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("totalTokens")}
|
||||
>
|
||||
Total Tokens{" "}
|
||||
{t("chartTotal")}{" "}
|
||||
<SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("cost")}
|
||||
>
|
||||
Cost <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} />
|
||||
{t("chartCost")} <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -751,7 +761,7 @@ export function ApiKeyTable({ byApiKey }) {
|
||||
{sorted.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-text-muted">
|
||||
No API key matches this filter.
|
||||
{t("filterNoKeysMatch")}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -765,6 +775,7 @@ export function ApiKeyTable({ byApiKey }) {
|
||||
// ── WeeklyPattern (Recharts) ───────────────────────────────────────────────
|
||||
|
||||
export function WeeklyPattern({ weeklyPattern }) {
|
||||
const t = useTranslations("analytics");
|
||||
const chartData = useMemo(() => {
|
||||
return (weeklyPattern || []).map((w) => ({
|
||||
day: w.day.slice(0, 3),
|
||||
@@ -775,7 +786,7 @@ export function WeeklyPattern({ weeklyPattern }) {
|
||||
return (
|
||||
<Card className="px-4 py-3">
|
||||
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-2">
|
||||
Weekly
|
||||
{t("chartWeekly")}
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={48}>
|
||||
<BarChart data={chartData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
|
||||
@@ -869,6 +880,7 @@ export function MostActiveDay7d({ activityMap }) {
|
||||
// ── WeeklySquares7d ────────────────────────────────────────────────────────
|
||||
|
||||
export function WeeklySquares7d({ activityMap }) {
|
||||
const t = useTranslations("analytics");
|
||||
const locale = useLocale();
|
||||
const weekdayFormatter = useMemo(
|
||||
() => createDateFormatter(locale, { weekday: "short" }),
|
||||
@@ -912,7 +924,7 @@ export function WeeklySquares7d({ activityMap }) {
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
Weekly
|
||||
{t("chartWeekly")}
|
||||
</h3>
|
||||
<div style={{ display: "flex", alignItems: "flex-end", gap: 6, justifyContent: "center" }}>
|
||||
{days.map((d, i) => (
|
||||
@@ -951,6 +963,7 @@ export function WeeklySquares7d({ activityMap }) {
|
||||
// ── ModelTable ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function ModelTable({ byModel, summary }) {
|
||||
const t = useTranslations("analytics");
|
||||
const [sortBy, setSortBy] = useState("totalTokens");
|
||||
const [sortOrder, setSortOrder] = useState("desc");
|
||||
|
||||
@@ -982,7 +995,7 @@ export function ModelTable({ byModel, summary }) {
|
||||
<Card className="overflow-hidden">
|
||||
<div className="p-4 border-b border-border">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
|
||||
Model Breakdown
|
||||
{t("chartModelBreakdown")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
@@ -993,40 +1006,44 @@ export function ModelTable({ byModel, summary }) {
|
||||
className="px-4 py-2.5 text-left cursor-pointer group"
|
||||
onClick={() => toggleSort("model")}
|
||||
>
|
||||
Model <SortIndicator active={sortBy === "model"} sortOrder={sortOrder} />
|
||||
{t("chartModel")}{" "}
|
||||
<SortIndicator active={sortBy === "model"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("requests")}
|
||||
>
|
||||
Requests <SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
|
||||
{t("chartRequests")}{" "}
|
||||
<SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("promptTokens")}
|
||||
>
|
||||
Input <SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} />
|
||||
{t("chartInput")}{" "}
|
||||
<SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("completionTokens")}
|
||||
>
|
||||
Output{" "}
|
||||
{t("chartOutput")}{" "}
|
||||
<SortIndicator active={sortBy === "completionTokens"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("totalTokens")}
|
||||
>
|
||||
Total <SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} />
|
||||
{t("chartTotal")}{" "}
|
||||
<SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("cost")}
|
||||
>
|
||||
Cost <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} />
|
||||
{t("chartCost")} <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-right w-36">Share</th>
|
||||
<th className="px-4 py-2.5 text-right w-36">{t("chartShare")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
@@ -1082,6 +1099,7 @@ export function ModelTable({ byModel, summary }) {
|
||||
}
|
||||
|
||||
export function ServiceTierBreakdown({ byServiceTier, summary }) {
|
||||
const t = useTranslations("analytics");
|
||||
const data = useMemo(() => byServiceTier || [], [byServiceTier]);
|
||||
const totalRequests = Number(summary?.totalRequests || 0);
|
||||
const totalCost = Number(summary?.totalCost || 0);
|
||||
@@ -1094,9 +1112,9 @@ export function ServiceTierBreakdown({ byServiceTier, summary }) {
|
||||
<Card className="overflow-hidden">
|
||||
<div className="p-4 border-b border-border flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
|
||||
Service Tier
|
||||
{t("chartServiceTier")}
|
||||
</h3>
|
||||
<span className="text-[11px] text-text-muted">Fast / Standard cost split</span>
|
||||
<span className="text-[11px] text-text-muted">{t("chartServiceTierSplit")}</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{data.map((tier) => {
|
||||
@@ -1121,7 +1139,7 @@ export function ServiceTierBreakdown({ byServiceTier, summary }) {
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-text-main">{tier.label}</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
{fmtFull(tier.requests)} requests · {fmt(tier.totalTokens)} tokens
|
||||
{fmtFull(tier.requests)} {t("chartRequests")} · {fmt(tier.totalTokens)} tokens
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1129,7 +1147,9 @@ export function ServiceTierBreakdown({ byServiceTier, summary }) {
|
||||
<div className="font-mono text-sm font-semibold text-amber-500">
|
||||
{fmtCost(tier.cost)}
|
||||
</div>
|
||||
<div className="text-xs text-text-muted">{costPct}% of cost</div>
|
||||
<div className="text-xs text-text-muted">
|
||||
{t("chartCostPct", { pct: costPct })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-black/5 dark:bg-white/10 overflow-hidden">
|
||||
@@ -1149,16 +1169,17 @@ export function ServiceTierBreakdown({ byServiceTier, summary }) {
|
||||
// ── UsageDetail ────────────────────────────────────────────────────────────
|
||||
|
||||
export function UsageDetail({ summary }) {
|
||||
const t = useTranslations("analytics");
|
||||
const items = [
|
||||
{ label: "Input", value: summary?.promptTokens, color: "text-primary" },
|
||||
{ label: "Cache read", value: 0, color: "text-text-muted" },
|
||||
{ label: "Output", value: summary?.completionTokens, color: "text-emerald-500" },
|
||||
{ label: t("chartInput"), value: summary?.promptTokens, color: "text-primary" },
|
||||
{ label: t("chartCacheRead"), value: 0, color: "text-text-muted" },
|
||||
{ label: t("chartOutput"), value: summary?.completionTokens, color: "text-emerald-500" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="p-4 flex-1">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
Usage Detail
|
||||
{t("chartUsageDetail")}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-2">
|
||||
{items.map((item, i) => (
|
||||
@@ -1188,6 +1209,7 @@ const PROVIDER_COLORS = [
|
||||
];
|
||||
|
||||
export function ProviderCostDonut({ byProvider }) {
|
||||
const t = useTranslations("analytics");
|
||||
const data = useMemo(() => byProvider || [], [byProvider]);
|
||||
const hasData = data.length > 0 && data.some((p) => p.cost > 0);
|
||||
|
||||
@@ -1207,9 +1229,9 @@ export function ProviderCostDonut({ byProvider }) {
|
||||
return (
|
||||
<Card className="p-4 flex-1">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
Cost by Provider
|
||||
{t("chartCostByProvider")}
|
||||
</h3>
|
||||
<div className="text-center text-text-muted text-sm py-8">No cost data</div>
|
||||
<div className="text-center text-text-muted text-sm py-8">{t("chartNoCostData")}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1217,7 +1239,7 @@ export function ProviderCostDonut({ byProvider }) {
|
||||
return (
|
||||
<Card className="p-4 flex-1">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
Cost by Provider
|
||||
{t("chartCostByProvider")}
|
||||
</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<ResponsiveContainer width={120} height={120}>
|
||||
@@ -1264,6 +1286,7 @@ export function ProviderCostDonut({ byProvider }) {
|
||||
// ── ModelOverTimeChart (Stacked Area) ──────────────────────────────────────
|
||||
|
||||
export function ModelOverTimeChart({ dailyByModel, modelNames }) {
|
||||
const t = useTranslations("analytics");
|
||||
const data = useMemo(() => dailyByModel || [], [dailyByModel]);
|
||||
const models = useMemo(() => modelNames || [], [modelNames]);
|
||||
|
||||
@@ -1284,9 +1307,9 @@ export function ModelOverTimeChart({ dailyByModel, modelNames }) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
Model Usage Over Time
|
||||
{t("chartModelUsageOverTime")}
|
||||
</h3>
|
||||
<div className="text-center text-text-muted text-sm py-8">No data</div>
|
||||
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1294,7 +1317,7 @@ export function ModelOverTimeChart({ dailyByModel, modelNames }) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
Model Usage Over Time
|
||||
{t("chartModelUsageOverTime")}
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
|
||||
@@ -1346,6 +1369,7 @@ export function ModelOverTimeChart({ dailyByModel, modelNames }) {
|
||||
// ── ProviderTable ──────────────────────────────────────────────────────────
|
||||
|
||||
export function ProviderTable({ byProvider }) {
|
||||
const t = useTranslations("analytics");
|
||||
const [sortBy, setSortBy] = useState("totalTokens");
|
||||
const [sortOrder, setSortOrder] = useState("desc");
|
||||
|
||||
@@ -1380,9 +1404,9 @@ export function ProviderTable({ byProvider }) {
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider mb-3">
|
||||
Provider Breakdown
|
||||
{t("chartProviderBreakdown")}
|
||||
</h3>
|
||||
<div className="text-center text-text-muted text-sm py-8">No data</div>
|
||||
<div className="text-center text-text-muted text-sm py-8">{t("chartNoData")}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1391,7 +1415,7 @@ export function ProviderTable({ byProvider }) {
|
||||
<Card className="overflow-hidden">
|
||||
<div className="p-4 border-b border-border">
|
||||
<h3 className="text-sm font-semibold text-text-muted uppercase tracking-wider">
|
||||
Provider Breakdown
|
||||
{t("chartProviderBreakdown")}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
@@ -1402,40 +1426,44 @@ export function ProviderTable({ byProvider }) {
|
||||
className="px-4 py-2.5 text-left cursor-pointer group"
|
||||
onClick={() => toggleSort("provider")}
|
||||
>
|
||||
Provider <SortIndicator active={sortBy === "provider"} sortOrder={sortOrder} />
|
||||
{t("chartProvider")}{" "}
|
||||
<SortIndicator active={sortBy === "provider"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("requests")}
|
||||
>
|
||||
Requests <SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
|
||||
{t("chartRequests")}{" "}
|
||||
<SortIndicator active={sortBy === "requests"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("promptTokens")}
|
||||
>
|
||||
Input <SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} />
|
||||
{t("chartInput")}{" "}
|
||||
<SortIndicator active={sortBy === "promptTokens"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("completionTokens")}
|
||||
>
|
||||
Output{" "}
|
||||
{t("chartOutput")}{" "}
|
||||
<SortIndicator active={sortBy === "completionTokens"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("totalTokens")}
|
||||
>
|
||||
Total <SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} />
|
||||
{t("chartTotal")}{" "}
|
||||
<SortIndicator active={sortBy === "totalTokens"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-2.5 text-right cursor-pointer group"
|
||||
onClick={() => toggleSort("cost")}
|
||||
>
|
||||
Cost <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} />
|
||||
{t("chartCost")} <SortIndicator active={sortBy === "cost"} sortOrder={sortOrder} />
|
||||
</th>
|
||||
<th className="px-4 py-2.5 text-right w-36">Share</th>
|
||||
<th className="px-4 py-2.5 text-right w-36">{t("chartShare")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
|
||||
Reference in New Issue
Block a user