734 lines
26 KiB
JavaScript
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);
|