mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 01:32:22 +03:00
Compare commits
5 Commits
compressio
...
fix/i18n-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2925fde9f | ||
|
|
9a64e0e17d | ||
|
|
e2eba05e5c | ||
|
|
9e628e73fa | ||
|
|
9061d7c70d |
1
changelog.d/fixes/9235-french-ui-catalog.md
Normal file
1
changelog.d/fixes/9235-french-ui-catalog.md
Normal 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
|
||||||
1
changelog.d/fixes/9245-hardcoded-web-ui-i18n.md
Normal file
1
changelog.d/fixes/9245-hardcoded-web-ui-i18n.md
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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") },
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
16
src/app/(dashboard)/dashboard/chaos/chaosI18n.ts
Normal file
16
src/app/(dashboard)/dashboard/chaos/chaosI18n.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)}%
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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")}`}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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" : ""}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]) => (
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 "Sync Now" 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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")} & 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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 / localhost(SS/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>clash(Clash.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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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") }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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're looking for doesn'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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user