// ========================================================= // Vemitreya v2.206 — React SPA // ========================================================= const { useState, useEffect, useCallback, useRef, useMemo } = React; const { LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } = Recharts; // ========================================================= // ICONS — Lucide-style line icons (inline SVG) // ========================================================= const ICON_PATHS = { // Дашборд dashboard: <>, // Соединения / активность activity: , // Переключение прокси shuffle: <>, // Группы / папки слоями layers: <>, // Подписки (radio waves) satellite: <>, // Правила (list-checks) list: <>, // Speedtest (gauge) gauge: <>, // AWG туннели (shield) shield: , // TrustTunnel (route) route: <>, // YAML / код fileCode: <>, // Сервисы (sliders) sliders: <>, // Обновления (download) download: <>, // Логи (terminal) terminal: <>, // Telegram (send) send: <>, // Прочие действия (для кнопок и т.п.) refresh: <>, plus: <>, edit: <>, trash: <>, search: <>, check: , x: <>, arrowUp: , play: , pause: <>, stop: , // Настройки (gear) settings: <>, // Дашборд: метрики системы и каналы cpu: <>, memory: <>, clock: <>, upload: <>, radio: <>, target: <>, globe: <>, zap: , flag: <>, link: <>, users: <>, chart: <>, }; function Icon({ name, size = 18, strokeWidth = 1.75, className = '', style = {} }) { const path = ICON_PATHS[name]; if (!path) return null; return ( ); } // ========================================================= // USER SETTINGS — частота обновления, пр. // ========================================================= const REFRESH_INTERVALS = [ { ms: 1000, label: '1 сек' }, { ms: 2000, label: '2 сек' }, { ms: 5000, label: '5 сек' }, { ms: 10000, label: '10 сек' }, { ms: 30000, label: '30 сек' }, { ms: 60000, label: '1 мин' }, ]; const settingsStore = { _listeners: new Set(), get refreshInterval() { const v = parseInt(localStorage.getItem('mp_refresh_ms')); return REFRESH_INTERVALS.some(i => i.ms === v) ? v : 2000; }, set refreshInterval(ms) { localStorage.setItem('mp_refresh_ms', String(ms)); this._listeners.forEach(fn => fn()); }, subscribe(fn) { this._listeners.add(fn); return () => this._listeners.delete(fn); }, }; function useRefreshInterval() { const [ms, setMs] = useState(settingsStore.refreshInterval); useEffect(() => settingsStore.subscribe(() => setMs(settingsStore.refreshInterval)), []); return ms; } // ========================================================= // GROUP CLASSIFICATION HELPERS // ========================================================= // Системные группы Mihomo, которые не нужно показывать const SYSTEM_GROUPS = new Set(['GLOBAL']); /** * Классификация группы: * - 'routing': группа управления трафиком — содержит ДРУГИЕ группы или DIRECT/REJECT в proxies * (используется в rules: для маршрутизации типа трафика) * - 'channel': группа каналов — содержит КОНКРЕТНЫЕ прокси / подписки * (это "пул серверов одного протокола", типа AWG, TrustTunnel, EOF [VLESS]) * * @param group объект группы { name, type, proxies, use, ... } * @param allGroupNames Set с именами всех групп */ function classifyGroup(group, allGroupNames) { if (!group) return 'channel'; const proxies = group.proxies || []; // Если есть use: (подписки) — почти наверняка это channel-группа if ((group.use || []).length > 0 && proxies.length === 0) return 'channel'; // Если хотя бы один член — другая группа или DIRECT/REJECT → routing const hasGroupMember = proxies.some(p => p === 'DIRECT' || p === 'REJECT' || allGroupNames.has(p) ); if (hasGroupMember) return 'routing'; return 'channel'; } /** * Из объекта runtime-групп с /api/proxies/groups строит карту классификации. * Возвращает { groupName -> 'routing' | 'channel' } */ function buildGroupClassification(groupsObj) { const allNames = new Set(Object.keys(groupsObj)); const result = {}; Object.entries(groupsObj).forEach(([name, g]) => { if (SYSTEM_GROUPS.has(name)) { result[name] = 'system'; return; } // У runtime-групп поле all = members, но без разбиения use/proxies. // Эвристика: если все члены входят в allNames или это DIRECT/REJECT → routing const members = g.all || []; if (members.length === 0) { result[name] = 'channel'; return; } const hasGroupMember = members.some(m => m === 'DIRECT' || m === 'REJECT' || allNames.has(m) ); result[name] = hasGroupMember ? 'routing' : 'channel'; }); return result; } const api = { base: '', token: '', configure(base, token) { this.base = base.replace(/\/$/, ''); this.token = token; }, async req(method, path, body) { const opts = { method, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.token}` } }; if (body !== undefined && body !== null) opts.body = JSON.stringify(body); const r = await fetch(`${this.base}${path}`, opts); if (!r.ok) { const text = await r.text(); let msg = text; try { const j = JSON.parse(text); if (Array.isArray(j.detail)) { // Pydantic validation errors msg = j.detail.map(e => { const loc = Array.isArray(e.loc) ? e.loc.slice(1).join('.') : ''; return loc ? `${loc}: ${e.msg}` : e.msg; }).join('; '); } else if (typeof j.detail === 'string') { msg = j.detail; } else if (j.detail) { msg = JSON.stringify(j.detail); } } catch {} throw new Error(`${r.status}: ${msg}`); } return r.json(); }, get(p) { return this.req('GET', p); }, post(p, b) { return this.req('POST', p, b); }, put(p, b) { return this.req('PUT', p, b); }, del(p) { return this.req('DELETE', p); }, wsUrl(path) { return `${this.base.replace(/^http/, 'ws')}${path}`; } }; // ========================================================= // TOASTS // ========================================================= let tId = 0; const tSubs = new Set(); const showToast = (msg, type = 'info') => { const t = { id: ++tId, msg, type }; tSubs.forEach(fn => fn('add', t)); setTimeout(() => tSubs.forEach(fn => fn('rm', t)), 3500); }; function Toasts() { const [items, setItems] = useState([]); useEffect(() => { const sub = (a, t) => setItems(its => a === 'add' ? [...its, t] : its.filter(x => x.id !== t.id)); tSubs.add(sub); return () => tSubs.delete(sub); }, []); return
{items.map(t =>
{t.msg}
)}
; } // ========================================================= // HELPERS // ========================================================= // единый компонент поля ввода с чёткой визуальной иерархией. // Поле в карточке-обёртке. Обязательные поля помечены красной звёздочкой // И подсветкой рамки (акцентная левая полоса + усиленная обводка). function Field({ label, required, hint, children, style = {} }) { const requiredStyle = required ? { borderColor: 'var(--accent, #5b8cff)', borderLeft: '3px solid var(--accent, #5b8cff)', background: 'rgba(91,140,255,.04)', } : {}; return (
{label && ( )} {hint && (
{hint}
)} {children}
); } // блок-подсказка/предупреждение с явным заголовком-бейджем — // чтобы визуально отличался от полей ввода (это ЧИТАТЬ, а не заполнять). function InfoBox({ kind = 'info', title, children, style = {} }) { const palette = { info: { bg: 'rgba(91,140,255,.07)', border: 'rgba(91,140,255,.35)', bar: '#5b8cff', icon: 'ℹ️', badge: 'ПОДСКАЗКА' }, warning: { bg: 'rgba(239,68,68,.07)', border: 'rgba(239,68,68,.35)', bar: '#ef4444', icon: '⚠️', badge: 'ВАЖНО' }, tip: { bg: 'rgba(16,185,129,.07)', border: 'rgba(16,185,129,.35)', bar: '#10b981', icon: '💡', badge: 'СОВЕТ' }, }; const p = palette[kind] || palette.info; return (
{p.icon} {p.badge} {title && {title}}
{children &&
{children}
}
); } // сворачиваемый блок. По умолчанию свёрнут. С опциональной // пометкой «важное» (important) — тогда заголовок акцентный (красная рамка/иконка), // чтобы свёрнутый блок было видно и хотелось раскрыть. function Collapsible({ title, important, defaultOpen = false, children, style = {} }) { const [open, setOpen] = useState(defaultOpen); const accent = important ? '#ef4444' : 'var(--border)'; const bg = important ? 'rgba(239,68,68,.06)' : 'transparent'; return (
{open && (
{children}
)}
); } const fmtBytes = (b) => { if (!b || b < 0) return '0 B'; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0, v = b; while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } return `${v.toFixed(v > 100 || i === 0 ? 0 : 1)} ${u[i]}`; }; const fmtBps = (b) => fmtBytes(b) + '/s'; const fmtUptime = (s) => { const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60); if (d > 0) return `${d}д ${h}ч`; if (h > 0) return `${h}ч ${m}м`; return `${m}м`; }; const delayClass = (d) => !d || d < 0 ? 'bad' : d < 200 ? 'good' : d < 500 ? 'medium' : 'bad'; // ========================================================= // ANIMATED NUMBER — плавное обновление чисел // ========================================================= function AnimatedNumber({ value, format = (v) => v, duration = 400, className = '' }) { const [displayed, setDisplayed] = useState(value); const startRef = useRef(null); const fromRef = useRef(value); const rafRef = useRef(null); useEffect(() => { if (value === displayed) return; fromRef.current = displayed; startRef.current = performance.now(); const animate = (now) => { const elapsed = now - startRef.current; const progress = Math.min(elapsed / duration, 1); // easeOutCubic const eased = 1 - Math.pow(1 - progress, 3); const next = fromRef.current + (value - fromRef.current) * eased; setDisplayed(next); if (progress < 1) rafRef.current = requestAnimationFrame(animate); }; rafRef.current = requestAnimationFrame(animate); return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; }, [value]); return {format(displayed)}; } // ========================================================= // THEME // ========================================================= const THEMES = [ { id: 'dark', label: 'Тёмная' }, { id: 'light', label: 'Светлая' }, ]; function ThemeSelector({ theme, onChange }) { // SVG иконки для тёмной (луна) и светлой (солнце) тем const icons = { dark: ( ), light: ( ) }; return (
{THEMES.map(t => ( ))}
); } // ========================================================= // LOGIN // ========================================================= function Login({ onAuth }) { const [url, setUrl] = useState(() => localStorage.getItem('mp_url') || location.origin); const [token, setToken] = useState(() => localStorage.getItem('mp_token') || ''); const [loading, setLoading] = useState(false); const connect = async () => { setLoading(true); try { api.configure(url, token); await api.get('/api/auth/check'); localStorage.setItem('mp_url', url); localStorage.setItem('mp_token', token); onAuth(); } catch (e) { showToast(`Ошибка: ${e.message}`, 'error'); } finally { setLoading(false); } }; return (
Vemitreya

