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> <span className="text-amber-500 dark:text-amber-400 text-base shrink-0 mt-0.5"></span>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-semibold text-amber-900 dark:text-amber-300"> <p className="font-semibold text-amber-900 dark:text-amber-300">
Running in zero-config mode {t("zeroConfigBannerTitle")}
</p> </p>
<p className="mt-0.5 text-amber-800/80 dark:text-amber-200/80"> <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{" "} {t.rich("zeroConfigBannerBody", {
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs"> dataDir,
{dataDir} code: (chunks) => (
</code> <code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs">
. No action is required your data is encrypted and safe. To use custom keys, add{" "} {chunks}
<code className="font-mono bg-amber-200/50 dark:bg-amber-500/20 px-1 rounded text-xs"> </code>
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.
</p> </p>
</div> </div>
<button <button

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,22 +4,24 @@ import { useEffect, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/shared/components"; 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 diffMs = Date.now() - ts * 1000;
const diffSec = Math.round(diffMs / 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); 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); 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); const diffDays = Math.round(diffHr / 24);
return `${diffDays}d ago`; return t("batchRelativeTimeAgo", { value: `${diffDays}d` });
} }
function relativeExpiration(ts: number | null): string { function relativeExpiration(ts: number | null, t: FileTranslator): string {
if (!ts) return "Never"; if (!ts) return t("batchFilesNeverExpires");
const diffMs = ts * 1000 - Date.now(); const diffMs = ts * 1000 - Date.now();
if (diffMs <= 0) return "Expired"; if (diffMs <= 0) return t("expirationBadgeExpired");
const diffSec = Math.round(diffMs / 1000); const diffSec = Math.round(diffMs / 1000);
if (diffSec < 60) return `${diffSec}s`; if (diffSec < 60) return `${diffSec}s`;
const diffMin = Math.round(diffSec / 60); const diffMin = Math.round(diffSec / 60);
@@ -55,6 +57,22 @@ interface BatchRecord {
model?: string | null; 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 { interface FileDetailModalProps {
file: FileRecord; file: FileRecord;
contents: string | null; contents: string | null;
@@ -133,7 +151,7 @@ export default function FileDetailModal({
</span> </span>
<div> <div>
<h2 className="text-base font-semibold text-[var(--color-text-main)]"> <h2 className="text-base font-semibold text-[var(--color-text-main)]">
File Contents {t("batchFileContents")}
</h2> </h2>
<div className="flex items-center gap-2 mt-0.5"> <div className="flex items-center gap-2 mt-0.5">
<p className="text-xs text-[var(--color-text-muted)] font-mono">{file.id}</p> <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="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"> <div className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]"> <span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
Size {t("batchFilesSizeColumn")}
</span> </span>
<span className="text-sm text-[var(--color-text-main)]"> <span className="text-sm text-[var(--color-text-main)]">
{formatBytes(file.bytes)} {formatBytes(file.bytes)}
@@ -172,24 +190,24 @@ export default function FileDetailModal({
</div> </div>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]"> <span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
Purpose {t("batchFilesPurpose")}
</span> </span>
<span className="text-sm text-[var(--color-text-main)]">{file.purpose}</span> <span className="text-sm text-[var(--color-text-main)]">{file.purpose}</span>
</div> </div>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]"> <span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
Created {t("batchDetailCreated")}
</span> </span>
<span className="text-sm text-[var(--color-text-main)]"> <span className="text-sm text-[var(--color-text-main)]">
{createdAtTs ? relativeTime(createdAtTs) : "—"} {createdAtTs ? relativeTime(createdAtTs, t) : "—"}
</span> </span>
</div> </div>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]"> <span className="text-[10px] uppercase tracking-wider font-medium text-[var(--color-text-muted)]">
Expires {t("batchFilesExpires")}
</span> </span>
<span className="text-sm text-[var(--color-text-main)]"> <span className="text-sm text-[var(--color-text-main)]">
{expiresAtTs ? relativeExpiration(expiresAtTs) : "Never"} {expiresAtTs ? relativeExpiration(expiresAtTs, t) : t("batchFilesNeverExpires")}
</span> </span>
</div> </div>
</div> </div>
@@ -198,7 +216,7 @@ export default function FileDetailModal({
{relatedBatches.length > 0 && ( {relatedBatches.length > 0 && (
<div> <div>
<h3 className="text-[11px] uppercase tracking-wider font-medium text-[var(--color-text-muted)] mb-2"> <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> </h3>
<div className="space-y-1.5"> <div className="space-y-1.5">
{relatedBatches.map((b) => ( {relatedBatches.map((b) => (
@@ -219,7 +237,9 @@ export default function FileDetailModal({
: "bg-gray-500/15 text-gray-400 border-gray-500/25" : "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> </span>
</div> </div>
))} ))}
@@ -231,7 +251,7 @@ export default function FileDetailModal({
<div className="flex-1 flex flex-col min-h-[300px]"> <div className="flex-1 flex flex-col min-h-[300px]">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]"> <h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
Preview {t("batchFilePreview")}
</h3> </h3>
{contents && ( {contents && (
<button <button
@@ -241,7 +261,7 @@ export default function FileDetailModal({
<span className="material-symbols-outlined text-[14px]"> <span className="material-symbols-outlined text-[14px]">
{copied ? "check" : "content_copy"} {copied ? "check" : "content_copy"}
</span> </span>
{copied ? "Copied!" : "Copy"} {copied ? t("copied") : t("copy")}
</button> </button>
)} )}
</div> </div>
@@ -259,7 +279,7 @@ export default function FileDetailModal({
{isTruncated && ( {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"> <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> <span className="material-symbols-outlined text-[16px]">warning</span>
Showing first 1000 lines ({lineCount} total lines) {t("batchFilePreviewTruncated", { shown: 1000, total: lineCount })}
</div> </div>
)} )}
</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" 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> <span className="material-symbols-outlined text-[18px]">download</span>
Download Full File {t("batchFileDownloadFull")}
</Button> </Button>
</div> </div>
</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"; "use client";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { chaosText, type ChaosTranslator } from "../chaosI18n";
/** /**
* Save/Reset + Test-run action buttons for the Chaos Mode config page. * Save/Reset + Test-run action buttons for the Chaos Mode config page.
@@ -22,7 +23,7 @@ export function ChaosConfigActionsBar({
onReset: () => void; onReset: () => void;
onTest: () => void; onTest: () => void;
}) { }) {
const t = useTranslations("chaosConfig"); const t = useTranslations("chaosConfig") as ChaosTranslator;
return ( return (
<> <>
@@ -66,7 +67,7 @@ export function ChaosConfigActionsBar({
) : ( ) : (
<span className="material-symbols-outlined text-[16px]">play_arrow</span> <span className="material-symbols-outlined text-[16px]">play_arrow</span>
)} )}
{testing ? "Running..." : t("testButton")} {testing ? chaosText(t, "running", "Running...") : t("testButton")}
</button> </button>
</div> </div>
</> </>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Card, Button } from "@/shared/components"; import { Card, Button } from "@/shared/components";
interface ToolState { interface ToolState {
@@ -24,6 +25,7 @@ interface UpdateInfo {
} }
export default function CliproxyapiToolCard({ isExpanded = false, onToggle = () => {} }) { export default function CliproxyapiToolCard({ isExpanded = false, onToggle = () => {} }) {
const t = useTranslations("cliTools");
const [toolState, setToolState] = useState<ToolState | null>(null); const [toolState, setToolState] = useState<ToolState | null>(null);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null); const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [loading, setLoading] = useState<string | 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(); const data = await res.json();
if (res.ok) { if (res.ok) {
setMessage({ type: "success", text: data.message || `${action} succeeded` }); setMessage({ type: "success", text: data.message || t("cliproxyapiActionSucceeded") });
await fetchStatus(); await fetchStatus();
if (action === "install" || action === "restart") await fetchUpdateInfo(); if (action === "install" || action === "restart") await fetchUpdateInfo();
} else { } else {
@@ -79,11 +81,14 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
type: "error", type: "error",
text: text:
(typeof data.error === "string" ? data.error : data.error?.message) || (typeof data.error === "string" ? data.error : data.error?.message) ||
`${action} failed`, t("cliproxyapiActionFailed"),
}); });
} }
} catch (err) { } 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 { } finally {
setLoading(null); setLoading(null);
} }
@@ -93,14 +98,26 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
if (!toolState) return null; if (!toolState) return null;
const s = toolState.status; const s = toolState.status;
const map: Record<string, { label: string; color: string }> = { const map: Record<string, { label: string; color: string }> = {
running: { label: "Running", color: "bg-green-500/10 text-green-600 dark:text-green-400" }, running: {
stopped: { label: "Stopped", color: "bg-zinc-500/10 text-zinc-500 dark:text-zinc-400" }, 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: { not_installed: {
label: "Not Installed", label: t("cliproxyapiStatusNotInstalled"),
color: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400", 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" }, installed: {
error: { label: "Error", color: "bg-red-500/10 text-red-600 dark:text-red-400" }, 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; const badge = map[s] || map.not_installed;
return ( return (
@@ -125,9 +142,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
<h3 className="font-medium text-sm">CLIProxyAPI</h3> <h3 className="font-medium text-sm">CLIProxyAPI</h3>
{statusBadge()} {statusBadge()}
</div> </div>
<p className="text-xs text-text-muted truncate"> <p className="text-xs text-text-muted truncate">{t("cliproxyapiDescription")}</p>
Upstream proxy fallback (Go-based OAuth)
</p>
</div> </div>
</div> </div>
<span <span
@@ -161,7 +176,10 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
system_update system_update
</span> </span>
<span className="text-sm text-yellow-700 dark:text-yellow-300"> <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> </span>
</div> </div>
<Button <Button
@@ -170,32 +188,34 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
onClick={() => apiCall("install", { version: updateInfo.latest })} onClick={() => apiCall("install", { version: updateInfo.latest })}
loading={loading === "install"} loading={loading === "install"}
> >
Update {t("cliproxyapiUpdate")}
</Button> </Button>
</div> </div>
)} )}
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="p-3 rounded-lg bg-bg-secondary"> <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"> <p className="text-sm font-medium">
{toolState?.installedVersion ? `v${toolState.installedVersion}` : "Not installed"} {toolState?.installedVersion
? `v${toolState.installedVersion}`
: t("cliproxyapiNotInstalledValue")}
</p> </p>
</div> </div>
<div className="p-3 rounded-lg bg-bg-secondary"> <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 <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"}`} 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" {toolState?.healthStatus === "healthy"
? `Healthy` ? t("cliproxyapiHealthy")
: toolState?.healthStatus === "unhealthy" : toolState?.healthStatus === "unhealthy"
? "Unhealthy" ? t("cliproxyapiUnhealthy")
: "Unknown"} : t("cliproxyapiUnknown")}
</p> </p>
</div> </div>
<div className="p-3 rounded-lg bg-bg-secondary"> <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> <p className="text-sm font-mono">{toolState?.port || 8317}</p>
</div> </div>
</div> </div>
@@ -209,7 +229,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
loading={loading === "install"} loading={loading === "install"}
> >
<span className="material-symbols-outlined text-[14px] mr-1">download</span> <span className="material-symbols-outlined text-[14px] mr-1">download</span>
Install {t("cliproxyapiInstall")}
</Button> </Button>
)} )}
{toolState?.status === "running" ? ( {toolState?.status === "running" ? (
@@ -220,7 +240,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
loading={loading === "stop"} loading={loading === "stop"}
> >
<span className="material-symbols-outlined text-[14px] mr-1">stop</span> <span className="material-symbols-outlined text-[14px] mr-1">stop</span>
Stop {t("cliproxyapiStop")}
</Button> </Button>
) : toolState?.installedVersion ? ( ) : toolState?.installedVersion ? (
<Button <Button
@@ -230,7 +250,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
loading={loading === "start"} loading={loading === "start"}
> >
<span className="material-symbols-outlined text-[14px] mr-1">play_arrow</span> <span className="material-symbols-outlined text-[14px] mr-1">play_arrow</span>
Start {t("cliproxyapiStart")}
</Button> </Button>
) : null} ) : null}
{toolState?.status === "running" && ( {toolState?.status === "running" && (
@@ -241,7 +261,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
loading={loading === "restart"} loading={loading === "restart"}
> >
<span className="material-symbols-outlined text-[14px] mr-1">restart_alt</span> <span className="material-symbols-outlined text-[14px] mr-1">restart_alt</span>
Restart {t("cliproxyapiRestart")}
</Button> </Button>
)} )}
<Button <Button
@@ -251,7 +271,7 @@ export default function CliproxyapiToolCard({ isExpanded = false, onToggle = ()
loading={loading === "check"} loading={loading === "check"}
> >
<span className="material-symbols-outlined text-[14px] mr-1">sync</span> <span className="material-symbols-outlined text-[14px] mr-1">sync</span>
Check Updates {t("cliproxyapiCheckUpdates")}
</Button> </Button>
</div> </div>
</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"> <code className="rounded bg-black/5 dark:bg-white/5 px-2 py-1 text-text-main">
{combo?.name} {combo?.name}
</code> </code>
<span>{allCombos.length} intelligent combo(s)</span> <span>{t("intelligentComboCount", { count: allCombos.length })}</span>
<span>{providerScopeCount} providers in scope</span> <span>{t("providersInScope", { count: providerScopeCount })}</span>
</div> </div>
</div> </div>
@@ -161,7 +161,7 @@ export default function IntelligentComboPanel({
</div> </div>
<div className="rounded-lg bg-black/5 dark:bg-white/5 px-3 py-2 text-right"> <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"> <p className="text-[10px] uppercase tracking-wide text-text-muted">
Candidate Pool {getI18nOrFallback(t, "candidatePoolLabel", "Candidate Pool")}
</p> </p>
<p className="text-lg font-semibold text-text-main">{providerScopeCount}</p> <p className="text-lg font-semibold text-text-main">{providerScopeCount}</p>
</div> </div>
@@ -183,7 +183,12 @@ export default function IntelligentComboPanel({
</p> </p>
</div> </div>
{savingModePack && ( {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> </div>
@@ -308,7 +313,7 @@ export default function IntelligentComboPanel({
</div> </div>
<div className="rounded-lg border border-black/8 bg-white/60 p-3 dark:border-white/8 dark:bg-white/[0.03]"> <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"> <p className="text-[11px] uppercase tracking-wide text-text-muted">
Exploration Rate {getI18nOrFallback(t, "explorationRateLabel", "Exploration Rate")}
</p> </p>
<p className="mt-1 text-sm font-semibold text-text-main"> <p className="mt-1 text-sm font-semibold text-text-main">
{Math.round(normalizedConfig.explorationRate * 100)}% {Math.round(normalizedConfig.explorationRate * 100)}%

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,18 @@ export default function NotionSourceCard() {
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
const [expanded, setExpanded] = useState(false); 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 () => { const fetchConfig = useCallback(async () => {
try { try {
const res = await fetch("/api/settings/notion"); const res = await fetch("/api/settings/notion");
@@ -37,7 +49,10 @@ export default function NotionSourceCard() {
const handleSaveToken = async () => { const handleSaveToken = async () => {
if (!token.trim()) { 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; return;
} }
setBusy(true); setBusy(true);
@@ -53,11 +68,20 @@ export default function NotionSourceCard() {
setConnected(true); setConnected(true);
setMessage({ type: "success", text: data.message }); setMessage({ type: "success", text: data.message });
} else { } else {
setMessage({ type: "error", text: data.error ?? "Failed to connect" }); setMessage({
type: "error",
text: data.error ?? translateOrFallback("notionConnectFailed", "Failed to connect"),
});
setConnected(false); setConnected(false);
} }
} catch (err) { } 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 { } finally {
setBusy(false); setBusy(false);
} }
@@ -74,10 +98,19 @@ export default function NotionSourceCard() {
setToken(""); setToken("");
setMessage({ type: "success", text: data.message }); setMessage({ type: "success", text: data.message });
} else { } else {
setMessage({ type: "error", text: data.error ?? "Failed to disconnect" }); setMessage({
type: "error",
text: data.error ?? translateOrFallback("notionDisconnectFailed", "Failed to disconnect"),
});
} }
} catch (err) { } 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 { } finally {
setBusy(false); setBusy(false);
} }
@@ -97,11 +130,16 @@ export default function NotionSourceCard() {
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm">Notion</span> <span className="font-semibold text-sm">Notion</span>
<Badge variant={connected ? "success" : "default"}> <Badge variant={connected ? "success" : "default"}>
{connected ? "Connected" : "Not connected"} {connected
? translateOrFallback("notionConnected", "Connected")
: translateOrFallback("notionNotConnected", "Not connected")}
</Badge> </Badge>
</div> </div>
<p className="text-xs text-text-muted mt-0.5"> <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> </p>
</div> </div>
<span <span
@@ -131,7 +169,10 @@ export default function NotionSourceCard() {
{!connected ? ( {!connected ? (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-xs text-text-muted font-medium"> <label className="text-xs text-text-muted font-medium">
Notion Internal Integration Token {translateOrFallback(
"notionIntegrationToken",
"Notion Internal Integration Token"
)}
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
@@ -143,11 +184,14 @@ export default function NotionSourceCard() {
className="font-mono text-sm flex-1" className="font-mono text-sm flex-1"
/> />
<Button onClick={handleSaveToken} loading={busy} variant="primary" size="sm"> <Button onClick={handleSaveToken} loading={busy} variant="primary" size="sm">
Connect {translateOrFallback("notionConnect", "Connect")}
</Button> </Button>
</div> </div>
<p className="text-[10px] text-text-muted"> <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"> <code className="text-primary font-mono bg-surface/80 px-1 rounded">
https://www.notion.so/profile/integrations https://www.notion.so/profile/integrations
</code> </code>
@@ -156,7 +200,10 @@ export default function NotionSourceCard() {
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-text-muted flex-1"> <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> </span>
<Button <Button
onClick={handleDisconnect} onClick={handleDisconnect}
@@ -165,7 +212,7 @@ export default function NotionSourceCard() {
size="sm" size="sm"
className="border-red-500/30! text-red-400! hover:bg-red-500/10!" className="border-red-500/30! text-red-400! hover:bg-red-500/10!"
> >
Disconnect {translateOrFallback("notionDisconnect", "Disconnect")}
</Button> </Button>
</div> </div>
)} )}

View File

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

View File

@@ -22,8 +22,10 @@
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { formatResetCountdown } from "@/shared/utils/formatting"; import { formatResetCountdown } from "@/shared/utils/formatting";
import type { ConnectionRowConnection } from "./ConnectionRow"; import type { ConnectionRowConnection } from "./ConnectionRow";
import { providerText } from "../providerPageHelpers";
export interface CoolingConnectionsPanelProps { export interface CoolingConnectionsPanelProps {
readonly connections: readonly ConnectionRowConnection[]; readonly connections: readonly ConnectionRowConnection[];
@@ -37,6 +39,7 @@ function isCoolingNow(connection: ConnectionRowConnection, now: number): boolean
export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelProps) { export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelProps) {
const { connections } = props; const { connections } = props;
const t = useTranslations("providers");
// Tick once per second so the human-readable countdown updates. // Tick once per second so the human-readable countdown updates.
const [now, setNow] = useState<number>(() => Date.now()); const [now, setNow] = useState<number>(() => Date.now());
useEffect(() => { 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" 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"> <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> </h3>
</div> </div>
<p className="mb-3 text-xs text-muted-foreground"> <p className="mb-3 text-xs text-muted-foreground">
These connections returned a 429 (rate-limit) on their last request. OmniRoute will skip {providerText(
them until the timer expires no manual disable required. 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> </p>
<ul className="space-y-1"> <ul className="space-y-1">
{cooling.map((c) => { {cooling.map((c) => {
@@ -72,7 +80,9 @@ export default function CoolingConnectionsPanel(props: CoolingConnectionsPanelPr
c.displayName || c.displayName ||
c.name || c.name ||
c.email || 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 ( return (
<li <li
key={c.id ?? label} key={c.id ?? label}

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import {
UPSTREAM_HEADERS_UI_MAX, UPSTREAM_HEADERS_UI_MAX,
headerRowsToRecord, headerRowsToRecord,
compatProtocolLabelKey, compatProtocolLabelKey,
providerText,
type HeaderDraftRow, type HeaderDraftRow,
} from "../providerPageHelpers"; } from "../providerPageHelpers";
@@ -353,7 +354,7 @@ export default function ModelCompatPopover({
{/* Param filters — model-level block/allow (#6625) */} {/* Param filters — model-level block/allow (#6625) */}
<div className="mt-4 space-y-2.5"> <div className="mt-4 space-y-2.5">
<label className="block text-[11px] font-semibold text-text-main"> <label className="block text-[11px] font-semibold text-text-main">
{t("compatParamFiltersLabel") ?? "Param Filters"} {providerText(t, "compatParamFiltersLabel", "Param Filters")}
</label> </label>
<div> <div>
<input <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" 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"> <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")}`} {paramSaving && `${t("compatSaving")}`}
</p> </p>
</div> </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" 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"> <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> </p>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,8 @@
*/ */
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslations } from "next-intl";
import { providerText } from "../providerPageHelpers";
export interface ConnectionDeleteConfirmTarget { export interface ConnectionDeleteConfirmTarget {
id: string; id: string;
@@ -34,6 +36,7 @@ export function useConnectionDeleteConfirm(
fetchConnections: () => Promise<void>, fetchConnections: () => Promise<void>,
notify: NotifyLike notify: NotifyLike
): ConnectionDeleteConfirmState { ): ConnectionDeleteConfirmState {
const t = useTranslations("providers");
const [connection, setConnection] = useState<ConnectionDeleteConfirmTarget | null>(null); const [connection, setConnection] = useState<ConnectionDeleteConfirmTarget | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@@ -56,24 +59,24 @@ export function useConnectionDeleteConfirm(
try { try {
const res = await fetch(`/api/providers/${connectionId}`, { method: "DELETE" }); const res = await fetch(`/api/providers/${connectionId}`, { method: "DELETE" });
if (res.ok) { if (res.ok) {
notify.success("Connection deleted"); notify.success(providerText(t, "connectionDeleted", "Connection deleted"));
await fetchConnections(); await fetchConnections();
} else { } else {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
const message = const message =
(typeof data?.error === "string" && data.error) || (typeof data?.error === "string" && data.error) ||
data?.error?.message || data?.error?.message ||
"Failed to delete connection"; providerText(t, "failedDeleteConnection", "Failed to delete connection");
notify.error(message); notify.error(message);
} }
} catch (error) { } catch (error) {
console.error("Error deleting connection:", error); console.error("Error deleting connection:", error);
notify.error("Failed to delete connection"); notify.error(providerText(t, "failedDeleteConnection", "Failed to delete connection"));
} finally { } finally {
setDeleting(false); setDeleting(false);
setConnection(null); setConnection(null);
} }
}, [connection, fetchConnections, notify]); }, [connection, fetchConnections, notify, t]);
return { connection, deleting, request, confirm, cancel }; 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 // extractApiErrorMessage coerces any object-shaped `error` (e.g. a Zod
// format object) to a string so notify.error never hands the toast a // format object) to a string so notify.error never hands the toast a
// non-string child (React #31 → frozen page). // 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" })); setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
} }
} catch (err) { } catch (err) {
notify.error("Network error testing model"); notify.error(providerText(t, "modelTestNetworkError", "Network error testing model"));
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" })); setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
} finally { } finally {
setTestingModelId(null); setTestingModelId(null);

View File

@@ -27,7 +27,7 @@ import { useNotificationStore } from "@/store/notificationStore";
import { isClaudeCodeCompatibleProvider } from "@/shared/constants/providers"; import { isClaudeCodeCompatibleProvider } from "@/shared/constants/providers";
import type { ConnectionRowConnection } from "../components/ConnectionRow"; import type { ConnectionRowConnection } from "../components/ConnectionRow";
import { connectionBelongsToProviderPage } from "../../providerPageUtils"; import { connectionBelongsToProviderPage } from "../../providerPageUtils";
import { normalizeCodexLimitPolicy } from "../providerPageHelpers"; import { normalizeCodexLimitPolicy, providerText } from "../providerPageHelpers";
import { useProviderQuotaVisibility } from "./useProviderQuotaVisibility"; import { useProviderQuotaVisibility } from "./useProviderQuotaVisibility";
import { useReorderByAvailability } from "./useReorderByAvailability"; import { useReorderByAvailability } from "./useReorderByAvailability";
import { import {
@@ -378,7 +378,14 @@ export function useProviderConnections(
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); 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; return;
} }
@@ -408,12 +415,26 @@ export function useProviderConnections(
); );
notify.success( notify.success(
enabled enabled
? "Claude extra-usage blocking enabled (extra usage will be blocked)" ? providerText(
: "Claude extra-usage blocking disabled (extra usage is allowed)" 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) { } catch (error) {
console.error("Error toggling Claude extra-usage policy:", 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) { if (!res.ok) {
const data = await res.json().catch(() => ({})); 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; return;
} }
@@ -464,10 +488,12 @@ export function useProviderConnections(
: connection : connection
) )
); );
notify.success("Codex limit policy updated"); notify.success(providerText(t, "codexLimitPolicyUpdated", "Codex limit policy updated"));
} catch (error) { } catch (error) {
console.error("Error toggling Codex quota policy:", 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) { if (!res.ok) {
const data = await res.json().catch(() => ({})); 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; return;
} }
setCpaProviderEnabled(enabled); setCpaProviderEnabled(enabled);
notify.success( notify.success(
enabled enabled
? "Requests now route through CLIProxyAPI (deeper emulation)" ? providerText(
: "Requests now use native OmniRoute (direct)" t,
"cliproxyRoutingEnabled",
"Requests now route through CLIProxyAPI (deeper emulation)"
)
: providerText(t, "cliproxyRoutingDisabled", "Requests now use native OmniRoute (direct)")
); );
} catch { } 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(); if (onAfter) await onAfter();
} else { } else {
const data = await res.json(); const data = await res.json();
notify.error(data.error || "Batch delete failed"); notify.error(data.error || providerText(t, "batchDeleteFailed", "Batch delete failed"));
} }
} catch { } catch {
notify.error("Network error during batch delete"); notify.error(providerText(t, "batchDeleteNetworkError", "Network error during batch delete"));
} finally { } finally {
setBatchDeleting(false); setBatchDeleting(false);
} }
@@ -689,7 +724,11 @@ export function useProviderConnections(
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); 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(); const data = await res.json();
updated += data.updated ?? 0; updated += data.updated ?? 0;
@@ -710,7 +749,10 @@ export function useProviderConnections(
); );
} }
} catch (error: any) { } 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 { } finally {
setBatchUpdating(null); setBatchUpdating(null);
} }
@@ -805,7 +847,13 @@ export function useProviderConnections(
const proxiesData = await proxiesRes.json(); const proxiesData = await proxiesRes.json();
const savedProxies = (proxiesData?.items || []).filter((p: any) => p.status === "active"); const savedProxies = (proxiesData?.items || []).filter((p: any) => p.status === "active");
if (savedProxies.length === 0) { 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; return;
} }
@@ -856,11 +904,16 @@ export function useProviderConnections(
await fetchConnections(); await fetchConnections();
const tagLabel = tagFilter ? `"${tagFilter}" ` : ""; const tagLabel = tagFilter ? `"${tagFilter}" ` : "";
notify.success( 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) { } catch (err) {
console.error("Error distributing proxies:", err); console.error("Error distributing proxies:", err);
notify.error("Failed to distribute proxies."); notify.error(providerText(t, "failedDistributeProxies", "Failed to distribute proxies."));
} finally { } finally {
setDistributingProxies(false); setDistributingProxies(false);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,27 @@
"use client"; "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({ export default function ProvidersError({
error: _error, error: _error,
reset, reset,
@@ -7,6 +29,8 @@ export default function ProvidersError({
error: Error & { digest?: string }; error: Error & { digest?: string };
reset: () => void; reset: () => void;
}) { }) {
const t = useTranslations("providers");
return ( return (
<div <div
className="flex flex-col items-center justify-center min-h-[400px]" 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"> <div className="text-center space-y-4">
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400"> <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> </h2>
<p className="text-text-muted max-w-md"> <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> </p>
{_error?.digest && ( {_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 && ( {process.env.NODE_ENV === "development" && _error?.message && (
<p className="text-xs text-red-600 dark:text-red-400 font-mono">{_error.message}</p> <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} 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" 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> </button>
</div> </div>
</div> </div>

View File

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

View File

@@ -134,22 +134,28 @@ type ProviderBatchTestResults = {
error?: string | { message?: string }; error?: string | { message?: string };
}; };
function getConnectionErrorTag(connection) { function getConnectionErrorTag(connection, t: ProviderMessageTranslator) {
if (!connection) return null; if (!connection) return null;
const explicitType = connection.lastErrorType; const explicitType = connection.lastErrorType;
if (explicitType === "runtime_error") return "Runtime"; if (explicitType === "runtime_error") return providerText(t, "errorTypeRuntime", "Runtime");
if ( if (
explicitType === "upstream_auth_error" || explicitType === "upstream_auth_error" ||
explicitType === "auth_missing" || explicitType === "auth_missing" ||
explicitType === "token_refresh_failed" || explicitType === "token_refresh_failed" ||
explicitType === "token_expired" 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); const numericCode = Number(connection.errorCode);
if (Number.isFinite(numericCode) && numericCode >= 400) { if (Number.isFinite(numericCode) && numericCode >= 400) {
@@ -157,19 +163,21 @@ function getConnectionErrorTag(connection) {
} }
const fromMessage = getErrorCode(connection.lastError); 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; if (fromMessage && fromMessage !== "ERR") return fromMessage;
const msg = (connection.lastError || "").toLowerCase(); const msg = (connection.lastError || "").toLowerCase();
if (msg.includes("runtime") || msg.includes("not runnable") || msg.includes("not installed")) if (msg.includes("runtime") || msg.includes("not runnable") || msg.includes("not installed"))
return "Runtime"; return providerText(t, "errorTypeRuntime", "Runtime");
if ( if (
msg.includes("invalid api key") || msg.includes("invalid api key") ||
msg.includes("token invalid") || msg.includes("token invalid") ||
msg.includes("revoked") || msg.includes("revoked") ||
msg.includes("unauthorized") msg.includes("unauthorized")
) )
return "Auth"; return providerText(t, "errorTypeUpstreamAuth", "Auth");
return "ERR"; return "ERR";
} }
@@ -352,7 +360,7 @@ export default function ProvidersPage() {
(a: any, b: any) => (a: any, b: any) =>
(new Date(b.lastErrorAt || 0) as any) - (new Date(a.lastErrorAt || 0) as any) (new Date(b.lastErrorAt || 0) as any) - (new Date(a.lastErrorAt || 0) as any)
)[0]; )[0];
const errorCode = latestError ? getConnectionErrorTag(latestError) : null; const errorCode = latestError ? getConnectionErrorTag(latestError, t) : null;
const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null; const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null;
// Check expirations // Check expirations
@@ -822,11 +830,14 @@ export default function ProvidersPage() {
<span className="material-symbols-outlined text-[32px] text-primary">dns</span> <span className="material-symbols-outlined text-[32px] text-primary">dns</span>
</div> </div>
<h2 className="text-xl font-semibold text-text-main"> <h2 className="text-xl font-semibold text-text-main">
{t("addFirstProvider") || "Add your first provider"} {providerText(t, "addFirstProvider", "Add your first provider")}
</h2> </h2>
<p className="text-sm text-text-muted mt-2 max-w-md"> <p className="text-sm text-text-muted mt-2 max-w-md">
{t("addFirstProviderDesc") || {providerText(
"Connect an AI provider to start routing requests through OmniRoute. You can use free providers, API keys, or OAuth accounts."} t,
"addFirstProviderDesc",
"Connect an AI provider to start routing requests through OmniRoute. You can use free providers, API keys, or OAuth accounts."
)}
</p> </p>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2"> <div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<Button icon="add" onClick={() => router.push("/dashboard/providers/new")}> <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" 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> <span className="material-symbols-outlined text-[16px]">help</span>
{t("learnMore") || "Learn more"} {providerText(t, "learnMore", "Learn more")}
</a> </a>
</div> </div>
</div> </div>
@@ -1094,10 +1105,10 @@ export default function ProvidersPage() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2"> <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"> <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 <span
className="size-2.5 rounded-full bg-cyan-500" className="size-2.5 rounded-full bg-cyan-500"
title={t("ideProviders") || "IDE Providers"} title={providerText(t, "ideProviders", "IDE Providers")}
/> />
<ProviderCountBadge {...countConfigured(ideProviderEntriesAll)} /> <ProviderCountBadge {...countConfigured(ideProviderEntriesAll)} />
</h2> </h2>
@@ -1121,12 +1132,15 @@ export default function ProvidersPage() {
</button> </button>
</div> </div>
<p className="text-sm text-text-muted -mt-2"> <p className="text-sm text-text-muted -mt-2">
{t("ideProvidersDesc") || {providerText(
"Editors with built-in AI subscription. Use the provider page to import credentials directly from the IDE's keychain."} t,
"ideProvidersDesc",
"Editors with built-in AI subscription. Use the provider page to import credentials directly from the IDE's keychain."
)}
</p> </p>
{ideProviderEntries.length === 0 ? ( {ideProviderEntries.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-bg-subtle p-6 text-center text-sm text-text-muted"> <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>
) : ( ) : (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-4 gap-3"> <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"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useTranslations } from "next-intl";
import { Card, Toggle } from "@/shared/components"; import { Card, Toggle } from "@/shared/components";
import { useServiceStatus } from "../hooks/useServiceStatus"; 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"; const NAME = "cliproxy";
export function CliproxyProviderExposureCard() { export function CliproxyProviderExposureCard() {
const t = useTranslations("embeddedServices");
const { data, mutate } = useServiceStatus(NAME); const { data, mutate } = useServiceStatus(NAME);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
@@ -28,14 +50,30 @@ export function CliproxyProviderExposureCard() {
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
const errorMsg = 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 }); setMsg({ ok: false, text: errorMsg });
return; return;
} }
setMsg(null); setMsg(null);
mutate(); mutate();
} catch { } 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 { } finally {
setPending(false); setPending(false);
} }
@@ -48,10 +86,15 @@ export function CliproxyProviderExposureCard() {
<span className="material-symbols-outlined text-sky-500 text-xl">hub</span> <span className="material-symbols-outlined text-sky-500 text-xl">hub</span>
</div> </div>
<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"> <p className="text-xs text-text-muted">
Expose CLIProxyAPI models as a routing target under the{" "} {serviceText(
<code className="font-mono bg-bg-subtle px-1 rounded">cliproxyapi/</code> prefix. t,
"cliproxyProviderExposureDescription",
"Expose CLIProxyAPI models as a routing target under the cliproxyapi/ prefix."
)}
</p> </p>
</div> </div>
</div> </div>
@@ -74,16 +117,23 @@ export function CliproxyProviderExposureCard() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm"> <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> <code className="font-mono bg-bg-subtle px-1 rounded text-xs">cliproxyapi/...</code>
</p> </p>
<p className="text-xs text-text-muted mt-0.5"> <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> </p>
</div> </div>
<Toggle checked={data?.providerExpose ?? false} onChange={handleToggle} disabled={pending || !data} /> <Toggle
checked={data?.providerExpose ?? false}
onChange={handleToggle}
disabled={pending || !data}
/>
</div> </div>
</Card> </Card>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -369,7 +369,10 @@ export default function MemorySkillsTab() {
role="note" role="note"
data-testid="memory-token-cost-warning" 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 info
</span> </span>
<p className="text-xs leading-relaxed"> <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="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="p-4 rounded-lg bg-surface/30 border border-border/30"> <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 <input
value={qdrant.host} value={qdrant.host}
onChange={(e) => setQdrant((s) => ({ ...s, host: e.target.value }))} onChange={(e) => setQdrant((s) => ({ ...s, host: e.target.value }))}
@@ -567,7 +570,7 @@ export default function MemorySkillsTab() {
</div> </div>
<div className="p-4 rounded-lg bg-surface/30 border border-border/30"> <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 <input
value={qdrant.collection} value={qdrant.collection}
onChange={(e) => setQdrant((s) => ({ ...s, collection: e.target.value }))} onChange={(e) => setQdrant((s) => ({ ...s, collection: e.target.value }))}
@@ -781,9 +784,7 @@ export default function MemorySkillsTab() {
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold">{t("memorySkillsSkillsmpMarketplace")}</h3> <h3 className="text-lg font-semibold">{t("memorySkillsSkillsmpMarketplace")}</h3>
<p className="text-sm text-text-muted"> <p className="text-sm text-text-muted">{t("memorySkillsSkillsmpDescription")}</p>
Connect to SkillsMP to discover and install skills from the marketplace.
</p>
</div> </div>
{skillsmpStatus === "saved" && ( {skillsmpStatus === "saved" && (
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1"> <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} 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" 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> </button>
</div> </div>
<p className="text-xs text-text-muted mt-2"> <p className="text-xs text-text-muted mt-2">
Get your API key from <span className="text-violet-400">skillsmp.com</span>. Rate limit: {t("skillsmpApiKeyHintBefore")} <span className="text-violet-400">skillsmp.com</span>
500 requests/day. {t("skillsmpApiKeyHintAfter", { limit: 500 })}
</p> </p>
</div> </div>
</Card> </Card>
@@ -833,9 +834,7 @@ export default function MemorySkillsTab() {
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold">{t("memorySkillsActiveSkillsProvider")}</h3> <h3 className="text-lg font-semibold">{t("memorySkillsActiveSkillsProvider")}</h3>
<p className="text-sm text-text-muted"> <p className="text-sm text-text-muted">{t("memorySkillsActiveProviderDescription")}</p>
Choose which provider the Skills page uses for search and install.
</p>
</div> </div>
{skillsProviderStatus === "saved" && ( {skillsProviderStatus === "saved" && (
<span className="ml-auto text-xs font-medium text-emerald-500 flex items-center gap-1"> <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 <p
className={`text-sm font-medium ${skillsProvider === "skillsmp" ? "text-indigo-400" : ""}`} className={`text-sm font-medium ${skillsProvider === "skillsmp" ? "text-indigo-400" : ""}`}
> >
SkillsMP Marketplace {t("memorySkillsSkillsmpProviderTitle")}
</p> </p>
<p className="text-xs text-text-muted mt-0.5 leading-relaxed"> <p className="text-xs text-text-muted mt-0.5 leading-relaxed">
Authenticated marketplace (uses your SkillsMP API key). {t("memorySkillsSkillsmpProviderDescription")}
</p> </p>
</button> </button>
@@ -884,10 +883,10 @@ export default function MemorySkillsTab() {
<p <p
className={`text-sm font-medium ${skillsProvider === "skillssh" ? "text-indigo-400" : ""}`} className={`text-sm font-medium ${skillsProvider === "skillssh" ? "text-indigo-400" : ""}`}
> >
skills.sh Directory {t("memorySkillsSkillsshProviderTitle")}
</p> </p>
<p className="text-xs text-text-muted mt-0.5 leading-relaxed"> <p className="text-xs text-text-muted mt-0.5 leading-relaxed">
Public directory provider (no API key required). {t("memorySkillsSkillsshProviderDescription")}
</p> </p>
</button> </button>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -702,7 +702,7 @@ function ComboCooldownWaitCard({
setDraft(value); setDraft(value);
}, [value]); }, [value]);
const title = t("resilienceComboCooldownWaitTitle") || "Combo cooldown wait"; const title = t("resilienceComboCooldownWaitTitle");
const desc = const desc =
t("resilienceComboCooldownWaitDesc") || 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."; "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 ? ( {editing ? (
<> <>
<BooleanField <BooleanField
label={t("resilienceEnableServerWait") || "Enabled"} label={t("resilienceEnableServerWait")}
description={ description={
t("resilienceComboCooldownWaitToggleDesc") || t("resilienceComboCooldownWaitToggleDesc") ||
"All combo strategies; never waits on quota_exhausted." "All combo strategies; never waits on quota_exhausted."
@@ -744,20 +744,20 @@ function ComboCooldownWaitCard({
onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))} onChange={(enabled) => setDraft((prev) => ({ ...prev, enabled }))}
/> />
<NumberField <NumberField
label={t("resilienceComboCooldownMaxWaitMs") || "Max wait per attempt"} label={t("resilienceComboCooldownMaxWaitMs")}
value={draft.maxWaitMs} value={draft.maxWaitMs}
min={0} min={0}
suffix="ms" suffix="ms"
onChange={(maxWaitMs) => setDraft((prev) => ({ ...prev, maxWaitMs }))} onChange={(maxWaitMs) => setDraft((prev) => ({ ...prev, maxWaitMs }))}
/> />
<NumberField <NumberField
label={t("resilienceMaxAttempts") || "Max attempts"} label={t("resilienceMaxAttempts")}
value={draft.maxAttempts} value={draft.maxAttempts}
min={0} min={0}
onChange={(maxAttempts) => setDraft((prev) => ({ ...prev, maxAttempts }))} onChange={(maxAttempts) => setDraft((prev) => ({ ...prev, maxAttempts }))}
/> />
<NumberField <NumberField
label={t("resilienceComboCooldownBudgetMs") || "Total wait budget"} label={t("resilienceComboCooldownBudgetMs")}
value={draft.budgetMs} value={draft.budgetMs}
min={0} min={0}
suffix="ms" suffix="ms"
@@ -767,31 +767,23 @@ function ComboCooldownWaitCard({
) : ( ) : (
<> <>
<div className="rounded-xl border border-border bg-bg-subtle p-4"> <div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted"> <div className="text-xs text-text-muted">{t("resilienceEnableServerWait")}</div>
{t("resilienceEnableServerWait") || "Enabled"}
</div>
<div className="mt-1 text-sm font-semibold text-text-main"> <div className="mt-1 text-sm font-semibold text-text-main">
{value.enabled ? t("statusEnabled") : t("statusDisabled")} {value.enabled ? t("statusEnabled") : t("statusDisabled")}
</div> </div>
</div> </div>
<div className="rounded-xl border border-border bg-bg-subtle p-4"> <div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted"> <div className="text-xs text-text-muted">{t("resilienceComboCooldownMaxWaitMs")}</div>
{t("resilienceComboCooldownMaxWaitMs") || "Max wait per attempt"}
</div>
<div className="mt-1 text-sm font-semibold text-text-main"> <div className="mt-1 text-sm font-semibold text-text-main">
{formatMs(value.maxWaitMs)} {formatMs(value.maxWaitMs)}
</div> </div>
</div> </div>
<div className="rounded-xl border border-border bg-bg-subtle p-4"> <div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted"> <div className="text-xs text-text-muted">{t("resilienceMaxAttempts")}</div>
{t("resilienceMaxAttempts") || "Max attempts"}
</div>
<div className="mt-1 text-sm font-semibold text-text-main">{value.maxAttempts}</div> <div className="mt-1 text-sm font-semibold text-text-main">{value.maxAttempts}</div>
</div> </div>
<div className="rounded-xl border border-border bg-bg-subtle p-4"> <div className="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted"> <div className="text-xs text-text-muted">{t("resilienceComboCooldownBudgetMs")}</div>
{t("resilienceComboCooldownBudgetMs") || "Total wait budget"}
</div>
<div className="mt-1 text-sm font-semibold text-text-main"> <div className="mt-1 text-sm font-semibold text-text-main">
{formatMs(value.budgetMs)} {formatMs(value.budgetMs)}
</div> </div>
@@ -820,8 +812,7 @@ function QuotaShareConcurrencyLimitCard({
setDraft(value); setDraft(value);
}, [value]); }, [value]);
const title = const title = t("resilienceQuotaShareConcurrencyTitle");
t("resilienceQuotaShareConcurrencyTitle") || "Quota-share per-connection concurrency";
const desc = const desc =
t("resilienceQuotaShareConcurrencyDesc") || 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."; "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"> <div className="grid grid-cols-1 gap-3">
{editing ? ( {editing ? (
<BooleanField <BooleanField
label={t("resilienceEnableServerWait") || "Enabled"} label={t("resilienceEnableServerWait")}
description={ description={
t("resilienceQuotaShareConcurrencyToggleDesc") || t("resilienceQuotaShareConcurrencyToggleDesc") ||
"Quota-share combos only; honors each connection's Max Concurrent cap." "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="rounded-xl border border-border bg-bg-subtle p-4">
<div className="text-xs text-text-muted"> <div className="text-xs text-text-muted">{t("resilienceEnableServerWait")}</div>
{t("resilienceEnableServerWait") || "Enabled"}
</div>
<div className="mt-1 text-sm font-semibold text-text-main"> <div className="mt-1 text-sm font-semibold text-text-main">
{value.enabled ? t("statusEnabled") : t("statusDisabled")} {value.enabled ? t("statusEnabled") : t("statusDisabled")}
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
export default function SettingsError({ export default function SettingsError({
error: _error, error: _error,
reset, reset,
@@ -7,6 +9,8 @@ export default function SettingsError({
error: Error & { digest?: string }; error: Error & { digest?: string };
reset: () => void; reset: () => void;
}) { }) {
const t = useTranslations("settings");
return ( return (
<div <div
className="flex flex-col items-center justify-center min-h-[400px]" 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"> <div className="text-center space-y-4">
<h2 className="text-xl font-semibold text-red-600 dark:text-red-400"> <h2 className="text-xl font-semibold text-red-600 dark:text-red-400">
Failed to load settings {t("errorPage.title")}
</h2> </h2>
<p className="text-text-muted max-w-md"> <p className="text-text-muted max-w-md">{t("errorPage.description")}</p>
We could not load settings right now. Please retry in a few seconds.
</p>
{_error?.digest && ( {_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 && ( {process.env.NODE_ENV === "development" && _error?.message && (
<p className="text-xs text-red-600 dark:text-red-400 font-mono">{_error.message}</p> <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} 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" 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> </button>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,13 @@
import { getTranslations } from "next-intl/server";
import { TrafficInspectorPageClient } from "./TrafficInspectorPageClient"; import { TrafficInspectorPageClient } from "./TrafficInspectorPageClient";
export const metadata = { export async function generateMetadata() {
title: "Traffic Inspector — OmniRoute", const t = await getTranslations("metadata");
description: "Monitor LLM calls + debug any application's HTTPS traffic", return {
}; title: t("trafficInspectorTitle"),
description: t("trafficInspectorDescription"),
};
}
export default function TrafficInspectorPage() { export default function TrafficInspectorPage() {
return <TrafficInspectorPageClient />; 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. */ /** 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", id: "1",
name: "Client Request", translationKey: "pipelineStepClientRequest",
description: "Request received in client format",
format: "claude", format: "claude",
content: content:
'{\n "model": "claude-sonnet-4-20250514",\n "messages": [\n { "role": "user", "content": "Hello!" }\n ]\n}', '{\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", id: "2",
name: "Format Detected", translationKey: "pipelineStepFormatDetected",
description: "Auto-detected source format",
format: "claude", format: "claude",
content: '{\n "detectedFormat": "claude",\n "confidence": "high"\n}', content: '{\n "detectedFormat": "claude",\n "confidence": "high"\n}',
status: "done", status: "done",
}, },
{ {
id: "3", id: "3",
name: "OpenAI Intermediate", translationKey: "pipelineStepOpenAIIntermediate",
description: "Translated to OpenAI hub format",
format: "openai", format: "openai",
content: content:
'{\n "model": "claude-sonnet-4-20250514",\n "messages": [\n { "role": "user", "content": "Hello!" }\n ],\n "stream": true\n}', '{\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", id: "4",
name: "Provider Format", translationKey: "pipelineStepProviderFormat",
description: "Translated to provider target format",
format: "gemini", format: "gemini",
content: content:
'{\n "model": "gemini-2.5-flash",\n "contents": [\n { "role": "user", "parts": [{ "text": "Hello!" }] }\n ]\n}', '{\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", id: "5",
name: "Provider Response", translationKey: "pipelineStepProviderResponse",
description: "Streaming response from provider",
format: "openai", format: "openai",
content: content:
'data: {"choices":[{"delta":{"content":"Hello! How can I help you today?"}}]}\ndata: [DONE]', 'data: {"choices":[{"delta":{"content":"Hello! How can I help you today?"}}]}\ndata: [DONE]',
@@ -151,7 +148,13 @@ export default function PipelineView({
[onOpenChange] [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 => { const tr = (key: string, fallback: string): string => {
try { try {

View File

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

View File

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

View File

@@ -1,21 +1,24 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold"; import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function ForbiddenStatusPage() { export default function ForbiddenStatusPage() {
const t = useTranslations("publicSystem");
return ( return (
<ErrorPageScaffold <ErrorPageScaffold
code="403" code="403"
icon="gpp_bad" icon="gpp_bad"
title="Forbidden" title={t("statusPages.403.title")}
description="Your request was understood, but access is denied by policy." description={t("statusPages.403.description")}
suggestions={[ suggestions={[
"Check IP allowlist/blocklist rules in settings.", t("statusPages.403.suggestion1"),
"Verify model and budget policies assigned to your API key.", t("statusPages.403.suggestion2"),
"Ask an administrator to grant the required permission scope.", t("statusPages.403.suggestion3"),
]} ]}
primaryAction={{ href: "/forbidden", label: "Open Access Help" }} primaryAction={{ href: "/forbidden", label: t("statusPages.403.primaryAction") }}
secondaryAction={{ secondaryAction={{
href: "/dashboard/settings?tab=security", 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 ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function RequestTimeoutPage() { export default function RequestTimeoutPage() {
const t = useTranslations("publicSystem");
return ( return (
<ErrorPageScaffold <ErrorPageScaffold
code="408" code="408"
icon="timer_off" icon="timer_off"
title="Request Timeout" title={t("statusPages.408.title")}
description="The server did not receive a complete request in time." description={t("statusPages.408.description")}
suggestions={[ suggestions={[
"Retry the request with a smaller payload.", t("statusPages.408.suggestion1"),
"Check your network stability and VPN/proxy latency.", t("statusPages.408.suggestion2"),
"For long operations, enable streaming or split the request.", t("statusPages.408.suggestion3"),
]} ]}
primaryAction={{ href: "/dashboard/endpoint", label: "Open Endpoint Guide" }} primaryAction={{ href: "/dashboard/endpoint", label: t("statusPages.408.primaryAction") }}
secondaryAction={{ href: "/status", label: "Check Network Status" }} secondaryAction={{ href: "/status", label: t("statusPages.408.secondaryAction") }}
/> />
); );
} }

View File

@@ -1,22 +1,25 @@
import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold"; import ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function TooManyRequestsPage() { export default function TooManyRequestsPage() {
const t = useTranslations("publicSystem");
return ( return (
<ErrorPageScaffold <ErrorPageScaffold
code="429" code="429"
icon="hourglass_top" icon="hourglass_top"
title="Too Many Requests" title={t("statusPages.429.title")}
description="Rate limits were exceeded for this client, key, or provider." description={t("statusPages.429.description")}
suggestions={[ suggestions={[
"Wait for cooldown and retry after the suggested interval.", t("statusPages.429.suggestion1"),
"Switch to a combo with fallback providers.", t("statusPages.429.suggestion2"),
"Tune provider resilience/rate-limit profiles in settings.", t("statusPages.429.suggestion3"),
]} ]}
primaryAction={{ primaryAction={{
href: "/dashboard/settings?tab=resilience", 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 ErrorPageScaffold from "@/shared/components/ErrorPageScaffold";
import { useTranslations } from "next-intl";
export default function InternalServerErrorPage() { export default function InternalServerErrorPage() {
const t = useTranslations("publicSystem");
return ( return (
<ErrorPageScaffold <ErrorPageScaffold
code="500" code="500"
icon="warning" icon="warning"
title="Internal Server Error" title={t("statusPages.500.title")}
description="An unexpected server-side error occurred while processing your request." description={t("statusPages.500.description")}
suggestions={[ suggestions={[
"Retry once in a few seconds.", t("statusPages.500.suggestion1"),
"Check health telemetry and server logs for correlated request IDs.", t("statusPages.500.suggestion2"),
"If persistent, report the issue with timestamp and request context.", t("statusPages.500.suggestion3"),
]} ]}
primaryAction={{ href: "/dashboard/health", label: "Open Health Dashboard" }} primaryAction={{ href: "/dashboard/health", label: t("statusPages.500.primaryAction") }}
secondaryAction={{ href: "/dashboard/logs", label: "Open Logs" }} secondaryAction={{ href: "/dashboard/logs", label: t("statusPages.500.secondaryAction") }}
/> />
); );
} }

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getTranslations } from "next-intl/server";
import { createProviderConnection } from "@/models"; import { createProviderConnection } from "@/models";
import { parseTraeCallbackQuery } from "./parseCallback"; import { parseTraeCallbackQuery } from "./parseCallback";
@@ -29,7 +30,7 @@ import { parseTraeCallbackQuery } from "./parseCallback";
* authorize URL; Trae echoes it back as `loginTraceID`. The modal verifies * authorize URL; Trae echoes it back as `loginTraceID`. The modal verifies
* the echoed state before trusting the postMessage. * 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 // 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 // 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, // 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", type: "trae-oauth-callback",
...message, ...message,
}).replace(/</g, "\\u003c"); }).replace(/</g, "\\u003c");
const title = message.success ? t("traeAuthorizationSuccess") : t("traeAuthorizationFailed");
const body = message.success ? t("closeAuthorizationWindow") : t("returnToDashboard");
return new NextResponse( return new NextResponse(
`<!doctype html><html><body style="font:16px sans-serif;padding:40px"> `<!doctype html><html><body style="font:16px sans-serif;padding:40px">
<h2 style="margin:0 0 8px">Trae authorization ${message.success ? "✓" : "failed"}</h2> <h2 style="margin:0 0 8px">${title}</h2>
<p>${message.success ? "You can close this window." : "Return to the dashboard."}</p> <p>${body}</p>
<script> <script>
(function () { (function () {
try { try {
@@ -64,21 +67,25 @@ function htmlClose(message: Record<string, unknown>): NextResponse {
} }
export async function GET(request: Request) { export async function GET(request: Request) {
const t = await getTranslations("auth");
const url = new URL(request.url); const url = new URL(request.url);
const q = url.searchParams; const q = url.searchParams;
const parsed = parseTraeCallbackQuery(q); const parsed = parseTraeCallbackQuery(q);
if (!parsed.ok) { if (!parsed.ok) {
return htmlClose({ success: false, error: parsed.error }); return htmlClose({ success: false, error: parsed.error }, t);
} }
try { try {
const connection: any = await createProviderConnection(parsed.record); const connection: any = await createProviderConnection(parsed.record);
return htmlClose({ return htmlClose(
success: true, {
connectionId: connection.id, success: true,
loginTraceId: q.get("loginTraceID") || null, connectionId: connection.id,
}); loginTraceId: q.get("loginTraceID") || null,
},
t
);
} catch (err: any) { } catch (err: any) {
console.error("[trae callback] error:", err); 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"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import Button from "@/shared/components/Button"; import Button from "@/shared/components/Button";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { import {
@@ -17,6 +18,8 @@ type Status = "validating" | "ready" | "starting" | "awaiting" | "saving" | "suc
* tokens back to the ticket-gated completion endpoint for persistence. * tokens back to the ticket-gated completion endpoint for persistence.
*/ */
export default function CodexConnectClient({ token }: { token: string }) { export default function CodexConnectClient({ token }: { token: string }) {
const t = useTranslations("auth");
const tc = useTranslations("common");
const [status, setStatus] = useState<Status>("validating"); const [status, setStatus] = useState<Status>("validating");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [userCode, setUserCode] = useState<CodexUserCode | null>(null); const [userCode, setUserCode] = useState<CodexUserCode | null>(null);
@@ -34,12 +37,12 @@ export default function CodexConnectClient({ token }: { token: string }) {
setStatus("ready"); setStatus("ready");
} else { } else {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
setError(data?.error || "This link is invalid or expired."); setError(data?.error || t("codexLinkInvalidOrExpired"));
setStatus("error"); setStatus("error");
} }
} catch { } catch {
if (!cancelled) { if (!cancelled) {
setError("Could not reach the server to validate this link."); setError(t("codexValidationServerError"));
setStatus("error"); setStatus("error");
} }
} }
@@ -47,7 +50,7 @@ export default function CodexConnectClient({ token }: { token: string }) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [token]); }, [token, t]);
// Abort any in-flight device flow if the visitor leaves. // Abort any in-flight device flow if the visitor leaves.
useEffect(() => () => abortRef.current?.abort(), []); useEffect(() => () => abortRef.current?.abort(), []);
@@ -79,26 +82,26 @@ export default function CodexConnectClient({ token }: { token: string }) {
if (res.ok && data?.success) { if (res.ok && data?.success) {
setStatus("success"); setStatus("success");
} else { } else {
setError(data?.error || "Could not save the connection. The link may have expired."); setError(data?.error || t("codexSaveConnectionError"));
setStatus("error"); setStatus("error");
} }
} catch (err) { } catch (err) {
if (err instanceof CodexDeviceFlowError) { if (err instanceof CodexDeviceFlowError) {
setError( setError(
err.code === "device_disabled" 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" : err.code === "timeout"
? "Authorization timed out. Click Start again to retry." ? t("codexAuthorizationTimedOut")
: err.code === "aborted" : err.code === "aborted"
? "Authentication was cancelled." ? t("authenticationCancelled")
: err.message : err.message
); );
} else { } else {
setError("Unexpected error during authentication. Please try again."); setError(t("unexpectedAuthenticationError"));
} }
setStatus("error"); setStatus("error");
} }
}, [token]); }, [token, t]);
return ( return (
<div className="min-h-screen flex items-center justify-center bg-bg-base px-4 py-10"> <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"> <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> <span className="material-symbols-outlined text-[28px]">key</span>
</div> </div>
<h1 className="text-lg font-semibold text-text-main">Connect OpenAI Codex</h1> <h1 className="text-lg font-semibold text-text-main">{t("connectOpenAiCodexTitle")}</h1>
<p className="mt-1 text-sm text-text-muted"> <p className="mt-1 text-sm text-text-muted">{t("codexConnectDescription")}</p>
Authorize a ChatGPT account to finish setting up this connection.
</p>
</div> </div>
{status === "validating" && ( {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" && ( {status === "ready" && (
<div className="text-center"> <div className="text-center">
<p className="mb-4 text-sm text-text-muted"> <p className="mb-4 text-sm text-text-muted">{t("codexGenerateCodeDescription")}</p>
Click below to generate a one-time code, then sign in to OpenAI.
</p>
<Button onClick={start} icon="login" className="w-full"> <Button onClick={start} icon="login" className="w-full">
Start {t("startCodexFlow")}
</Button> </Button>
</div> </div>
)} )}
{status === "starting" && ( {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 && ( {status === "awaiting" && userCode && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-text-muted"> <p className="text-sm text-text-muted">{t("codexVerificationInstructions")}</p>
1. Open the OpenAI verification page and 2. enter this code. This page updates
automatically once you authorize.
</p>
<div className="rounded-lg border border-border bg-bg-base p-3"> <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"> <div className="flex items-center justify-between gap-2">
<code className="text-lg font-semibold tracking-widest text-text-main"> <code className="text-lg font-semibold tracking-widest text-text-main">
{userCode.userCode} {userCode.userCode}
@@ -151,7 +147,7 @@ export default function CodexConnectClient({ token }: { token: string }) {
icon="content_copy" icon="content_copy"
onClick={() => copy(userCode.userCode, "code")} onClick={() => copy(userCode.userCode, "code")}
> >
{copied === "code" ? "Copied" : "Copy"} {copied === "code" ? tc("copied") : tc("copy")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -162,23 +158,23 @@ export default function CodexConnectClient({ token }: { token: string }) {
icon="open_in_new" icon="open_in_new"
onClick={() => window.open(userCode.verificationUri, "_blank", "noopener")} onClick={() => window.open(userCode.verificationUri, "_blank", "noopener")}
> >
Open verification page {t("openVerificationPage")}
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
icon="link" icon="link"
onClick={() => copy(userCode.verificationUri, "url")} onClick={() => copy(userCode.verificationUri, "url")}
> >
{copied === "url" ? "Copied" : "Copy URL"} {copied === "url" ? tc("copied") : t("copyUrlShort")}
</Button> </Button>
</div> </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> </div>
)} )}
{status === "saving" && ( {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" && ( {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"> <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> <span className="material-symbols-outlined text-[26px]">check_circle</span>
</div> </div>
<p className="font-medium text-text-main">Connected!</p> <p className="font-medium text-text-main">{t("codexConnected")}</p>
<p className="mt-1 text-sm text-text-muted"> <p className="mt-1 text-sm text-text-muted">{t("codexConnectionRegistered")}</p>
The Codex account was registered. You can close this tab.
</p>
</div> </div>
)} )}
@@ -200,7 +194,7 @@ export default function CodexConnectClient({ token }: { token: string }) {
</div> </div>
<p className="mb-4 text-sm text-text-muted">{error}</p> <p className="mb-4 text-sm text-text-muted">{error}</p>
<Button variant="secondary" icon="refresh" onClick={start} className="w-full"> <Button variant="secondary" icon="refresh" onClick={start} className="w-full">
Try again {t("tryAgain")}
</Button> </Button>
</div> </div>
)} )}

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,11 @@
"use client"; "use client";
import React, { useState } from "react"; import React, { useState } from "react";
import { useTranslations } from "next-intl";
export function FeedbackWidget({ slug }: { slug: string }) { export function FeedbackWidget({ slug }: { slug: string }) {
const t = useTranslations("docs");
const tc = useTranslations("common");
const [feedback, setFeedback] = useState<"yes" | "no" | null>(null); const [feedback, setFeedback] = useState<"yes" | "no" | null>(null);
const [submitted, setSubmitted] = useState(false); 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"> <span className="material-symbols-outlined text-primary text-2xl block mb-1">
check_circle check_circle
</span> </span>
<p className="text-sm text-text-main">Thanks for your feedback!</p> <p className="text-sm text-text-main">{t("feedbackThanks")}</p>
</div> </div>
); );
} }
return ( return (
<div className="mt-8 p-4 bg-bg-subtle border border-border rounded-lg"> <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"> <div className="flex gap-3">
<button <button
onClick={() => handleFeedback("yes")} 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" 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> <span className="material-symbols-outlined text-sm">thumb_up</span>
Yes {tc("yes")}
</button> </button>
<button <button
onClick={() => handleFeedback("no")} 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" 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> <span className="material-symbols-outlined text-sm">thumb_down</span>
No {tc("no")}
</button> </button>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
"use client"; "use client";
import { useTranslations } from "next-intl";
/** /**
* Server Error Page — P-1 * Server Error Page — P-1
* *
@@ -13,6 +15,9 @@ interface ErrorProps {
} }
export default function Error({ error, reset }: ErrorProps) { export default function Error({ error, reset }: ErrorProps) {
const t = useTranslations("publicSystem");
const tc = useTranslations("common");
return ( return (
<div <div
className="flex flex-col items-center justify-center min-h-[60vh] p-6 text-center" 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> </div>
<h1 className="text-[28px] font-bold mb-2 text-[var(--color-text-main)]"> <h1 className="text-[28px] font-bold mb-2 text-[var(--color-text-main)]">
Internal Server Error {t("error.title")}
</h1> </h1>
<p className="text-[15px] text-[var(--color-text-muted)] max-w-[400px] leading-relaxed mb-2"> <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 {t("error.description")}
working on a fix.
</p> </p>
{error?.digest && ( {error?.digest && (
<p className="text-xs text-[var(--color-text-muted)] mb-6 font-mono"> <p className="text-xs text-[var(--color-text-muted)] mb-6 font-mono">
Error ID: {error.digest} {t("error.errorId", { id: error.digest })}
</p> </p>
)} )}
{process.env.NODE_ENV === "development" && error?.message && ( {process.env.NODE_ENV === "development" && error?.message && (
<pre <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" 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} {error.message}
</pre> </pre>
@@ -45,24 +49,24 @@ export default function Error({ error, reset }: ErrorProps) {
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={reset} 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)]" 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> </button>
<a <a
href="/dashboard" 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)]" 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>
<a <a
href="/status" 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)]" 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> </a>
</div> </div>
</div> </div>

View File

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

View File

@@ -1,5 +1,10 @@
"use client"; "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 * Global Error Page — FASE-04 Error Handling
* *
@@ -13,48 +18,104 @@ interface GlobalErrorProps {
reset: () => void; 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 ( return (
// lang="en" is intentional: global-error is a client-side root boundary that <main role="alert" aria-live="assertive" className="flex flex-col items-center">
// renders ABOVE the next-intl provider, so the active locale isn't reliably <div className="text-[64px] mb-4" aria-hidden="true">
// available here. Its visible text is static English, so lang="en" stays
// consistent with the content. User-facing locale is handled by the normal </div>
// layout (<html lang={locale}> in src/app/layout.tsx). <h1 className="text-[28px] font-bold mb-2">{t("globalError.title")}</h1>
<html lang="en"> <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"> <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"> <NextIntlClientProvider locale={locale} messages={messages}>
<div className="text-[64px] mb-4" aria-hidden="true"> <GlobalErrorContent error={error} reset={reset} />
</NextIntlClientProvider>
</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>
</body> </body>
</html> </html>
); );

View File

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

View File

@@ -1,17 +1,17 @@
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl";
export default function MaintenancePage() { export default function MaintenancePage() {
const t = useTranslations("publicSystem");
return ( return (
<main className="min-h-screen text-text-main flex items-center justify-center p-6"> <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"> <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"> <span className="material-symbols-outlined text-5xl text-primary mb-3" aria-hidden="true">
construction construction
</span> </span>
<h1 className="text-2xl font-semibold">Scheduled Maintenance</h1> <h1 className="text-2xl font-semibold">{t("maintenance.title")}</h1>
<p className="mt-3 text-text-muted leading-relaxed"> <p className="mt-3 text-text-muted leading-relaxed">{t("maintenance.description")}</p>
Some services are temporarily unavailable while maintenance is in progress. Core routing
usually remains online, but management features may be degraded.
</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"> <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"> <li className="flex items-start gap-2">
@@ -21,7 +21,7 @@ export default function MaintenancePage() {
> >
info info
</span> </span>
Retry after a few minutes. {t("maintenance.suggestion1")}
</li> </li>
<li className="flex items-start gap-2"> <li className="flex items-start gap-2">
<span <span
@@ -30,7 +30,7 @@ export default function MaintenancePage() {
> >
info info
</span> </span>
Check current health indicators and provider status before retrying. {t("maintenance.suggestion2")}
</li> </li>
</ul> </ul>
@@ -39,13 +39,13 @@ export default function MaintenancePage() {
href="/status" 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" 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>
<Link <Link
href="/dashboard/health" 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" 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> </Link>
</div> </div>
</section> </section>

View File

@@ -1,8 +1,12 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl";
export default function NotFound() { export default function NotFound() {
const t = useTranslations("publicSystem");
const tc = useTranslations("common");
return ( return (
<div <div
className="flex flex-col items-center justify-center min-h-screen p-6 bg-bg text-text-main text-center" 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 404
</div> </div>
<h1 id="not-found-title" className="text-2xl font-semibold mb-2"> <h1 id="not-found-title" className="text-2xl font-semibold mb-2">
Page not found {t("notFound.title")}
</h1> </h1>
<p className="text-[15px] text-text-muted max-w-[400px] leading-relaxed mb-8"> <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> </p>
<div className="flex flex-col sm:flex-row items-center gap-3"> <div className="flex flex-col sm:flex-row items-center gap-3">
<Link <Link
href="/dashboard" 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" 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>
<Link <Link
href="/status" 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" 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> </Link>
</div> </div>
</div> </div>

View File

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

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl";
import { Spinner } from "@/shared/components/Loading"; import { Spinner } from "@/shared/components/Loading";
interface HealthPayload { interface HealthPayload {
@@ -18,43 +19,48 @@ interface HealthPayload {
error?: string; error?: string;
} }
function formatUptime(seconds?: number) { function formatUptime(
if (!seconds || seconds <= 0) return "0m"; 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 total = Math.floor(seconds);
const hours = Math.floor(total / 3600); const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60); const minutes = Math.floor((total % 3600) / 60);
if (hours > 0) return `${hours}h ${minutes}m`; if (hours > 0) return format("status.uptimeHoursMinutes", { hours, minutes });
return `${minutes}m`; return format("status.uptimeMinutes", { minutes });
} }
export default function StatusPage() { export default function StatusPage() {
const t = useTranslations("publicSystem");
const tc = useTranslations("common");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [health, setHealth] = useState<HealthPayload | null>(null); const [health, setHealth] = useState<HealthPayload | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
async function loadHealth() { const loadHealth = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await fetch("/api/monitoring/health", { cache: "no-store" }); const response = await fetch("/api/monitoring/health", { cache: "no-store" });
const data = (await response.json()) as HealthPayload; const data = (await response.json()) as HealthPayload;
if (!response.ok) { if (!response.ok) {
setError(data.error || "Failed to load system health."); setError(data.error || t("status.failedToLoad"));
setHealth(null); setHealth(null);
return; return;
} }
setHealth(data); setHealth(data);
} catch { } catch {
setError("Unable to reach health endpoint. Check connectivity and retry."); setError(t("status.unableToReachHealth"));
setHealth(null); setHealth(null);
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }, [t]);
useEffect(() => { useEffect(() => {
void loadHealth(); void loadHealth();
}, []); }, [loadHealth]);
const providerStats = useMemo(() => { const providerStats = useMemo(() => {
const providers = Object.entries(health?.providerHealth || {}); const providers = Object.entries(health?.providerHealth || {});
@@ -69,16 +75,14 @@ export default function StatusPage() {
<section className="max-w-4xl mx-auto space-y-6"> <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"> <header className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div>
<h1 className="text-3xl font-bold tracking-tight">System Status</h1> <h1 className="text-3xl font-bold tracking-tight">{t("status.title")}</h1>
<p className="text-text-muted mt-1"> <p className="text-text-muted mt-1">{t("status.description")}</p>
Live operational snapshot for OmniRoute core services.
</p>
</div> </div>
<button <button
onClick={() => void loadHealth()} 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" 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> </button>
</header> </header>
@@ -89,14 +93,14 @@ export default function StatusPage() {
aria-live="polite" aria-live="polite"
> >
<Spinner size="md" /> <Spinner size="md" />
<span className="text-text-muted">Loading health metrics...</span> <span className="text-text-muted">{t("status.loadingHealth")}</span>
</div> </div>
)} )}
{!loading && error && ( {!loading && error && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 p-6" role="alert"> <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"> <h2 className="text-lg font-semibold text-red-600 dark:text-red-400">
Health Check Failed {t("status.healthCheckFailed")}
</h2> </h2>
<p className="mt-2 text-sm text-text-muted">{error}</p> <p className="mt-2 text-sm text-text-muted">{error}</p>
<div className="mt-4 flex flex-wrap gap-2"> <div className="mt-4 flex flex-wrap gap-2">
@@ -104,13 +108,13 @@ export default function StatusPage() {
href="/offline" href="/offline"
className="px-3 py-2 rounded-lg border border-border text-sm font-medium hover:bg-bg-alt transition-colors" 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>
<Link <Link
href="/maintenance" href="/maintenance"
className="px-3 py-2 rounded-lg border border-border text-sm font-medium hover:bg-bg-alt transition-colors" 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> </Link>
</div> </div>
</div> </div>
@@ -120,32 +124,43 @@ export default function StatusPage() {
<> <>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="rounded-xl border border-border bg-surface p-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="text-xs uppercase tracking-wide text-text-muted">
<p className="mt-2 text-xl font-semibold">{health.status || "unknown"}</p> {t("status.service")}
</p>
<p className="mt-2 text-xl font-semibold">{health.status || t("status.unknown")}</p>
</div> </div>
<div className="rounded-xl border border-border bg-surface p-4"> <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="text-xs uppercase tracking-wide text-text-muted">{tc("version")}</p>
<p className="mt-2 text-xl font-semibold">{health.system?.version || "n/a"}</p> <p className="mt-2 text-xl font-semibold">
{health.system?.version || t("status.notAvailable")}
</p>
</div> </div>
<div className="rounded-xl border border-border bg-surface p-4"> <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="text-xs uppercase tracking-wide text-text-muted">{tc("uptime")}</p>
<p className="mt-2 text-xl font-semibold">{formatUptime(health.system?.uptime)}</p> <p className="mt-2 text-xl font-semibold">
{formatUptime(health.system?.uptime, t)}
</p>
</div> </div>
<div className="rounded-xl border border-border bg-surface p-4"> <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> <p className="mt-2 text-xl font-semibold">{providerStats.total}</p>
</div> </div>
</div> </div>
<div className="rounded-xl border border-border bg-surface p-6"> <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"> <p className="text-sm text-text-muted mt-1">
OPEN: {providerStats.open} · HALF_OPEN: {providerStats.halfOpen} · CLOSED:{" "} OPEN: {providerStats.open} · HALF_OPEN: {providerStats.halfOpen} · CLOSED:{" "}
{providerStats.closed} {providerStats.closed}
</p> </p>
<p className="mt-4 text-xs text-text-muted"> <p className="mt-4 text-xs text-text-muted">
Last update:{" "} {t("status.lastUpdate", {
{health.timestamp ? new Date(health.timestamp).toLocaleString() : "n/a"} timestamp: health.timestamp
? new Date(health.timestamp).toLocaleString()
: t("status.notAvailable"),
})}
</p> </p>
</div> </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" 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" role="dialog"
aria-modal="true" 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"> <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"> <span className="material-symbols-outlined text-[20px] text-text-muted shrink-0">
@@ -245,7 +245,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
ref={inputRef} ref={inputRef}
type="text" type="text"
className="flex-1 bg-transparent text-text placeholder:text-text-muted outline-none text-base" 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} value={query}
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value); setQuery(e.target.value);
@@ -262,7 +262,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
setSelectedIndex(0); setSelectedIndex(0);
}} }}
tabIndex={-1} tabIndex={-1}
aria-label="Clear search" aria-label={t("commandPalette.clearSearch")}
> >
<span className="material-symbols-outlined text-[16px]">close</span> <span className="material-symbols-outlined text-[16px]">close</span>
</button> </button>
@@ -353,7 +353,9 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
))} ))}
</ul> </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"> <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 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> </kbd>
navigate {t("commandPalette.navigate")}
</span> </span>
<span className="flex items-center gap-1"> <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 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> </kbd>
open {t("commandPalette.open")}
</span> </span>
<span className="flex items-center gap-1"> <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 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 Esc
</kbd> </kbd>
close {t("commandPalette.close")}
</span> </span>
</div> </div>
</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 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="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> </Link>
); );
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useCallback, useRef, useEffect } from "react"; import { useState, useCallback, useRef, useEffect } from "react";
import { useTranslations } from "next-intl";
type ButtonState = "idle" | "distributing" | "complete"; type ButtonState = "idle" | "distributing" | "complete";
@@ -22,6 +23,7 @@ export default function DistributeProxiesButton({
size = "md", size = "md",
}: DistributeProxiesButtonProps) { }: DistributeProxiesButtonProps) {
const [state, setState] = useState<ButtonState>("idle"); const [state, setState] = useState<ButtonState>("idle");
const t = useTranslations("sharedComponents.distributeProxies");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
@@ -44,8 +46,7 @@ export default function DistributeProxiesButton({
const isDisabled = disabled || state === "distributing"; const isDisabled = disabled || state === "distributing";
const sizeClasses = const sizeClasses = size === "sm" ? "px-2 py-1 text-[11px]" : "px-3 py-1.5 text-xs";
size === "sm" ? "px-2 py-1 text-[11px]" : "px-3 py-1.5 text-xs";
const stateClasses = const stateClasses =
state === "distributing" 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"; : "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 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 ( return (
<button <button
@@ -65,7 +71,9 @@ export default function DistributeProxiesButton({
title={displayLabel} title={displayLabel}
aria-label={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} {icon}
</span> </span>
{displayLabel} {displayLabel}

View File

@@ -7,38 +7,38 @@ import { APP_CONFIG } from "@/shared/constants/appConfig";
const footerLinks = { const footerLinks = {
product: [ product: [
{ label: "Features", href: "#features" }, { key: "featuresLink", href: "#features" },
{ label: "Pricing", href: "#pricing" }, { key: "pricing", href: "#pricing" },
{ {
label: "Changelog", key: "changelog",
href: "https://github.com/diegosouzapw/OmniRoute/releases", href: "https://github.com/diegosouzapw/OmniRoute/releases",
external: true, external: true,
}, },
], ],
resources: [ resources: [
{ label: "Documentation", href: "/docs" }, { key: "documentation", href: "/docs" },
{ label: "API Reference", href: "/docs#api-reference" }, { key: "apiReference", href: "/docs#api-reference" },
{ {
label: "Help Center", key: "helpCenter",
href: "https://github.com/diegosouzapw/OmniRoute/discussions", href: "https://github.com/diegosouzapw/OmniRoute/discussions",
external: true, external: true,
}, },
], ],
company: [ company: [
{ label: "About", href: "https://github.com/diegosouzapw/OmniRoute", external: true }, { key: "about", href: "https://github.com/diegosouzapw/OmniRoute", external: true },
{ label: "Blog", href: "https://github.com/diegosouzapw/OmniRoute/releases", 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", href: "https://github.com/diegosouzapw/OmniRoute/issues/new/choose",
external: true, external: true,
}, },
{ label: "Terms", href: "/terms" }, { key: "terms", href: "/terms" },
{ label: "Privacy", href: "/privacy" }, { key: "privacy", href: "/privacy" },
], ],
}; };
export default function Footer() { export default function Footer() {
const t = useTranslations("stats"); const t = useTranslations("landing");
const renderFooterLink = (link) => { const renderFooterLink = (link) => {
if (link.external) { if (link.external) {
return ( return (
@@ -48,7 +48,7 @@ export default function Footer() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:text-primary transition-colors" className="hover:text-primary transition-colors"
> >
{link.label} {t(link.key)}
</a> </a>
); );
} }
@@ -77,9 +77,7 @@ export default function Footer() {
</div> </div>
<span className="text-xl font-bold text-text-main">{APP_CONFIG.name}</span> <span className="text-xl font-bold text-text-main">{APP_CONFIG.name}</span>
</div> </div>
<p className="text-text-muted mb-6 max-w-sm font-light"> <p className="text-text-muted mb-6 max-w-sm font-light">{t("footerDescription")}</p>
The unified interface for modern AI infrastructure. Secure, observable, and scalable.
</p>
{/* Social links */} {/* Social links */}
<div className="flex gap-4"> <div className="flex gap-4">
<a <a
@@ -87,7 +85,7 @@ export default function Footer() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-gray-400 hover:text-primary transition-colors" 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"> <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" /> <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> <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"> <ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
{footerLinks.product.map((link) => ( {footerLinks.product.map((link) => (
<li key={link.label}>{renderFooterLink(link)}</li> <li key={link.key}>{renderFooterLink(link)}</li>
))} ))}
</ul> </ul>
</div> </div>
@@ -122,7 +120,7 @@ export default function Footer() {
<h4 className="font-semibold text-text-main mb-4">{t("resources")}</h4> <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"> <ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
{footerLinks.resources.map((link) => ( {footerLinks.resources.map((link) => (
<li key={link.label}>{renderFooterLink(link)}</li> <li key={link.key}>{renderFooterLink(link)}</li>
))} ))}
</ul> </ul>
</div> </div>
@@ -132,7 +130,7 @@ export default function Footer() {
<h4 className="font-semibold text-text-main mb-4">{t("company")}</h4> <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"> <ul className="flex flex-col gap-3 text-sm text-text-muted font-light">
{footerLinks.company.map((link) => ( {footerLinks.company.map((link) => (
<li key={link.label}>{renderFooterLink(link)}</li> <li key={link.key}>{renderFooterLink(link)}</li>
))} ))}
</ul> </ul>
</div> </div>
@@ -141,17 +139,17 @@ export default function Footer() {
{/* Bottom */} {/* Bottom */}
<div className="border-t border-border pt-8 flex flex-col md:flex-row justify-between items-center gap-4"> <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"> <p className="text-sm text-text-muted">
© {new Date().getFullYear()} {APP_CONFIG.name} Inc. All rights reserved. {t("copyright", { year: new Date().getFullYear() })}
</p> </p>
<div className="flex gap-6 text-sm text-text-muted"> <div className="flex gap-6 text-sm text-text-muted">
<Link href="/docs" className="hover:text-primary transition-colors"> <Link href="/docs" className="hover:text-primary transition-colors">
Documentation {t("documentation")}
</Link> </Link>
<Link href="/terms" className="hover:text-primary transition-colors"> <Link href="/terms" className="hover:text-primary transition-colors">
Terms {t("terms")}
</Link> </Link>
<Link href="/privacy" className="hover:text-primary transition-colors"> <Link href="/privacy" className="hover:text-primary transition-colors">
Privacy {t("privacy")}
</Link> </Link>
<a <a
href="https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE" href="https://github.com/diegosouzapw/OmniRoute/blob/main/LICENSE"
@@ -159,7 +157,7 @@ export default function Footer() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="hover:text-primary transition-colors" className="hover:text-primary transition-colors"
> >
License {t("license")}
</a> </a>
</div> </div>
</div> </div>

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ interface Combo {
export default function ModelRoutingSection({ combos: externalCombos }: { combos?: Combo[] } = {}) { export default function ModelRoutingSection({ combos: externalCombos }: { combos?: Combo[] } = {}) {
const t = useTranslations("settings"); const t = useTranslations("settings");
const tCommon = useTranslations("common");
const [mappings, setMappings] = useState<ModelMapping[]>([]); const [mappings, setMappings] = useState<ModelMapping[]>([]);
const [internalCombos, setInternalCombos] = useState<Combo[]>([]); const [internalCombos, setInternalCombos] = useState<Combo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -250,7 +251,7 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos
<input <input
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} 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 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" 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 <button
onClick={() => handleEdit(m)} onClick={() => handleEdit(m)}
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors" 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"> <span className="material-symbols-outlined text-[14px] text-text-muted">
edit edit
@@ -337,7 +338,7 @@ export default function ModelRoutingSection({ combos: externalCombos }: { combos
<button <button
onClick={() => handleDelete(m.id)} onClick={() => handleDelete(m.id)}
className="p-1 rounded hover:bg-red-500/10 transition-colors" 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> <span className="material-symbols-outlined text-[14px] text-red-500">delete</span>
</button> </button>

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