// --- Icons Initialization --- lucide.createIcons(); // --- State --- const state = { nodes: [], logs: [], activeNode: null, isFetching: false, isConnecting: false, subscriptionUrl: '', sessionId: Math.random().toString(16).substr(2, 8).toUpperCase(), userInfo: null, proxyEnabled: true, urlVisible: false, serverStartTime: 0, uptimeInterval: null }; // --- DOM Elements --- const els = { subUrlInput: document.getElementById('subUrlInput'), subUrlFull: document.getElementById('subUrlFull'), toggleUrlVisibility: document.getElementById('toggleUrlVisibility'), urlEyeIcon: document.getElementById('urlEyeIcon'), fetchServersBtn: document.getElementById('fetchServersBtn'), fetchIcon: document.getElementById('fetchIcon'), fetchText: document.getElementById('fetchText'), serverListContainer: document.getElementById('serverListContainer'), serverCount: document.getElementById('serverCount'), logsContainer: document.getElementById('logsContainer'), clearLogs: document.getElementById('clearLogs'), headerStatus: document.getElementById('headerStatus'), sessionId: document.getElementById('sessionId'), trafficValue: document.getElementById('trafficValue'), // Master toggle masterProxyToggle: document.getElementById('masterProxyToggle'), proxyModeLabel: document.getElementById('proxyModeLabel'), proxyModeSubtitle: document.getElementById('proxyModeSubtitle'), currentIpDisplay: document.getElementById('currentIpDisplay'), // Fallback Proxy elements fallbackToggle: document.getElementById('fallbackToggle'), fallbackToggleLabel: document.getElementById('fallbackToggleLabel'), fallbackHost: document.getElementById('fallbackHost'), fallbackPort: document.getElementById('fallbackPort'), saveFallbackBtn: document.getElementById('saveFallbackBtn'), fallbackStatus: document.getElementById('fallbackStatus'), // Proxy Chain visualization chainFallbackRow: document.getElementById('chainFallbackRow'), chainFallbackBox: document.getElementById('chainFallbackBox'), chainFallbackLabel: document.getElementById('chainFallbackLabel'), chainFallbackLatency: document.getElementById('chainFallbackLatency'), chainFallbackX: document.getElementById('chainFallbackX'), chainVPNRow: document.getElementById('chainVPNRow'), chainVPNBox: document.getElementById('chainVPNBox'), chainVPNLabel: document.getElementById('chainVPNLabel'), chainVPNLatency: document.getElementById('chainVPNLatency'), chainVPNX: document.getElementById('chainVPNX'), chainDirectRow: document.getElementById('chainDirectRow'), chainStatus: document.getElementById('chainStatus'), // Connection settings httpProxyUrl: document.getElementById('httpProxyUrl'), socks5ProxyUrl: document.getElementById('socks5ProxyUrl'), // Uptime uptimeDisplay: document.getElementById('uptimeDisplay') }; els.sessionId.textContent = state.sessionId; // --- Helpers --- function formatBytes(bytes, decimals = 1) { if (!+bytes) return '0 B'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; } function maskUrl(url) { if (!url) return ''; try { const parsed = new URL(url); return `${parsed.hostname}/...`; } catch { return url.length > 30 ? url.substring(0, 30) + '...' : url; } } function formatUptime(seconds) { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } function startUptimeTimer() { if (state.uptimeInterval) clearInterval(state.uptimeInterval); if (!state.serverStartTime || state.serverStartTime <= 0) { els.uptimeDisplay.textContent = '00:00:00'; return; } // Adjust local offset if needed, but simple diff is usually enough const update = () => { const now = Date.now() / 1000; const elapsed = Math.floor(now - state.serverStartTime); if (elapsed >= 0) { els.uptimeDisplay.textContent = formatUptime(elapsed); } }; update(); state.uptimeInterval = setInterval(update, 1000); } function stopUptimeTimer() { if (state.uptimeInterval) { clearInterval(state.uptimeInterval); state.uptimeInterval = null; } els.uptimeDisplay.textContent = '00:00:00'; } // --- URL Visibility Toggle --- els.toggleUrlVisibility.addEventListener('click', () => { state.urlVisible = !state.urlVisible; updateUrlDisplay(); }); function updateUrlDisplay() { const fullUrl = els.subUrlFull.value || state.subscriptionUrl; if (state.urlVisible) { els.subUrlInput.value = fullUrl; els.urlEyeIcon.setAttribute('data-lucide', 'eye'); } else { els.subUrlInput.value = maskUrl(fullUrl); els.urlEyeIcon.setAttribute('data-lucide', 'eye-off'); } lucide.createIcons(); } els.subUrlInput.addEventListener('input', () => { // When user types, store full URL els.subUrlFull.value = els.subUrlInput.value; state.subscriptionUrl = els.subUrlInput.value; }); els.subUrlInput.addEventListener('blur', () => { // On blur, mask if not visible if (!state.urlVisible && els.subUrlFull.value) { els.subUrlInput.value = maskUrl(els.subUrlFull.value); } }); els.subUrlInput.addEventListener('focus', () => { // On focus, show full for editing if (els.subUrlFull.value) { els.subUrlInput.value = els.subUrlFull.value; } }); // --- Logger --- function addLog(msg, type = 'info') { const time = new Date().toLocaleTimeString('ru-RU', { hour12: false }); const logEl = document.createElement('div'); logEl.className = 'flex gap-2 items-start leading-tight animate-in fade-in slide-in-from-left-1 duration-300'; let colorClass = 'text-[#00ff41]/70'; let prefix = '>>'; if (type === 'success') { colorClass = 'text-blue-400'; prefix = 'OK.'; } if (type === 'warning') { colorClass = 'text-yellow-500'; prefix = '!!'; } if (type === 'error') { colorClass = 'text-red-500'; prefix = 'ERR'; } logEl.innerHTML = ` [${time}] ${prefix} ${msg} `; els.logsContainer.insertBefore(logEl, els.logsContainer.lastElementChild); els.logsContainer.scrollTop = els.logsContainer.scrollHeight; // Limit logs while (els.logsContainer.children.length > 50) { els.logsContainer.removeChild(els.logsContainer.firstElementChild); } } // Global copy function for proxy URLs function copyToClipboard(inputId, btn) { const input = document.getElementById(inputId); navigator.clipboard.writeText(input.value).then(() => { const originalText = btn.textContent; btn.textContent = 'Copied!'; btn.classList.add('text-blue-400'); setTimeout(() => { btn.textContent = originalText; btn.classList.remove('text-blue-400'); }, 1500); addLog(`COPIED: ${input.value}`, 'success'); }); } els.clearLogs.addEventListener('click', () => { const lastChild = els.logsContainer.lastElementChild; els.logsContainer.innerHTML = ''; els.logsContainer.appendChild(lastChild); addLog('LOGS_CLEARED', 'info'); }); // --- UI Rendering --- function renderNodes() { els.serverListContainer.innerHTML = ''; if (state.nodes.length === 0) { els.serverListContainer.innerHTML = `
No_Data // Awaiting_Sync
`; els.serverCount.textContent = '0 endpoints'; return; } els.serverCount.textContent = `${state.nodes.length} endpoints`; state.nodes.forEach((node, index) => { const isActive = state.activeNode && state.activeNode.tag === node.tag; const card = document.createElement('div'); card.className = `server-card cursor-pointer p-2 bg-black border border-[#00ff41]/20 hover:border-[#00ff41]/50 ${isActive ? 'active' : ''}`; card.onclick = () => handleConnect(node); card.innerHTML = `
${node.tag}
${node.type}
--
`; els.serverListContainer.appendChild(card); }); lucide.createIcons(); } function updateMasterToggleUI() { if (state.proxyEnabled) { els.proxyModeLabel.textContent = 'VPN_MODE'; els.proxyModeLabel.classList.remove('text-yellow-500'); els.proxyModeLabel.classList.add('text-[#00ff41]'); els.proxyModeSubtitle.textContent = 'Traffic routed via proxy'; els.headerStatus.textContent = state.activeNode ? 'TUNNEL_UP' : 'STANDBY'; // Show VPN row, hide direct els.chainVPNRow.classList.remove('hidden'); els.chainDirectRow.classList.add('hidden'); // Start uptime if connected if (state.activeNode && !state.uptimeInterval) { startUptimeTimer(); } } else { els.proxyModeLabel.textContent = 'DIRECT_MODE'; els.proxyModeLabel.classList.remove('text-[#00ff41]'); els.proxyModeLabel.classList.add('text-yellow-500'); els.proxyModeSubtitle.textContent = 'Bypass proxy — direct connection'; els.headerStatus.textContent = 'DIRECT'; // Hide VPN/Fallback, show direct els.chainVPNRow.classList.add('hidden'); els.chainFallbackRow.classList.add('hidden'); els.chainDirectRow.classList.remove('hidden'); els.chainStatus.innerHTML = ` Direct connection active`; // Stop uptime timer stopUptimeTimer(); } lucide.createIcons(); } function updateTrafficUI(info) { if (!info) return; const used = formatBytes((info.download || 0) + (info.upload || 0)); const total = info.total ? formatBytes(info.total) : '∞'; els.trafficValue.textContent = `${used} / ${total}`; } async function checkServerLatencies(nodes) { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; const pingEl = document.getElementById(`ping-${i}`); if (pingEl) pingEl.textContent = '...'; try { const res = await fetch('/ping-target', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server: node.server, port: node.server_port || 443 }) }); const data = await res.json(); if (pingEl) { if (data.latency && data.latency !== -1) { pingEl.textContent = data.latency + 'ms'; if (data.latency > 300) pingEl.style.color = 'rgb(239, 68, 68)'; else if (data.latency < 100) pingEl.style.color = '#00ff41'; else pingEl.style.color = 'rgb(234, 179, 8)'; } else { pingEl.textContent = 'Timeout'; pingEl.style.color = 'rgb(239, 68, 68)'; } } } catch (e) { if (pingEl) pingEl.textContent = 'Err'; } } } async function checkConnectionSpeed(fullTest = false) { els.currentIpDisplay.textContent = '...'; try { const res = await fetch(`/test-connection?speed=${fullTest}`); const data = await res.json(); if (data.error) { els.currentIpDisplay.textContent = 'ERROR'; } else { els.currentIpDisplay.textContent = data.ip || '---.---.---.---'; } } catch (e) { els.currentIpDisplay.textContent = 'NET_ERR'; } } // --- Master Proxy Toggle --- els.masterProxyToggle.addEventListener('change', async () => { state.proxyEnabled = els.masterProxyToggle.checked; updateMasterToggleUI(); addLog(state.proxyEnabled ? 'PROXY_ENABLED' : 'PROXY_DISABLED_DIRECT_MODE', state.proxyEnabled ? 'success' : 'warning'); try { const res = await fetch('/proxy-enabled', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: state.proxyEnabled }) }); const data = await res.json(); if (data.success) { addLog('CONFIG_APPLIED', 'success'); // Force fetch new status to get correct fallback/uptime state setTimeout(async () => { await fetchStatus(); checkConnectionSpeed(false); }, 500); } } catch (e) { addLog('TOGGLE_FAILED: ' + e.message, 'error'); } updateProxyChain(); }); // --- Actions --- async function handleFetchNodes() { // Get the actual URL (from hidden field or input) let url = els.subUrlFull.value || els.subUrlInput.value; url = url.trim(); if (!url || url.endsWith('/...')) { addLog('ERROR_MISSING_URL', 'error'); return; } state.subscriptionUrl = url; els.subUrlFull.value = url; state.isFetching = true; els.fetchIcon.classList.add('hidden'); els.fetchText.innerHTML = ''; lucide.createIcons(); addLog(`FETCHING: ${maskUrl(url)}`, 'info'); try { const res = await fetch('/fetch-subscription', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) }); const data = await res.json(); if (data.success && data.servers) { state.nodes = data.servers; state.config = data.config; state.userInfo = data.userInfo; renderNodes(); updateTrafficUI(state.userInfo); updateUrlDisplay(); addLog(`SYNC_OK: ${state.nodes.length} endpoints`, 'success'); addLog('CHECKING_LATENCY...', 'info'); checkServerLatencies(state.nodes); } else { throw new Error(data.error || 'Unknown Error'); } } catch (e) { addLog(`SYNC_FAILED: ${e.message}`, 'error'); } finally { state.isFetching = false; els.fetchIcon.classList.remove('hidden'); els.fetchText.textContent = 'Sync'; lucide.createIcons(); } } async function handleConnect(node) { if (!state.proxyEnabled) { addLog('ENABLE_PROXY_FIRST', 'warning'); return; } if (state.activeNode && state.activeNode.tag === node.tag && !state.isConnecting) { return; } state.activeNode = node; state.isConnecting = true; // Block UI els.serverListContainer.classList.add('pointer-events-none', 'opacity-50'); renderNodes(); addLog(`CONNECTING: ${node.tag}`, 'warning'); try { const res = await fetch('/apply-subscription', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ config: state.config, selectedServer: node.tag, subUrl: state.subscriptionUrl, userInfo: state.userInfo }) }); const data = await res.json(); if (data.success) { setTimeout(async () => { state.isConnecting = false; els.serverListContainer.classList.remove('pointer-events-none', 'opacity-50'); els.headerStatus.textContent = 'TUNNEL_UP'; renderNodes(); addLog(`CONNECTED: ${node.tag}`, 'success'); // Wait for next status update to sync start time properly await fetchStatus(); // Fallback: start timer optimistically if status didn't catch it yet if (!state.uptimeInterval) { state.serverStartTime = Date.now() / 1000; startUptimeTimer(); } checkConnectionSpeed(false); updateProxyChain(); }, 800); } else { throw new Error(data.error); } } catch (e) { state.isConnecting = false; els.serverListContainer.classList.remove('pointer-events-none', 'opacity-50'); state.activeNode = null; renderNodes(); addLog(`CONNECT_FAILED: ${e.message}`, 'error'); } } async function fetchStatus() { try { const res = await fetch('/status'); const data = await res.json(); if (data.proxyPort) { const host = window.location.hostname; els.httpProxyUrl.value = `http://${host}:${data.proxyPort}`; els.socks5ProxyUrl.value = `socks5://${host}:${data.proxyPort}`; } if (data.proxyEnabled !== undefined) { state.proxyEnabled = data.proxyEnabled; els.masterProxyToggle.checked = state.proxyEnabled; updateMasterToggleUI(); } if (data.startTime) { state.serverStartTime = data.startTime; } if (data.active && data.tag && state.proxyEnabled) { const currentTag = state.activeNode ? state.activeNode.tag : null; if (currentTag !== data.tag) { const fullNode = state.nodes.find(n => n.tag === data.tag); if (fullNode) { state.activeNode = fullNode; } else { state.activeNode = { tag: data.tag, server: data.server, port: '?', type: 'UNKNOWN' }; } renderNodes(); checkConnectionSpeed(false); // Restart timer with new server start time startUptimeTimer(); } // Ensure timer is running if active if (!state.uptimeInterval) startUptimeTimer(); } else if (!data.active && state.proxyEnabled) { // Proxy enabled but backend says not active/configured state.activeNode = null; renderNodes(); stopUptimeTimer(); } // Always update chain visualization updateProxyChain(); } catch (e) { // ignore } } async function loadSaved() { try { addLog('SYSTEM_BOOT...', 'info'); const res = await fetch('/subscription'); const data = await res.json(); if (data.saved && data.url) { state.subscriptionUrl = data.url; els.subUrlFull.value = data.url; els.subUrlInput.value = maskUrl(data.url); state.userInfo = data.userInfo; updateTrafficUI(state.userInfo); await handleFetchNodes(); } else { addLog('NO_SAVED_CONFIG', 'warning'); } await fetchStatus(); } catch (e) { addLog('BOOT_ERROR', 'error'); } } // --- Event Listeners --- els.fetchServersBtn.addEventListener('click', handleFetchNodes); // --- Fallback Proxy Functions --- async function loadFallbackConfig() { try { const res = await fetch('/fallback-config'); const data = await res.json(); els.fallbackToggle.checked = data.enabled || false; els.fallbackHost.value = data.host || '192.168.50.111'; els.fallbackPort.value = data.port || 8080; updateFallbackUI(data.enabled || false); } catch (e) { addLog('FALLBACK_LOAD_FAILED', 'error'); } } function updateFallbackUI(enabled) { els.fallbackToggleLabel.textContent = enabled ? 'ON' : 'OFF'; els.fallbackToggleLabel.classList.toggle('opacity-100', enabled); els.fallbackToggleLabel.classList.toggle('opacity-50', !enabled); } async function saveFallbackConfig() { const enabled = els.fallbackToggle.checked; const host = els.fallbackHost.value.trim(); const port = parseInt(els.fallbackPort.value) || 8080; if (enabled && !host) { addLog('FALLBACK_HOST_REQUIRED', 'error'); showFallbackStatus('Host required', 'error'); return; } try { const res = await fetch('/fallback-config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled, host, port }) }); const data = await res.json(); if (data.success) { addLog(enabled ? 'FALLBACK_ON' : 'FALLBACK_OFF', 'success'); showFallbackStatus('Applied!', 'success'); updateFallbackUI(enabled); updateProxyChain(); } else { throw new Error(data.error); } } catch (e) { addLog(`FALLBACK_FAILED: ${e.message}`, 'error'); showFallbackStatus('Failed', 'error'); } } function showFallbackStatus(msg, type = 'info') { els.fallbackStatus.textContent = msg; els.fallbackStatus.classList.remove('hidden', 'text-[#00ff41]/50', 'text-red-500', 'text-blue-400'); if (type === 'success') els.fallbackStatus.classList.add('text-blue-400'); else if (type === 'error') els.fallbackStatus.classList.add('text-red-500'); else els.fallbackStatus.classList.add('text-[#00ff41]/50'); setTimeout(() => { els.fallbackStatus.classList.add('hidden'); }, 3000); } // --- Proxy Chain Visualization --- async function updateProxyChain() { // Always update UI based on current state first for immediate feedback if (!state.proxyEnabled) { // Direct mode is handled by updateMasterToggleUI generally, // but we ensure clean slate here too if needed return; } try { // If we are in VPN mode but no node selected (Standby) if (!state.activeNode) { els.chainVPNLabel.textContent = 'VPN (Standby)'; els.chainVPNLatency.textContent = '--ms'; els.chainVPNBox.classList.remove('border-[#00ff41]', 'bg-[#00ff41]/20'); els.chainVPNBox.classList.add('border-[#00ff41]/30', 'border-dashed'); els.chainStatus.innerHTML = ' VPN Standby - Select Server'; // Show X to indicate no connection through VPN yet els.chainVPNX.classList.remove('hidden'); els.chainVPNX.classList.add('flex'); } else { els.chainVPNBox.classList.remove('border-dashed'); } const res = await fetch('/active-proxy'); const data = await res.json(); // Reset all states els.chainFallbackBox.classList.remove('border-[#00ff41]', 'bg-[#00ff41]/20'); els.chainFallbackBox.classList.add('border-[#00ff41]/30'); els.chainVPNBox.classList.remove('border-[#00ff41]', 'bg-[#00ff41]/20'); els.chainVPNBox.classList.add('border-[#00ff41]/30'); els.chainFallbackX.classList.add('hidden'); els.chainFallbackX.classList.remove('flex'); els.chainVPNX.classList.add('hidden'); els.chainVPNX.classList.remove('flex'); if (!data.configured) { els.chainStatus.innerHTML = ' VPN Standby'; els.chainFallbackRow.classList.add('hidden'); els.chainVPNLabel.textContent = 'Select Server'; // Visual cue for disconnected VPN els.chainVPNX.classList.remove('hidden'); els.chainVPNX.classList.add('flex'); return; } // Update VPN label els.chainVPNLabel.textContent = data.vpnTag || 'VPN'; els.chainVPNLatency.textContent = data.vpnLatency ? `${data.vpnLatency}ms` : '--ms'; if (data.fallbackEnabled) { els.chainFallbackRow.classList.remove('hidden'); els.chainFallbackLabel.textContent = data.fallbackHost || 'Fallback'; els.chainFallbackLatency.textContent = data.fallbackLatency ? `${data.fallbackLatency}ms` : '--ms'; if (data.fallbackReachable) { els.chainFallbackBox.classList.remove('border-[#00ff41]/30'); els.chainFallbackBox.classList.add('border-[#00ff41]', 'bg-[#00ff41]/20'); els.chainStatus.innerHTML = ` Fallback active (${data.fallbackLatency}ms)`; } else { els.chainFallbackX.classList.remove('hidden'); els.chainFallbackX.classList.add('flex'); els.chainVPNBox.classList.remove('border-[#00ff41]/30'); els.chainVPNBox.classList.add('border-[#00ff41]', 'bg-[#00ff41]/20'); els.chainStatus.innerHTML = ` VPN active (fallback down)`; } } else { els.chainFallbackRow.classList.add('hidden'); els.chainVPNBox.classList.remove('border-[#00ff41]/30'); els.chainVPNBox.classList.add('border-[#00ff41]', 'bg-[#00ff41]/20'); els.chainStatus.innerHTML = ` VPN direct`; } lucide.createIcons(); } catch (e) { els.chainStatus.textContent = 'Failed to load'; } } // Fallback Event Listeners els.saveFallbackBtn.addEventListener('click', saveFallbackConfig); els.fallbackToggle.addEventListener('change', saveFallbackConfig); // --- Init --- addLog('TERMINAL_READY', 'info'); loadSaved(); loadFallbackConfig(); updateProxyChain(); // Periodically update proxy chain setInterval(updateProxyChain, 10000);