diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js
index 9e9f9ce6..7dbb03b8 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js
@@ -161,6 +161,10 @@ export default function CLIToolsPageClient({ machineId }) {
onToggle: () => setExpandedTool(expandedTool === toolId ? null : toolId),
baseUrl: getBaseUrl(),
apiKeys,
+ tunnelEnabled,
+ tunnelPublicUrl,
+ tailscaleEnabled,
+ tailscaleUrl,
};
switch (toolId) {
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js
new file mode 100644
index 00000000..d2f43840
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js
@@ -0,0 +1,156 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { APP_CONFIG } from "@/shared/constants/config";
+
+const STORAGE_KEY = "9router.cliToolEndpointPresets";
+const CUSTOM_VALUE = "__custom__";
+const SAVE_VALUE = "__save__";
+
+const ensureV1 = (url) => {
+ const trimmed = (url || "").replace(/\/+$/, "");
+ if (!trimmed) return "";
+ return /\/v1$/.test(trimmed) ? trimmed : `${trimmed}/v1`;
+};
+
+const stripV1 = (url) => (url || "").replace(/\/v1\/?$/, "");
+
+const readSavedPresets = () => {
+ if (typeof window === "undefined") return [];
+ try {
+ const raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || "[]");
+ if (!Array.isArray(raw)) return [];
+ return raw.filter((p) => p?.name && p?.baseUrl);
+ } catch {
+ return [];
+ }
+};
+
+const writeSavedPresets = (presets) => {
+ if (typeof window === "undefined") return;
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
+};
+
+// Build endpoint options ordered by priority
+const buildOptions = ({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, savedPresets, withV1 }) => {
+ const opts = [];
+ const wrap = (url) => (withV1 ? ensureV1(url) : url.replace(/\/+$/, ""));
+ if (!requiresExternalUrl) {
+ opts.push({ value: "local", label: `Localhost (127.0.0.1)`, url: wrap(`http://127.0.0.1:${APP_CONFIG.appPort}`) });
+ }
+ if (tunnelEnabled && tunnelPublicUrl) {
+ opts.push({ value: "tunnel", label: `Tunnel - ${tunnelPublicUrl}`, url: wrap(tunnelPublicUrl) });
+ }
+ if (tailscaleEnabled && tailscaleUrl) {
+ opts.push({ value: "tailscale", label: `Tailscale - ${tailscaleUrl}`, url: wrap(tailscaleUrl) });
+ }
+ savedPresets.forEach((p) => {
+ opts.push({ value: `saved:${p.name}`, label: `★ ${p.name} - ${p.baseUrl}`, url: p.baseUrl, saved: true });
+ });
+ opts.push({ value: CUSTOM_VALUE, label: "Custom URL...", url: "" });
+ return opts;
+};
+
+export default function BaseUrlSelect({
+ value,
+ onChange,
+ requiresExternalUrl = false,
+ tunnelEnabled = false,
+ tunnelPublicUrl = "",
+ tailscaleEnabled = false,
+ tailscaleUrl = "",
+ withV1 = true,
+}) {
+ const [savedPresets, setSavedPresets] = useState([]);
+ const [mode, setMode] = useState("");
+
+ useEffect(() => {
+ setSavedPresets(readSavedPresets());
+ }, []);
+
+ const options = useMemo(
+ () => buildOptions({ requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, savedPresets, withV1 }),
+ [requiresExternalUrl, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl, savedPresets, withV1]
+ );
+
+ // Auto-detect mode based on current value matching an option
+ useEffect(() => {
+ if (!value) {
+ if (options[0] && options[0].value !== CUSTOM_VALUE) {
+ setMode(options[0].value);
+ onChange(options[0].url);
+ }
+ return;
+ }
+ const match = options.find((o) => o.url && o.url === value);
+ setMode(match ? match.value : CUSTOM_VALUE);
+ }, [value, options]);
+
+ const handleSelect = (e) => {
+ const next = e.target.value;
+ if (next === SAVE_VALUE) {
+ const trimmed = (value || "").trim();
+ if (!trimmed) return;
+ let defaultName = trimmed;
+ try { defaultName = new URL(trimmed).host; } catch {}
+ const name = window.prompt("Save endpoint as:", defaultName);
+ if (!name?.trim()) return;
+ const next = [...savedPresets.filter((p) => p.name !== name.trim()), { name: name.trim(), baseUrl: trimmed }]
+ .sort((a, b) => a.name.localeCompare(b.name));
+ setSavedPresets(next);
+ writeSavedPresets(next);
+ return;
+ }
+ setMode(next);
+ if (next === CUSTOM_VALUE) {
+ onChange("");
+ return;
+ }
+ const opt = options.find((o) => o.value === next);
+ if (opt) onChange(opt.url);
+ };
+
+ const handleDeleteSaved = () => {
+ if (!mode.startsWith("saved:")) return;
+ const name = mode.slice(6);
+ const next = savedPresets.filter((p) => p.name !== name);
+ setSavedPresets(next);
+ writeSavedPresets(next);
+ setMode(CUSTOM_VALUE);
+ };
+
+ const isSaved = mode.startsWith("saved:");
+ const isCustom = mode === CUSTOM_VALUE;
+ const canSave = isCustom && (value || "").trim().length > 0;
+
+ return (
+
+
Base URL
arrow_forward
- setCustomBaseUrl(e.target.value)}
- placeholder="https://.../v1"
- className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
+
- {customBaseUrl && customBaseUrl !== baseUrl && (
-
- )}
{/* API Key */}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js
index 2bea5272..360db6cd 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js
@@ -3,9 +3,9 @@
import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
-import EndpointPresetControl from "./EndpointPresetControl";
+import BaseUrlSelect from "./BaseUrlSelect";
-export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus }) {
+export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
const [codexStatus, setCodexStatus] = useState(initialStatus || null);
const [checkingCodex, setCheckingCodex] = useState(false);
const [applying, setApplying] = useState(false);
@@ -281,29 +281,19 @@ model = "${effectiveSubagentModel}"
) : null;
})()}
-
-
{/* Base URL */}
-
+
Base URL
arrow_forward
- setCustomBaseUrl(e.target.value)}
- placeholder="https://.../v1"
- className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
+
- {customBaseUrl && customBaseUrl !== `${baseUrl}/v1` && (
-
- )}
{/* API Key */}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js
index 192723cb..c2334c6f 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js
@@ -3,9 +3,9 @@
import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
-import EndpointPresetControl from "./EndpointPresetControl";
+import BaseUrlSelect from "./BaseUrlSelect";
-export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus }) {
+export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
const [status, setStatus] = useState(initialStatus || null);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
@@ -206,21 +206,16 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
-
-
- setCustomBaseUrl(e.target.value)}
- placeholder="https://.../v1"
- className="px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
+
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
index 528de780..a148215e 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js
@@ -3,7 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
-import EndpointPresetControl from "./EndpointPresetControl";
+import BaseUrlSelect from "./BaseUrlSelect";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
@@ -17,6 +17,10 @@ export default function DroidToolCard({
activeProviders,
cloudEnabled,
initialStatus,
+ tunnelEnabled,
+ tunnelPublicUrl,
+ tailscaleEnabled,
+ tailscaleUrl,
}) {
const [droidStatus, setDroidStatus] = useState(initialStatus || null);
const [checkingDroid, setCheckingDroid] = useState(false);
@@ -298,29 +302,19 @@ export default function DroidToolCard({
)}
-
-
{/* Base URL */}
-
+
Base URL
arrow_forward
- setCustomBaseUrl(e.target.value)}
- placeholder="https://.../v1"
- className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
+
- {customBaseUrl && customBaseUrl !== baseUrl && (
-
- )}
{/* API Key */}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js
index 4b87931c..13620b05 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/HermesToolCard.js
@@ -3,7 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
-import EndpointPresetControl from "./EndpointPresetControl";
+import BaseUrlSelect from "./BaseUrlSelect";
const ENDPOINT = "/api/cli-tools/hermes-settings";
@@ -17,6 +17,10 @@ export default function HermesToolCard({
activeProviders,
cloudEnabled,
initialStatus,
+ tunnelEnabled,
+ tunnelPublicUrl,
+ tailscaleEnabled,
+ tailscaleUrl,
}) {
const [hermesStatus, setHermesStatus] = useState(initialStatus || null);
const [checking, setChecking] = useState(false);
@@ -239,28 +243,18 @@ export default function HermesToolCard({
)}
-
-
-
+
Base URL
arrow_forward
- setCustomBaseUrl(e.target.value)}
- placeholder="https://.../v1"
- className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
+
- {customBaseUrl && customBaseUrl !== baseUrl && (
-
- )}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js
index 75e8895d..82758086 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js
@@ -3,7 +3,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
-import EndpointPresetControl from "./EndpointPresetControl";
+import BaseUrlSelect from "./BaseUrlSelect";
export default function OpenClawToolCard({
tool,
@@ -15,6 +15,10 @@ export default function OpenClawToolCard({
activeProviders,
cloudEnabled,
initialStatus,
+ tunnelEnabled,
+ tunnelPublicUrl,
+ tailscaleEnabled,
+ tailscaleUrl,
}) {
const [openclawStatus, setOpenclawStatus] = useState(initialStatus || null);
const [checkingOpenclaw, setCheckingOpenclaw] = useState(false);
@@ -289,29 +293,19 @@ export default function OpenClawToolCard({
)}
-
-
{/* Base URL */}
-
+
Base URL
arrow_forward
- setCustomBaseUrl(e.target.value)}
- placeholder="https://.../v1"
- className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
+
- {customBaseUrl && customBaseUrl !== baseUrl && (
-
- )}
{/* API Key */}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js
index d006599c..1f1f1d3b 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js
@@ -3,9 +3,9 @@
import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
-import EndpointPresetControl from "./EndpointPresetControl";
+import BaseUrlSelect from "./BaseUrlSelect";
-export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus }) {
+export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus, tunnelEnabled, tunnelPublicUrl, tailscaleEnabled, tailscaleUrl }) {
const [status, setStatus] = useState(initialStatus || null);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
@@ -268,29 +268,19 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
)}
-
-
{/* Base URL */}
-
+
Base URL
arrow_forward
- setCustomBaseUrl(e.target.value)}
- placeholder="https://.../v1"
- className="w-full min-w-0 px-2 py-2 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"
+
- {customBaseUrl && customBaseUrl !== `${baseUrl}/v1` && (
-
- )}
{/* API Key */}
diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js
index 06b45ccc..18565604 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js
+++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js
@@ -12,3 +12,4 @@ export { default as MitmServerCard } from "./MitmServerCard";
export { default as MitmToolCard } from "./MitmToolCard";
export { default as MitmLinkCard } from "./MitmLinkCard";
export { default as EndpointPresetControl } from "./EndpointPresetControl";
+export { default as BaseUrlSelect } from "./BaseUrlSelect";
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js
index ad6f884a..390c50fe 100644
--- a/src/app/(dashboard)/dashboard/providers/[id]/page.js
+++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js
@@ -482,42 +482,49 @@ export default function ProviderDetailPage() {
setShowBulkProxyModal(false);
};
- const handleBulkApplyProxyPool = async () => {
- if (selectedConnectionIds.length === 0) return;
-
- const proxyPoolId = bulkProxyPoolId === "__none__" ? null : bulkProxyPoolId;
+ const applyProxyAssignments = async (assignments) => {
setBulkUpdatingProxy(true);
try {
- const results = [];
- for (const connectionId of selectedConnectionIds) {
+ const results = await Promise.all(assignments.map(async ({ connectionId, proxyPoolId }) => {
try {
const res = await fetch(`/api/providers/${connectionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ proxyPoolId }),
});
- results.push(res.ok);
+ return res.ok;
} catch (e) {
- console.log("Error applying bulk proxy pool for", connectionId, e);
- results.push(false);
+ console.log("Error applying proxy for", connectionId, e);
+ return false;
}
- }
-
- const failedCount = results.filter((ok) => !ok).length;
- if (failedCount > 0) {
- alert(`Updated with ${failedCount} failed request(s).`);
- }
-
+ }));
+ const failed = results.filter((ok) => !ok).length;
+ if (failed > 0) alert(`Updated with ${failed} failed request(s).`);
await fetchConnections();
- clearSelection();
setShowBulkProxyModal(false);
- } catch (error) {
- console.log("Error applying bulk proxy pool:", error);
} finally {
setBulkUpdatingProxy(false);
}
};
+ const handleApplySinglePool = (proxyPoolId) => {
+ const targets = connections.map((c) => ({ connectionId: c.id, proxyPoolId }));
+ return applyProxyAssignments(targets);
+ };
+
+ const handleApplyOneToOne = () => {
+ const activePools = proxyPools.filter((p) => p.isActive === true);
+ if (activePools.length === 0) {
+ alert("No active proxy pools available.");
+ return;
+ }
+ const targets = connections.map((c, i) => ({
+ connectionId: c.id,
+ proxyPoolId: activePools[i % activePools.length].id,
+ }));
+ return applyProxyAssignments(targets);
+ };
+
const isSelected = (connectionId) => selectedConnectionIds.includes(connectionId);
@@ -566,43 +573,69 @@ export default function ProviderDetailPage() {
);
- const bulkProxyOptions = [
- { value: "__none__", label: "None" },
- ...proxyPools.map((pool) => ({ value: pool.id, label: pool.name })),
- ];
-
- const bulkHint = selectedConnectionIds.length === 0
- ? "Select one or more connections, then click Proxy Action."
- : selectedProxySummary;
-
- const canApplyBulkProxy = selectedConnectionIds.length > 0 && !bulkUpdatingProxy;
+ const activePools = proxyPools.filter((p) => p.isActive === true);
const bulkActionModal = (
-
-