- Added tunnel
- Removed cloud feature
This commit is contained in:
decolua
2026-02-21 16:42:46 +07:00
parent adf57aa0c9
commit 0baa299722
37 changed files with 858 additions and 933 deletions

View File

@@ -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}`);

View File

@@ -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;
}

View File

@@ -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";
};

View File

@@ -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";
};

View File

@@ -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";
};

View File

@@ -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>

View File

@@ -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,
};

View File

@@ -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 {

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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() };
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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"
});
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View File

@@ -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({

View File

@@ -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");
},

View File

@@ -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;

View File

@@ -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,

View 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
View 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 */ }
}

View 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
};
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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>

View 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;

View File

@@ -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;