Files
vpn-proxy/web/static/js/app.js

734 lines
26 KiB
JavaScript

// --- 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 = `
<span class="opacity-20 shrink-0 tracking-tighter text-[#00ff41]">[${time}]</span>
<span class="${colorClass}">${prefix} ${msg}</span>
`;
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 = `
<div class="col-span-3 text-center py-6 text-[#00ff41]/30 text-xs uppercase">
No_Data // Awaiting_Sync
</div>`;
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 = `
<div class="flex items-center gap-1.5 mb-1">
<div class="w-1.5 h-1.5 rounded-full ${isActive ? 'bg-[#00ff41] animate-pulse' : 'bg-[#00ff41]/30'}"></div>
<span class="text-[10px] font-bold text-white uppercase truncate">${node.tag}</span>
</div>
<div class="text-[9px] opacity-40 text-[#00ff41] truncate">${node.type}</div>
<div id="ping-${index}" class="text-[10px] font-mono text-[#00ff41]/70 mt-1">--</div>
`;
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 = `<span class="text-yellow-400">●</span> 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 = '<i data-lucide="refresh-cw" class="w-4 h-4 animate-spin"></i>';
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 = '<span class="text-orange-400">●</span> 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 = '<span class="text-orange-400">●</span> 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 = `<span class="text-[#00ff41]">●</span> 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 = `<span class="text-yellow-400">●</span> 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 = `<span class="text-[#00ff41]">●</span> 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);