diff --git a/package.json b/package.json index 4851fc4..118af2d 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "9router-app", - "version": "0.4.20", + "version": "0.4.25", "description": "9Router web dashboard", "private": true, "scripts": { - "dev": "next dev --webpack --port 20128", + "dev": "next dev --webpack --hostname 127.0.0.1 --port 20128", "build": "NODE_ENV=production next build --webpack", "start": "NODE_ENV=production next start", "dev:bun": "bun --bun next dev --webpack --port 20128", @@ -20,7 +20,6 @@ "fs": "^0.0.1-security", "http-proxy-middleware": "^3.0.5", "jose": "^6.1.3", - "lowdb": "^7.0.1", "marked": "^18.0.1", "monaco-editor": "^0.55.1", "next": "^16.1.6", @@ -28,7 +27,6 @@ "node-machine-id": "^1.1.12", "open": "^11.0.0", "ora": "^9.1.0", - "proper-lockfile": "^4.1.2", "react": "19.2.4", "react-dom": "19.2.4", "react-is": "^16.13.1", @@ -43,6 +41,7 @@ "optionalDependencies": { "better-sqlite3": "^12.6.2" }, + "comment_better_sqlite3": "kept in optionalDependencies so npm install doesn't fail on systems without build tools — sql.js is used as fallback at runtime", "devDependencies": { "@tailwindcss/postcss": "^4.1.18", "eslint": "^9", diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index 7dbb03b..f123e63 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js @@ -4,21 +4,12 @@ import { useState, useEffect, useCallback } from "react"; import { Card, CardSkeleton } from "@/shared/components"; import { CLI_TOOLS } from "@/shared/constants/cliTools"; import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; -import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, MitmLinkCard } from "./components"; +import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, HermesToolCard, DefaultToolCard, OpenCodeToolCard, CoworkToolCard, CopilotToolCard, MitmLinkCard } from "./components"; import { MITM_TOOLS } from "@/shared/constants/cliTools"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; - -const STATUS_ENDPOINTS = { - claude: "/api/cli-tools/claude-settings", - codex: "/api/cli-tools/codex-settings", - opencode: "/api/cli-tools/opencode-settings", - droid: "/api/cli-tools/droid-settings", - openclaw: "/api/cli-tools/openclaw-settings", - hermes: "/api/cli-tools/hermes-settings", - cowork: "/api/cli-tools/cowork-settings", -}; +const ALL_STATUSES_URL = "/api/cli-tools/all-statuses"; export default function CLIToolsPageClient({ machineId }) { const [connections, setConnections] = useState([]); @@ -42,18 +33,8 @@ export default function CLIToolsPageClient({ machineId }) { const fetchAllStatuses = async () => { try { - const entries = await Promise.all( - Object.entries(STATUS_ENDPOINTS).map(async ([toolId, url]) => { - try { - const res = await fetch(url); - const data = await res.json(); - return [toolId, data]; - } catch { - return [toolId, null]; - } - }) - ); - setToolStatuses(Object.fromEntries(entries)); + const res = await fetch(ALL_STATUSES_URL); + if (res.ok) setToolStatuses(await res.json()); } catch (error) { console.log("Error fetching tool statuses:", error); } @@ -138,7 +119,7 @@ export default function CLIToolsPageClient({ machineId }) { if (tunnelEnabled && tunnelPublicUrl) return tunnelPublicUrl; if (cloudEnabled && CLOUD_URL) return CLOUD_URL; if (typeof window !== "undefined") return window.location.origin; - return "http://localhost:20128"; + return "http://127.0.0.1:20128"; }; if (loading) { @@ -207,6 +188,8 @@ export default function CLIToolsPageClient({ machineId }) { return ; case "hermes": return ; + case "copilot": + return ; default: return ; } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js index b8084d7..a7773f2 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CopilotToolCard.js @@ -16,10 +16,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a const [customBaseUrl, setCustomBaseUrl] = useState(""); const [modelAliases, setModelAliases] = useState({}); const [showManualConfigModal, setShowManualConfigModal] = useState(false); - - // Model list management - const [modelInput, setModelInput] = useState(""); - const [modelList, setModelList] = useState([]); + const [selectedModels, setSelectedModels] = useState([]); const [modalOpen, setModalOpen] = useState(false); useEffect(() => { @@ -40,12 +37,12 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a if (isExpanded) fetchModelAliases(); }, [isExpanded]); - // Pre-fill model list from existing config + // Pre-fill from existing config useEffect(() => { - if (status?.config && Array.isArray(status.config) && modelList.length === 0) { + if (status?.config && Array.isArray(status.config) && selectedModels.length === 0) { const entry = status.config.find((e) => e.name === "9Router"); if (entry?.models?.length > 0) { - setModelList(entry.models.map((m) => m.id)); + setSelectedModels(entry.models.map((m) => m.id)); } } }, [status]); @@ -68,20 +65,16 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a }; const configStatus = getConfigStatus(); + const getEffectiveBaseUrl = () => { const url = customBaseUrl || baseUrl; return url.endsWith("/v1") ? url : `${url}/v1`; }; + + const getDisplayUrl = () => customBaseUrl || `${baseUrl}/v1`; const hasCustomSelectedApiKey = selectedApiKey && !apiKeys.some((key) => key.key === selectedApiKey); - const addModel = () => { - const val = modelInput.trim(); - if (!val || modelList.includes(val)) return; - setModelList((prev) => [...prev, val]); - setModelInput(""); - }; - - const removeModel = (id) => setModelList((prev) => prev.filter((m) => m !== id)); + const removeModel = (id) => setSelectedModels((prev) => prev.filter((m) => m !== id)); const checkStatus = async () => { setChecking(true); @@ -107,11 +100,11 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a const res = await fetch("/api/cli-tools/copilot-settings", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, models: modelList }), + body: JSON.stringify({ baseUrl: getEffectiveBaseUrl(), apiKey: keyToUse, models: selectedModels }), }); const data = await res.json(); if (res.ok) { - setMessage({ type: "success", text: data.message || "Settings applied successfully!" }); + setMessage({ type: "success", text: data.message || "Settings applied! Reload VS Code." }); checkStatus(); } else { setMessage({ type: "error", text: data.error || "Failed to apply settings" }); @@ -131,7 +124,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a const data = await res.json(); if (res.ok) { setMessage({ type: "success", text: "Settings reset successfully!" }); - setModelList([]); + setSelectedModels([]); checkStatus(); } else { setMessage({ type: "error", text: data.error || "Failed to reset settings" }); @@ -148,6 +141,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a ? selectedApiKey : (!cloudEnabled ? "sk_9router" : ""); const effectiveBaseUrl = getEffectiveBaseUrl(); + const modelsToShow = selectedModels.length > 0 ? selectedModels : ["provider/model-id"]; return [{ filename: "~/Library/Application Support/Code/User/chatLanguageModels.json", @@ -155,7 +149,7 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a name: "9Router", vendor: "azure", apiKey: keyToUse, - models: modelList.map((id) => ({ + models: modelsToShow.map((id) => ({ id, name: id, url: `${effectiveBaseUrl}/chat/completions#models.ai.azure.com`, toolCalling: true, vision: false, @@ -166,14 +160,14 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a }; return ( - -
-
+ +
+
{tool.name} { e.target.style.display = "none"; }} />
-
+

{tool.name}

{configStatus === "configured" && Connected} {configStatus === "not_configured" && Not configured} @@ -196,7 +190,6 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a {!checking && ( <> - {/* Info */}
info
@@ -205,11 +198,13 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
-
-
- +
+ {/* Endpoint */} +
+ Select Endpoint + arrow_forward {/* API Key */} -
- +
+ API Key + arrow_forward {apiKeys.length > 0 || selectedApiKey ? ( - setSelectedApiKey(e.target.value)} className="w-full min-w-0 px-2 py-2 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50 sm:py-1.5"> {hasCustomSelectedApiKey && } {apiKeys.map((key) => )} ) : ( - + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"} )}
- {/* Model input + Add */} -
- - - {/* Model list */} - {modelList.length > 0 && ( -
- {modelList.map((id) => ( -
- {id} - -
- ))} + {/* Models */} +
+ Models + arrow_forward +
+
+ {selectedModels.length === 0 ? ( + No models selected + ) : ( + selectedModels.map((model) => ( + + {model} + + + )) + )} +
+
+
- )} - -
- setModelInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && addModel()} - placeholder="provider/model-id" - className="min-w-0 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50" - /> - -
@@ -279,13 +264,13 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a )}
- -
@@ -297,11 +282,16 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a setModalOpen(false)} - onSelect={(model) => { setModelInput(model.value); setModalOpen(false); }} - selectedModel={modelInput} + onSelect={(model) => { + if (!selectedModels.includes(model.value)) { + setSelectedModels([...selectedModels, model.value]); + } + setModalOpen(false); + }} + selectedModel={null} activeProviders={activeProviders} modelAliases={modelAliases} - title="Select Model for GitHub Copilot" + title="Add Model for GitHub Copilot" /> { + if (reachable) { + missRef.current = 0; + setter(true); + if (!everRef.current) { + everRef.current = true; + everSetter(true); + } + } else { + missRef.current += 1; + if (missRef.current >= REACHABLE_MISS_THRESHOLD) setter(false); + } + }, []); + // Trust user intent (settingsEnabled): UI stays "enabled" while watchdog restarts process const syncTunnelStatus = async () => { try { @@ -97,11 +128,13 @@ export default function APIPageClient({ machineId }) { setTunnelUrl(tUrl); setTunnelPublicUrl(tPublicUrl); setTunnelEnabled(tEnabled); + updateReachable(!!data.tunnel?.reachable, tunnelMissRef, setTunnelReachable, tunnelEverReachableRef, setTunnelEverReachable); const tsEn = data.tailscale?.settingsEnabled ?? data.tailscale?.enabled ?? false; const tsUrlVal = data.tailscale?.tunnelUrl || ""; setTsUrl(tsUrlVal); setTsEnabled(tsEn); + updateReachable(!!data.tailscale?.reachable, tsMissRef, setTsReachable, tsEverReachableRef, setTsEverReachable); } catch { /* ignore poll errors */ } }; @@ -129,26 +162,14 @@ export default function APIPageClient({ machineId }) { const tPublicUrl = data.tunnel?.publicUrl || ""; setTunnelUrl(tUrl); setTunnelPublicUrl(tPublicUrl); - // Trust user intent: stays enabled while watchdog restores process setTunnelEnabled(tEnabled); + updateReachable(!!data.tunnel?.reachable, tunnelMissRef, setTunnelReachable, tunnelEverReachableRef, setTunnelEverReachable); const tsEn = data.tailscale?.settingsEnabled ?? data.tailscale?.enabled ?? false; const tsUrlVal = data.tailscale?.tunnelUrl || ""; setTsUrl(tsUrlVal); setTsEnabled(tsEn); - - // Background reachability probes (non-blocking, only show warning) - if (tEnabled && (tPublicUrl || tUrl)) { - const healthUrl = `${tPublicUrl || tUrl}/api/health`; - fetch(healthUrl, { cache: "no-store" }) - .then((r) => { if (!r.ok) setTunnelStatus({ type: "warning", message: "Tunnel reconnecting..." }); }) - .catch(() => setTunnelStatus({ type: "warning", message: "Tunnel reconnecting..." })); - } - if (tsEn && tsUrlVal) { - fetch(`${tsUrlVal}/api/health`, { mode: "no-cors", cache: "no-store" }) - .then((r) => { if (!(r.ok || r.type === "opaque")) setTsStatus({ type: "warning", message: "Tailscale reconnecting..." }); }) - .catch(() => setTsStatus({ type: "warning", message: "Tailscale reconnecting..." })); - } + updateReachable(!!data.tailscale?.reachable, tsMissRef, setTsReachable, tsEverReachableRef, setTsEverReachable); } } catch (error) { console.log("Error loading settings:", error); @@ -428,8 +449,15 @@ export default function APIPageClient({ machineId }) { return false; }; - const handleConnectTailscale = async (preOpenedTab) => { - const tab = preOpenedTab || null; + // Open auth URL only when actually needed (avoids blank popup flash on success path). + // Falls back to status message with clickable link if popup blocker prevents opening. + const openAuthUrl = (url) => { + const w = window.open(url, "tailscale_auth", "width=600,height=700"); + if (!w) setTsStatus({ type: "warning", message: `Popup blocked. Open manually: ${url}` }); + return w; + }; + + const handleConnectTailscale = async () => { setShowTsModal(false); setTsConnecting(true); setTsLoading(true); @@ -440,23 +468,15 @@ export default function APIPageClient({ machineId }) { const data = await res.json(); if (res.ok && data.success) { - if (tab) tab.close(); setTsUrl(data.tunnelUrl || ""); const reachable = await pingTsHealth(data.tunnelUrl); - if (reachable) { - setTsEnabled(true); - setTsStatus(null); - } else { - setTsEnabled(true); - setTsStatus({ type: "warning", message: "Connected but not reachable yet." }); - } + setTsEnabled(true); + setTsStatus(reachable ? null : { type: "warning", message: "Connected but not reachable yet." }); return; } - // Needs login: redirect pre-opened tab or open new if (data.needsLogin && data.authUrl) { - if (tab) tab.location.href = data.authUrl; - else window.open(data.authUrl, "tailscale_auth", "width=600,height=700"); + openAuthUrl(data.authUrl); setTsProgress("Waiting for login..."); for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 3000)); @@ -469,18 +489,12 @@ export default function APIPageClient({ machineId }) { const res2 = await fetch("/api/tunnel/tailscale-enable", { method: "POST" }); const data2 = await res2.json(); if (res2.ok && data2.success) { - if (tab) tab.close(); setTsUrl(data2.tunnelUrl || ""); const ok2 = await pingTsHealth(data2.tunnelUrl); - if (ok2) { - setTsEnabled(true); - setTsStatus(null); - } else { - setTsEnabled(true); - setTsStatus({ type: "warning", message: "Connected but not reachable yet." }); - } + setTsEnabled(true); + setTsStatus(ok2 ? null : { type: "warning", message: "Connected but not reachable yet." }); } else if (data2.funnelNotEnabled && data2.enableUrl) { - await pollFunnelEnable(data2.enableUrl, tab); + await pollFunnelEnable(data2.enableUrl); } else { setTsStatus({ type: "error", message: data2.error || "Failed to start funnel" }); } @@ -493,16 +507,13 @@ export default function APIPageClient({ machineId }) { return; } - // Funnel not enabled: redirect pre-opened tab if (data.funnelNotEnabled && data.enableUrl) { - await pollFunnelEnable(data.enableUrl, tab); + await pollFunnelEnable(data.enableUrl); return; } - if (tab) tab.close(); setTsStatus({ type: "error", message: data.error || "Failed to connect" }); } catch (error) { - if (tab) tab.close(); setTsStatus({ type: "error", message: error.message }); } finally { setTsLoading(false); @@ -511,9 +522,8 @@ export default function APIPageClient({ machineId }) { } }; - const pollFunnelEnable = async (enableUrl, tab) => { - if (tab) tab.location.href = enableUrl; - else window.open(enableUrl, "tailscale_auth", "width=600,height=700"); + const pollFunnelEnable = async (enableUrl) => { + openAuthUrl(enableUrl); setTsProgress("Enable Funnel in browser, waiting..."); for (let i = 0; i < 40; i++) { await new Promise((r) => setTimeout(r, 3000)); @@ -521,16 +531,10 @@ export default function APIPageClient({ machineId }) { const res = await fetch("/api/tunnel/tailscale-enable", { method: "POST" }); const data = await res.json(); if (res.ok && data.success) { - if (tab) tab.close(); setTsUrl(data.tunnelUrl || ""); const ok3 = await pingTsHealth(data.tunnelUrl); - if (ok3) { - setTsEnabled(true); - setTsStatus(null); - } else { - setTsEnabled(true); - setTsStatus({ type: "warning", message: "Connected but not reachable yet." }); - } + setTsEnabled(true); + setTsStatus(ok3 ? null : { type: "warning", message: "Connected but not reachable yet." }); return; } if (data.funnelNotEnabled) continue; @@ -685,7 +689,7 @@ export default function APIPageClient({ machineId }) { Tunnel - {tunnelEnabled && !tunnelLoading ? ( + {tunnelEnabled && !tunnelLoading && tunnelReachable ? ( <> + ) : tunnelEnabled && !tunnelLoading && !tunnelReachable ? ( + <> +
+ progress_activity + {tunnelEverReachable ? "Tunnel reconnecting..." : "Tunnel checking..."} +
+ + ) : tunnelLoading ? ( <>
@@ -759,7 +777,7 @@ export default function APIPageClient({ machineId }) { Tailscale - {tsEnabled && !tsLoading ? ( + {tsEnabled && !tsLoading && tsReachable ? ( <> + ) : tsEnabled && !tsLoading && !tsReachable ? ( + <> +
+ progress_activity + {tsEverReachable ? "Tailscale reconnecting..." : "Tailscale checking..."} +
+ + ) : (tsLoading || tsConnecting) ? ( <>
@@ -1211,11 +1243,7 @@ export default function APIPageClient({ machineId }) {
diff --git a/src/app/layout.js b/src/app/layout.js index 9839a5d..17aecd7 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -30,13 +30,25 @@ export default function RootLayout({ children }) { return ( - - {/* eslint-disable-next-line @next/next/no-page-custom-font */} + {/* Non-blocking icon font: preload + inject stylesheet via script */} +