mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
feat :
- Added tunnel - Removed cloud feature
This commit is contained in:
@@ -457,7 +457,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
||||
|
||||
} catch (error) {
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
trackPendingRequest(model, provider, connectionId, false, true);
|
||||
appendRequestLog({ model, provider, connectionId, status: `FAILED ${error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY}` }).catch(() => { });
|
||||
|
||||
const errorDetail = {
|
||||
@@ -532,7 +532,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
|
||||
// Check provider response - return error info for fallback handling
|
||||
if (!providerResponse.ok) {
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
trackPendingRequest(model, provider, connectionId, false, true);
|
||||
const { statusCode, message, retryAfterMs } = await parseUpstreamError(providerResponse, provider);
|
||||
appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { });
|
||||
|
||||
@@ -880,8 +880,8 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
console.error("[RequestDetail] Failed to update streaming content:", err.message);
|
||||
});
|
||||
|
||||
// Save usage stats for dashboard
|
||||
if (usage && typeof usage === 'object') {
|
||||
// Save usage stats for dashboard (skip if no real token data to avoid duplicates)
|
||||
if (usage && typeof usage === 'object' && (usage.prompt_tokens > 0 || usage.completion_tokens > 0)) {
|
||||
const msg = `[${new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" })}] 📊 [STREAM USAGE] ${provider.toUpperCase()} | in=${usage?.prompt_tokens || 0} | out=${usage?.completion_tokens || 0}${connectionId ? ` | account=${connectionId.slice(0, 8)}...` : ""}`;
|
||||
console.log(`${COLORS.green}${msg}${COLORS.reset}`);
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ export default function CLIToolsPageClient({ machineId }) {
|
||||
const [expandedTool, setExpandedTool] = useState(null);
|
||||
const [modelMappings, setModelMappings] = useState({});
|
||||
const [cloudEnabled, setCloudEnabled] = useState(false);
|
||||
const [tunnelEnabled, setTunnelEnabled] = useState(false);
|
||||
const [tunnelUrl, setTunnelUrl] = useState("");
|
||||
const [apiKeys, setApiKeys] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -24,13 +26,21 @@ export default function CLIToolsPageClient({ machineId }) {
|
||||
|
||||
const loadCloudSettings = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const [settingsRes, tunnelRes] = await Promise.all([
|
||||
fetch("/api/settings"),
|
||||
fetch("/api/tunnel/status"),
|
||||
]);
|
||||
if (settingsRes.ok) {
|
||||
const data = await settingsRes.json();
|
||||
setCloudEnabled(data.cloudEnabled || false);
|
||||
}
|
||||
if (tunnelRes.ok) {
|
||||
const data = await tunnelRes.json();
|
||||
setTunnelEnabled(data.enabled || false);
|
||||
setTunnelUrl(data.tunnelUrl || "");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error loading cloud settings:", error);
|
||||
console.log("Error loading settings:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -108,6 +118,9 @@ export default function CLIToolsPageClient({ machineId }) {
|
||||
}, []);
|
||||
|
||||
const getBaseUrl = () => {
|
||||
if (tunnelEnabled && tunnelUrl) {
|
||||
return tunnelUrl;
|
||||
}
|
||||
if (cloudEnabled && CLOUD_URL) {
|
||||
return CLOUD_URL;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ export default function ClaudeToolCard({
|
||||
if (!currentUrl) return "not_configured";
|
||||
const localMatch = currentUrl.includes("localhost") || currentUrl.includes("127.0.0.1");
|
||||
const cloudMatch = cloudEnabled && CLOUD_URL && currentUrl.startsWith(CLOUD_URL);
|
||||
if (localMatch || cloudMatch) return "configured";
|
||||
const tunnelMatch = baseUrl && currentUrl.startsWith(baseUrl);
|
||||
if (localMatch || cloudMatch || tunnelMatch) return "configured";
|
||||
return "other";
|
||||
};
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ export default function DroidToolCard({
|
||||
if (!currentConfig) return "not_configured";
|
||||
const localMatch = currentConfig.baseUrl?.includes("localhost") || currentConfig.baseUrl?.includes("127.0.0.1");
|
||||
const cloudMatch = cloudEnabled && CLOUD_URL && currentConfig.baseUrl?.startsWith(CLOUD_URL);
|
||||
if (localMatch || cloudMatch) return "configured";
|
||||
const tunnelMatch = baseUrl && currentConfig.baseUrl?.startsWith(baseUrl);
|
||||
if (localMatch || cloudMatch || tunnelMatch) return "configured";
|
||||
return "other";
|
||||
};
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ export default function OpenClawToolCard({
|
||||
const currentProvider = openclawStatus.settings?.models?.providers?.["9router"];
|
||||
if (!currentProvider) return "not_configured";
|
||||
const localMatch = currentProvider.baseUrl?.includes("localhost") || currentProvider.baseUrl?.includes("127.0.0.1") || currentProvider.baseUrl?.includes("0.0.0.0");
|
||||
if (localMatch) return "configured";
|
||||
const tunnelMatch = baseUrl && currentProvider.baseUrl?.startsWith(baseUrl);
|
||||
if (localMatch || tunnelMatch) return "configured";
|
||||
return "other";
|
||||
};
|
||||
|
||||
|
||||
@@ -5,8 +5,19 @@ import PropTypes from "prop-types";
|
||||
import { Card, Button, Input, Modal, CardSkeleton, Toggle } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
/* ========== CLOUD CODE — COMMENTED OUT (replaced by Tunnel) ==========
|
||||
const DEFAULT_CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL || "";
|
||||
const CLOUD_ACTION_TIMEOUT_MS = 15000;
|
||||
========== END CLOUD CODE ========== */
|
||||
|
||||
const TUNNEL_BENEFITS = [
|
||||
{ icon: "public", title: "Access Anywhere", desc: "Use your API from any network" },
|
||||
{ icon: "group", title: "Share Endpoint", desc: "Share URL with team members" },
|
||||
{ icon: "code", title: "Use in Cursor/Cline", desc: "Connect AI tools remotely" },
|
||||
{ icon: "lock", title: "Encrypted", desc: "End-to-end TLS via Cloudflare" },
|
||||
];
|
||||
|
||||
const TUNNEL_ACTION_TIMEOUT_MS = 90000;
|
||||
|
||||
export default function APIPageClient({ machineId }) {
|
||||
const [keys, setKeys] = useState([]);
|
||||
@@ -15,8 +26,7 @@ export default function APIPageClient({ machineId }) {
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [createdKey, setCreatedKey] = useState(null);
|
||||
|
||||
// Cloud sync state
|
||||
const [requireApiKey, setRequireApiKey] = useState(false);
|
||||
/* ========== CLOUD STATE — COMMENTED OUT (replaced by Tunnel) ==========
|
||||
const [cloudEnabled, setCloudEnabled] = useState(false);
|
||||
const [cloudUrl, setCloudUrl] = useState(DEFAULT_CLOUD_URL);
|
||||
const [cloudUrlInput, setCloudUrlInput] = useState(DEFAULT_CLOUD_URL);
|
||||
@@ -27,15 +37,28 @@ export default function APIPageClient({ machineId }) {
|
||||
const [setupStatus, setSetupStatus] = useState(null);
|
||||
const [cloudSyncing, setCloudSyncing] = useState(false);
|
||||
const [cloudStatus, setCloudStatus] = useState(null);
|
||||
const [syncStep, setSyncStep] = useState(""); // "syncing" | "verifying" | "disabling" | ""
|
||||
const [syncStep, setSyncStep] = useState("");
|
||||
========== END CLOUD STATE ========== */
|
||||
|
||||
// Tunnel state
|
||||
const [requireApiKey, setRequireApiKey] = useState(false);
|
||||
const [tunnelEnabled, setTunnelEnabled] = useState(false);
|
||||
const [tunnelUrl, setTunnelUrl] = useState("");
|
||||
const [tunnelShortId, setTunnelShortId] = useState("");
|
||||
const [tunnelLoading, setTunnelLoading] = useState(false);
|
||||
const [tunnelProgress, setTunnelProgress] = useState("");
|
||||
const [tunnelStatus, setTunnelStatus] = useState(null);
|
||||
const [showDisableModal, setShowDisableModal] = useState(false);
|
||||
const [showEnableModal, setShowEnableModal] = useState(false);
|
||||
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
loadCloudSettings();
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
/* ========== CLOUD FUNCTIONS — COMMENTED OUT (replaced by Tunnel) ==========
|
||||
const postCloudAction = async (action, timeoutMs = CLOUD_ACTION_TIMEOUT_MS) => {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
@@ -74,33 +97,6 @@ export default function APIPageClient({ machineId }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequireApiKey = async (value) => {
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requireApiKey: value }),
|
||||
});
|
||||
if (res.ok) setRequireApiKey(value);
|
||||
} catch (error) {
|
||||
console.log("Error updating requireApiKey:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const keysRes = await fetch("/api/keys");
|
||||
const keysData = await keysRes.json();
|
||||
if (keysRes.ok) {
|
||||
setKeys(keysData.keys || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloudToggle = (checked) => {
|
||||
if (checked) {
|
||||
setShowCloudModal(true);
|
||||
@@ -116,24 +112,16 @@ export default function APIPageClient({ machineId }) {
|
||||
const { ok, data } = await postCloudAction("enable");
|
||||
if (ok) {
|
||||
setSyncStep("verifying");
|
||||
|
||||
if (data.verified) {
|
||||
setCloudEnabled(true);
|
||||
setCloudStatus({ type: "success", message: "Cloud Proxy connected and verified!" });
|
||||
setShowCloudModal(false);
|
||||
} else {
|
||||
setCloudEnabled(true);
|
||||
setCloudStatus({
|
||||
type: "warning",
|
||||
message: data.verifyError || "Connected but verification failed"
|
||||
});
|
||||
setCloudStatus({ type: "warning", message: data.verifyError || "Connected but verification failed" });
|
||||
setShowCloudModal(false);
|
||||
}
|
||||
|
||||
// Refresh keys list if new key was created
|
||||
if (data.createdKey) {
|
||||
await fetchData();
|
||||
}
|
||||
if (data.createdKey) await fetchData();
|
||||
} else {
|
||||
setCloudStatus({ type: "error", message: data.error || "Failed to enable cloud" });
|
||||
}
|
||||
@@ -148,16 +136,10 @@ export default function APIPageClient({ machineId }) {
|
||||
const handleConfirmDisable = async () => {
|
||||
setCloudSyncing(true);
|
||||
setSyncStep("syncing");
|
||||
|
||||
try {
|
||||
// Step 1: Sync latest data from cloud
|
||||
await postCloudAction("sync");
|
||||
|
||||
setSyncStep("disabling");
|
||||
|
||||
// Step 2: Disable cloud
|
||||
const { ok, data } = await postCloudAction("disable");
|
||||
|
||||
if (ok) {
|
||||
setCloudEnabled(false);
|
||||
setCloudStatus({ type: "success", message: "Cloud disabled" });
|
||||
@@ -166,7 +148,6 @@ export default function APIPageClient({ machineId }) {
|
||||
setCloudStatus({ type: "error", message: data.error || "Failed to disable cloud" });
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error disabling cloud:", error);
|
||||
setCloudStatus({ type: "error", message: "Failed to disable cloud" });
|
||||
} finally {
|
||||
setCloudSyncing(false);
|
||||
@@ -176,15 +157,11 @@ export default function APIPageClient({ machineId }) {
|
||||
|
||||
const handleSyncCloud = async () => {
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
setCloudSyncing(true);
|
||||
try {
|
||||
const { ok, data } = await postCloudAction("sync");
|
||||
if (ok) {
|
||||
setCloudStatus({ type: "success", message: "Synced successfully" });
|
||||
} else {
|
||||
setCloudStatus({ type: "error", message: data.error });
|
||||
}
|
||||
if (ok) setCloudStatus({ type: "success", message: "Synced successfully" });
|
||||
else setCloudStatus({ type: "error", message: data.error });
|
||||
} catch (error) {
|
||||
setCloudStatus({ type: "error", message: error.message });
|
||||
} finally {
|
||||
@@ -193,10 +170,8 @@ export default function APIPageClient({ machineId }) {
|
||||
};
|
||||
|
||||
const handleSaveCloudUrl = async () => {
|
||||
// Strip trailing /v1 or /v1/ and trailing slashes
|
||||
const trimmed = cloudUrlInput.trim().replace(/\/v1\/?$/, "").replace(/\/+$/, "");
|
||||
if (!trimmed) return;
|
||||
|
||||
setCloudUrlSaving(true);
|
||||
setSetupStatus(null);
|
||||
try {
|
||||
@@ -225,17 +200,128 @@ export default function APIPageClient({ machineId }) {
|
||||
setSetupStatus(null);
|
||||
try {
|
||||
const { ok, data } = await postCloudAction("check", 8000);
|
||||
if (ok) {
|
||||
setSetupStatus({ type: "success", message: data.message || "Worker is running" });
|
||||
} else {
|
||||
setSetupStatus({ type: "error", message: data.error || "Check failed" });
|
||||
}
|
||||
if (ok) setSetupStatus({ type: "success", message: data.message || "Worker is running" });
|
||||
else setSetupStatus({ type: "error", message: data.error || "Check failed" });
|
||||
} catch {
|
||||
setSetupStatus({ type: "error", message: "Cannot reach worker" });
|
||||
} finally {
|
||||
setCloudSyncing(false);
|
||||
}
|
||||
};
|
||||
========== END CLOUD FUNCTIONS ========== */
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const [settingsRes, tunnelRes] = await Promise.all([
|
||||
fetch("/api/settings"),
|
||||
fetch("/api/tunnel/status")
|
||||
]);
|
||||
if (settingsRes.ok) {
|
||||
const data = await settingsRes.json();
|
||||
setRequireApiKey(data.requireApiKey || false);
|
||||
}
|
||||
if (tunnelRes.ok) {
|
||||
const data = await tunnelRes.json();
|
||||
setTunnelEnabled(data.enabled || false);
|
||||
setTunnelUrl(data.tunnelUrl || "");
|
||||
setTunnelShortId(data.shortId || "");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error loading settings:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequireApiKey = async (value) => {
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ requireApiKey: value }),
|
||||
});
|
||||
if (res.ok) setRequireApiKey(value);
|
||||
} catch (error) {
|
||||
console.log("Error updating requireApiKey:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const keysRes = await fetch("/api/keys");
|
||||
const keysData = await keysRes.json();
|
||||
if (keysRes.ok) {
|
||||
setKeys(keysData.keys || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching data:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnableTunnel = async () => {
|
||||
setShowEnableModal(false);
|
||||
setTunnelLoading(true);
|
||||
setTunnelStatus(null);
|
||||
setTunnelProgress("Connecting to server...");
|
||||
|
||||
const progressSteps = [
|
||||
{ delay: 2000, msg: "Creating tunnel..." },
|
||||
{ delay: 5000, msg: "Starting cloudflared..." },
|
||||
{ delay: 15000, msg: "Establishing connections..." },
|
||||
{ delay: 30000, msg: "Waiting for tunnel ready..." },
|
||||
];
|
||||
const timers = progressSteps.map(({ delay, msg }) =>
|
||||
setTimeout(() => setTunnelProgress(msg), delay)
|
||||
);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), TUNNEL_ACTION_TIMEOUT_MS);
|
||||
const res = await fetch("/api/tunnel/enable", {
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
timers.forEach(clearTimeout);
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setTunnelEnabled(true);
|
||||
setTunnelUrl(data.tunnelUrl || "");
|
||||
setTunnelShortId(data.shortId || "");
|
||||
setTunnelStatus({ type: "success", message: "Tunnel connected!" });
|
||||
} else {
|
||||
setTunnelStatus({ type: "error", message: data.error || "Failed to enable tunnel" });
|
||||
}
|
||||
} catch (error) {
|
||||
timers.forEach(clearTimeout);
|
||||
const msg = error?.name === "AbortError" ? "Tunnel creation timed out" : error.message;
|
||||
setTunnelStatus({ type: "error", message: msg });
|
||||
} finally {
|
||||
setTunnelLoading(false);
|
||||
setTunnelProgress("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisableTunnel = async () => {
|
||||
setTunnelLoading(true);
|
||||
setTunnelStatus(null);
|
||||
try {
|
||||
const res = await fetch("/api/tunnel/disable", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setTunnelEnabled(false);
|
||||
setTunnelUrl("");
|
||||
setTunnelStatus({ type: "success", message: "Tunnel disabled" });
|
||||
setShowDisableModal(false);
|
||||
} else {
|
||||
setTunnelStatus({ type: "error", message: data.error || "Failed to disable tunnel" });
|
||||
}
|
||||
} catch (error) {
|
||||
setTunnelStatus({ type: "error", message: error.message });
|
||||
} finally {
|
||||
setTunnelLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
if (!newKeyName.trim()) return;
|
||||
@@ -288,7 +374,6 @@ export default function APIPageClient({ machineId }) {
|
||||
};
|
||||
|
||||
const [baseUrl, setBaseUrl] = useState("/v1");
|
||||
const cloudEndpointNew = cloudUrl ? `${cloudUrl}/v1` : "";
|
||||
|
||||
// Hydration fix: Only access window on client side
|
||||
useEffect(() => {
|
||||
@@ -306,8 +391,7 @@ export default function APIPageClient({ machineId }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Use new format endpoint (machineId embedded in key)
|
||||
const currentEndpoint = cloudEnabled ? cloudEndpointNew : baseUrl;
|
||||
const currentEndpoint = tunnelEnabled && tunnelUrl ? `${tunnelUrl}/v1` : baseUrl;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -317,38 +401,35 @@ export default function APIPageClient({ machineId }) {
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">API Endpoint</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
{cloudEnabled ? "Using Cloud Proxy" : "Using Local Server"}
|
||||
{tunnelEnabled ? "Using Tunnel" : "Using Local Server"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="settings"
|
||||
onClick={() => setShowSetupModal(true)}
|
||||
>
|
||||
Setup Cloudflare
|
||||
</Button>
|
||||
{cloudEnabled ? (
|
||||
{tunnelEnabled ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="cloud_off"
|
||||
onClick={() => handleCloudToggle(false)}
|
||||
disabled={cloudSyncing}
|
||||
onClick={() => setShowDisableModal(true)}
|
||||
disabled={tunnelLoading}
|
||||
className="bg-red-500/10! text-red-500! hover:bg-red-500/20! border-red-500/30!"
|
||||
>
|
||||
Disable Cloud
|
||||
Disable Tunnel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon="cloud_upload"
|
||||
onClick={() => handleCloudToggle(true)}
|
||||
disabled={cloudSyncing || !cloudUrl}
|
||||
onClick={() => setShowEnableModal(true)}
|
||||
disabled={tunnelLoading}
|
||||
className="bg-linear-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600"
|
||||
>
|
||||
Enable Cloud
|
||||
{tunnelLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
|
||||
{tunnelProgress || "Creating tunnel..."}
|
||||
</span>
|
||||
) : "Enable Tunnel"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -359,7 +440,7 @@ export default function APIPageClient({ machineId }) {
|
||||
<Input
|
||||
value={currentEndpoint}
|
||||
readOnly
|
||||
className={`flex-1 font-mono text-sm ${cloudEnabled ? "animate-border-glow" : ""}`}
|
||||
className={`flex-1 font-mono text-sm ${tunnelEnabled ? "animate-border-glow" : ""}`}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -370,14 +451,14 @@ export default function APIPageClient({ machineId }) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Cloud Status */}
|
||||
{cloudStatus && (
|
||||
{/* Tunnel Status */}
|
||||
{tunnelStatus && (
|
||||
<div className={`mt-3 p-2 rounded text-sm ${
|
||||
cloudStatus.type === "success" ? "bg-green-500/10 text-green-600 dark:text-green-400" :
|
||||
cloudStatus.type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" :
|
||||
tunnelStatus.type === "success" ? "bg-green-500/10 text-green-600 dark:text-green-400" :
|
||||
tunnelStatus.type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" :
|
||||
"bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
}`}>
|
||||
{cloudStatus.message}
|
||||
{tunnelStatus.message}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
@@ -470,133 +551,9 @@ export default function APIPageClient({ machineId }) {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Setup Cloud Modal */}
|
||||
<Modal
|
||||
isOpen={showSetupModal}
|
||||
title="Setup Cloudflare Worker"
|
||||
onClose={() => { setShowSetupModal(false); setSetupStatus(null); }}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
<code className="font-semibold">https://9router.com</code> is a pre-configured worker ready to use. You can also deploy your own.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Worker URL</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={cloudUrlInput}
|
||||
onChange={(e) => setCloudUrlInput(e.target.value)}
|
||||
placeholder="https://9router.your-subdomain.workers.dev"
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-2">
|
||||
Deploy your own worker from <code className="text-xs bg-sidebar px-1 py-0.5 rounded">app/cloud/</code> directory.{" "}
|
||||
<a href="https://github.com/decolua/9router/tree/main/app/cloud" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
Setup guide →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status in modal */}
|
||||
{setupStatus && (
|
||||
<div className={`p-2 rounded text-sm ${
|
||||
setupStatus.type === "success" ? "bg-green-500/10 text-green-600 dark:text-green-400" :
|
||||
"bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
}`}>
|
||||
{setupStatus.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSaveCloudUrl}
|
||||
fullWidth
|
||||
disabled={cloudUrlSaving || !cloudUrlInput.trim() || cloudUrlInput.trim().replace(/\/v1\/?$/, "").replace(/\/+$/, "") === cloudUrl}
|
||||
>
|
||||
{cloudUrlSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCheckCloud}
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
disabled={cloudSyncing || !cloudUrl}
|
||||
icon="check_circle"
|
||||
>
|
||||
{cloudSyncing ? "Checking..." : "Check"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Cloud Enable Modal */}
|
||||
<Modal
|
||||
isOpen={showCloudModal}
|
||||
title="Enable Cloud Proxy"
|
||||
onClose={() => setShowCloudModal(false)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium mb-2">
|
||||
What you will get
|
||||
</p>
|
||||
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
|
||||
<li>• Access your API from anywhere in the world</li>
|
||||
<li>• Share endpoint with your team easily</li>
|
||||
<li>• No need to open ports or configure firewall</li>
|
||||
<li>• Fast global edge network</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-medium mb-1">
|
||||
Note
|
||||
</p>
|
||||
<ul className="text-sm text-yellow-700 dark:text-yellow-300 space-y-1">
|
||||
<li>• Cloud will keep your auth session for 1 day. If not used, it will be automatically deleted.</li>
|
||||
<li>• Cloud is currently unstable with Claude Code OAuth in some cases.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Sync Progress */}
|
||||
{cloudSyncing && (
|
||||
<div className="flex items-center gap-3 p-3 bg-primary/10 border border-primary/30 rounded-lg">
|
||||
<span className="material-symbols-outlined animate-spin text-primary">progress_activity</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-primary">
|
||||
{syncStep === "syncing" && "Syncing data to cloud..."}
|
||||
{syncStep === "verifying" && "Verifying connection..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleEnableCloud}
|
||||
fullWidth
|
||||
disabled={cloudSyncing}
|
||||
>
|
||||
{cloudSyncing ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
|
||||
{syncStep === "syncing" ? "Syncing..." : "Verifying..."}
|
||||
</span>
|
||||
) : "Enable Cloud"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowCloudModal(false)}
|
||||
variant="ghost"
|
||||
fullWidth
|
||||
disabled={cloudSyncing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
{/* CLOUD MODALS — COMMENTED OUT (replaced by Tunnel) */}
|
||||
{/* Setup Cloud Modal — removed */}
|
||||
{/* Cloud Enable Modal — removed */}
|
||||
|
||||
{/* Add Key Modal */}
|
||||
<Modal
|
||||
@@ -667,11 +624,65 @@ export default function APIPageClient({ machineId }) {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Disable Cloud Modal */}
|
||||
{/* Enable Tunnel Modal */}
|
||||
<Modal
|
||||
isOpen={showEnableModal}
|
||||
title="Enable Tunnel"
|
||||
onClose={() => setShowEnableModal(false)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="material-symbols-outlined text-blue-600 dark:text-blue-400">cloud_upload</span>
|
||||
<div>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium mb-1">
|
||||
Cloudflare Tunnel
|
||||
</p>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
Expose your local 9Router to the internet. No port forwarding, no static IP needed. Share endpoint URL with your team or use it in Cursor, Cline, and other AI tools from anywhere.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{TUNNEL_BENEFITS.map((benefit) => (
|
||||
<div key={benefit.title} className="flex flex-col items-center text-center p-3 rounded-lg bg-sidebar/50">
|
||||
<span className="material-symbols-outlined text-xl text-primary mb-1">{benefit.icon}</span>
|
||||
<p className="text-xs font-semibold">{benefit.title}</p>
|
||||
<p className="text-xs text-text-muted">{benefit.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted">
|
||||
Requires outbound port 7844 (TCP/UDP). Connection may take 10-30s.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleEnableTunnel}
|
||||
fullWidth
|
||||
className="bg-linear-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600 text-white!"
|
||||
>
|
||||
Start Tunnel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowEnableModal(false)}
|
||||
variant="ghost"
|
||||
fullWidth
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Disable Tunnel Modal */}
|
||||
<Modal
|
||||
isOpen={showDisableModal}
|
||||
title="Disable Cloud Proxy"
|
||||
onClose={() => !cloudSyncing && setShowDisableModal(false)}
|
||||
title="Disable Tunnel"
|
||||
onClose={() => !tunnelLoading && setShowDisableModal(false)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
@@ -682,46 +693,33 @@ export default function APIPageClient({ machineId }) {
|
||||
Warning
|
||||
</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">
|
||||
All auth sessions will be deleted from cloud.
|
||||
The tunnel will be disconnected. Remote access will stop working.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync Progress */}
|
||||
{cloudSyncing && (
|
||||
<div className="flex items-center gap-3 p-3 bg-primary/10 border border-primary/30 rounded-lg">
|
||||
<span className="material-symbols-outlined animate-spin text-primary">progress_activity</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-primary">
|
||||
{syncStep === "syncing" && "Syncing latest data..."}
|
||||
{syncStep === "disabling" && "Disabling cloud..."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-text-muted">Are you sure you want to disable cloud proxy?</p>
|
||||
<p className="text-sm text-text-muted">Are you sure you want to disable the tunnel?</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleConfirmDisable}
|
||||
onClick={handleDisableTunnel}
|
||||
fullWidth
|
||||
disabled={cloudSyncing}
|
||||
disabled={tunnelLoading}
|
||||
className="bg-red-500! hover:bg-red-600! text-white!"
|
||||
>
|
||||
{cloudSyncing ? (
|
||||
{tunnelLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
|
||||
{syncStep === "syncing" ? "Syncing..." : "Disabling..."}
|
||||
Disabling...
|
||||
</span>
|
||||
) : "Disable Cloud"}
|
||||
) : "Disable Tunnel"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowDisableModal(false)}
|
||||
variant="ghost"
|
||||
fullWidth
|
||||
disabled={cloudSyncing}
|
||||
disabled={tunnelLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -51,7 +51,7 @@ function ProviderNode({ data }) {
|
||||
|
||||
{/* Provider name */}
|
||||
<span
|
||||
className="text-sm font-medium truncate"
|
||||
className="text-base font-medium truncate"
|
||||
style={{ color: active ? color : "var(--color-text)" }}
|
||||
>
|
||||
{label}
|
||||
@@ -99,7 +99,7 @@ RouterNode.propTypes = {
|
||||
const nodeTypes = { provider: ProviderNode, router: RouterNode };
|
||||
|
||||
// Place N nodes evenly along an ellipse around the router center.
|
||||
function buildLayout(providers, activeSet, lastSet) {
|
||||
function buildLayout(providers, activeSet, lastSet, errorSet) {
|
||||
const nodeW = 180;
|
||||
const nodeH = 30;
|
||||
const routerW = 120;
|
||||
@@ -130,8 +130,9 @@ function buildLayout(providers, activeSet, lastSet) {
|
||||
draggable: false,
|
||||
});
|
||||
|
||||
const edgeStyle = (active, last, color) => {
|
||||
if (active) return { stroke: color, strokeWidth: 2, opacity: 0.8 };
|
||||
const edgeStyle = (active, last, error, color) => {
|
||||
if (error) return { stroke: "#ef4444", strokeWidth: 2.5, opacity: 0.9 };
|
||||
if (active) return { stroke: "#22c55e", strokeWidth: 2.5, opacity: 0.9 };
|
||||
if (last) return { stroke: "#f59e0b", strokeWidth: 2, opacity: 0.7 };
|
||||
return { stroke: "var(--color-border)", strokeWidth: 1, opacity: 0.3 };
|
||||
};
|
||||
@@ -140,6 +141,7 @@ function buildLayout(providers, activeSet, lastSet) {
|
||||
const config = getProviderConfig(p.provider);
|
||||
const active = activeSet.has(p.provider?.toLowerCase());
|
||||
const last = !active && lastSet.has(p.provider?.toLowerCase());
|
||||
const error = !active && errorSet.has(p.provider?.toLowerCase());
|
||||
const nodeId = `provider-${p.provider}`;
|
||||
const data = {
|
||||
label: config.name || p.name || p.provider,
|
||||
@@ -181,28 +183,32 @@ function buildLayout(providers, activeSet, lastSet) {
|
||||
target: nodeId,
|
||||
targetHandle,
|
||||
animated: active,
|
||||
style: edgeStyle(active, last, config.color),
|
||||
style: edgeStyle(active, last, error, config.color),
|
||||
});
|
||||
});
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export default function ProviderTopology({ providers = [], activeRequests = [], lastProvider = "" }) {
|
||||
export default function ProviderTopology({ providers = [], activeRequests = [], lastProvider = "", errorProvider = "" }) {
|
||||
const activeSet = useMemo(
|
||||
() => new Set(activeRequests.map((r) => r.provider?.toLowerCase()).filter(Boolean)),
|
||||
[activeRequests]
|
||||
);
|
||||
|
||||
// lastSet: providers that finished most recently (not currently active)
|
||||
const lastSet = useMemo(
|
||||
() => new Set(lastProvider ? [lastProvider.toLowerCase()] : []),
|
||||
[lastProvider]
|
||||
);
|
||||
|
||||
const errorSet = useMemo(
|
||||
() => new Set(errorProvider ? [errorProvider.toLowerCase()] : []),
|
||||
[errorProvider]
|
||||
);
|
||||
|
||||
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
||||
() => buildLayout(providers, activeSet, lastSet),
|
||||
[providers, activeSet, lastSet]
|
||||
() => buildLayout(providers, activeSet, lastSet, errorSet),
|
||||
[providers, activeSet, lastSet, errorSet]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -246,4 +252,5 @@ ProviderTopology.propTypes = {
|
||||
account: PropTypes.string,
|
||||
})),
|
||||
lastProvider: PropTypes.string,
|
||||
errorProvider: PropTypes.string,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { validateApiKey, getModelAliases, setModelAlias, isCloudEnabled } from "@/models";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
import { validateApiKey, getModelAliases, setModelAlias } from "@/models";
|
||||
|
||||
// PUT /api/cloud/models/alias - Set model alias (for cloud/CLI)
|
||||
export async function PUT(request) {
|
||||
@@ -37,9 +35,6 @@ export async function PUT(request) {
|
||||
// Update alias
|
||||
await setModelAlias(alias, model);
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
model,
|
||||
@@ -52,21 +47,6 @@ export async function PUT(request) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to Cloud if enabled
|
||||
*/
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing aliases to cloud:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/cloud/models/alias - Get all aliases
|
||||
export async function GET(request) {
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getComboById, updateCombo, deleteCombo, getComboByName, isCloudEnabled } from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
import { getComboById, updateCombo, deleteCombo, getComboByName } from "@/lib/localDb";
|
||||
|
||||
// Validate combo name: only a-z, A-Z, 0-9, -, _
|
||||
const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
@@ -48,9 +46,6 @@ export async function PUT(request, { params }) {
|
||||
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json(combo);
|
||||
} catch (error) {
|
||||
console.log("Error updating combo:", error);
|
||||
@@ -67,9 +62,6 @@ export async function DELETE(request, { params }) {
|
||||
if (!success) {
|
||||
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -77,18 +69,3 @@ export async function DELETE(request, { params }) {
|
||||
return NextResponse.json({ error: "Failed to delete combo" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to Cloud if enabled
|
||||
*/
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing to cloud:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getCombos, createCombo, getComboByName, isCloudEnabled } from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
import { getCombos, createCombo, getComboByName } from "@/lib/localDb";
|
||||
|
||||
// Validate combo name: only a-z, A-Z, 0-9, -, _
|
||||
const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
@@ -40,27 +38,9 @@ export async function POST(request) {
|
||||
|
||||
const combo = await createCombo({ name, models: models || [] });
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json(combo, { status: 201 });
|
||||
} catch (error) {
|
||||
console.log("Error creating combo:", error);
|
||||
return NextResponse.json({ error: "Failed to create combo" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to Cloud if enabled
|
||||
*/
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing to cloud:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { deleteApiKey, getApiKeyById, updateApiKey, isCloudEnabled } from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
import { deleteApiKey, getApiKeyById, updateApiKey } from "@/lib/localDb";
|
||||
|
||||
// GET /api/keys/[id] - Get single key
|
||||
export async function GET(request, { params }) {
|
||||
@@ -34,7 +32,6 @@ export async function PUT(request, { params }) {
|
||||
if (isActive !== undefined) updateData.isActive = isActive;
|
||||
|
||||
const updated = await updateApiKey(id, updateData);
|
||||
await syncKeysToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({ key: updated });
|
||||
} catch (error) {
|
||||
@@ -53,27 +50,9 @@ export async function DELETE(request, { params }) {
|
||||
return NextResponse.json({ error: "Key not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncKeysToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({ message: "Key deleted successfully" });
|
||||
} catch (error) {
|
||||
console.log("Error deleting key:", error);
|
||||
return NextResponse.json({ error: "Failed to delete key" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync API keys to Cloud if enabled
|
||||
*/
|
||||
async function syncKeysToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing keys to cloud:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeys, createApiKey, isCloudEnabled } from "@/lib/localDb";
|
||||
import { getApiKeys, createApiKey } from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
|
||||
// GET /api/keys - List API keys
|
||||
export async function GET() {
|
||||
@@ -28,9 +27,6 @@ export async function POST(request) {
|
||||
const machineId = await getConsistentMachineId();
|
||||
const apiKey = await createApiKey(name, machineId);
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncKeysToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({
|
||||
key: apiKey.key,
|
||||
name: apiKey.name,
|
||||
@@ -42,18 +38,3 @@ export async function POST(request) {
|
||||
return NextResponse.json({ error: "Failed to create key" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync API keys to Cloud if enabled
|
||||
*/
|
||||
async function syncKeysToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing keys to cloud:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getModelAliases, setModelAlias, deleteModelAlias, isCloudEnabled } from "@/models";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
import { getModelAliases, setModelAlias, deleteModelAlias } from "@/models";
|
||||
|
||||
// GET /api/models/alias - Get all aliases
|
||||
export async function GET() {
|
||||
@@ -25,7 +23,6 @@ export async function PUT(request) {
|
||||
}
|
||||
|
||||
await setModelAlias(alias, model);
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({ success: true, model, alias });
|
||||
} catch (error) {
|
||||
@@ -45,7 +42,6 @@ export async function DELETE(request) {
|
||||
}
|
||||
|
||||
await deleteModelAlias(alias);
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
@@ -53,15 +49,3 @@ export async function DELETE(request) {
|
||||
return NextResponse.json({ error: "Failed to delete alias" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing aliases to cloud:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,7 @@ import {
|
||||
requestDeviceCode,
|
||||
pollForToken
|
||||
} from "@/lib/oauth/providers";
|
||||
import { createProviderConnection, isCloudEnabled } from "@/models";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
import { createProviderConnection } from "@/models";
|
||||
|
||||
/**
|
||||
* Dynamic OAuth API Route
|
||||
@@ -89,9 +87,6 @@ export async function POST(request, { params }) {
|
||||
testStatus: "active",
|
||||
});
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
connection: {
|
||||
@@ -138,9 +133,6 @@ export async function POST(request, { params }) {
|
||||
testStatus: "active",
|
||||
});
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
connection: {
|
||||
@@ -167,18 +159,3 @@ export async function POST(request, { params }) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to Cloud if enabled
|
||||
*/
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing to cloud after OAuth:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { CursorService } from "@/lib/oauth/services/cursor";
|
||||
import { createProviderConnection, isCloudEnabled } from "@/models";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
import { createProviderConnection } from "@/models";
|
||||
|
||||
/**
|
||||
* POST /api/oauth/cursor/import
|
||||
@@ -58,9 +56,6 @@ export async function POST(request) {
|
||||
testStatus: "active",
|
||||
});
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
connection: {
|
||||
@@ -103,18 +98,3 @@ export async function GET() {
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to Cloud if enabled
|
||||
*/
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing to cloud after Cursor import:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { KiroService } from "@/lib/oauth/services/kiro";
|
||||
import { createProviderConnection, isCloudEnabled } from "@/models";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
import { createProviderConnection } from "@/models";
|
||||
|
||||
/**
|
||||
* POST /api/oauth/kiro/import
|
||||
@@ -43,9 +41,6 @@ export async function POST(request) {
|
||||
testStatus: "active",
|
||||
});
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
connection: {
|
||||
@@ -59,18 +54,3 @@ export async function POST(request) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to Cloud if enabled
|
||||
*/
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing to cloud after Kiro import:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { KiroService } from "@/lib/oauth/services/kiro";
|
||||
import { createProviderConnection, isCloudEnabled } from "@/models";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
import { createProviderConnection } from "@/models";
|
||||
|
||||
/**
|
||||
* POST /api/oauth/kiro/social-exchange
|
||||
@@ -54,9 +52,6 @@ export async function POST(request) {
|
||||
testStatus: "active",
|
||||
});
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
connection: {
|
||||
@@ -70,18 +65,3 @@ export async function POST(request) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to Cloud if enabled
|
||||
*/
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing to cloud after Kiro OAuth:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById, updateProviderConnection, deleteProviderConnection, isCloudEnabled } from "@/models";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
import { getProviderConnectionById, updateProviderConnection, deleteProviderConnection } from "@/models";
|
||||
|
||||
// GET /api/providers/[id] - Get single connection
|
||||
export async function GET(request, { params }) {
|
||||
@@ -59,9 +57,6 @@ export async function PUT(request, { params }) {
|
||||
delete result.refreshToken;
|
||||
delete result.idToken;
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({ connection: result });
|
||||
} catch (error) {
|
||||
console.log("Error updating connection:", error);
|
||||
@@ -79,27 +74,9 @@ export async function DELETE(request, { params }) {
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({ message: "Connection deleted successfully" });
|
||||
} catch (error) {
|
||||
console.log("Error deleting connection:", error);
|
||||
return NextResponse.json({ error: "Failed to delete connection" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to Cloud if enabled
|
||||
*/
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing providers to cloud:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { getProviderConnectionById, updateProviderConnection, isCloudEnabled } from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
import {
|
||||
GEMINI_CONFIG,
|
||||
@@ -325,17 +323,5 @@ export async function testSingleConnection(id) {
|
||||
|
||||
await updateProviderConnection(id, updateData);
|
||||
|
||||
if (result.refreshed) {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (cloudEnabled) {
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("Error syncing to cloud after token refresh:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: result.valid, error: result.error, latencyMs, testedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnections, createProviderConnection, getProviderNodeById, isCloudEnabled } from "@/models";
|
||||
import { getProviderConnections, createProviderConnection, getProviderNodeById } from "@/models";
|
||||
import { APIKEY_PROVIDERS } from "@/shared/constants/config";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
|
||||
// GET /api/providers - List all connections
|
||||
export async function GET() {
|
||||
@@ -101,27 +99,9 @@ export async function POST(request) {
|
||||
const result = { ...newConnection };
|
||||
delete result.apiKey;
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
return NextResponse.json({ connection: result }, { status: 201 });
|
||||
} catch (error) {
|
||||
console.log("Error creating provider:", error);
|
||||
return NextResponse.json({ error: "Failed to create provider" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to Cloud if enabled
|
||||
*/
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const cloudEnabled = await isCloudEnabled();
|
||||
if (!cloudEnabled) return;
|
||||
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.log("Error syncing providers to cloud:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnections, getModelAliases, getCombos, getApiKeys, createApiKey, updateProviderConnection, updateSettings, getCloudUrl } from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
const CLOUD_SYNC_TIMEOUT_MS = Number(process.env.CLOUD_SYNC_TIMEOUT_MS || 12000);
|
||||
|
||||
async function getResolvedCloudUrl() {
|
||||
return await getCloudUrl();
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options = {}, timeoutMs = CLOUD_SYNC_TIMEOUT_MS) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...options, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/sync/cloud
|
||||
* Sync data with Cloud
|
||||
*/
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { action } = body;
|
||||
|
||||
// Always get machineId from server, don't trust client
|
||||
const machineId = await getConsistentMachineId();
|
||||
|
||||
switch (action) {
|
||||
case "enable":
|
||||
await updateSettings({ cloudEnabled: true });
|
||||
// Auto create key if none exists
|
||||
const keys = await getApiKeys();
|
||||
let createdKey = null;
|
||||
if (keys.length === 0) {
|
||||
createdKey = await createApiKey("Default Key", machineId);
|
||||
}
|
||||
return syncAndVerify(machineId, createdKey?.key, keys);
|
||||
case "sync": {
|
||||
const syncResult = await syncToCloud(machineId);
|
||||
if (syncResult.error) {
|
||||
return NextResponse.json(syncResult, { status: 502 });
|
||||
}
|
||||
return NextResponse.json(syncResult);
|
||||
}
|
||||
case "disable":
|
||||
await updateSettings({ cloudEnabled: false });
|
||||
return handleDisable(machineId, request);
|
||||
case "check":
|
||||
return handleCheck();
|
||||
default:
|
||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Cloud sync error:", error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync data to Cloud (exported for reuse)
|
||||
* @param {string} machineId
|
||||
* @param {string|null} createdKey - Key created during enable
|
||||
*/
|
||||
export async function syncToCloud(machineId, createdKey = null) {
|
||||
const cloudUrl = await getResolvedCloudUrl();
|
||||
if (!cloudUrl) {
|
||||
return { error: "Cloud URL is not configured" };
|
||||
}
|
||||
|
||||
// Get current data from db
|
||||
const providers = await getProviderConnections();
|
||||
const modelAliases = await getModelAliases();
|
||||
const combos = await getCombos();
|
||||
const apiKeys = await getApiKeys();
|
||||
|
||||
let response;
|
||||
try {
|
||||
// Send to Cloud
|
||||
response = await fetchWithTimeout(`${cloudUrl}/sync/${machineId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
providers,
|
||||
modelAliases,
|
||||
combos,
|
||||
apiKeys
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
const isTimeout = error?.name === "AbortError";
|
||||
return { error: isTimeout ? "Cloud sync timeout" : "Cloud sync request failed" };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.log("Cloud sync failed:", errorText);
|
||||
return { error: "Cloud sync failed" };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Update local db with tokens from Cloud (providers stored by ID)
|
||||
if (result.data && result.data.providers) {
|
||||
await updateLocalTokens(result.data.providers);
|
||||
}
|
||||
|
||||
const responseData = {
|
||||
success: true,
|
||||
message: "Synced successfully",
|
||||
changes: result.changes
|
||||
};
|
||||
|
||||
if (createdKey) {
|
||||
responseData.createdKey = createdKey;
|
||||
}
|
||||
|
||||
return responseData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync and verify connection with ping
|
||||
*/
|
||||
async function syncAndVerify(machineId, createdKey, existingKeys) {
|
||||
// Step 1: Sync data to cloud
|
||||
const syncResult = await syncToCloud(machineId, createdKey);
|
||||
if (syncResult.error) {
|
||||
return NextResponse.json(syncResult, { status: 502 });
|
||||
}
|
||||
|
||||
// Step 2: Verify connection by pinging the cloud
|
||||
const apiKey = createdKey || existingKeys[0]?.key;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: false,
|
||||
verifyError: "No API key available"
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const cloudUrl = await getResolvedCloudUrl();
|
||||
const pingResponse = await fetchWithTimeout(`${cloudUrl}/${machineId}/v1/verify`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
if (pingResponse.ok) {
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: true
|
||||
});
|
||||
} else {
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: false,
|
||||
verifyError: `Ping failed: ${pingResponse.status}`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
...syncResult,
|
||||
verified: false,
|
||||
verifyError: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable Cloud - delete cache and update Claude CLI settings
|
||||
*/
|
||||
async function handleDisable(machineId, request) {
|
||||
const cloudUrl = await getResolvedCloudUrl();
|
||||
if (!cloudUrl) {
|
||||
return NextResponse.json({ error: "Cloud URL is not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetchWithTimeout(`${cloudUrl}/sync/${machineId}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
} catch (error) {
|
||||
const isTimeout = error?.name === "AbortError";
|
||||
return NextResponse.json(
|
||||
{ error: isTimeout ? "Cloud disable timeout" : "Failed to reach cloud service" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.log("Cloud disable failed:", errorText);
|
||||
return NextResponse.json({ error: "Failed to disable cloud" }, { status: 502 });
|
||||
}
|
||||
|
||||
// Update Claude CLI settings to use local endpoint
|
||||
const host = request.headers.get("host") || "localhost:20128";
|
||||
await updateClaudeSettingsToLocal(machineId, host, cloudUrl);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Cloud disabled"
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Claude CLI settings to use local endpoint (only if currently using cloud)
|
||||
*/
|
||||
async function updateClaudeSettingsToLocal(machineId, host, cloudUrl) {
|
||||
try {
|
||||
const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
|
||||
const cloudEndpoint = `${cloudUrl}/${machineId}`;
|
||||
const localUrl = `http://${host}`;
|
||||
|
||||
// Read current settings
|
||||
let settings;
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, "utf-8");
|
||||
settings = JSON.parse(content);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
return; // No settings file, nothing to update
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check if ANTHROPIC_BASE_URL matches cloud URL
|
||||
const currentUrl = settings.env?.ANTHROPIC_BASE_URL;
|
||||
if (!currentUrl || currentUrl !== cloudEndpoint) {
|
||||
return; // Not using cloud URL, don't modify
|
||||
}
|
||||
|
||||
// Update to local URL
|
||||
settings.env.ANTHROPIC_BASE_URL = localUrl;
|
||||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||||
console.log(`Updated Claude CLI settings: ${cloudEndpoint} → ${localUrl}`);
|
||||
} catch (error) {
|
||||
console.log("Failed to update Claude CLI settings:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cloud worker is reachable
|
||||
*/
|
||||
async function handleCheck() {
|
||||
const cloudUrl = await getResolvedCloudUrl();
|
||||
if (!cloudUrl) {
|
||||
return NextResponse.json({ error: "Cloud URL is not configured" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchWithTimeout(`${cloudUrl}/health`, { method: "GET" }, 5000);
|
||||
if (res.ok) {
|
||||
return NextResponse.json({ success: true, message: "Worker is running" });
|
||||
}
|
||||
return NextResponse.json({ error: `Worker responded with ${res.status}` }, { status: 502 });
|
||||
} catch (error) {
|
||||
const isTimeout = error?.name === "AbortError";
|
||||
return NextResponse.json(
|
||||
{ error: isTimeout ? "Worker request timeout" : "Cannot reach worker" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update local db with data from Cloud
|
||||
* Simple logic: if Cloud is newer, sync entire provider
|
||||
* cloudProviders is object keyed by provider ID
|
||||
*/
|
||||
async function updateLocalTokens(cloudProviders) {
|
||||
const localProviders = await getProviderConnections();
|
||||
|
||||
for (const localProvider of localProviders) {
|
||||
const cloudProvider = cloudProviders[localProvider.id];
|
||||
if (!cloudProvider) continue;
|
||||
|
||||
const cloudUpdatedAt = new Date(cloudProvider.updatedAt || 0).getTime();
|
||||
const localUpdatedAt = new Date(localProvider.updatedAt || 0).getTime();
|
||||
|
||||
// Simple logic: if Cloud is newer, sync entire provider
|
||||
if (cloudUpdatedAt > localUpdatedAt) {
|
||||
const updates = {
|
||||
// Tokens
|
||||
accessToken: cloudProvider.accessToken,
|
||||
refreshToken: cloudProvider.refreshToken,
|
||||
expiresAt: cloudProvider.expiresAt,
|
||||
expiresIn: cloudProvider.expiresIn,
|
||||
|
||||
// Provider specific data
|
||||
providerSpecificData: cloudProvider.providerSpecificData || localProvider.providerSpecificData,
|
||||
|
||||
// Status fields
|
||||
testStatus: cloudProvider.status || "active",
|
||||
lastError: cloudProvider.lastError,
|
||||
lastErrorAt: cloudProvider.lastErrorAt,
|
||||
errorCode: cloudProvider.errorCode,
|
||||
rateLimitedUntil: cloudProvider.rateLimitedUntil,
|
||||
|
||||
// Metadata
|
||||
updatedAt: cloudProvider.updatedAt
|
||||
};
|
||||
|
||||
await updateProviderConnection(localProvider.id, updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import initializeCloudSync from "@/shared/services/initializeCloudSync";
|
||||
|
||||
let syncInitialized = false;
|
||||
|
||||
// POST /api/sync/initialize - Initialize cloud sync scheduler
|
||||
export async function POST(request) {
|
||||
try {
|
||||
if (syncInitialized) {
|
||||
return NextResponse.json({
|
||||
message: "Cloud sync already initialized"
|
||||
});
|
||||
}
|
||||
|
||||
await initializeCloudSync();
|
||||
syncInitialized = true;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Cloud sync initialized successfully"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error initializing cloud sync:", error);
|
||||
return NextResponse.json({
|
||||
error: "Failed to initialize cloud sync"
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/sync/status - Check sync initialization status
|
||||
export async function GET(request) {
|
||||
return NextResponse.json({
|
||||
initialized: syncInitialized,
|
||||
message: syncInitialized ? "Cloud sync is running" : "Cloud sync not initialized"
|
||||
});
|
||||
}
|
||||
12
src/app/api/tunnel/disable/route.js
Normal file
12
src/app/api/tunnel/disable/route.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { disableTunnel } from "@/lib/tunnel/tunnelManager";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const result = await disableTunnel();
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error("Tunnel disable error:", error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
src/app/api/tunnel/enable/route.js
Normal file
12
src/app/api/tunnel/enable/route.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { enableTunnel } from "@/lib/tunnel/tunnelManager";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const result = await enableTunnel();
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error("Tunnel enable error:", error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
src/app/api/tunnel/status/route.js
Normal file
12
src/app/api/tunnel/status/route.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getTunnelStatus } from "@/lib/tunnel/tunnelManager";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const status = await getTunnelStatus();
|
||||
return NextResponse.json(status);
|
||||
} catch (error) {
|
||||
console.error("Tunnel status error:", error);
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,6 @@
|
||||
import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb";
|
||||
import { getMachineId } from "@/shared/utils/machine";
|
||||
import { getUsageForProvider } from "open-sse/services/usage.js";
|
||||
import { getExecutor } from "open-sse/executors/index.js";
|
||||
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||
|
||||
/**
|
||||
* Sync to cloud if enabled
|
||||
*/
|
||||
async function syncToCloudIfEnabled() {
|
||||
try {
|
||||
const machineId = await getMachineId();
|
||||
if (!machineId) return;
|
||||
await syncToCloud(machineId);
|
||||
} catch (error) {
|
||||
console.error("[Usage API] Error syncing to cloud:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh credentials using executor and update database
|
||||
* @returns {{ connection, refreshed: boolean }}
|
||||
@@ -119,16 +103,9 @@ export async function GET(request, { params }) {
|
||||
}
|
||||
|
||||
// Refresh credentials if needed using executor
|
||||
let refreshed = false;
|
||||
try {
|
||||
const result = await refreshAndUpdateCredentials(connection);
|
||||
connection = result.connection;
|
||||
refreshed = result.refreshed;
|
||||
|
||||
// Sync to cloud only if token was refreshed
|
||||
if (refreshed) {
|
||||
await syncToCloudIfEnabled();
|
||||
}
|
||||
} catch (refreshError) {
|
||||
console.error("[Usage API] Credential refresh failed:", refreshError);
|
||||
return Response.json({
|
||||
|
||||
@@ -1,33 +1,53 @@
|
||||
import { getUsageStats, statsEmitter } from "@/lib/usageDb";
|
||||
import { getUsageStats, statsEmitter, getActiveRequests } from "@/lib/usageDb";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const encoder = new TextEncoder();
|
||||
const state = { closed: false, keepalive: null, send: null };
|
||||
const state = { closed: false, keepalive: null, send: null, sendPending: null, cachedStats: null };
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Full stats refresh (heavy) + immediate lightweight push
|
||||
state.send = async () => {
|
||||
if (state.closed) return;
|
||||
try {
|
||||
const stats = await getUsageStats();
|
||||
if (stats.activeRequests?.length > 0) {
|
||||
console.log(`[SSE] Push | active=${stats.activeRequests.length} | ${stats.activeRequests.map(r => r.provider).join(",")}`);
|
||||
// Push lightweight update immediately so UI reflects changes fast
|
||||
if (state.cachedStats) {
|
||||
const { activeRequests, recentRequests, errorProvider } = await getActiveRequests();
|
||||
const quickStats = { ...state.cachedStats, activeRequests, recentRequests, errorProvider };
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(quickStats)}\n\n`));
|
||||
}
|
||||
// Then do full recalc and update cache
|
||||
const stats = await getUsageStats();
|
||||
state.cachedStats = stats;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
|
||||
} catch {
|
||||
// Controller closed → self-cleanup
|
||||
state.closed = true;
|
||||
statsEmitter.off("update", state.send);
|
||||
statsEmitter.off("pending", state.sendPending);
|
||||
clearInterval(state.keepalive);
|
||||
}
|
||||
};
|
||||
|
||||
// Lightweight push: only refresh activeRequests + recentRequests on pending changes
|
||||
state.sendPending = async () => {
|
||||
if (state.closed || !state.cachedStats) return;
|
||||
try {
|
||||
const { activeRequests, recentRequests, errorProvider } = await getActiveRequests();
|
||||
const stats = { ...state.cachedStats, activeRequests, recentRequests, errorProvider };
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(stats)}\n\n`));
|
||||
} catch {
|
||||
state.closed = true;
|
||||
statsEmitter.off("pending", state.sendPending);
|
||||
}
|
||||
};
|
||||
|
||||
await state.send();
|
||||
console.log(`[SSE] Client connected | listeners=${statsEmitter.listenerCount("update") + 1}`);
|
||||
|
||||
statsEmitter.on("update", state.send);
|
||||
statsEmitter.on("pending", state.sendPending);
|
||||
|
||||
state.keepalive = setInterval(() => {
|
||||
if (state.closed) { clearInterval(state.keepalive); return; }
|
||||
@@ -43,6 +63,7 @@ export async function GET() {
|
||||
cancel() {
|
||||
state.closed = true;
|
||||
statsEmitter.off("update", state.send);
|
||||
statsEmitter.off("pending", state.sendPending);
|
||||
clearInterval(state.keepalive);
|
||||
console.log("[SSE] Client disconnected");
|
||||
},
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import initializeCloudSync from "@/shared/services/initializeCloudSync";
|
||||
import initializeApp from "@/shared/services/initializeApp";
|
||||
|
||||
// Initialize cloud sync when this module is imported
|
||||
let initialized = false;
|
||||
|
||||
export async function ensureCloudSyncInitialized() {
|
||||
export async function ensureAppInitialized() {
|
||||
if (!initialized) {
|
||||
try {
|
||||
await initializeCloudSync();
|
||||
await initializeApp();
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error("[ServerInit] Error initializing cloud sync:", error);
|
||||
console.error("[ServerInit] Error initializing app:", error);
|
||||
}
|
||||
}
|
||||
return initialized;
|
||||
}
|
||||
|
||||
// Auto-initialize when module loads
|
||||
ensureCloudSyncInitialized().catch(console.log);
|
||||
|
||||
export default ensureCloudSyncInitialized;
|
||||
ensureAppInitialized().catch(console.log);
|
||||
|
||||
export default ensureAppInitialized;
|
||||
|
||||
@@ -49,6 +49,8 @@ const defaultData = {
|
||||
apiKeys: [],
|
||||
settings: {
|
||||
cloudEnabled: false,
|
||||
tunnelEnabled: false,
|
||||
tunnelUrl: "",
|
||||
stickyRoundRobinLimit: 3,
|
||||
requireLogin: true,
|
||||
observabilityEnabled: true,
|
||||
@@ -70,6 +72,8 @@ function cloneDefaultData() {
|
||||
apiKeys: [],
|
||||
settings: {
|
||||
cloudEnabled: false,
|
||||
tunnelEnabled: false,
|
||||
tunnelUrl: "",
|
||||
stickyRoundRobinLimit: 3,
|
||||
requireLogin: true,
|
||||
observabilityEnabled: true,
|
||||
|
||||
193
src/lib/tunnel/cloudflared.js
Normal file
193
src/lib/tunnel/cloudflared.js
Normal file
@@ -0,0 +1,193 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import https from "https";
|
||||
import os from "os";
|
||||
import { execSync, spawn } from "child_process";
|
||||
import { savePid, loadPid, clearPid } from "./state.js";
|
||||
|
||||
const BIN_DIR = path.join(os.homedir(), ".9router", "bin");
|
||||
const BINARY_NAME = "cloudflared";
|
||||
const IS_WINDOWS = os.platform() === "win32";
|
||||
const BIN_NAME = IS_WINDOWS ? `${BINARY_NAME}.exe` : BINARY_NAME;
|
||||
const BIN_PATH = path.join(BIN_DIR, BIN_NAME);
|
||||
|
||||
const GITHUB_BASE_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download";
|
||||
|
||||
const PLATFORM_MAPPINGS = {
|
||||
darwin: {
|
||||
x64: "cloudflared-darwin-amd64.tgz",
|
||||
arm64: "cloudflared-darwin-amd64.tgz"
|
||||
},
|
||||
win32: {
|
||||
x64: "cloudflared-windows-amd64.exe"
|
||||
},
|
||||
linux: {
|
||||
x64: "cloudflared-linux-amd64",
|
||||
arm64: "cloudflared-linux-arm64"
|
||||
}
|
||||
};
|
||||
|
||||
function getDownloadUrl() {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
|
||||
const platformMapping = PLATFORM_MAPPINGS[platform];
|
||||
if (!platformMapping) {
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
const binaryName = platformMapping[arch];
|
||||
if (!binaryName) {
|
||||
throw new Error(`Unsupported architecture: ${arch} for platform ${platform}`);
|
||||
}
|
||||
|
||||
return `${GITHUB_BASE_URL}/${binaryName}`;
|
||||
}
|
||||
|
||||
function downloadFile(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = fs.createWriteStream(dest);
|
||||
|
||||
https.get(url, (response) => {
|
||||
if ([301, 302].includes(response.statusCode)) {
|
||||
file.close();
|
||||
fs.unlinkSync(dest);
|
||||
downloadFile(response.headers.location, dest).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
file.close();
|
||||
fs.unlinkSync(dest);
|
||||
reject(new Error(`Download failed with status ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
response.pipe(file);
|
||||
|
||||
file.on("finish", () => {
|
||||
file.close(() => resolve(dest));
|
||||
});
|
||||
|
||||
file.on("error", (err) => {
|
||||
file.close();
|
||||
fs.unlinkSync(dest);
|
||||
reject(err);
|
||||
});
|
||||
}).on("error", (err) => {
|
||||
file.close();
|
||||
if (fs.existsSync(dest)) fs.unlinkSync(dest);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureCloudflared() {
|
||||
if (!fs.existsSync(BIN_DIR)) {
|
||||
fs.mkdirSync(BIN_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
if (fs.existsSync(BIN_PATH)) {
|
||||
if (!IS_WINDOWS) {
|
||||
fs.chmodSync(BIN_PATH, "755");
|
||||
}
|
||||
return BIN_PATH;
|
||||
}
|
||||
|
||||
const url = getDownloadUrl();
|
||||
const isArchive = url.endsWith(".tgz");
|
||||
const downloadDest = isArchive ? path.join(BIN_DIR, "cloudflared.tgz") : BIN_PATH;
|
||||
|
||||
await downloadFile(url, downloadDest);
|
||||
|
||||
if (isArchive) {
|
||||
execSync(`tar -xzf "${downloadDest}" -C "${BIN_DIR}"`, { stdio: "pipe" });
|
||||
fs.unlinkSync(downloadDest);
|
||||
}
|
||||
|
||||
if (!IS_WINDOWS) {
|
||||
fs.chmodSync(BIN_PATH, "755");
|
||||
}
|
||||
|
||||
return BIN_PATH;
|
||||
}
|
||||
|
||||
let cloudflaredProcess = null;
|
||||
|
||||
export async function spawnCloudflared(tunnelToken) {
|
||||
const binaryPath = await ensureCloudflared();
|
||||
|
||||
const child = spawn(binaryPath, ["tunnel", "run", "--dns-resolver-addrs", "1.1.1.1:53", "--token", tunnelToken], {
|
||||
detached: false,
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
});
|
||||
|
||||
cloudflaredProcess = child;
|
||||
savePid(child.pid);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let connectionCount = 0;
|
||||
const timeout = setTimeout(() => {
|
||||
resolve(child);
|
||||
}, 90000);
|
||||
|
||||
const handleLog = (data) => {
|
||||
const msg = data.toString();
|
||||
if (msg.includes("Registered tunnel connection")) {
|
||||
connectionCount++;
|
||||
if (connectionCount >= 4) {
|
||||
clearTimeout(timeout);
|
||||
resolve(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on("data", handleLog);
|
||||
child.stderr.on("data", handleLog);
|
||||
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
child.on("exit", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (connectionCount === 0) {
|
||||
reject(new Error(`cloudflared exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function killCloudflared() {
|
||||
if (cloudflaredProcess) {
|
||||
try {
|
||||
cloudflaredProcess.kill();
|
||||
} catch (e) { /* ignore */ }
|
||||
cloudflaredProcess = null;
|
||||
}
|
||||
|
||||
const pid = loadPid();
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid);
|
||||
} catch (e) { /* ignore */ }
|
||||
clearPid();
|
||||
}
|
||||
|
||||
// Kill any remaining cloudflared processes
|
||||
try {
|
||||
execSync("pkill -f cloudflared 2>/dev/null || true", { stdio: "ignore" });
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
export function isCloudflaredRunning() {
|
||||
const pid = loadPid();
|
||||
if (!pid) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
53
src/lib/tunnel/state.js
Normal file
53
src/lib/tunnel/state.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
const TUNNEL_DIR = path.join(os.homedir(), ".9router", "tunnel");
|
||||
const STATE_FILE = path.join(TUNNEL_DIR, "state.json");
|
||||
const PID_FILE = path.join(TUNNEL_DIR, "cloudflared.pid");
|
||||
|
||||
function ensureDir() {
|
||||
if (!fs.existsSync(TUNNEL_DIR)) {
|
||||
fs.mkdirSync(TUNNEL_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function loadState() {
|
||||
try {
|
||||
if (fs.existsSync(STATE_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(STATE_FILE, "utf8"));
|
||||
}
|
||||
} catch (e) { /* ignore corrupt state */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
export function saveState(state) {
|
||||
ensureDir();
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
export function clearState() {
|
||||
try {
|
||||
if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
export function savePid(pid) {
|
||||
ensureDir();
|
||||
fs.writeFileSync(PID_FILE, pid.toString());
|
||||
}
|
||||
|
||||
export function loadPid() {
|
||||
try {
|
||||
if (fs.existsSync(PID_FILE)) {
|
||||
return parseInt(fs.readFileSync(PID_FILE, "utf8"));
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearPid() {
|
||||
try {
|
||||
if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
120
src/lib/tunnel/tunnelManager.js
Normal file
120
src/lib/tunnel/tunnelManager.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import crypto from "crypto";
|
||||
import { loadState, saveState, clearState } from "./state.js";
|
||||
import { spawnCloudflared, killCloudflared, isCloudflaredRunning } from "./cloudflared.js";
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
|
||||
const TUNNEL_WORKER_URL = process.env.TUNNEL_WORKER_URL || "https://tunnel.9router.com";
|
||||
const MACHINE_ID_SALT = "9router-tunnel-salt";
|
||||
const API_KEY_SECRET = "9router-tunnel-api-key-secret";
|
||||
const SHORT_ID_LENGTH = 6;
|
||||
const SHORT_ID_CHARS = "abcdefghijklmnpqrstuvwxyz23456789";
|
||||
|
||||
function generateShortId() {
|
||||
let result = "";
|
||||
for (let i = 0; i < SHORT_ID_LENGTH; i++) {
|
||||
result += SHORT_ID_CHARS.charAt(Math.floor(Math.random() * SHORT_ID_CHARS.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getMachineId() {
|
||||
try {
|
||||
const { machineIdSync } = require("node-machine-id");
|
||||
const raw = machineIdSync();
|
||||
return crypto.createHash("sha256").update(raw + MACHINE_ID_SALT).digest("hex").substring(0, 16);
|
||||
} catch (e) {
|
||||
return crypto.randomUUID().replace(/-/g, "").substring(0, 16);
|
||||
}
|
||||
}
|
||||
|
||||
function generateApiKey(machineId) {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let keyId = "";
|
||||
for (let i = 0; i < 6; i++) {
|
||||
keyId += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
const crc = crypto.createHmac("sha256", API_KEY_SECRET).update(machineId + keyId).digest("hex").slice(0, 8);
|
||||
return `sk-${machineId}-${keyId}-${crc}`;
|
||||
}
|
||||
|
||||
async function workerFetch(reqPath, options = {}) {
|
||||
const url = `${TUNNEL_WORKER_URL}${reqPath}`;
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: { "Content-Type": "application/json", ...options.headers }
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function enableTunnel() {
|
||||
const existing = loadState();
|
||||
if (existing && existing.tunnelUrl && isCloudflaredRunning()) {
|
||||
return { success: true, tunnelUrl: existing.tunnelUrl, shortId: existing.shortId, alreadyRunning: true };
|
||||
}
|
||||
|
||||
killCloudflared();
|
||||
|
||||
const machineId = getMachineId();
|
||||
const shortId = existing?.shortId || generateShortId();
|
||||
const apiKey = existing?.apiKey || generateApiKey(machineId);
|
||||
|
||||
await workerFetch("/api/session/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ apiKey, shortId })
|
||||
});
|
||||
|
||||
const tunnelResult = await workerFetch("/api/tunnel/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ apiKey })
|
||||
});
|
||||
|
||||
if (tunnelResult.error) {
|
||||
throw new Error(tunnelResult.error);
|
||||
}
|
||||
|
||||
const { token, hostname } = tunnelResult;
|
||||
|
||||
await spawnCloudflared(token);
|
||||
|
||||
saveState({ shortId, apiKey, tunnelUrl: hostname, machineId });
|
||||
|
||||
await updateSettings({ tunnelEnabled: true, tunnelUrl: hostname });
|
||||
|
||||
return { success: true, tunnelUrl: hostname, shortId };
|
||||
}
|
||||
|
||||
export async function disableTunnel() {
|
||||
const state = loadState();
|
||||
|
||||
killCloudflared();
|
||||
|
||||
if (state?.apiKey) {
|
||||
try {
|
||||
await workerFetch("/api/tunnel/delete", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ apiKey: state.apiKey })
|
||||
});
|
||||
} catch (e) { /* ignore worker errors on disable */ }
|
||||
}
|
||||
|
||||
if (state) {
|
||||
saveState({ shortId: state.shortId, apiKey: state.apiKey, machineId: state.machineId, tunnelUrl: null });
|
||||
}
|
||||
|
||||
await updateSettings({ tunnelEnabled: false, tunnelUrl: "" });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getTunnelStatus() {
|
||||
const state = loadState();
|
||||
const running = isCloudflaredRunning();
|
||||
const settings = await getSettings();
|
||||
|
||||
return {
|
||||
enabled: settings.tunnelEnabled === true && running,
|
||||
tunnelUrl: state?.tunnelUrl || "",
|
||||
shortId: state?.shortId || "",
|
||||
running
|
||||
};
|
||||
}
|
||||
@@ -78,6 +78,12 @@ if (!global._pendingRequests) {
|
||||
}
|
||||
const pendingRequests = global._pendingRequests;
|
||||
|
||||
// Track last error provider for UI edge coloring (auto-clears after 10s)
|
||||
if (!global._lastErrorProvider) {
|
||||
global._lastErrorProvider = { provider: "", ts: 0 };
|
||||
}
|
||||
const lastErrorProvider = global._lastErrorProvider;
|
||||
|
||||
// Use global to share singleton across Next.js route modules
|
||||
if (!global._statsEmitter) {
|
||||
global._statsEmitter = new EventEmitter();
|
||||
@@ -91,8 +97,9 @@ export const statsEmitter = global._statsEmitter;
|
||||
* @param {string} provider
|
||||
* @param {string} connectionId
|
||||
* @param {boolean} started - true if started, false if finished
|
||||
* @param {boolean} [error] - true if ended with error
|
||||
*/
|
||||
export function trackPendingRequest(model, provider, connectionId, started) {
|
||||
export function trackPendingRequest(model, provider, connectionId, started, error = false) {
|
||||
const modelKey = provider ? `${model} (${provider})` : model;
|
||||
|
||||
// Track by model
|
||||
@@ -107,8 +114,71 @@ export function trackPendingRequest(model, provider, connectionId, started) {
|
||||
pendingRequests.byAccount[accountKey][modelKey] = Math.max(0, pendingRequests.byAccount[accountKey][modelKey] + (started ? 1 : -1));
|
||||
}
|
||||
|
||||
console.log(`[PENDING] ${started ? "START" : "END"} | provider=${provider} | model=${model} | emitter listeners=${statsEmitter.listenerCount("update")}`);
|
||||
statsEmitter.emit("update");
|
||||
// Track error provider (auto-clears after 10s)
|
||||
if (!started && error && provider) {
|
||||
lastErrorProvider.provider = provider.toLowerCase();
|
||||
lastErrorProvider.ts = Date.now();
|
||||
}
|
||||
|
||||
console.log(`[PENDING] ${started ? "START" : "END"}${error ? " (ERROR)" : ""} | provider=${provider} | model=${model} | emitter listeners=${statsEmitter.listenerCount("pending")}`);
|
||||
statsEmitter.emit("pending");
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight: get only activeRequests + recentRequests without full stats recalc
|
||||
*/
|
||||
export async function getActiveRequests() {
|
||||
const activeRequests = [];
|
||||
|
||||
// Build active requests from pending state
|
||||
let connectionMap = {};
|
||||
try {
|
||||
const { getProviderConnections } = await import("@/lib/localDb.js");
|
||||
const allConnections = await getProviderConnections();
|
||||
for (const conn of allConnections) {
|
||||
connectionMap[conn.id] = conn.name || conn.email || conn.id;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
for (const [connectionId, models] of Object.entries(pendingRequests.byAccount)) {
|
||||
for (const [modelKey, count] of Object.entries(models)) {
|
||||
if (count > 0) {
|
||||
const accountName = connectionMap[connectionId] || `Account ${connectionId.slice(0, 8)}...`;
|
||||
const match = modelKey.match(/^(.*) \((.*)\)$/);
|
||||
const modelName = match ? match[1] : modelKey;
|
||||
const providerName = match ? match[2] : "unknown";
|
||||
activeRequests.push({ model: modelName, provider: providerName, account: accountName, count });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get recent requests from history (re-read to get latest)
|
||||
const db = await getUsageDb();
|
||||
await db.read();
|
||||
const history = db.data.history || [];
|
||||
const seen = new Set();
|
||||
const recentRequests = [...history]
|
||||
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
|
||||
.map((e) => {
|
||||
const t = e.tokens || {};
|
||||
const promptTokens = t.prompt_tokens || t.input_tokens || 0;
|
||||
const completionTokens = t.completion_tokens || t.output_tokens || 0;
|
||||
return { timestamp: e.timestamp, model: e.model, provider: e.provider || "", promptTokens, completionTokens, status: e.status || "ok" };
|
||||
})
|
||||
.filter((e) => {
|
||||
if (e.promptTokens === 0 && e.completionTokens === 0) return false;
|
||||
const minute = e.timestamp ? e.timestamp.slice(0, 16) : "";
|
||||
const key = `${e.model}|${e.provider}|${e.promptTokens}|${e.completionTokens}|${minute}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.slice(0, 20);
|
||||
|
||||
// Error provider (auto-clear after 10s)
|
||||
const errorProvider = (Date.now() - lastErrorProvider.ts < 10000) ? lastErrorProvider.provider : "";
|
||||
|
||||
return { activeRequests, recentRequests, errorProvider };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -443,6 +513,7 @@ export async function getUsageStats() {
|
||||
pending: pendingRequests,
|
||||
activeRequests: [],
|
||||
recentRequests,
|
||||
errorProvider: (Date.now() - lastErrorProvider.ts < 10000) ? lastErrorProvider.provider : "",
|
||||
};
|
||||
|
||||
// Build active requests list from pending counts
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
// Server startup script
|
||||
import initializeCloudSync from "./shared/services/initializeCloudSync.js";
|
||||
import initializeApp from "./shared/services/initializeApp.js";
|
||||
|
||||
async function startServer() {
|
||||
console.log("Starting server with cloud sync...");
|
||||
console.log("Starting server...");
|
||||
|
||||
try {
|
||||
// Initialize cloud sync
|
||||
await initializeCloudSync();
|
||||
console.log("Server started with cloud sync initialized");
|
||||
await initializeApp();
|
||||
console.log("Server initialized");
|
||||
} catch (error) {
|
||||
console.log("Error initializing cloud sync:", error);
|
||||
console.log("Error initializing server:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server initialization
|
||||
startServer().catch(console.log);
|
||||
|
||||
// Export for use as module if needed
|
||||
export default startServer;
|
||||
|
||||
@@ -362,6 +362,7 @@ export default function UsageStats() {
|
||||
providers={providers}
|
||||
activeRequests={stats.activeRequests || []}
|
||||
lastProvider={stats.recentRequests?.[0]?.provider || ""}
|
||||
errorProvider={stats.errorProvider || ""}
|
||||
/>
|
||||
<RecentRequests requests={stats.recentRequests || []} />
|
||||
</div>
|
||||
|
||||
42
src/shared/services/initializeApp.js
Normal file
42
src/shared/services/initializeApp.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { cleanupProviderConnections, getSettings } from "@/lib/localDb";
|
||||
import { enableTunnel } from "@/lib/tunnel/tunnelManager";
|
||||
import { killCloudflared, isCloudflaredRunning, ensureCloudflared } from "@/lib/tunnel/cloudflared";
|
||||
|
||||
/**
|
||||
* Initialize app on startup
|
||||
* - Cleanup stale data
|
||||
* - Auto-reconnect tunnel if previously enabled
|
||||
* - Register shutdown handler to kill cloudflared
|
||||
*/
|
||||
export async function initializeApp() {
|
||||
try {
|
||||
await cleanupProviderConnections();
|
||||
|
||||
// Auto-reconnect tunnel if it was enabled before restart
|
||||
const settings = await getSettings();
|
||||
if (settings.tunnelEnabled && !isCloudflaredRunning()) {
|
||||
console.log("[InitApp] Tunnel was enabled, auto-reconnecting...");
|
||||
try {
|
||||
await enableTunnel();
|
||||
console.log("[InitApp] Tunnel reconnected");
|
||||
} catch (error) {
|
||||
console.log("[InitApp] Tunnel reconnect failed:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Kill cloudflared on process exit
|
||||
const cleanup = () => {
|
||||
killCloudflared();
|
||||
process.exit();
|
||||
};
|
||||
process.on("SIGINT", cleanup);
|
||||
process.on("SIGTERM", cleanup);
|
||||
|
||||
// Pre-download cloudflared binary in background
|
||||
ensureCloudflared().catch(() => {});
|
||||
} catch (error) {
|
||||
console.error("[InitApp] Error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export default initializeApp;
|
||||
@@ -1,5 +1,7 @@
|
||||
/* ========== CLOUD SYNC — COMMENTED OUT (replaced by Tunnel) ==========
|
||||
import { getCloudSyncScheduler } from "@/shared/services/cloudSyncScheduler";
|
||||
import { isCloudEnabled, cleanupProviderConnections } from "@/lib/localDb";
|
||||
========== END CLOUD SYNC ========== */
|
||||
import { cleanupProviderConnections } from "@/lib/localDb";
|
||||
|
||||
/**
|
||||
* Initialize cloud sync scheduler
|
||||
@@ -10,6 +12,7 @@ export async function initializeCloudSync() {
|
||||
// Cleanup null fields from existing data
|
||||
await cleanupProviderConnections();
|
||||
|
||||
/* ========== CLOUD SYNC — COMMENTED OUT (replaced by Tunnel) ==========
|
||||
// Create scheduler instance with default 15-minute interval
|
||||
const scheduler = await getCloudSyncScheduler(null, 15);
|
||||
|
||||
@@ -17,6 +20,8 @@ export async function initializeCloudSync() {
|
||||
await scheduler.start();
|
||||
|
||||
return scheduler;
|
||||
========== END CLOUD SYNC ========== */
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("[CloudSync] Error initializing scheduler:", error);
|
||||
throw error;
|
||||
|
||||
Reference in New Issue
Block a user