Vemitreya

Управление прокси-инфраструктурой

setUrl(e.target.value)} placeholder="http://SERVER_IP:8888" />
setToken(e.target.value)} />
); } // ========================================================= // DASHBOARD // ========================================================= function Dashboard() { const refreshMs = useRefreshInterval(); const [sys, setSys] = useState(null); const [summary, setSummary] = useState(null); const [traffic, setTraffic] = useState({ up: 0, down: 0 }); const [history, setHistory] = useState([]); const [topDomains, setTopDomains] = useState([]); const [conns, setConns] = useState({ total: 0 }); const [channels, setChannels] = useState({ channels: [], summary: { total: 0, active: 0, total_received: 0, total_sent: 0 } }); const [proxyGroups, setProxyGroups] = useState({}); const [pings, setPings] = useState({ results: [], ts: 0 }); const [pinging, setPinging] = useState(false); const [alerts, setAlerts] = useState(null); // alert cards // Лёгкие данные — обновляются часто (CPU/RAM/uptime/conns/groups) const reloadFast = async () => { try { const [s, c, pg, a] = await Promise.all([ api.get('/api/stats/system'), api.get('/api/connections?limit=1').catch(() => ({ total: 0 })), api.get('/api/proxies/groups').catch(() => ({})), api.get('/api/alerts/dashboard').catch(() => null), ]); setSys(s); setConns(c); setProxyGroups(pg); setAlerts(a); } catch (e) { console.error(e); } }; // Тяжёлые данные — обновляются реже const reloadSlow = async () => { try { const [sm, h, td, ch] = await Promise.all([ api.get('/api/mihomo/summary').catch(() => null), api.get('/api/stats/traffic/history?minutes=15'), api.get('/api/stats/top-domains?limit=10'), api.get('/api/stats/channels').catch(() => ({ channels: [], summary: { total: 0, active: 0, total_received: 0, total_sent: 0 } })), ]); setSummary(sm); setHistory(h); setTopDomains(td); setChannels(ch); } catch (e) { console.error(e); } }; // Backward-compat: первый запуск делает всё сразу const reload = async () => { await Promise.all([reloadFast(), reloadSlow()]); }; const reloadPings = async () => { setPinging(true); try { const r = await api.get('/api/speedtest/dashboard-ping'); setPings(r); } catch {} finally { setPinging(false); } }; useEffect(() => { reload(); reloadPings(); // Лёгкие данные = выбранный пользователем интервал // Тяжёлые данные = max(refreshMs * 2, 5000) const slowMs = Math.max(refreshMs * 2, 5000); const iFast = setInterval(reloadFast, refreshMs); const iSlow = setInterval(reloadSlow, slowMs); const ip = setInterval(reloadPings, 30000); const ws = new WebSocket(api.wsUrl('/ws/traffic')); ws.onopen = () => ws.send(JSON.stringify({ token: api.token })); ws.onmessage = (ev) => { try { setTraffic(JSON.parse(ev.data)); } catch {} }; return () => { clearInterval(iFast); clearInterval(iSlow); clearInterval(ip); ws.close(); }; }, [refreshMs]); if (!sys) return
Загрузка...
; const chartData = history.map(p => ({ time: new Date(p.ts * 1000).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }), up: +(p.up / 1024).toFixed(1), down: +(p.down / 1024).toFixed(1) })); return (
{/* Шапка дашборда с селектором частоты обновления */}
{/* Алерт-карточки (показываются только если есть проблемы) */} {alerts && (() => { const cards = []; // AWG handshake age for (const a of (alerts.awg || [])) { if (a.handshake_status === 'ok') continue; // не показываем зелёные const age = a.handshake_age_seconds; const ageStr = age == null ? 'никогда' : age < 60 ? `${age}s` : `${Math.floor(age/60)}m ${age%60}s`; cards.push({ key: `awg-${a.name}`, severity: a.handshake_status, icon: 'shield', title: `AWG: ${a.name}`, value: ageStr, label: 'handshake устарел', }); } // Mihomo memory const sys2 = alerts.system || {}; if (sys2.mihomo_memory_status && sys2.mihomo_memory_status !== 'ok') { cards.push({ key: 'mihomo-mem', severity: sys2.mihomo_memory_status, icon: 'cpu', title: 'Mihomo память', value: `${sys2.mihomo_memory_mb} MB`, label: sys2.mihomo_memory_status === 'critical' ? 'критично — рестарт?' : 'выше нормы', }); } // Disk if (sys2.disk_status && sys2.disk_status !== 'ok') { cards.push({ key: 'disk', severity: sys2.disk_status, icon: 'database', title: 'Диск', value: `${sys2.disk_used_pct}%`, label: `свободно ${sys2.disk_free_gb} GB`, }); } // Services down for (const [svc, state] of Object.entries(alerts.services || {})) { if (state === 'active') continue; cards.push({ key: `svc-${svc}`, severity: 'critical', icon: 'x', title: svc, value: state, label: 'сервис не активен', }); } if (cards.length === 0) return null; return (
{cards.map(c => (
{c.title}
{c.value}
{c.label}
))}
); })()}
CPU
v.toFixed(1) + '%'} />
RAM
v.toFixed(0) + '%'} />
{sys.memory.used_gb} / {sys.memory.total_gb} GB
Uptime
{fmtUptime(sys.uptime)}
Соединений: Math.round(v)} />
{summary && (
Mihomo
{summary.rules_count} правил
Прокси: {summary.proxies_count} · Подписки: {summary.proxy_providers_count}
)}
Live трафик
UPLOAD
fmtBps(v)} />
DOWNLOAD
fmtBps(v)} />
{/* Активные прокси-группы и статистика использования каналов */} {Object.keys(proxyGroups).length > 0 && (() => { // Подсчитаем — какой канал в скольких группах используется const usageMap = {}; // proxy_name -> [groups using it] const activeMap = {}; // proxy_name -> [groups where it's CURRENTLY active] Object.entries(proxyGroups).forEach(([gname, g]) => { if (SYSTEM_GROUPS.has(gname)) return; // GLOBAL не учитываем (g.all || []).forEach(p => { if (p === 'DIRECT' || p === 'REJECT') return; if (proxyGroups[p]) return; // вложенная группа — скипаем if (!usageMap[p]) usageMap[p] = []; usageMap[p].push(gname); if (g.now === p) { if (!activeMap[p]) activeMap[p] = []; activeMap[p].push(gname); } }); }); // Только активные каналы — те что выбраны хотя бы в одной группе const usageList = Object.entries(usageMap) .filter(([name]) => (activeMap[name] || []).length > 0) .map(([name, gs]) => ({ name, groups: gs, activeIn: activeMap[name] || [] })) .sort((a, b) => { if (b.activeIn.length !== a.activeIn.length) return b.activeIn.length - a.activeIn.length; return b.groups.length - a.groups.length; }); // Классификация const classification = buildGroupClassification(proxyGroups); // Только routing группы const routingEntries = Object.entries(proxyGroups) .filter(([gname]) => classification[gname] === 'routing'); // Резолв: пройти по цепочке group → ... → конкретный прокси const resolveChain = (startGroupName) => { const chain = []; let cur = startGroupName; const seen = new Set(); while (cur && !seen.has(cur)) { seen.add(cur); const g = proxyGroups[cur]; if (!g) break; const next = g.now; if (!next) break; chain.push(next); // Если конечный — выходим if (!proxyGroups[next] || next === 'DIRECT' || next === 'REJECT') break; cur = next; } return chain; }; return ( <>
Активная маршрутизация трафика
{routingEntries.length} групп управления · показывается полная цепочка до конечного канала
{routingEntries.length === 0 && (
Нет routing-групп. Создайте на странице «Группы».
)} {(() => { // Мапа имя_прокси → delay для быстрого lookup const pingMap = {}; (pings.results || []).forEach(r => { pingMap[r.name] = r.delay; }); return routingEntries.map(([gname, g]) => { const chain = resolveChain(gname); const finalChannel = chain[chain.length - 1]; const isFallback = finalChannel === 'DIRECT' || finalChannel === 'REJECT'; const delay = pingMap[finalChannel]; const pingCls = !delay || delay < 0 ? 'bad' : delay < 200 ? 'good' : delay < 500 ? 'medium' : 'bad'; return (
{gname}
{g.type}
{chain.length > 1 ? (
{chain.slice(0, -1).join(' › ')} ›
{finalChannel}
) : ( {finalChannel || } )}
{finalChannel && !isFallback && (
{delay > 0 ? <> {delay} ms : <> {pinging ? 'тест...' : '—'}}
)}
); }); })()}
Использование активных каналов
Сейчас задействовано: {usageList.length} каналов
{usageList.length === 0 ? (
Нет каналов в группах
) : ( {usageList.map(item => ( ))}
Группа (где канал активен) Канал Доступен в группах
{item.activeIn.length > 0 ? item.activeIn.map(gn => ( {gn} )) : не активен}
0 ? 'green' : 'gray'}`}> {item.name} {item.groups.length}
)}
); })()}
🏆 Top-10 доменов
{topDomains.length === 0 ? (
Нет данных
) : ( {topDomains.map(d => ( ))}
ДоменТрафик
{d.domain} {fmtBytes((d.upload_bytes || 0) + (d.download_bytes || 0))}
)}
{/* Активные каналы — туннели с трафиком (в самом низу) */}
Активные каналы ({channels.summary.active}/{channels.summary.total})
{fmtBytes(channels.summary.total_received)} получено · {fmtBytes(channels.summary.total_sent)} отправлено
{channels.channels.length === 0 ? (
Нет активных каналов
) : ( {channels.channels.map(c => ( ))}
Канал Тип Endpoint / Порт Handshake RX TX
{c.name} {c.type === 'awg' ? 'AWG' : 'TrustTunnel'} {c.endpoint || (c.local_port ? `127.0.0.1:${c.local_port}` : '—')} {c.handshake || (c.type === 'trusttunnel' ? (c.active ? 'running' : 'stopped') : '—')} {c.type === 'awg' ? fmtBytes(c.received_bytes) : '—'} {c.type === 'awg' ? fmtBytes(c.sent_bytes) : '—'}
)}
); } // ========================================================= // PROXY SWITCHER — пользовательские вкладки + прогрессивный пинг // ========================================================= // Ключи localStorage const LS_CUSTOM_TABS = 'mp_proxy_custom_tabs'; const LS_ACTIVE_TAB = 'mp_proxy_active_tab'; const LS_DELAYS = 'mp_proxy_delays'; // Загрузка/сохранение вкладок const loadCustomTabs = () => { try { return JSON.parse(localStorage.getItem(LS_CUSTOM_TABS) || '[]'); } catch { return []; } }; const saveCustomTabs = (t) => localStorage.setItem(LS_CUSTOM_TABS, JSON.stringify(t)); // Загрузка/сохранение пингов — чтобы не терять при рефреше страницы const loadStoredDelays = () => { try { const raw = JSON.parse(localStorage.getItem(LS_DELAYS) || '{}'); // Пинги старше 10 минут сбрасываем const now = Date.now(); const fresh = {}; for (const [k, v] of Object.entries(raw)) { if (v && v.ts && now - v.ts < 10 * 60 * 1000) fresh[k] = v; } return fresh; } catch { return {}; } }; const saveStoredDelays = (d) => { try { localStorage.setItem(LS_DELAYS, JSON.stringify(d)); } catch {} }; function ProxySwitcher() { const [groups, setGroups] = useState({}); const [smartGroupsList, setSmartGroupsList] = useState([]); // v2.206 const [smartConfigMap, setSmartConfigMap] = useState({}); // {group: {interval, tolerance, exclude}} const [smartModal, setSmartModal] = useState(null); // {name, g} | null // какие каналы раскрыты (схлопнутые показывают только активный сервер) const [expandedChannels, setExpandedChannels] = useState({}); const [delays, setDelays] = useState(() => loadStoredDelays()); // Live трафик по proxy {name: {up, down, total_up, total_down}} const [traffic, setTraffic] = useState({}); const [testing, setTesting] = useState(null); const [testingProgress, setTestingProgress] = useState(null); // { current, total } const [loading, setLoading] = useState(true); const [customTabs, setCustomTabs] = useState(() => loadCustomTabs()); const [activeTabId, setActiveTabId] = useState(() => localStorage.getItem(LS_ACTIVE_TAB) || 'all'); const [editMode, setEditMode] = useState(false); const [tabModal, setTabModal] = useState(null); // null | 'create' | tab object const [sortByPing, setSortByPing] = useState(() => localStorage.getItem('mp_sort_by_ping') === '1'); useEffect(() => { localStorage.setItem('mp_sort_by_ping', sortByPing ? '1' : '0'); }, [sortByPing]); // Сохраняем пинги в localStorage при каждом изменении useEffect(() => { saveStoredDelays(delays); }, [delays]); const load = async () => { try { setGroups(await api.get('/api/proxies/groups')); } catch (e) { showToast(e.message, 'error'); } finally { setLoading(false); } }; useEffect(() => { load(); const loadSmart = () => api.get("/api/mihomo/smart-config") .then(r => { const c = r.config || {}; setSmartConfigMap(c); setSmartGroupsList(Object.keys(c).filter(n => c[n] && c[n].enabled !== false)); }).catch(() => {}); loadSmart(); // подтягиваем последние пинги от воркера и других тестов, // чтобы в Переключении были свежие задержки без ручного «Тест всех». const loadRecent = () => api.get('/api/proxies/recent-delays') .then(r => { if (!r || typeof r !== 'object') return; setDelays(prev => { const next = { ...prev }; for (const [name, info] of Object.entries(r)) { if (info && typeof info.delay === 'number' && info.delay > 0) { const tsMs = (info.ts || 0) * 1000; const prevEntry = prev[name]; // обновляем только если сервер дал свежее значение чем то что у нас if (!prevEntry || tsMs > (prevEntry.ts || 0)) { next[name] = { delay: info.delay, ts: tsMs }; } } } return next; }); }).catch(() => {}); loadRecent(); const i = setInterval(() => { loadSmart(); loadRecent(); }, 10000); return () => clearInterval(i); }, []); // Polling трафика каждые 2с + обновление выбранного сервера (now) каждые 5с useEffect(() => { let stopped = false; let tickCount = 0; const tick = async () => { try { const t = await api.get('/api/proxies/traffic'); if (!stopped) setTraffic(t || {}); } catch {} // каждые ~6с обновляем группы, чтобы now (выбранный сервер) // менялся визуально без ручного F5 — особенно для умного автовыбора. tickCount++; if (tickCount % 3 === 0) { try { const g = await api.get('/api/proxies/groups'); if (!stopped && g) setGroups(g); } catch {} } }; tick(); const interval = setInterval(tick, 2000); return () => { stopped = true; clearInterval(interval); }; }, []); useEffect(() => { localStorage.setItem(LS_ACTIVE_TAB, activeTabId); }, [activeTabId]); useEffect(() => { saveCustomTabs(customTabs); }, [customTabs]); // Классификация групп: routing/channel/system const classification = useMemo(() => buildGroupClassification(groups), [groups]); // Резолвинг цепочки g.now → ... → конечный канал (для отображения в шапке) const resolveChain = (startGroupName) => { const chain = []; let cur = startGroupName; const seen = new Set(); while (cur && !seen.has(cur)) { seen.add(cur); const g = groups[cur]; if (!g) break; const next = g.now; if (!next) break; chain.push(next); if (!groups[next] || next === 'DIRECT' || next === 'REJECT') break; cur = next; } return chain; }; // Имена всех видимых групп (без GLOBAL) const allGroupNames = Object.keys(groups).filter(n => !SYSTEM_GROUPS.has(n)); const routingGroups = allGroupNames.filter(n => classification[n] === 'routing'); const channelGroups = allGroupNames.filter(n => classification[n] === 'channel'); const assignedGroupNames = new Set(customTabs.flatMap(t => t.groups)); let visibleGroupNames = []; if (activeTabId === 'all') { visibleGroupNames = allGroupNames; } else if (activeTabId === 'routing') { visibleGroupNames = routingGroups; } else if (activeTabId === 'channels') { visibleGroupNames = channelGroups; } else if (activeTabId === 'unassigned') { visibleGroupNames = allGroupNames.filter(n => !assignedGroupNames.has(n)); } else { const t = customTabs.find(t => t.id === activeTabId); if (t) visibleGroupNames = t.groups.filter(n => allGroupNames.includes(n)); } // ============ ПИНГ ============ // Тест ОДНОГО прокси — обновляет state сразу const testOne = async (name) => { try { const r = await api.get(`/api/proxies/delay/${encodeURIComponent(name)}`); const delay = r.delay > 0 ? r.delay : -1; setDelays(prev => ({ ...prev, [name]: { delay, ts: Date.now() } })); return delay; } catch { setDelays(prev => ({ ...prev, [name]: { delay: -1, ts: Date.now() } })); return -1; } }; // Тест списка — с прогрессом const testMany = async (proxyNames) => { const list = Array.from(new Set(proxyNames)).filter(p => p !== 'DIRECT' && p !== 'REJECT'); setTestingProgress({ current: 0, total: list.length }); // Параллельно по 4 штуки const CONCURRENT = 4; let done = 0; const queue = [...list]; const worker = async () => { while (queue.length) { const p = queue.shift(); if (!p) break; await testOne(p); done++; setTestingProgress({ current: done, total: list.length }); } }; await Promise.all(Array.from({ length: Math.min(CONCURRENT, list.length) }, worker)); setTestingProgress(null); }; const testAll = async () => { const all = new Set(); Object.values(groups).forEach(g => g.all.forEach(p => all.add(p))); await testMany(Array.from(all)); showToast('✓ Тест завершён', 'success'); }; const testVisible = async () => { const set = new Set(); visibleGroupNames.forEach(n => { if (groups[n]) groups[n].all.forEach(p => set.add(p)); }); await testMany(Array.from(set)); showToast(`✓ Протестировано во вкладке`, 'success'); }; const testGroup = async (name) => { const g = groups[name]; if (!g) return; await testMany(g.all); }; const switchP = async (group, proxy) => { try { await api.put('/api/proxies/switch', { group, proxy }); load(); } catch (e) { showToast(e.message, 'error'); } }; // вкл/выкл умного автовыбора. При первом включении открывает // модал настроек этой конкретной группы (per-group: разный exclude для каналов). const toggleSmartGroup = (name, g) => { const has = smartGroupsList.includes(name); if (has) { // Выключение — конфиг сохраняется, только enabled=false api.post('/api/mihomo/smart-config', { group: name, enabled: false }) .then(() => { setSmartGroupsList(prev => prev.filter(x => x !== name)); setSmartConfigMap(prev => ({ ...prev, [name]: { ...(prev[name] || {}), enabled: false } })); showToast(`Умный автовыбор выключен: "${name}"`, 'success'); }) .catch(e => showToast(e.message, 'error')); } else { // Включение: если у группы уже есть сохранённый конфиг — просто включаем, // без модала (настройки помнятся). Иначе открываем модал с дефолтами. const existing = smartConfigMap[name]; if (existing && (existing.interval || existing.tolerance || existing.exclude)) { api.post('/api/mihomo/smart-config', { group: name, enabled: true }) .then(() => { setSmartGroupsList(prev => [...prev, name]); setSmartConfigMap(prev => ({ ...prev, [name]: { ...existing, enabled: true } })); showToast(`⚡ Умный автовыбор включён: "${name}" (восстановлены настройки)`, 'success'); }) .catch(e => showToast(e.message, 'error')); } else { setSmartModal({ name, g }); } } }; const findBest = async (group) => { setTesting(group); showToast(`Поиск лучшего в "${group}"...`, 'info'); try { const r = await api.post(`/api/proxies/best/${encodeURIComponent(group)}`); showToast(`✨ ${r.best.name} (${r.best.delay}ms)`, 'success'); const now = Date.now(); setDelays(prev => { const next = { ...prev }; r.all_results.forEach(x => { next[x.name] = { delay: x.delay, ts: now }; }); return next; }); load(); } catch (e) { showToast(e.message, 'error'); } finally { setTesting(null); } }; // ============ УПРАВЛЕНИЕ ВКЛАДКАМИ ============ const createTab = (name) => { const newTab = { id: `tab_${Date.now()}`, name, groups: [] }; setCustomTabs([...customTabs, newTab]); setActiveTabId(newTab.id); }; const renameTab = (id, newName) => { setCustomTabs(customTabs.map(t => t.id === id ? { ...t, name: newName } : t)); }; const deleteTab = (id) => { if (!confirm('Удалить вкладку? Группы останутся, просто не будут закреплены.')) return; setCustomTabs(customTabs.filter(t => t.id !== id)); if (activeTabId === id) setActiveTabId('all'); }; const toggleGroupInTab = (tabId, groupName) => { setCustomTabs(customTabs.map(t => { if (t.id !== tabId) return t; const has = t.groups.includes(groupName); return { ...t, groups: has ? t.groups.filter(g => g !== groupName) : [...t.groups, groupName] }; })); }; const moveGroupTo = (groupName, targetTabId) => { setCustomTabs(customTabs.map(t => { const without = t.groups.filter(g => g !== groupName); if (t.id === targetTabId) { return { ...t, groups: [...without, groupName] }; } return { ...t, groups: without }; })); showToast(targetTabId === null ? 'Откреплена от вкладок' : 'Перенесена', 'success'); }; if (loading) return
; const unassignedCount = allGroupNames.filter(n => !assignedGroupNames.has(n)).length; return (
{/* Полоса вкладок */}
setActiveTabId('all')}> Все {allGroupNames.length}
{routingGroups.length > 0 && (
setActiveTabId('routing')} title="Группы управления трафиком — определяют куда направить тип трафика"> Маршрутизация {routingGroups.length}
)} {channelGroups.length > 0 && (
setActiveTabId('channels')} title="Группы каналов — пулы серверов одного протокола (AWG, TT, VLESS...)"> Каналы {channelGroups.length}
)} {unassignedCount > 0 && unassignedCount < allGroupNames.length && (
setActiveTabId('unassigned')}> Без вкладки {unassignedCount}
)} {customTabs.map(t => (
setActiveTabId(t.id)}> {t.name} {t.groups.filter(g => allGroupNames.includes(g)).length} {editMode && ( <> { e.stopPropagation(); setTabModal(t); }} title="Настроить"> { e.stopPropagation(); deleteTab(t.id); }} title="Удалить">🗑 )}
))}
setTabModal('create')} title="Новая вкладка"> ➕
{/* Toolbar */}
{testingProgress ? `🧪 Тестируется ${testingProgress.current}/${testingProgress.total}...` : `Видимо групп: ${visibleGroupNames.length} · Всего: ${allGroupNames.length}`}
{/* Прогресс-бар */} {testingProgress && (
)} {/* Группы */} {visibleGroupNames.length === 0 && (
📂
{activeTabId === 'all' ? 'Нет групп в конфиге' : activeTabId === 'unassigned' ? 'Все группы закреплены за вкладками' : 'Вкладка пуста. Нажмите шестерёнку чтобы добавить группы.'}
)} {visibleGroupNames.map(name => { const g = groups[name]; if (!g) return null; const groupKind = classification[name]; const isRouting = groupKind === 'routing'; // Трафик через группу: всегда показываем для routing-групп (даже 0) const grpTraffic = traffic[name] || { down: 0, up: 0 }; const showGroupTraffic = isRouting; return (
{(() => { const isChannel = classification[name] === 'channel'; const isExpanded = !isChannel || expandedChannels[name]; const activeProxy = g.now; const activeDelay = delays[activeProxy]?.delay; return ( <>
{ // не сворачиваем если клик по кнопке if (e.target.closest('button')) return; setExpandedChannels(p => ({ ...p, [name]: !p[name] })); } : undefined} style={isChannel ? { cursor: 'pointer' } : {}}> {/* Левая часть: имя группы + badge + скорость */}
{isChannel && ( )} {name} {{ 'Selector': 'Выбор вручную', 'URLTest': 'Авто (Mihomo)', 'Fallback': 'Резерв', 'LoadBalance': 'Баланс', 'Relay': 'Цепочка' }[g.type] || g.type} {smartGroupsList.includes(name) && ( ⚡ Умный выбор )} {showGroupTraffic && ( RX {fmtBps(grpTraffic.down)} · TX {fmtBps(grpTraffic.up)} )}
{/* Правая часть: кнопки действий */}
{customTabs.length > 0 && ( t.groups.includes(name))?.id} customTabs={customTabs} onMove={(tid) => moveGroupTo(name, tid)} /> )} {smartGroupsList.includes(name) && ( )}
{isChannel && !isExpanded && (
setExpandedChannels(p => ({ ...p, [name]: true }))} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 11px', background: 'var(--bg-3)', border: '1px solid var(--border)', borderRadius: 6, cursor: 'pointer', fontSize: 12, marginTop: 6 }}> {activeProxy ? `↳ ${activeProxy}` : '(нет выбора)'} {activeDelay !== undefined && activeDelay > 0 && ( {activeDelay}ms )} ▼ показать {g.all.length}
)} {isExpanded && (
{(() => { let opts = [...g.all]; if (sortByPing) { // DIRECT/REJECT — в конце, остальные по пингу asc (untested в конце) opts.sort((a, b) => { const sA = a === 'DIRECT' || a === 'REJECT'; const sB = b === 'DIRECT' || b === 'REJECT'; if (sA && !sB) return 1; if (!sA && sB) return -1; if (sA && sB) return g.all.indexOf(a) - g.all.indexOf(b); const dA = delays[a]?.delay; const dB = delays[b]?.delay; const validA = dA !== undefined && dA > 0; const validB = dB !== undefined && dB > 0; if (validA && !validB) return -1; if (!validA && validB) return 1; if (validA && validB) return dA - dB; // Оба не тестированы или оба не работают — по исходному порядку return g.all.indexOf(a) - g.all.indexOf(b); }); } return opts.map(p => { const entry = delays[p]; const d = entry?.delay; const age = entry?.ts ? Math.floor((Date.now() - entry.ts) / 1000) : null; const isActive = g.now === p; // Если эта плитка ссылается на другую группу — резолвим её цепочку, // показываем финальный прокси под именем (вне зависимости активна она или нет) let finalProxy = null; if (groups[p] && p !== 'DIRECT' && p !== 'REJECT') { const subChain = resolveChain(p); const last = subChain[subChain.length - 1]; if (last && last !== p && last !== 'DIRECT' && last !== 'REJECT') { finalProxy = last; } } const isSmart = smartGroupsList.includes(name); return (
{ if (isSmart) { showToast('Управляется умным автовыбором — отключите ⚡ Умный для ручного выбора', 'info'); return; } switchP(name, p); }} style={isSmart ? { cursor: 'not-allowed', opacity: 0.85 } : {}} title={isSmart ? 'Управляется умным автовыбором' : ''}>
{p} {d !== undefined && ( {d > 0 ? `${d}ms` : '❌'} )}
{finalProxy && (
↳ {finalProxy}
)}
); }); })()}
)} ); })()}
); })} {tabModal && setTabModal(null)} onCreate={(name) => { createTab(name); setTabModal(null); }} onRename={(id, name) => { renameTab(id, name); setTabModal(null); }} onToggleGroup={(id, group) => toggleGroupInTab(id, group)} />} {smartModal && ( { const all = Object.keys(groups).filter(n => !SYSTEM_GROUPS.has(n)); return classifyGroup({ name: smartModal.name, ...(smartModal.g || {}) }, new Set(all)) === 'channel'; })()} initialConfig={smartConfigMap[smartModal.name]} onClose={() => setSmartModal(null)} onSaved={() => { api.get('/api/mihomo/smart-config').then(r => { const c = r.config || {}; setSmartConfigMap(c); setSmartGroupsList(Object.keys(c).filter(n => c[n] && c[n].enabled !== false)); }).catch(() => {}); }} /> )}
); } function GroupMoveMenu({ groupName, currentTabId, customTabs, onMove }) { const [open, setOpen] = useState(false); const current = customTabs.find(t => t.id === currentTabId); return (
{open && ( <>
setOpen(false)} />
{ onMove(null); setOpen(false); }}> 📌 Без вкладки
{customTabs.map(t => (
{ onMove(t.id); setOpen(false); }}> {t.id === currentTabId ? '✓ ' : ''}{t.name}
))}
)}
); } function TabEditModal({ initial, allGroups, onClose, onCreate, onRename, onToggleGroup }) { const isCreate = !initial; const [name, setName] = useState(initial?.name || ''); const submit = () => { if (!name.trim()) return; if (isCreate) onCreate(name.trim()); else onRename(initial.id, name.trim()); }; return (
e.stopPropagation()} style={{ minWidth: 480 }}>
{isCreate ? '+ Новая вкладка' : `Настроить: ${initial.name}`}
setName(e.target.value)} placeholder="Основной трафик" autoFocus onKeyDown={e => e.key === 'Enter' && submit()} />
{!isCreate && (
{allGroups.length === 0 && (
Нет групп в конфиге
)} {allGroups.map(g => ( ))}
)}
{(isCreate || name !== initial.name) && ( )}
); } // ========================================================= // PROXY PROVIDERS (Subscriptions) // ========================================================= function Providers() { const [list, setList] = useState([]); const [loading, setLoading] = useState(true); const [modal, setModal] = useState(null); // null | 'create' | {name, ...} const load = async () => { try { setList(await api.get('/api/mihomo/providers')); } catch (e) { showToast(e.message, 'error'); } finally { setLoading(false); } }; useEffect(() => { load(); }, []); const del = async (name) => { if (!confirm(`Удалить провайдер "${name}"?`)) return; try { await api.del(`/api/mihomo/providers/${encodeURIComponent(name)}`); showToast('Удалён', 'success'); load(); } catch (e) { showToast(e.message, 'error'); } }; const refresh = async (name, clearCache = false) => { try { const url = `/api/mihomo/providers/${encodeURIComponent(name)}/refresh${clearCache ? '?clear_cache=true' : ''}`; const r = await api.post(url); if (r.cache_deleted) showToast(`Кеш удалён, подписка перезагружается`, 'success'); else showToast('Обновление запущено', 'success'); setTimeout(load, 1500); } catch (e) { showToast(e.message, 'error'); } }; if (loading) return
; return (
Подписки (proxy-providers): {list.length}
💡 Это подписки из секции proxy-providers вашего config.yaml
{list.length === 0 ? (
В конфиге нет секции proxy-providers
) : list.map(p => (
{p.name}
{p.type} Interval: {p.interval}s {p.health_check?.enable && health-check}
{p.url &&
{p.url}
} {p.path &&
Path: {p.path}
}
))} {modal && setModal(null)} onSaved={() => { setModal(null); load(); }} />}
); } function ProviderEditModal({ initial, onClose, onSaved }) { const isEdit = !!initial; const [form, setForm] = useState(() => initial || { name: '', type: 'http', url: '', interval: 3600, path: '', health_check: { enable: true, url: 'http://www.gstatic.com/generate_204', interval: 600 } }); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); const testUrl = async () => { if (!form.url) return; setTesting(true); setTestResult(null); try { const r = await api.post('/api/mihomo/providers/test-url', { url: form.url, timeout: 10 }); setTestResult(r); } catch (e) { setTestResult({ ok: false, error: e.message }); } finally { setTesting(false); } }; const f = (k) => ({ value: form[k] || '', onChange: e => setForm(p => ({ ...p, [k]: e.target.value })), disabled: saving }); const hcf = (k) => ({ value: (form.health_check || {})[k] ?? '', onChange: e => setForm(p => ({ ...p, health_check: { ...(p.health_check || {}), [k]: e.target.value } })), disabled: saving, }); const submit = async () => { try { setSaving(true); const payload = { ...form, interval: parseInt(form.interval) || 3600, health_check: form.health_check?.enable ? form.health_check : null }; if (isEdit) { await api.put(`/api/mihomo/providers/${encodeURIComponent(initial.name)}`, payload); showToast('Обновлено', 'success'); } else { await api.post('/api/mihomo/providers', payload); showToast('Создано', 'success'); } onSaved(); } catch (e) { showToast(e.message, 'error'); } finally { setSaving(false); } }; return (
e.stopPropagation()}> {saving && (
Сохранение и перезагрузка Mihomo...
)}
{isEdit ? `✏️ ${initial.name}` : '+ Новая подписка'}
{form.type !== 'file' && (
{testResult && (
{testResult.ok ? ( <> ✓ Доступна · формат: {testResult.format} {testResult.proxy_count > 0 && <> · прокси: {testResult.proxy_count}} · размер: {(testResult.size / 1024).toFixed(1)} KB ) : ( <> ✗ Недоступна · {testResult.error} {testResult.status && <> (HTTP {testResult.status})} )}
)}
)}
{form.health_check?.enable && ( <>
)}
); } // ========================================================= // RULES EDITOR // ========================================================= const RULE_TYPES = [ 'DOMAIN', 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD', 'DOMAIN-REGEX', 'IP-CIDR', 'IP-CIDR6', 'SRC-IP-CIDR', 'GEOIP', 'GEOSITE', 'DST-PORT', 'SRC-PORT', 'NETWORK', 'PROCESS-NAME', 'PROCESS-PATH', 'RULE-SET', 'MATCH' ]; function RulesEditor() { const [rules, setRules] = useState([]); const [targets, setTargets] = useState([]); const [loading, setLoading] = useState(true); const [editingIdx, setEditingIdx] = useState(null); const [newRule, setNewRule] = useState({ type: 'DOMAIN-SUFFIX', payload: '', target: '' }); const [search, setSearch] = useState(''); const load = async () => { try { const [r, t] = await Promise.all([ api.get('/api/mihomo/rules'), api.get('/api/mihomo/rules/targets') ]); setRules(r); setTargets(t.targets); if (!newRule.target && t.targets.length) { setNewRule(prev => ({ ...prev, target: t.targets[0] })); } } catch (e) { showToast(e.message, 'error'); } finally { setLoading(false); } }; useEffect(() => { load(); }, []); const addRule = async () => { if (!newRule.target || !newRule.target.trim()) { showToast('Выберите target (целевую группу или DIRECT/REJECT)', 'warning'); return; } if (newRule.type !== 'MATCH' && !newRule.payload) { showToast('Заполните payload', 'warning'); return; } // автоматически добавлять no-resolve для IP правил const needsNoResolve = ['IP-CIDR', 'IP-CIDR6', 'GEOIP', 'SRC-IP-CIDR'].includes(newRule.type); let ruleStr; if (newRule.type === 'MATCH') { ruleStr = `MATCH,${newRule.target}`; } else if (needsNoResolve && newRule.noResolve !== false) { ruleStr = `${newRule.type},${newRule.payload},${newRule.target},no-resolve`; } else { ruleStr = `${newRule.type},${newRule.payload},${newRule.target}`; } try { await api.post('/api/mihomo/rules', { rule: ruleStr }); showToast('Правило добавлено', 'success'); setNewRule({ ...newRule, payload: '' }); load(); } catch (e) { showToast(e.message, 'error'); } }; const delRule = async (idx) => { if (!confirm(`Удалить правило #${idx + 1}?`)) return; try { await api.del(`/api/mihomo/rules/${idx}`); load(); } catch (e) { showToast(e.message, 'error'); } }; const saveEdit = async (idx, newStr) => { try { await api.put(`/api/mihomo/rules/${idx}`, { rule: newStr }); showToast('Сохранено', 'success'); setEditingIdx(null); load(); } catch (e) { showToast(e.message, 'error'); } }; const move = async (idx, dir) => { const newIdx = idx + dir; if (newIdx < 0 || newIdx >= rules.length) return; const arr = rules.map(r => r.raw); [arr[idx], arr[newIdx]] = [arr[newIdx], arr[idx]]; try { await api.put('/api/mihomo/rules', { rules: arr }); load(); } catch (e) { showToast(e.message, 'error'); } }; // drag&drop для переупорядочивания правил const [dragSrc, setDragSrc] = useState(null); const [dragOver, setDragOver] = useState(null); const onDragStart = (idx) => (e) => { setDragSrc(idx); e.dataTransfer.effectAllowed = 'move'; }; const onDragOverIdx = (idx) => (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (dragOver !== idx) setDragOver(idx); }; const onDragLeave = () => setDragOver(null); const onDrop = (dstIdx) => async (e) => { e.preventDefault(); setDragOver(null); const src = dragSrc; setDragSrc(null); if (src === null || src === dstIdx) return; const arr = rules.map(r => r.raw); const [item] = arr.splice(src, 1); arr.splice(dstIdx, 0, item); try { await api.put('/api/mihomo/rules', { rules: arr }); load(); } catch (e) { showToast(e.message, 'error'); } }; const filtered = search ? rules.filter(r => r.raw.toLowerCase().includes(search.toLowerCase())) : rules; if (loading) return
; return (
💡 Target — группа из proxy-groups или DIRECT/REJECT. Управлять группами — на вкладке 📂 Группы.
+ Добавить правило
setNewRule(p => ({ ...p, payload: e.target.value }))} />
Правил: {rules.length} {search && `· отфильтровано: ${filtered.length}`} setSearch(e.target.value)} />
{filtered.map(r => (
setEditingIdx(r.index)} onCancelEdit={() => setEditingIdx(null)} onSave={(str) => saveEdit(r.index, str)} onDelete={() => delRule(r.index)} onMoveUp={() => move(r.index, -1)} onMoveDown={() => move(r.index, 1)} />
))}
); } function ProxyGroupModal({ onClose, onSaved, initial = null }) { const isEdit = !!initial; const [form, setForm] = useState(() => initial || { name: '', type: 'select', proxies: [], use: [], url: 'http://www.gstatic.com/generate_204', interval: 300, tolerance: null, }); const [available, setAvailable] = useState({ groups: [], proxies: [], providers: [], provider_proxies: {}, specials: [] }); useEffect(() => { api.get('/api/mihomo/proxy-groups/available-proxies').then(setAvailable).catch(() => {}); }, []); const f = (k) => ({ value: form[k] ?? '', onChange: e => setForm(p => ({ ...p, [k]: e.target.value })) }); const toggleItem = (field, item) => { setForm(p => { const arr = p[field] || []; return { ...p, [field]: arr.includes(item) ? arr.filter(x => x !== item) : [...arr, item] }; }); }; const submit = async () => { if (!form.name) return; if ((form.proxies?.length || 0) + (form.use?.length || 0) === 0) { showToast('Выберите хотя бы один прокси или подписку', 'warning'); return; } const payload = { name: form.name, type: form.type, proxies: form.proxies?.length ? form.proxies : null, use: form.use?.length ? form.use : null, }; if (form.type !== 'select') { payload.url = form.url; payload.interval = parseInt(form.interval) || 300; if ((form.type === 'url-test' || form.type === 'fallback') && form.tolerance) { payload.tolerance = parseInt(form.tolerance); } } try { if (isEdit) { await api.put(`/api/mihomo/proxy-groups/${encodeURIComponent(initial.name)}`, payload); showToast('Группа обновлена', 'success'); } else { await api.post('/api/mihomo/proxy-groups', payload); showToast('Группа создана', 'success'); } onSaved(); } catch (e) { showToast(e.message, 'error'); } }; const allForGroupMembers = [ ...available.specials, ...available.groups.filter(g => g !== form.name), // не даём добавить себя ...available.proxies ]; return (
e.stopPropagation()} style={{ minWidth: 560 }}>
{isEdit ? `✏️ ${initial.name}` : '➕ Новая proxy-group'}
{form.type !== 'select' && (
)}
{allForGroupMembers.length === 0 ?
Нет доступных прокси
: allForGroupMembers.map(p => ( )) }
{available.providers.length > 0 && (
{available.providers.map(p => ( ))}
)} {available.provider_proxies && Object.keys(available.provider_proxies).length > 0 && (
Выберите конкретный сервер (Vless/Hy2/...) из подписки — он добавится в канал как отдельный прокси, без всей подписки.
{Object.entries(available.provider_proxies).map(([prov, names]) => (
{prov}
{names.map(pn => ( ))}
))}
)}
); } function RuleRow({ rule, targets, editing, onStartEdit, onCancelEdit, onSave, onDelete, onMoveUp, onMoveDown }) { const [form, setForm] = useState({ type: rule.type, payload: rule.payload, target: rule.target }); // target существует в текущих группах? const targetMissing = targets && targets.length > 0 && !targets.includes(rule.target); useEffect(() => { if (editing) setForm({ type: rule.type, payload: rule.payload, target: rule.target }); }, [editing, rule]); if (editing) { return (
{rule.index + 1} setForm(p => ({ ...p, payload: e.target.value }))} />
); } return (
{rule.index + 1} {rule.type} {rule.payload} {targetMissing && '⚠️ '}{rule.target} {rule.params.map((p, i) => {p})}
); } // ========================================================= // AWG MULTI-TUNNEL // ========================================================= // ────────────────────────────────────────────────────────────────── // Установка AWG/TrustTunnel — общий компонент-визард (v2.203) // ────────────────────────────────────────────────────────────────── function InstallPrompt({ kind, title, description, onInstalled }) { // kind = 'awg' | 'trusttunnel' const [phase, setPhase] = useState('idle'); // idle | running | done | failed const [logs, setLogs] = useState([]); const [jobId, setJobId] = useState(null); const [proxyOffered, setProxyOffered] = useState(false); const [proxyMode, setProxyMode] = useState(false); const logsRef = useRef(null); useEffect(() => { if (logsRef.current) { logsRef.current.scrollTop = logsRef.current.scrollHeight; } }, [logs]); const start = async (useProxy = false) => { setPhase('running'); setLogs([]); setProxyMode(useProxy); try { const r = await api.post(`/api/${kind}/install`, { use_mihomo_proxy: useProxy }); setJobId(r.job_id); } catch (e) { setPhase('failed'); setLogs(['Ошибка запуска: ' + e.message]); } }; // Polling логов useEffect(() => { if (!jobId || phase !== 'running') return; const iv = setInterval(async () => { try { const r = await api.get(`/api/install/jobs/${jobId}`); setLogs(r.logs || []); if (r.status === 'done') { setPhase('done'); clearInterval(iv); // через секунду проверим что status стал installed setTimeout(async () => { try { const s = await api.get(`/api/${kind}/install/status`); if (s.installed) { onInstalled && onInstalled(); } } catch (e) {} }, 1500); } else if (r.status === 'failed') { setPhase('failed'); clearInterval(iv); // если ещё не пробовали proxy — предложить if (!proxyMode) setProxyOffered(true); } } catch (e) {} }, 1200); return () => clearInterval(iv); }, [jobId, phase, proxyMode]); return (

