From 6344abcf8d0e703653ac0d3c410746da3ea67cc8 Mon Sep 17 00:00:00 2001 From: decolua Date: Tue, 5 May 2026 20:22:21 +0700 Subject: [PATCH] Refactor CLI tool cards to use BaseUrlSelect component and pass additional tunnel and Tailscale configuration --- .../dashboard/cli-tools/CLIToolsPageClient.js | 4 + .../cli-tools/components/BaseUrlSelect.js | 156 ++++++++++++++++++ .../cli-tools/components/ClaudeToolCard.js | 34 ++-- .../cli-tools/components/CodexToolCard.js | 32 ++-- .../cli-tools/components/CopilotToolCard.js | 25 ++- .../cli-tools/components/DroidToolCard.js | 34 ++-- .../cli-tools/components/HermesToolCard.js | 34 ++-- .../cli-tools/components/OpenClawToolCard.js | 34 ++-- .../cli-tools/components/OpenCodeToolCard.js | 32 ++-- .../dashboard/cli-tools/components/index.js | 1 + .../dashboard/providers/[id]/page.js | 139 ++++++++++------ 11 files changed, 340 insertions(+), 185 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/cli-tools/components/BaseUrlSelect.js 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 ( +
+
+ + {isSaved && ( + + )} +
+ {isCustom && ( + onChange(e.target.value)} + placeholder={withV1 ? "https://example.com/v1" : "https://example.com"} + 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" + /> + )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index d24a4629..739d513e 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -3,7 +3,7 @@ import { useState, useEffect, useRef } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal, Tooltip } 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; @@ -19,6 +19,10 @@ export default function ClaudeToolCard({ apiKeys, cloudEnabled, initialStatus, + tunnelEnabled, + tunnelPublicUrl, + tailscaleEnabled, + tailscaleUrl, }) { const [claudeStatus, setClaudeStatus] = useState(initialStatus || null); const [checkingClaude, setCheckingClaude] = useState(false); @@ -303,29 +307,19 @@ export default function ClaudeToolCard({ )} - - {/* 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/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 = ( -
-