feat: Реализован новый веб-интерфейс и бэкенд для управления VPN-клиентом, включая списки серверов, элементы управления прокси и опции конфигурации.
This commit is contained in:
130
web/static/css/style.css
Normal file
130
web/static/css/style.css
Normal file
@@ -0,0 +1,130 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100..800&display=swap");
|
||||
|
||||
:root {
|
||||
--color-neon: #00ff41;
|
||||
--color-bg: #050505;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-neon);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--color-neon);
|
||||
color: black;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 255, 65, 0.2);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 255, 65, 0.5);
|
||||
}
|
||||
|
||||
.matrix-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%),
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 0, 0, 0.06),
|
||||
rgba(0, 255, 0, 0.02),
|
||||
rgba(0, 0, 255, 0.06)
|
||||
);
|
||||
background-size:
|
||||
100% 2px,
|
||||
3px 100%;
|
||||
}
|
||||
|
||||
/* Big Toggle Switch */
|
||||
.big-toggle {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.big-toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.big-toggle .slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background-color: #1a1a1a;
|
||||
border: 2px solid rgba(0, 255, 65, 0.3);
|
||||
border-radius: 40px;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.big-toggle .slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: rgba(0, 255, 65, 0.4);
|
||||
border-radius: 50%;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.big-toggle input:checked + .slider {
|
||||
background-color: rgba(0, 255, 65, 0.2);
|
||||
border-color: #00ff41;
|
||||
box-shadow: 0 0 20px rgba(0, 255, 65, 0.4);
|
||||
}
|
||||
|
||||
.big-toggle input:checked + .slider:before {
|
||||
transform: translateX(40px);
|
||||
background-color: #00ff41;
|
||||
box-shadow: 0 0 10px #00ff41;
|
||||
}
|
||||
|
||||
/* Server Card */
|
||||
.server-card {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.server-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 255, 65, 0.15);
|
||||
}
|
||||
|
||||
.server-card.active {
|
||||
border-color: #00ff41 !important;
|
||||
box-shadow: 0 0 15px rgba(0, 255, 65, 0.3);
|
||||
}
|
||||
|
||||
.blink-1 {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
.blink-2 {
|
||||
animation: blink 1s infinite 0.2s;
|
||||
}
|
||||
.blink-3 {
|
||||
animation: blink 1s infinite 0.4s;
|
||||
}
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
732
web/static/js/app.js
Normal file
732
web/static/js/app.js
Normal file
@@ -0,0 +1,732 @@
|
||||
// --- 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',
|
||||
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);
|
||||
Reference in New Issue
Block a user