{title}

{description}
{phase === 'idle' && (
Если установка не удастся — будет предложено повторить через системный прокси Mihomo (полезно если внешние репозитории заблокированы).
)} {(phase === 'running' || phase === 'done' || phase === 'failed') && (
{phase === 'running' && <>
Идёт установка…} {phase === 'done' && ✓ Установка успешно завершена} {phase === 'failed' && ✗ Установка завершилась с ошибкой} {proxyMode && ( через Mihomo proxy )}
            {logs.length > 0 ? logs.join('\n') : '...'}
          
{phase === 'failed' && proxyOffered && (
Возможная причина — блокировка репозитория
Установка может не пройти из-за блокировок (например PPA Amnezia в РФ). Mihomo может работать как системный прокси и пропустить установочные запросы через VPN-канал.
Условие: в Mihomo выбран VPN-канал в группе «🌐 Основной трафик» (не DIRECT), и этот канал реально работает.
)} {phase === 'failed' && !proxyOffered && (
)} {phase === 'done' && (
)}
)}
); } // Хук для проверки установлен ли сервис (AWG/TT) function useInstallStatus(kind) { const [status, setStatus] = useState({ loading: true, installed: false, version: null }); const check = async () => { try { const r = await api.get(`/api/${kind}/install/status`); setStatus({ loading: false, installed: !!r.installed, version: r.version }); } catch (e) { setStatus({ loading: false, installed: false, version: null }); } }; useEffect(() => { check(); }, []); return [status, check]; } function AWGTunnels() { const [installStatus, recheckInstall] = useInstallStatus('awg'); const [list, setList] = useState([]); const [orphans, setOrphans] = useState([]); const [loading, setLoading] = useState(true); const [editing, setEditing] = useState(null); const [creating, setCreating] = useState(false); const [registering, setRegistering] = useState(null); const load = async () => { if (!installStatus.installed) { setLoading(false); return; } try { const [tunnels, orph] = await Promise.all([ api.get('/api/awg/tunnels'), api.get('/api/awg/orphan-proxies').catch(() => ({ orphans: [] })), ]); setList(tunnels); setOrphans(orph.orphans || []); } catch (e) { showToast(e.message, 'error'); } finally { setLoading(false); } }; useEffect(() => { if (!installStatus.loading) { load(); if (installStatus.installed) { const i = setInterval(load, 5000); return () => clearInterval(i); } } }, [installStatus.installed, installStatus.loading]); if (installStatus.loading) return
; if (!installStatus.installed) { return ( ); } const action = async (name, a) => { try { await api.post(`/api/awg/tunnels/${name}/action`, { service: name, action: a }); showToast(`${name}: ${a}`, 'success'); setTimeout(load, 500); } catch (e) { showToast(e.message, 'error'); } }; const del = async (name) => { if (!confirm(`Удалить туннель "${name}"?\nЕсли он добавлен в Mihomo как direct прокси через interface — прокси тоже удалится.`)) return; try { await api.del(`/api/awg/tunnels/${name}`); showToast('Удалён', 'success'); load(); } catch (e) { showToast(e.message, 'error'); } }; const cleanupOrphans = async () => { if (!orphans.length) return; const names = orphans.map(o => o.name).join(', '); if (!confirm(`Удалить из Mihomo orphan-прокси (${orphans.length}): ${names}?\n\n` + `Они ссылаются на удалённые AWG-интерфейсы и не работают.\n` + `Также будут удалены ссылки на них во всех proxy-groups.\n` + `Если группа станет пустой — в неё добавится DIRECT.`)) return; try { const r = await api.post('/api/awg/orphan-proxies/cleanup', {}); showToast(`Удалено ${r.removed?.length || 0} orphan-прокси`, 'success'); load(); } catch (e) { showToast(e.message, 'error'); } }; const emergencyStopAll = async () => { if (!confirm( 'АВАРИЙНАЯ ОСТАНОВКА всех AWG туннелей?\n\n' + 'Используйте если AWG туннель сломал маршрутизацию и SSH/доступ к VM был потерян.\n\n' + '• Все awg-quick@* будут остановлены\n' + '• Все интерфейсы awg* будут удалены через ip link delete\n' + '• Маршруты в default routing table будут очищены\n\n' + 'Конфиги и автозагрузка НЕ удаляются — туннели можно запустить заново позже.' )) return; try { const r = await api.post('/api/awg/emergency-stop-all', {}); showToast(r.message || 'Все AWG туннели остановлены', 'success'); load(); } catch (e) { showToast(e.message, 'error'); } }; if (loading) return
; if (editing) return { setEditing(null); load(); }} />; if (creating) return { setCreating(false); load(); }} />; return (
{/* Warning баннер если есть orphan AWG-прокси в Mihomo */} {orphans.length > 0 && (
Orphan AWG-прокси в Mihomo: {orphans.length}
Прокси ссылаются на удалённые интерфейсы: {orphans.map(o => `"${o.name}" → ${o.interface}`).join(', ')}. Они не работают и засоряют конфиг.
)}
AWG туннель работает на уровне ОС. Чтобы Mihomo направлял трафик через него, добавьте прокси в proxies: с type: direct и interface: имя — это и сделает кнопка "+ В Mihomo" на карточке.
AWG туннелей: {list.length}
{list.some(t => t.active) && ( )}
{list.length === 0 &&
Нет AWG туннелей
} {list.map(t => (
{t.name} {t.active && active} {t.enabled && auto} {t.orphan && ⚠ orphan } {t.in_mihomo ? ✓ в Mihomo : ⚠ нет в Mihomo }
{!t.in_mihomo && !t.orphan && ( )} {!t.orphan && } {!t.orphan && }
Endpoint {t.endpoint || '—'}
Handshake {t.handshake || '—'}
Трафик {t.transfer || '—'}
))} {registering && setRegistering(null)} onSaved={() => { setRegistering(null); load(); }} />}
); } function AWGRegisterModal({ name, onClose, onSaved }) { const defaultName = `${name.toUpperCase()} (AWG)`; const [proxyName, setProxyName] = useState(defaultName); const [routingMark, setRoutingMark] = useState(51820); const [groups, setGroups] = useState([]); const [selected, setSelected] = useState([]); useEffect(() => { api.get('/api/mihomo/proxy-groups').then(setGroups).catch(() => {}); }, []); const toggle = (g) => setSelected(s => s.includes(g) ? s.filter(x => x !== g) : [...s, g]); const submit = async () => { try { await api.post(`/api/awg/tunnels/${name}/register-in-mihomo`, { proxy_name: proxyName, routing_mark: parseInt(routingMark) || 51820, add_to_groups: selected }); showToast(`✓ ${proxyName} добавлен в Mihomo`, 'success'); onSaved(); } catch (e) { showToast(e.message, 'error'); } }; return (
e.stopPropagation()} style={{ minWidth: 540 }}>
➕ Добавить AWG "{name}" в Mihomo
setProxyName(e.target.value)} />
Будет добавлено в config.yaml:
- name: {proxyName}
  type: direct
  interface: {name}
  routing-mark: {routingMark || 51820}
setRoutingMark(e.target.value)} />
{groups.length === 0 ?
Нет групп в конфиге
:
{groups.map(g => ( ))}
}
); } function AWGEdit({ name, onBack }) { const [content, setContent] = useState(''); const [saving, setSaving] = useState(false); useEffect(() => { api.get(`/api/awg/tunnels/${name}`).then(d => setContent(d.content)) .catch(e => showToast(e.message, 'error')); }, [name]); const save = async () => { if (!confirm(`Сохранить и перезапустить ${name}?`)) return; setSaving(true); try { await api.put(`/api/awg/tunnels/${name}`, { content }); showToast('✓ Сохранено', 'success'); onBack(); } catch (e) { showToast(e.message, 'error'); } finally { setSaving(false); } }; return (
✏️ {name}.conf