mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
feat(db): migrate from lowdb to SQLite with repos pattern
- Add modular DB layer (adapters, migrations, repos, helpers) - Replace localDb/usageDb/requestDetailsDb monoliths with repos - Add Tailscale tunnel integration & status check API - Add /api/cli-tools/all-statuses aggregated endpoint - Add settingsStore (Zustand) and mitm/dbReader - Add DB unit tests (benchmark, concurrent, migration, vs-lowdb)
This commit is contained in:
@@ -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 <OpenClawToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} initialStatus={toolStatuses.openclaw} />;
|
||||
case "hermes":
|
||||
return <HermesToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} initialStatus={toolStatuses.hermes} />;
|
||||
case "copilot":
|
||||
return <CopilotToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} initialStatus={toolStatuses.copilot} />;
|
||||
default:
|
||||
return <DefaultToolCard key={toolId} toolId={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} tunnelEnabled={tunnelEnabled} />;
|
||||
}
|
||||
|
||||
@@ -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" : "<API_KEY_FROM_DASHBOARD>");
|
||||
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 (
|
||||
<Card padding="sm" className="overflow-hidden">
|
||||
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Card padding="xs" className="overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-3 hover:cursor-pointer sm:items-center" onClick={onToggle}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<Image src="/providers/copilot.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" sizes="32px" onError={(e) => { e.target.style.display = "none"; }} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{configStatus === "configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
|
||||
{configStatus === "not_configured" && <span className="px-1.5 py-0.5 text-[10px] font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
|
||||
@@ -196,7 +190,6 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
|
||||
|
||||
{!checking && (
|
||||
<>
|
||||
{/* Info */}
|
||||
<div className="flex items-start gap-3 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
|
||||
<span className="material-symbols-outlined text-blue-500 text-lg">info</span>
|
||||
<div className="text-xs text-blue-700 dark:text-blue-300">
|
||||
@@ -205,11 +198,13 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-text-muted">Select Endpoint</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Endpoint */}
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">Select Endpoint</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
<BaseUrlSelect
|
||||
value={customBaseUrl || getEffectiveBaseUrl()}
|
||||
value={customBaseUrl || getDisplayUrl()}
|
||||
onChange={setCustomBaseUrl}
|
||||
requiresExternalUrl={tool.requiresExternalUrl}
|
||||
tunnelEnabled={tunnelEnabled}
|
||||
@@ -220,53 +215,43 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-text-muted">API Key</label>
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr_auto] sm:items-center sm:gap-2">
|
||||
<span className="text-xs font-semibold text-text-main sm:text-right sm:text-sm">API Key</span>
|
||||
<span className="material-symbols-outlined hidden text-text-muted text-[14px] sm:inline">arrow_forward</span>
|
||||
{apiKeys.length > 0 || selectedApiKey ? (
|
||||
<select value={selectedApiKey} onChange={(e) => setSelectedApiKey(e.target.value)} 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">
|
||||
<select value={selectedApiKey} onChange={(e) => 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 && <option value={selectedApiKey}>{selectedApiKey}</option>}
|
||||
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-sm text-text-muted">
|
||||
<span className="min-w-0 rounded bg-surface/40 px-2 py-2 text-xs text-text-muted sm:py-1.5">
|
||||
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model input + Add */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-text-muted">
|
||||
Models {modelList.length > 0 && <span className="text-primary">({modelList.length} added)</span>}
|
||||
</label>
|
||||
|
||||
{/* Model list */}
|
||||
{modelList.length > 0 && (
|
||||
<div className="flex flex-col gap-1 mb-1">
|
||||
{modelList.map((id) => (
|
||||
<div key={id} className="flex items-center gap-2 px-3 py-1.5 bg-bg-secondary rounded-lg border border-border">
|
||||
<span className="flex-1 text-sm font-mono truncate">{id}</span>
|
||||
<button onClick={() => removeModel(id)} className="text-text-muted hover:text-red-500 transition-colors" title="Remove">
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{/* Models */}
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-[8rem_auto_1fr] sm:items-start sm:gap-2">
|
||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right pt-1">Models</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px] mt-1.5">arrow_forward</span>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div className="flex flex-wrap gap-1.5 min-h-[28px] px-2 py-1.5 bg-surface rounded border border-border">
|
||||
{selectedModels.length === 0 ? (
|
||||
<span className="text-xs text-text-muted">No models selected</span>
|
||||
) : (
|
||||
selectedModels.map((model) => (
|
||||
<span key={model} className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-black/5 dark:bg-white/5 text-text-muted border border-transparent hover:border-border">
|
||||
{model}
|
||||
<button onClick={(e) => { e.stopPropagation(); removeModel(model); }} className="ml-0.5 hover:text-red-500">
|
||||
<span className="material-symbols-outlined text-[12px]">close</span>
|
||||
</button>
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`px-2 py-1 rounded border text-xs transition-colors ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Add Model</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_auto_auto] sm:items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={modelInput}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`rounded-lg border px-3 py-2 text-sm transition-colors sm:shrink-0 ${activeProviders?.length ? "bg-bg-secondary border-border hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select</button>
|
||||
<button onClick={addModel} disabled={!modelInput.trim()} className="rounded-lg border border-border bg-bg-secondary px-3 py-2 text-sm transition-colors hover:border-primary disabled:opacity-50 sm:shrink-0" title="Add model">
|
||||
<span className="material-symbols-outlined text-[16px]">add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,13 +264,13 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
|
||||
<Button variant="primary" size="sm" onClick={handleApply} disabled={modelList.length === 0} loading={applying}>
|
||||
<Button variant="primary" size="sm" onClick={handleApply} disabled={selectedModels.length === 0} loading={applying}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleReset} disabled={!status?.has9Router} loading={restoring}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)} disabled={modelList.length === 0}>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowManualConfigModal(true)} disabled={selectedModels.length === 0}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">content_copy</span>Manual Config
|
||||
</Button>
|
||||
</div>
|
||||
@@ -297,11 +282,16 @@ export default function CopilotToolCard({ tool, isExpanded, onToggle, baseUrl, a
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => 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"
|
||||
/>
|
||||
|
||||
<ManualConfigModal
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, ba
|
||||
: (!cloudEnabled ? "sk_9router" : "your-api-key");
|
||||
|
||||
// Add /v1 suffix only if not already present (DRY - avoid duplicate)
|
||||
const normalizedBaseUrl = baseUrl || "http://localhost:20128";
|
||||
const normalizedBaseUrl = baseUrl || "http://127.0.0.1:20128";
|
||||
const baseUrlWithV1 = normalizedBaseUrl.endsWith("/v1")
|
||||
? normalizedBaseUrl
|
||||
: `${normalizedBaseUrl}/v1`;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button, Badge, Input } from "@/shared/components";
|
||||
|
||||
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
|
||||
const DEFAULT_MITM_ROUTER_BASE = "http://127.0.0.1:20128";
|
||||
|
||||
/**
|
||||
* Shared MITM infrastructure card — manages SSL cert + server start/stop.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Card, Button, Input, Modal, CardSkeleton, Toggle } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
@@ -15,6 +15,7 @@ const TUNNEL_BENEFITS = [
|
||||
const TUNNEL_PING_INTERVAL_MS = 2000;
|
||||
const TUNNEL_PING_MAX_MS = 300000;
|
||||
const STATUS_POLL_INTERVAL_MS = 5000;
|
||||
const REACHABLE_MISS_THRESHOLD = 2;
|
||||
|
||||
const CAVEMAN_LEVELS = [
|
||||
{ id: "lite", label: "Lite", desc: "Drop filler, keep grammar" },
|
||||
@@ -39,6 +40,7 @@ export default function APIPageClient({ machineId }) {
|
||||
// Cloudflare Tunnel state
|
||||
const [tunnelChecking, setTunnelChecking] = useState(true);
|
||||
const [tunnelEnabled, setTunnelEnabled] = useState(false);
|
||||
const [tunnelReachable, setTunnelReachable] = useState(false);
|
||||
const [tunnelUrl, setTunnelUrl] = useState("");
|
||||
const [tunnelPublicUrl, setTunnelPublicUrl] = useState("");
|
||||
const [tunnelLoading, setTunnelLoading] = useState(false);
|
||||
@@ -49,6 +51,7 @@ export default function APIPageClient({ machineId }) {
|
||||
|
||||
// Tailscale state
|
||||
const [tsEnabled, setTsEnabled] = useState(false);
|
||||
const [tsReachable, setTsReachable] = useState(false);
|
||||
const [tsUrl, setTsUrl] = useState("");
|
||||
const [tsLoading, setTsLoading] = useState(false);
|
||||
const [tsProgress, setTsProgress] = useState("");
|
||||
@@ -62,6 +65,17 @@ export default function APIPageClient({ machineId }) {
|
||||
const [showDisableTsModal, setShowDisableTsModal] = useState(false);
|
||||
const tsLogRef = useRef(null);
|
||||
|
||||
// Debounce reachable=false: server may briefly return false during background refresh.
|
||||
// Only flip UI to "reconnecting" after N consecutive misses to avoid spinner flicker.
|
||||
const tunnelMissRef = useRef(0);
|
||||
const tsMissRef = useRef(0);
|
||||
// Track whether reachable=true was ever observed in this session.
|
||||
// Distinguishes "Checking..." (initial cold cache) from "Reconnecting..." (lost connection).
|
||||
const tunnelEverReachableRef = useRef(false);
|
||||
const tsEverReachableRef = useRef(false);
|
||||
const [tunnelEverReachable, setTunnelEverReachable] = useState(false);
|
||||
const [tsEverReachable, setTsEverReachable] = useState(false);
|
||||
|
||||
// API key visibility toggle state
|
||||
const [visibleKeys, setVisibleKeys] = useState(new Set());
|
||||
|
||||
@@ -85,6 +99,23 @@ export default function APIPageClient({ machineId }) {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update reachable state with miss-debounce: avoids spinner flicker when server
|
||||
// briefly returns reachable=false during background probe refresh.
|
||||
// Also flips everReachable on first success (UI uses it to distinguish Checking vs Reconnecting).
|
||||
const updateReachable = useCallback((reachable, missRef, setter, everRef, everSetter) => {
|
||||
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 }) {
|
||||
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[88px] text-center ${
|
||||
tunnelEnabled ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
|
||||
}`}>Tunnel</span>
|
||||
{tunnelEnabled && !tunnelLoading ? (
|
||||
{tunnelEnabled && !tunnelLoading && tunnelReachable ? (
|
||||
<>
|
||||
<Input value={`${tunnelPublicUrl || tunnelUrl}/v1`} readOnly className="flex-1 font-mono text-sm" />
|
||||
<button
|
||||
@@ -702,6 +706,20 @@ export default function APIPageClient({ machineId }) {
|
||||
<span className="material-symbols-outlined text-[18px]">power_settings_new</span>
|
||||
</button>
|
||||
</>
|
||||
) : tunnelEnabled && !tunnelLoading && !tunnelReachable ? (
|
||||
<>
|
||||
<div className="flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-amber-300 dark:border-amber-800 bg-amber-500/5 text-sm text-amber-600 dark:text-amber-400">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
|
||||
{tunnelEverReachable ? "Tunnel reconnecting..." : "Tunnel checking..."}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDisableTunnelModal(true)}
|
||||
className="p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
|
||||
title="Disable Tunnel"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">power_settings_new</span>
|
||||
</button>
|
||||
</>
|
||||
) : tunnelLoading ? (
|
||||
<>
|
||||
<div className="flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-border bg-input text-sm text-text-muted">
|
||||
@@ -759,7 +777,7 @@ export default function APIPageClient({ machineId }) {
|
||||
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[88px] text-center ${
|
||||
tsEnabled ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
|
||||
}`}>Tailscale</span>
|
||||
{tsEnabled && !tsLoading ? (
|
||||
{tsEnabled && !tsLoading && tsReachable ? (
|
||||
<>
|
||||
<Input value={`${tsUrl}/v1`} readOnly className="flex-1 font-mono text-sm" />
|
||||
<button
|
||||
@@ -776,6 +794,20 @@ export default function APIPageClient({ machineId }) {
|
||||
<span className="material-symbols-outlined text-[18px]">power_settings_new</span>
|
||||
</button>
|
||||
</>
|
||||
) : tsEnabled && !tsLoading && !tsReachable ? (
|
||||
<>
|
||||
<div className="flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-amber-300 dark:border-amber-800 bg-amber-500/5 text-sm text-amber-600 dark:text-amber-400">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
|
||||
{tsEverReachable ? "Tailscale reconnecting..." : "Tailscale checking..."}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDisableTsModal(true)}
|
||||
className="p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
|
||||
title="Disable Tailscale"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">power_settings_new</span>
|
||||
</button>
|
||||
</>
|
||||
) : (tsLoading || tsConnecting) ? (
|
||||
<>
|
||||
<div className="flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-border bg-input text-sm text-text-muted">
|
||||
@@ -1211,11 +1243,7 @@ export default function APIPageClient({ machineId }) {
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
const tab = window.open("", "tailscale_auth", "width=600,height=700");
|
||||
if (tab) tab.document.write("<p style='font-family:sans-serif;text-align:center;margin-top:40px'>Connecting to Tailscale...</p>");
|
||||
handleConnectTailscale(tab);
|
||||
}}
|
||||
onClick={() => handleConnectTailscale()}
|
||||
fullWidth
|
||||
>
|
||||
Connect
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useParams, notFound, useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, Badge, Button, AddCustomEmbeddingModal } from "@/shared/components";
|
||||
import { Card, Badge, Button, Toggle, AddCustomEmbeddingModal } from "@/shared/components";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers";
|
||||
|
||||
@@ -19,7 +19,7 @@ function getEffectiveStatus(conn) {
|
||||
return conn.testStatus === "unavailable" && !isCooldown ? "active" : conn.testStatus;
|
||||
}
|
||||
|
||||
function MediaProviderCard({ provider, kind, connections, isCustom }) {
|
||||
function MediaProviderCard({ provider, kind, connections, isCustom, onToggle }) {
|
||||
const providerInfo = AI_PROVIDERS[provider.id];
|
||||
const isNoAuth = !!providerInfo?.noAuth;
|
||||
|
||||
@@ -29,6 +29,12 @@ function MediaProviderCard({ provider, kind, connections, isCustom }) {
|
||||
const total = providerConns.length;
|
||||
const allDisabled = total > 0 && providerConns.every((c) => c.isActive === false);
|
||||
|
||||
const handleToggleClick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onToggle) onToggle(provider.id, allDisabled);
|
||||
};
|
||||
|
||||
const renderStatus = () => {
|
||||
if (isNoAuth) return <Badge variant="success" size="sm">Ready</Badge>;
|
||||
if (allDisabled) return <Badge variant="default" size="sm">Disabled</Badge>;
|
||||
@@ -48,27 +54,42 @@ function MediaProviderCard({ provider, kind, connections, isCustom }) {
|
||||
padding="xs"
|
||||
className={`h-full hover:bg-black/[0.01] dark:hover:bg-white/[0.01] transition-colors cursor-pointer ${allDisabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className="size-8 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: `${provider.color?.length > 7 ? provider.color : (provider.color ?? "#888") + "15"}` }}
|
||||
>
|
||||
<ProviderIcon
|
||||
src={`/providers/${provider.id}.png`}
|
||||
alt={provider.name}
|
||||
size={30}
|
||||
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
|
||||
fallbackText={provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
||||
fallbackColor={provider.color}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">{provider.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{isCustom && <Badge variant="default" size="sm">Custom</Badge>}
|
||||
{renderStatus()}
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div
|
||||
className="size-8 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{ backgroundColor: `${provider.color?.length > 7 ? provider.color : (provider.color ?? "#888") + "15"}` }}
|
||||
>
|
||||
<ProviderIcon
|
||||
src={`/providers/${provider.id}.png`}
|
||||
alt={provider.name}
|
||||
size={30}
|
||||
className="object-contain rounded-lg max-w-[30px] max-h-[30px]"
|
||||
fallbackText={provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
|
||||
fallbackColor={provider.color}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-semibold text-sm">{provider.name}</h3>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{isCustom && <Badge variant="default" size="sm">Custom</Badge>}
|
||||
{renderStatus()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{total > 0 && (
|
||||
<div
|
||||
className="shrink-0 opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100"
|
||||
onClick={handleToggleClick}
|
||||
>
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={!allDisabled}
|
||||
onChange={() => {}}
|
||||
title={allDisabled ? "Enable provider" : "Disable provider"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
@@ -170,6 +191,22 @@ export default function MediaProviderKindPage() {
|
||||
|
||||
const allProviders = [...providers, ...customProviders];
|
||||
|
||||
const handleToggleProvider = async (providerId, newActive) => {
|
||||
const providerConns = connections.filter((c) => c.provider === providerId);
|
||||
setConnections((prev) =>
|
||||
prev.map((c) => (c.provider === providerId ? { ...c, isActive: newActive } : c))
|
||||
);
|
||||
await Promise.allSettled(
|
||||
providerConns.map((c) =>
|
||||
fetch(`/api/providers/${c.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isActive: newActive }),
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateCombo = async () => {
|
||||
const base = COMBO_BASE_NAMES[kind] || `${kind}-combo`;
|
||||
let name = base;
|
||||
@@ -221,6 +258,7 @@ export default function MediaProviderKindPage() {
|
||||
provider={provider}
|
||||
kind={kind}
|
||||
connections={connections}
|
||||
onToggle={handleToggleProvider}
|
||||
/>
|
||||
))}
|
||||
{customProviders.map((provider) => (
|
||||
@@ -230,6 +268,7 @@ export default function MediaProviderKindPage() {
|
||||
kind={kind}
|
||||
connections={connections}
|
||||
isCustom
|
||||
onToggle={handleToggleProvider}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -235,7 +235,7 @@ export default function ComboDetailPage() {
|
||||
const examplePath = EXAMPLE_PATHS[combo.kind];
|
||||
const exampleBody = combo.kind && EXAMPLE_BODIES[combo.kind] ? EXAMPLE_BODIES[combo.kind](combo.name) : null;
|
||||
const curlExample = examplePath
|
||||
? `curl -X POST http://localhost:20128${examplePath} \\\n -H "Content-Type: application/json" \\\n -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\\n -d '${JSON.stringify(exampleBody)}'`
|
||||
? `curl -X POST http://127.0.0.1:20128${examplePath} \\\n -H "Content-Type: application/json" \\\n -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\\n -d '${JSON.stringify(exampleBody)}'`
|
||||
: "";
|
||||
const backHref = getListingHref(combo.kind);
|
||||
|
||||
|
||||
@@ -389,7 +389,7 @@ export default function ProfilePage() {
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 rounded-lg bg-bg border border-border gap-2">
|
||||
<div>
|
||||
<p className="font-medium text-sm sm:text-base">Database Location</p>
|
||||
<p className="text-xs sm:text-sm text-text-muted font-mono break-all">~/.9router/db.json</p>
|
||||
<p className="text-xs sm:text-sm text-text-muted font-mono break-all">~/.9router/db/data.sqlite</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { GET as claudeGet } from "../claude-settings/route";
|
||||
import { GET as codexGet } from "../codex-settings/route";
|
||||
import { GET as opencodeGet } from "../opencode-settings/route";
|
||||
import { GET as droidGet } from "../droid-settings/route";
|
||||
import { GET as openclawGet } from "../openclaw-settings/route";
|
||||
import { GET as hermesGet } from "../hermes-settings/route";
|
||||
import { GET as coworkGet } from "../cowork-settings/route";
|
||||
import { GET as copilotGet } from "../copilot-settings/route";
|
||||
|
||||
const STATUS_GETTERS = {
|
||||
claude: claudeGet,
|
||||
codex: codexGet,
|
||||
opencode: opencodeGet,
|
||||
droid: droidGet,
|
||||
openclaw: openclawGet,
|
||||
hermes: hermesGet,
|
||||
cowork: coworkGet,
|
||||
copilot: copilotGet,
|
||||
};
|
||||
|
||||
// Batch endpoint: gather all CLI tool statuses in one round-trip
|
||||
export async function GET() {
|
||||
const entries = await Promise.all(
|
||||
Object.entries(STATUS_GETTERS).map(async ([toolId, getter]) => {
|
||||
try {
|
||||
const res = await getter();
|
||||
const data = await res.json();
|
||||
return [toolId, data];
|
||||
} catch {
|
||||
return [toolId, null];
|
||||
}
|
||||
})
|
||||
);
|
||||
return NextResponse.json(Object.fromEntries(entries));
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
|
||||
initDbHooks(getSettings, updateSettings);
|
||||
|
||||
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
|
||||
const DEFAULT_MITM_ROUTER_BASE = "http://127.0.0.1:20128";
|
||||
|
||||
function normalizeMitmRouterBaseUrlInput(input) {
|
||||
if (input == null || String(input).trim() === "") {
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
import os from "os";
|
||||
import { execSync } from "child_process";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { NextResponse } from "next/server";
|
||||
import { isTailscaleInstalled, isTailscaleLoggedIn, TAILSCALE_SOCKET } from "@/lib/tunnel/tailscale";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`;
|
||||
const PROBE_TIMEOUT_MS = 1500;
|
||||
|
||||
function hasBrew() {
|
||||
try { execSync("which brew", { stdio: "ignore", windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH } }); return true; } catch { return false; }
|
||||
async function hasBrew() {
|
||||
try {
|
||||
await execAsync("which brew", { windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: PROBE_TIMEOUT_MS });
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
function isDaemonRunning() {
|
||||
async function isDaemonRunning() {
|
||||
try {
|
||||
// Use custom socket + --json; exit 0 even when not logged in
|
||||
execSync(`tailscale --socket ${TAILSCALE_SOCKET} status --json`, {
|
||||
stdio: "ignore",
|
||||
await execAsync(`tailscale --socket ${TAILSCALE_SOCKET} status --json`, {
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PATH: EXTENDED_PATH },
|
||||
timeout: 3000
|
||||
timeout: PROBE_TIMEOUT_MS
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
// Fallback: check if tailscaled process is alive
|
||||
try {
|
||||
execSync("pgrep -x tailscaled", { stdio: "ignore", windowsHide: true, timeout: 2000 });
|
||||
await execAsync("pgrep -x tailscaled", { windowsHide: true, timeout: PROBE_TIMEOUT_MS });
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
@@ -32,8 +35,11 @@ export async function GET() {
|
||||
try {
|
||||
const installed = isTailscaleInstalled();
|
||||
const platform = os.platform();
|
||||
const brewAvailable = platform === "darwin" && hasBrew();
|
||||
const daemonRunning = installed ? isDaemonRunning() : false;
|
||||
// Run independent probes in parallel — none blocks the event loop
|
||||
const [brewAvailable, daemonRunning] = await Promise.all([
|
||||
platform === "darwin" ? hasBrew() : Promise.resolve(false),
|
||||
installed ? isDaemonRunning() : Promise.resolve(false),
|
||||
]);
|
||||
const loggedIn = daemonRunning ? isTailscaleLoggedIn() : false;
|
||||
return NextResponse.json({ installed, loggedIn, platform, brewAvailable, daemonRunning });
|
||||
} catch (error) {
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function GetStarted() {
|
||||
<div className="flex-none w-8 h-8 rounded-full bg-[#f97815]/20 text-[#f97815] flex items-center justify-center font-bold">3</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-lg">Route Requests</h4>
|
||||
<p className="text-sm text-gray-500 mt-1">Point your CLI tools to http://localhost:20128</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Point your CLI tools to http://127.0.0.1:20128</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,8 +72,8 @@ export default function GetStarted() {
|
||||
|
||||
<div className="text-gray-400 mb-6">
|
||||
<span className="text-[#f97815]">></span> Starting 9Router...<br/>
|
||||
<span className="text-[#f97815]">></span> Server running on <span className="text-blue-400">http://localhost:20128</span><br/>
|
||||
<span className="text-[#f97815]">></span> Dashboard: <span className="text-blue-400">http://localhost:20128/dashboard</span><br/>
|
||||
<span className="text-[#f97815]">></span> Server running on <span className="text-blue-400">http://127.0.0.1:20128</span><br/>
|
||||
<span className="text-[#f97815]">></span> Dashboard: <span className="text-blue-400">http://127.0.0.1:20128/dashboard</span><br/>
|
||||
<span className="text-green-400">></span> Ready to route! ✓
|
||||
</div>
|
||||
|
||||
@@ -83,8 +83,8 @@ export default function GetStarted() {
|
||||
|
||||
<div className="text-gray-400 text-xs">
|
||||
<span className="text-purple-400">Data Location:</span><br/>
|
||||
<span className="text-gray-500"> macOS/Linux:</span> ~/.9router/db.json<br/>
|
||||
<span className="text-gray-500"> Windows:</span> %APPDATA%/9router/db.json
|
||||
<span className="text-gray-500"> macOS/Linux:</span> ~/.9router/db/data.sqlite<br/>
|
||||
<span className="text-gray-500"> Windows:</span> %APPDATA%/9router/db/data.sqlite
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+15
-3
@@ -30,13 +30,25 @@ export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||
{/* eslint-disable-next-line @next/next/no-page-custom-font */}
|
||||
{/* Non-blocking icon font: preload + inject stylesheet via script */}
|
||||
<link
|
||||
rel="preload"
|
||||
as="style"
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){var l=document.createElement('link');l.rel='stylesheet';l.href='https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap';document.head.appendChild(l);})();`,
|
||||
}}
|
||||
/>
|
||||
<noscript>
|
||||
{/* eslint-disable-next-line @next/next/no-page-custom-font */}
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</noscript>
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<ThemeProvider>
|
||||
|
||||
Reference in New Issue
Block a user