Compare commits

...

5 Commits

Author SHA1 Message Date
Alex Jordan
b2925fde9f chore(changelog): add PR 9245 fragment 2026-08-04 08:31:07 -03:00
Alex Jordan
9a64e0e17d test(i18n): cover hardcoded UI regressions 2026-08-04 08:31:06 -03:00
Alex Jordan
e2eba05e5c fix(i18n): localize hardcoded web UI copy 2026-08-04 08:31:01 -03:00
Alex Jordan
9e628e73fa chore(changelog): add PR 9235 fragment 2026-08-04 08:25:11 -03:00
Alex Jordan
9061d7c70d fix(i18n): complete French UI catalog 2026-08-04 08:25:11 -03:00
118 changed files with 6490 additions and 1850 deletions

View File

@@ -0,0 +1 @@
- **fix(i18n):** completed the French UI catalog by adding all missing keys and replacing every placeholder translation ([#9235](https://github.com/diegosouzapw/OmniRoute/pull/9235)) — thanks @alex-jordan547

View File

@@ -0,0 +1 @@
- **fix(i18n):** localized hardcoded web UI copy across public pages, dashboard views, and shared components, with complete French and Vietnamese coverage ([#9245](https://github.com/diegosouzapw/OmniRoute/pull/9245)) — thanks @alex-jordan547

View File

@@ -27,22 +27,17 @@ export default function BootstrapBanner() {
<span className="text-amber-500 dark:text-amber-400 text-base shrink-0 mt-0.5"></span>
<div className="flex-1 min-w-0">
<p className="font-semibold text-amber-900 dark:text-amber-300">
Running in zero-config mode
{t("zeroConfigBannerTitle")}
</p>
<p className="mt-0.5 text-amber-800/80 dark:text-amber-200/80">
OmniRoute auto-generated secure encryption keys on first launch. They are persisted to{" "}
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
{dataDir}
</code>
. No action is required your data is encrypted and safe. To use custom keys, add{" "}
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
JWT_SECRET
</code>{" "}
and{" "}
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
STORAGE_ENCRYPTION_KEY
</code>{" "}
to that file.
{t.rich("zeroConfigBannerBody", {
dataDir,
code: (chunks) => (
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
{chunks}
</code>
),
})}
</p>
</div>
<button

View File

@@ -144,31 +144,31 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
const cleanLatest = latest.replace(/^v/, "");
if (platform === "darwin") {
return {
label: "Download DMG (macOS)",
label: t("downloadDmg"),
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.dmg`,
desc: `A new version of the OmniRoute desktop app is available. Please download and install the macOS DMG installer to update (current: v${versionInfo?.current || ""}).`,
desc: t("downloadDmgDescription", { version: versionInfo?.current || "" }),
};
}
if (platform === "win32") {
return {
label: "Download EXE (Windows)",
label: t("downloadExe"),
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute.Setup.${cleanLatest}.exe`,
desc: `A new version of the OmniRoute desktop app is available. Please download and install the Windows EXE installer to update (current: v${versionInfo?.current || ""}).`,
desc: t("downloadExeDescription", { version: versionInfo?.current || "" }),
};
}
if (platform === "linux") {
return {
label: "Download AppImage (Linux)",
label: t("downloadAppImage"),
url: `https://github.com/diegosouzapw/OmniRoute/releases/download/v${cleanLatest}/OmniRoute-${cleanLatest}.AppImage`,
desc: `A new version of the OmniRoute desktop app is available. Please download the Linux AppImage package to update (current: v${versionInfo?.current || ""}).`,
desc: t("downloadAppImageDescription", { version: versionInfo?.current || "" }),
};
}
return {
label: "Download Update",
label: t("downloadUpdate"),
url: `https://github.com/diegosouzapw/OmniRoute/releases/tag/v${cleanLatest}`,
desc: `A new version of the OmniRoute desktop app is available. Please download the respective app format for your system to update (current: v${versionInfo?.current || ""}).`,
desc: t("downloadUpdateDescription", { version: versionInfo?.current || "" }),
};
}, [platform, versionInfo?.latest, versionInfo?.current]);
}, [platform, t, versionInfo?.latest, versionInfo?.current]);
// Electron internal auto-updater state and listeners
const [electronUpdateStatus, setElectronUpdateStatus] = useState<{
@@ -539,29 +539,29 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
{
step: "install",
status: "done",
message: message || `Queued update to v${targetVersion}.`,
message: message || t("updateQueued", { version: targetVersion }),
},
{
step: "rebuild",
status: "running",
message: "Docker image is rebuilding in the background.",
message: t("updateDockerRebuilding"),
},
{
step: "restart",
status: "pending",
message: "Waiting for OmniRoute to restart with the new version.",
message: t("updateWaitingRestart"),
},
]
: [
{
step: "install",
status: "running",
message: message || `Installing v${targetVersion}.`,
message: message || t("updateInstalling", { version: targetVersion }),
},
{
step: "restart",
status: "pending",
message: "Waiting for OmniRoute to restart with the new version.",
message: t("updateWaitingRestart"),
},
];
@@ -593,14 +593,14 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
next = mergeUpdateStep(next, {
step: "complete",
status: "done",
message: `OmniRoute is now running v${targetVersion}.`,
message: t("updateRunning", { version: targetVersion }),
});
return next;
});
setUpdating(false);
setUpdatePhase("done");
notify.success(`OmniRoute updated to v${targetVersion}.`);
notify.success(t("updateCompleted", { version: targetVersion }));
await fetchData();
return;
}
@@ -611,20 +611,20 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
next = mergeUpdateStep(next, {
step: "rebuild",
status: "running",
message: `Docker image is still rebuilding for v${targetVersion}.`,
message: t("updateDockerStillRebuilding", { version: targetVersion }),
});
} else {
next = mergeUpdateStep(next, {
step: "install",
status: "running",
message: `Installing v${targetVersion} in the background.`,
message: t("updateInstallingBackground", { version: targetVersion }),
});
}
next = mergeUpdateStep(next, {
step: "restart",
status: "pending",
message: `Waiting for OmniRoute to come back on v${targetVersion}.`,
message: t("updateWaitingVersion", { version: targetVersion }),
});
return next;
@@ -636,20 +636,20 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
next = mergeUpdateStep(next, {
step: "rebuild",
status: "running",
message: "Docker rebuild is still in progress.",
message: t("updateDockerStillInProgress"),
});
} else {
next = mergeUpdateStep(next, {
step: "install",
status: "running",
message: `Installing v${targetVersion} in the background.`,
message: t("updateInstallingBackground", { version: targetVersion }),
});
}
next = mergeUpdateStep(next, {
step: "restart",
status: "running",
message: "Service restart in progress. Waiting for OmniRoute to come back online...",
message: t("updateRestarting"),
});
return next;
@@ -661,14 +661,14 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
mergeUpdateStep(prev, {
step: "error",
status: "failed",
message: `Update started, but v${targetVersion} did not become available before timeout. Refresh the page or check server logs.`,
message: t("updateTimeout", { version: targetVersion }),
})
);
setUpdating(false);
setUpdatePhase("failed");
notify.error(`Update to v${targetVersion} timed out.`);
notify.error(t("updateTimedOut", { version: targetVersion }));
},
[fetchData]
[fetchData, t]
);
const handleUpdate = async () => {
@@ -689,12 +689,12 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
// Passing the raw object to notify.error() rendered it as a React child →
// "Minified React error #31" crash ("Internal Server Error" screen), e.g. on
// the 403 from the loopback-only /api/system/version. Extract the string.
notify.error(extractApiErrorMessage(data, "Failed to start update."));
notify.error(extractApiErrorMessage(data, t("updateStartFailed")));
setUpdating(false);
setUpdatePhase("idle");
return;
}
notify.success(data.message || "Update started.");
notify.success(data.message || t("updateStarted"));
await pollBackgroundUpdate({
channel: data.channel || "docker-compose",
message: data.message || "",
@@ -705,7 +705,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
// SSE stream — read progress events
if (!res.body) {
notify.error("No response stream received.");
notify.error(t("noResponseStream"));
setUpdating(false);
setUpdatePhase("idle");
return;
@@ -735,10 +735,10 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
if (event.step === "complete") {
setUpdatePhase("done");
setUpdating(false);
notify.success(event.message || "Update complete!");
notify.success(event.message || t("updateComplete"));
} else if (event.step === "error") {
setUpdatePhase("failed");
notify.error(event.message || "Update failed.");
notify.error(event.message || t("updateFailed"));
setUpdating(false);
}
} catch {
@@ -753,7 +753,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
{
step: "error",
status: "failed",
message: "Network error — connection lost during update.",
message: t("updateNetworkError"),
},
]);
setUpdating(false);
@@ -769,11 +769,11 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
return () => clearTimeout(timer);
}, [updatePhase]);
const stepLabels: Record<string, string> = {
install: "Install Package",
rebuild: "Rebuild Native Modules",
restart: "Restart Service",
complete: "Complete",
error: "Error",
install: t("stepInstallPackage"),
rebuild: t("stepRebuildNativeModules"),
restart: t("stepRestartService"),
complete: t("stepComplete"),
error: t("stepError"),
};
const showUpdateOverlay = updatePhase !== "idle";
@@ -801,17 +801,17 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
<div>
<h3 className="text-lg font-bold">
{updatePhase === "done"
? "Update Complete!"
? t("updateCompleteTitle")
: updatePhase === "failed"
? "Update Failed"
: "Updating OmniRoute..."}
? t("updateFailedTitle")
: t("updatingTitle")}
</h3>
<p className="text-xs text-text-muted mt-0.5">
{updatePhase === "done"
? "The page will reload automatically in a few seconds."
? t("reloadNotice")
: updatePhase === "failed"
? "Please try again or update manually via the CLI."
: "Do not close this page. The system will restart automatically."}
? t("retryNotice")
: t("restartNotice")}
</p>
</div>
</div>
@@ -871,7 +871,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
<div className="mt-1 px-3 py-2.5 rounded-lg border border-green-500/30 bg-green-500/5">
<p className="text-sm font-semibold text-green-500 flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]">check_circle</span>
{updateSteps.find((s) => s.step === "complete")?.message || "Update complete!"}
{updateSteps.find((s) => s.step === "complete")?.message || t("updateComplete")}
</p>
<p className="text-xs text-text-muted mt-1">{t("reloadingPageAutomatically")}</p>
</div>
@@ -891,11 +891,11 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
if (updatePhase === "done") globalThis.window.location.reload();
}}
>
{updatePhase === "done" ? "Reload Now" : "Close"}
{updatePhase === "done" ? t("reloadNow") : t("closeUpdate")}
</Button>
{updatePhase === "failed" && (
<Button size="sm" variant="secondary" fullWidth onClick={handleUpdate}>
Retry
{t("retryUpdate")}
</Button>
)}
</div>
@@ -917,30 +917,32 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
</span>
<div>
<p className="font-semibold text-sm">
Update Available: v{versionInfo.latest} {isElectron && "(Desktop App)"}
{t("updateAvailableTitle", {
version: versionInfo.latest,
desktop: isElectron ? ` ${t("desktopAppLabel")}` : "",
})}
</p>
<p className="text-xs opacity-80 mt-0.5">
{isElectron ? (
<>
{electronUpdateStatus.status === "checking" && "Checking for updates..."}
{electronUpdateStatus.status === "checking" && t("checkingForUpdates")}
{electronUpdateStatus.status === "available" &&
`Version v${versionInfo.latest} is available for download.`}
t("versionAvailableForDownload", { version: versionInfo.latest })}
{electronUpdateStatus.status === "downloading" &&
`Downloading update... ${electronUpdateStatus.percent || 0}% complete.`}
{electronUpdateStatus.status === "downloaded" &&
"Update downloaded successfully! Click Restart & Install to apply."}
t("downloadingUpdate", { percent: electronUpdateStatus.percent || 0 })}
{electronUpdateStatus.status === "downloaded" && t("updateDownloaded")}
{electronUpdateStatus.status === "error" &&
`Auto-update failed: ${electronUpdateStatus.message || "Unknown error"}.`}
t("autoUpdateFailed", {
reason: electronUpdateStatus.message || t("unknownUpdateError"),
})}
{(electronUpdateStatus.status === "idle" ||
electronUpdateStatus.status === "not-available") &&
`Version v${versionInfo.latest} is available for the desktop app.`}
t("versionAvailableDesktop", { version: versionInfo.latest })}
</>
) : versionInfo.autoUpdateSupported ? (
t("updateAvailableDesc") ||
`You are currently using v${versionInfo.current}. Update to access the latest features and bug fixes.`
t("updateAvailableDesc")
) : (
versionInfo.autoUpdateError ||
"Manual update required for this installation type."
versionInfo.autoUpdateError || t("manualUpdateRequired")
)}
</p>
</div>
@@ -954,7 +956,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
onClick={() => globalThis.window.electronAPI?.downloadUpdate()}
className="font-semibold"
>
Download Update
{t("downloadUpdate")}
</Button>
)}
{electronUpdateStatus.status === "downloading" && (
@@ -973,7 +975,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
onClick={() => globalThis.window.electronAPI?.installUpdate()}
className="font-semibold animate-pulse"
>
Restart & Install
{t("restartAndInstall")}
</Button>
)}
{(electronUpdateStatus.status === "error" ||
@@ -989,7 +991,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
}}
className="font-semibold"
>
Check for Update
{t("checkForUpdate")}
</Button>
)}
</div>
@@ -1001,9 +1003,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
className="ml-4 shrink-0 font-semibold"
title={versionInfo.autoUpdateError || ""}
>
{versionInfo.autoUpdateSupported
? t("updateNow") || "Update Now"
: "Manual Update"}
{versionInfo.autoUpdateSupported ? t("updateNow") : t("manualUpdate")}
</Button>
)}
</div>
@@ -1015,9 +1015,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
electronUpdateStatus.status === "available" ||
electronUpdateStatus.status === "not-available") && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between border-t border-primary/20 mt-2 pt-3 gap-2">
<p className="text-xs opacity-75">
Or download the respective installer format directly:
</p>
<p className="text-xs opacity-75">{t("directDownloadHint")}</p>
<div className="flex gap-2">
<Button
size="sm"
@@ -1029,7 +1027,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
}
className="font-semibold text-xs py-1"
>
Release Notes
{t("releaseNotes")}
</Button>
<Button
size="sm"
@@ -1067,7 +1065,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) {
rel="noopener noreferrer"
className="ml-4 inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-border bg-bg px-4 py-2 text-xs font-semibold text-text-main transition-colors hover:border-primary/30 hover:text-primary"
>
{versionInfo.news.linkLabel || "Ler Mais"}
{versionInfo.news.linkLabel || t("readMore")}
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
</a>
)}
@@ -1214,7 +1212,7 @@ function ProviderOverviewCard({
item.errors > 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted";
const authTypeConfig = {
"no-auth": { color: "bg-stone-500", label: "No Auth" },
"no-auth": { color: "bg-stone-500", label: t("noAuthLabel") },
free: { color: "bg-green-500", label: tc("free") },
oauth: { color: "bg-blue-500", label: t("oauthLabel") },
apikey: { color: "bg-amber-500", label: t("apiKeyLabel") },

View File

@@ -42,9 +42,7 @@ export default function AutoRoutingAnalyticsTab() {
if (!stats) {
return (
<Card>
<div className="text-center py-8 text-text-muted">
No auto-routing analytics available. Make requests using the auto/ prefix to see metrics.
</div>
<div className="text-center py-8 text-text-muted">{t("autoRoutingNoDataAvailable")}</div>
</Card>
);
}
@@ -110,7 +108,9 @@ export default function AutoRoutingAnalyticsTab() {
const percentage = stats.totalRequests > 0 ? (count / stats.totalRequests) * 100 : 0;
return (
<div key={variant} className="flex items-center gap-3">
<div className="w-32 text-sm font-medium capitalize">{variant || "default"}</div>
<div className="w-32 text-sm font-medium capitalize">
{variant || t("defaultVariantLabel")}
</div>
<div className="flex-1 h-3 bg-border rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all"
@@ -133,9 +133,9 @@ export default function AutoRoutingAnalyticsTab() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 px-3 font-medium">Provider</th>
<th className="text-right py-2 px-3 font-medium">Requests</th>
<th className="text-right py-2 px-3 font-medium">Share</th>
<th className="text-left py-2 px-3 font-medium">{t("chartProvider")}</th>
<th className="text-right py-2 px-3 font-medium">{t("chartRequests")}</th>
<th className="text-right py-2 px-3 font-medium">{t("chartShare")}</th>
</tr>
</thead>
<tbody>

View File

@@ -50,11 +50,13 @@ function ProviderBar({
count,
total,
costUsd,
queriesLabel,
}: {
provider: string;
count: number;
total: number;
costUsd: number;
queriesLabel: string;
}) {
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
return (
@@ -62,7 +64,7 @@ function ProviderBar({
<div className="flex justify-between text-sm">
<span className="font-medium text-text">{provider}</span>
<span className="text-text-muted">
{count} queries · ${costUsd.toFixed(4)}
{count} {queriesLabel} · ${costUsd.toFixed(4)}
</span>
</div>
<div className="h-2 rounded-full bg-bg-muted overflow-hidden">
@@ -99,7 +101,7 @@ export default function SearchAnalyticsTab() {
return (
<div className="flex items-center justify-center py-16 text-text-muted">
<span className="material-symbols-outlined animate-spin mr-2">progress_activity</span>
Loading search analytics
{t("searchAnalyticsLoading")}
</div>
);
}
@@ -108,9 +110,11 @@ export default function SearchAnalyticsTab() {
return (
<div className="card p-6 text-center text-text-muted">
<span className="material-symbols-outlined text-[32px] mb-2 block">search_off</span>
{error || "No search data available yet."}
{error || t("searchAnalyticsNoData")}
<p className="text-xs mt-2">
Search requests will appear here after the first search via /v1/search.
{t.rich("searchAnalyticsNoDataDescription", {
code: (chunks) => <code className="bg-bg-muted px-1 rounded">{chunks}</code>,
})}
</p>
</div>
);
@@ -126,25 +130,29 @@ export default function SearchAnalyticsTab() {
icon="manage_search"
label={t("searchAnalyticsTotalSearches")}
value={stats.total.toLocaleString()}
sub={`${stats.today} today`}
sub={t("searchAnalyticsToday", { count: stats.today })}
/>
<StatCard
icon="cached"
label={t("searchAnalyticsCacheHitRate")}
value={`${stats.cacheHitRate}%`}
sub={`${stats.cached} cached requests`}
sub={t("searchAnalyticsCachedRequests", { count: stats.cached })}
/>
<StatCard
icon="attach_money"
label={t("searchAnalyticsTotalCost")}
value={`$${stats.totalCostUsd.toFixed(4)}`}
sub="search API costs"
sub={t("searchAnalyticsApiCosts")}
/>
<StatCard
icon="timer"
label={t("searchAnalyticsAvgResponse")}
value={`${stats.avgDurationMs}ms`}
sub={stats.errors > 0 ? `${stats.errors} errors` : "No errors"}
sub={
stats.errors > 0
? t("searchAnalyticsErrors", { count: stats.errors })
: t("searchAnalyticsNoErrors")
}
/>
</div>
@@ -153,7 +161,7 @@ export default function SearchAnalyticsTab() {
<div className="card p-5">
<h3 className="font-semibold text-text mb-4 flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[20px]">hub</span>
Provider Breakdown
{t("searchAnalyticsProviderBreakdown")}
</h3>
<div className="flex flex-col gap-4">
{providers.map(([prov, data]) => (
@@ -163,6 +171,7 @@ export default function SearchAnalyticsTab() {
count={data.count}
total={stats.total}
costUsd={data.costUsd}
queriesLabel={t("searchAnalyticsQueries")}
/>
))}
</div>
@@ -177,8 +186,9 @@ export default function SearchAnalyticsTab() {
</span>
<p className="font-medium text-text">{t("searchAnalyticsNoSearchesYet")}</p>
<p className="text-sm mt-1">
Use <code className="bg-bg-muted px-1 rounded">POST /v1/search</code> to start routing
web searches.
{t.rich("searchAnalyticsEmptyDescription", {
code: (chunks) => <code className="bg-bg-muted px-1 rounded">{chunks}</code>,
})}
</p>
</div>
)}
@@ -189,8 +199,9 @@ export default function SearchAnalyticsTab() {
check_circle
</span>
<span>
<strong>Free tier available:</strong> Serper (2,500/mo), Brave (2,000/mo), Exa (1,000/mo),
Tavily (1,000/mo) total 6,500+ free searches/month with automatic failover.
{t.rich("searchAnalyticsFreeTier", {
strong: (chunks) => <strong>{chunks}</strong>,
})}
</span>
</div>
</div>

View File

@@ -122,23 +122,23 @@ export default function McpAuditTab() {
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{[
{
label: "Calls (24h)",
label: t("mcpMetricCalls24h"),
value: stats.totalCalls.toLocaleString(),
icon: "terminal",
},
{
label: "Success rate",
label: t("mcpMetricSuccessRate"),
value: `${Math.round(stats.successRate * 100)}%`,
icon: "check_circle",
highlight: stats.successRate >= 0.9,
},
{
label: "Avg duration",
label: t("mcpMetricAvgDuration"),
value: `${Math.round(stats.avgDurationMs)}ms`,
icon: "timer",
},
{
label: "Top tool",
label: t("mcpMetricTopTool"),
value: stats.topTools[0]?.tool ?? "—",
icon: "star",
},

View File

@@ -4,7 +4,9 @@ import { useEffect } from "react";
import { useTranslations } from "next-intl";
import { useBatchActions } from "./components/useBatchActions";
function relativeTime(ts: number): string {
type BatchTranslator = ReturnType<typeof useTranslations>;
function relativeTime(ts: number, t: BatchTranslator): string {
const diffMs = Date.now() - ts * 1000;
const isFuture = diffMs < 0;
const absDiffMs = Math.abs(diffMs);
@@ -22,8 +24,9 @@ function relativeTime(ts: number): string {
}
}
if (isFuture) return `in ${res}`;
return `${res} ago`;
return isFuture
? t("batchRelativeTimeIn", { value: res })
: t("batchRelativeTimeAgo", { value: res });
}
interface BatchRecord {
@@ -93,6 +96,22 @@ const STATUS_LABELS: Record<string, string> = {
expired_with_failures: "expired (partial)",
};
const STATUS_TRANSLATION_KEYS: Record<string, string> = {
completed: "batchStatusCompleted",
completed_with_failures: "batchStatusCompletedWithFailures",
failed: "batchStatusFailed",
in_progress: "batchStatusInProgress",
in_progress_with_failures: "batchStatusInProgressWithFailures",
finalizing: "batchStatusFinalizing",
finalizing_with_failures: "batchStatusFinalizingWithFailures",
validating: "batchStatusValidating",
cancelling: "batchStatusCancelling",
cancelled: "batchStatusCancelled",
cancelled_with_failures: "batchStatusCancelledWithFailures",
expired: "batchStatusExpired",
expired_with_failures: "batchStatusExpiredWithFailures",
};
function effectiveStatus(batch: BatchRecord): string {
const hasFailed = (batch.requestCountsFailed ?? 0) > 0;
if (!hasFailed) return batch.status;
@@ -106,10 +125,12 @@ function effectiveStatus(batch: BatchRecord): string {
return map[batch.status] ?? batch.status;
}
function StatusBadge({ batch }: { batch: BatchRecord }) {
function StatusBadge({ batch, t }: { batch: BatchRecord; t: BatchTranslator }) {
const key = effectiveStatus(batch);
const cls = STATUS_STYLES[key] ?? "bg-gray-500/15 text-gray-400 border-gray-500/25";
const label = STATUS_LABELS[key] ?? key.replace(/_/g, " ");
const label = STATUS_TRANSLATION_KEYS[key]
? t(STATUS_TRANSLATION_KEYS[key])
: (STATUS_LABELS[key] ?? key.replace(/_/g, " "));
return (
<span className={`inline-block px-2 py-0.5 rounded-md text-xs font-medium border ${cls}`}>
{label}
@@ -141,12 +162,24 @@ function formatTs(ts: number | null | undefined): string {
});
}
export default function BatchDetailModal({ batch, files, onClose, onActionDone }: BatchDetailModalProps) {
export default function BatchDetailModal({
batch,
files,
onClose,
onActionDone,
}: BatchDetailModalProps) {
const t = useTranslations("common");
// ── Action hook (F7) ─────────────────────────────────────────────────────────
const { cancelling, retrying, error: actionError, cancel, retry, downloadHrefOutput, downloadHrefErrors } =
useBatchActions({ onRefresh: onActionDone, t });
const {
cancelling,
retrying,
error: actionError,
cancel,
retry,
downloadHrefOutput,
downloadHrefErrors,
} = useBatchActions({ onRefresh: onActionDone, t });
// ── Status flags ──────────────────────────────────────────────────────────────
const isTerminal = ["completed", "failed", "cancelled", "expired"].includes(batch.status);
@@ -195,8 +228,11 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
pending_actions
</span>
<div>
<h2 id="batch-detail-modal-title" className="text-base font-semibold text-[var(--color-text-main)]">
Batch Details
<h2
id="batch-detail-modal-title"
className="text-base font-semibold text-[var(--color-text-main)]"
>
{t("batchDetailsTitle")}
</h2>
<div className="flex items-center gap-2 mt-0.5">
<p className="text-xs text-[var(--color-text-muted)] font-mono">{batch.id}</p>
@@ -227,16 +263,18 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<div className="flex flex-col gap-0.5">
<span className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
Status
{t("status")}
</span>
<StatusBadge batch={batch} />
<StatusBadge batch={batch} t={t} />
</div>
<Field label={t("batchDetailEndpoint")} value={batch.endpoint} />
{batch.model && <Field label={t("batchDetailModel")} value={batch.model} />}
<Field label={t("batchDetailWindow")} value={batch.completionWindow} />
<Field
label={t("batchDetailCreated")}
value={<span title={formatTs(batch.createdAt)}>{relativeTime(batch.createdAt)}</span>}
value={
<span title={formatTs(batch.createdAt)}>{relativeTime(batch.createdAt, t)}</span>
}
/>
</div>
@@ -245,7 +283,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--color-text-muted)] uppercase tracking-wider font-medium">
Progress
{t("batchProgress")}
</span>
<span className="text-[var(--color-text-muted)]">
{completed} / {total} ({pct}%)
@@ -262,17 +300,9 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
/>
</div>
<div className="flex gap-4 text-xs text-[var(--color-text-muted)]">
<span>
<span className="text-emerald-400 font-medium">{completed}</span> completed
</span>
{failed > 0 && (
<span>
<span className="text-red-400 font-medium">{failed}</span> failed
</span>
)}
<span>
<span className="font-medium">{total - completed - failed}</span> pending
</span>
<span>{t("batchCompletedCount", { count: completed })}</span>
{failed > 0 && <span>{t("batchFailedCount", { count: failed })}</span>}
<span>{t("batchPendingCount", { count: total - completed - failed })}</span>
</div>
</div>
)}
@@ -280,19 +310,19 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
{/* Timestamps */}
<div>
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
Timeline
{t("batchTimeline")}
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
{[
{ label: "Created", ts: batch.createdAt },
{ label: "In Progress", ts: batch.inProgressAt },
{ label: "Finalizing", ts: batch.finalizingAt },
{ label: "Completed", ts: batch.completedAt },
{ label: "Failed", ts: batch.failedAt },
{ label: "Expires", ts: batch.expiresAt },
{ label: "Expired", ts: batch.expiredAt },
{ label: "Cancelling", ts: batch.cancellingAt },
{ label: "Cancelled", ts: batch.cancelledAt },
{ label: t("batchDetailCreated"), ts: batch.createdAt },
{ label: t("batchTimelineInProgress"), ts: batch.inProgressAt },
{ label: t("batchTimelineFinalizing"), ts: batch.finalizingAt },
{ label: t("batchTimelineCompleted"), ts: batch.completedAt },
{ label: t("batchTimelineFailed"), ts: batch.failedAt },
{ label: t("batchTimelineExpires"), ts: batch.expiresAt },
{ label: t("batchTimelineExpired"), ts: batch.expiredAt },
{ label: t("batchTimelineCancelling"), ts: batch.cancellingAt },
{ label: t("batchTimelineCancelled"), ts: batch.cancelledAt },
]
.filter((t) => t.ts)
.map(({ label, ts }) => (
@@ -315,9 +345,21 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
</h3>
<div className="space-y-2">
{[
{ role: "Input", fileId: batch.inputFileId, record: inputFile },
{ role: "Output", fileId: batch.outputFileId, record: outputFile },
{ role: "Errors", fileId: batch.errorFileId, record: errorFile },
{
role: t("filesListUsedByRoleInput"),
fileId: batch.inputFileId,
record: inputFile,
},
{
role: t("filesListUsedByRoleOutput"),
fileId: batch.outputFileId,
record: outputFile,
},
{
role: t("filesListUsedByRoleError"),
fileId: batch.errorFileId,
record: errorFile,
},
]
.filter((f) => f.fileId)
.map(({ role, fileId, record }) => (
@@ -350,7 +392,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text-main)] transition-colors"
>
<span className="material-symbols-outlined text-[13px]">download</span>
Download
{t("filesListDownload")}
</a>
</div>
</div>
@@ -362,7 +404,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
{batch.usage && (
<div>
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
Token Usage
{t("batchTokenUsage")}
</h3>
<pre className="p-3 rounded-lg bg-[var(--color-bg-alt)] border border-[var(--color-border)] text-xs font-mono text-[var(--color-text-main)] overflow-x-auto">
{JSON.stringify(batch.usage, null, 2)}
@@ -374,7 +416,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
{batch.errors && (
<div>
<h3 className="text-[11px] uppercase tracking-wider font-medium text-red-400 mb-3">
Errors
{t("errors")}
</h3>
<pre className="p-3 rounded-lg bg-red-500/5 border border-red-500/20 text-xs font-mono text-red-300 overflow-x-auto">
{JSON.stringify(batch.errors, null, 2)}
@@ -386,7 +428,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
{batch.metadata && Object.keys(batch.metadata).length > 0 && (
<div>
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-3">
Metadata
{t("batchMetadata")}
</h3>
<div className="space-y-1">
{Object.entries(batch.metadata).map(([k, v]) => (
@@ -438,7 +480,7 @@ export default function BatchDetailModal({ batch, files, onClose, onActionDone }
if (
window.confirm(
t("batchDetailActionRetry") +
` (${batch.requestCountsFailed} ${t("batchActionRetry")})?`,
` (${batch.requestCountsFailed} ${t("batchActionRetry")})?`
)
) {
const result = await retry({

View File

@@ -4,22 +4,24 @@ import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components";
function relativeTime(ts: number): string {
type FileTranslator = ReturnType<typeof useTranslations>;
function relativeTime(ts: number, t: FileTranslator): string {
const diffMs = Date.now() - ts * 1000;
const diffSec = Math.round(diffMs / 1000);
if (diffSec < 60) return `${diffSec}s ago`;
if (diffSec < 60) return t("batchRelativeTimeAgo", { value: `${diffSec}s` });
const diffMin = Math.round(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
if (diffMin < 60) return t("batchRelativeTimeAgo", { value: `${diffMin}m` });
const diffHr = Math.round(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
if (diffHr < 24) return t("batchRelativeTimeAgo", { value: `${diffHr}h` });
const diffDays = Math.round(diffHr / 24);
return `${diffDays}d ago`;
return t("batchRelativeTimeAgo", { value: `${diffDays}d` });
}
function relativeExpiration(ts: number | null): string {
if (!ts) return "Never";
function relativeExpiration(ts: number | null, t: FileTranslator): string {
if (!ts) return t("batchFilesNeverExpires");
const diffMs = ts * 1000 - Date.now();
if (diffMs <= 0) return "Expired";
if (diffMs <= 0) return t("expirationBadgeExpired");
const diffSec = Math.round(diffMs / 1000);
if (diffSec < 60) return `${diffSec}s`;
const diffMin = Math.round(diffSec / 60);
@@ -55,6 +57,22 @@ interface BatchRecord {
model?: string | null;
}
const BATCH_STATUS_TRANSLATION_KEYS: Record<string, string> = {
completed: "batchStatusCompleted",
completed_with_failures: "batchStatusCompletedWithFailures",
failed: "batchStatusFailed",
in_progress: "batchStatusInProgress",
in_progress_with_failures: "batchStatusInProgressWithFailures",
finalizing: "batchStatusFinalizing",
finalizing_with_failures: "batchStatusFinalizingWithFailures",
validating: "batchStatusValidating",
cancelling: "batchStatusCancelling",
cancelled: "batchStatusCancelled",
cancelled_with_failures: "batchStatusCancelledWithFailures",
expired: "batchStatusExpired",
expired_with_failures: "batchStatusExpiredWithFailures",
};
interface FileDetailModalProps {
file: FileRecord;
contents: string | null;
@@ -133,7 +151,7 @@ export default function FileDetailModal({
</span>
<div>
<h2 className="text-base font-semibold text-[var(--color-text-main)]">
File Contents
{t("batchFileContents")}
</h2>
<div className="flex items-center gap-2 mt-0.5">
<p className="text-xs text-[var(--color-text-muted)] font-mono">{file.id}</p>
@@ -164,7 +182,7 @@ export default function FileDetailModal({
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 rounded-xl bg-[var(--color-bg-alt)] border border-[var(--color-border)]">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
Size
{t("batchFilesSizeColumn")}
</span>
<span className="text-sm text-[var(--color-text-main)]">
{formatBytes(file.bytes)}
@@ -172,24 +190,24 @@ export default function FileDetailModal({
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
Purpose
{t("batchFilesPurpose")}
</span>
<span className="text-sm text-[var(--color-text-main)]">{file.purpose}</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
Created
{t("batchDetailCreated")}
</span>
<span className="text-sm text-[var(--color-text-main)]">
{createdAtTs ? relativeTime(createdAtTs) : "—"}
{createdAtTs ? relativeTime(createdAtTs, t) : "—"}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
Expires
{t("batchFilesExpires")}
</span>
<span className="text-sm text-[var(--color-text-main)]">
{expiresAtTs ? relativeExpiration(expiresAtTs) : "Never"}
{expiresAtTs ? relativeExpiration(expiresAtTs, t) : t("batchFilesNeverExpires")}
</span>
</div>
</div>
@@ -198,7 +216,7 @@ export default function FileDetailModal({
{relatedBatches.length > 0 && (
<div>
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-2">
Used by {relatedBatches.length} batch{relatedBatches.length > 1 ? "es" : ""}
{t("batchFileUsedByCount", { count: relatedBatches.length })}
</h3>
<div className="space-y-1.5">
{relatedBatches.map((b) => (
@@ -219,7 +237,9 @@ export default function FileDetailModal({
: "bg-gray-500/15 text-gray-400 border-gray-500/25"
}`}
>
{b.status.replaceAll("_", " ")}
{BATCH_STATUS_TRANSLATION_KEYS[b.status]
? t(BATCH_STATUS_TRANSLATION_KEYS[b.status])
: b.status.replaceAll("_", " ")}
</span>
</div>
))}
@@ -231,7 +251,7 @@ export default function FileDetailModal({
<div className="flex-1 flex flex-col min-h-[300px]">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Preview
{t("batchFilePreview")}
</h3>
{contents && (
<button
@@ -241,7 +261,7 @@ export default function FileDetailModal({
<span className="material-symbols-outlined text-[14px]">
{copied ? "check" : "content_copy"}
</span>
{copied ? "Copied!" : "Copy"}
{copied ? t("copied") : t("copy")}
</button>
)}
</div>
@@ -259,7 +279,7 @@ export default function FileDetailModal({
{isTruncated && (
<div className="mt-3 p-3 bg-yellow-500/10 border border-yellow-500/25 rounded-lg text-xs text-yellow-400 flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]">warning</span>
Showing first 1000 lines ({lineCount} total lines)
{t("batchFilePreviewTruncated", { shown: 1000, total: lineCount })}
</div>
)}
</div>
@@ -281,7 +301,7 @@ export default function FileDetailModal({
className="flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-lg bg-[var(--color-accent)] text-white hover:opacity-90 transition-opacity"
>
<span className="material-symbols-outlined text-[18px]">download</span>
Download Full File
{t("batchFileDownloadFull")}
</Button>
</div>
</div>

View File

@@ -0,0 +1,16 @@
export type ChaosTranslator = ((
key: string,
values?: Record<string, string | number>
) => string) & {
has?: (key: string) => boolean;
};
export function chaosText(
t: ChaosTranslator,
key: string,
fallback: string,
values?: Record<string, string | number>
): string {
if (typeof t.has !== "function" || !t.has(key)) return fallback;
return values ? t(key, values) : t(key);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
import { chaosText, type ChaosTranslator } from "../chaosI18n";
/**
* Save/Reset + Test-run action buttons for the Chaos Mode config page.
@@ -22,7 +23,7 @@ export function ChaosConfigActionsBar({
onReset: () => void;
onTest: () => void;
}) {
const t = useTranslations("chaosConfig");
const t = useTranslations("chaosConfig") as ChaosTranslator;
return (
<>
@@ -66,7 +67,7 @@ export function ChaosConfigActionsBar({
) : (
<span className="material-symbols-outlined text-[16px]">play_arrow</span>
)}
{testing ? "Running..." : t("testButton")}
{testing ? chaosText(t, "running", "Running...") : t("testButton")}
</button>
</div>
</>

View File

@@ -1,5 +1,8 @@
"use client";
import { useTranslations } from "next-intl";
import { chaosText, type ChaosTranslator } from "../chaosI18n";
export interface ChaosModelResult {
providerId: string;
providerName: string;
@@ -26,14 +29,24 @@ export interface ChaosTestResult {
* complexity/size ratchet (config/quality/complexity-baseline.json).
*/
export function ChaosTestResultsPanel({ result }: { result: ChaosTestResult }) {
const t = useTranslations("chaosConfig") as ChaosTranslator;
const resultsTitle = chaosText(
t,
"testResults",
`Test Results — ${result.mode} mode (${result.totalProviders} providers)`,
{ mode: result.mode, count: result.totalProviders }
);
const startedLabel = chaosText(
t,
"started",
`Started: ${new Date(result.startedAt).toLocaleTimeString()}`,
{ time: new Date(result.startedAt).toLocaleTimeString() }
);
return (
<div className="p-3 rounded-lg border border-border bg-surface/40 space-y-3">
<h3 className="text-sm font-bold text-text-main">
Test Results {result.mode} mode ({result.totalProviders} providers)
</h3>
<div className="text-xs text-text-muted">
Started: {new Date(result.startedAt).toLocaleTimeString()}
</div>
<h3 className="text-sm font-bold text-text-main">{resultsTitle}</h3>
<div className="text-xs text-text-muted">{startedLabel}</div>
{result.models.map((model, idx) => (
<div
key={idx}

View File

@@ -1,11 +1,13 @@
/**
* /dashboard/chaos/page.tsx — Chaos Mode Configuration
*/
import { getTranslations } from "next-intl/server";
import ChaosConfigPageClient from "./ChaosConfigPageClient";
export const metadata = {
title: "Chaos Mode — OmniRoute",
};
export async function generateMetadata() {
const t = await getTranslations("chaosConfig");
return { title: `${t("pageTitle")} — OmniRoute` };
}
export default function Page() {
return <ChaosConfigPageClient />;

View File

@@ -2,6 +2,7 @@
import { useCallback, useState, type Dispatch, type SetStateAction } from "react";
import { useTranslations } from "next-intl";
import { chaosText, type ChaosTranslator } from "./chaosI18n";
import type { ChaosPageConfig, ChaosPageMessage } from "./chaosPageTypes";
/**
@@ -13,7 +14,7 @@ export function useChaosConfigPersistence(
config: ChaosPageConfig,
setConfig: Dispatch<SetStateAction<ChaosPageConfig>>
) {
const t = useTranslations("chaosConfig");
const t = useTranslations("chaosConfig") as ChaosTranslator;
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<ChaosPageMessage>(null);
@@ -49,17 +50,26 @@ export function useChaosConfigPersistence(
if (res.ok) {
const data = await res.json();
setConfig(data.config);
setMessage({ type: "success", text: "Config reset to defaults" });
setMessage({
type: "success",
text: chaosText(t, "resetSuccess", "Config reset to defaults"),
});
} else {
const err = await res.json().catch(() => ({ error: "Reset failed" }));
setMessage({ type: "error", text: err.error || "Reset failed" });
const err = await res.json().catch(() => ({ error: null }));
setMessage({
type: "error",
text: err.error || chaosText(t, "resetFailed", "Reset failed"),
});
}
} catch {
setMessage({ type: "error", text: "Failed to reset config" });
setMessage({
type: "error",
text: chaosText(t, "resetFailed", "Failed to reset config"),
});
} finally {
setSaving(false);
}
}, [setConfig]);
}, [setConfig, t]);
return { t, saving, message, setMessage, saveConfig, resetConfig };
}

View File

@@ -2,6 +2,7 @@
import { useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import { chaosText, type ChaosTranslator } from "./chaosI18n";
import type { ChaosTestResult } from "./components/ChaosTestResultsPanel";
import type { ChaosPageConfig, ChaosPageMessage } from "./chaosPageTypes";
@@ -10,8 +11,11 @@ import type { ChaosPageConfig, ChaosPageMessage } from "./chaosPageTypes";
* of the page component to keep it under the complexity/size ratchet
* (config/quality/complexity-baseline.json).
*/
export function useChaosTestRun(config: ChaosPageConfig, setMessage: (message: ChaosPageMessage) => void) {
const t = useTranslations("chaosConfig");
export function useChaosTestRun(
config: ChaosPageConfig,
setMessage: (message: ChaosPageMessage) => void
) {
const t = useTranslations("chaosConfig") as ChaosTranslator;
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<ChaosTestResult | null>(null);
@@ -34,11 +38,17 @@ export function useChaosTestRun(config: ChaosPageConfig, setMessage: (message: C
const data: ChaosTestResult = await res.json();
setTestResult(data);
} else {
const err = await res.json().catch(() => ({ error: "Unknown error" }));
setMessage({ type: "error", text: err.error || "Test failed" });
const err = await res.json().catch(() => ({ error: null }));
setMessage({
type: "error",
text: err.error || chaosText(t, "testFailed", "Test failed"),
});
}
} catch (err: any) {
setMessage({ type: "error", text: err.message || "Test failed" });
setMessage({
type: "error",
text: err.message || chaosText(t, "testFailed", "Test failed"),
});
} finally {
setTesting(false);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Card, Button } from "@/shared/components";
interface ToolState {
@@ -24,6 +25,7 @@ interface UpdateInfo {
}
export default function CliproxyapiToolCard({ isExpanded = false, onToggle = () => {} }) {
const t = useTranslations("cliTools");
const [toolState, setToolState] = useState<ToolState | null>(null);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [loading, setLoading] = useState<string | null>(null);
@@ -71,7 +73,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: data.message || `${action} succeeded` });
setMessage({ type: "success", text: data.message || t("cliproxyapiActionSucceeded") });
await fetchStatus();
if (action === "install" || action === "restart") await fetchUpdateInfo();
} else {
@@ -79,11 +81,14 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
type: "error",
text:
(typeof data.error === "string" ? data.error : data.error?.message) ||
`${action} failed`,
t("cliproxyapiActionFailed"),
});
}
} catch (err) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "Request failed" });
setMessage({
type: "error",
text: err instanceof Error ? err.message : t("cliproxyapiRequestFailed"),
});
} finally {
setLoading(null);
}
@@ -93,14 +98,26 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
if (!toolState) return null;
const s = toolState.status;
const map: Record<string, { label: string; color: string }> = {
running: { label: "Running", color: "bg-green-500/10 text-green-600 dark:text-green-400" },
stopped: { label: "Stopped", color: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400" },
running: {
label: t("cliproxyapiStatusRunning"),
color: "bg-green-500/10 text-green-600 dark:text-green-400",
},
stopped: {
label: t("cliproxyapiStatusStopped"),
color: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400",
},
not_installed: {
label: "Not Installed",
label: t("cliproxyapiStatusNotInstalled"),
color: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400",
},
installed: { label: "Installed", color: "bg-blue-500/10 text-blue-600 dark:text-blue-400" },
error: { label: "Error", color: "bg-red-500/10 text-red-600 dark:text-red-400" },
installed: {
label: t("cliproxyapiStatusInstalled"),
color: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
},
error: {
label: t("cliproxyapiStatusError"),
color: "bg-red-500/10 text-red-600 dark:text-red-400",
},
};
const badge = map[s] || map.not_installed;
return (
@@ -125,9 +142,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
<h3 className="font-medium text-sm">CLIProxyAPI</h3>
{statusBadge()}
</div>
<p className="text-xs text-text-muted truncate">
Upstream proxy fallback (Go-based OAuth)
</p>
<p className="text-xs text-text-muted truncate">{t("cliproxyapiDescription")}</p>
</div>
</div>
<span
@@ -161,7 +176,10 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
system_update
</span>
<span className="text-sm text-yellow-700 dark:text-yellow-300">
Update available: v{updateInfo.current} v{updateInfo.latest}
{t("cliproxyapiUpdateAvailable", {
current: updateInfo.current,
latest: updateInfo.latest,
})}
</span>
</div>
<Button
@@ -170,32 +188,34 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
onClick={() => apiCall("install", { version: updateInfo.latest })}
loading={loading === "install"}
>
Update
{t("cliproxyapiUpdate")}
</Button>
</div>
)}
<div className="grid grid-cols-3 gap-3">
<div className="p-3 rounded-lg bg-bg-secondary">
<p className="text-xs text-text-muted mb-1">Version</p>
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiVersion")}</p>
<p className="text-sm font-medium">
{toolState?.installedVersion ? `v${toolState.installedVersion}` : "Not installed"}
{toolState?.installedVersion
? `v${toolState.installedVersion}`
: t("cliproxyapiNotInstalledValue")}
</p>
</div>
<div className="p-3 rounded-lg bg-bg-secondary">
<p className="text-xs text-text-muted mb-1">Health</p>
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiHealth")}</p>
<p
className={`text-sm font-medium ${toolState?.healthStatus === "healthy" ? "text-green-600 dark:text-green-400" : toolState?.healthStatus === "unhealthy" ? "text-red-600 dark:text-red-400" : "text-text-muted"}`}
>
{toolState?.healthStatus === "healthy"
? `Healthy`
? t("cliproxyapiHealthy")
: toolState?.healthStatus === "unhealthy"
? "Unhealthy"
: "Unknown"}
? t("cliproxyapiUnhealthy")
: t("cliproxyapiUnknown")}
</p>
</div>
<div className="p-3 rounded-lg bg-bg-secondary">
<p className="text-xs text-text-muted mb-1">Port</p>
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiPort")}</p>
<p className="text-sm font-mono">{toolState?.port || 8317}</p>
</div>
</div>
@@ -209,7 +229,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
loading={loading === "install"}
>
<span className="material-symbols-outlined text-[14px] mr-1">download</span>
Install
{t("cliproxyapiInstall")}
</Button>
)}
{toolState?.status === "running" ? (
@@ -220,7 +240,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
loading={loading === "stop"}
>
<span className="material-symbols-outlined text-[14px] mr-1">stop</span>
Stop
{t("cliproxyapiStop")}
</Button>
) : toolState?.installedVersion ? (
<Button
@@ -230,7 +250,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
loading={loading === "start"}
>
<span className="material-symbols-outlined text-[14px] mr-1">play_arrow</span>
Start
{t("cliproxyapiStart")}
</Button>
) : null}
{toolState?.status === "running" && (
@@ -241,7 +261,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
loading={loading === "restart"}
>
<span className="material-symbols-outlined text-[14px] mr-1">restart_alt</span>
Restart
{t("cliproxyapiRestart")}
</Button>
)}
<Button
@@ -251,7 +271,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
loading={loading === "check"}
>
<span className="material-symbols-outlined text-[14px] mr-1">sync</span>
Check Updates
{t("cliproxyapiCheckUpdates")}
</Button>
</div>
</div>

View File

@@ -133,8 +133,8 @@ export default function IntelligentComboPanel({
<code className="rounded bg-black/5 dark:bg-white/5 px-2 py-1 text-text-main">
{combo?.name}
</code>
<span>{allCombos.length} intelligent combo(s)</span>
<span>{providerScopeCount} providers in scope</span>
<span>{t("intelligentComboCount", { count: allCombos.length })}</span>
<span>{t("providersInScope", { count: providerScopeCount })}</span>
</div>
</div>
@@ -161,7 +161,7 @@ export default function IntelligentComboPanel({
</div>
<div className="rounded-lg bg-black/5 dark:bg-white/5 px-3 py-2 text-right">
<p className="text-[10px] uppercase tracking-wide text-text-muted">
Candidate Pool
{getI18nOrFallback(t, "candidatePoolLabel", "Candidate Pool")}
</p>
<p className="text-lg font-semibold text-text-main">{providerScopeCount}</p>
</div>
@@ -183,7 +183,12 @@ export default function IntelligentComboPanel({
</p>
</div>
{savingModePack && (
<span className="text-[11px] text-text-muted">Saving {savingModePack}</span>
<span className="text-[11px] text-text-muted">
{getI18nOrFallback(t, "savingModePack", "Saving {pack}…").replace(
"{pack}",
savingModePack
)}
</span>
)}
</div>
@@ -308,7 +313,7 @@ export default function IntelligentComboPanel({
</div>
<div className="rounded-lg border border-black/8 bg-white/60 p-3 dark:border-white/8 dark:bg-white/[0.03]">
<p className="text-[11px] uppercase tracking-wide text-text-muted">
Exploration Rate
{getI18nOrFallback(t, "explorationRateLabel", "Exploration Rate")}
</p>
<p className="mt-1 text-sm font-semibold text-text-main">
{Math.round(normalizedConfig.explorationRate * 100)}%

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
export default function CombosError({
error: _error,
reset,
@@ -7,6 +9,8 @@ export default function CombosError({
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useTranslations("combos");
return (
<div
className="flex flex-col items-center justify-center min-h-[400px]"
@@ -14,14 +18,10 @@ export default function CombosError({
aria-live="assertive"
>
<div className="text-center space-y-4">
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
Failed to load combos
</h2>
<p className="text-text-muted max-w-md">
We could not load combo data right now. Check your connection and try again.
</p>
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">{t("errorTitle")}</h2>
<p className="text-text-muted max-w-md">{t("errorDescription")}</p>
{_error?.digest && (
<p className="text-xs text-text-muted font-mono">Error ID: {_error.digest}</p>
<p className="text-xs text-text-muted font-mono">{t("errorId", { id: _error.digest })}</p>
)}
{process.env.NODE_ENV === "development" && _error?.message && (
<p className="text-xs text-red-600 dark:text-red-400 font-mono">{_error.message}</p>
@@ -30,7 +30,7 @@ export default function CombosError({
onClick={reset}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-primary"
>
Try Again
{t("errorRetry")}
</button>
</div>
</div>

View File

@@ -1,9 +1,13 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
export const metadata = {
title: "Compression",
description: "Configure context compression settings to reduce token usage and costs.",
};
export async function generateMetadata() {
const t = await getTranslations("metadata");
return {
title: t("compressionTitle"),
description: t("compressionDescription"),
};
}
export default function CompressionPage() {
redirect("/dashboard/context/caveman");

View File

@@ -534,7 +534,10 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
if (!cloudConfigured) {
setCloudStatus({
type: "warning",
message: "Cloud sync is not configured on this instance.",
message: translateOrFallback(
"cloudSyncNotConfigured",
"Cloud sync is not configured on this instance."
),
});
return;
}
@@ -1822,7 +1825,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-primary">hub</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryCore") || "Core APIs"}
{t("categoryCore")}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
@@ -1843,7 +1846,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="code"
iconColor="text-indigo-500"
iconBg="bg-indigo-500/10"
title={t("responses") || "Responses API"}
title={t("responses")}
path="/v1/responses"
models={endpointData.chat}
copy={copy}
@@ -1855,7 +1858,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="text_fields"
iconColor="text-orange-500"
iconBg="bg-orange-500/10"
title={t("completionsLegacy") || "Completions (Legacy)"}
title={t("completionsLegacy")}
path="/v1/completions"
models={endpointData.chat}
copy={copy}
@@ -1867,7 +1870,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="psychology"
iconColor="text-violet-500"
iconBg="bg-violet-500/10"
title={t("messagesApi") || "Messages"}
title={t("messagesApi")}
path="/v1/messages"
models={null}
badge="Anthropic"
@@ -1883,7 +1886,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-purple-400">perm_media</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryMedia") || "Media & Multi-Modal"}
{t("categoryMedia")}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
@@ -1916,7 +1919,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="edit_square"
iconColor="text-violet-500"
iconBg="bg-violet-500/10"
title={t("imageEdits") || "Image Edits"}
title={t("imageEdits")}
path="/v1/images/edits"
models={endpointData.images}
copy={copy}
@@ -1952,7 +1955,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="music_note"
iconColor="text-fuchsia-500"
iconBg="bg-fuchsia-500/10"
title={t("musicGeneration") || "Music Generation"}
title={t("musicGeneration")}
path="/v1/music/generations"
models={endpointData.music}
copy={copy}
@@ -1964,7 +1967,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="videocam"
iconColor="text-red-500"
iconBg="bg-red-500/10"
title={t("videoGeneration") || "Video Generation"}
title={t("videoGeneration")}
path="/v1/videos/generations"
models={endpointData.video}
copy={copy}
@@ -1983,7 +1986,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
travel_explore
</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categorySearch") || "Search & Discovery"}
{t("categorySearch")}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
@@ -1992,7 +1995,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="search"
iconColor="text-cyan-500"
iconBg="bg-cyan-500/10"
title={t("webSearch") || "Web Search"}
title={t("webSearch")}
path="/v1/search"
models={searchProviders.map((p) => ({ id: p.id, owned_by: p.id, type: "search" }))}
copy={copy}
@@ -2008,7 +2011,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-sm text-amber-400">build</span>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider">
{t("categoryUtility") || "Utility & Management"}
{t("categoryUtility")}
</h3>
<div className="flex-1 h-px bg-border/50" />
</div>
@@ -2041,7 +2044,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="view_list"
iconColor="text-teal-500"
iconBg="bg-teal-500/10"
title={t("batchApi") || "Batch API"}
title={t("batchApi")}
path="/v1/batches"
models={null}
badge="OpenAI"
@@ -2053,7 +2056,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="folder"
iconColor="text-yellow-500"
iconBg="bg-yellow-500/10"
title={t("filesApi") || "Files API"}
title={t("filesApi")}
path="/v1/files"
models={null}
copy={copy}
@@ -2064,7 +2067,7 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
icon="list"
iconColor="text-teal-500"
iconBg="bg-teal-500/10"
title={t("listModels") || "List Models"}
title={t("listModels")}
path="/v1/models"
models={null}
copy={copy}

View File

@@ -429,7 +429,7 @@ export default function A2ADashboardPage() {
<th className="text-left py-2 pr-2">{t("tableTask")}</th>
<th className="text-left py-2 pr-2">{t("tableSkill")}</th>
<th className="text-left py-2 pr-2">{t("tableState")}</th>
<th className="text-left py-2 pr-2">{t("tablePhase") || "FSM Status"}</th>
<th className="text-left py-2 pr-2">{t("tablePhase")}</th>
<th className="text-left py-2 pr-2">{t("tableUpdated")}</th>
<th className="text-left py-2">{t("tableActions")}</th>
</tr>

View File

@@ -12,6 +12,18 @@ export default function NotionSourceCard() {
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [expanded, setExpanded] = useState(false);
const translateOrFallback = (key: string, fallback: string) => {
try {
const translated = t(key as never);
if (!translated || translated === key || translated === `endpoint.${key}`) {
return fallback;
}
return translated;
} catch {
return fallback;
}
};
const fetchConfig = useCallback(async () => {
try {
const res = await fetch("/api/settings/notion");
@@ -37,7 +49,10 @@ export default function NotionSourceCard() {
const handleSaveToken = async () => {
if (!token.trim()) {
setMessage({ type: "error", text: "Please enter a Notion integration token" });
setMessage({
type: "error",
text: translateOrFallback("notionEnterToken", "Please enter a Notion integration token"),
});
return;
}
setBusy(true);
@@ -53,11 +68,20 @@ export default function NotionSourceCard() {
setConnected(true);
setMessage({ type: "success", text: data.message });
} else {
setMessage({ type: "error", text: data.error ?? "Failed to connect" });
setMessage({
type: "error",
text: data.error ?? translateOrFallback("notionConnectFailed", "Failed to connect"),
});
setConnected(false);
}
} catch (err) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "Connection failed" });
setMessage({
type: "error",
text:
err instanceof Error
? err.message
: translateOrFallback("notionConnectionFailed", "Connection failed"),
});
} finally {
setBusy(false);
}
@@ -74,10 +98,19 @@ export default function NotionSourceCard() {
setToken("");
setMessage({ type: "success", text: data.message });
} else {
setMessage({ type: "error", text: data.error ?? "Failed to disconnect" });
setMessage({
type: "error",
text: data.error ?? translateOrFallback("notionDisconnectFailed", "Failed to disconnect"),
});
}
} catch (err) {
setMessage({ type: "error", text: err instanceof Error ? err.message : "Disconnect failed" });
setMessage({
type: "error",
text:
err instanceof Error
? err.message
: translateOrFallback("notionDisconnectFailed", "Disconnect failed"),
});
} finally {
setBusy(false);
}
@@ -97,11 +130,16 @@ export default function NotionSourceCard() {
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm">Notion</span>
<Badge variant={connected ? "success" : "default"}>
{connected ? "Connected" : "Not connected"}
{connected
? translateOrFallback("notionConnected", "Connected")
: translateOrFallback("notionNotConnected", "Not connected")}
</Badge>
</div>
<p className="text-xs text-text-muted mt-0.5">
Search, read, query, and write to Notion through routed AI models
{translateOrFallback(
"notionDescription",
"Search, read, query, and write to Notion through routed AI models"
)}
</p>
</div>
<span
@@ -131,7 +169,10 @@ export default function NotionSourceCard() {
{!connected ? (
<div className="flex flex-col gap-2">
<label className="text-xs text-text-muted font-medium">
Notion Internal Integration Token
{translateOrFallback(
"notionIntegrationToken",
"Notion Internal Integration Token"
)}
</label>
<div className="flex gap-2">
<Input
@@ -143,11 +184,14 @@ export default function NotionSourceCard() {
className="font-mono text-sm flex-1"
/>
<Button onClick={handleSaveToken} loading={busy} variant="primary" size="sm">
Connect
{translateOrFallback("notionConnect", "Connect")}
</Button>
</div>
<p className="text-[10px] text-text-muted">
Create an Internal Integration at{" "}
{translateOrFallback(
"notionIntegrationHelp",
"Create an Internal Integration at"
)}{" "}
<code className="text-primary font-mono bg-surface/80 px-1 rounded">
https://www.notion.so/profile/integrations
</code>
@@ -156,7 +200,10 @@ export default function NotionSourceCard() {
) : (
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted flex-1">
Token configured. Notion tools are available via MCP.
{translateOrFallback(
"notionTokenConfigured",
"Token configured. Notion tools are available via MCP."
)}
</span>
<Button
onClick={handleDisconnect}
@@ -165,7 +212,7 @@ export default function NotionSourceCard() {
size="sm"
className="border-red-500/30! text-red-400! hover:bg-red-500/10!"
>
Disconnect
{translateOrFallback("notionDisconnect", "Disconnect")}
</Button>
</div>
)}

View File

@@ -15,11 +15,7 @@ import {
getCodexEffectiveServiceTier,
type CodexGlobalServiceMode,
} from "@/lib/providers/codexFastTier";
import {
normalizeCodexLimitPolicy,
providerText,
ERROR_TYPE_LABELS,
} from "../providerPageHelpers";
import { normalizeCodexLimitPolicy, providerText, ERROR_TYPE_LABELS } from "../providerPageHelpers";
import { getCodexPlanLabel } from "../codexPlanLabel";
import ProviderQuotaVisibilityToggle from "./ProviderQuotaVisibilityToggle";
@@ -245,7 +241,7 @@ function getStatusPresentation(
if (errorType === "account_deactivated") {
return {
statusVariant: "error",
statusLabel: t("statusDeactivated", "Deactivated"),
statusLabel: providerText(t, "statusDeactivated", "Deactivated"),
errorType,
errorBadge,
errorTextClass: "text-red-600 font-bold",
@@ -300,7 +296,7 @@ function getStatusPresentation(
if (errorType === "banned") {
return {
statusVariant: "error",
statusLabel: t("statusBanned", "Banned (403)"),
statusLabel: providerText(t, "statusBanned", "Banned (403)"),
errorType,
errorBadge,
errorTextClass: "text-red-600 font-bold",
@@ -310,7 +306,7 @@ function getStatusPresentation(
if (errorType === "credits_exhausted") {
return {
statusVariant: "warning",
statusLabel: t("statusCreditsExhausted", "Out of Credits"),
statusLabel: providerText(t, "statusCreditsExhausted", "Out of Credits"),
errorType,
errorBadge,
errorTextClass: "text-amber-500",
@@ -391,22 +387,10 @@ export default function ConnectionRow({
t("oauthAccount")
)
: connection.name;
const applyCodexAuthLabel =
typeof t.has === "function" && t.has("applyCodexAuthLocal")
? t("applyCodexAuthLocal")
: "Apply auth";
const exportCodexAuthLabel =
typeof t.has === "function" && t.has("exportCodexAuthFile")
? t("exportCodexAuthFile")
: "Export auth";
const applyClaudeAuthLabel =
typeof t.has === "function" && t.has("applyClaudeAuthLocal")
? t("applyClaudeAuthLocal")
: "Apply auth";
const exportClaudeAuthLabel =
typeof t.has === "function" && t.has("exportClaudeAuthFile")
? t("exportClaudeAuthFile")
: "Export auth";
const applyCodexAuthLabel = providerText(t, "applyCodexAuthLocal", "Apply auth");
const exportCodexAuthLabel = providerText(t, "exportCodexAuthFile", "Export auth");
const applyClaudeAuthLabel = providerText(t, "applyClaudeAuthLocal", "Apply auth");
const exportClaudeAuthLabel = providerText(t, "exportClaudeAuthFile", "Export auth");
// Use useState + useEffect for impure Date.now() to avoid calling during render
const [isCooldown, setIsCooldown] = useState(false);
// T12: token expiry status — lazy init avoids calling Date.now() during render;

View File

@@ -22,8 +22,10 @@
*/
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { formatResetCountdown } from "@/shared/utils/formatting";
import type { ConnectionRowConnection } from "./ConnectionRow";
import { providerText } from "../providerPageHelpers";
export interface CoolingConnectionsPanelProps {
readonly connections: readonly ConnectionRowConnection[];
@@ -37,6 +39,7 @@ function isCoolingNow(connection: ConnectionRowConnection, now: number): boolean
export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelProps) {
const { connections } = props;
const t = useTranslations("providers");
// Tick once per second so the human-readable countdown updates.
const [now, setNow] = useState<number>(() => Date.now());
useEffect(() => {
@@ -58,12 +61,17 @@ export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelPr
className="inline-block h-2 w-2 animate-pulse rounded-full bg-amber-500"
/>
<h3 className="text-sm font-medium text-amber-700 dark:text-amber-300">
Currently cooling ({cooling.length})
{providerText(t, "coolingConnectionsTitle", "Currently cooling ({count})", {
count: cooling.length,
})}
</h3>
</div>
<p className="mb-3 text-xs text-muted-foreground">
These connections returned a 429 (rate-limit) on their last request. OmniRoute will skip
them until the timer expires no manual disable required.
{providerText(
t,
"coolingConnectionsDescription",
"These connections returned a 429 (rate-limit) on their last request. OmniRoute will skip them until the timer expires — no manual disable required."
)}
</p>
<ul className="space-y-1">
{cooling.map((c) => {
@@ -72,7 +80,9 @@ export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelPr
c.displayName ||
c.name ||
c.email ||
(c.id ? `connection ${c.id.slice(0, 8)}` : "connection");
(c.id
? `${providerText(t, "connectionFallback", "connection")} ${c.id.slice(0, 8)}`
: providerText(t, "connectionFallback", "connection"));
return (
<li
key={c.id ?? label}

View File

@@ -21,6 +21,7 @@ import {
effectivePreserveForProtocol,
effectiveUpstreamHeadersForProtocol,
formatProviderModelsErrorResponse,
providerText,
targetFormatBadgeI18nKey,
type CompatModelRow,
type CompatByProtocolMap,
@@ -285,17 +286,32 @@ export default function CustomModelsSection({
if (!res.ok) {
const detail = await formatProviderModelsErrorResponse(res);
throw new Error(detail || "Failed to save model endpoint settings");
throw new Error(
detail ||
providerText(
t,
"failedSaveModelEndpointSettings",
"Failed to save model endpoint settings"
)
);
}
await fetchCustomModels();
onModelsChanged?.();
notify.success("Saved model endpoint settings");
notify.success(
providerText(t, "savedModelEndpointSettings", "Saved model endpoint settings")
);
cancelEdit();
} catch (e) {
console.error("Failed to save custom model:", e);
notify.error(
e instanceof Error && e.message ? e.message : "Failed to save model endpoint settings"
e instanceof Error && e.message
? e.message
: providerText(
t,
"failedSaveModelEndpointSettings",
"Failed to save model endpoint settings"
)
);
} finally {
setSavingModelId(null);
@@ -305,7 +321,9 @@ export default function CustomModelsSection({
const saveEdit = async (modelId: string) => {
if (!editingModelId || editingModelId !== modelId) return;
if (!editingEndpoints.length) {
notify.error("Select at least one supported endpoint");
notify.error(
providerText(t, "selectSupportedEndpoint", "Select at least one supported endpoint")
);
return;
}
@@ -429,7 +447,7 @@ export default function CustomModelsSection({
: ep === "embeddings"
? `📐 ${t("supportedEndpointEmbeddings")}`
: ep === "rerank"
? "Rerank"
? providerText(t, "rerankEndpoint", "Rerank")
: ep === "images"
? `🖼️ ${t("supportedEndpointImages")}`
: `🔊 ${t("supportedEndpointAudio")}`}
@@ -661,7 +679,7 @@ export default function CustomModelsSection({
: ep === "embeddings"
? `📐 ${t("supportedEndpointEmbeddings")}`
: ep === "rerank"
? "Rerank"
? providerText(t, "rerankEndpoint", "Rerank")
: ep === "images"
? `🖼️ ${t("supportedEndpointImages")}`
: `🔊 ${t("supportedEndpointAudio")}`}

View File

@@ -1,7 +1,7 @@
"use client";
import { Modal } from "@/shared/components";
import type { ProviderMessageTranslator } from "../providerPageHelpers";
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
type KimiCodeAuthMethodModalProps = {
isOpen: boolean;
@@ -50,7 +50,9 @@ export default function KimiCodeAuthMethodModal({
<div className="flex items-start gap-3">
<span className="material-symbols-outlined mt-0.5 text-primary">key</span>
<div className="min-w-0 flex-1">
<h3 className="mb-1 font-semibold">Kimi Code API Key</h3>
<h3 className="mb-1 font-semibold">
{providerText(t, "kimiCodeApiKeyLabel", "Kimi Code API Key")}
</h3>
<p className="text-sm text-text-muted">{t("apiKeySecure")}</p>
</div>
</div>

View File

@@ -13,6 +13,7 @@ import {
UPSTREAM_HEADERS_UI_MAX,
headerRowsToRecord,
compatProtocolLabelKey,
providerText,
type HeaderDraftRow,
} from "../providerPageHelpers";
@@ -353,7 +354,7 @@ export default function ModelCompatPopover({
{/* Param filters — model-level block/allow (#6625) */}
<div className="mt-4 space-y-2.5">
<label className="block text-[11px] font-semibold text-text-main">
{t("compatParamFiltersLabel") ?? "Param Filters"}
{providerText(t, "compatParamFiltersLabel", "Param Filters")}
</label>
<div>
<input
@@ -369,7 +370,11 @@ export default function ModelCompatPopover({
className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
/>
<p className="text-[10px] text-text-muted">
{t("compatBlockedParamsHint") ?? "Blocked params (stripped from requests)"}
{providerText(
t,
"compatBlockedParamsHint",
"Blocked params (stripped from requests)"
)}
{paramSaving && `${t("compatSaving")}`}
</p>
</div>
@@ -387,7 +392,11 @@ export default function ModelCompatPopover({
className="mb-1 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-[11px] font-mono text-text-main placeholder:text-text-muted focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
/>
<p className="text-[10px] text-text-muted">
{t("compatAllowedParamsHint") ?? "Allowed params (re-added after deny)"}
{providerText(
t,
"compatAllowedParamsHint",
"Allowed params (re-added after deny)"
)}
</p>
</div>
</div>

View File

@@ -411,7 +411,7 @@ export default function ModelRow({
: testStatus === "ok"
? "OK"
: testStatus === "error"
? "Error"
? providerText(t, "errorShort", "Error")
: t("testModel")
}
>

View File

@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { NoAuthAccountCard, NoAuthProviderCard } from "@/shared/components";
import { getProviderAlias, supportsNoAuthProviderProxy } from "@/shared/constants/providers";
import { useNotificationStore } from "@/store/notificationStore";
import { providerText } from "../providerPageHelpers";
const ACCOUNT_PROVIDER_NAMES: Record<string, string> = {
mimocode: "MiMoCode",
@@ -123,7 +124,10 @@ export default function NoAuthProviderControls({
const res = await fetch("/api/dahl/tokens", { method: "POST" });
const data = await res.json();
if (!res.ok || !data.token) {
throw new Error(data?.error || "Failed to create Dahl token");
throw new Error(
data?.error ||
providerText(t, "createDahlTokenFailed", "Failed to create Dahl token")
);
}
return data.token as string;
}

View File

@@ -190,7 +190,7 @@ export default function PassthroughModelRow({
: testStatus === "ok"
? "OK"
: testStatus === "error"
? "Error"
? providerText(t, "errorShort", "Error")
: t("testModel")
}
>

View File

@@ -6,6 +6,7 @@
// a single-kind panel or the LlmChatCard for standard LLM providers.
import { useState } from "react";
import { useTranslations } from "next-intl";
import { LlmChatCard } from "@/app/(dashboard)/dashboard/media-providers/components/LlmChatCard";
import { ServiceKindTabs } from "@/app/(dashboard)/dashboard/media-providers/components/ServiceKindTabs";
import { EmbeddingExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/EmbeddingExampleCard";
@@ -18,6 +19,7 @@ import { VideoExampleCard } from "@/app/(dashboard)/dashboard/media-providers/co
import { MusicExampleCard } from "@/app/(dashboard)/dashboard/media-providers/components/MusicExampleCard";
import type { ServiceKind } from "@/shared/constants/providers";
import { AI_PROVIDERS } from "@/shared/constants/providers";
import { providerText } from "../providerPageHelpers";
export const MEDIA_SERVICE_KINDS: ServiceKind[] = [
"embedding",
@@ -56,12 +58,12 @@ export function renderKindPanel(kind: ServiceKind, providerId: string): JSX.Elem
}
export default function ProviderPlaygroundPanel({ providerId }: { providerId: string }) {
const t = useTranslations("providers");
// Resolve serviceKinds from AI_PROVIDERS.
// For providers without explicit serviceKinds (most LLM providers), we infer
// "llm" as the default.
const providerEntry = AI_PROVIDERS[providerId as keyof typeof AI_PROVIDERS] as
| (Record<string, unknown> & { serviceKinds?: string[] })
| undefined;
(Record<string, unknown> & { serviceKinds?: string[] }) | undefined;
const rawKinds: string[] = providerEntry?.serviceKinds ?? [];
@@ -93,7 +95,7 @@ export default function ProviderPlaygroundPanel({ providerId }: { providerId: st
return (
<div className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">Playground</h2>
<h2 className="text-lg font-semibold">{providerText(t, "playgroundTitle", "Playground")}</h2>
<ServiceKindTabs
kinds={playgroundableKinds}
activeKind={activeKind}

View File

@@ -3,6 +3,7 @@ import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "../../providerDetailConstants";
import { providerText } from "../../providerPageHelpers";
interface EditCompatibleNodeModalNode {
id?: string;
name?: string;
@@ -45,9 +46,11 @@ export default function EditCompatibleNodeModal({
const [checkKey, setCheckKey] = useState("");
const [checkModelId, setCheckModelId] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<
null | { valid: boolean; error?: string | null; method?: string | null }
>(null);
const [validationResult, setValidationResult] = useState<null | {
valid: boolean;
error?: string | null;
method?: string | null;
}>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
useEffect(() => {
@@ -130,7 +133,10 @@ export default function EditCompatibleNodeModal({
method: data.method ?? null,
});
} catch {
setValidationResult({ valid: false, error: "Network error" });
setValidationResult({
valid: false,
error: providerText(t, "networkError", "Network error"),
});
} finally {
setValidating(false);
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { type CommandCodeAuthFlowState } from "../providerPageHelpers";
import { useTranslations } from "next-intl";
import { providerText, type CommandCodeAuthFlowState } from "../providerPageHelpers";
export type UseCommandCodeAuthParams = {
providerId: string;
@@ -15,6 +16,7 @@ export function useCommandCodeAuth({
setShowAddApiKeyModal,
notify,
}: UseCommandCodeAuthParams) {
const t = useTranslations("providers");
const [commandCodeAuthState, setCommandCodeAuthState] = useState<CommandCodeAuthFlowState>({
phase: "idle",
state: "",
@@ -62,7 +64,7 @@ export function useCommandCodeAuth({
setCommandCodeAuthState((current) => ({
...current,
phase: "applying",
message: "Applying browser-approved key…",
message: providerText(t, "commandCodeApplyingKey", "Applying browser-approved key…"),
}));
try {
@@ -74,7 +76,9 @@ export function useCommandCodeAuth({
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const errorMessage = data.error || "Failed to apply Command Code auth";
const errorMessage =
data.error ||
providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth");
setCommandCodeAuthState((current) => ({
...current,
phase: "error",
@@ -87,26 +91,30 @@ export function useCommandCodeAuth({
setCommandCodeAuthState((current) => ({
...current,
phase: "applied",
message: "Command Code connected",
message: providerText(t, "commandCodeConnected", "Command Code connected"),
}));
commandCodeAuthWindowRef.current?.close?.();
commandCodeAuthWindowRef.current = null;
await fetchConnections();
handleCloseAddApiKeyModal();
notify.success("Command Code connection added");
notify.success(
providerText(t, "commandCodeConnectionAdded", "Command Code connection added")
);
return true;
} catch (error) {
console.error("Error applying Command Code auth:", error);
setCommandCodeAuthState((current) => ({
...current,
phase: "error",
message: "Failed to apply Command Code auth",
message: providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth"),
}));
notify.error("Failed to apply Command Code auth");
notify.error(
providerText(t, "commandCodeApplyFailed", "Failed to apply Command Code auth")
);
return false;
}
},
[fetchConnections, handleCloseAddApiKeyModal, notify]
[fetchConnections, handleCloseAddApiKeyModal, notify, t]
);
const handleStartCommandCodeAuth = useCallback(async () => {
@@ -124,7 +132,7 @@ export function useCommandCodeAuth({
authUrl: "",
callbackUrl: "",
expiresAt: null,
message: "Opening Command Code Studio…",
message: providerText(t, "commandCodeOpeningStudio", "Opening Command Code Studio…"),
});
try {
@@ -135,7 +143,9 @@ export function useCommandCodeAuth({
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.state || !data.authUrl) {
const errorMessage = data.error || "Failed to start Command Code auth";
const errorMessage =
data.error ||
providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth");
setCommandCodeAuthState((current) => ({
...current,
phase: "error",
@@ -152,7 +162,11 @@ export function useCommandCodeAuth({
authUrl: data.authUrl,
callbackUrl: data.callbackUrl || "",
expiresAt: data.expiresAt || null,
message: "Open the auth URL, approve access, then paste the returned key/JSON/URL below…",
message: providerText(
t,
"commandCodeApprovalInstructions",
"Open the auth URL, approve access, then paste the returned key/JSON/URL below…"
),
});
if (popup) {
@@ -169,9 +183,19 @@ export function useCommandCodeAuth({
setCommandCodeAuthState((current) => ({
...current,
phase: "error",
message: "Popup blocked. Please allow popups and try Command Code Connect again.",
message: providerText(
t,
"commandCodePopupBlocked",
"Popup blocked. Please allow popups and try Command Code Connect again."
),
}));
notify.error("Popup blocked. Please allow popups and try Command Code Connect again.");
notify.error(
providerText(
t,
"commandCodePopupBlocked",
"Popup blocked. Please allow popups and try Command Code Connect again."
)
);
return;
}
commandCodeAuthWindowRef.current = fallbackPopup;
@@ -183,11 +207,11 @@ export function useCommandCodeAuth({
setCommandCodeAuthState((current) => ({
...current,
phase: "expired",
message: "Command Code link expired",
message: providerText(t, "commandCodeLinkExpired", "Command Code link expired"),
}));
commandCodeAuthWindowRef.current?.close?.();
commandCodeAuthWindowRef.current = null;
notify.error("Command Code auth expired");
notify.error(providerText(t, "commandCodeAuthExpired", "Command Code auth expired"));
clearCommandCodeAuthTimer();
return;
}
@@ -206,11 +230,11 @@ export function useCommandCodeAuth({
setCommandCodeAuthState((current) => ({
...current,
phase: "expired",
message: "Command Code link expired",
message: providerText(t, "commandCodeLinkExpired", "Command Code link expired"),
}));
commandCodeAuthWindowRef.current?.close?.();
commandCodeAuthWindowRef.current = null;
notify.error("Command Code auth expired");
notify.error(providerText(t, "commandCodeAuthExpired", "Command Code auth expired"));
clearCommandCodeAuthTimer();
return;
}
@@ -219,13 +243,15 @@ export function useCommandCodeAuth({
setCommandCodeAuthState((current) => ({
...current,
phase: "applied",
message: "Command Code connected",
message: providerText(t, "commandCodeConnected", "Command Code connected"),
}));
commandCodeAuthWindowRef.current?.close?.();
commandCodeAuthWindowRef.current = null;
await fetchConnections();
handleCloseAddApiKeyModal();
notify.success("Command Code connection added");
notify.success(
providerText(t, "commandCodeConnectionAdded", "Command Code connection added")
);
clearCommandCodeAuthTimer();
return;
}
@@ -234,7 +260,11 @@ export function useCommandCodeAuth({
setCommandCodeAuthState((current) => ({
...current,
phase: "received",
message: "Browser approved, applying…",
message: providerText(
t,
"commandCodeApplyingApproval",
"Browser approved, applying…"
),
}));
clearCommandCodeAuthTimer();
await handleCommandCodeAuthApply(
@@ -258,9 +288,9 @@ export function useCommandCodeAuth({
setCommandCodeAuthState((current) => ({
...current,
phase: "error",
message: "Failed to start Command Code auth",
message: providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth"),
}));
notify.error("Failed to start Command Code auth");
notify.error(providerText(t, "commandCodeStartFailed", "Failed to start Command Code auth"));
popup?.close?.();
commandCodeAuthWindowRef.current = null;
clearCommandCodeAuthTimer();
@@ -272,6 +302,7 @@ export function useCommandCodeAuth({
fetchConnections,
handleCommandCodeAuthApply,
notify,
t,
]);
const handleOpenCommandCodeConnect = useCallback(() => {

View File

@@ -11,6 +11,8 @@
*/
import { useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import { providerText } from "../providerPageHelpers";
export interface ConnectionDeleteConfirmTarget {
id: string;
@@ -34,6 +36,7 @@ export function useConnectionDeleteConfirm(
fetchConnections: () => Promise<void>,
notify: NotifyLike
): ConnectionDeleteConfirmState {
const t = useTranslations("providers");
const [connection, setConnection] = useState<ConnectionDeleteConfirmTarget | null>(null);
const [deleting, setDeleting] = useState(false);
@@ -56,24 +59,24 @@ export function useConnectionDeleteConfirm(
try {
const res = await fetch(`/api/providers/${connectionId}`, { method: "DELETE" });
if (res.ok) {
notify.success("Connection deleted");
notify.success(providerText(t, "connectionDeleted", "Connection deleted"));
await fetchConnections();
} else {
const data = await res.json().catch(() => ({}));
const message =
(typeof data?.error === "string" && data.error) ||
data?.error?.message ||
"Failed to delete connection";
providerText(t, "failedDeleteConnection", "Failed to delete connection");
notify.error(message);
}
} catch (error) {
console.error("Error deleting connection:", error);
notify.error("Failed to delete connection");
notify.error(providerText(t, "failedDeleteConnection", "Failed to delete connection"));
} finally {
setDeleting(false);
setConnection(null);
}
}, [connection, fetchConnections, notify]);
}, [connection, fetchConnections, notify, t]);
return { connection, deleting, request, confirm, cancel };
}

View File

@@ -316,11 +316,13 @@ export function useModelVisibilityHandlers({
// extractApiErrorMessage coerces any object-shaped `error` (e.g. a Zod
// format object) to a string so notify.error never hands the toast a
// non-string child (React #31 → frozen page).
notify.error(extractApiErrorMessage(data, "Model test failed"));
notify.error(
extractApiErrorMessage(data, providerText(t, "modelTestFailed", "Model test failed"))
);
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
}
} catch (err) {
notify.error("Network error testing model");
notify.error(providerText(t, "modelTestNetworkError", "Network error testing model"));
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
} finally {
setTestingModelId(null);

View File

@@ -27,7 +27,7 @@ import { useNotificationStore } from "@/store/notificationStore";
import { isClaudeCodeCompatibleProvider } from "@/shared/constants/providers";
import type { ConnectionRowConnection } from "../components/ConnectionRow";
import { connectionBelongsToProviderPage } from "../../providerPageUtils";
import { normalizeCodexLimitPolicy } from "../providerPageHelpers";
import { normalizeCodexLimitPolicy, providerText } from "../providerPageHelpers";
import { useProviderQuotaVisibility } from "./useProviderQuotaVisibility";
import { useReorderByAvailability } from "./useReorderByAvailability";
import {
@@ -378,7 +378,14 @@ export function useProviderConnections(
if (!res.ok) {
const data = await res.json().catch(() => ({}));
notify.error(data.error || "Failed to update Claude extra-usage policy");
notify.error(
data.error ||
providerText(
t,
"failedUpdateClaudeExtraUsagePolicy",
"Failed to update Claude extra-usage policy"
)
);
return;
}
@@ -408,12 +415,26 @@ export function useProviderConnections(
);
notify.success(
enabled
? "Claude extra-usage blocking enabled (extra usage will be blocked)"
: "Claude extra-usage blocking disabled (extra usage is allowed)"
? providerText(
t,
"claudeExtraUsageBlockingEnabled",
"Claude extra-usage blocking enabled (extra usage will be blocked)"
)
: providerText(
t,
"claudeExtraUsageBlockingDisabled",
"Claude extra-usage blocking disabled (extra usage is allowed)"
)
);
} catch (error) {
console.error("Error toggling Claude extra-usage policy:", error);
notify.error("Failed to update Claude extra-usage policy");
notify.error(
providerText(
t,
"failedUpdateClaudeExtraUsagePolicy",
"Failed to update Claude extra-usage policy"
)
);
}
};
@@ -447,7 +468,10 @@ export function useProviderConnections(
if (!res.ok) {
const data = await res.json().catch(() => ({}));
notify.error(data.error || "Failed to update Codex limit policy");
notify.error(
data.error ||
providerText(t, "failedUpdateCodexLimitPolicy", "Failed to update Codex limit policy")
);
return;
}
@@ -464,10 +488,12 @@ export function useProviderConnections(
: connection
)
);
notify.success("Codex limit policy updated");
notify.success(providerText(t, "codexLimitPolicyUpdated", "Codex limit policy updated"));
} catch (error) {
console.error("Error toggling Codex quota policy:", error);
notify.error("Failed to update Codex limit policy");
notify.error(
providerText(t, "failedUpdateCodexLimitPolicy", "Failed to update Codex limit policy")
);
}
};
@@ -481,18 +507,27 @@ export function useProviderConnections(
if (!res.ok) {
const data = await res.json().catch(() => ({}));
notify.error(data.error || "Failed to update CLIProxyAPI routing");
notify.error(
data.error ||
providerText(t, "failedUpdateCliproxyRouting", "Failed to update CLIProxyAPI routing")
);
return;
}
setCpaProviderEnabled(enabled);
notify.success(
enabled
? "Requests now route through CLIProxyAPI (deeper emulation)"
: "Requests now use native OmniRoute (direct)"
? providerText(
t,
"cliproxyRoutingEnabled",
"Requests now route through CLIProxyAPI (deeper emulation)"
)
: providerText(t, "cliproxyRoutingDisabled", "Requests now use native OmniRoute (direct)")
);
} catch {
notify.error("Failed to update CLIProxyAPI routing");
notify.error(
providerText(t, "failedUpdateCliproxyRouting", "Failed to update CLIProxyAPI routing")
);
}
};
@@ -664,10 +699,10 @@ export function useProviderConnections(
if (onAfter) await onAfter();
} else {
const data = await res.json();
notify.error(data.error || "Batch delete failed");
notify.error(data.error || providerText(t, "batchDeleteFailed", "Batch delete failed"));
}
} catch {
notify.error("Network error during batch delete");
notify.error(providerText(t, "batchDeleteNetworkError", "Network error during batch delete"));
} finally {
setBatchDeleting(false);
}
@@ -689,7 +724,11 @@ export function useProviderConnections(
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error?.message || data.error || "Batch update failed");
throw new Error(
data.error?.message ||
data.error ||
providerText(t, "batchUpdateFailed", "Batch update failed")
);
}
const data = await res.json();
updated += data.updated ?? 0;
@@ -710,7 +749,10 @@ export function useProviderConnections(
);
}
} catch (error: any) {
notify.error(error?.message || "Network error during batch update");
notify.error(
error?.message ||
providerText(t, "batchUpdateNetworkError", "Network error during batch update")
);
} finally {
setBatchUpdating(null);
}
@@ -805,7 +847,13 @@ export function useProviderConnections(
const proxiesData = await proxiesRes.json();
const savedProxies = (proxiesData?.items || []).filter((p: any) => p.status === "active");
if (savedProxies.length === 0) {
notify.error("No saved proxies found. Add proxies in Settings → Proxy first.");
notify.error(
providerText(
t,
"noSavedProxies",
"No saved proxies found. Add proxies in Settings → Proxy first."
)
);
return;
}
@@ -856,11 +904,16 @@ export function useProviderConnections(
await fetchConnections();
const tagLabel = tagFilter ? `"${tagFilter}" ` : "";
notify.success(
`Distributed ${assigned} proxy assignment(s) across ${tagLabel}${sorted.length} connection(s).`
providerText(
t,
"proxiesDistributed",
"Distributed {assigned} proxy assignment(s) across {tagLabel}{total} connection(s).",
{ assigned, tagLabel, total: sorted.length }
)
);
} catch (err) {
console.error("Error distributing proxies:", err);
notify.error("Failed to distribute proxies.");
notify.error(providerText(t, "failedDistributeProxies", "Failed to distribute proxies."));
} finally {
setDistributingProxies(false);
}

View File

@@ -16,7 +16,7 @@
import { useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useNotificationStore } from "@/store/notificationStore";
import type { CompatModelRow } from "../providerPageHelpers";
import { providerText, type CompatModelRow } from "../providerPageHelpers";
// ──── types ─────────────────────────────────────────────────────────────────
@@ -79,11 +79,13 @@ export function useProviderModels(
notify.success(t("setAliasSuccess", { alias }));
} else {
const data = await res.json().catch(() => ({}));
notify.error(data?.error?.message || "Failed to set alias");
notify.error(
data?.error?.message || providerText(t, "failedSetAlias", "Failed to set alias")
);
}
} catch (error) {
console.log("Error setting alias:", error);
notify.error("Network error setting alias");
notify.error(providerText(t, "networkErrorSettingAlias", "Network error setting alias"));
}
},
[fetchAliases, t, notify]
@@ -100,11 +102,13 @@ export function useProviderModels(
notify.success(t("deleteAliasSuccess", { alias }));
} else {
const data = await res.json().catch(() => ({}));
notify.error(data?.error?.message || "Failed to delete alias");
notify.error(
data?.error?.message || providerText(t, "failedDeleteAlias", "Failed to delete alias")
);
}
} catch (error) {
console.log("Error deleting alias:", error);
notify.error("Network error deleting alias");
notify.error(providerText(t, "networkErrorDeletingAlias", "Network error deleting alias"));
}
},
[fetchAliases, t, notify]
@@ -113,10 +117,9 @@ export function useProviderModels(
const fetchProviderModelMeta = useCallback(async () => {
if (isSearchProvider) return;
try {
const res = await fetch(
`/api/provider-models?provider=${encodeURIComponent(providerId)}`,
{ cache: "no-store" }
);
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`, {
cache: "no-store",
});
if (!res.ok) return;
const data = await res.json();
setModelMeta({

View File

@@ -115,9 +115,7 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
} catch (error) {
if (!isCurrentRequest()) return;
setCodexSettingsLoaded(false);
setCodexSettingsLoadError(
error instanceof Error ? error.message : "Failed to load settings"
);
setCodexSettingsLoadError(error instanceof Error ? error.message : "Failed to load settings");
}
}, [providerId]);
@@ -184,15 +182,20 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setCodexGlobalServiceMode(previousMode);
notify.error(data.error || "Failed to update Codex service mode");
notify.error(
data.error ||
providerText(t, "failedUpdateCodexServiceMode", "Failed to update Codex service mode")
);
return;
}
notify.success("Codex service mode updated");
notify.success(providerText(t, "codexServiceModeUpdated", "Codex service mode updated"));
} catch (error) {
setCodexGlobalServiceMode(previousMode);
console.error("Error updating Codex service mode:", error);
notify.error("Failed to update Codex service mode");
notify.error(
providerText(t, "failedUpdateCodexServiceMode", "Failed to update Codex service mode")
);
} finally {
setSavingCodexGlobalServiceMode(false);
}
@@ -215,7 +218,14 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setPreferClaudeCodeForUnprefixedClaudeModels(previous);
notify.error(data.error || "Failed to update Claude Code routing preference");
notify.error(
data.error ||
providerText(
t,
"failedUpdateClaudeRoutingPreference",
"Failed to update Claude Code routing preference"
)
);
return;
}
@@ -227,14 +237,26 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
}
notify.success(
enabled
? "Unprefixed Claude models now prefer Claude Code"
: "Unprefixed Claude models no longer prefer Claude Code"
? providerText(
t,
"claudeRoutingPreferenceEnabled",
"Unprefixed Claude models now prefer Claude Code"
)
: providerText(
t,
"claudeRoutingPreferenceDisabled",
"Unprefixed Claude models no longer prefer Claude Code"
)
);
} catch (error) {
setPreferClaudeCodeForUnprefixedClaudeModels(previous);
console.error("Error updating Claude Code routing preference:", error);
notify.error(
providerText(t, "failedUpdateClaudeRoutingPreference", "Failed to update Claude Code routing preference")
providerText(
t,
"failedUpdateClaudeRoutingPreference",
"Failed to update Claude Code routing preference"
)
);
} finally {
setSavingClaudeRoutingPreference(false);

View File

@@ -8,6 +8,7 @@ import {
CLIENT_IDENTITY_PROFILE_OPTIONS,
getClientIdentityProfileHeaders,
} from "@/shared/constants/clientIdentityProfiles";
import { providerText } from "../[id]/providerPageHelpers";
type CompatibleMode = "openai" | "anthropic" | "cc";
type CompatibleProviderNode = { id: string } & Record<string, unknown>;
@@ -100,9 +101,11 @@ export default function AddCompatibleProviderModal({
const [checkKey, setCheckKey] = useState("");
const [checkModelId, setCheckModelId] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<
null | { valid: boolean; error?: string | null; method?: string | null }
>(null);
const [validationResult, setValidationResult] = useState<null | {
valid: boolean;
error?: string | null;
method?: string | null;
}>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const apiTypeOptions = useMemo(
@@ -242,7 +245,10 @@ export default function AddCompatibleProviderModal({
method: data.method ?? null,
});
} catch {
setValidationResult({ valid: false, error: "Network error" });
setValidationResult({
valid: false,
error: providerText(t, "networkError", "Network error"),
});
} finally {
setValidating(false);
}

View File

@@ -30,17 +30,17 @@ interface ProviderStats {
codexServiceTier?: "default" | "priority" | "flex" | null;
}
const KIND_LABEL: Record<string, string> = {
llm: "Chat",
embedding: "Embed",
image: "Image",
imageToText: "I→T",
tts: "TTS",
stt: "STT",
webSearch: "Search",
webFetch: "Fetch",
video: "Video",
music: "Music",
const KIND_LABEL_KEYS: Record<string, { key: string; fallback: string }> = {
llm: { key: "serviceKindChat", fallback: "Chat" },
embedding: { key: "serviceKindEmbedding", fallback: "Embed" },
image: { key: "serviceKindImage", fallback: "Image" },
imageToText: { key: "serviceKindImageToText", fallback: "I→T" },
tts: { key: "serviceKindTts", fallback: "TTS" },
stt: { key: "serviceKindStt", fallback: "STT" },
webSearch: { key: "serviceKindWebSearch", fallback: "Search" },
webFetch: { key: "serviceKindWebFetch", fallback: "Fetch" },
video: { key: "serviceKindVideo", fallback: "Video" },
music: { key: "serviceKindMusic", fallback: "Music" },
};
/** Maps a compatible-provider `apiType` to its `KIND_LABEL` key (#6936: non-chat
@@ -166,6 +166,10 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
const t = useTranslations("providers");
const tc = useTranslations("common");
const tp = useTranslations("miniPlayground");
const kindLabel = (kind: string) => {
const entry = KIND_LABEL_KEYS[kind];
return entry ? providerText(t, entry.key, entry.fallback) : kind;
};
const [testExpanded, setTestExpanded] = useState<boolean>(false);
const innerRef = useRef<HTMLDivElement>(null);
const linkElementRef = useRef<HTMLAnchorElement>(null);
@@ -426,15 +430,15 @@ const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function
key={k}
className="text-[10px] px-1.5 py-0.5 rounded bg-bg-subtle border border-border text-text-muted leading-none"
>
{KIND_LABEL[k] ?? k}
{kindLabel(k)}
</span>
))}
{isCompatible && (
<Badge variant="default" size="sm">
{provider.apiType === "responses"
? t("responses")
: (KIND_LABEL[COMPATIBLE_API_TYPE_KIND[provider.apiType ?? ""] ?? ""] ??
t("chat"))}
: kindLabel(COMPATIBLE_API_TYPE_KIND[provider.apiType ?? ""] ?? "") ||
t("chat")}
</Badge>
)}
{isCcCompatible && (

View File

@@ -110,7 +110,12 @@ export default function ProviderSummaryCard({
const categories = [
{ key: null, color: null, label: t("providerSummaryAll"), stat: summaryStats.all },
{ key: "oauth", color: "bg-blue-500", label: t("oauthLabel"), stat: summaryStats.oauth },
{ key: "ide", color: "bg-cyan-500", label: "IDE", stat: summaryStats.ide },
{
key: "ide",
color: "bg-cyan-500",
label: providerText(t, "categoryIde", "IDE"),
stat: summaryStats.ide,
},
{
key: "free",
color: "bg-green-500",
@@ -132,8 +137,18 @@ export default function ProviderSummaryCard({
label: t("compatibleLabel"),
stat: summaryStats.compatible,
},
{ key: "webcookie", color: "bg-purple-500", label: "Web Cookie", stat: summaryStats.webcookie },
{ key: "search", color: "bg-teal-500", label: "Search", stat: summaryStats.search },
{
key: "webcookie",
color: "bg-purple-500",
label: providerText(t, "categoryWebCookie", "Web Cookie"),
stat: summaryStats.webcookie,
},
{
key: "search",
color: "bg-teal-500",
label: providerText(t, "categorySearch", "Search"),
stat: summaryStats.search,
},
{
key: "webfetch",
color: "bg-orange-500",
@@ -141,12 +156,22 @@ export default function ProviderSummaryCard({
stat: summaryStats.webfetch,
title: t("webFetchTooltip"),
},
{ key: "audio", color: "bg-rose-500", label: "Audio", stat: summaryStats.audio },
{ key: "local", color: "bg-emerald-500", label: "Local", stat: summaryStats.local },
{
key: "audio",
color: "bg-rose-500",
label: providerText(t, "categoryAudio", "Audio"),
stat: summaryStats.audio,
},
{
key: "local",
color: "bg-emerald-500",
label: providerText(t, "categoryLocal", "Local"),
stat: summaryStats.local,
},
{
key: "cloudagent",
color: "bg-violet-500",
label: "Cloud Agent",
label: providerText(t, "categoryCloudAgent", "Cloud Agent"),
stat: summaryStats.cloudagent,
},
].filter((category) => category.key !== "no-auth" || category.stat.total > 0);
@@ -178,8 +203,8 @@ export default function ProviderSummaryCard({
<Input
value={modelSearchQuery}
onChange={(e) => setModelSearchQuery(e.target.value)}
placeholder={t("searchByModel") || "Search by model…"}
aria-label={t("searchByModel") || "Search by model"}
placeholder={providerText(t, "searchByModel", "Search by model…")}
aria-label={providerText(t, "searchByModelAria", "Search by model")}
icon="psychology"
inputClassName={modelSearchQuery ? "pr-9" : ""}
/>

View File

@@ -1,5 +1,27 @@
"use client";
import { useTranslations } from "next-intl";
type ProviderMessageTranslator = ((key: string, values?: Record<string, unknown>) => string) & {
has?: (key: string) => boolean;
};
function providerText(
t: ProviderMessageTranslator,
key: string,
fallback: string,
values?: Record<string, unknown>
): string {
if (typeof t.has === "function" && t.has(key)) return t(key, values);
if (values) {
return Object.entries(values).reduce(
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
fallback
);
}
return fallback;
}
export default function ProvidersError({
error: _error,
reset,
@@ -7,6 +29,8 @@ export default function ProvidersError({
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useTranslations("providers");
return (
<div
className="flex flex-col items-center justify-center min-h-[400px]"
@@ -15,13 +39,19 @@ export default function ProvidersError({
>
<div className="text-center space-y-4">
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
Failed to load providers
{providerText(t, "pageLoadErrorTitle", "Failed to load providers")}
</h2>
<p className="text-text-muted max-w-md">
We could not load provider data right now. Check your connection and try again.
{providerText(
t,
"pageLoadErrorDescription",
"We could not load provider data right now. Check your connection and try again."
)}
</p>
{_error?.digest && (
<p className="text-xs text-text-muted font-mono">Error ID: {_error.digest}</p>
<p className="text-xs text-text-muted font-mono">
{providerText(t, "pageLoadErrorId", "Error ID: {id}", { id: _error.digest })}
</p>
)}
{process.env.NODE_ENV === "development" && _error?.message && (
<p className="text-xs text-red-600 dark:text-red-400 font-mono">{_error.message}</p>
@@ -30,7 +60,7 @@ export default function ProvidersError({
onClick={reset}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-primary"
>
Try Again
{providerText(t, "pageLoadErrorRetry", "Try Again")}
</button>
</div>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
export interface ProviderModel {
id: string;
@@ -29,6 +30,7 @@ interface UseProviderModelsResult {
* `providerId` changes).
*/
export function useProviderModels(providerId: string): UseProviderModelsResult {
const t = useTranslations("providers");
const [models, setModels] = useState<ProviderModel[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
@@ -49,7 +51,7 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
const body = (await res.json().catch(() => null)) as {
error?: { message?: string };
} | null;
const msg = body?.error?.message ?? `HTTP ${res.status}`;
const msg = body?.error?.message ?? `${t("providerTestFailed")} (HTTP ${res.status})`;
if (!cancelled) setError(msg);
return;
}
@@ -113,7 +115,7 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
return () => {
cancelled = true;
};
}, [providerId]);
}, [providerId, t]);
return { models, loading, error };
}

View File

@@ -134,22 +134,28 @@ type ProviderBatchTestResults = {
error?: string | { message?: string };
};
function getConnectionErrorTag(connection) {
function getConnectionErrorTag(connection, t: ProviderMessageTranslator) {
if (!connection) return null;
const explicitType = connection.lastErrorType;
if (explicitType === "runtime_error") return "Runtime";
if (explicitType === "runtime_error") return providerText(t, "errorTypeRuntime", "Runtime");
if (
explicitType === "upstream_auth_error" ||
explicitType === "auth_missing" ||
explicitType === "token_refresh_failed" ||
explicitType === "token_expired"
) {
return "Auth";
return providerText(t, "errorTypeUpstreamAuth", "Auth");
}
if (explicitType === "upstream_rate_limited") {
return providerText(t, "errorTypeRateLimited", "Rate limited");
}
if (explicitType === "upstream_unavailable") {
return providerText(t, "errorTypeUpstreamUnavailable", "Server error");
}
if (explicitType === "network_error") {
return providerText(t, "errorTypeNetworkError", "Network");
}
if (explicitType === "upstream_rate_limited") return "Rate limited";
if (explicitType === "upstream_unavailable") return "Server error";
if (explicitType === "network_error") return "Network";
const numericCode = Number(connection.errorCode);
if (Number.isFinite(numericCode) && numericCode >= 400) {
@@ -157,19 +163,21 @@ function getConnectionErrorTag(connection) {
}
const fromMessage = getErrorCode(connection.lastError);
if (fromMessage === "401" || fromMessage === "403") return "Auth";
if (fromMessage === "401" || fromMessage === "403") {
return providerText(t, "errorTypeUpstreamAuth", "Auth");
}
if (fromMessage && fromMessage !== "ERR") return fromMessage;
const msg = (connection.lastError || "").toLowerCase();
if (msg.includes("runtime") || msg.includes("not runnable") || msg.includes("not installed"))
return "Runtime";
return providerText(t, "errorTypeRuntime", "Runtime");
if (
msg.includes("invalid api key") ||
msg.includes("token invalid") ||
msg.includes("revoked") ||
msg.includes("unauthorized")
)
return "Auth";
return providerText(t, "errorTypeUpstreamAuth", "Auth");
return "ERR";
}
@@ -352,7 +360,7 @@ export default function ProvidersPage() {
(a: any, b: any) =>
(new Date(b.lastErrorAt || 0) as any) - (new Date(a.lastErrorAt || 0) as any)
)[0];
const errorCode = latestError ? getConnectionErrorTag(latestError) : null;
const errorCode = latestError ? getConnectionErrorTag(latestError, t) : null;
const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null;
// Check expirations
@@ -822,11 +830,14 @@ export default function ProvidersPage() {
<span className="material-symbols-outlined text-[32px] text-primary">dns</span>
</div>
<h2 className="text-xl font-semibold text-text-main">
{t("addFirstProvider") || "Add your first provider"}
{providerText(t, "addFirstProvider", "Add your first provider")}
</h2>
<p className="text-sm text-text-muted mt-2 max-w-md">
{t("addFirstProviderDesc") ||
"Connect an AI provider to start routing requests through OmniRoute. You can use free providers, API keys, or OAuth accounts."}
{providerText(
t,
"addFirstProviderDesc",
"Connect an AI provider to start routing requests through OmniRoute. You can use free providers, API keys, or OAuth accounts."
)}
</p>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<Button icon="add" onClick={() => router.push("/dashboard/providers/new")}>
@@ -839,7 +850,7 @@ export default function ProvidersPage() {
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-lg border border-border text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
>
<span className="material-symbols-outlined text-[16px]">help</span>
{t("learnMore") || "Learn more"}
{providerText(t, "learnMore", "Learn more")}
</a>
</div>
</div>
@@ -1094,10 +1105,10 @@ export default function ProvidersPage() {
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold flex items-center gap-2 flex-1 min-w-0">
{t("ideProviders") || "IDE Providers"}{" "}
{providerText(t, "ideProviders", "IDE Providers")}{" "}
<span
className="size-2.5 rounded-full bg-cyan-500"
title={t("ideProviders") || "IDE Providers"}
title={providerText(t, "ideProviders", "IDE Providers")}
/>
<ProviderCountBadge {...countConfigured(ideProviderEntriesAll)} />
</h2>
@@ -1121,12 +1132,15 @@ export default function ProvidersPage() {
</button>
</div>
<p className="text-sm text-text-muted -mt-2">
{t("ideProvidersDesc") ||
"Editors with built-in AI subscription. Use the provider page to import credentials directly from the IDE's keychain."}
{providerText(
t,
"ideProvidersDesc",
"Editors with built-in AI subscription. Use the provider page to import credentials directly from the IDE's keychain."
)}
</p>
{ideProviderEntries.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-bg-subtle p-6 text-center text-sm text-text-muted">
{t("noIdeProviders") || "No IDE providers match the current filters."}
{providerText(t, "noIdeProviders", "No IDE providers match the current filters.")}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3">

View File

@@ -6,12 +6,34 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Card, Toggle } from "@/shared/components";
import { useServiceStatus } from "../hooks/useServiceStatus";
type ServiceMessageTranslator = ((key: string, values?: Record<string, unknown>) => string) & {
has?: (key: string) => boolean;
};
function serviceText(
t: ServiceMessageTranslator,
key: string,
fallback: string,
values?: Record<string, unknown>
): string {
if (typeof t.has === "function" && t.has(key)) return t(key, values);
if (values) {
return Object.entries(values).reduce(
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
fallback
);
}
return fallback;
}
const NAME = "cliproxy";
export function CliproxyProviderExposureCard() {
const t = useTranslations("embeddedServices");
const { data, mutate } = useServiceStatus(NAME);
const [pending, setPending] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
@@ -28,14 +50,30 @@ export function CliproxyProviderExposureCard() {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const errorMsg =
body?.error?.message ?? body?.message ?? `Failed to update (HTTP ${res.status})`;
body?.error?.message ??
body?.message ??
serviceText(
t,
"cliproxyProviderExposureUpdateFailed",
"Failed to update (HTTP {status})",
{
status: res.status,
}
);
setMsg({ ok: false, text: errorMsg });
return;
}
setMsg(null);
mutate();
} catch {
setMsg({ ok: false, text: "Network error — could not update provider exposure setting" });
setMsg({
ok: false,
text: serviceText(
t,
"cliproxyProviderExposureNetworkFailed",
"Network error — could not update provider exposure setting"
),
});
} finally {
setPending(false);
}
@@ -48,10 +86,15 @@ export function CliproxyProviderExposureCard() {
<span className="material-symbols-outlined text-sky-500 text-xl">hub</span>
</div>
<div>
<h3 className="font-medium text-sm">Provider Exposure</h3>
<h3 className="font-medium text-sm">
{serviceText(t, "cliproxyProviderExposureTitle", "Provider Exposure")}
</h3>
<p className="text-xs text-text-muted">
Expose CLIProxyAPI models as a routing target under the{" "}
<code className="font-mono bg-bg-subtle px-1 rounded">cliproxyapi/</code> prefix.
{serviceText(
t,
"cliproxyProviderExposureDescription",
"Expose CLIProxyAPI models as a routing target under the cliproxyapi/ prefix."
)}
</p>
</div>
</div>
@@ -74,16 +117,23 @@ export function CliproxyProviderExposureCard() {
<div className="flex items-center justify-between">
<div>
<p className="text-sm">
Expose as{" "}
{serviceText(t, "cliproxyProviderExposureLabel", "Expose as")}{" "}
<code className="font-mono bg-bg-subtle px-1 rounded text-xs">cliproxyapi/...</code>
</p>
<p className="text-xs text-text-muted mt-0.5">
When enabled, discovered models appear in provider selects across OmniRoute.
{serviceText(
t,
"cliproxyProviderExposureHint",
"When enabled, discovered models appear in provider selects across OmniRoute."
)}
</p>
</div>
<Toggle checked={data?.providerExpose ?? false} onChange={handleToggle} disabled={pending || !data} />
<Toggle
checked={data?.providerExpose ?? false}
onChange={handleToggle}
disabled={pending || !data}
/>
</div>
</Card>
);
}

View File

@@ -1,10 +1,14 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import RelayProxyClient from "./RelayProxyClient";
export const metadata: Metadata = {
title: "OmniRoute — Relay Proxies",
description: "Serverless relay proxy endpoints for your AI infrastructure",
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("metadata");
return {
title: t("relayTitle"),
description: t("relayDescription"),
};
}
export default function RelayProxyPage() {
return <RelayProxyClient />;

View File

@@ -320,7 +320,7 @@ export default function AuthzSection() {
onClick={() => handleRemovePrefix(prefix)}
disabled={locked || submitting}
>
Remove
{t("authz.remove")}
</Button>
</li>
);

View File

@@ -38,7 +38,7 @@ export default function AutoDisableCard() {
const savedData = await res.json();
setData(savedData);
setEditMode(false);
notify.success(t("savedSuccessfully") || "Saved successfully");
notify.success(t("savedSuccessfully"));
} catch (err) {
notify.error(err instanceof Error ? err.message : "Error saving");
} finally {

View File

@@ -93,18 +93,12 @@ export default function BackgroundDegradationTab() {
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">
{t("backgroundDegradationTitle") || "Background Task Degradation"}
</h3>
<p className="text-sm text-text-muted">
{t("backgroundDegradationDesc") ||
"Auto-redirect background requests (titles, summaries) to cheaper models"}
</p>
<h3 className="text-lg font-semibold">{t("backgroundDegradationTitle")}</h3>
<p className="text-sm text-text-muted">{t("backgroundDegradationDesc")}</p>
</div>
{status === "saved" && (
<span className="text-xs font-medium text-emerald-500 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">check_circle</span>{" "}
{t("saved") || "Saved"}
<span className="material-symbols-outlined text-[14px]">check_circle</span> {t("saved")}
</span>
)}
</div>
@@ -112,9 +106,7 @@ export default function BackgroundDegradationTab() {
{/* Toggle */}
<div className="flex items-center justify-between p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
<div>
<p className="text-sm font-medium">
{t("enableDegradation") || "Enable Background Degradation"}
</p>
<p className="text-sm font-medium">{t("enableDegradation")}</p>
<p className="text-xs text-text-muted mt-0.5">
{t("enableDegradationHint") ||
"Automatically use cheaper models for background utility tasks"}
@@ -124,7 +116,7 @@ export default function BackgroundDegradationTab() {
checked={config.enabled}
onChange={(enabled) => save({ enabled })}
disabled={loading || saving}
ariaLabel={t("enableDegradation") || "Enable Background Degradation"}
ariaLabel={t("enableDegradation")}
/>
</div>
@@ -133,9 +125,7 @@ export default function BackgroundDegradationTab() {
<div className="flex items-center gap-4 p-3 rounded-lg bg-sky-500/5 border border-sky-500/20 mb-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[16px] text-sky-400">analytics</span>
<span className="text-xs text-text-muted">
{t("tasksDetected") || "Tasks detected"}:
</span>
<span className="text-xs text-text-muted">{t("tasksDetected")}:</span>
<span className="text-sm font-mono font-semibold text-sky-400">
{config.stats.detected}
</span>
@@ -148,7 +138,7 @@ export default function BackgroundDegradationTab() {
{/* Degradation Map */}
<div className="mb-4">
<p className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
{t("degradationMap") || "Model Degradation Map"}
{t("degradationMap")}
</p>
{/* Add new mapping */}
@@ -157,23 +147,19 @@ export default function BackgroundDegradationTab() {
<ModelSelectField
value={newFrom}
onChange={setNewFrom}
placeholder={t("premiumModel") || "Premium model"}
placeholder={t("premiumModel")}
/>
</div>
<span className="text-text-muted text-lg"></span>
<div className="flex-1">
<ModelSelectField
value={newTo}
onChange={setNewTo}
placeholder={t("cheapModel") || "Cheap model"}
/>
<ModelSelectField value={newTo} onChange={setNewTo} placeholder={t("cheapModel")} />
</div>
<button
onClick={addMapping}
disabled={saving || !newFrom.trim() || !newTo.trim()}
className="px-3 py-2 rounded-lg text-sm font-medium bg-sky-500/10 text-sky-500 hover:bg-sky-500/20 disabled:opacity-50 transition-all"
>
{t("add") || "Add"}
{t("add")}
</button>
</div>
@@ -206,15 +192,14 @@ export default function BackgroundDegradationTab() {
<span className="material-symbols-outlined text-[14px] group-open:rotate-90 transition-transform">
chevron_right
</span>
{t("detectionPatterns") || "Detection Patterns"} (
{config.detectionPatterns?.length || 0})
{t("detectionPatterns")} ({config.detectionPatterns?.length || 0})
</summary>
{/* Add new pattern */}
<div className="flex items-center gap-2 mb-3">
<input
type="text"
placeholder={t("newPattern") || 'e.g. "generate a title"'}
placeholder={t("newPattern")}
value={newPattern}
onChange={(e) => setNewPattern(e.target.value)}
className="flex-1 px-3 py-2 rounded-lg text-sm bg-surface border border-border/50 focus:border-sky-500/50 focus:outline-none"
@@ -224,7 +209,7 @@ export default function BackgroundDegradationTab() {
disabled={saving || !newPattern.trim()}
className="px-3 py-2 rounded-lg text-sm font-medium bg-sky-500/10 text-sky-500 hover:bg-sky-500/20 disabled:opacity-50 transition-all"
>
{t("add") || "Add"}
{t("add")}
</button>
</div>

View File

@@ -49,17 +49,21 @@ export default function CliproxyapiSettingsTab() {
const data = await res.json();
if (res.ok) {
setImportResult(
`Imported ${data.imported ?? 0} account(s) (scanned ${data.scanned ?? 0}, skipped ${data.skipped ?? 0}).`
t("cliproxyapiImportResult", {
imported: data.imported ?? 0,
scanned: data.scanned ?? 0,
skipped: data.skipped ?? 0,
})
);
} else {
setImportResult(data.error || "Import failed.");
setImportResult(data.error || t("cliproxyapiImportFailed"));
}
} catch {
setImportResult("Import failed.");
setImportResult(t("cliproxyapiImportFailed"));
} finally {
setImporting(false);
}
}, []);
}, [t]);
useEffect(() => {
fetch("/api/settings")
@@ -95,34 +99,37 @@ export default function CliproxyapiSettingsTab() {
});
}, []);
const updateSetting = useCallback(async (key: string, value: boolean | string) => {
if (key === "cliproxyapi_url" && typeof value === "string" && value.trim() !== "") {
if (!isValidUrl(value)) {
setMessage({ type: "error", text: "Invalid URL format. Use http:// or https://" });
return;
const updateSetting = useCallback(
async (key: string, value: boolean | string) => {
if (key === "cliproxyapi_url" && typeof value === "string" && value.trim() !== "") {
if (!isValidUrl(value)) {
setMessage({ type: "error", text: t("cliproxyapiInvalidUrl") });
return;
}
}
}
setSaving(true);
setMessage(null);
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: value }),
});
if (!res.ok) {
throw new Error(`Server returned ${res.status}`);
setSaving(true);
setMessage(null);
try {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [key]: value }),
});
if (!res.ok) {
throw new Error(`Server returned ${res.status}`);
}
await res.json();
setSettings((prev) => ({ ...prev, [key]: value }));
setMessage({ type: "success", text: t("settingSaved") });
} catch {
setMessage({ type: "error", text: t("settingSaveFailed") });
} finally {
setSaving(false);
}
await res.json();
setSettings((prev) => ({ ...prev, [key]: value }));
setMessage({ type: "success", text: "Setting saved" });
} catch {
setMessage({ type: "error", text: "Failed to save setting" });
} finally {
setSaving(false);
}
}, []);
},
[t]
);
const cpaEnabled = settings.cliproxyapi_fallback_enabled === true;
const cpaUrl = settings.cliproxyapi_url || "http://127.0.0.1:8317";
@@ -148,14 +155,14 @@ export default function CliproxyapiSettingsTab() {
<div className="flex items-start gap-2 px-3 py-2.5 rounded-lg bg-blue-500/10 text-blue-700 dark:text-blue-300 text-xs">
<span className="material-symbols-outlined text-[14px] mt-0.5 shrink-0">info</span>
<span>
CLIProxyAPI lifecycle management (install, start, stop) has moved to{" "}
{t("cliproxyapiLifecycleNoticeBefore")}{" "}
<Link
href="/dashboard/providers/services"
className="underline underline-offset-2 hover:opacity-80"
>
Providers Services
{t("cliproxyapiLifecycleNoticeLink")}
</Link>
. Fallback routing settings below remain here.
{t("cliproxyapiLifecycleNoticeAfter")}
</span>
</div>
@@ -181,9 +188,7 @@ export default function CliproxyapiSettingsTab() {
</div>
<div>
<h3 className="font-medium text-sm">{t("cliproxyapiFallback")}</h3>
<p className="text-xs text-text-muted">
When enabled, failed requests are retried through CLIProxyAPI (localhost:8317)
</p>
<p className="text-xs text-text-muted">{t("cliproxyapiFallbackDescription")}</p>
</div>
</div>
@@ -212,7 +217,7 @@ export default function CliproxyapiSettingsTab() {
<div>
<label className="text-xs text-text-muted mb-1.5 block">
Fallback Status Codes (comma-separated)
{t("cliproxyapiFallbackCodes")}
</label>
<Input
value={cpaCodes}
@@ -233,31 +238,31 @@ export default function CliproxyapiSettingsTab() {
<span className="material-symbols-outlined animate-spin text-base">
progress_activity
</span>
Loading...
{t("loading")}
</div>
) : toolStateError ? (
<p className="text-sm text-text-muted">{toolStateError}</p>
) : toolState ? (
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-bg-secondary">
<p className="text-xs text-text-muted mb-1">Status</p>
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiStatusLabel")}</p>
<div className="flex items-center gap-1.5">
<span className={`material-symbols-outlined text-sm ${statusColor}`}>
{statusIcon}
</span>
<p className={`text-sm font-medium capitalize ${statusColor}`}>
{toolState.status?.replace("_", " ") || "Unknown"}
{toolState.status?.replace("_", " ") || t("unknown")}
</p>
</div>
</div>
<div className="p-3 rounded-lg bg-bg-secondary">
<p className="text-xs text-text-muted mb-1">Version</p>
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiVersionLabel")}</p>
<p className="text-sm font-medium">
{toolState.installedVersion ? `v${toolState.installedVersion}` : "Not installed"}
{toolState.installedVersion ? `v${toolState.installedVersion}` : t("notInstalled")}
</p>
</div>
<div className="p-3 rounded-lg bg-bg-secondary">
<p className="text-xs text-text-muted mb-1">Health</p>
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiHealthLabel")}</p>
<p
className={`text-sm font-medium ${
toolState.healthStatus === "healthy"
@@ -268,14 +273,14 @@ export default function CliproxyapiSettingsTab() {
}`}
>
{toolState.healthStatus === "healthy"
? "Healthy"
? t("healthy")
: toolState.healthStatus === "unhealthy"
? "Unhealthy"
: "Unknown"}
? t("unhealthy")
: t("unknown")}
</p>
</div>
<div className="p-3 rounded-lg bg-bg-secondary">
<p className="text-xs text-text-muted mb-1">Port</p>
<p className="text-xs text-text-muted mb-1">{t("cliproxyapiPortLabel")}</p>
<p className="text-sm font-mono">{toolState.port || 8317}</p>
</div>
</div>

View File

@@ -369,7 +369,10 @@ export default function MemorySkillsTab() {
role="note"
data-testid="memory-token-cost-warning"
>
<span className="material-symbols-outlined text-[18px] leading-none mt-0.5" aria-hidden="true">
<span
className="material-symbols-outlined text-[18px] leading-none mt-0.5"
aria-hidden="true"
>
info
</span>
<p className="text-xs leading-relaxed">
@@ -540,7 +543,7 @@ export default function MemorySkillsTab() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="p-4 rounded-lg bg-surface/30 border border-border/30">
<label className="text-sm font-medium block mb-2">Host</label>
<label className="text-sm font-medium block mb-2">{t("host")}</label>
<input
value={qdrant.host}
onChange={(e) => setQdrant((s) => ({ ...s, host: e.target.value }))}
@@ -567,7 +570,7 @@ export default function MemorySkillsTab() {
</div>
<div className="p-4 rounded-lg bg-surface/30 border border-border/30">
<label className="text-sm font-medium block mb-2">Collection</label>
<label className="text-sm font-medium block mb-2">{t("collection")}</label>
<input
value={qdrant.collection}
onChange={(e) => setQdrant((s) => ({ ...s, collection: e.target.value }))}
@@ -781,9 +784,7 @@ export default function MemorySkillsTab() {
</div>
<div>
<h3 className="text-lg font-semibold">{t("memorySkillsSkillsmpMarketplace")}</h3>
<p className="text-sm text-text-muted">
Connect to SkillsMP to discover and install skills from the marketplace.
</p>
<p className="text-sm text-text-muted">{t("memorySkillsSkillsmpDescription")}</p>
</div>
{skillsmpStatus === "saved" && (
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
@@ -813,12 +814,12 @@ export default function MemorySkillsTab() {
disabled={skillsmpSaving}
className="px-4 py-2 text-sm font-medium rounded-lg bg-violet-500 text-white hover:bg-violet-600 disabled:opacity-50 transition-colors"
>
{skillsmpSaving ? "Saving..." : "Save"}
{skillsmpSaving ? t("saving") : t("save")}
</button>
</div>
<p className="text-xs text-text-muted mt-2">
Get your API key from <span className="text-violet-400">skillsmp.com</span>. Rate limit:
500 requests/day.
{t("skillsmpApiKeyHintBefore")} <span className="text-violet-400">skillsmp.com</span>
{t("skillsmpApiKeyHintAfter", { limit: 500 })}
</p>
</div>
</Card>
@@ -833,9 +834,7 @@ export default function MemorySkillsTab() {
</div>
<div>
<h3 className="text-lg font-semibold">{t("memorySkillsActiveSkillsProvider")}</h3>
<p className="text-sm text-text-muted">
Choose which provider the Skills page uses for search and install.
</p>
<p className="text-sm text-text-muted">{t("memorySkillsActiveProviderDescription")}</p>
</div>
{skillsProviderStatus === "saved" && (
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
@@ -864,10 +863,10 @@ export default function MemorySkillsTab() {
<p
className={`text-sm font-medium ${skillsProvider === "skillsmp" ? "text-indigo-400" : ""}`}
>
SkillsMP Marketplace
{t("memorySkillsSkillsmpProviderTitle")}
</p>
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">
Authenticated marketplace (uses your SkillsMP API key).
{t("memorySkillsSkillsmpProviderDescription")}
</p>
</button>
@@ -884,10 +883,10 @@ export default function MemorySkillsTab() {
<p
className={`text-sm font-medium ${skillsProvider === "skillssh" ? "text-indigo-400" : ""}`}
>
skills.sh Directory
{t("memorySkillsSkillsshProviderTitle")}
</p>
<p className="text-xs text-text-muted mt-0.5 leading-relaxed">
Public directory provider (no API key required).
{t("memorySkillsSkillsshProviderDescription")}
</p>
</button>
</div>

View File

@@ -82,26 +82,23 @@ export default function ModelAliasesTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">{t("modelAliasesTitle") || "Model Aliases"}</h3>
<p className="text-sm text-text-muted">
{t("modelAliasesDesc") || "Auto-forward deprecated model IDs to their replacements"}
</p>
<h3 className="text-lg font-semibold">{t("modelAliasesTitle")}</h3>
<p className="text-sm text-text-muted">{t("modelAliasesDesc")}</p>
</div>
{status === "saved" && (
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]">check_circle</span>{" "}
{t("saved") || "Saved"}
<span className="material-symbols-outlined text-[14px]">check_circle</span> {t("saved")}
</span>
)}
</div>
{/* Add custom alias */}
<div className="p-4 rounded-lg bg-surface/30 border border-border/30 mb-4">
<p className="text-sm font-medium mb-3">{t("addCustomAlias") || "Add Custom Alias"}</p>
<p className="text-sm font-medium mb-3">{t("addCustomAlias")}</p>
<div className="flex items-center gap-2">
<input
type="text"
placeholder={t("deprecatedModelId") || "Deprecated model ID"}
placeholder={t("deprecatedModelId")}
value={newFrom}
onChange={(e) => setNewFrom(e.target.value)}
className="flex-1 px-3 py-2 rounded-lg text-sm bg-surface border border-border/50 focus:border-amber-500/50 focus:outline-none"
@@ -109,7 +106,7 @@ export default function ModelAliasesTab() {
<span className="text-text-muted text-lg"></span>
<input
type="text"
placeholder={t("newModelId") || "New model ID"}
placeholder={t("newModelId")}
value={newTo}
onChange={(e) => setNewTo(e.target.value)}
className="flex-1 px-3 py-2 rounded-lg text-sm bg-surface border border-border/50 focus:border-amber-500/50 focus:outline-none"
@@ -119,7 +116,7 @@ export default function ModelAliasesTab() {
disabled={saving || !newFrom.trim() || !newTo.trim()}
className="px-4 py-2 rounded-lg text-sm font-medium bg-amber-500/10 text-amber-500 hover:bg-amber-500/20 disabled:opacity-50 transition-all"
>
{t("add") || "Add"}
{t("add")}
</button>
</div>
</div>
@@ -128,7 +125,7 @@ export default function ModelAliasesTab() {
{customEntries.length > 0 && (
<div className="mb-4">
<p className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
{t("customAliases") || "Custom Aliases"}
{t("customAliases")}
</p>
<div className="rounded-lg border border-border/30 divide-y divide-border/20">
{customEntries.map(([from, to]) => (
@@ -157,7 +154,7 @@ export default function ModelAliasesTab() {
<span className="material-symbols-outlined text-[14px] group-open:rotate-90 transition-transform">
chevron_right
</span>
{t("builtInAliases") || "Built-in Aliases"} ({builtInEntries.length})
{t("builtInAliases")} ({builtInEntries.length})
</summary>
<div className="rounded-lg border border-border/30 divide-y divide-border/20 max-h-60 overflow-y-auto">
{builtInEntries.map(([from, to]) => (

View File

@@ -85,7 +85,7 @@ export default function ModelsDevSyncTab() {
});
fetchStatus();
} else {
setFeedback({ type: "error", message: result.error || "Sync failed" });
setFeedback({ type: "error", message: result.error || t("syncFailed") });
}
} else {
setFeedback({ type: "error", message: "Sync request failed" });
@@ -110,7 +110,7 @@ export default function ModelsDevSyncTab() {
});
if (!res.ok) {
setEnabled(!newVal);
setFeedback({ type: "error", message: t("enableSyncError") || "Failed to update" });
setFeedback({ type: "error", message: t("enableSyncError") });
} else {
setFeedback({ type: "success", message: "Settings saved" });
}
@@ -136,7 +136,7 @@ export default function ModelsDevSyncTab() {
if (!res.ok) {
setIntervalHours(oldInterval);
setDraftIntervalHours(oldInterval);
setFeedback({ type: "error", message: t("enableSyncError") || "Failed to update" });
setFeedback({ type: "error", message: t("enableSyncError") });
} else {
setFeedback({ type: "success", message: "Interval updated" });
}

View File

@@ -44,6 +44,7 @@ export default function OneproxyTab() {
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState<string | null>(null);
const [syncSucceeded, setSyncSucceeded] = useState(false);
const [filterProtocol, setFilterProtocol] = useState("");
const [filterCountry, setFilterCountry] = useState("");
const [minQuality, setMinQuality] = useState("");
@@ -83,24 +84,32 @@ export default function OneproxyTab() {
const handleSync = async () => {
setSyncing(true);
setSyncResult(null);
setSyncSucceeded(false);
try {
const res = await fetch("/api/settings/oneproxy", { method: "POST" });
const data = await res.json();
if (data.success) {
setSyncResult(`Synced ${data.total} proxies (${data.added} new, ${data.updated} updated)`);
setSyncSucceeded(true);
setSyncResult(
t("oneproxySyncSuccess", {
total: data.total,
added: data.added,
updated: data.updated,
})
);
} else {
setSyncResult(`Sync failed: ${data.error}`);
setSyncResult(t("oneproxySyncFailed", { error: data.error }));
}
await loadData();
} catch (err) {
setSyncResult(`Sync failed: ${err}`);
setSyncResult(t("oneproxySyncFailed", { error: String(err) }));
} finally {
setSyncing(false);
}
};
const handleClearAll = async () => {
if (!confirm("Clear all 1proxy proxies?")) return;
if (!confirm(t("oneproxyClearAllConfirm"))) return;
try {
await fetch("/api/settings/oneproxy?clearAll=1", { method: "DELETE" });
await loadData();
@@ -141,17 +150,15 @@ export default function OneproxyTab() {
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-text-main">{t("oneproxyTitle")}</h2>
<p className="text-sm text-text-muted mt-1">
Fetch and rotate free validated proxies from the 1proxy community platform
</p>
<p className="text-sm text-text-muted mt-1">{t("oneproxyDescription")}</p>
</div>
<div className="flex gap-2">
<Button onClick={handleSync} disabled={syncing} variant="primary">
{syncing ? "Syncing..." : "Sync Now"}
{syncing ? t("oneproxySyncing") : t("oneproxySyncNow")}
</Button>
{proxies.length > 0 && (
<Button onClick={handleClearAll} variant="danger">
Clear All
{t("oneproxyClearAll")}
</Button>
)}
</div>
@@ -160,7 +167,7 @@ export default function OneproxyTab() {
{syncResult && (
<div
className={`p-3 rounded-lg text-sm ${
syncResult.startsWith("Synced")
syncSucceeded
? "bg-green-50 text-green-800 dark:bg-green-900/30 dark:text-green-300"
: "bg-red-50 text-red-800 dark:bg-red-900/30 dark:text-red-300"
}`}
@@ -177,7 +184,7 @@ export default function OneproxyTab() {
</Card>
<Card className="p-4">
<div className="text-2xl font-bold text-green-600">{stats.active}</div>
<div className="text-sm text-text-muted">Active</div>
<div className="text-sm text-text-muted">{t("oneproxyActive")}</div>
</Card>
<Card className="p-4">
<div className="text-2xl font-bold text-text-main">
@@ -230,22 +237,36 @@ export default function OneproxyTab() {
{loading ? (
<div className="text-center py-8 text-text-muted">{t("oneproxyLoadingProxies")}</div>
) : proxies.length === 0 ? (
<div className="text-center py-8 text-text-muted">
No 1proxy proxies found. Click &quot;Sync Now&quot; to fetch free proxies.
</div>
<div className="text-center py-8 text-text-muted">{t("oneproxyEmpty")}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 px-3 text-text-muted font-medium">Host</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Protocol</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Country</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Quality</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Latency</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Anonymity</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Google</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">Actions</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">
{t("oneproxyHost")}
</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">
{t("oneproxyProtocol")}
</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">
{t("oneproxyCountry")}
</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">
{t("oneproxyQuality")}
</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">
{t("oneproxyLatency")}
</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">
{t("oneproxyAnonymity")}
</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">
{t("oneproxyGoogle")}
</th>
<th className="text-left py-2 px-3 text-text-muted font-medium">
{t("oneproxyActions")}
</th>
</tr>
</thead>
<tbody>
@@ -289,7 +310,7 @@ export default function OneproxyTab() {
onClick={() => handleDelete(proxy.id)}
className="text-red-500 hover:text-red-700 text-xs"
>
Delete
{t("oneproxyDelete")}
</button>
</td>
</tr>

View File

@@ -1,10 +1,13 @@
"use client";
import { useTranslations } from "next-intl";
interface ProxyStatusBadgeProps {
status?: string;
}
export function ProxyStatusBadge({ status }: ProxyStatusBadgeProps) {
const t = useTranslations("settings");
const isInactive = status === "inactive";
return (
<span
@@ -17,7 +20,7 @@ export function ProxyStatusBadge({ status }: ProxyStatusBadgeProps) {
<span
className={`w-1.5 h-1.5 rounded-full ${isInactive ? "bg-red-400" : "bg-emerald-400"}`}
/>
{isInactive ? "Inactive" : "Active"}
{isInactive ? t("proxyStatusInactive") : t("proxyStatusActive")}
</span>
);
}

View File

@@ -702,7 +702,7 @@ function ComboCooldownWaitCard({
setDraft(value);
}, [value]);
const title = t("resilienceComboCooldownWaitTitle") || "Combo cooldown wait";
const title = t("resilienceComboCooldownWaitTitle");
const desc =
t("resilienceComboCooldownWaitDesc") ||
"For all combo strategies: wait out a short transient cooldown and re-dispatch instead of returning a 429 immediately. Never waits on quota_exhausted.";
@@ -735,7 +735,7 @@ function ComboCooldownWaitCard({
{editing ? (
<>
<BooleanField
label={t("resilienceEnableServerWait") || "Enabled"}
label={t("resilienceEnableServerWait")}
description={
t("resilienceComboCooldownWaitToggleDesc") ||
"All combo strategies; never waits on quota_exhausted."
@@ -744,20 +744,20 @@ function ComboCooldownWaitCard({
onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))}
/>
<NumberField
label={t("resilienceComboCooldownMaxWaitMs") || "Max wait per attempt"}
label={t("resilienceComboCooldownMaxWaitMs")}
value={draft.maxWaitMs}
min={0}
suffix="ms"
onChange={(maxWaitMs) => setDraft((prev) => ({ ...prev, maxWaitMs }))}
/>
<NumberField
label={t("resilienceMaxAttempts") || "Max attempts"}
label={t("resilienceMaxAttempts")}
value={draft.maxAttempts}
min={0}
onChange={(maxAttempts) => setDraft((prev) => ({ ...prev, maxAttempts }))}
/>
<NumberField
label={t("resilienceComboCooldownBudgetMs") || "Total wait budget"}
label={t("resilienceComboCooldownBudgetMs")}
value={draft.budgetMs}
min={0}
suffix="ms"
@@ -767,31 +767,23 @@ function ComboCooldownWaitCard({
) : (
<>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">
{t("resilienceEnableServerWait") || "Enabled"}
</div>
<div className="text-xs text-text-muted">{t("resilienceEnableServerWait")}</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.enabled ? t("statusEnabled") : t("statusDisabled")}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">
{t("resilienceComboCooldownMaxWaitMs") || "Max wait per attempt"}
</div>
<div className="text-xs text-text-muted">{t("resilienceComboCooldownMaxWaitMs")}</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{formatMs(value.maxWaitMs)}
</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">
{t("resilienceMaxAttempts") || "Max attempts"}
</div>
<div className="text-xs text-text-muted">{t("resilienceMaxAttempts")}</div>
<div className="mt-1 text-sm font-semibold text-text-main">{value.maxAttempts}</div>
</div>
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">
{t("resilienceComboCooldownBudgetMs") || "Total wait budget"}
</div>
<div className="text-xs text-text-muted">{t("resilienceComboCooldownBudgetMs")}</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{formatMs(value.budgetMs)}
</div>
@@ -820,8 +812,7 @@ function QuotaShareConcurrencyLimitCard({
setDraft(value);
}, [value]);
const title =
t("resilienceQuotaShareConcurrencyTitle") || "Quota-share per-connection concurrency";
const title = t("resilienceQuotaShareConcurrencyTitle");
const desc =
t("resilienceQuotaShareConcurrencyDesc") ||
"For quota-share combos only: when a connection sets a Max Concurrent cap, serialize concurrent requests to that subscription account so it is never flooded past its ceiling — excess requests wait in the queue instead of getting a 429. The cap comes from each connection's Max Concurrent field; this switch only enables/disables honoring it.";
@@ -853,7 +844,7 @@ function QuotaShareConcurrencyLimitCard({
<div className="grid grid-cols-1 gap-3">
{editing ? (
<BooleanField
label={t("resilienceEnableServerWait") || "Enabled"}
label={t("resilienceEnableServerWait")}
description={
t("resilienceQuotaShareConcurrencyToggleDesc") ||
"Quota-share combos only; honors each connection's Max Concurrent cap."
@@ -863,9 +854,7 @@ function QuotaShareConcurrencyLimitCard({
/>
) : (
<div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted">
{t("resilienceEnableServerWait") || "Enabled"}
</div>
<div className="text-xs text-text-muted">{t("resilienceEnableServerWait")}</div>
<div className="mt-1 text-sm font-semibold text-text-main">
{value.enabled ? t("statusEnabled") : t("statusDisabled")}
</div>

View File

@@ -180,17 +180,17 @@ const DEFAULT_SYSTEM_TRANSFORMS_CLIENT = {
const PROVIDER_TILE_DISPLAY: Record<
string,
{ name: string; description: string; icon: string; tone: string }
{ nameKey: string; descriptionKey: string; icon: string; tone: string }
> = {
[PROVIDER_CLAUDE]: {
name: "Claude (OAuth)",
description: "Native Claude provider with OAuth-issued tokens.",
nameKey: "routingClaudeProviderName",
descriptionKey: "routingClaudeProviderDescription",
icon: "anthropic",
tone: "indigo",
},
[PROVIDER_CC_BRIDGE]: {
name: "Claude-Code Bridge",
description: "Relay endpoints using API keys (anthropic-compatible-cc-*).",
nameKey: "routingCcBridgeName",
descriptionKey: "routingCcBridgeDescription",
icon: "hub",
tone: "purple",
},
@@ -335,7 +335,7 @@ function StringListEditor({
onClick={() => onChange([...items, ""])}
className="self-start"
>
{tCommon("add") || "Add entry"}
{t("routingAddEntry")}
</Button>
</div>
);
@@ -567,7 +567,11 @@ function OpEditor({
</div>
);
default:
return <p className="text-xs text-text-muted">Unknown op kind: {op?.kind}</p>;
return (
<p className="text-xs text-text-muted">
{t("routingUnknownOpKind", { kind: String(op?.kind ?? "") })}
</p>
);
}
}
@@ -625,16 +629,16 @@ function summarizeTransformOp(op: any, t: any): string {
// Client-side validator — light shape check before we PATCH; the server
// re-validates with the full zod schema in settingsSchemas.ts.
function validateProviderTransformsConfig(value: unknown): string | null {
if (!value || typeof value !== "object") return "Config must be a JSON object";
function validateProviderTransformsConfig(value: unknown, t: any): string | null {
if (!value || typeof value !== "object") return t("routingConfigMustBeObject");
const cfg = value as { enabled?: unknown; pipeline?: unknown };
if (typeof cfg.enabled !== "boolean") return "`enabled` must be true or false";
if (!Array.isArray(cfg.pipeline)) return "`pipeline` must be an array of ops";
if (cfg.pipeline.length > 50) return "Pipeline cannot exceed 50 ops";
if (typeof cfg.enabled !== "boolean") return t("routingEnabledMustBeBoolean");
if (!Array.isArray(cfg.pipeline)) return t("routingPipelineMustBeArray");
if (cfg.pipeline.length > 50) return t("routingPipelineTooLong");
for (let i = 0; i < cfg.pipeline.length; i++) {
const op = cfg.pipeline[i] as { kind?: unknown };
if (!op || typeof op !== "object" || typeof op.kind !== "string") {
return `Op #${i + 1}: missing or invalid \`kind\``;
return t("routingOpMissingKind", { index: i + 1 });
}
const validKinds = [
"drop_paragraph_if_contains",
@@ -648,7 +652,7 @@ function validateProviderTransformsConfig(value: unknown): string | null {
"obfuscate_words",
];
if (!validKinds.includes(op.kind)) {
return `Op #${i + 1}: unknown kind "${op.kind}"`;
return t("routingOpUnknownKind", { index: i + 1, kind: op.kind });
}
}
return null;
@@ -813,11 +817,11 @@ export default function RoutingTab() {
} catch (err) {
setJsonErrors((prev) => ({
...prev,
[providerId]: `Invalid JSON: ${(err as Error).message}`,
[providerId]: t("routingInvalidJson", { error: (err as Error).message }),
}));
return;
}
const validationError = validateProviderTransformsConfig(parsed);
const validationError = validateProviderTransformsConfig(parsed, t);
if (validationError) {
setJsonErrors((prev) => ({ ...prev, [providerId]: validationError }));
return;
@@ -1017,7 +1021,7 @@ export default function RoutingTab() {
</option>
{availableProvidersToAdd.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.id})
{p.id === PROVIDER_CC_BRIDGE ? t("routingCcBridgeCatalogName") : p.name} ({p.id})
</option>
))}
</Select>
@@ -1039,12 +1043,11 @@ export default function RoutingTab() {
<div className="flex flex-col gap-3">
{Object.entries(systemTransforms.providers).map(([providerId, providerCfg]) => {
const isBuiltin = BUILTIN_PROVIDERS.has(providerId);
const display = PROVIDER_TILE_DISPLAY[providerId] ?? {
name: providerId,
description: "Custom provider.",
icon: "extension",
tone: "purple",
};
const display = PROVIDER_TILE_DISPLAY[providerId];
const displayName = display ? t(display.nameKey) : providerId;
const displayDescription = display
? t(display.descriptionKey)
: t("routingCustomProviderDescription");
const draft = jsonDrafts[providerId] ?? JSON.stringify(providerCfg, null, 2);
const errorMsg = jsonErrors[providerId] ?? null;
const opCount = Array.isArray(providerCfg.pipeline) ? providerCfg.pipeline.length : 0;
@@ -1066,7 +1069,7 @@ export default function RoutingTab() {
<code className="text-xs font-mono rounded bg-surface px-1.5 py-0.5">
{providerId}
</code>
<span className="text-sm font-medium">{display.name}</span>
<span className="text-sm font-medium">{displayName}</span>
</div>
}
subtitle={
@@ -1081,7 +1084,7 @@ export default function RoutingTab() {
onChange={(checked) => toggleProviderEnabled(providerId, checked)}
disabled={loading}
ariaLabel={
tCommon("enable") + " " + display.name + " " + t("systemTransforms")
tCommon("enable") + " " + displayName + " " + t("systemTransforms")
}
/>
{!isBuiltin && (
@@ -1098,7 +1101,7 @@ export default function RoutingTab() {
</>
}
>
<p className="text-xs text-text-muted mb-3">{display.description}</p>
<p className="text-xs text-text-muted mb-3">{displayDescription}</p>
{providerSaveErrors[providerId] && (
<div
role="alert"
@@ -1211,13 +1214,13 @@ export default function RoutingTab() {
className="text-[11px] text-primary hover:underline"
>
{isJsonOpen
? "▾ " + tCommon("hide") + " JSON editor"
: "▸ Import / export JSON"}
? `${t("routingJsonEditorHide")}`
: `${t("routingJsonEditorImportExport")}`}
</button>
{isJsonOpen && (
<div className="mt-2">
<label className="text-[11px] font-medium text-text-muted block mb-1">
JSON ({tCommon("edit")} &amp; Apply, or paste to import)
{t("routingJsonEditorLabel")}
</label>
<textarea
value={draft}
@@ -1240,7 +1243,7 @@ export default function RoutingTab() {
size="sm"
icon="check"
>
Apply JSON
{t("routingApplyJson")}
</Button>
{hasDefault && (
<Button
@@ -1262,10 +1265,7 @@ export default function RoutingTab() {
})}
</div>
<p className="mt-3 text-[11px] text-text-muted">
All transform ops are idempotent on re-run. Changes take effect immediately on the next
request.
</p>
<p className="mt-3 text-[11px] text-text-muted">{t("routingTransformsFootnote")}</p>
</Card>
<Card>
@@ -1471,13 +1471,8 @@ export default function RoutingTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">
{t("echoRequestedModelTitle") || "Echo requested model name in responses"}
</h3>
<p className="text-sm text-text-muted mt-1">
{t("echoRequestedModelDesc") ||
"When enabled, the response model field echoes the alias or combo name the client requested instead of the upstream model name."}
</p>
<h3 className="text-lg font-semibold">{t("echoRequestedModelTitle")}</h3>
<p className="text-sm text-text-muted mt-1">{t("echoRequestedModelDesc")}</p>
</div>
</div>
<div className="pt-1">
@@ -1500,20 +1495,15 @@ export default function RoutingTab() {
</span>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold">
{t("webSearchRouteTitle") || "Web search routing"}
</h3>
<p className="text-sm text-text-muted mt-1">
{t("webSearchRouteDesc") ||
"When a request includes a native web_search tool, route the whole request to this model instead of the default — useful for providers that don't implement Anthropic's web_search server tool. Leave blank to disable."}
</p>
<h3 className="text-lg font-semibold">{t("webSearchRouteTitle")}</h3>
<p className="text-sm text-text-muted mt-1">{t("webSearchRouteDesc")}</p>
<div className="mt-3">
<ModelSelectField
value={String(settings.webSearchRouteModel ?? "")}
onChange={(v) => updateSetting({ webSearchRouteModel: v })}
placeholder={t("webSearchRoutePlaceholder") || "Search or select a model…"}
placeholder={t("webSearchRoutePlaceholder")}
disabled={loading}
ariaLabel={t("webSearchRouteTitle") || "Web search routing model"}
ariaLabel={t("webSearchRouteTitle")}
/>
</div>
</div>
@@ -1529,13 +1519,8 @@ export default function RoutingTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">
{t("lkgpToggleTitle") || "Last Known Good Provider (LKGP)"}
</h3>
<p className="text-sm text-text-muted mt-1">
{t("lkgpToggleDesc") ||
"When enabled, the router remembers which provider last served a successful response and tries it first on subsequent requests."}
</p>
<h3 className="text-lg font-semibold">{t("lkgpToggleTitle")}</h3>
<p className="text-sm text-text-muted mt-1">{t("lkgpToggleDesc")}</p>
</div>
</div>
<div className="pt-1">
@@ -1561,19 +1546,18 @@ export default function RoutingTab() {
if (res.ok) {
setLkgpCacheStatus({
type: "success",
message: t("lkgpCacheCleared") || "LKGP cache cleared successfully",
message: t("lkgpCacheCleared"),
});
} else {
setLkgpCacheStatus({
type: "error",
message:
data.error || t("lkgpCacheClearFailed") || "Failed to clear LKGP cache",
message: data.error || t("lkgpCacheClearFailed"),
});
}
} catch {
setLkgpCacheStatus({
type: "error",
message: t("errorOccurred") || "An error occurred",
message: t("errorOccurred"),
});
} finally {
setLkgpCacheLoading(false);
@@ -1583,7 +1567,7 @@ export default function RoutingTab() {
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
delete_sweep
</span>
{t("clearLkgpCache") || "Clear LKGP Cache"}
{t("clearLkgpCache")}
</Button>
{lkgpCacheStatus.message && (
<span
@@ -1604,13 +1588,8 @@ export default function RoutingTab() {
</span>
</div>
<div>
<h3 className="text-lg font-semibold">
{t("adaptiveVolumeRouting") || "Adaptive Volume Routing"}
</h3>
<p className="text-sm text-text-muted mt-1">
{t("adaptiveVolumeRoutingDesc") ||
"Automatically adjusts traffic volume between providers based on real-time latency and error rates."}
</p>
<h3 className="text-lg font-semibold">{t("adaptiveVolumeRouting")}</h3>
<p className="text-sm text-text-muted mt-1">{t("adaptiveVolumeRoutingDesc")}</p>
</div>
</div>
<div className="pt-1">

View File

@@ -81,13 +81,11 @@ export default function SecurityTab() {
setRequireLoginModalOpen(false);
} else {
const data = await res.json();
setRequireLoginError(
data?.error?.message || t("errorOccurred", { fallback: "An error occurred" })
);
setRequireLoginError(data?.error?.message || t("errorOccurred"));
}
} catch (err) {
console.error("Failed to update require login:", err);
setRequireLoginError(t("errorOccurred", { fallback: "An error occurred" }));
setRequireLoginError(t("errorOccurred"));
} finally {
setRequireLoginLoading(false);
}
@@ -196,9 +194,7 @@ export default function SecurityTab() {
title={t("currentPassword")}
>
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">
{t("enterCurrentPassword", { fallback: "Enter your current password to continue" })}
</p>
<p className="text-sm text-text-muted">{t("enterCurrentPassword")}</p>
<Input
label={t("currentPassword")}
type="password"
@@ -226,7 +222,7 @@ export default function SecurityTab() {
loading={requireLoginLoading}
disabled={!requireLoginPassword}
>
{t("confirm", { fallback: "Confirm" })}
{t("confirm")}
</Button>
</div>
</div>

View File

@@ -236,12 +236,12 @@ export default function SystemStorageTab() {
if (res.ok) {
setClearCacheStatus({
type: "success",
message: t("cacheCleared") || "Cache cleared successfully",
message: t("cacheCleared"),
});
} else {
setClearCacheStatus({
type: "error",
message: data?.error || t("clearCacheFailed") || "Failed to clear cache",
message: data?.error || t("clearCacheFailed"),
});
}
} catch {
@@ -266,7 +266,7 @@ export default function SystemStorageTab() {
} else {
setPurgeLogsStatus({
type: "error",
message: data?.error || t("purgeLogsFailed") || "Failed to purge logs",
message: data?.error || t("purgeLogsFailed"),
});
}
} catch {
@@ -1391,7 +1391,7 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[18px] text-blue-500" aria-hidden="true">
build
</span>
<p className="font-medium">{t("maintenance") || "Maintenance"}</p>
<p className="font-medium">{t("maintenance")}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
@@ -1403,7 +1403,7 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
delete_sweep
</span>
{t("clearCache") || "Clear Cache"}
{t("clearCache")}
</Button>
<Button
variant="outline"
@@ -1414,7 +1414,7 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
auto_delete
</span>
{t("purgeExpiredLogs") || "Purge Expired Logs"}
{t("purgeExpiredLogs")}
</Button>
<Button
variant="outline"
@@ -1469,7 +1469,7 @@ export default function SystemStorageTab() {
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
restart_alt
</span>
{t("resetUsageData") || "Reset Usage Data"}
{t("resetUsageData")}
</Button>
</div>
<div className="mt-4 border-t border-border/50 pt-3">
@@ -1543,7 +1543,7 @@ export default function SystemStorageTab() {
isOpen={resetUsageModalOpen}
onClose={() => !resetUsageLoading && setResetUsageModalOpen(false)}
onConfirm={handleResetUsageHistory}
title={t("resetUsageData") || "Reset Usage Data"}
title={t("resetUsageData")}
message={
<div className="space-y-3">
<p className="text-text-muted">
@@ -1563,7 +1563,7 @@ export default function SystemStorageTab() {
</select>
</div>
}
confirmText={resetUsageLoading ? t("resetting") || "Resetting..." : t("reset") || "Reset"}
confirmText={resetUsageLoading ? t("resetting") : t("reset")}
variant="danger"
loading={resetUsageLoading}
/>

View File

@@ -41,23 +41,22 @@ export default function DocumentationTab() {
<h3 className="font-semibold mb-2">SOCKS5</h3>
<p className="text-sm text-text-muted">
{t("proxyDocumentationSocks5DescBefore")}{" "}
<code className="bg-surface-alt px-1 rounded">ENABLE_SOCKS5_PROXY=false</code> to disable
(ON by default).
<code className="bg-surface-alt px-1 rounded">ENABLE_SOCKS5_PROXY=false</code>{" "}
{t("proxyDocumentationSocks5DescAfter")}
</p>
</section>
<section>
<h3 className="font-semibold mb-2">{t("freePoolTab")}</h3>
<p className="text-sm text-text-muted">
{t("proxyDocumentationFreePoolDesc")}
</p>
<p className="text-sm text-text-muted">{t("proxyDocumentationFreePoolDesc")}</p>
</section>
<section>
<h3 className="font-semibold mb-2">Vercel Relay</h3>
<p className="text-sm text-text-muted">
{t("proxyDocumentationVercelRelayDescBefore")} (
<code className="bg-surface-alt px-1 rounded">x-relay-auth</code>). {t("proxyDocumentationVercelRelayDescAfter")}
<code className="bg-surface-alt px-1 rounded">x-relay-auth</code>).{" "}
{t("proxyDocumentationVercelRelayDescAfter")}
</p>
</section>
</Card>

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
export interface FreeProxyRowData {
id: string;
source: string;
@@ -28,6 +30,7 @@ export default function FreeProxyRow({
onAddToPool,
adding,
}: FreeProxyRowProps) {
const t = useTranslations("settings");
const qualityColor =
proxy.qualityScore == null
? "text-text-muted"
@@ -46,7 +49,7 @@ export default function FreeProxyRow({
onChange={() => onToggleSelect(proxy.id)}
className="rounded"
disabled={proxy.inPool}
aria-label={`Select ${proxy.host}:${proxy.port}`}
aria-label={t("proxyFreePoolSelectProxy", { endpoint: `${proxy.host}:${proxy.port}` })}
/>
</td>
<td className="px-3 py-2 text-text-muted text-xs">{proxy.source}</td>
@@ -64,16 +67,16 @@ export default function FreeProxyRow({
<td className="px-3 py-2">
{proxy.inPool ? (
<span className="px-2 py-0.5 rounded text-xs bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
in pool
{t("proxyFreePoolInPool")}
</span>
) : (
<button
onClick={() => onAddToPool(proxy.id)}
disabled={adding}
aria-label={`Add ${proxy.host}:${proxy.port} to pool`}
aria-label={t("proxyFreePoolAddProxy", { endpoint: `${proxy.host}:${proxy.port}` })}
className="px-2 py-0.5 rounded text-xs bg-primary/15 text-primary border border-primary/30 hover:bg-primary/25 disabled:opacity-50"
>
{adding ? "..." : "⊕"}
{adding ? t("proxyFreePoolAdding") : "⊕"}
</button>
)}
</td>

View File

@@ -59,19 +59,22 @@ export default function SubscriptionTab() {
// Resolve a subscription `error` value into a localized message. Values are
// either a `{ code, detail? }` JSON (user-facing, i18n'd) or a plain
// diagnostic string (technical fetch/sync errors) shown verbatim.
const resolveSubError = useCallback((raw: string | null): string | null => {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { code?: string; detail?: string };
if (parsed?.code) {
const base = t(`proxySubscription.error.${parsed.code}`);
return parsed.detail ? `${base}${parsed.detail}` : base;
const resolveSubError = useCallback(
(raw: string | null): string | null => {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { code?: string; detail?: string };
if (parsed?.code) {
const base = t(`proxySubscription.error.${parsed.code}`);
return parsed.detail ? `${base}${parsed.detail}` : base;
}
} catch {
// plain diagnostic string — show as-is
}
} catch {
// plain diagnostic string — show as-is
}
return raw;
}, [t]);
return raw;
},
[t]
);
const [busyId, setBusyId] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
@@ -85,7 +88,7 @@ export default function SubscriptionTab() {
setError(null);
try {
const res = await fetch("/api/v1/management/proxy-subscriptions");
if (!res.ok) throw new Error("加载订阅列表失败");
if (!res.ok) throw new Error(t("proxySubscription.loadFailed"));
const data = await res.json();
setSubs(Array.isArray(data.items) ? data.items : []);
} catch (e) {
@@ -93,7 +96,7 @@ export default function SubscriptionTab() {
} finally {
setLoading(false);
}
}, []);
}, [t]);
const loadProviders = useCallback(async () => {
try {
@@ -143,10 +146,10 @@ export default function SubscriptionTab() {
setSaving(true);
setFormError(null);
try {
if (!form.name.trim()) throw new Error("请填写名称");
if (!form.url.trim()) throw new Error("请填写订阅链接");
if (!form.name.trim()) throw new Error(t("proxySubscription.nameRequired"));
if (!form.url.trim()) throw new Error(t("proxySubscription.urlRequired"));
if (form.mode === "rule" && form.ruleProviders.length === 0) {
throw new Error("规则模式下请至少选择一个 Provider");
throw new Error(t("proxySubscription.providerRequired"));
}
const payload = {
name: form.name.trim(),
@@ -170,7 +173,7 @@ export default function SubscriptionTab() {
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "保存失败");
throw new Error(data.error || t("proxySubscription.saveFailed"));
}
resetForm();
await load();
@@ -189,7 +192,7 @@ export default function SubscriptionTab() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !sub.enabled }),
});
if (!res.ok) throw new Error("切换开关失败");
if (!res.ok) throw new Error(t("proxySubscription.toggleFailed"));
await load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -204,7 +207,7 @@ export default function SubscriptionTab() {
const res = await fetch(`/api/v1/management/proxy-subscriptions/${sub.id}/refresh`, {
method: "POST",
});
if (!res.ok) throw new Error("刷新失败");
if (!res.ok) throw new Error(t("proxySubscription.refreshFailed"));
await load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -214,13 +217,13 @@ export default function SubscriptionTab() {
};
const remove = async (sub: SubscriptionRecord) => {
if (!window.confirm(`确定删除订阅「${sub.name}」?相关代理节点也会一并移除。`)) return;
if (!window.confirm(t("proxySubscription.deleteConfirm", { name: sub.name }))) return;
setBusyId(sub.id);
try {
const res = await fetch(`/api/v1/management/proxy-subscriptions/${sub.id}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("删除失败");
if (!res.ok) throw new Error(t("proxySubscription.deleteFailed"));
await load();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
@@ -238,13 +241,10 @@ export default function SubscriptionTab() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-text-muted">
Provider
</p>
<p className="text-sm text-text-muted">{t("proxySubscription.description")}</p>
{!showForm && (
<Button size="sm" variant="primary" icon="add" onClick={() => setShowForm(true)}>
{t("proxySubscription.add")}
</Button>
)}
</div>
@@ -259,38 +259,38 @@ export default function SubscriptionTab() {
<div className="rounded-lg border border-border bg-surface p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">
{editingId ? "编辑订阅" : "新增订阅"}
{editingId ? t("proxySubscription.edit") : t("proxySubscription.add")}
</h3>
<Button size="sm" variant="secondary" icon="close" onClick={resetForm}>
{t("proxySubscription.cancel")}
</Button>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="text-text-muted"></span>
<span className="text-text-muted">{t("proxySubscription.name")}</span>
<input
className="rounded border border-border bg-surface px-2 py-1.5 text-text outline-none focus:border-primary"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="例如我的订阅A"
placeholder={t("proxySubscription.namePlaceholder")}
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="text-text-muted"></span>
<span className="text-text-muted">{t("proxySubscription.url")}</span>
<input
className="rounded border border-border bg-surface px-2 py-1.5 text-text outline-none focus:border-primary"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
placeholder="https://.../subscribe?token=..."
placeholder={t("proxySubscription.urlPlaceholder")}
/>
</label>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div className="flex flex-col gap-1 text-sm">
<span className="text-text-muted"></span>
<span className="text-text-muted">{t("proxySubscription.mode")}</span>
<div className="flex gap-2">
{(["global", "rule"] as const).map((m) => (
<button
@@ -303,19 +303,21 @@ export default function SubscriptionTab() {
: "border-border text-text-muted hover:text-text"
}`}
>
{m === "global" ? "全局模式" : "规则模式"}
{m === "global"
? t("proxySubscription.globalMode")
: t("proxySubscription.ruleMode")}
</button>
))}
</div>
<span className="text-xs text-text-muted">
{form.mode === "global"
? "所有 Provider 流量都走该订阅的代理池。"
: "仅所选 Provider 的流量走代理,其余直连。"}
? t("proxySubscription.globalModeDescription")
: t("proxySubscription.ruleModeDescription")}
</span>
</div>
<label className="flex flex-col gap-1 text-sm">
<span className="text-text-muted"> SOCKS5/HTTP </span>
<span className="text-text-muted">{t("proxySubscription.localCoreEndpoint")}</span>
<input
className="rounded border border-border bg-surface px-2 py-1.5 text-text outline-none focus:border-primary"
value={form.localCoreEndpoint}
@@ -323,16 +325,18 @@ export default function SubscriptionTab() {
placeholder="socks5://127.0.0.1:1080"
/>
<span className="text-xs text-text-muted">
127.0.0.1 / localhostSS/VMess/Trojan/VLESS sing-box/clash
{t("proxySubscription.localCoreHint")}
</span>
</label>
</div>
{form.mode === "rule" && (
<div className="flex flex-col gap-1 text-sm">
<span className="text-text-muted"> Provider </span>
<span className="text-text-muted">{t("proxySubscription.providerRouting")}</span>
{providers.length === 0 ? (
<span className="text-xs text-text-muted"> Provider </span>
<span className="text-xs text-text-muted">
{t("proxySubscription.loadingProviders")}
</span>
) : (
<div className="flex flex-wrap gap-2">
{providers.map((p) => {
@@ -366,7 +370,7 @@ export default function SubscriptionTab() {
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="text-text-muted"></span>
<span className="text-text-muted">{t("proxySubscription.refreshInterval")}</span>
<input
type="number"
min={5}
@@ -383,7 +387,7 @@ export default function SubscriptionTab() {
checked={form.enabled}
onChange={(e) => setForm({ ...form, enabled: e.target.checked })}
/>
<span></span>
<span>{t("proxySubscription.enableAfterCreate")}</span>
</label>
</div>
@@ -391,129 +395,147 @@ export default function SubscriptionTab() {
<div className="flex justify-end gap-2">
<Button size="sm" variant="secondary" onClick={resetForm}>
{t("proxySubscription.cancel")}
</Button>
<Button size="sm" variant="primary" icon="save" onClick={save} disabled={saving}>
{saving ? "保存中…" : editingId ? "保存修改" : "创建订阅"}
{saving
? t("proxySubscription.saving")
: editingId
? t("proxySubscription.saveChanges")
: t("proxySubscription.create")}
</Button>
</div>
</div>
)}
<div className="space-y-2">
{loading && <p className="text-sm text-text-muted"></p>}
{loading && <p className="text-sm text-text-muted">{t("proxySubscription.loading")}</p>}
{!loading && subs.length === 0 && (
<p className="text-sm text-text-muted"></p>
<p className="text-sm text-text-muted">{t("proxySubscription.empty")}</p>
)}
{subs.map((sub) => {
const needsCoreNodes = (sub.lastNodes ?? []).filter(isNeedsCoreNode);
const showCoreHint = needsCoreNodes.length > 0 && !sub.localCoreEndpoint;
return (
<div
key={sub.id}
className="rounded-lg border border-border bg-surface p-3 flex flex-col gap-2"
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{sub.name}</span>
<span
className={`text-xs px-2 py-0.5 rounded border ${
statusBadge[sub.status] || statusBadge.empty
}`}
>
{sub.status === "ok" ? "正常" : sub.status === "error" ? "错误" : "空"}
</span>
<span className="text-xs px-2 py-0.5 rounded border border-border text-text-muted">
{sub.mode === "global" ? "全局" : "规则"}
</span>
<span className="text-xs px-2 py-0.5 rounded border border-border text-text-muted">
{sub.enabled ? "已启用" : "已停用"}
</span>
</div>
<p className="text-xs text-text-muted truncate mt-1" title={sub.url}>
{sub.url}
</p>
{resolveSubError(sub.error) && (
<p className="text-xs text-amber-600 mt-1 break-words">{resolveSubError(sub.error)}</p>
)}
{showCoreHint && (
<div className="mt-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 space-y-1.5">
<p className="font-medium">
{needsCoreNodes.length} SS / VMess / Trojan / VLESS
</p>
<p>
OmniRoute <code>sing-box</code> {" "}
<code>clashClash.Meta</code> SOCKS5/HTTP 127.0.0.1 / localhost
</p>
<div className="flex flex-wrap items-center gap-2">
<code className="rounded bg-surface px-2 py-1 border border-border">socks5://127.0.0.1:2080</code>
<button
type="button"
onClick={() => navigator.clipboard?.writeText("socks5://127.0.0.1:2080")}
className="px-2 py-1 rounded border border-border hover:border-primary/50"
>
</button>
<button
type="button"
onClick={() => startEdit(sub)}
className="px-2 py-1 rounded border border-border hover:border-primary/50"
>
</button>
</div>
<div
key={sub.id}
className="rounded-lg border border-border bg-surface p-3 flex flex-col gap-2"
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{sub.name}</span>
<span
className={`text-xs px-2 py-0.5 rounded border ${
statusBadge[sub.status] || statusBadge.empty
}`}
>
{sub.status === "ok"
? t("proxySubscription.statusOk")
: sub.status === "error"
? t("proxySubscription.statusError")
: t("proxySubscription.statusEmpty")}
</span>
<span className="text-xs px-2 py-0.5 rounded border border-border text-text-muted">
{sub.mode === "global"
? t("proxySubscription.global")
: t("proxySubscription.rule")}
</span>
<span className="text-xs px-2 py-0.5 rounded border border-border text-text-muted">
{sub.enabled
? t("proxySubscription.enabled")
: t("proxySubscription.disabled")}
</span>
</div>
)}
<p className="text-xs text-text-muted mt-1">
{sub.lastNodes?.length ?? 0}
{needsCoreNodes.length > 0 ? `${needsCoreNodes.length} 个需本地内核)` : ""}
{sub.lastFetchedAt ? ` · 上次同步:${sub.lastFetchedAt}` : ""}
{sub.consecutiveFailures > 0 ? ` · 连续失败 ${sub.consecutiveFailures}` : ""}
{sub.lastErrorAt ? ` · 上次错误:${sub.lastErrorAt}` : ""}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
disabled={busyId === sub.id}
onClick={() => toggleEnabled(sub)}
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
title={sub.enabled ? "停用" : "启用"}
>
{sub.enabled ? "停用" : "启用"}
</button>
<button
type="button"
disabled={busyId === sub.id}
onClick={() => refresh(sub)}
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
title="刷新节点"
>
</button>
<button
type="button"
disabled={busyId === sub.id}
onClick={() => startEdit(sub)}
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
title="编辑"
>
</button>
<button
type="button"
disabled={busyId === sub.id}
onClick={() => remove(sub)}
className="px-2 py-1 text-xs rounded border border-red-500/30 text-red-600 hover:bg-red-500/10"
title="删除"
>
</button>
<p className="text-xs text-text-muted truncate mt-1" title={sub.url}>
{sub.url}
</p>
{resolveSubError(sub.error) && (
<p className="text-xs text-amber-600 mt-1 break-words">
{resolveSubError(sub.error)}
</p>
)}
{showCoreHint && (
<div className="mt-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 space-y-1.5">
<p className="font-medium">
{t("proxySubscription.coreNodesHint", { count: needsCoreNodes.length })}
</p>
<p>{t("proxySubscription.coreEndpointHint")}</p>
<div className="flex flex-wrap items-center gap-2">
<code className="rounded bg-surface px-2 py-1 border border-border">
socks5://127.0.0.1:2080
</code>
<button
type="button"
onClick={() => navigator.clipboard?.writeText("socks5://127.0.0.1:2080")}
className="px-2 py-1 rounded border border-border hover:border-primary/50"
>
{t("proxySubscription.copyEndpoint")}
</button>
<button
type="button"
onClick={() => startEdit(sub)}
className="px-2 py-1 rounded border border-border hover:border-primary/50"
>
{t("proxySubscription.configure")}
</button>
</div>
</div>
)}
<p className="text-xs text-text-muted mt-1">
{t("proxySubscription.nodeSummary", {
count: sub.lastNodes?.length ?? 0,
coreCount: needsCoreNodes.length,
lastFetchedAt: sub.lastFetchedAt ?? "",
failures: sub.consecutiveFailures,
lastErrorAt: sub.lastErrorAt ?? "",
})}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
disabled={busyId === sub.id}
onClick={() => toggleEnabled(sub)}
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
title={
sub.enabled ? t("proxySubscription.disable") : t("proxySubscription.enable")
}
>
{sub.enabled ? t("proxySubscription.disable") : t("proxySubscription.enable")}
</button>
<button
type="button"
disabled={busyId === sub.id}
onClick={() => refresh(sub)}
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
title={t("proxySubscription.refreshNodes")}
>
{t("proxySubscription.refresh")}
</button>
<button
type="button"
disabled={busyId === sub.id}
onClick={() => startEdit(sub)}
className="px-2 py-1 text-xs rounded border border-border hover:border-primary/50"
title={t("proxySubscription.edit")}
>
{t("proxySubscription.edit")}
</button>
<button
type="button"
disabled={busyId === sub.id}
onClick={() => remove(sub)}
className="px-2 py-1 text-xs rounded border border-red-500/30 text-red-600 hover:bg-red-500/10"
title={t("proxySubscription.delete")}
>
{t("proxySubscription.delete")}
</button>
</div>
</div>
</div>
</div>
); })}
);
})}
</div>
</div>
);

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
export default function SettingsError({
error: _error,
reset,
@@ -7,6 +9,8 @@ export default function SettingsError({
error: Error & { digest?: string };
reset: () => void;
}) {
const t = useTranslations("settings");
return (
<div
className="flex flex-col items-center justify-center min-h-[400px]"
@@ -15,13 +19,13 @@ export default function SettingsError({
>
<div className="text-center space-y-4">
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
Failed to load settings
{t("errorPage.title")}
</h2>
<p className="text-text-muted max-w-md">
We could not load settings right now. Please retry in a few seconds.
</p>
<p className="text-text-muted max-w-md">{t("errorPage.description")}</p>
{_error?.digest && (
<p className="text-xs text-text-muted font-mono">Error ID: {_error.digest}</p>
<p className="text-xs text-text-muted font-mono">
{t("errorPage.errorId", { id: _error.digest })}
</p>
)}
{process.env.NODE_ENV === "development" && _error?.message && (
<p className="text-xs text-red-600 dark:text-red-400 font-mono">{_error.message}</p>
@@ -30,7 +34,7 @@ export default function SettingsError({
onClick={reset}
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-hover transition-colors focus:outline-2 focus:outline-offset-2 focus:outline-primary"
>
Try Again
{t("errorPage.retry")}
</button>
</div>
</div>

View File

@@ -1,9 +1,13 @@
import { getTranslations } from "next-intl/server";
import { TrafficInspectorPageClient } from "./TrafficInspectorPageClient";
export const metadata = {
title: "Traffic Inspector — OmniRoute",
description: "Monitor LLM calls + debug any application's HTTPS traffic",
};
export async function generateMetadata() {
const t = await getTranslations("metadata");
return {
title: t("trafficInspectorTitle"),
description: t("trafficInspectorDescription"),
};
}
export default function TrafficInspectorPage() {
return <TrafficInspectorPageClient />;

View File

@@ -24,11 +24,12 @@ export interface PipelineViewProps extends Omit<AdvancedAccordionProps, "slug">
}
/** Default demo steps shown when no real pipeline is running. */
const DEMO_STEPS: PipelineStep[] = [
const DEMO_STEP_CONTENT: Array<
Pick<PipelineStep, "id" | "format" | "content" | "status"> & { translationKey: string }
> = [
{
id: "1",
name: "Client Request",
description: "Request received in client format",
translationKey: "pipelineStepClientRequest",
format: "claude",
content:
'{\n "model": "claude-sonnet-4-20250514",\n "messages": [\n { "role": "user", "content": "Hello!" }\n ]\n}',
@@ -36,16 +37,14 @@ const DEMO_STEPS: PipelineStep[] = [
},
{
id: "2",
name: "Format Detected",
description: "Auto-detected source format",
translationKey: "pipelineStepFormatDetected",
format: "claude",
content: '{\n "detectedFormat": "claude",\n "confidence": "high"\n}',
status: "done",
},
{
id: "3",
name: "OpenAI Intermediate",
description: "Translated to OpenAI hub format",
translationKey: "pipelineStepOpenAIIntermediate",
format: "openai",
content:
'{\n "model": "claude-sonnet-4-20250514",\n "messages": [\n { "role": "user", "content": "Hello!" }\n ],\n "stream": true\n}',
@@ -53,8 +52,7 @@ const DEMO_STEPS: PipelineStep[] = [
},
{
id: "4",
name: "Provider Format",
description: "Translated to provider target format",
translationKey: "pipelineStepProviderFormat",
format: "gemini",
content:
'{\n "model": "gemini-2.5-flash",\n "contents": [\n { "role": "user", "parts": [{ "text": "Hello!" }] }\n ]\n}',
@@ -62,8 +60,7 @@ const DEMO_STEPS: PipelineStep[] = [
},
{
id: "5",
name: "Provider Response",
description: "Streaming response from provider",
translationKey: "pipelineStepProviderResponse",
format: "openai",
content:
'data: {"choices":[{"delta":{"content":"Hello! How can I help you today?"}}]}\ndata: [DONE]',
@@ -151,7 +148,13 @@ export default function PipelineView({
[onOpenChange]
);
const steps = pipelineSteps ?? DEMO_STEPS;
const steps =
pipelineSteps ??
DEMO_STEP_CONTENT.map((step) => ({
...step,
name: t(step.translationKey as Parameters<typeof t>[0]),
description: t(`${step.translationKey}Desc` as Parameters<typeof t>[0]),
}));
const tr = (key: string, fallback: string): string => {
try {

View File

@@ -1,19 +1,25 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function BadRequestPage() {
const t = useTranslations("publicSystem");
return (
<ErrorPageScaffold
code="400"
icon="rule"
title="Bad Request"
description="The request payload is invalid or incomplete."
title={t("statusPages.400.title")}
description={t("statusPages.400.description")}
suggestions={[
"Review required fields and payload format before retrying.",
"If you are using the API, validate the JSON schema locally.",
"If this keeps happening, open the request in Translator Playground to inspect the payload.",
t("statusPages.400.suggestion1"),
t("statusPages.400.suggestion2"),
t("statusPages.400.suggestion3"),
]}
primaryAction={{ href: "/docs", label: "Open Documentation" }}
secondaryAction={{ href: "/dashboard/translator", label: "Open Translator" }}
primaryAction={{ href: "/docs", label: t("statusPages.400.primaryAction") }}
secondaryAction={{
href: "/dashboard/translator",
label: t("statusPages.400.secondaryAction"),
}}
/>
);
}

View File

@@ -1,19 +1,25 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function UnauthorizedPage() {
const t = useTranslations("publicSystem");
return (
<ErrorPageScaffold
code="401"
icon="lock"
title="Unauthorized"
description="Authentication is required to access this resource."
title={t("statusPages.401.title")}
description={t("statusPages.401.description")}
suggestions={[
"Sign in again and retry the operation.",
"For API calls, confirm the Bearer token is present and valid.",
"If the token was recently rotated, update your client credentials.",
t("statusPages.401.suggestion1"),
t("statusPages.401.suggestion2"),
t("statusPages.401.suggestion3"),
]}
primaryAction={{ href: "/login", label: "Go to Login" }}
secondaryAction={{ href: "/dashboard/api-manager", label: "Manage API Keys" }}
primaryAction={{ href: "/login", label: t("statusPages.401.primaryAction") }}
secondaryAction={{
href: "/dashboard/api-manager",
label: t("statusPages.401.secondaryAction"),
}}
/>
);
}

View File

@@ -1,21 +1,24 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function ForbiddenStatusPage() {
const t = useTranslations("publicSystem");
return (
<ErrorPageScaffold
code="403"
icon="gpp_bad"
title="Forbidden"
description="Your request was understood, but access is denied by policy."
title={t("statusPages.403.title")}
description={t("statusPages.403.description")}
suggestions={[
"Check IP allowlist/blocklist rules in settings.",
"Verify model and budget policies assigned to your API key.",
"Ask an administrator to grant the required permission scope.",
t("statusPages.403.suggestion1"),
t("statusPages.403.suggestion2"),
t("statusPages.403.suggestion3"),
]}
primaryAction={{ href: "/forbidden", label: "Open Access Help" }}
primaryAction={{ href: "/forbidden", label: t("statusPages.403.primaryAction") }}
secondaryAction={{
href: "/dashboard/settings?tab=security",
label: "Open Security Settings",
label: t("statusPages.403.secondaryAction"),
}}
/>
);

View File

@@ -1,19 +1,22 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function RequestTimeoutPage() {
const t = useTranslations("publicSystem");
return (
<ErrorPageScaffold
code="408"
icon="timer_off"
title="Request Timeout"
description="The server did not receive a complete request in time."
title={t("statusPages.408.title")}
description={t("statusPages.408.description")}
suggestions={[
"Retry the request with a smaller payload.",
"Check your network stability and VPN/proxy latency.",
"For long operations, enable streaming or split the request.",
t("statusPages.408.suggestion1"),
t("statusPages.408.suggestion2"),
t("statusPages.408.suggestion3"),
]}
primaryAction={{ href: "/dashboard/endpoint", label: "Open Endpoint Guide" }}
secondaryAction={{ href: "/status", label: "Check Network Status" }}
primaryAction={{ href: "/dashboard/endpoint", label: t("statusPages.408.primaryAction") }}
secondaryAction={{ href: "/status", label: t("statusPages.408.secondaryAction") }}
/>
);
}

View File

@@ -1,22 +1,25 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function TooManyRequestsPage() {
const t = useTranslations("publicSystem");
return (
<ErrorPageScaffold
code="429"
icon="hourglass_top"
title="Too Many Requests"
description="Rate limits were exceeded for this client, key, or provider."
title={t("statusPages.429.title")}
description={t("statusPages.429.description")}
suggestions={[
"Wait for cooldown and retry after the suggested interval.",
"Switch to a combo with fallback providers.",
"Tune provider resilience/rate-limit profiles in settings.",
t("statusPages.429.suggestion1"),
t("statusPages.429.suggestion2"),
t("statusPages.429.suggestion3"),
]}
primaryAction={{
href: "/dashboard/settings?tab=resilience",
label: "Open Resilience Settings",
label: t("statusPages.429.primaryAction"),
}}
secondaryAction={{ href: "/dashboard/combos", label: "Open Combos" }}
secondaryAction={{ href: "/dashboard/combos", label: t("statusPages.429.secondaryAction") }}
/>
);
}

View File

@@ -1,19 +1,22 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function InternalServerErrorPage() {
const t = useTranslations("publicSystem");
return (
<ErrorPageScaffold
code="500"
icon="warning"
title="Internal Server Error"
description="An unexpected server-side error occurred while processing your request."
title={t("statusPages.500.title")}
description={t("statusPages.500.description")}
suggestions={[
"Retry once in a few seconds.",
"Check health telemetry and server logs for correlated request IDs.",
"If persistent, report the issue with timestamp and request context.",
t("statusPages.500.suggestion1"),
t("statusPages.500.suggestion2"),
t("statusPages.500.suggestion3"),
]}
primaryAction={{ href: "/dashboard/health", label: "Open Health Dashboard" }}
secondaryAction={{ href: "/dashboard/logs", label: "Open Logs" }}
primaryAction={{ href: "/dashboard/health", label: t("statusPages.500.primaryAction") }}
secondaryAction={{ href: "/dashboard/logs", label: t("statusPages.500.secondaryAction") }}
/>
);
}

View File

@@ -1,19 +1,25 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function BadGatewayPage() {
const t = useTranslations("publicSystem");
return (
<ErrorPageScaffold
code="502"
icon="hub"
title="Bad Gateway"
description="Upstream provider or gateway integration returned an invalid response."
title={t("statusPages.502.title")}
description={t("statusPages.502.description")}
suggestions={[
"Retry with another provider or active combo route.",
"Check provider credentials and model availability.",
"Inspect translator output if format conversion is involved.",
t("statusPages.502.suggestion1"),
t("statusPages.502.suggestion2"),
t("statusPages.502.suggestion3"),
]}
primaryAction={{ href: "/dashboard/providers", label: "Open Providers" }}
secondaryAction={{ href: "/dashboard/translator", label: "Open Translator" }}
primaryAction={{ href: "/dashboard/providers", label: t("statusPages.502.primaryAction") }}
secondaryAction={{
href: "/dashboard/translator",
label: t("statusPages.502.secondaryAction"),
}}
/>
);
}

View File

@@ -1,19 +1,22 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function ServiceUnavailablePage() {
const t = useTranslations("publicSystem");
return (
<ErrorPageScaffold
code="503"
icon="build_circle"
title="Service Unavailable"
description="The service is temporarily unavailable due to maintenance or degraded dependencies."
title={t("statusPages.503.title")}
description={t("statusPages.503.description")}
suggestions={[
"Wait a moment and retry.",
"Check maintenance notices and system status.",
"Use fallback providers if your workflow is latency-sensitive.",
t("statusPages.503.suggestion1"),
t("statusPages.503.suggestion2"),
t("statusPages.503.suggestion3"),
]}
primaryAction={{ href: "/maintenance", label: "Maintenance Details" }}
secondaryAction={{ href: "/status", label: "System Status" }}
primaryAction={{ href: "/maintenance", label: t("statusPages.503.primaryAction") }}
secondaryAction={{ href: "/status", label: t("statusPages.503.secondaryAction") }}
/>
);
}

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { getTranslations } from "next-intl/server";
import { createProviderConnection } from "@/models";
import { parseTraeCallbackQuery } from "./parseCallback";
@@ -29,7 +30,7 @@ import { parseTraeCallbackQuery } from "./parseCallback";
* authorize URL; Trae echoes it back as `loginTraceID`. The modal verifies
* the echoed state before trusting the postMessage.
*/
function htmlClose(message: Record<string, unknown>): NextResponse {
function htmlClose(message: Record<string, unknown>, t: (key: string) => string): NextResponse {
// Embedding values: only emit the small/sanitized status payload — never the
// raw token. We post to the loopback origin pair (localhost + 127.0.0.1) on
// this same port rather than "*": Trae forces the callback onto 127.0.0.1,
@@ -40,10 +41,12 @@ function htmlClose(message: Record<string, unknown>): NextResponse {
type: "trae-oauth-callback",
...message,
}).replace(/</g, "\\u003c");
const title = message.success ? t("traeAuthorizationSuccess") : t("traeAuthorizationFailed");
const body = message.success ? t("closeAuthorizationWindow") : t("returnToDashboard");
return new NextResponse(
`<!doctype html><html><body style="font:16px sans-serif;padding:40px">
<h2 style="margin:0 0 8px">Trae authorization ${message.success ? "✓" : "failed"}</h2>
<p>${message.success ? "You can close this window." : "Return to the dashboard."}</p>
<h2 style="margin:0 0 8px">${title}</h2>
<p>${body}</p>
<script>
(function () {
try {
@@ -64,21 +67,25 @@ function htmlClose(message: Record<string, unknown>): NextResponse {
}
export async function GET(request: Request) {
const t = await getTranslations("auth");
const url = new URL(request.url);
const q = url.searchParams;
const parsed = parseTraeCallbackQuery(q);
if (!parsed.ok) {
return htmlClose({ success: false, error: parsed.error });
return htmlClose({ success: false, error: parsed.error }, t);
}
try {
const connection: any = await createProviderConnection(parsed.record);
return htmlClose({
success: true,
connectionId: connection.id,
loginTraceId: q.get("loginTraceID") || null,
});
return htmlClose(
{
success: true,
connectionId: connection.id,
loginTraceId: q.get("loginTraceID") || null,
},
t
);
} catch (err: any) {
console.error("[trae callback] error:", err);
return htmlClose({ success: false, error: "Internal error during callback" });
return htmlClose({ success: false, error: "Internal error during callback" }, t);
}
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import Button from "@/shared/components/Button";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import {
@@ -17,6 +18,8 @@ type Status = "validating" | "ready" | "starting" | "awaiting" | "saving" | "suc
* tokens back to the ticket-gated completion endpoint for persistence.
*/
export default function CodexConnectClient({ token }: { token: string }) {
const t = useTranslations("auth");
const tc = useTranslations("common");
const [status, setStatus] = useState<Status>("validating");
const [error, setError] = useState<string | null>(null);
const [userCode, setUserCode] = useState<CodexUserCode | null>(null);
@@ -34,12 +37,12 @@ export default function CodexConnectClient({ token }: { token: string }) {
setStatus("ready");
} else {
const data = await res.json().catch(() => ({}));
setError(data?.error || "This link is invalid or expired.");
setError(data?.error || t("codexLinkInvalidOrExpired"));
setStatus("error");
}
} catch {
if (!cancelled) {
setError("Could not reach the server to validate this link.");
setError(t("codexValidationServerError"));
setStatus("error");
}
}
@@ -47,7 +50,7 @@ export default function CodexConnectClient({ token }: { token: string }) {
return () => {
cancelled = true;
};
}, [token]);
}, [token, t]);
// Abort any in-flight device flow if the visitor leaves.
useEffect(() => () => abortRef.current?.abort(), []);
@@ -79,26 +82,26 @@ export default function CodexConnectClient({ token }: { token: string }) {
if (res.ok && data?.success) {
setStatus("success");
} else {
setError(data?.error || "Could not save the connection. The link may have expired.");
setError(data?.error || t("codexSaveConnectionError"));
setStatus("error");
}
} catch (err) {
if (err instanceof CodexDeviceFlowError) {
setError(
err.code === "device_disabled"
? "Device code login is disabled for this OpenAI account. Enable it in ChatGPT security settings (or ask your workspace admin)."
? t("codexDeviceLoginDisabled")
: err.code === "timeout"
? "Authorization timed out. Click Start again to retry."
? t("codexAuthorizationTimedOut")
: err.code === "aborted"
? "Authentication was cancelled."
? t("authenticationCancelled")
: err.message
);
} else {
setError("Unexpected error during authentication. Please try again.");
setError(t("unexpectedAuthenticationError"));
}
setStatus("error");
}
}, [token]);
}, [token, t]);
return (
<div className="min-h-screen flex items-center justify-center bg-bg-base px-4 py-10">
@@ -107,40 +110,33 @@ export default function CodexConnectClient({ token }: { token: string }) {
<div className="mb-3 inline-flex h-14 w-14 items-center justify-center rounded-full bg-primary/10 text-primary">
<span className="material-symbols-outlined text-[28px]">key</span>
</div>
<h1 className="text-lg font-semibold text-text-main">Connect OpenAI Codex</h1>
<p className="mt-1 text-sm text-text-muted">
Authorize a ChatGPT account to finish setting up this connection.
</p>
<h1 className="text-lg font-semibold text-text-main">{t("connectOpenAiCodexTitle")}</h1>
<p className="mt-1 text-sm text-text-muted">{t("codexConnectDescription")}</p>
</div>
{status === "validating" && (
<p className="text-center text-sm text-text-muted">Validating link</p>
<p className="text-center text-sm text-text-muted">{t("validatingCodexLink")}</p>
)}
{status === "ready" && (
<div className="text-center">
<p className="mb-4 text-sm text-text-muted">
Click below to generate a one-time code, then sign in to OpenAI.
</p>
<p className="mb-4 text-sm text-text-muted">{t("codexGenerateCodeDescription")}</p>
<Button onClick={start} icon="login" className="w-full">
Start
{t("startCodexFlow")}
</Button>
</div>
)}
{status === "starting" && (
<p className="text-center text-sm text-text-muted">Requesting code from OpenAI</p>
<p className="text-center text-sm text-text-muted">{t("requestingOpenAiCode")}</p>
)}
{status === "awaiting" && userCode && (
<div className="space-y-4">
<p className="text-sm text-text-muted">
1. Open the OpenAI verification page and 2. enter this code. This page updates
automatically once you authorize.
</p>
<p className="text-sm text-text-muted">{t("codexVerificationInstructions")}</p>
<div className="rounded-lg border border-border bg-bg-base p-3">
<p className="mb-1 text-xs text-text-muted">Your code</p>
<p className="mb-1 text-xs text-text-muted">{t("yourCode")}</p>
<div className="flex items-center justify-between gap-2">
<code className="text-lg font-semibold tracking-widest text-text-main">
{userCode.userCode}
@@ -151,7 +147,7 @@ export default function CodexConnectClient({ token }: { token: string }) {
icon="content_copy"
onClick={() => copy(userCode.userCode, "code")}
>
{copied === "code" ? "Copied" : "Copy"}
{copied === "code" ? tc("copied") : tc("copy")}
</Button>
</div>
</div>
@@ -162,23 +158,23 @@ export default function CodexConnectClient({ token }: { token: string }) {
icon="open_in_new"
onClick={() => window.open(userCode.verificationUri, "_blank", "noopener")}
>
Open verification page
{t("openVerificationPage")}
</Button>
<Button
variant="secondary"
icon="link"
onClick={() => copy(userCode.verificationUri, "url")}
>
{copied === "url" ? "Copied" : "Copy URL"}
{copied === "url" ? tc("copied") : t("copyUrlShort")}
</Button>
</div>
<p className="text-center text-xs text-text-muted">Waiting for authorization</p>
<p className="text-center text-xs text-text-muted">{t("waitingForAuthorization")}</p>
</div>
)}
{status === "saving" && (
<p className="text-center text-sm text-text-muted">Saving connection</p>
<p className="text-center text-sm text-text-muted">{t("savingConnection")}</p>
)}
{status === "success" && (
@@ -186,10 +182,8 @@ export default function CodexConnectClient({ token }: { token: string }) {
<div className="mb-3 inline-flex h-12 w-12 items-center justify-center rounded-full bg-green-500/10 text-green-500">
<span className="material-symbols-outlined text-[26px]">check_circle</span>
</div>
<p className="font-medium text-text-main">Connected!</p>
<p className="mt-1 text-sm text-text-muted">
The Codex account was registered. You can close this tab.
</p>
<p className="font-medium text-text-main">{t("codexConnected")}</p>
<p className="mt-1 text-sm text-text-muted">{t("codexConnectionRegistered")}</p>
</div>
)}
@@ -200,7 +194,7 @@ export default function CodexConnectClient({ token }: { token: string }) {
</div>
<p className="mb-4 text-sm text-text-muted">{error}</p>
<Button variant="secondary" icon="refresh" onClick={start} className="w-full">
Try again
{t("tryAgain")}
</Button>
</div>
)}

View File

@@ -10,6 +10,7 @@ import path from "node:path";
import { marked } from "marked";
import { sanitizeDocsHtml } from "@/lib/docsSanitizer";
import { resolveSafeI18nSectionDir } from "@/lib/docsI18nPath";
import { getTranslations } from "next-intl/server";
// ── Locale detection ────────────────────────────────────────────────────────
@@ -111,9 +112,10 @@ export async function generateMetadata(props: {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) return {};
const t = await getTranslations("docs");
return {
title: `${page.data.title} — OmniRoute Docs`,
description: page.data.description ?? `OmniRoute documentation: ${page.data.title}`,
title: t("pageMetadataTitle", { title: page.data.title }),
description: page.data.description ?? t("pageMetadataDescription", { title: page.data.title }),
};
}

View File

@@ -1,19 +1,22 @@
import { Metadata } from "next";
import { ApiExplorerClient } from "../components/ApiExplorerClient";
import { getTranslations } from "next-intl/server";
import { useTranslations } from "next-intl";
export const metadata: Metadata = {
title: "API Explorer — OmniRoute Docs",
description: "Interactive API explorer — try OmniRoute endpoints live with real-time responses",
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("docs");
return {
title: t("apiExplorerMetadataTitle"),
description: t("apiExplorerMetadataDescription"),
};
}
export default function ApiExplorerPage() {
const t = useTranslations("docs");
return (
<div>
<h1 className="text-3xl font-bold text-text-main mb-2">API Explorer</h1>
<p className="text-text-muted mb-8">
Try OmniRoute endpoints live. Select an endpoint, configure your request, and see the
response in real time.
</p>
<h1 className="text-3xl font-bold text-text-main mb-2">{t("apiExplorerTitle")}</h1>
<p className="text-text-muted mb-8">{t("apiExplorerDescription")}</p>
<ApiExplorerClient />
</div>
);

View File

@@ -1,6 +1,7 @@
"use client";
import React, { useState, useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import {
OPENAPI_ENDPOINTS,
OPENAPI_TAGS,
@@ -88,6 +89,8 @@ const EXAMPLE_BODIES: Record<string, string> = {
};
export function ApiExplorerClient() {
const t = useTranslations("docs");
const te = useTranslations("endpoint");
const [selected, setSelected] = useState<OpenApiEndpoint | null>(null);
const [baseUrl, setBaseUrl] = useState("http://localhost:20128");
const [apiKey, setApiKey] = useState("");
@@ -130,13 +133,17 @@ export function ApiExplorerClient() {
const contentType = res.headers.get("content-type") || "";
if (contentType.includes("text/event-stream")) {
setResponse("SSE stream started — check the terminal/devtools for real-time output.");
setResponse(t("apiExplorerSseStarted"));
} else {
const data = await res.json();
setResponse(JSON.stringify(data, null, 2));
}
} catch (err) {
setResponse(`Error: ${err instanceof Error ? err.message : "Request failed"}`);
setResponse(
t("apiExplorerError", {
message: err instanceof Error ? err.message : te("requestFailed"),
})
);
} finally {
setLoading(false);
}
@@ -157,7 +164,7 @@ export function ApiExplorerClient() {
className={`px-2.5 py-1 text-xs rounded-full border transition-colors
${!filterTag ? "bg-primary/10 text-primary border-primary/20" : "border-border text-text-muted hover:text-text-main"}`}
>
All
{te("all")}
</button>
{OPENAPI_TAGS.map((tag) => (
<button
@@ -211,7 +218,7 @@ export function ApiExplorerClient() {
<span className="font-mono text-sm text-text-main">{selected.path}</span>
{selected.requiresAuth && (
<span className="px-1.5 py-0.5 text-[10px] font-mono rounded border border-amber-500/30 bg-amber-500/10 text-amber-600">
auth
{t("apiExplorerAuth")}
</span>
)}
</div>
@@ -222,7 +229,7 @@ export function ApiExplorerClient() {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="text-xs text-text-muted block mb-1">Base URL</label>
<label className="text-xs text-text-muted block mb-1">{te("baseUrl")}</label>
<input
type="text"
value={baseUrl}
@@ -231,7 +238,7 @@ export function ApiExplorerClient() {
/>
</div>
<div>
<label className="text-xs text-text-muted block mb-1">API Key</label>
<label className="text-xs text-text-muted block mb-1">{te("apiKey")}</label>
<input
type="password"
value={apiKey}
@@ -244,7 +251,7 @@ export function ApiExplorerClient() {
{selected.method !== "GET" && selected.hasRequestBody && (
<div>
<label className="text-xs text-text-muted block mb-1">Request Body</label>
<label className="text-xs text-text-muted block mb-1">{te("requestBody")}</label>
<textarea
value={requestBody}
onChange={(e) => setRequestBody(e.target.value)}
@@ -259,12 +266,14 @@ export function ApiExplorerClient() {
disabled={loading}
className="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
{loading ? "Sending..." : "Send Request"}
{loading ? te("sending") : te("sendRequest")}
</button>
{response !== null && (
<div>
<label className="text-xs text-text-muted block mb-1">Response</label>
<label className="text-xs text-text-muted block mb-1">
{t("apiExplorerResponseLabel")}
</label>
<pre className="bg-bg-subtle p-4 rounded-lg overflow-x-auto text-xs font-mono text-text-main max-h-80">
{response}
</pre>
@@ -274,10 +283,8 @@ export function ApiExplorerClient() {
) : (
<div className="text-center py-16 text-text-muted">
<span className="material-symbols-outlined text-4xl mb-2 block">api</span>
<p className="text-lg font-medium">Select an endpoint to explore</p>
<p className="text-sm mt-1">
Choose an API from the sidebar to see details and try it live
</p>
<p className="text-lg font-medium">{t("apiExplorerSelectEndpoint")}</p>
<p className="text-sm mt-1">{t("apiExplorerChooseApi")}</p>
</div>
)}
</div>

View File

@@ -1,8 +1,11 @@
"use client";
import React, { useState } from "react";
import { useTranslations } from "next-intl";
export function FeedbackWidget({ slug }: { slug: string }) {
const t = useTranslations("docs");
const tc = useTranslations("common");
const [feedback, setFeedback] = useState<"yes" | "no" | null>(null);
const [submitted, setSubmitted] = useState(false);
@@ -22,28 +25,28 @@ export function FeedbackWidget({ slug }: { slug: string }) {
<span className="material-symbols-outlined text-primary text-2xl block mb-1">
check_circle
</span>
<p className="text-sm text-text-main">Thanks for your feedback!</p>
<p className="text-sm text-text-main">{t("feedbackThanks")}</p>
</div>
);
}
return (
<div className="mt-8 p-4 bg-bg-subtle border border-border rounded-lg">
<p className="text-sm text-text-main mb-3">Was this page helpful?</p>
<p className="text-sm text-text-main mb-3">{t("feedbackQuestion")}</p>
<div className="flex gap-3">
<button
onClick={() => handleFeedback("yes")}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-lg hover:border-primary hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-sm">thumb_up</span>
Yes
{tc("yes")}
</button>
<button
onClick={() => handleFeedback("no")}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-border rounded-lg hover:border-red-400 hover:text-red-400 transition-colors"
>
<span className="material-symbols-outlined text-sm">thumb_down</span>
No
{tc("no")}
</button>
</div>
</div>

View File

@@ -5,45 +5,49 @@ import type { ReactNode } from "react";
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
import { Suspense } from "react";
import LanguageSelector from "@/shared/components/LanguageSelector";
import { getTranslations } from "next-intl/server";
export const metadata = {
title: {
template: "%s — OmniRoute Docs",
default: "OmniRoute Documentation",
},
description:
"Comprehensive documentation for OmniRoute AI gateway — setup, API, compression, deployment, and more.",
robots: {
index: true,
follow: true,
},
};
export async function generateMetadata() {
const t = await getTranslations("docs");
return {
title: {
template: t("metadataTitleTemplate"),
default: t("metadataDefaultTitle"),
},
description: t("metadataDescription"),
robots: {
index: true,
follow: true,
},
};
}
const docsLayoutOptions: BaseLayoutProps = {
nav: {
title: "OmniRoute Docs",
url: "/docs",
children: (
<Suspense fallback={<div className="w-24 h-8" />}>
<LanguageSelector />
</Suspense>
),
},
links: [
{
text: "Docs Home",
export default async function Layout({ children }: { children: ReactNode }) {
const t = await getTranslations("docs");
const docsLayoutOptions: BaseLayoutProps = {
nav: {
title: t("layoutNavTitle"),
url: "/docs",
children: (
<Suspense fallback={<div className="w-24 h-8" />}>
<LanguageSelector />
</Suspense>
),
},
{
text: "\u2190 Back to Dashboard",
url: "/dashboard",
secondary: true,
},
],
githubUrl: "https://github.com/diegosouzapw/OmniRoute",
};
links: [
{
text: t("layoutHomeLink"),
url: "/docs",
},
{
text: t("layoutDashboardLink"),
url: "/dashboard",
secondary: true,
},
],
githubUrl: "https://github.com/diegosouzapw/OmniRoute",
};
export default function Layout({ children }: { children: ReactNode }) {
return (
<RootProvider
theme={{

View File

@@ -1,79 +1,90 @@
import Link from "next/link";
import { Metadata } from "next";
import { source } from "@/lib/source";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: "OmniRoute Documentation",
description:
"Everything you need to route, compress, and scale your AI — setup guides, API reference, compression, deployment, and more.",
openGraph: {
title: "OmniRoute Documentation",
description:
"Comprehensive docs for OmniRoute AI gateway — setup, API, compression, deployment, and more.",
type: "website",
url: "https://omniroute.online/docs",
},
twitter: {
card: "summary_large_image",
title: "OmniRoute Documentation",
description: "Comprehensive docs for OmniRoute AI gateway",
},
};
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("docs");
return {
title: t("homeTitle"),
description: t("homeMetadataDescription"),
openGraph: {
title: t("homeTitle"),
description: t("homeMetadataDescription"),
type: "website",
url: "https://omniroute.online/docs",
},
twitter: {
card: "summary_large_image",
title: t("homeTitle"),
description: t("homeTwitterDescription"),
},
};
}
const featuredLinks = [
{
href: "/docs/getting-started/quick-start",
title: "Quick Start",
titleKey: "featuredQuickStartTitle",
icon: "rocket_launch",
desc: "Get OmniRoute running in 3 minutes",
descriptionKey: "featuredQuickStartDescription",
},
{
href: "/docs/getting-started/auto-combo-guide",
title: "Auto-Combo Guide",
titleKey: "featuredAutoComboTitle",
icon: "auto_awesome",
desc: "Let OmniRoute pick the best AI for you",
descriptionKey: "featuredAutoComboDescription",
},
{
href: "/docs/getting-started/providers-guide",
title: "Providers Guide",
titleKey: "featuredProvidersTitle",
icon: "link",
desc: "Connect AI providers in minutes",
descriptionKey: "featuredProvidersDescription",
},
];
const sections = [
{
title: "For Non-Tech Users",
subtitle: "Get started quickly — no technical background needed",
titleKey: "nonTechUsersTitle",
subtitleKey: "nonTechUsersSubtitle",
icon: "rocket_launch",
color: "green",
folders: ["getting-started", "guides"],
},
{
title: "For Tech Users",
subtitle: "Deep dive into architecture, APIs, and internals",
titleKey: "techUsersTitle",
subtitleKey: "techUsersSubtitle",
icon: "code",
color: "blue",
folders: ["architecture", "reference", "frameworks", "routing", "security", "compression", "ops"],
folders: [
"architecture",
"reference",
"frameworks",
"routing",
"security",
"compression",
"ops",
],
},
];
export default function DocsHomePage() {
export default async function DocsHomePage() {
const t = await getTranslations("docs");
const pages = source.getPages();
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<div className="text-center mb-16 mt-8">
<h1 className="text-4xl font-bold text-fd-foreground mb-5">OmniRoute Documentation</h1>
<p className="text-lg text-fd-muted-foreground mb-6">
Everything you need to route, compress, and scale your AI
</p>
<h1 className="text-4xl font-bold text-fd-foreground mb-5">{t("homeTitle")}</h1>
<p className="text-lg text-fd-muted-foreground mb-6">{t("homeDescription")}</p>
<p className="text-sm text-fd-muted-foreground">
Press{" "}
<kbd className="px-1.5 py-0.5 bg-fd-muted border border-fd-border rounded font-mono text-xs">
Ctrl K
</kbd>{" "}
to search the docs
{t.rich("homeSearchHint", {
kbd: (chunks) => (
<kbd className="px-1.5 py-0.5 bg-fd-muted border border-fd-border rounded font-mono text-xs">
{chunks}
</kbd>
),
})}
</p>
</div>
@@ -89,9 +100,9 @@ export default function DocsHomePage() {
{link.icon}
</span>
<span className="font-semibold text-fd-foreground group-hover:text-fd-primary transition-colors">
{link.title}
{t(link.titleKey)}
</span>
<span className="text-sm text-fd-muted-foreground mt-2">{link.desc}</span>
<span className="text-sm text-fd-muted-foreground mt-2">{t(link.descriptionKey)}</span>
</Link>
))}
</div>
@@ -103,7 +114,7 @@ export default function DocsHomePage() {
);
return (
<div
key={section.title}
key={section.titleKey}
className="border border-fd-border rounded-xl p-6 hover:border-fd-primary/30 transition-colors bg-fd-card/50"
>
<div className="flex items-center gap-3 mb-4">
@@ -111,8 +122,10 @@ export default function DocsHomePage() {
{section.icon}
</span>
<div>
<h2 className="text-base font-semibold text-fd-foreground">{section.title}</h2>
<p className="text-sm text-fd-muted-foreground">{section.subtitle}</p>
<h2 className="text-base font-semibold text-fd-foreground">
{t(section.titleKey)}
</h2>
<p className="text-sm text-fd-muted-foreground">{t(section.subtitleKey)}</p>
</div>
</div>
<ul className="space-y-2.5">

View File

@@ -1,5 +1,7 @@
"use client";
import { useTranslations } from "next-intl";
/**
* Server Error Page — P-1
*
@@ -13,6 +15,9 @@ interface ErrorProps {
}
export default function Error({ error, reset }: ErrorProps) {
const t = useTranslations("publicSystem");
const tc = useTranslations("common");
return (
<div
className="flex flex-col items-center justify-center min-h-[60vh] p-6 text-center"
@@ -23,21 +28,20 @@ export default function Error({ error, reset }: ErrorProps) {
🔧
</div>
<h1 className="text-[28px] font-bold mb-2 text-[var(--color-text-main)]">
Internal Server Error
{t("error.title")}
</h1>
<p className="text-[15px] text-[var(--color-text-muted)] max-w-[400px] leading-relaxed mb-2">
Something went wrong while processing your request. Our team has been notified and is
working on a fix.
{t("error.description")}
</p>
{error?.digest && (
<p className="text-xs text-[var(--color-text-muted)] mb-6 font-mono">
Error ID: {error.digest}
{t("error.errorId", { id: error.digest })}
</p>
)}
{process.env.NODE_ENV === "development" && error?.message && (
<pre
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-xs max-w-[600px] overflow-auto text-left mb-6"
aria-label="Error details"
aria-label={t("error.detailsAriaLabel")}
>
{error.message}
</pre>
@@ -45,24 +49,24 @@ export default function Error({ error, reset }: ErrorProps) {
<div className="flex gap-3">
<button
onClick={reset}
aria-label="Retry loading the page"
aria-label={t("error.retryAriaLabel")}
className="px-6 py-2.5 rounded-lg text-white text-sm font-semibold cursor-pointer transition-all duration-200 motion-reduce:transition-none bg-[var(--color-accent)] hover:bg-[var(--color-accent-hover)] focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
>
Try Again
{t("error.tryAgain")}
</button>
<a
href="/dashboard"
className="px-6 py-2.5 rounded-lg text-[var(--color-text-main)] text-sm font-semibold cursor-pointer transition-all duration-200 motion-reduce:transition-none border border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] no-underline focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
aria-label="Return to dashboard"
aria-label={t("error.dashboardAriaLabel")}
>
Go to Dashboard
{tc("goToDashboard")}
</a>
<a
href="/status"
className="px-6 py-2.5 rounded-lg text-[var(--color-text-main)] text-sm font-semibold cursor-pointer transition-all duration-200 motion-reduce:transition-none border border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] no-underline focus:outline-2 focus:outline-offset-2 focus:outline-[var(--color-accent)]"
aria-label="Open system status"
aria-label={t("error.statusAriaLabel")}
>
System Status
{t("error.systemStatus")}
</a>
</div>
</div>

View File

@@ -48,14 +48,12 @@ export default function ForgotPasswordPage() {
</span>
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold mb-1">Reset via App Data</h2>
<p className="text-sm text-text-muted mb-3">
Delete the settings file from the app data directory to reset your password:
</p>
<h2 className="text-lg font-semibold mb-1">{t("resetViaAppDataTitle")}</h2>
<p className="text-sm text-text-muted mb-3">{t("resetViaAppDataDescription")}</p>
<ol className="text-sm text-text-muted space-y-2 list-decimal list-inside mb-3">
<li>Quit the OmniRoute desktop app completely</li>
<li>{t("quitDesktopApp")}</li>
<li>
Navigate to the app data directory:
{t("navigateToAppDataDirectory")}
{dataDir ? (
<div className="bg-black/30 rounded-lg p-2 mt-1 font-mono text-xs text-green-400 border border-white/5 break-all">
{dataDir}
@@ -63,7 +61,7 @@ export default function ForgotPasswordPage() {
) : (
<div className="bg-black/30 rounded-lg p-2 mt-1 font-mono text-xs text-green-400 border border-white/5">
<span className="text-text-muted/60">
(Check your system app data folder)
{t("checkSystemAppDataFolder")}
</span>
</div>
)}
@@ -73,7 +71,7 @@ export default function ForgotPasswordPage() {
<code className="bg-black/30 px-1 rounded text-text-main">settings.json</code>{" "}
({t("orRemovePasswordHashField")})
</li>
<li>Relaunch the OmniRoute desktop app it will start fresh setup</li>
<li>{t("relaunchDesktopFreshSetup")}</li>
</ol>
</div>
</div>
@@ -88,14 +86,14 @@ export default function ForgotPasswordPage() {
</span>
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold mb-1">Alternative: Set New Password</h2>
<h2 className="text-lg font-semibold mb-1">{t("alternativeSetPasswordTitle")}</h2>
<p className="text-sm text-text-muted mb-3">
Set a new initial password via the server environment file:
{t("alternativeSetPasswordDescription")}
</p>
<ol className="text-sm text-text-muted space-y-2 list-decimal list-inside mb-3">
<li>Quit the OmniRoute desktop app completely</li>
<li>{t("quitDesktopApp")}</li>
<li>
Open{" "}
{t("openServerEnvFilePrefix")}{" "}
<code className="bg-black/30 px-1 rounded text-text-main">server.env</code> in
the data directory
{dataDir && (
@@ -115,7 +113,7 @@ export default function ForgotPasswordPage() {
<code className="bg-black/30 px-1 rounded text-text-main">settings.json</code>{" "}
from the data directory
</li>
<li>Relaunch the OmniRoute desktop app</li>
<li>{t("relaunchDesktopApp")}</li>
</ol>
</div>
</div>

View File

@@ -1,5 +1,10 @@
"use client";
import { NextIntlClientProvider, useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { DEFAULT_LOCALE, LOCALES, LOCALE_COOKIE } from "@/i18n/config";
import enMessages from "@/i18n/messages/en.json";
/**
* Global Error Page — FASE-04 Error Handling
*
@@ -13,48 +18,104 @@ interface GlobalErrorProps {
reset: () => void;
}
export default function GlobalError({ error, reset }: GlobalErrorProps) {
function getCookieLocale() {
const cookie = document.cookie
.split(";")
.map((entry) => entry.trim())
.find((entry) => entry.startsWith(`${LOCALE_COOKIE}=`));
const locale = cookie?.slice(`${LOCALE_COOKIE}=`.length) || DEFAULT_LOCALE;
return LOCALES.includes(locale) ? locale : DEFAULT_LOCALE;
}
function buildGlobalErrorMessages(localeMessages: Record<string, unknown>) {
const publicSystem = localeMessages.publicSystem;
const globalError =
publicSystem && typeof publicSystem === "object" && !Array.isArray(publicSystem)
? (publicSystem as Record<string, unknown>).globalError
: null;
const translated =
globalError && typeof globalError === "object" && !Array.isArray(globalError)
? Object.fromEntries(
Object.entries(globalError).filter(
([, value]) => typeof value === "string" && !value.startsWith("__MISSING__:")
)
)
: {};
return {
publicSystem: {
globalError: {
...enMessages.publicSystem.globalError,
...translated,
},
},
};
}
function GlobalErrorContent({ error, reset }: GlobalErrorProps) {
const t = useTranslations("publicSystem");
return (
// lang="en" is intentional: global-error is a client-side root boundary that
// renders ABOVE the next-intl provider, so the active locale isn't reliably
// available here. Its visible text is static English, so lang="en" stays
// consistent with the content. User-facing locale is handled by the normal
// layout (<html lang={locale}> in src/app/layout.tsx).
<html lang="en">
<main role="alert" aria-live="assertive" className="flex flex-col items-center">
<div className="text-[64px] mb-4" aria-hidden="true">
</div>
<h1 className="text-[28px] font-bold mb-2">{t("globalError.title")}</h1>
<p className="text-[15px] text-text-muted max-w-[400px] leading-relaxed mb-6">
{t("globalError.description")}
</p>
{process.env.NODE_ENV === "development" && error?.message && (
<pre
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-xs max-w-[600px] overflow-auto text-left mb-6"
aria-label={t("globalError.detailsAriaLabel")}
>
{error.message}
</pre>
)}
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={reset}
aria-label={t("globalError.retryAriaLabel")}
className="px-8 py-3 rounded-[10px] text-white border-none text-sm font-semibold cursor-pointer transition-transform duration-200 motion-reduce:transition-none motion-reduce:transform-none shadow-warm hover:-translate-y-0.5 bg-gradient-to-br from-primary to-primary-hover focus:outline-2 focus:outline-offset-2 focus:outline-primary"
>
{t("globalError.tryAgain")}
</button>
<a
href="/status"
className="px-8 py-3 rounded-[10px] text-sm font-semibold border border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] no-underline focus:outline-2 focus:outline-offset-2 focus:outline-primary"
aria-label={t("globalError.statusAriaLabel")}
>
{t("globalError.systemStatus")}
</a>
</div>
</main>
);
}
export default function GlobalError({ error, reset }: GlobalErrorProps) {
const [locale, setLocale] = useState<string>(DEFAULT_LOCALE);
const [messages, setMessages] = useState<Record<string, unknown>>(() =>
buildGlobalErrorMessages(enMessages)
);
useEffect(() => {
const nextLocale = getCookieLocale();
setLocale(nextLocale);
if (nextLocale === DEFAULT_LOCALE) return;
void import(`../i18n/messages/${nextLocale}.json`)
.then((module) =>
setMessages(buildGlobalErrorMessages(module.default as Record<string, unknown>))
)
.catch(() => setMessages(buildGlobalErrorMessages(enMessages)));
}, []);
return (
<html lang={locale}>
<body className="flex flex-col items-center justify-center min-h-screen p-6 bg-bg text-text-main font-[system-ui,-apple-system,sans-serif] text-center m-0">
<main role="alert" aria-live="assertive" className="flex flex-col items-center">
<div className="text-[64px] mb-4" aria-hidden="true">
</div>
<h1 className="text-[28px] font-bold mb-2">Something went wrong</h1>
<p className="text-[15px] text-text-muted max-w-[400px] leading-relaxed mb-6">
An unexpected error occurred. This has been logged and our team will investigate.
</p>
{process.env.NODE_ENV === "development" && error?.message && (
<pre
className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 text-xs max-w-[600px] overflow-auto text-left mb-6"
aria-label="Error details"
>
{error.message}
</pre>
)}
<div className="flex flex-col sm:flex-row gap-3">
<button
onClick={reset}
aria-label="Retry loading the page"
className="px-8 py-3 rounded-[10px] text-white border-none text-sm font-semibold cursor-pointer transition-transform duration-200 motion-reduce:transition-none motion-reduce:transform-none shadow-warm hover:-translate-y-0.5 bg-gradient-to-br from-primary to-primary-hover focus:outline-2 focus:outline-offset-2 focus:outline-primary"
>
Try Again
</button>
<a
href="/status"
className="px-8 py-3 rounded-[10px] text-sm font-semibold border border-[var(--color-border)] hover:bg-[var(--color-bg-alt)] no-underline focus:outline-2 focus:outline-offset-2 focus:outline-primary"
aria-label="Open system status"
>
System Status
</a>
</div>
</main>
<NextIntlClientProvider locale={locale} messages={messages}>
<GlobalErrorContent error={error} reset={reset} />
</NextIntlClientProvider>
</body>
</html>
);

View File

@@ -276,7 +276,7 @@ export default function LoginPage() {
className="w-full h-11 text-sm font-medium"
onClick={() => (window.location.href = "/api/auth/oidc/login")}
>
{t("continueWithOidc") || "Continue with OIDC"}
{t("continueWithOidc")}
</Button>
</div>
)}

View File

@@ -1,17 +1,17 @@
import Link from "next/link";
import { useTranslations } from "next-intl";
export default function MaintenancePage() {
const t = useTranslations("publicSystem");
return (
<main className="min-h-screen text-text-main flex items-center justify-center p-6">
<section className="w-full max-w-xl rounded-2xl border border-border bg-surface p-8 shadow-soft text-center">
<span className="material-symbols-outlined text-5xl text-primary mb-3" aria-hidden="true">
construction
</span>
<h1 className="text-2xl font-semibold">Scheduled Maintenance</h1>
<p className="mt-3 text-text-muted leading-relaxed">
Some services are temporarily unavailable while maintenance is in progress. Core routing
usually remains online, but management features may be degraded.
</p>
<h1 className="text-2xl font-semibold">{t("maintenance.title")}</h1>
<p className="mt-3 text-text-muted leading-relaxed">{t("maintenance.description")}</p>
<ul className="mt-6 text-sm text-text-muted text-left rounded-xl border border-border bg-bg-alt p-4 space-y-2">
<li className="flex items-start gap-2">
@@ -21,7 +21,7 @@ export default function MaintenancePage() {
>
info
</span>
Retry after a few minutes.
{t("maintenance.suggestion1")}
</li>
<li className="flex items-start gap-2">
<span
@@ -30,7 +30,7 @@ export default function MaintenancePage() {
>
info
</span>
Check current health indicators and provider status before retrying.
{t("maintenance.suggestion2")}
</li>
</ul>
@@ -39,13 +39,13 @@ export default function MaintenancePage() {
href="/status"
className="inline-flex items-center justify-center px-6 py-3 rounded-lg text-white text-sm font-semibold bg-gradient-to-br from-primary to-primary-hover hover:shadow-elevated transition-all duration-200 motion-reduce:transition-none"
>
View System Status
{t("maintenance.systemStatus")}
</Link>
<Link
href="/dashboard/health"
className="inline-flex items-center justify-center px-6 py-3 rounded-lg text-sm font-semibold border border-border hover:bg-bg-alt transition-colors duration-200 motion-reduce:transition-none"
>
Open Health Dashboard
{t("maintenance.healthDashboard")}
</Link>
</div>
</section>

View File

@@ -1,8 +1,12 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
export default function NotFound() {
const t = useTranslations("publicSystem");
const tc = useTranslations("common");
return (
<div
className="flex flex-col items-center justify-center min-h-screen p-6 bg-bg text-text-main text-center"
@@ -16,25 +20,25 @@ export default function NotFound() {
404
</div>
<h1 id="not-found-title" className="text-2xl font-semibold mb-2">
Page not found
{t("notFound.title")}
</h1>
<p className="text-[15px] text-text-muted max-w-[400px] leading-relaxed mb-8">
The page you&apos;re looking for doesn&apos;t exist or has been moved.
{t("notFound.description")}
</p>
<div className="flex flex-col sm:flex-row items-center gap-3">
<Link
href="/dashboard"
className="px-8 py-3 rounded-xl text-white text-sm font-medium no-underline transition-all duration-200 motion-reduce:transition-none shadow-warm hover:-translate-y-0.5 bg-gradient-to-br from-primary to-primary-hover hover:shadow-elevated focus:outline-2 focus:outline-offset-2 focus:outline-primary"
aria-label="Return to dashboard"
aria-label={t("notFound.dashboardAriaLabel")}
>
Go to Dashboard
{tc("goToDashboard")}
</Link>
<Link
href="/status"
className="px-8 py-3 rounded-xl text-sm font-medium no-underline border border-border hover:bg-bg-alt transition-colors duration-200 motion-reduce:transition-none focus:outline-2 focus:outline-offset-2 focus:outline-primary"
aria-label="Open system status page"
aria-label={t("notFound.statusAriaLabel")}
>
System Status
{t("notFound.systemStatus")}
</Link>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import { useSyncExternalStore } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
function subscribeToOnline(callback: () => void) {
window.addEventListener("online", callback);
@@ -13,6 +14,7 @@ function subscribeToOnline(callback: () => void) {
}
export default function OfflinePage() {
const t = useTranslations("publicSystem");
const isOnline = useSyncExternalStore(
subscribeToOnline,
() => navigator.onLine,
@@ -25,10 +27,8 @@ export default function OfflinePage() {
<span className="material-symbols-outlined text-5xl text-primary mb-3" aria-hidden="true">
wifi_off
</span>
<h1 className="text-2xl font-semibold">Connectivity Issue</h1>
<p className="mt-3 text-text-muted leading-relaxed">
OmniRoute cannot reach the network right now. Check your internet, VPN, or proxy settings.
</p>
<h1 className="text-2xl font-semibold">{t("offline.title")}</h1>
<p className="mt-3 text-text-muted leading-relaxed">{t("offline.description")}</p>
<div
className={`mt-6 inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-sm border ${
@@ -41,7 +41,9 @@ export default function OfflinePage() {
<span className="material-symbols-outlined text-base" aria-hidden="true">
{isOnline ? "wifi" : "wifi_off"}
</span>
<span>{isOnline ? "Connection restored" : "Offline mode detected"}</span>
<span>
{isOnline ? t("offline.connectionRestored") : t("offline.offlineModeDetected")}
</span>
</div>
<div className="mt-8 flex flex-col sm:flex-row gap-3">
@@ -49,13 +51,13 @@ export default function OfflinePage() {
onClick={() => window.location.reload()}
className="inline-flex items-center justify-center px-6 py-3 rounded-lg text-white text-sm font-semibold bg-gradient-to-br from-primary to-primary-hover hover:shadow-elevated transition-all duration-200 motion-reduce:transition-none"
>
Retry Connection
{t("offline.retryConnection")}
</button>
<Link
href="/status"
className="inline-flex items-center justify-center px-6 py-3 rounded-lg text-sm font-semibold border border-border hover:bg-bg-alt transition-colors duration-200 motion-reduce:transition-none"
>
Open Status Page
{t("offline.openStatusPage")}
</Link>
</div>
</section>

View File

@@ -1,7 +1,8 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Spinner } from "@/shared/components/Loading";
interface HealthPayload {
@@ -18,43 +19,48 @@ interface HealthPayload {
error?: string;
}
function formatUptime(seconds?: number) {
if (!seconds || seconds <= 0) return "0m";
function formatUptime(
seconds: number | undefined,
format: (key: string, values?: Record<string, number>) => string
) {
if (!seconds || seconds <= 0) return format("status.uptimeMinutes", { minutes: 0 });
const total = Math.floor(seconds);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
if (hours > 0) return format("status.uptimeHoursMinutes", { hours, minutes });
return format("status.uptimeMinutes", { minutes });
}
export default function StatusPage() {
const t = useTranslations("publicSystem");
const tc = useTranslations("common");
const [loading, setLoading] = useState(true);
const [health, setHealth] = useState<HealthPayload | null>(null);
const [error, setError] = useState<string | null>(null);
async function loadHealth() {
const loadHealth = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/monitoring/health", { cache: "no-store" });
const data = (await response.json()) as HealthPayload;
if (!response.ok) {
setError(data.error || "Failed to load system health.");
setError(data.error || t("status.failedToLoad"));
setHealth(null);
return;
}
setHealth(data);
} catch {
setError("Unable to reach health endpoint. Check connectivity and retry.");
setError(t("status.unableToReachHealth"));
setHealth(null);
} finally {
setLoading(false);
}
}
}, [t]);
useEffect(() => {
void loadHealth();
}, []);
}, [loadHealth]);
const providerStats = useMemo(() => {
const providers = Object.entries(health?.providerHealth || {});
@@ -69,16 +75,14 @@ export default function StatusPage() {
<section className="max-w-4xl mx-auto space-y-6">
<header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 className="text-3xl font-bold tracking-tight">System Status</h1>
<p className="text-text-muted mt-1">
Live operational snapshot for OmniRoute core services.
</p>
<h1 className="text-3xl font-bold tracking-tight">{t("status.title")}</h1>
<p className="text-text-muted mt-1">{t("status.description")}</p>
</div>
<button
onClick={() => void loadHealth()}
className="inline-flex items-center justify-center px-4 py-2 rounded-lg text-sm font-semibold bg-gradient-to-br from-primary to-primary-hover text-white transition-all duration-200 motion-reduce:transition-none"
>
Refresh
{tc("refresh")}
</button>
</header>
@@ -89,14 +93,14 @@ export default function StatusPage() {
aria-live="polite"
>
<Spinner size="md" />
<span className="text-text-muted">Loading health metrics...</span>
<span className="text-text-muted">{t("status.loadingHealth")}</span>
</div>
)}
{!loading && error && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-6" role="alert">
<h2 className="text-lg font-semibold text-red-600 dark:text-red-400">
Health Check Failed
{t("status.healthCheckFailed")}
</h2>
<p className="mt-2 text-sm text-text-muted">{error}</p>
<div className="mt-4 flex flex-wrap gap-2">
@@ -104,13 +108,13 @@ export default function StatusPage() {
href="/offline"
className="px-3 py-2 rounded-lg border border-border text-sm font-medium hover:bg-bg-alt transition-colors"
>
Open Connectivity Help
{t("status.openConnectivityHelp")}
</Link>
<Link
href="/maintenance"
className="px-3 py-2 rounded-lg border border-border text-sm font-medium hover:bg-bg-alt transition-colors"
>
Maintenance Info
{t("status.maintenanceInfo")}
</Link>
</div>
</div>
@@ -120,32 +124,43 @@ export default function StatusPage() {
<>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="rounded-xl border border-border bg-surface p-4">
<p className="text-xs uppercase tracking-wide text-text-muted">Service</p>
<p className="mt-2 text-xl font-semibold">{health.status || "unknown"}</p>
<p className="text-xs uppercase tracking-wide text-text-muted">
{t("status.service")}
</p>
<p className="mt-2 text-xl font-semibold">{health.status || t("status.unknown")}</p>
</div>
<div className="rounded-xl border border-border bg-surface p-4">
<p className="text-xs uppercase tracking-wide text-text-muted">Version</p>
<p className="mt-2 text-xl font-semibold">{health.system?.version || "n/a"}</p>
<p className="text-xs uppercase tracking-wide text-text-muted">{tc("version")}</p>
<p className="mt-2 text-xl font-semibold">
{health.system?.version || t("status.notAvailable")}
</p>
</div>
<div className="rounded-xl border border-border bg-surface p-4">
<p className="text-xs uppercase tracking-wide text-text-muted">Uptime</p>
<p className="mt-2 text-xl font-semibold">{formatUptime(health.system?.uptime)}</p>
<p className="text-xs uppercase tracking-wide text-text-muted">{tc("uptime")}</p>
<p className="mt-2 text-xl font-semibold">
{formatUptime(health.system?.uptime, t)}
</p>
</div>
<div className="rounded-xl border border-border bg-surface p-4">
<p className="text-xs uppercase tracking-wide text-text-muted">Providers Tracked</p>
<p className="text-xs uppercase tracking-wide text-text-muted">
{t("status.providersTracked")}
</p>
<p className="mt-2 text-xl font-semibold">{providerStats.total}</p>
</div>
</div>
<div className="rounded-xl border border-border bg-surface p-6">
<h2 className="text-lg font-semibold">Provider Circuit Breaker State</h2>
<h2 className="text-lg font-semibold">{t("status.circuitBreakerState")}</h2>
<p className="text-sm text-text-muted mt-1">
OPEN: {providerStats.open} · HALF_OPEN: {providerStats.halfOpen} · CLOSED:{" "}
{providerStats.closed}
</p>
<p className="mt-4 text-xs text-text-muted">
Last update:{" "}
{health.timestamp ? new Date(health.timestamp).toLocaleString() : "n/a"}
{t("status.lastUpdate", {
timestamp: health.timestamp
? new Date(health.timestamp).toLocaleString()
: t("status.notAvailable"),
})}
</p>
</div>
</>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -235,7 +235,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
className="relative w-full max-w-3xl bg-surface border border-black/10 dark:border-white/10 rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200"
role="dialog"
aria-modal="true"
aria-label="Command palette"
aria-label={t("commandPalette.title")}
>
<div className="flex items-center gap-3 px-6 py-4 border-b border-black/5 dark:border-white/5">
<span className="material-symbols-outlined text-[20px] text-text-muted shrink-0">
@@ -245,7 +245,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
ref={inputRef}
type="text"
className="flex-1 bg-transparent text-text placeholder:text-text-muted outline-none text-base"
placeholder="Search pages, settings, tools..."
placeholder={t("commandPalette.searchPlaceholder")}
value={query}
onChange={(e) => {
setQuery(e.target.value);
@@ -262,7 +262,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
setSelectedIndex(0);
}}
tabIndex={-1}
aria-label="Clear search"
aria-label={t("commandPalette.clearSearch")}
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
@@ -353,7 +353,9 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
))}
</ul>
) : (
<div className="py-10 text-center text-text-muted text-sm">No results</div>
<div className="py-10 text-center text-text-muted text-sm">
{t("commandPalette.noResults")}
</div>
)}
<div className="flex items-center gap-4 px-4 py-2 border-t border-black/5 dark:border-white/5 text-[11px] text-text-muted">
@@ -361,19 +363,19 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
<kbd className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 font-mono">
</kbd>
navigate
{t("commandPalette.navigate")}
</span>
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 font-mono">
</kbd>
open
{t("commandPalette.open")}
</span>
<span className="flex items-center gap-1">
<kbd className="px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 font-mono">
Esc
</kbd>
close
{t("commandPalette.close")}
</span>
</div>
</div>

View File

@@ -35,7 +35,7 @@ export default function DegradationBadge() {
title={t("warning")} // Using common warning text, or we could just use English / fixed string if i18n is not strict
>
<span className="material-symbols-outlined text-[16px]">healing</span>
<span className="text-xs font-semibold whitespace-nowrap">Degraded</span>
<span className="text-xs font-semibold whitespace-nowrap">{t("degraded")}</span>
</Link>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
type ButtonState = "idle" | "distributing" | "complete";
@@ -22,6 +23,7 @@ export default function DistributeProxiesButton({
size = "md",
}: DistributeProxiesButtonProps) {
const [state, setState] = useState<ButtonState>("idle");
const t = useTranslations("sharedComponents.distributeProxies");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
@@ -44,8 +46,7 @@ export default function DistributeProxiesButton({
const isDisabled = disabled || state === "distributing";
const sizeClasses =
size === "sm" ? "px-2 py-1 text-[11px]" : "px-3 py-1.5 text-xs";
const sizeClasses = size === "sm" ? "px-2 py-1 text-[11px]" : "px-3 py-1.5 text-xs";
const stateClasses =
state === "distributing"
@@ -55,7 +56,12 @@ export default function DistributeProxiesButton({
: "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/40";
const icon = state === "distributing" ? "sync" : state === "complete" ? "check" : "swap_horiz";
const displayLabel = state === "distributing" ? "Distributing..." : state === "complete" ? "Complete" : label;
const displayLabel =
state === "distributing"
? t("distributing")
: state === "complete"
? t("complete")
: label || t("defaultLabel");
return (
<button
@@ -65,7 +71,9 @@ export default function DistributeProxiesButton({
title={displayLabel}
aria-label={displayLabel}
>
<span className={`material-symbols-outlined text-[14px] ${state === "distributing" ? "animate-spin" : ""}`}>
<span
className={`material-symbols-outlined text-[14px] ${state === "distributing" ? "animate-spin" : ""}`}
>
{icon}
</span>
{displayLabel}

View File

@@ -7,38 +7,38 @@ import { APP_CONFIG } from "@/shared/constants/appConfig";
const footerLinks = {
product: [
{ label: "Features", href: "#features" },
{ label: "Pricing", href: "#pricing" },
{ key: "featuresLink", href: "#features" },
{ key: "pricing", href: "#pricing" },
{
label: "Changelog",
key: "changelog",
href: "https://github.com/diegosouzapw/OmniRoute/releases",
external: true,
},
],
resources: [
{ label: "Documentation", href: "/docs" },
{ label: "API Reference", href: "/docs#api-reference" },
{ key: "documentation", href: "/docs" },
{ key: "apiReference", href: "/docs#api-reference" },
{
label: "Help Center",
key: "helpCenter",
href: "https://github.com/diegosouzapw/OmniRoute/discussions",
external: true,
},
],
company: [
{ label: "About", href: "https://github.com/diegosouzapw/OmniRoute", external: true },
{ label: "Blog", href: "https://github.com/diegosouzapw/OmniRoute/releases", external: true },
{ key: "about", href: "https://github.com/diegosouzapw/OmniRoute", external: true },
{ key: "blog", href: "https://github.com/diegosouzapw/OmniRoute/releases", external: true },
{
label: "Contact",
key: "contact",
href: "https://github.com/diegosouzapw/OmniRoute/issues/new/choose",
external: true,
},
{ label: "Terms", href: "/terms" },
{ label: "Privacy", href: "/privacy" },
{ key: "terms", href: "/terms" },
{ key: "privacy", href: "/privacy" },
],
};
export default function Footer() {
const t = useTranslations("stats");
const t = useTranslations("landing");
const renderFooterLink = (link) => {
if (link.external) {
return (
@@ -48,7 +48,7 @@ export default function Footer() {
rel="noopener noreferrer"
className="hover:text-primary transition-colors"
>
{link.label}
{t(link.key)}
</a>
);
}
@@ -77,9 +77,7 @@ export default function Footer() {
</div>
<span className="text-xl font-bold text-text-main">{APP_CONFIG.name}</span>
</div>
<p className="text-text-muted mb-6 max-w-sm font-light">
The unified interface for modern AI infrastructure. Secure, observable, and scalable.
</p>
<p className="text-text-muted mb-6 max-w-sm font-light">{t("footerDescription")}</p>
{/* Social links */}
<div className="flex gap-4">
<a
@@ -87,7 +85,7 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-primary transition-colors"
aria-label="Community Discussions"
aria-label={t("communityDiscussions")}
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M22.46 6c-.77.35-1.6.58-2.46.69.88-.53 1.56-1.37 1.88-2.38-.83.5-1.75.85-2.72 1.05C18.37 4.5 17.26 4 16 4c-2.35 0-4.27 1.92-4.27 4.29 0 .34.04.67.11.98C8.28 9.09 5.11 7.38 3 4.79c-.37.63-.58 1.37-.58 2.15 0 1.49.75 2.81 1.91 3.56-.71 0-1.37-.2-1.95-.5v.03c0 2.08 1.48 3.82 3.44 4.21a4.22 4.22 0 0 1-1.93.07 4.28 4.28 0 0 0 4 2.98 8.521 8.521 0 0 1-5.33 1.84c-.34 0-.68-.02-1.02-.06C3.44 20.29 5.7 21 8.12 21 16 21 20.33 14.46 20.33 8.79c0-.19 0-.37-.01-.56.84-.6 1.56-1.36 2.14-2.23z" />
@@ -112,7 +110,7 @@ export default function Footer() {
<h4 className="font-semibold text-text-main mb-4">{t("product")}</h4>
<ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
{footerLinks.product.map((link) => (
<li key={link.label}>{renderFooterLink(link)}</li>
<li key={link.key}>{renderFooterLink(link)}</li>
))}
</ul>
</div>
@@ -122,7 +120,7 @@ export default function Footer() {
<h4 className="font-semibold text-text-main mb-4">{t("resources")}</h4>
<ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
{footerLinks.resources.map((link) => (
<li key={link.label}>{renderFooterLink(link)}</li>
<li key={link.key}>{renderFooterLink(link)}</li>
))}
</ul>
</div>
@@ -132,7 +130,7 @@ export default function Footer() {
<h4 className="font-semibold text-text-main mb-4">{t("company")}</h4>
<ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
{footerLinks.company.map((link) => (
<li key={link.label}>{renderFooterLink(link)}</li>
<li key={link.key}>{renderFooterLink(link)}</li>
))}
</ul>
</div>
@@ -141,17 +139,17 @@ export default function Footer() {
{/* Bottom */}
<div className="border-t border-border pt-8 flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-sm text-text-muted">
© {new Date().getFullYear()} {APP_CONFIG.name} Inc. All rights reserved.
{t("copyright", { year: new Date().getFullYear() })}
</p>
<div className="flex gap-6 text-sm text-text-muted">
<Link href="/docs" className="hover:text-primary transition-colors">
Documentation
{t("documentation")}
</Link>
<Link href="/terms" className="hover:text-primary transition-colors">
Terms
{t("terms")}
</Link>
<Link href="/privacy" className="hover:text-primary transition-colors">
Privacy
{t("privacy")}
</Link>
<a
href="https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE"
@@ -159,7 +157,7 @@ export default function Footer() {
rel="noopener noreferrer"
className="hover:text-primary transition-colors"
>
License
{t("license")}
</a>
</div>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import Modal from "./Modal";
import Button from "./Button";
import Input from "./Input";
@@ -24,6 +25,7 @@ export default function KiroAuthModal({
onMethodSelect,
onClose,
}: KiroAuthModalProps) {
const t = useTranslations("kiroAuthModal");
const [selectedMethod, setSelectedMethod] = useState(null);
const [idcStartUrl, setIdcStartUrl] = useState("");
const [idcRegion, setIdcRegion] = useState("us-east-1");
@@ -65,17 +67,17 @@ export default function KiroAuthModal({
onClose();
return;
} else {
setError(data.error || "Could not auto-detect token");
setError(data.error || t("errorAutoDetect"));
}
} catch (err) {
setError("Failed to auto-detect token");
setError(t("errorAutoDetectFailed"));
} finally {
setAutoDetecting(false);
}
};
autoDetect();
}, [providerId, selectedMethod, isOpen, onMethodSelect, onClose]);
}, [providerId, selectedMethod, isOpen, onMethodSelect, onClose, t]);
const handleMethodSelect = (method) => {
setSelectedMethod(method);
@@ -89,7 +91,7 @@ export default function KiroAuthModal({
const handleImportToken = async () => {
if (!refreshToken.trim()) {
setError("Please enter a refresh token");
setError(t("errorRefreshTokenRequired"));
return;
}
@@ -111,14 +113,14 @@ export default function KiroAuthModal({
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Import failed");
throw new Error(data.error || t("errorImportFailed"));
}
// Success - close modal
onMethodSelect("import");
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Import failed");
setError(err instanceof Error ? err.message : t("errorImportFailed"));
} finally {
setImporting(false);
}
@@ -126,7 +128,7 @@ export default function KiroAuthModal({
const handleImportApiKey = async () => {
if (!apiKey.trim()) {
setError("Please enter a Kiro API key");
setError(t("errorApiKeyRequired"));
return;
}
@@ -149,13 +151,13 @@ export default function KiroAuthModal({
const data = await res.json();
if (!res.ok) {
throw new Error(data.error?.message || data.error || "API key import failed");
throw new Error(data.error?.message || data.error || t("errorApiKeyImportFailed"));
}
onMethodSelect("api-key");
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "API key import failed");
setError(err instanceof Error ? err.message : t("errorApiKeyImportFailed"));
} finally {
setImportingApiKey(false);
}
@@ -163,7 +165,7 @@ export default function KiroAuthModal({
const handleIdcContinue = () => {
if (!idcStartUrl.trim()) {
setError("Please enter your IDC start URL");
setError(t("errorIdcStartUrlRequired"));
return;
}
onMethodSelect("idc", { startUrl: idcStartUrl.trim(), region: idcRegion });
@@ -174,12 +176,12 @@ export default function KiroAuthModal({
};
return (
<Modal isOpen={isOpen} title={`Connect ${providerLabel}`} onClose={onClose} size="lg">
<Modal isOpen={isOpen} title={t("title", { providerLabel })} onClose={onClose} size="lg">
<div className="flex flex-col gap-4">
{/* Method Selection */}
{!selectedMethod && (
<div className="space-y-3">
<p className="text-sm text-text-muted mb-4">Choose your authentication method:</p>
<p className="text-sm text-text-muted mb-4">{t("chooseMethod")}</p>
{/* AWS Builder ID */}
<button
@@ -189,10 +191,9 @@ export default function KiroAuthModal({
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-primary mt-0.5">shield</span>
<div className="flex-1">
<h3 className="font-semibold mb-1">AWS Builder ID</h3>
<h3 className="font-semibold mb-1">{t("builderId")}</h3>
<p className="text-sm text-text-muted">
Recommended for most users. Sign in with the AWS account linked to{" "}
{providerLabel}.
{t("builderDescription", { providerLabel })}
</p>
</div>
</div>
@@ -206,11 +207,11 @@ export default function KiroAuthModal({
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-primary mt-0.5">business</span>
<div className="flex-1">
<h3 className="font-semibold mb-1">
Your Organization (AWS IAM Identity Center)
</h3>
<h3 className="font-semibold mb-1">{t("organization")}</h3>
<p className="text-sm text-text-muted">
Use your company SSO start URL (example: https://your-org.awsapps.com/start).
{t("organizationDescription", {
url: "https://your-org.awsapps.com/start",
})}
</p>
</div>
</div>
@@ -226,8 +227,8 @@ export default function KiroAuthModal({
account_circle
</span>
<div className="flex-1">
<h3 className="font-semibold mb-1">Google Account</h3>
<p className="text-sm text-text-muted">Login with your Google account.</p>
<h3 className="font-semibold mb-1">{t("googleAccount")}</h3>
<p className="text-sm text-text-muted">{t("googleDescription")}</p>
</div>
</div>
</button>
@@ -240,8 +241,8 @@ export default function KiroAuthModal({
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-primary mt-0.5">code</span>
<div className="flex-1">
<h3 className="font-semibold mb-1">GitHub Account</h3>
<p className="text-sm text-text-muted">Login with your GitHub account.</p>
<h3 className="font-semibold mb-1">{t("githubAccount")}</h3>
<p className="text-sm text-text-muted">{t("githubDescription")}</p>
</div>
</div>
</button>
@@ -254,9 +255,9 @@ export default function KiroAuthModal({
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-primary mt-0.5">file_upload</span>
<div className="flex-1">
<h3 className="font-semibold mb-1">Import Token</h3>
<h3 className="font-semibold mb-1">{t("importToken")}</h3>
<p className="text-sm text-text-muted">
Paste a refresh token exported from {providerLabel}.
{t("importDescription", { providerLabel })}
</p>
</div>
</div>
@@ -270,10 +271,9 @@ export default function KiroAuthModal({
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-primary mt-0.5">key</span>
<div className="flex-1">
<h3 className="font-semibold mb-1">API Key</h3>
<h3 className="font-semibold mb-1">{t("apiKey")}</h3>
<p className="text-sm text-text-muted">
Paste a long-lived {providerLabel} / CodeWhisperer API key. It is stored as a
bearer credential with no refresh token; profile discovery is best-effort.
{t("apiKeyDescription", { providerLabel })}
</p>
</div>
</div>
@@ -286,40 +286,36 @@ export default function KiroAuthModal({
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">
IDC Start URL <span className="text-red-500">*</span>
{t("idcStartUrl")} <span className="text-red-500">*</span>
</label>
<Input
value={idcStartUrl}
onChange={(e) => setIdcStartUrl(e.target.value)}
placeholder="https://your-org.awsapps.com/start"
placeholder={t("idcStartUrlPlaceholder")}
className="font-mono text-sm"
/>
<p className="text-xs text-text-muted mt-1">
Your organization&apos;s AWS IAM Identity Center URL
</p>
<p className="text-xs text-text-muted mt-1">{t("idcStartUrlDescription")}</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">AWS Region</label>
<label className="block text-sm font-medium mb-2">{t("awsRegion")}</label>
<Input
value={idcRegion}
onChange={(e) => setIdcRegion(e.target.value)}
placeholder="us-east-1"
placeholder={t("regionPlaceholder")}
className="font-mono text-sm"
/>
<p className="text-xs text-text-muted mt-1">
AWS region for your Identity Center (default: us-east-1)
</p>
<p className="text-xs text-text-muted mt-1">{t("idcRegionDescription")}</p>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex gap-2">
<Button onClick={handleIdcContinue} fullWidth>
Continue
{t("continue")}
</Button>
<Button onClick={handleBack} variant="ghost" fullWidth>
Back
{t("back")}
</Button>
</div>
</div>
@@ -336,9 +332,9 @@ export default function KiroAuthModal({
progress_activity
</span>
</div>
<h3 className="text-lg font-semibold mb-2">Auto-detecting token...</h3>
<h3 className="text-lg font-semibold mb-2">{t("autoDetecting")}</h3>
<p className="text-sm text-text-muted">
Reading {providerLabel} credentials from AWS SSO cache
{t("readingCredentials", { providerLabel })}
</p>
</div>
)}
@@ -354,8 +350,7 @@ export default function KiroAuthModal({
info
</span>
<p className="text-sm text-blue-800 dark:text-blue-200">
{providerLabel} token was not auto-detected. Please paste your refresh token
manually.
{t("tokenNotDetected", { providerLabel })}
</p>
</div>
</div>
@@ -363,13 +358,13 @@ export default function KiroAuthModal({
<div>
<label className="block text-sm font-medium mb-2">
Refresh Token <span className="text-red-500">*</span>
{t("refreshToken")} <span className="text-red-500">*</span>
</label>
<Input
type="password"
value={refreshToken}
onChange={(e) => setRefreshToken(e.target.value)}
placeholder="Token will be auto-filled..."
placeholder={t("tokenPlaceholder")}
className="font-mono text-sm"
/>
</div>
@@ -386,10 +381,10 @@ export default function KiroAuthModal({
fullWidth
disabled={importing || !refreshToken.trim()}
>
{importing ? "Importing..." : "Import Token"}
{importing ? t("importing") : t("importToken")}
</Button>
<Button onClick={handleBack} variant="ghost" fullWidth>
Back
{t("back")}
</Button>
</div>
</>
@@ -402,31 +397,27 @@ export default function KiroAuthModal({
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">
API Key <span className="text-red-500">*</span>
{t("apiKey")} <span className="text-red-500">*</span>
</label>
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={`Paste your ${providerLabel} API key...`}
placeholder={t("apiKeyPlaceholder", { providerLabel })}
className="font-mono text-sm"
/>
<p className="text-xs text-text-muted mt-1">
Stored encrypted as a long-lived bearer credential. There is no refresh flow.
</p>
<p className="text-xs text-text-muted mt-1">{t("apiKeyStoredDescription")}</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">AWS Region</label>
<label className="block text-sm font-medium mb-2">{t("awsRegion")}</label>
<Input
value={apiKeyRegion}
onChange={(e) => setApiKeyRegion(e.target.value)}
placeholder="us-east-1"
placeholder={t("regionPlaceholder")}
className="font-mono text-sm"
/>
<p className="text-xs text-text-muted mt-1">
AWS region for the key (default: us-east-1)
</p>
<p className="text-xs text-text-muted mt-1">{t("apiKeyRegionDescription")}</p>
</div>
{error && (
@@ -441,10 +432,10 @@ export default function KiroAuthModal({
fullWidth
disabled={importingApiKey || !apiKey.trim()}
>
{importingApiKey ? "Validating..." : "Validate and Save API Key"}
{importingApiKey ? t("validating") : t("validateAndSaveApiKey")}
</Button>
<Button onClick={handleBack} variant="ghost" fullWidth>
Back
{t("back")}
</Button>
</div>
</div>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import Modal from "./Modal";
import Button from "./Button";
import { copyToClipboard } from "@/shared/utils/clipboard";
@@ -23,6 +24,7 @@ export default function KiroSocialOAuthModal({
onSuccess,
onClose,
}: KiroSocialOAuthModalProps) {
const t = useTranslations("kiroSocialOAuthModal");
const [step, setStep] = useState<"loading" | "polling" | "success" | "error">("loading");
const [error, setError] = useState<string | null>(null);
const [userCode, setUserCode] = useState("");
@@ -60,7 +62,7 @@ export default function KiroSocialOAuthModal({
if (cancelled) return;
if (!res.ok) {
throw new Error(data.error || "Failed to start authorization");
throw new Error(data.error || t("errorStartAuthorization"));
}
setUserCode(data.userCode || "");
@@ -80,7 +82,7 @@ export default function KiroSocialOAuthModal({
pollRef.current = null;
if (cancelled) return;
if (Date.now() >= expiresAt) {
fail("Authorization expired. Start the login flow again.");
fail(t("errorAuthorizationExpired"));
return;
}
@@ -101,7 +103,7 @@ export default function KiroSocialOAuthModal({
}
if (!pollData.pending) {
fail(pollData.error || "Authorization failed");
fail(pollData.error || t("errorAuthorizationFailed"));
return;
}
@@ -124,7 +126,7 @@ export default function KiroSocialOAuthModal({
cancelled = true;
stopPolling();
};
}, [isOpen, provider, targetProvider]);
}, [isOpen, provider, targetProvider, t]);
const handleClose = () => {
if (pollRef.current) {
@@ -139,7 +141,7 @@ export default function KiroSocialOAuthModal({
return (
<Modal
isOpen={isOpen}
title={`Connect ${providerLabel} via ${providerName}`}
title={t("title", { providerLabel, providerName })}
onClose={handleClose}
size="lg"
>
@@ -151,8 +153,8 @@ export default function KiroSocialOAuthModal({
progress_activity
</span>
</div>
<h3 className="text-lg font-semibold mb-2">Initializing...</h3>
<p className="text-sm text-text-muted">Setting up {providerName} authentication</p>
<h3 className="text-lg font-semibold mb-2">{t("initializing")}</h3>
<p className="text-sm text-text-muted">{t("settingUp", { providerName })}</p>
</div>
)}
@@ -163,10 +165,8 @@ export default function KiroSocialOAuthModal({
open_in_browser
</span>
</div>
<h3 className="text-lg font-semibold mb-2">Open this link in an Incognito window</h3>
<p className="text-sm text-text-muted mb-3">
Use an Incognito/Private window to avoid session conflicts with existing accounts.
</p>
<h3 className="text-lg font-semibold mb-2">{t("openIncognito")}</h3>
<p className="text-sm text-text-muted mb-3">{t("incognitoDescription")}</p>
{authUrl && (
<div className="mb-4">
<div className="flex items-center gap-2 justify-center">
@@ -181,7 +181,7 @@ export default function KiroSocialOAuthModal({
<button
onClick={() => copyToClipboard(authUrl)}
className="shrink-0 p-1 rounded hover:bg-sidebar"
title="Copy link"
title={t("copyLink")}
>
<span className="material-symbols-outlined text-base">content_copy</span>
</button>
@@ -190,7 +190,7 @@ export default function KiroSocialOAuthModal({
)}
{userCode && (
<div className="mb-4">
<p className="text-xs text-text-muted mb-1">Verification code</p>
<p className="text-xs text-text-muted mb-1">{t("verificationCode")}</p>
<p className="font-mono text-2xl font-bold tracking-widest">{userCode}</p>
</div>
)}
@@ -198,11 +198,11 @@ export default function KiroSocialOAuthModal({
<span className="material-symbols-outlined text-base animate-spin">
progress_activity
</span>
Waiting for authorization...
{t("waiting")}
</div>
<div className="mt-6">
<Button onClick={handleClose} variant="ghost" fullWidth>
Cancel
{t("cancel")}
</Button>
</div>
</div>
@@ -215,12 +215,12 @@ export default function KiroSocialOAuthModal({
check_circle
</span>
</div>
<h3 className="text-lg font-semibold mb-2">Connected Successfully!</h3>
<h3 className="text-lg font-semibold mb-2">{t("successTitle")}</h3>
<p className="text-sm text-text-muted mb-4">
Your {providerLabel} account via {providerName} has been connected.
{t("successMessage", { providerLabel, providerName })}
</p>
<Button onClick={handleClose} fullWidth>
Done
{t("done")}
</Button>
</div>
)}
@@ -230,11 +230,11 @@ export default function KiroSocialOAuthModal({
<div className="size-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-red-600">error</span>
</div>
<h3 className="text-lg font-semibold mb-2">Connection Failed</h3>
<h3 className="text-lg font-semibold mb-2">{t("errorTitle")}</h3>
<p className="text-sm text-red-600 mb-4">{error}</p>
<div className="flex gap-2">
<Button onClick={handleClose} variant="ghost" fullWidth>
Close
{t("close")}
</Button>
</div>
</div>

View File

@@ -22,6 +22,7 @@ interface Combo {
export default function ModelRoutingSection({ combos: externalCombos }: { combos?: Combo[] } = {}) {
const t = useTranslations("settings");
const tCommon = useTranslations("common");
const [mappings, setMappings] = useState<ModelMapping[]>([]);
const [internalCombos, setInternalCombos] = useState<Combo[]>([]);
const [loading, setLoading] = useState(true);
@@ -250,7 +251,7 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Route Opus models to frontier combo"
placeholder={t("modelRoutingDescriptionPlaceholder")}
className="w-full mt-0.5 px-2.5 py-1.5 text-xs rounded-lg border border-black/10 dark:border-white/10
bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary"
/>
@@ -328,7 +329,7 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos
<button
onClick={() => handleEdit(m)}
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
title="Edit"
title={tCommon("edit")}
>
<span className="material-symbols-outlined text-[14px] text-text-muted">
edit
@@ -337,7 +338,7 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos
<button
onClick={() => handleDelete(m.id)}
className="p-1 rounded hover:bg-red-500/10 transition-colors"
title="Delete"
title={tCommon("delete")}
>
<span className="material-symbols-outlined text-[14px] text-red-500">delete</span>
</button>

Some files were not shown because too many files have changed in this diff Show More