// =========================================================
// 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 (
{path}
);
}
// =========================================================
// 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 && (
{label}
{required && (
*
)}
)}
{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 (
setOpen(o => !o)} style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 14px', background: bg, border: 'none', cursor: 'pointer',
textAlign: 'left', color: 'var(--text)', fontSize: 12.5, fontWeight: 600,
}}>
▶
{important && (
⚠ ВАЖНОЕ
)}
{title}
{open ? 'свернуть' : 'подробнее'}
{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 => (
onChange(t.id)}>
{icons[t.id]}
{t.label}
))}
);
}
// =========================================================
// 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 (
);
}
// =========================================================
// 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 (
{/* Шапка дашборда с селектором частоты обновления */}
settingsStore.refreshInterval = parseInt(e.target.value)}
className="dashboard-refresh-select">
{REFRESH_INTERVALS.map(i => (
{i.label}
))}
{/* Алерт-карточки (показываются только если есть проблемы) */}
{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}
))}
);
})()}
RAM
{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}
)}
{/* Активные прокси-группы и статистика использования каналов */}
{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 доменов
{
await api.del('/api/stats/top-domains'); reload();
showToast('Очищено', 'success');
}}>Очистить
{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 ? (
Нет активных каналов
) : (
Канал
Тип
Endpoint / Порт
Handshake
RX
TX
{channels.channels.map(c => (
{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="Новая вкладка">
➕
setEditMode(!editMode)} title="Режим редактирования">
{editMode ? <> Готово> : }
{/* Toolbar */}
{testingProgress
? `🧪 Тестируется ${testingProgress.current}/${testingProgress.total}...`
: `Видимо групп: ${visibleGroupNames.length} · Всего: ${allGroupNames.length}`}
setSortByPing(!sortByPing)}
title="Сортировать прокси в группе по пингу">
{sortByPing ? <> По пингу> : <> Порядок>}
↻
{/* Прогресс-бар */}
{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)}
/>
)}
testGroup(name)}
title="Протестировать пинг всех серверов в этой группе"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
Тест
toggleSmartGroup(name, g)}
title={smartGroupsList.includes(name)
? 'Умный автовыбор включён — нажмите чтобы выключить'
: 'Включить умный автовыбор быстрейшего сервера'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{smartGroupsList.includes(name) ? 'Умный вкл' : 'Умный'}
{smartGroupsList.includes(name) && (
setSmartModal({ name, g })}
title="Настройки умного автовыбора для этой группы">
⚙
)}
{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 (
{ e.stopPropagation(); setOpen(!open); }}
title="Переместить на вкладку">
📂 {current ? current.name : 'Без вкладки'} ▾
{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 && (
Группы на этой вкладке ({initial.groups.length})
{allGroups.length === 0 && (
Нет групп в конфиге
)}
{allGroups.map(g => (
onToggleGroup(initial.id, g)} />
{g}
))}
)}
{isCreate ? 'Отмена' : 'Закрыть'}
{(isCreate || name !== initial.name) && (
{isCreate ? 'Создать' : 'Переименовать'}
)}
);
}
// =========================================================
// 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
setModal('create')}>+ Новая подписка
{list.length === 0 ? (
В конфиге нет секции proxy-providers
) : list.map(p => (
{p.name}
{p.type}
Interval: {p.interval}s
{p.health_check?.enable && health-check }
refresh(p.name)} title="Обновить подписку (из кеша/по URL)"> Обновить
refresh(p.name, true)}
title="Удалить кеш и скачать заново">🧹 Hard refresh
setModal(p)}>✏️
del(p.name)}>🗑
{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}` : '+ Новая подписка'}
Имя (уникальное)
Тип
http (URL подписки)
file (локальный файл)
inline
{form.type !== 'file' && (
URL подписки
{testing ? '⏳ Проверка...' : '🔍 Проверить'}
{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})>}
>
)}
)}
)}
Interval (секунды)
Path (путь кеша)
setForm(p => ({
...p,
health_check: { ...(p.health_check || {}),
enable: e.target.checked,
url: (p.health_check?.url || 'http://www.gstatic.com/generate_204'),
interval: +(p.health_check?.interval || 600) }
}))} />
Health-check
{form.health_check?.enable && (
<>
Health-check URL
Health-check Interval (сек)
>
)}
Отмена
{saving ? <> Сохранение...> : (isEdit ? 'Сохранить' : 'Создать')}
);
}
// =========================================================
// 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, type: e.target.value }))}>
{RULE_TYPES.map(t => {t} )}
Payload {newRule.type === 'MATCH' && '(не требуется)'}
setNewRule(p => ({ ...p, payload: e.target.value }))} />
Target
setNewRule(p => ({ ...p, target: e.target.value }))}>
{targets.map(t => {t} )}
+ Добавить
Правил: {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'}
Имя группы
Тип
select — ручной выбор
url-test — автоматически самый быстрый
fallback — переключение при недоступности
load-balance — распределение нагрузки
{form.type !== 'select' && (
)}
Прокси в группе ({form.proxies?.length || 0} выбрано)
{allForGroupMembers.length === 0
?
Нет доступных прокси
: allForGroupMembers.map(p => (
toggleItem('proxies', p)} />
{p}
{available.specials.includes(p) && special }
{available.groups.includes(p) && group }
))
}
{available.providers.length > 0 && (
)}
{available.provider_proxies && Object.keys(available.provider_proxies).length > 0 && (
Отдельные серверы из подписок ({(form.proxies || []).filter(p =>
Object.values(available.provider_proxies).some(arr => arr.includes(p))).length} выбрано)
Выберите конкретный сервер (Vless/Hy2/...) из подписки — он добавится в канал
как отдельный прокси, без всей подписки.
{Object.entries(available.provider_proxies).map(([prov, names]) => (
{prov}
{names.map(pn => (
toggleItem('proxies', pn)} />
{pn}
))}
))}
)}
Отмена
{isEdit ? 'Сохранить' : 'Создать'}
);
}
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, type: e.target.value }))}>
{RULE_TYPES.map(t => {t} )}
setForm(p => ({ ...p, payload: e.target.value }))} />
setForm(p => ({ ...p, target: e.target.value }))}>
{targets.map(t => {t} )}
{
if (!form.target || !form.target.trim()) {
showToast('Выберите target', 'warning');
return;
}
const s = form.type === 'MATCH' ? `MATCH,${form.target}` : `${form.type},${form.payload},${form.target}`;
onSave(s);
}}>✓
✕
);
}
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' && (
start(false)}>
Установить
Если установка не удастся — будет предложено повторить через системный прокси 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),
и этот канал реально работает.
start(true)}>
Попробовать через Mihomo proxy
start(false)}>
Повторить без прокси
)}
{phase === 'failed' && !proxyOffered && (
start(false)}>
Попробовать снова
)}
{phase === 'done' && (
onInstalled && onInstalled()}>
Продолжить
)}
)}
);
}
// Хук для проверки установлен ли сервис (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(', ')}.
Они не работают и засоряют конфиг.
Очистить ({orphans.length})
)}
AWG туннель работает на уровне ОС. Чтобы Mihomo направлял
трафик через него , добавьте прокси в proxies: с type: direct и
interface: имя — это и сделает кнопка "+ В Mihomo" на карточке.
AWG туннелей: {list.length}
{list.some(t => t.active) && (
Аварийная остановка
)}
setCreating(true)}>+ Новый туннель
{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 && (
setRegistering({ name: t.name })}>
➕ В Mihomo
)}
action(t.name, 'start')}>
action(t.name, 'restart')}>↻
action(t.name, 'stop')}>
{!t.orphan && setEditing(t.name)}>✏️ }
{!t.orphan && del(t.name)}>🗑 }
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
Имя прокси в Mihomo
setProxyName(e.target.value)} />
Будет добавлено в config.yaml:
- name: {proxyName}
type: direct
interface: {name}
routing-mark: {routingMark || 51820}
routing-mark (обычно 51820 — порт WG)
setRoutingMark(e.target.value)} />
Добавить в группы ({selected.length} выбрано)
{groups.length === 0
?
Нет групп в конфиге
:
{groups.map(g => (
toggle(g.name)} />
{g.name}
{g.type}
))}
}
Отмена
Добавить
);
}
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
{saving ? '...' : <> Сохранить>}
);
}
// блок с готовым snippet для вставки в [Interface] секцию.
// Подменяет awg0 на имя текущего интерфейса. Кнопка копирования.
function AWGRequiredSnippet({ ifaceName }) {
const [copied, setCopied] = useState(false);
const name = ifaceName || 'awg0';
const snippet = `Table = off
MTU = 1100
PostUp = ip route add default dev ${name} table 51820 || true
PostUp = ip rule add fwmark 0xca6c lookup 51820 || true
PostDown = ip rule del fwmark 0xca6c lookup 51820 2>/dev/null || true
PostDown = ip route del default dev ${name} table 51820 2>/dev/null || true`;
const copy = async () => {
try {
await navigator.clipboard.writeText(snippet);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
// fallback для не-HTTPS
const ta = document.createElement('textarea');
ta.value = snippet;
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); setCopied(true); setTimeout(() => setCopied(false), 2000); }
catch {}
document.body.removeChild(ta);
}
};
return (
⚠ ОБЯЗАТЕЛЬНО
Строки для секции [Interface]
Эти строки нужно добавить в
[Interface] туннеля (после
Address,
DNS, обфускации Jc/S/H).
Они обеспечивают что трафик через AWG идёт
только когда Mihomo его туда направил (по fwmark),
а SSH/системный трафик не пропадёт.
{!ifaceName && (
⚠ Сначала укажи «Имя» туннеля выше — тогда подстановка обновится автоматически.
)}
{copied ? '✓ Скопировано' : '📋 Копировать'}
{snippet.split('\n').map((ln, i, arr) => (
{i + 1}
{ln || ' '}
))}
💡 {name} подставляется автоматически из поля «Имя» выше. Готовый шаблон в окне ниже
уже содержит Table = off — добавь только PostUp/PostDown и
измени MTU на 1100 если у тебя не указан.
);
}
function AWGCreate({ onBack }) {
const [name, setName] = useState('');
const [content, setContent] = useState(`[Interface]
PrivateKey =
Address = 10.0.0.2/32
DNS = 1.1.1.1
MTU = 1280
# БЕЗОПАСНО: Table = off — не трогаем системный routing.
# Mihomo сам направит трафик через routing-mark.
# Если убрать эту строку, AWG поставит default-route — VM станет недоступна.
Table = off
# Обфускация AmneziaWG
Jc = 4
Jmin = 40
Jmax = 70
S1 = 50
S2 = 100
H1 = 1234567890
H2 = 2345678901
H3 = 3456789012
H4 = 4567890123
[Peer]
PublicKey =
PresharedKey =
AllowedIPs = 0.0.0.0/0
Endpoint =
PersistentKeepalive = 25
`);
// Добавление в Mihomo
const [addToMihomo, setAddToMihomo] = useState(true);
const [mihomoName, setMihomoName] = useState('');
const [routingMark, setRoutingMark] = useState(51820);
const [groups, setGroups] = useState([]);
const [selectedGroups, setSelectedGroups] = useState([]);
useEffect(() => {
api.get('/api/mihomo/proxy-groups').then(setGroups).catch(() => {});
}, []);
useEffect(() => {
// Автоматически подставить имя прокси в Mihomo
if (name && !mihomoName) setMihomoName(`${name.toUpperCase()} (AWG)`);
}, [name]);
const toggleGroup = (g) =>
setSelectedGroups(s => s.includes(g) ? s.filter(x => x !== g) : [...s, g]);
const create = async () => {
if (!name.match(/^[a-z0-9]+$/)) {
showToast('Имя: только латиница и цифры, lowercase', 'warning');
return;
}
try {
const body = {
name, content,
add_to_mihomo: addToMihomo,
mihomo_proxy_name: mihomoName || undefined,
mihomo_groups: selectedGroups,
routing_mark: parseInt(routingMark) || 51820
};
const r = await api.post('/api/awg/tunnels', body);
if (r.mihomo?.added) {
showToast(`✓ Создан и добавлен в Mihomo${selectedGroups.length ? ` (в ${selectedGroups.length} групп)` : ''}`, 'success');
} else if (r.mihomo && !r.mihomo.added) {
showToast(`Туннель создан, но Mihomo: ${r.mihomo.error}`, 'warning');
} else {
showToast('✓ Туннель создан и запущен', 'success');
}
onBack();
} catch (e) { showToast(e.message, 'error'); }
};
return (
← Назад
+ Новый AWG туннель
Создать
Если в конфиге AllowedIPs = 0.0.0.0/0 и Table не указан как off —
AWG установит default-route, и весь исходящий трафик (включая ваш SSH) уйдёт через туннель.
Если туннель недоступен — VM станет недоступной.
Безопасные варианты:
Добавьте в [Interface]: Table = off — Mihomo сам направит трафик через
routing-mark, маршруты ОС не трогаются.
Или сузьте AllowedIPs до конкретных подсетей VPN-сервиса (не 0.0.0.0/0).
setName(e.target.value.toLowerCase())}
placeholder="awg1" />
{/* готовый snippet который нужно вставить в [Interface] */}
{/* Mihomo интеграция */}
);
}
// =========================================================
// TRUSTTUNNEL
// =========================================================
function TrustTunnels() {
const [installStatus, recheckInstall] = useInstallStatus('trusttunnel');
const [list, setList] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null);
const [creating, setCreating] = useState(false);
const [registering, setRegistering] = useState(null); // { name, suggestedPort }
const load = async () => {
if (!installStatus.installed) { setLoading(false); return; }
try { setList(await api.get('/api/trusttunnel/list')); }
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 del = async (name) => {
if (!confirm(`Удалить ${name}? Также удалится соответствующий прокси из Mihomo.`)) return;
await api.del(`/api/trusttunnel/${name}`);
showToast('Удалён', 'success');
load();
};
const action = async (name, a) => {
try {
await api.post(`/api/trusttunnel/${name}/action`, { action: a });
showToast(`${name}: ${a}`, 'success');
setTimeout(load, 500);
} catch (e) { showToast(e.message, 'error'); }
};
if (loading) return ;
if (editing) return { setEditing(null); load(); }} />;
if (creating) return { setCreating(false); load(); }} />;
return (
TrustTunnel серверов: {list.length}
setCreating(true)}>+ Новый
{list.length === 0 &&
Нет серверов
}
{list.map(c => (
{c.name}
{c.local_port && :{c.local_port} }
{c.in_mihomo
?
✓ в Mihomo
:
⚠ нет в Mihomo
}
{c.file}
action(c.name, 'start')}
title="Запустить">
action(c.name, 'restart')}
title="Перезапустить">↻
action(c.name, 'stop')}
title="Остановить">
{!c.in_mihomo && c.local_port && (
setRegistering({ name: c.name, port: c.local_port })}>
➕ В Mihomo
)}
setEditing(c.name)} title="Редактировать">✏️
del(c.name)} title="Удалить">🗑
))}
{registering &&
setRegistering(null)}
onSaved={() => { setRegistering(null); load(); }} />}
);
}
function TTRegisterModal({ name, port, onClose, onSaved }) {
const defaultName = `🔐 ${name[0].toUpperCase() + name.slice(1)} (SOCKS5)`;
const [proxyName, setProxyName] = useState(defaultName);
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/trusttunnel/${name}/register-in-mihomo`, {
proxy_name: proxyName,
add_to_groups: selected
});
showToast(`✓ ${proxyName} добавлен в Mihomo`, 'success');
onSaved();
} catch (e) { showToast(e.message, 'error'); }
};
return (
e.stopPropagation()} style={{ minWidth: 520 }}>
➕ Добавить {name} в Mihomo
Включить в группы ({selected.length} выбрано)
{groups.length === 0
?
Нет групп в конфиге
:
{groups.map(g => (
toggle(g.name)} />
{g.name}
{g.type}
))}
}
Отмена
Добавить
);
}
function TTEdit({ name, onBack }) {
const [content, setContent] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
api.get(`/api/trusttunnel/${name}`).then(d => setContent(d.content))
.catch(e => showToast(e.message, 'error'));
}, [name]);
const save = async () => {
setSaving(true);
try {
await api.put(`/api/trusttunnel/${name}`, { content });
showToast('✓ Сохранено', 'success');
onBack();
} catch (e) { showToast(e.message, 'error'); }
finally { setSaving(false); }
};
return (
← Назад
✏️ {name}
Сохранить
);
}
function TTCreate({ onBack }) {
const [mode, setMode] = useState('form'); // 'form' | 'toml'
const [form, setForm] = useState({
name: '', hostname: 'xx.eof.observer', address: '',
username: '', password: '', local_port: 10007,
toml_content: '',
add_to_mihomo: true,
mihomo_proxy_name: '',
add_to_groups: []
});
const [groups, setGroups] = useState([]);
const [existingNames, setExistingNames] = useState([]);
const [saving, setSaving] = useState(false);
const f = (k) => ({ value: form[k], onChange: e => setForm(p => ({ ...p, [k]: e.target.value })), disabled: saving });
useEffect(() => {
api.get('/api/mihomo/proxy-groups').then(setGroups).catch(() => {});
api.get('/api/trusttunnel/list').then(list => setExistingNames(list.map(t => t.name))).catch(() => {});
}, []);
// Валидация имени
const nameValid = /^[A-Za-z0-9_-]+$/.test(form.name);
const nameTaken = form.name && existingNames.includes(form.name);
const nameError = !form.name ? null
: !nameValid ? 'Только латинские буквы, цифры, _ и -'
: nameTaken ? `Имя занято — уже есть TrustTunnel "${form.name}"`
: null;
// Авто-извлечение порта из TOML для preview
const tomlPort = (() => {
if (mode !== 'toml' || !form.toml_content) return null;
const m = form.toml_content.match(/address\s*=\s*"127\.0\.0\.1:(\d+)"/);
return m ? parseInt(m[1]) : null;
})();
// Автогенерация имени прокси на основе name
const suggestedProxyName = form.name
? `🔐 ${form.name[0].toUpperCase() + form.name.slice(1)} (SOCKS5)`
: '';
const toggleGroup = (g) => setForm(p => ({
...p,
add_to_groups: p.add_to_groups.includes(g)
? p.add_to_groups.filter(x => x !== g)
: [...p.add_to_groups, g]
}));
const create = async () => {
try {
setSaving(true);
const payload = {
name: form.name,
mihomo_proxy_name: form.mihomo_proxy_name || suggestedProxyName,
add_to_mihomo: form.add_to_mihomo,
add_to_groups: form.add_to_mihomo ? form.add_to_groups : null,
};
if (mode === 'toml') {
payload.toml_content = form.toml_content;
} else {
payload.hostname = form.hostname;
payload.address = form.address;
payload.username = form.username;
payload.password = form.password;
payload.local_port = parseInt(form.local_port);
}
const r = await api.post('/api/trusttunnel', payload);
if (r.mihomo?.added) {
showToast(`✓ Создан и добавлен в Mihomo${r.mihomo.added_to_groups?.length ? ` (${r.mihomo.added_to_groups.length} групп)` : ''}`, 'success');
} else if (r.mihomo?.error) {
showToast(`✓ Создан, но не добавлен в Mihomo: ${r.mihomo.error}`, 'warning');
} else {
showToast('✓ Создан', 'success');
}
onBack();
} catch (e) {
showToast(e.message, 'error');
} finally {
setSaving(false);
}
};
// Шаблон TOML для подсказки
const tomlTemplate = `loglevel = "info"
vpn_mode = "selective"
[endpoint]
hostname = "xx.eof.observer"
addresses = ["1.2.3.4:443"]
username = "your_username"
password = "your_password"
skip_verification = true
upstream_protocol = "http2"
[listener]
[listener.socks]
address = "127.0.0.1:10007"`;
// Можно ли создавать
const canCreate = !saving && form.name && !nameError && (
mode === 'toml'
? form.toml_content.trim().length > 0
: (form.address && form.username)
);
return (
← Назад
+ Новый TrustTunnel сервер
TrustTunnel endpoint
Имя (латиница, цифры, _ и -)
{nameError ? (
⚠️ {nameError}
) : (
Файл {form.name || 'NAME'}_socks.toml · сервис trusttunnel-{form.name || 'NAME'}.service
)}
{/* Tabs: По полям / TOML конфиг */}
setMode('form')} disabled={saving}>
📋 По полям
setMode('toml')} disabled={saving}>
📄 TOML-конфиг
{mode === 'form' ? (
<>
Hostname
Address (IP:Port)
Local SOCKS5 порт
>
) : (
Содержимое TOML-конфига
{tomlPort && (
✓ Обнаружен порт: {tomlPort}
)}
)}
Интеграция с Mihomo
{form.add_to_mihomo && (
<>
Включить в группы ({form.add_to_groups.length} выбрано)
{groups.length === 0
?
Нет групп в конфиге
:
{groups.map(g => (
toggleGroup(g.name)}
disabled={saving} />
{g.name}
{g.type}
))}
}
>
)}
{saving ? <> Создание и запуск сервиса...> : 'Создать и запустить'}
);
}
// =========================================================
// MIHOMO PROXIES — прямые прокси (вкладка «Прокси» в Туннелях)
// =========================================================
// Поддерживаемые типы для формы добавления. Backend кладёт name/type/server/port/
// username/password напрямую, всё остальное — через extra (см. mihomo_proxy_add).
const PROXY_TYPES = [
{ id: 'trusttunnel', label: 'TrustTunnel (нативный)' },
];
function MihomoProxies() {
const [list, setList] = useState([]);
const [loading, setLoading] = useState(true);
const [adding, setAdding] = useState(false);
const [editing, setEditing] = useState(null);
const load = async () => {
try {
const all = await api.get('/api/mihomo/proxies/list');
// Туннельные прокси (AWG-direct, TrustTunnel-socks5) создаются автоматически
// и управляются на своих вкладках — сюда не показываем.
setList((all || []).filter(p => !p.tunnel));
} catch (e) { showToast(e.message, 'error'); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const del = async (name) => {
if (!confirm(`Удалить прокси "${name}" из Mihomo? Он также уберётся из всех групп.`)) return;
try {
await api.del(`/api/mihomo/proxies/${encodeURIComponent(name)}`);
showToast('Удалён', 'success');
load();
} catch (e) { showToast(e.message, 'error'); }
};
if (loading) return ;
return (
Прямых прокси: {list.length}
setAdding(true)}>+ Добавить прокси
Прокси, которые Mihomo держит сам, без отдельного клиента. Например нативный
TrustTunnel — Mihomo подключается к TT-серверу напрямую. Прокси с вкладок AWG и
TrustTunnel (socks5-обёртка) здесь не показываются — они управляются там.
{list.length === 0 &&
Нет прямых прокси
}
{list.map(p => (
{p.name}
{p.type}
{p.server}{p.port ? `:${p.port}` : ''}
setEditing(p.name)} title="Редактировать">✏️
del(p.name)} title="Удалить">🗑
))}
{adding &&
setAdding(false)}
onSaved={() => { setAdding(false); load(); }} />}
{editing && setEditing(null)}
onSaved={() => { setEditing(null); load(); }} />}
);
}
function ProxyAddModal({ onClose, onSaved }) {
const [type, setType] = useState('trusttunnel');
const [name, setName] = useState('');
const [server, setServer] = useState('');
const [port, setPort] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
// доп. поля (нативный TrustTunnel)
const [udp, setUdp] = useState(true);
const [sni, setSni] = useState('');
const [alpn, setAlpn] = useState('');
const [skipCert, setSkipCert] = useState(false);
const [fingerprint, setFingerprint] = useState('');
const [quic, setQuic] = useState(false);
const [cc, setCc] = useState('');
// группы
const [groups, setGroups] = useState([]);
const [selected, setSelected] = useState([]);
const [saving, setSaving] = useState(false);
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 canSave = name.trim() && server.trim() && port && username.trim() && password.trim();
const submit = async () => {
setSaving(true);
try {
const extra = { udp };
if (sni.trim()) extra.sni = sni.trim();
if (alpn.trim()) extra.alpn = alpn.split(',').map(s => s.trim()).filter(Boolean);
if (skipCert) extra['skip-cert-verify'] = true;
if (fingerprint.trim()) extra['client-fingerprint'] = fingerprint.trim();
if (quic) {
extra.quic = true;
if (cc.trim()) extra['congestion-controller'] = cc.trim();
}
await api.post('/api/mihomo/proxies', {
name: name.trim(),
type,
server: server.trim(),
port: parseInt(port, 10),
username: username.trim(),
password: password.trim(),
extra,
add_to_groups: selected,
});
showToast(`✓ Прокси "${name.trim()}" добавлен`, 'success');
onSaved();
} catch (e) { showToast(e.message, 'error'); }
finally { setSaving(false); }
};
return (
);
}
function ProxyEditModal({ name, onClose, onSaved }) {
const [yamlText, setYamlText] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
api.get(`/api/mihomo/proxies/${encodeURIComponent(name)}/raw`)
.then(d => setYamlText(d.yaml || ''))
.catch(e => showToast(e.message, 'error'))
.finally(() => setLoading(false));
}, [name]);
const save = async () => {
setSaving(true);
try {
await api.put(`/api/mihomo/proxies/${encodeURIComponent(name)}/raw`, { yaml: yamlText });
showToast('✓ Сохранено', 'success');
onSaved();
} catch (e) { showToast(e.message, 'error'); }
finally { setSaving(false); }
};
return (
e.stopPropagation()}
style={{ minWidth: 560, maxWidth: '90vw' }}>
✏️ {name}
YAML-фрагмент прокси целиком. Можно менять любые поля и даже имя — ссылки в группах
переедут сами. Конфиг проверяется через mihomo перед применением (битый YAML не сломает ядро).
{loading
?
:
);
}
// =========================================================
// RAW MIHOMO YAML EDITOR
// =========================================================
function MihomoConfigEditor() {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
api.get('/api/config/mihomo').then(d => { setContent(d.content); setLoading(false); })
.catch(e => { showToast(e.message, 'error'); setLoading(false); });
}, []);
const save = async () => {
if (!confirm('Сохранить Mihomo конфиг?')) return;
setSaving(true);
try {
await api.put('/api/config/mihomo', { content });
showToast('✓ Сохранено', 'success');
} catch (e) { showToast(e.message, 'error'); }
finally { setSaving(false); }
};
if (loading) return ;
return (
/opt/mihomo/config/config.yaml
{saving ? '...' : <> Сохранить>}
);
}
// =========================================================
// SERVICES
// =========================================================
function SystemProxyCard() {
const [status, setStatus] = useState(null);
const [loading, setLoading] = useState(true);
const [toggling, setToggling] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
const load = async () => {
try {
const s = await api.get('/api/system-proxy/status');
setStatus(s);
} catch (e) { showToast(e.message, 'error'); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const toggle = async () => {
if (!status) return;
const goingOn = !status.enabled;
if (goingOn) {
if (!confirm(
'Включить системный прокси?\n\n' +
'Системные команды (apt, curl, wget, git) будут использовать Mihomo\n' +
`как HTTP-прокси (127.0.0.1:${status.port}).\n\n` +
'Это нужно когда часть репозиториев заблокирована (например PPA Amnezia).\n\n' +
'Локальные адреса (127.0.0.1, localhost) идут напрямую.'
)) return;
}
setToggling(true);
try {
const r = await api.post('/api/system-proxy', { enable: goingOn });
showToast(r.message || (goingOn ? 'Включён' : 'Выключен'), 'success');
load();
} catch (e) {
showToast(e.message, 'error');
} finally { setToggling(false); }
};
const test = async () => {
setTesting(true);
setTestResult(null);
try {
const r = await api.post('/api/system-proxy/test', {});
setTestResult(r);
} catch (e) {
setTestResult({ ok: false, error: e.message });
} finally { setTesting(false); }
};
if (loading) return null;
if (!status) return null;
return (
Системный прокси через Mihomo
Использовать локальный Mihomo как HTTP-прокси для системных команд (apt, curl, wget, git).
Полезно когда репозитории заблокированы — например при доустановке AmneziaWG (PPA заблокирован в РФ).
Состояние
{status.enabled
? Включён
: Выключен }
Прокси-адрес
{status.proxy_url}
apt.conf.d
{status.apt_enabled
? ✓ настроен
: не настроен }
profile.d (shell env)
{status.env_enabled
? ✓ настроен
: не настроен }
{toggling
? <> Применение...>
: status.enabled
? <> Выключить>
: <> Включить>}
{status.enabled && (
{testing
? <> Тест...>
: <> Тест соединения>}
)}
{testResult && (
{testResult.ok
? <> {testResult.message}>
: <> {testResult.message || testResult.error}>}
)}
• Создаются файлы /etc/apt/apt.conf.d/99vemitreya-proxy и
/etc/profile.d/vemitreya-proxy.sh
• apt применяет настройку сразу. curl/wget/git — после re-login
SSH или source /etc/profile.d/vemitreya-proxy.sh
• Локальные адреса (127.0.0.1, localhost) идут напрямую ,
не через прокси
);
}
function MihomoPortsCard() {
const [ports, setPorts] = useState(null);
const [edited, setEdited] = useState({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const load = async () => {
try {
const p = await api.get('/api/mihomo/ports');
setPorts(p);
// edited = текущие значения для редактирования
const e = {};
Object.entries(p).forEach(([k, v]) => { e[k] = v.value ?? ''; });
setEdited(e);
} catch (err) { showToast(err.message, 'error'); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const handleChange = (field, val) => {
setEdited(prev => ({ ...prev, [field]: val }));
};
const isDirty = ports && Object.keys(ports).some(k => {
const cur = ports[k].value;
const ed = edited[k];
if ((cur ?? '') === '' && (ed === '' || ed === null)) return false;
return String(cur ?? '') !== String(ed);
});
const validate = () => {
const used = {};
for (const [field, val] of Object.entries(edited)) {
if (val === '' || val === null || val === 0) continue;
const port = parseInt(val);
if (isNaN(port)) return `${field}: «${val}» не число`;
if (port < 1024 || port > 65535) return `${field}: порт ${port} вне 1024-65535`;
if (used[port]) return `Дубликат: ${used[port]} и ${field} оба ${port}`;
used[port] = field;
}
return null;
};
const save = async () => {
const err = validate();
if (err) { showToast(err, 'error'); return; }
setSaving(true);
try {
// Готовим payload — пустые → null
const payload = {};
Object.entries(edited).forEach(([k, v]) => {
payload[k] = (v === '' || v === null) ? null : parseInt(v);
});
await api.put('/api/mihomo/ports', { ports: payload });
showToast('Порты сохранены, Mihomo перезагружен', 'success');
load();
} catch (e) {
showToast(e.message, 'error');
} finally { setSaving(false); }
};
const reset = () => {
if (!ports) return;
const e = {};
Object.entries(ports).forEach(([k, v]) => { e[k] = v.value ?? ''; });
setEdited(e);
};
if (loading || !ports) return null;
return (
Порты Mihomo
Локальные порты на которых Mihomo принимает запросы. Оставьте пустым чтобы отключить порт.
Все значения должны быть в диапазоне 1024-65535.
{Object.entries(ports).map(([field, info]) => (
))}
{isDirty && (
Отменить
)}
{saving
? <> Сохранение...>
: <> Сохранить>}
После сохранения Mihomo перезагрузится. Если изменили порт API — Vemitreya
не сможет связаться с Mihomo (сейчас API не настраивается через эту форму, только локальные порты).
);
}
function Services() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const load = async () => {
try { setItems(await api.get('/api/services')); }
catch (e) { showToast(e.message, 'error'); }
finally { setLoading(false); }
};
useEffect(() => { load(); const i = setInterval(load, 5000); return () => clearInterval(i); }, []);
const action = async (svc, act) => {
try {
await api.post('/api/services/action', { service: svc, action: act });
showToast(`${svc}: ${act}`, 'success');
setTimeout(load, 500);
} catch (e) { showToast(e.message, 'error'); }
};
const removeLegacy = async (svc) => {
if (!confirm(
`Удалить устаревший сервис «${svc}»?\n\n` +
`Это безопасная операция:\n` +
`1. Сервис будет остановлен (если запущен)\n` +
`2. Отключён из автозагрузки\n` +
`3. Файл /etc/systemd/system/${svc}.service удалится\n` +
`4. systemd будет перезагружен\n\n` +
`Продолжить?`
)) return;
try {
await api.post('/api/services/legacy-remove', { service: svc });
showToast(`${svc} удалён`, 'success');
setTimeout(load, 500);
} catch (e) { showToast(e.message, 'error'); }
};
if (loading) return ;
return (
{items.map(s => (
{s.name}
{s.enabled && auto }
{s.legacy && (
⚠ устаревший
)}
action(s.name, 'start')}>
action(s.name, 'restart')}>↻
action(s.name, 'stop')}>
{s.legacy && (
removeLegacy(s.name)}>🗑
)}
))}
);
}
// =========================================================
// CONNECTIONS
// =========================================================
function Connections() {
const [conns, setConns] = useState([]);
const [total, setTotal] = useState(0);
const [counts, setCounts] = useState({ wg: 0, tt: 0, other: 0 });
const [filter, setFilter] = useState('');
const [field, setField] = useState('all');
const [tab, setTab] = useState(() => localStorage.getItem('mp_conn_tab') || 'wg');
const setTabPersist = (t) => { setTab(t); localStorage.setItem('mp_conn_tab', t); };
useEffect(() => {
const load = async () => {
try {
const d = await api.get('/api/connections?limit=200');
setConns(d.connections); setTotal(d.total);
if (d.counts) setCounts(d.counts);
} catch {}
};
load();
const i = setInterval(load, 2000);
return () => clearInterval(i);
}, []);
// фильтр по хосту / порту / источнику / сети / цепочке / всем сразу
const matchConn = (c, q) => {
if (!q) return true;
q = q.toLowerCase();
const fields = {
host: c.host,
port: String(c.port ?? ''),
source: c.source,
client: c.client,
network: c.network,
chain: (c.chains || []).join(' → '),
};
if (field === 'all') {
return Object.values(fields).some(v => (v || '').toLowerCase().includes(q));
}
return (fields[field] || '').toLowerCase().includes(q);
};
// Сначала по активной подвкладке (источник трафика), потом по поиску
const inTab = conns.filter(c => (c.inbound || 'other') === tab);
const filtered = inTab.filter(c => matchConn(c, filter));
const fieldLabels = {
all: 'Везде', host: 'Хост', port: 'Порт',
source: 'Источник', client: 'Клиент', network: 'Сеть', chain: 'Цепочка',
};
const TABS = [
['wg', '🔒 WireGuard (wg0)', counts.wg || 0],
['tt', '🛡 TrustTunnel (вход)', counts.tt || 0],
['other', '↔ Прочее', counts.other || 0],
];
return (
{TABS.map(([id, label, n]) => (
setTabPersist(id)}>
{label} ({n})
))}
Клиент Хост Порт Сеть Источник Цепочка
↑ ↓
{filtered.map(c => (
{c.client || '—'}
{c.host}
{c.port}
{c.network}
{c.source}
{c.chains?.join(' → ')}
{fmtBytes(c.upload)}
{fmtBytes(c.download)}
))}
{filtered.length === 0 &&
Нет соединений
}
);
}
// =========================================================
// LOGS
// =========================================================
function LogsView() {
const [svcs, setSvcs] = useState([]);
const [svc, setSvc] = useState('mihomo');
const [text, setText] = useState('');
const [live, setLive] = useState(false);
const ref = useRef(null);
const wsRef = useRef(null);
useEffect(() => { api.get('/api/services').then(s => setSvcs(s.map(x => x.name))); }, []);
const load = useCallback(async () => {
try {
const d = await api.get(`/api/logs/${svc}?lines=200`);
setText(d.logs);
setTimeout(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, 50);
} catch (e) { showToast(e.message, 'error'); }
}, [svc]);
useEffect(() => { load(); }, [load]);
useEffect(() => {
if (!live) { wsRef.current?.close(); return; }
const ws = new WebSocket(api.wsUrl(`/ws/logs/${svc}`));
ws.onopen = () => ws.send(JSON.stringify({ token: api.token }));
ws.onmessage = (ev) => {
setText(t => t + ev.data);
setTimeout(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight; }, 50);
};
wsRef.current = ws;
return () => ws.close();
}, [live, svc]);
const hl = (l) => {
if (/error|failed|fatal/i.test(l)) return 'log-error';
if (/warn/i.test(l)) return 'log-warn';
if (/success|started|listening|handshake/i.test(l)) return 'log-success';
if (/info/i.test(l)) return 'log-info';
return '';
};
return (
setSvc(e.target.value)}>
{svcs.map(s => {s} )}
↻
setLive(e.target.checked)} />
Live
{text.split('\n').map((l, i) =>
{l}
)}
);
}
// =========================================================
// TELEGRAM
// =========================================================
function TelegramSettings() {
const [s, setS] = useState({
tg_bot_token: '', tg_chat_id: '', tg_enabled: 'false',
tg_alerts_enabled: '0', tg_alerts_recovery: '1',
tg_alerts_awg_handshake_max_minutes: '10'
});
const [saving, setSaving] = useState(false);
useEffect(() => { api.get('/api/telegram/settings').then(d => setS(p => ({ ...p, ...d }))); }, []);
const save = async () => {
setSaving(true);
try { await api.put('/api/telegram/settings', s); showToast('Сохранено', 'success'); }
finally { setSaving(false); }
};
const test = async () => {
try {
const r = await api.post('/api/telegram/test');
showToast(r.ok ? 'Отправлено!' : 'Ошибка', r.ok ? 'success' : 'error');
} catch (e) { showToast(e.message, 'error'); }
};
const f = (k) => ({ value: s[k] || '', onChange: e => setS(p => ({ ...p, [k]: e.target.value })) });
const cb = (k, on, off) => ({
checked: s[k] === on,
onChange: e => setS(p => ({ ...p, [k]: e.target.checked ? on : off }))
});
return (
);
}
// =========================================================
// SPEEDTEST — пинг популярных ресурсов через прокси
// =========================================================
function Speedtest() {
const [groups, setGroups] = useState({});
const [targets, setTargets] = useState([]);
const [selectedProxies, setSelectedProxies] = useState([]);
const [selectedTargets, setSelectedTargets] = useState([]);
const [matrix, setMatrix] = useState(null);
const [running, setRunning] = useState(false);
const [progress, setProgress] = useState(null);
// Развёрнуты ли секции групп
const [expandedGroups, setExpandedGroups] = useState({});
// Развёрнут ли блок прокси-серверов целиком (по умолчанию свёрнут)
const [proxiesExpanded, setProxiesExpanded] = useState(false);
useEffect(() => {
Promise.all([
api.get('/api/proxies/groups').catch(() => ({})),
api.get('/api/speedtest/targets').catch(() => [])
]).then(([g, t]) => {
setGroups(g);
setTargets(t);
setSelectedTargets(t.slice(0, 5).map(x => x.id));
// По умолчанию все группы развёрнуты
const expanded = {};
Object.keys(g).forEach(name => { expanded[name] = true; });
setExpandedGroups(expanded);
});
}, []);
// Классификация групп: routing vs channel
const classification = useMemo(() => buildGroupClassification(groups), [groups]);
// Группировка прокси: { groupName -> [proxy_names] }
// Только CHANNEL-группы (т.е. пулы серверов: AWG, TrustTunnel, VLESS, HY2 и т.п.)
// Не показываем routing-группы (Основной трафик, Серверный, Telegram и т.п.)
// Не показываем системные (GLOBAL)
const proxiesByGroup = useMemo(() => {
const result = {};
Object.entries(groups).forEach(([groupName, g]) => {
// Скипаем системные и routing
if (SYSTEM_GROUPS.has(groupName)) return;
if (classification[groupName] !== 'channel') return;
const proxies = (g.all || []).filter(p =>
p !== 'DIRECT' && p !== 'REJECT' && !groups[p]
);
if (proxies.length > 0) {
result[groupName] = proxies;
}
});
return result;
}, [groups, classification]);
// Все уникальные прокси (для allProxies счётчика)
const allProxies = useMemo(() => {
const set = new Set();
Object.values(proxiesByGroup).forEach(list => list.forEach(p => set.add(p)));
return Array.from(set);
}, [proxiesByGroup]);
const toggleProxy = (p) => setSelectedProxies(s =>
s.includes(p) ? s.filter(x => x !== p) : [...s, p]);
const toggleTarget = (id) => setSelectedTargets(s =>
s.includes(id) ? s.filter(x => x !== id) : [...s, id]);
// Toggle всю группу
const toggleGroup = (groupName) => {
const list = proxiesByGroup[groupName] || [];
const allSelected = list.every(p => selectedProxies.includes(p));
if (allSelected) {
setSelectedProxies(s => s.filter(p => !list.includes(p)));
} else {
setSelectedProxies(s => Array.from(new Set([...s, ...list])));
}
};
const toggleGroupExpanded = (groupName) => {
setExpandedGroups(prev => ({ ...prev, [groupName]: !prev[groupName] }));
};
const selectAllProxies = () => setSelectedProxies(allProxies);
const clearProxies = () => setSelectedProxies([]);
const selectAllTargets = () => setSelectedTargets(targets.map(t => t.id));
const clearTargets = () => setSelectedTargets([]);
const runTest = async () => {
if (!selectedProxies.length || !selectedTargets.length) {
showToast('Выберите прокси и цели', 'warning');
return;
}
setRunning(true);
setMatrix(null);
setProgress({ done: 0, total: selectedProxies.length * selectedTargets.length });
try {
const r = await api.post('/api/speedtest/matrix', {
proxies: selectedProxies,
targets: selectedTargets,
timeout_ms: 5000
});
setMatrix(r);
showToast('✓ Тест завершён', 'success');
} catch (e) {
showToast(`Ошибка: ${e.message}`, 'error');
} finally {
setRunning(false);
setProgress(null);
}
};
const runSingleProxy = async (proxy) => {
try {
setRunning(true);
const r = await api.post('/api/speedtest/run', {
proxy, targets: selectedTargets, timeout_ms: 5000
});
const m = { [proxy]: {} };
r.results.forEach(res => { m[proxy][res.id] = res.ok ? res.delay : -1; });
setMatrix({
proxies: [proxy],
targets: r.results.map(t => ({ id: t.id, name: t.name, icon: t.icon, favicon: t.favicon })),
matrix: m
});
showToast(`✓ ${proxy}: ${r.summary.ok}/${r.summary.total} OK`, 'success');
} catch (e) {
showToast(e.message, 'error');
} finally { setRunning(false); }
};
const delayClass = (d) => !d || d < 0 ? 'bad' : d < 200 ? 'good' : d < 500 ? 'medium' : 'bad';
// Render favicon с fallback на эмодзи
const TargetFavicon = ({ target, size = 16 }) => {
const [failed, setFailed] = useState(false);
if (!target.favicon || failed) {
return {target.icon} ;
}
return (
setFailed(true)} />
);
};
return (
Цели ({selectedTargets.length}/{targets.length})
Все
Снять
{targets.map(t => (
toggleTarget(t.id)}
className={`speedtest-target-card ${selectedTargets.includes(t.id) ? 'active' : ''}`}>
{t.name}
{selectedTargets.includes(t.id) && (
)}
))}
setProxiesExpanded(v => !v)}>
Прокси серверы ({selectedProxies.length}/{allProxies.length})
e.stopPropagation()}>
{proxiesExpanded && (
<>
Все
Снять
>
)}
setProxiesExpanded(v => !v)}
title={proxiesExpanded ? 'Свернуть' : 'Развернуть'}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
{proxiesExpanded && (
{Object.keys(proxiesByGroup).length === 0 ? (
Нет proxy-groups в конфиге
) : Object.entries(proxiesByGroup).map(([groupName, proxies]) => {
const selectedInGroup = proxies.filter(p => selectedProxies.includes(p)).length;
const allInGroupSelected = selectedInGroup === proxies.length;
const someSelected = selectedInGroup > 0;
const isExpanded = expandedGroups[groupName] !== false;
return (
toggleGroupExpanded(groupName)}>
▶
toggleGroupExpanded(groupName)}>
{groupName}
{selectedInGroup}/{proxies.length}
toggleGroup(groupName)}>
{allInGroupSelected ? 'Снять все' : someSelected ? 'Выбрать все' : 'Выбрать все'}
{isExpanded && (
{proxies.map(p => (
toggleProxy(p)}
className={`chip ${selectedProxies.includes(p) ? 'chip-active' : ''}`}>
{p}
{ e.stopPropagation(); runSingleProxy(p); }}
title="Тест только этого">
))}
)}
);
})}
)}
{running ? <> Тестируется...>
: <> Запустить ({selectedProxies.length}×{selectedTargets.length})>}
{progress && (
)}
{/* Матрица результатов */}
{matrix && (
Прокси \ Цель
{matrix.targets.map(t => (
{t.name}
))}
📊 Среднее
{matrix.proxies.map(proxy => {
const row = matrix.matrix[proxy] || {};
const okValues = matrix.targets.map(t => row[t.id]).filter(d => d > 0);
const avg = okValues.length ? Math.round(okValues.reduce((a, b) => a + b, 0) / okValues.length) : 0;
return (
{proxy}
{matrix.targets.map(t => {
const d = row[t.id];
return (
{d > 0
? {d}ms
: ❌ }
);
})}
{avg ? {avg}ms : '—'}
);
})}
)}
);
}
// =========================================================
// QUICK RULES (v2.206) — per-domain channel selector
// =========================================================
// Простой UI: введи домен → выбери канал → правило создано перед MATCH
function QuickRules() {
const [target, setTarget] = useState('');
const [targets, setTargets] = useState([]);
const [input, setInput] = useState('');
const [history, setHistory] = useState(() => {
try { return JSON.parse(localStorage.getItem('quickrules_history') || '[]'); }
catch { return []; }
});
const [submitting, setSubmitting] = useState(false);
const [matchHint, setMatchHint] = useState(null);
useEffect(() => {
api.get('/api/mihomo/rules/targets').then(r => {
setTargets(r.targets || []);
if (!target && r.targets?.length) {
// Дефолт — первая группа кроме DIRECT/REJECT
const def = r.targets.find(t => !['DIRECT', 'REJECT', 'PASS'].includes(t)) || r.targets[0];
setTarget(def);
}
});
}, []);
// Авто-определение типа правила по введённому
const detectType = (str) => {
const s = str.trim();
if (!s) return null;
// IP-CIDR (IPv4)
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(\/\d{1,2})?$/.test(s)) {
return { type: 'IP-CIDR', payload: s.includes('/') ? s : s + '/32', label: 'IPv4-адрес/подсеть' };
}
// IP-CIDR6
if (s.includes(':') && /^[0-9a-fA-F:]+(\/\d{1,3})?$/.test(s)) {
return { type: 'IP-CIDR6', payload: s.includes('/') ? s : s + '/128', label: 'IPv6-адрес/подсеть' };
}
// GEOIP — 2 буквы заглавные
if (/^[A-Z]{2}$/.test(s)) {
return { type: 'GEOIP', payload: s, label: 'GeoIP страна' };
}
// Domain — содержит точку, без слешей и пробелов
if (s.includes('.') && !/[\s\/\\]/.test(s)) {
return { type: 'DOMAIN-SUFFIX', payload: s, label: 'Домен (suffix-match)' };
}
// Keyword (короткое слово без точки)
if (s.length > 1 && /^[\w-]+$/.test(s)) {
return { type: 'DOMAIN-KEYWORD', payload: s, label: 'Ключевое слово в домене' };
}
return null;
};
useEffect(() => {
setMatchHint(detectType(input));
}, [input]);
const submit = async () => {
const m = matchHint;
if (!m || !target) {
showToast('Заполни поле и выбери канал', 'warning');
return;
}
setSubmitting(true);
try {
// Формируем правило с no-resolve если IP
const needsNoResolve = ['IP-CIDR', 'IP-CIDR6', 'GEOIP'].includes(m.type);
const rule = needsNoResolve
? `${m.type},${m.payload},${target},no-resolve`
: `${m.type},${m.payload},${target}`;
// Загружаем текущие правила, чтобы вставить перед MATCH
const cur = await api.get('/api/mihomo/rules');
const rules = cur.map(r => r.raw);
// Найти MATCH
const matchIdx = rules.findIndex(r => /^MATCH\b/i.test(r));
const insertIdx = matchIdx >= 0 ? matchIdx : rules.length;
rules.splice(insertIdx, 0, rule);
await api.put('/api/mihomo/rules', { rules });
showToast(`✓ ${m.payload} → ${target}`, 'success');
// Сохраняем в history (последние 20)
const newHistory = [
{ input: input.trim(), target, rule, ts: Date.now() },
...history.filter(h => h.input !== input.trim() || h.target !== target)
].slice(0, 20);
setHistory(newHistory);
localStorage.setItem('quickrules_history', JSON.stringify(newHistory));
setInput('');
} catch (e) {
showToast(e.message, 'error');
} finally {
setSubmitting(false);
}
};
const removeFromHistory = (idx) => {
const newHistory = history.filter((_, i) => i !== idx);
setHistory(newHistory);
localStorage.setItem('quickrules_history', JSON.stringify(newHistory));
};
return (
⚡ Быстрое правило
Введи домен (или IP) и выбери канал. Правило добавится перед MATCH с авто-определением типа.
Домен или IP
setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submit()} />
{matchHint && (
Будет создано: {matchHint.type},{matchHint.payload},{target}
({matchHint.label})
)}
{input.trim() && !matchHint && (
Не удалось определить тип. Используй точное значение: домен (`google.com`),
IP (`8.8.8.8`), подсеть (`8.8.0.0/16`), или код страны (`US`).
)}
Канал / группа
setTarget(e.target.value)}>
{targets.map(t => {t} )}
{submitting ? 'Сохранение...' : '+ Добавить'}
{(() => {
// пример из реальных групп, а не захардкоженный EOF_HY2.
const real = targets.filter(t => !['DIRECT', 'REJECT', 'PASS', 'GLOBAL'].includes(t));
if (real.length === 0) {
return <>💡 Введи домен (например youtube.com), выбери канал и нажми «Добавить». Правило сразу подействует.>;
}
// Предпочитаем Hysteria2/EOF-подобную группу для примера, иначе первую
const example = real.find(t => /hy|hysteria|eof/i.test(t)) || real[0];
return <>💡 Хочешь чтобы YouTube открывался через {example}? Введи youtube.com,
выбери {example} и нажми «Добавить». Правило сразу подействует.>;
})()}
{history.length > 0 && (
Недавно добавленные через быстрое правило
История из браузера. Сами правила хранятся в Mihomo и не пропадают.
Когда
Введено
Канал
Правило
{history.map((h, i) => (
{new Date(h.ts).toLocaleString()}
{h.input}
{h.target}
{h.rule}
removeFromHistory(i)}
title="Убрать из истории">×
))}
)}
);
}
// =========================================================
// HEALTH MATRIX (v2.206) — матрица proxy × target
// =========================================================
// =========================================================
// КОМБИНИРОВАННЫЕ СТРАНИЦЫ (без табов внутри)
// =========================================================
// Правила = быстрое правило (вверху) + редактор (снизу), друг под другом
// настройки умного автовыбора (вынесены в Систему отдельной вкладкой)
// модал настроек умного автовыбора для одной группы.
// Разный набор полей для каналов (с исключениями) и маршрутизации (без).
function SmartGroupSettingsModal({ groupName, isChannel, initialConfig, onClose, onSaved }) {
const [interval, setIntervalV] = useState(initialConfig?.interval ?? (isChannel ? 30 : 60));
const [tolerance, setTolerance] = useState(initialConfig?.tolerance ?? (isChannel ? 0 : 50));
const [exclude, setExclude] = useState(initialConfig?.exclude ?? '');
const [saving, setSaving] = useState(false);
const save = async () => {
setSaving(true);
try {
await api.post('/api/mihomo/smart-config', {
group: groupName, enabled: true,
interval, tolerance,
exclude: isChannel ? exclude : '',
});
showToast('Настройки сохранены', 'success');
onSaved && onSaved();
onClose();
} catch (e) { showToast(e.message, 'error'); }
finally { setSaving(false); }
};
return (
e.stopPropagation()} style={{ maxWidth: 520 }}>
⚡ Настройки умного автовыбора
×
Группа: {groupName}
{' · '}
{isChannel
? 'Канал — выбор лучшего сервера внутри'
: 'Маршрутизация — выбор лучшего канала'}
setIntervalV(+e.target.value)}>
10 секунд
30 секунд
1 минуту
2 минуты
5 минут
setTolerance(+e.target.value)}>
Всегда выбирать быстрейший
Только если быстрее на 30+ мс
Только если быстрее на 50+ мс
Только если быстрее на 100+ мс
Только если быстрее на 200+ мс
{isChannel && (
setExclude(e.target.value)}
placeholder="Mobile, Russia, Belarus" />
)}
Отмена
{saving ? 'Сохранение...' : 'Сохранить'}
);
}
function RulesPage() {
return (
);
}
// =========================================================
// GeoSitePanel — подвкладка GEO в Правилах: правила по категориям и странам
// =========================================================
function GeoSitePanel() {
const [info, setInfo] = useState(null);
const [targets, setTargets] = useState([]);
const [target, setTarget] = useState('');
const [picked, setPicked] = useState({}); // {category: bool, '_ip_RU': bool, ...}
const [submitting, setSubmitting] = useState(false);
const [siteFilter, setSiteFilter] = useState('');
const [ipFilter, setIpFilter] = useState('');
useEffect(() => {
api.get('/api/mihomo/rules/geo-info').then(r => setInfo(r)).catch(() => {});
api.get('/api/mihomo/rules/targets').then(r => {
setTargets(r.targets || []);
const def = (r.targets || []).find(t => !['DIRECT', 'REJECT', 'PASS'].includes(t));
if (def) setTarget(def);
});
}, []);
if (!info) return Загрузка...
;
const ready = info.ready;
const geoDir = info.dir || '/opt/mihomo/geo';
const togglePick = (name) => setPicked(p => ({ ...p, [name]: !p[name] }));
const pickedGeoSite = Object.keys(picked).filter(k => !k.startsWith('_ip_') && picked[k]);
const pickedGeoIP = Object.keys(picked).filter(k => k.startsWith('_ip_') && picked[k]).map(k => k.slice(4));
const addGeoSiteRules = async () => {
if (!target) { showToast('Выберите канал/группу куда направить', 'error'); return; }
if (pickedGeoSite.length === 0) { showToast('Выберите хотя бы одну категорию', 'error'); return; }
const rules = pickedGeoSite.map(cat => `GEOSITE,${cat},${target}`);
setSubmitting(true);
try {
const r = await api.post('/api/mihomo/rules/bulk-add', { rules, position: 0 });
showToast(`Добавлено правил: ${r.added}`, 'success');
setPicked(p => { const n = {...p}; pickedGeoSite.forEach(c => delete n[c]); return n; });
} catch (e) { showToast(e.message, 'error'); }
finally { setSubmitting(false); }
};
const addGeoIPRules = async () => {
if (!target) { showToast('Выберите канал/группу куда направить', 'error'); return; }
if (pickedGeoIP.length === 0) { showToast('Выберите хотя бы одну страну', 'error'); return; }
const rules = pickedGeoIP.map(c => `GEOIP,${c},${target},no-resolve`);
setSubmitting(true);
try {
const r = await api.post('/api/mihomo/rules/bulk-add', { rules, position: 0 });
showToast(`Добавлено правил: ${r.added}`, 'success');
setPicked(p => { const n = {...p}; pickedGeoIP.forEach(c => delete n['_ip_'+c]); return n; });
} catch (e) { showToast(e.message, 'error'); }
finally { setSubmitting(false); }
};
const addStarterPack = async () => {
if (!target) { showToast('Выберите канал/группу куда направить заблокированные', 'error'); return; }
// Категории meta-rules-dat (облегчённой версии). category-gfw/category-ru в ней нет.
// Берём то что точно есть: популярные сервисы → VPN, RU/private → DIRECT.
const rules = [
'GEOIP,private,DIRECT,no-resolve',
'GEOSITE,private,DIRECT',
`GEOSITE,youtube,${target}`,
`GEOSITE,openai,${target}`,
`GEOSITE,telegram,${target}`,
`GEOSITE,twitter,${target}`,
`GEOSITE,github,${target}`,
'GEOIP,RU,DIRECT,no-resolve',
];
if (!confirm(`Добавить в начало списка правил ${rules.length} стандартных правил?\n\n` +
`YouTube / Telegram / OpenAI / Twitter / GitHub → ${target}\n` +
`Российские IP → напрямую (DIRECT)\n` +
`Локальная сеть → напрямую (DIRECT)`)) return;
setSubmitting(true);
try {
const r = await api.post('/api/mihomo/rules/bulk-add', { rules, position: 0 });
showToast(`Стандартный пакет добавлен (${r.added} правил)`, 'success');
} catch (e) { showToast(e.message, 'error'); }
finally { setSubmitting(false); }
};
return (
{/* Шапка: статус баз */}
🌍 Правила по категориям и странам
{ready ? '✓ базы готовы' : '⚠ базы не найдены'}
{!ready && (
GeoSite/GeoIP базы не найдены в {geoDir}. Правила
добавятся, но Mihomo не сможет их применить. Обновите geo-базы в
разделе настроек geo-URL или переустановите панель.
)}
Правила GEOSITE сопоставляют доменные имена с категориями
(списки обновляются автоматически с GitHub). Правила GEOIP —
с диапазонами IP по странам.
Для работы GEOSITE в transparent-режиме нужно чтобы DNS-запросы
клиентов проходили через Mihomo (fake-ip). Иначе работать будет только{' '}
GEOIP.
{/* Куда направить */}
setTarget(e.target.value)}>
{targets.map(t => {t} )}
{/* Стандартный пакет */}
⚡ Стандартный пакет
Один клик — 8 правил для типичного сценария: популярные заблокированные
сервисы (YouTube, Telegram, OpenAI, Twitter, GitHub) через{' '}
{target || '—'} ; российские IP, локальная сеть — напрямую (DIRECT).
⚡ Добавить стандартный пакет
{/* GeoSite — категории */}
{/* GeoIP — страны */}
);
}
// =========================================================
// MihomoDNSPanel — управление DNS-секцией config.yaml для GeoSite/GeoIP
// =========================================================
function MihomoDNSPanel() {
const [state, setState] = useState(null);
const [mode, setMode] = useState('off');
const [port, setPort] = useState(5353);
const [nameservers, setNameservers] = useState('1.1.1.1, 8.8.8.8');
const [fallback, setFallback] = useState('tls://1.1.1.1:853, tls://8.8.8.8:853');
const [fakeFilter, setFakeFilter] = useState('*.lan, *.local, *.localdomain');
const [saving, setSaving] = useState(false);
const load = async () => {
try {
const r = await api.get('/api/mihomo/dns');
setState(r);
setMode(r.enabled ? (r.mode || 'redir-host') : 'off');
if (r.port) setPort(r.port);
if (r.nameservers?.length) setNameservers(r.nameservers.join(', '));
if (r.fallback?.length) setFallback(r.fallback.join(', '));
if (r.fake_ip_filter?.length) setFakeFilter(r.fake_ip_filter.join(', '));
} catch (e) { showToast(e.message, 'error'); }
};
useEffect(() => { load(); }, []);
const apply = async () => {
if (mode !== 'off') {
if (!confirm(
`Применить DNS-конфигурацию Mihomo (режим: ${mode})?\n\n` +
`Mihomo займёт порт ${port} для DNS. Если на хосте уже что-то слушает 53 (systemd-resolved),\n` +
`могут быть конфликты. После применения проверь:\n sudo systemctl status mihomo\n\n` +
`Также на роутере выдай Vemitreya как DNS-сервер для клиентов — иначе режим бесполезен.`
)) return;
} else {
if (!confirm('Отключить DNS Mihomo (enable: false)?')) return;
}
setSaving(true);
try {
const body = {
mode,
port: parseInt(port) || 5353,
nameservers: nameservers.split(',').map(s => s.trim()).filter(Boolean),
fallback: fallback.split(',').map(s => s.trim()).filter(Boolean),
fake_ip_filter: fakeFilter.split(',').map(s => s.trim()).filter(Boolean),
};
await api.put('/api/mihomo/dns', body);
showToast('DNS-конфигурация применена', 'success');
await load();
} catch (e) { showToast(e.message, 'error'); }
finally { setSaving(false); }
};
if (!state) return Загрузка...
;
return (
Чтобы правила GEOSITE точно срабатывали, Mihomo должен видеть
домены, а не только IP-адреса. Это требует чтобы DNS-запросы клиентов
проходили через Mihomo. Здесь — настройка DNS-секции config.yaml.
На роутере также нужно выдать Vemitreya как DNS-сервер
для клиентов (DHCP option 6). Иначе клиенты будут резолвить мимо Mihomo
и режим не сработает.
Текущий статус
DNS:
{state.enabled ? '✓ включён' : '○ выключен'}
{state.enabled && (
<>
Режим: {state.mode || '—'}
Порт: {state.port || '—'}
>
)}
{[
['off', 'Выключен', 'DNS Mihomo не работает'],
['redir-host', 'redir-host', 'проще, медленнее'],
['fake-ip', 'fake-ip', 'рекомендуется для GeoSite'],
].map(([id, label, hint]) => (
setMode(id)} />
{label}
{hint}
))}
{mode !== 'off' && (
<>
setPort(e.target.value)} style={{ maxWidth: 120 }} />
setNameservers(e.target.value)} />
setFallback(e.target.value)} />
{mode === 'fake-ip' && (
setFakeFilter(e.target.value)} />
)}
>
)}
{saving ? 'Применение...' : 'Применить DNS-конфигурацию'}
Обновить
Если включаешь DNS на порту 53 — убедись что systemd-resolved не
слушает его (если слушает: sudo systemctl stop systemd-resolved &&
sudo systemctl disable systemd-resolved).
На роутере выдай Vemitreya как DNS-сервер для клиентов
(DHCP option 6). Без этого fake-ip не работает.
Заблокируй на роутере прямой DNS наружу (UDP/TCP 53) от всех кроме Vemitreya,
чтобы клиенты не обходили fake-ip через 1.1.1.1.
Подробнее в docs/routers/*.md → раздел «GeoSite через fake-ip».
);
}
// =========================================================
// PROXY GROUPS — визуальный редактор групп
// =========================================================
function ProxyGroupsPage() {
const [groups, setGroups] = useState([]);
const [available, setAvailable] = useState({ groups: [], proxies: [], providers: [], specials: [] });
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [editing, setEditing] = useState(null); // null | 'new' | group object
const [search, setSearch] = useState('');
const [smartGroups, setSmartGroups] = useState([]); // v2.206
const [smartInterval, setSmartInterval] = useState(60);
const [smartTolerance, setSmartTolerance] = useState(0);
const [smartExclude, setSmartExclude] = useState(''); // v2.206
const loadSmart = async () => {
try {
const r = await api.get('/api/mihomo/smart-groups');
setSmartGroups(r.groups || []);
if (r.interval) setSmartInterval(r.interval);
if (r.tolerance != null) setSmartTolerance(r.tolerance);
if (r.exclude != null) setSmartExclude(r.exclude);
} catch {}
};
const saveSmartSettings = async (interval, tolerance, exclude) => {
try {
await api.post('/api/mihomo/smart-groups',
{ groups: smartGroups, interval, tolerance,
exclude: exclude !== undefined ? exclude : smartExclude });
showToast('Настройки автовыбора сохранены', 'success');
} catch (e) { showToast(e.message, 'error'); }
};
const toggleSmart = async (name) => {
const next = smartGroups.includes(name)
? smartGroups.filter(g => g !== name)
: [...smartGroups, name];
setSmartGroups(next);
try {
await api.post('/api/mihomo/smart-groups',
{ groups: next, interval: smartInterval, tolerance: smartTolerance });
showToast(next.includes(name)
? `⚡ Умный автовыбор включён: "${name}"`
: `Умный автовыбор выключен: "${name}"`, 'success');
} catch (e) { showToast(e.message, 'error'); loadSmart(); }
};
const load = async () => {
setLoadError(null);
try {
const g = await api.get('/api/mihomo/proxy-groups');
const av = await api.get('/api/mihomo/proxy-groups/available-proxies')
.catch(() => ({ groups: [], proxies: [], providers: [], provider_proxies: {}, specials: [] }));
setGroups(Array.isArray(g) ? g : []);
setAvailable(av);
} catch (e) {
setLoadError(e.message);
showToast(`Ошибка загрузки групп: ${e.message}`, 'error');
}
finally { setLoading(false); }
};
useEffect(() => { load(); loadSmart(); }, []);
// пока есть умные группы — тихо обновляем список, чтобы видеть
// автопереключения без ручного F5.
useEffect(() => {
if (!smartGroups.length) return;
const i = setInterval(() => {
api.get('/api/mihomo/proxy-groups')
.then(g => setGroups(Array.isArray(g) ? g : []))
.catch(() => {});
}, 8000);
return () => clearInterval(i);
}, [smartGroups]);
const del = async (name) => {
if (!confirm(`Удалить группу "${name}"?`)) return;
try {
await api.del(`/api/mihomo/proxy-groups/${encodeURIComponent(name)}`);
showToast('Удалено', 'success');
load();
} catch (e) {
// если группа используется — предложить удалить вместе со ссылками
if (String(e.message).startsWith('409')) {
if (confirm(
`Канал "${name}" используется в других группах или правилах.\n\n` +
`Удалить его и автоматически убрать все ссылки на него?`
)) {
try {
const r = await api.del(`/api/mihomo/proxy-groups/${encodeURIComponent(name)}?force=true`);
const refs = r.cleaned_refs || [];
showToast(refs.length
? `Удалено. Очищено ссылок: ${refs.length}`
: 'Удалено', 'success');
load();
} catch (e2) { showToast(e2.message, 'error'); }
}
} else {
showToast(e.message, 'error');
}
}
};
// Классификация для каждой группы
const filtered = search
? groups.filter(g => g.name.toLowerCase().includes(search.toLowerCase()))
: groups;
const allGroupNames = new Set(groups.map(g => g.name));
const classifyOne = (g) => {
if (SYSTEM_GROUPS.has(g.name)) return 'system';
return classifyGroup(g, allGroupNames);
};
// Тип таба для фильтрации
const [groupTab, setGroupTab] = useState('all'); // all | routing | channels
const visible = filtered.filter(g => {
if (SYSTEM_GROUPS.has(g.name)) return false; // никогда не показываем GLOBAL
if (groupTab === 'all') return true;
const cls = classifyOne(g);
return groupTab === 'routing' ? cls === 'routing' : cls === 'channel';
});
const routingCount = filtered.filter(g => !SYSTEM_GROUPS.has(g.name) && classifyOne(g) === 'routing').length;
const channelCount = filtered.filter(g => !SYSTEM_GROUPS.has(g.name) && classifyOne(g) === 'channel').length;
if (loading) return ;
return (
{/* Табы Routing / Channels */}
setGroupTab('all')}>
📋 Все
{routingCount + channelCount}
setGroupTab('routing')}
title="Группы управления трафиком — определяют куда направить тип трафика. В rules: используются именно они.">
🎯 Маршрутизация
{routingCount}
setGroupTab('channels')}
title="Группы каналов — пулы серверов одного протокола (AWG, TrustTunnel, VLESS, HY2 и т.п.)">
🔒 Каналы
{channelCount}
{/* Описание текущей вкладки */}
{groupTab === 'routing' && (
<>
🎯 Маршрутизация — группы управления трафиком.
Содержат другие группы или DIRECT/REJECT. На них ссылаются rules:
(например «Telegram идёт через эту группу»).
>
)}
{groupTab === 'channels' && (
<>
🔒 Каналы — пулы конкретных серверов одного протокола (AWG, TrustTunnel, VLESS, HY2…).
Не используются напрямую в правилах, а служат «выбором сервера» когда канал
подключён в маршрутизирующую группу.
>
)}
{groupTab === 'all' && (
<>
📋 Все — обе категории сразу.
Сверху — Маршрутизация (используется в rules), ниже — Каналы (пулы серверов).
>
)}
{loadError && (
Не удалось загрузить группы
{loadError}
Возможно на сервере старая версия backend — обновите через sudo ./update.sh
↻ Повторить
)}
{!loadError && visible.length === 0 && (
📂
{groups.length === 0 ? 'Нет групп. Создайте первую.'
: groupTab === 'routing' ? 'Нет групп маршрутизации'
: groupTab === 'channels' ? 'Нет групп-каналов'
: 'Ничего не найдено'}
)}
{(() => {
// В режиме "Все" — сначала routing, потом channels
const sorted = groupTab === 'all'
? [...visible].sort((a, b) => {
const ca = classifyOne(a), cb = classifyOne(b);
if (ca === cb) return 0;
return ca === 'routing' ? -1 : 1;
})
: visible;
return sorted.map(g => (
toggleSmart(g.name)}
onEdit={() => setEditing(g)}
onDelete={() => del(g.name)}
onReload={load} />
));
})()}
{editing &&
setEditing(null)}
onSaved={() => { setEditing(null); load(); loadSmart(); }} />}
);
}
const TYPE_INFO = {
'select': { label: 'Выбор вручную', icon: '👉', color: 'var(--accent)' },
'url-test': { label: 'Авто (по пингу)', icon: '⚡', color: 'var(--success)' },
'fallback': { label: 'Резервирование', icon: '🛡️', color: 'var(--warning)' },
'load-balance': { label: 'Балансировка', icon: '⚖️', color: 'var(--purple)' },
};
function ProxyGroupCard({ group, kind, available, smart, onToggleSmart, onEdit, onDelete, onReload }) {
const type = TYPE_INFO[group.type] || { label: group.type, icon: '📂', color: 'var(--text-3)' };
const totalMembers = (group.proxies || []).length + (group.use || []).length;
const isRouting = kind === 'routing';
// Быстро убрать прокси из группы
const removeMember = async (member, isUse) => {
try {
const updated = {
...group,
proxies: isUse ? group.proxies : (group.proxies || []).filter(p => p !== member),
use: isUse ? (group.use || []).filter(u => u !== member) : group.use,
};
await api.put(`/api/mihomo/proxy-groups/${encodeURIComponent(group.name)}`, updated);
onReload();
} catch (e) { showToast(e.message, 'error'); }
};
return (
{group.name}
{isRouting ? '🎯 ROUTING' : '🔒 CHANNEL'}
{smart ? '⚡ Умный автовыбор' : `${type.icon} ${type.label}`}
{totalMembers} {totalMembers === 1 ? 'элемент' : 'элементов'}
✏️
🗑
{/* Список членов группы */}
{(group.proxies || []).map(p => {
const isSpecial = p === 'DIRECT' || p === 'REJECT';
const isGroup = available.groups?.includes(p);
return (
{p}
removeMember(p, false)}
title="Убрать из группы">×
);
})}
{(group.use || []).map(u => (
📡
{u}
removeMember(u, true)}
title="Убрать">×
))}
{totalMembers === 0 && (
Пусто
)}
{/* Дополнительная инфа для url-test/fallback */}
{group.type !== 'select' && (group.url || group.interval) && (
{group.url &&
test: {group.url}
}
{group.interval &&
every {group.interval}s
}
)}
);
}
function ProxyGroupEditor({ initial, available, existingNames, existingGroups, defaultKind, initialSmart, smartGroups, onClose, onSaved }) {
const isEdit = !!initial;
// Определяем kind при редактировании
const initialKind = useMemo(() => {
if (defaultKind) return defaultKind;
if (!initial) return 'routing';
const allNames = new Set((existingGroups || existingNames || []).map(g => typeof g === 'string' ? g : g.name));
return classifyGroup(initial, allNames);
}, [initial, defaultKind]);
const [kind, setKind] = useState(initialKind); // 'routing' | 'channel'
const [form, setForm] = useState(() => initial ? {
name: initial.name,
type: initial.type || 'select',
proxies: [...(initial.proxies || [])],
use: [...(initial.use || [])],
filter: initial.filter || '',
url: initial.url || 'http://www.gstatic.com/generate_204',
interval: initial.interval || 300,
tolerance: initial.tolerance || null,
strategy: initial.strategy || 'consistent-hashing',
icon: initial.icon || '',
} : {
name: '',
type: 'select',
proxies: [],
use: [],
filter: '',
url: 'http://www.gstatic.com/generate_204',
interval: 300,
tolerance: null,
strategy: 'consistent-hashing',
icon: '',
});
// при редактировании группы с filter — восстановить выбранные
// серверы подписок в proxies (для отображения галочек), чтобы submit пересобрал их.
useEffect(() => {
if (!initial?.filter) return;
const pp = available.provider_proxies || {};
if (!Object.keys(pp).length) return;
let re;
try { re = new RegExp(initial.filter); } catch { return; }
const matched = [];
Object.values(pp).forEach(names => names.forEach(n => { if (re.test(n)) matched.push(n); }));
if (matched.length) {
setForm(p => ({
...p,
proxies: Array.from(new Set([...p.proxies, ...matched])),
}));
}
}, [available.provider_proxies]);
const [search, setSearch] = useState('');
const [saving, setSaving] = useState(false);
// поиск и сворачивание серверов подписок
const [srvSearch, setSrvSearch] = useState('');
const [openProvs, setOpenProvs] = useState({}); // {provName: true} — раскрытые
const toggleProv = (p) => setOpenProvs(o => ({ ...o, [p]: !o[p] }));
const f = (k) => ({
value: form[k] ?? '',
onChange: e => setForm(p => ({ ...p, [k]: e.target.value })),
disabled: saving,
});
// Доступные элементы — фильтруются по типу группы
// Routing-группа: можно добавлять других routing-групп, channel-группы, DIRECT/REJECT
// Channel-группа: можно добавлять только конкретные прокси и подписки (use:)
const channelGroupNames = useMemo(() => {
if (!existingGroups) return new Set();
const allNames = new Set(existingGroups.map(g => g.name));
return new Set(existingGroups
.filter(g => classifyGroup(g, allNames) === 'channel')
.map(g => g.name));
}, [existingGroups]);
const isChannelKind = kind === 'channel';
let allItems = [];
if (isChannelKind) {
// Channel: только прокси (без других групп и без DIRECT/REJECT)
allItems = (available.proxies || []).map(p => ({ name: p, kind: 'proxy' }));
} else {
// Routing: специальные + ВСЕ группы (но не сама себя), без сырых прокси
allItems = [
...(available.specials || []).map(p => ({ name: p, kind: 'special' })),
...(available.groups || [])
.filter(g => g !== form.name && !SYSTEM_GROUPS.has(g))
.map(p => ({
name: p,
kind: channelGroupNames.has(p) ? 'channel-group' : 'group'
})),
];
}
const allProviders = isChannelKind ? (available.providers || []) : [];
const filtered = search
? allItems.filter(i => i.name.toLowerCase().includes(search.toLowerCase()))
: allItems;
const toggleMember = (name) => setForm(p => ({
...p,
proxies: p.proxies.includes(name) ? p.proxies.filter(x => x !== name) : [...p.proxies, name]
}));
const toggleProvider = (name) => setForm(p => ({
...p,
use: p.use.includes(name) ? p.use.filter(x => x !== name) : [...p.use, name]
}));
const moveMember = (name, dir) => setForm(p => {
const arr = [...p.proxies];
const idx = arr.indexOf(name);
if (idx < 0) return p;
const newIdx = idx + dir;
if (newIdx < 0 || newIdx >= arr.length) return p;
[arr[idx], arr[newIdx]] = [arr[newIdx], arr[idx]];
return { ...p, proxies: arr };
});
// drag&drop для proxies внутри группы
const [memDragSrc, setMemDragSrc] = useState(null);
const [memDragOver, setMemDragOver] = useState(null);
const onMemDragStart = (idx) => (e) => {
setMemDragSrc(idx);
e.dataTransfer.effectAllowed = 'move';
};
const onMemDragOver = (idx) => (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (memDragOver !== idx) setMemDragOver(idx);
};
const onMemDragLeave = () => setMemDragOver(null);
const onMemDrop = (dstIdx) => (e) => {
e.preventDefault();
const src = memDragSrc;
setMemDragSrc(null);
setMemDragOver(null);
if (src === null || src === dstIdx) return;
setForm(p => {
const arr = [...p.proxies];
const [item] = arr.splice(src, 1);
arr.splice(dstIdx, 0, item);
return { ...p, proxies: arr };
});
};
const submit = async () => {
if (!form.name.trim()) { showToast('Укажите имя группы', 'warning'); return; }
const existingList = existingGroups
? existingGroups.map(g => g.name)
: (existingNames || []);
if (!isEdit && existingList.includes(form.name)) {
showToast('Группа с таким именем уже существует', 'warning');
return;
}
if (form.proxies.length + form.use.length === 0) {
showToast('Добавьте хотя бы один прокси или подписку', 'warning');
return;
}
// Защита от потери fallback в routing-группе
if (!isChannelKind) {
const hasFallback = form.proxies.includes('DIRECT') || form.proxies.includes('REJECT');
if (!hasFallback) {
const ok = confirm(
`⚠️ Внимание!\n\nГруппа "${form.name}" — routing-группа, но в ней НЕТ DIRECT или REJECT.\n\n` +
`Если все прокси-каналы упадут, трафик не сможет пойти напрямую — это приведёт к timeout'ам ` +
`(как у группы Telegram при недоступности EOF).\n\n` +
`Рекомендуется добавить DIRECT в конец списка как fallback.\n\n` +
`Сохранить без DIRECT/REJECT?`
);
if (!ok) return;
}
}
// серверы из подписок нельзя класть в proxies (Mihomo упадёт:
// 'not found'). Их надо перевести в use:[provider] + filter:"regex имён".
// Разделяем form.proxies на настоящие прокси/группы и серверы-из-подписок.
const provProxies = available.provider_proxies || {};
const serverToProvider = {}; // имя сервера → провайдер
Object.entries(provProxies).forEach(([prov, names]) => {
names.forEach(n => { serverToProvider[n] = prov; });
});
const realProxies = []; // статические прокси и группы — в proxies
const usedProviders = new Set(form.use); // подписки целиком
const filterNames = []; // имена серверов для regex-фильтра
form.proxies.forEach(p => {
if (serverToProvider[p]) {
usedProviders.add(serverToProvider[p]);
filterNames.push(p);
} else {
realProxies.push(p);
}
});
// Экранируем regex-спецсимволы в именах серверов, объединяем через |
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const filterStr = filterNames.length
? filterNames.map(escapeRe).join('|')
: null;
const payload = {
name: form.name.trim(),
type: form.type,
proxies: realProxies.length ? realProxies : null,
use: usedProviders.size ? Array.from(usedProviders) : null,
filter: filterStr || '', // пустая строка → backend удалит старый filter
};
// Параметры авто-типов (url-test/fallback/load-balance)
if (form.type !== 'select') {
payload.url = (form.url || '').trim() || 'http://www.gstatic.com/generate_204';
payload.interval = parseInt(form.interval) || 300;
}
if (form.type === 'url-test' && form.tolerance) payload.tolerance = parseInt(form.tolerance);
if (form.type === 'load-balance') payload.strategy = form.strategy || 'consistent-hashing';
try {
setSaving(true);
const gname = form.name.trim();
if (isEdit) {
await api.put(`/api/mihomo/proxy-groups/${encodeURIComponent(initial.name)}`, payload);
showToast(`✓ ${form.name} обновлена`, 'success');
} else {
await api.post('/api/mihomo/proxy-groups', payload);
showToast(`✓ ${form.name} создана`, 'success');
}
onSaved();
} catch (e) {
showToast(e.message, 'error');
} finally {
setSaving(false);
}
};
const presetEmojis = ['🌐', '✈️', '💚', '🇺🇸', '🇪🇺', '🇷🇺', '🇩🇪', '🇫🇮', '🇳🇱', '🇫🇷', '🤖', '🎮', '📺', '🛡️', '⚡', '🎯', '📡', '🔒'];
return (
e.stopPropagation()}>
{saving && (
Сохранение и перезагрузка Mihomo...
)}
{isEdit ? `✏️ Редактирование «${initial.name}»` : '➕ Новая группа'}
{!isEdit && (
Что создаём?
setKind('routing')}>
🎯
Маршрутизация
Управляет трафиком: «Telegram → одна группа», «весь остальной → другая».
Содержит ДРУГИЕ группы или DIRECT/REJECT. Используется в rules:.
Пример: 🌐 Основной трафик, ✈️ Telegram, 🖥️ Серверный
setKind('channel')}>
🔒
Канал (пул серверов)
Пул серверов одного протокола: AWG, TrustTunnel, VLESS, HY2 и т.п.
Содержит конкретные прокси или подписки use:.
Пример: 🔒 AWG Tunnel, 🔒 EOF [Hysteria-2], 🔒 EOF [TrustTunnel]
)}
{/* Левая колонка: настройки группы */}
Название
{!isEdit && (
{presetEmojis.map(em => (
setForm(p => ({
...p,
name: em + (p.name && !p.name.startsWith(em) ? ' ' + p.name.replace(/^[\p{Emoji}\s]+/u, '') : ' ')
}))}>{em}
))}
)}
{/* Тип группы (нативный mihomo) */}
Тип группы
setForm(p => ({ ...p, type: e.target.value }))} disabled={saving}>
Выбор вручную (select)
Умный — авто по пингу (url-test)
Fallback — резерв при сбое
Load-balance — распределение нагрузки
{form.type !== 'select' && (
)}
{form.type === 'url-test' && (
Tolerance, мс (необязательно)
)}
{form.type === 'load-balance' && (
Стратегия
setForm(p => ({ ...p, strategy: e.target.value }))} disabled={saving}>
consistent-hashing — один сервер на target
round-robin — по кругу
sticky-sessions — по src+target (10 мин)
)}
{/* Выбранные элементы */}
В группе: {form.proxies.length + form.use.length}
{form.proxies.length === 0 && form.use.length === 0 && (
Кликни справа чтобы добавить
)}
{form.proxies.map((p, i) => (
{i + 1}
{p}
moveMember(p, -1)} disabled={i === 0}
className="btn btn-sm btn-icon" title="Вверх">↑
moveMember(p, 1)} disabled={i === form.proxies.length - 1}
className="btn btn-sm btn-icon" title="Вниз">↓
toggleMember(p)} className="btn btn-sm btn-icon"
style={{ color: 'var(--error)' }}>×
))}
{form.use.map(u => (
📡
{u}
toggleProvider(u)} className="btn btn-sm btn-icon"
style={{ color: 'var(--error)' }}>×
))}
{/* Правая колонка: доступные элементы */}
{isChannelKind ? 'Доступные прокси и серверы' : 'Доступные группы и DIRECT/REJECT'}
setSearch(e.target.value)} style={{ marginBottom: 8 }} />
{filtered.map(item => {
const selected = form.proxies.includes(item.name);
return (
toggleMember(item.name)}>
{item.kind === 'special' ? '⚙️'
: item.kind === 'channel-group' ? '🔒'
: item.kind === 'group' ? '🎯'
: '🔌'}
{item.name}
{selected ? '✓' : '+'}
);
})}
{filtered.length === 0 && (
{search ? 'Ничего не найдено' : 'Список пуст'}
)}
{allProviders.length > 0 && (
Подписки (use)
{allProviders.map(p => {
const selected = form.use.includes(p);
return (
toggleProvider(p)}>
📡
{p}
{selected ? '✓' : '+'}
);
})}
)}
{isChannelKind && available.provider_proxies
&& Object.keys(available.provider_proxies).length > 0 && (
Отдельные серверы из подписок
Конкретный сервер (Vless/Hy2/...) из подписки — добавится в канал
отдельно, без всей подписки.
setSrvSearch(e.target.value)}
placeholder="🔍 Поиск сервера по имени..."
style={{ marginBottom: 8, fontSize: 12 }} />
{Object.entries(available.provider_proxies).map(([prov, names]) => {
const q = srvSearch.trim().toLowerCase();
const matched = q ? names.filter(n => n.toLowerCase().includes(q)) : names;
if (q && matched.length === 0) return null; // скрыть провайдера без совпадений
// При активном поиске — раскрываем автоматически; иначе по клику
const isOpen = q ? true : !!openProvs[prov];
const selCount = names.filter(n => form.proxies.includes(n)).length;
return (
!q && toggleProv(prov)}
style={{ display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 6px', cursor: q ? 'default' : 'pointer',
fontSize: 12, fontWeight: 600 }}>
▶
📡
{prov}
{selCount > 0 && {selCount} выбр. · }
{matched.length} серв.
{isOpen && matched.map(pn => {
const selected = form.proxies.includes(pn);
return (
toggleMember(pn)} style={{ paddingLeft: 28 }}>
🔹
{pn}
{selected ? '✓' : '+'}
);
})}
);
})}
)}
Отмена
{saving ? <> Сохранение...> : (isEdit ? 'Сохранить' : 'Создать группу')}
);
}
// =========================================================
// MIHOMO UPDATER
// =========================================================
function MihomoUpdater() {
const [info, setInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [updating, setUpdating] = useState(false);
const [status, setStatus] = useState(null);
const pollRef = useRef(null);
// кастомный URL и upload файла
const [customUrl, setCustomUrl] = useState('');
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef(null);
// Быстрая загрузка — только локальная версия, без GitHub
const loadVersion = async () => {
try {
setInfo(await api.get('/api/mihomo/version'));
} catch (e) { showToast(e.message, 'error'); }
finally { setLoading(false); }
};
// Проверка обновлений (медленно — идёт в GitHub)
const checkUpdates = async () => {
setChecking(true);
try {
setInfo(await api.get('/api/mihomo/version?check=true'));
} catch (e) { showToast(e.message, 'error'); }
finally { setChecking(false); }
};
useEffect(() => { loadVersion(); }, []);
// Poll статуса при обновлении
useEffect(() => {
if (!updating) return;
const poll = async () => {
try {
const s = await api.get('/api/mihomo/update/status');
setStatus(s);
if (!s.running) {
setUpdating(false);
if (s.error) showToast(`Ошибка: ${s.error.slice(0, 100)}`, 'error');
else showToast(`✓ Mihomo обновлён${s.result?.new_version ? ` до v${s.result.new_version}` : ''}`, 'success');
setTimeout(loadVersion, 1000);
}
} catch {}
};
pollRef.current = setInterval(poll, 1500);
poll();
return () => clearInterval(pollRef.current);
}, [updating]);
const startUpdate = async () => {
const action = info.downgrade_to_stable
? `Перейти со сборки "${info.current}" на стабильную v${info.latest}?`
: `Обновить Mihomo с v${info.current} до v${info.latest}?`;
if (!confirm(`${action}\n\nMihomo будет ненадолго остановлен. В случае ошибки автоматически произойдёт откат.`)) return;
try {
await api.post('/api/mihomo/update');
setUpdating(true);
setStatus({ running: true, step: 'Запуск...', progress: 5 });
} catch (e) { showToast(e.message, 'error'); }
};
// обновление с произвольного URL
const startUpdateFromUrl = async () => {
const url = customUrl.trim();
if (!url) { showToast('Введи URL', 'warning'); return; }
if (!confirm(`Скачать и установить Mihomo с этого URL?\n\n${url}\n\nMihomo будет ненадолго остановлен. В случае ошибки автоматически произойдёт откат.`)) return;
try {
await api.post('/api/mihomo/update-from-url', { url });
setUpdating(true);
setStatus({ running: true, step: 'Запуск...', progress: 5 });
setCustomUrl('');
} catch (e) { showToast(e.message, 'error'); }
};
// обновление из загруженного файла
const startUpdateFromFile = async (file) => {
if (!file) return;
if (file.size < 1024 * 1024) {
showToast(`Файл слишком маленький (${file.size} байт). Ожидается .gz или ELF binary.`, 'warning');
return;
}
if (!confirm(`Загрузить и установить Mihomo из файла "${file.name}" (${(file.size/1024/1024).toFixed(1)} MB)?\n\nMihomo будет ненадолго остановлен. В случае ошибки автоматически произойдёт откат.`)) return;
setUploading(true);
try {
const fd = new FormData();
fd.append('file', file);
const r = await fetch(api.base + '/api/mihomo/update-from-file', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('mp_token') || '') },
body: fd,
});
if (!r.ok) {
const text = await r.text();
throw new Error(text || `HTTP ${r.status}`);
}
setUpdating(true);
setStatus({ running: true, step: 'Загрузка...', progress: 10 });
if (fileInputRef.current) fileInputRef.current.value = '';
} catch (e) {
showToast(e.message, 'error');
} finally {
setUploading(false);
}
};
if (loading) return ;
if (!info) return Не удалось получить информацию
;
return (
Mihomo Core
Обновление proxy core с GitHub releases
{checking ? '⏳ Проверка...' : '🔍 Проверить обновления'}
Бинарь
{info.binary_path}
Архитектура
{info.arch}
Текущая версия
{info.current
? <>
{info.current.match(/^\d/) ? `v${info.current}` : info.current}
{info.channel === 'prerelease' && (
⚠ {info.current.split('-')[0]}
)}
{info.channel === 'stable' && (
stable
)}
>
: не определено }
Последняя стабильная
{info.latest
?
v{info.latest}
: info.checked
? —
:
нажмите «🔍 Проверить обновления»
}
{info.release_notes_url && (
📝 Release notes ↗
)}
{info.latest_published && (
Дата релиза
{new Date(info.latest_published).toLocaleDateString('ru-RU', {
year: 'numeric', month: 'long', day: 'numeric'
})}
)}
{info.error && (
⚠ Ошибка
{info.error}
)}
{info.note && (
⚠️ {info.note}
)}
{!info.update_available && info.current && info.latest && (
✓ Установлена последняя версия
)}
{info.update_available && (
{updating
? '⏳ Обновление...'
: (info.downgrade_to_stable
? `🔄 Перейти на stable v${info.latest}`
: `⬆️ Обновить до v${info.latest}`)}
)}
{/* Прогресс обновления */}
{(updating || status) && status && (
{status.step}
{status.running && {status.progress}% }
{status.backup && (
Backup: {status.backup}
)}
)}
Скачивается {info.latest_asset_name || `mihomo-${info.arch}-vX.Y.Z.gz`} с GitHub
Распаковывается во временный файл
Проверяется работоспособность через mihomo -v
Текущий бинарь сохраняется как {info.binary_path}.bak.YYYYMMDD_HHMMSS
Mihomo останавливается → бинарь заменяется → запускается обратно
Если запуск не удался — автоматический откат из backup
{/* ручное обновление — свёрнуто, нужно редко */}
Если auto-update не находит релиз (новая схема имён, прокси блокирует github, форк) —
скачай бинарь руками или укажи URL.
);
}
// =========================================================
// GEO DATABASES UPDATER (v2.206)
// =========================================================
function GeoUpdater() {
const [info, setInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState(false);
const [status, setStatus] = useState(null);
const [urls, setUrls] = useState({});
const [savingUrls, setSavingUrls] = useState(false);
const pollRef = useRef(null);
const load = async () => {
try {
const i = await api.get('/api/mihomo/geo/info');
setInfo(i);
// Поля редактирования: показываем только кастомные URL, дефолтные оставляем
// пустыми (дефолт виден как placeholder).
const init = {};
(i.files || []).forEach(f => { init[f.name] = f.custom ? f.url : ''; });
setUrls(init);
}
catch (e) { showToast(e.message, 'error'); }
finally { setLoading(false); }
};
const saveUrls = async () => {
setSavingUrls(true);
try {
await api.put('/api/mihomo/geo/urls', { urls });
showToast('✓ Источники сохранены', 'success');
await load();
} catch (e) { showToast(e.message, 'error'); }
finally { setSavingUrls(false); }
};
const resetUrls = async () => {
if (!confirm('Сбросить URL всех geo-баз к значениям по умолчанию?')) return;
setSavingUrls(true);
try {
await api.put('/api/mihomo/geo/urls', { urls: {} });
showToast('✓ Сброшено к значениям по умолчанию', 'success');
await load();
} catch (e) { showToast(e.message, 'error'); }
finally { setSavingUrls(false); }
};
useEffect(() => { load(); }, []);
useEffect(() => {
if (!updating) return;
const poll = async () => {
try {
const s = await api.get('/api/mihomo/geo/update/status');
setStatus(s);
if (!s.running) {
setUpdating(false);
if (s.error) showToast(`Ошибка: ${s.error.slice(0, 100)}`, 'error');
else showToast(`✓ Geo базы обновлены`, 'success');
setTimeout(load, 1000);
}
} catch {}
};
pollRef.current = setInterval(poll, 1000);
poll();
return () => clearInterval(pollRef.current);
}, [updating]);
const start = async () => {
if (!confirm('Скачать свежие geosite.dat / geoip.dat / Country.mmdb с github MetaCubeX/meta-rules-dat?\n\nMihomo перезапускать не нужно — базы перечитываются автоматически при следующем reload.')) return;
try {
await api.post('/api/mihomo/geo/update');
setUpdating(true);
setStatus({ running: true, step: 'Запуск...', progress: 5 });
} catch (e) { showToast(e.message, 'error'); }
};
if (loading) return ;
if (!info) return null;
const fmtAge = (mtime) => {
if (!mtime) return '—';
const ageSec = Math.floor(Date.now() / 1000) - mtime;
const ageDays = Math.floor(ageSec / 86400);
const ageHours = Math.floor(ageSec / 3600);
if (ageDays > 1) return `${ageDays} дн. назад`;
if (ageHours > 1) return `${ageHours} ч. назад`;
return `${Math.floor(ageSec / 60)} мин. назад`;
};
const fmtDate = (mtime) => mtime ? new Date(mtime * 1000).toLocaleString() : '—';
// Если хотя бы один файл старше 14 дней — рекомендуем обновить
const ageThresholdSec = 14 * 86400;
const now = Math.floor(Date.now() / 1000);
const oldFile = info.files.find(f => f.exists && f.mtime && (now - f.mtime) > ageThresholdSec);
const missingFile = info.files.find(f => !f.exists);
return (
Geo databases
{info.source}
{updating ? '⏳ Обновление...' : '⬇️ Обновить базы'}
Файл
Размер
Возраст
Изменён
{info.files.map(f => {
const isOld = f.exists && f.mtime && (now - f.mtime) > ageThresholdSec;
return (
{f.name}
{f.exists ? `${f.size_mb} MB` : нет }
{f.exists ? fmtAge(f.mtime) : '—'}
{fmtDate(f.mtime)}
);
})}
{missingFile && (
⚠️ Отсутствуют некоторые geo-базы. Нажми «Обновить» чтобы их скачать.
)}
{!missingFile && oldFile && (
💡 Базы старше 14 дней. Рекомендуется обновить для актуальных GEOSITE правил.
)}
{/* Прогресс */}
{(updating || status) && status && (
{status.step}
{status.running && {status.progress}% }
{status.result?.files && (
Обновлено: {status.result.files.map(f => `${f.name} (${f.size_mb} MB)`).join(', ')}
)}
)}
{/* Источники (URL) — свёрнуто, по умолчанию используются зашитые */}
Можно указать свои URL для каждой базы. Пустое поле = использовать значение
по умолчанию (показано в плейсхолдере). Поддерживаются http(s)://.
{info.files.map(f => (
{f.name}
{f.custom
? свой
: по умолчанию }
setUrls(u => ({ ...u, [f.name]: e.target.value }))}
/>
))}
{savingUrls ? '⏳ Сохранение...' : '💾 Сохранить'}
↺ Сбросить к дефолтам
💡 Geo-базы определяют правила GEOSITE (например facebook, openai,
category-ru). После обновления Mihomo сам перечитает их при следующем reload —
перезапускать сервис не нужно.
);
}
// =========================================================
// PANEL SELF-UPDATER (обновление самой панели через архив или GitHub)
// =========================================================
function PanelUpdater() {
const [info, setInfo] = useState(null);
const [checking, setChecking] = useState(false);
const [updating, setUpdating] = useState(false);
const [status, setStatus] = useState(null);
const fileRef = useRef(null);
const pollRef = useRef(null);
// Быстрая загрузка — без проверки GitHub
const load = async () => {
try { setInfo(await api.get('/api/panel/version')); }
catch (e) { showToast(e.message, 'error'); }
};
// Полная проверка с GitHub (по кнопке)
const checkUpdates = async () => {
setChecking(true);
try { setInfo(await api.get('/api/panel/version?check=true')); }
catch (e) { showToast(e.message, 'error'); }
finally { setChecking(false); }
};
useEffect(() => { load(); }, []);
// Poll статуса при обновлении
useEffect(() => {
if (!updating) return;
const poll = async () => {
try {
const s = await api.get('/api/panel/update/status');
setStatus(s);
if (!s.running) {
setUpdating(false);
if (s.error) showToast(`Ошибка: ${s.error.slice(0, 100)}`, 'error');
else {
showToast('✓ Vemitreya обновлена. Сервис перезапускается...', 'success');
setTimeout(() => window.location.reload(), 4000);
}
}
} catch {}
};
pollRef.current = setInterval(poll, 1500);
poll();
return () => clearInterval(pollRef.current);
}, [updating]);
const uploadZip = async (file) => {
if (!file) return;
if (!confirm(`Установить обновление из "${file.name}" (${(file.size / 1024).toFixed(0)} KB)?\n\nПанель будет перезагружена.`)) return;
const fd = new FormData();
fd.append('file', file);
setUpdating(true);
setStatus({ running: true, step: 'Загрузка...', progress: 5 });
try {
const r = await fetch('/api/panel/update/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${api.token}` },
body: fd,
});
if (!r.ok) {
const j = await r.json().catch(() => ({}));
throw new Error(j.detail || `HTTP ${r.status}`);
}
} catch (e) {
setUpdating(false);
showToast(e.message, 'error');
}
};
const updateFromGithub = async () => {
if (!confirm(`Обновить Vemitreya до v${info.latest} с GitHub?`)) return;
try {
await api.post('/api/panel/update/from-github');
setUpdating(true);
setStatus({ running: true, step: 'Скачивание...', progress: 5 });
} catch (e) { showToast(e.message, 'error'); }
};
if (!info) return null;
return (
🎨 Vemitreya панель
Обновление самой панели управления
{info.github_repo && (
{checking ? '⏳ Проверка...' : '🔍 Проверить обновления'}
)}
Текущая версия
v{info.current}
Расположение
{info.install_dir}
{info.github_repo ? (
<>
GitHub репо
{info.github_repo}
{info.token_configured
? 🔒 приватный (токен)
: публичный }
Последняя версия
{info.latest
?
v{info.latest}
: info.checked
? —
:
нажмите «🔍 Проверить обновления»
}
{info.release_notes_url && (
📝 Release notes ↗
)}
{info.error && (
⚠ Ошибка
{info.error}
)}
>
) : (
{info.note}
)}
{info.update_available && (
{updating ? '⏳ Обновление...' : `⬆️ Обновить до v${info.latest}`}
)}
{/* Загрузка архива */}
uploadZip(e.target.files?.[0])} />
fileRef.current?.click()} disabled={updating}>
📦 Загрузить .zip архив
{/* Прогресс */}
{(updating || status) && status && (
{status.step}
{status.running && {status.progress}% }
{status.backup_dir && (
Backup: {status.backup_dir}
)}
)}
);
}
// =========================================================
// BACKUP / RESTORE — экспорт и импорт конфигурации
// =========================================================
function BackupRestore() {
const [exportType, setExportType] = useState('full');
const [exporting, setExporting] = useState(false);
const [stage, setStage] = useState('idle'); // idle | uploading | preview | applying | done
const [preview, setPreview] = useState(null);
const [importOpts, setImportOpts] = useState({
do_backup: true,
awg_mode: 'merge',
tt_mode: 'merge',
rules_mode: 'merge',
apply_full_config: false,
apply_providers: false,
});
const [applyResult, setApplyResult] = useState(null);
const doExport = async () => {
setExporting(true);
try {
const url = `${api.base}/api/config/export?type=${exportType}`;
const r = await fetch(url, { headers: { Authorization: `Bearer ${api.token}` }});
if (!r.ok) {
const t = await r.text();
throw new Error(`${r.status}: ${t}`);
}
const blob = await r.blob();
// достаём имя файла из заголовка
const cd = r.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="([^"]+)"/);
const fname = m ? m[1] : `vemitreya-${exportType}-${Date.now()}.tar.gz`;
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = fname;
link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
showToast(`Экспортировано: ${fname}`, 'success');
} catch (e) {
showToast('Ошибка экспорта: ' + e.message, 'error');
} finally {
setExporting(false);
}
};
const handleFileSelect = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setStage('uploading');
setApplyResult(null);
try {
const fd = new FormData();
fd.append('file', file);
const r = await fetch(`${api.base}/api/config/import/preview`, {
method: 'POST',
headers: { Authorization: `Bearer ${api.token}` },
body: fd
});
if (!r.ok) {
const t = await r.text();
let msg = t;
try { msg = JSON.parse(t).detail || t; } catch {}
throw new Error(msg);
}
const data = await r.json();
setPreview(data);
// Авто-настройка опций по содержимому
setImportOpts(prev => ({
...prev,
apply_full_config: data.items.has_full_config,
apply_providers: (data.items.providers || []).length > 0,
}));
setStage('preview');
} catch (e) {
showToast('Ошибка: ' + e.message, 'error');
setStage('idle');
}
};
const cancelImport = async () => {
if (preview?.staging_id) {
try {
await api.del(`/api/config/import/staging/${preview.staging_id}`);
} catch {}
}
setPreview(null);
setStage('idle');
};
const applyImport = async () => {
if (!preview?.staging_id) return;
if (!confirm('Применить импорт? Текущая конфигурация будет изменена.')) return;
setStage('applying');
try {
const r = await api.post('/api/config/import/apply', {
staging_id: preview.staging_id,
...importOpts
});
setApplyResult(r);
setStage('done');
showToast('Импорт применён', 'success');
} catch (e) {
showToast('Ошибка применения: ' + e.message, 'error');
setStage('preview');
}
};
return (
{/* === ЭКСПОРТ === */}
{/* === ИМПОРТ === */}
Импорт конфигурации
Загрузите архив .tar.gz от другой инсталляции Vemitreya. Сначала покажем diff,
затем спросим подтверждение.
{stage === 'idle' && (
Выбрать архив
)}
{stage === 'uploading' && (
Загрузка и анализ...
)}
{stage === 'preview' && preview && (
Манифест архива
Тип: {preview.manifest.type}
{' · '}Источник: {preview.manifest.source_host}
{' · '}Дата: {new Date(preview.manifest.exported_at).toLocaleString()}
{' · '}Vemitreya v{preview.manifest.version}
{/* AWG */}
{preview.items.awg.length > 0 && (
AWG туннели в архиве: {preview.items.awg.length}
{preview.items.awg.map(a => (
{a.name}
{a.exists &&
(уже существует) }
))}
setImportOpts(o => ({...o, awg_mode: e.target.value}))}>
Слияние: добавить новые, дубликаты перезаписать
Пропустить уже существующие
Полная замена: удалить все старые, импортировать новые
)}
{/* TT */}
{preview.items.tt.length > 0 && (
TrustTunnel в архиве: {preview.items.tt.length}
{preview.items.tt.map(t => (
{t.name}
{t.exists &&
(уже существует) }
))}
setImportOpts(o => ({...o, tt_mode: e.target.value}))}>
Слияние: добавить новые, дубликаты перезаписать
Пропустить уже существующие
Полная замена
)}
{/* Rules */}
{preview.items.has_rules_only && (
Правила Mihomo: {preview.items.new_rules_count} шт
setImportOpts(o => ({...o, rules_mode: e.target.value}))}>
Добавить новые правила (дубликаты пропустить)
Заменить ВСЕ правила импортированными
)}
{/* Full config */}
{preview.items.has_full_config && (
)}
{/* Providers */}
{preview.items.providers.length > 0 && (
)}
{/* Backup */}
Применить
Отмена
)}
{stage === 'applying' && (
Применение изменений...
)}
{stage === 'done' && applyResult && (
Импорт применён успешно
Лог операции:
{applyResult.log.join('\n')}
{ setStage('idle'); setPreview(null); setApplyResult(null); }}
style={{ marginTop: 10 }}>
Готово
)}
);
}
// выпадающее меню экспорта списка для разных устройств.
// Копирование ссылки или скачивание файла прямо из карточки списка.
function ExportMenu({ list }) {
const [open, setOpen] = useState(false);
const [copiedKey, setCopiedKey] = useState(null);
const ref = useRef(null);
const base = (localStorage.getItem('mp_url') || location.origin).replace(/\/$/, '');
const srvIp = (() => {
const saved = localStorage.getItem('mp_setup_serverip');
if (saved) return saved;
try { return new URL(base).hostname; } catch { return '192.168.1.1'; }
})();
const slug = list.name;
// Закрытие при клике вне меню
useEffect(() => {
if (!open) return;
const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
const targets = [
{ key: 'rsc', label: 'MikroTik', url: `${base}/rl/${slug}.rsc`, fname: `${slug}.rsc` },
{ key: 'keen', label: 'Keenetic (роутер)', url: `${base}/rl/${slug}.keenetic?gateway=${srvIp}`, fname: `${slug}.keenetic` },
{ key: 'bat', label: 'Keenetic/Windows', url: `${base}/rl/${slug}.bat?gateway=${srvIp}`, fname: `${slug}.bat` },
{ key: 'txt', label: 'Универсальный', url: `${base}/rl/${slug}.txt`, fname: `${slug}.txt` },
];
const copy = async (key, text) => {
try { await navigator.clipboard.writeText(text); }
catch {
const ta = document.createElement('textarea'); ta.value = text;
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch {}
document.body.removeChild(ta);
}
setCopiedKey(key); setTimeout(() => setCopiedKey(null), 1500);
};
const download = async (url, fname) => {
try {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const blob = await r.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = fname;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(a.href);
showToast(`Скачан ${fname}`, 'success'); setOpen(false);
} catch (e) { showToast('Ошибка: ' + e.message, 'error'); }
};
return (
setOpen(o => !o)}>
📤 Экспорт {open ? '▴' : '▾'}
{open && (
Экспорт для устройства
{targets.map(t => (
{t.label}
copy(t.key, t.url)}>
{copiedKey === t.key ? '✓' : '📋'}
download(t.url, t.fname)}>⬇️
))}
📋 — копировать ссылку для роутера · ⬇️ — скачать файл
)}
);
}
// =========================================================
// ROUTER LISTS (v2.206) — списки доменов/IP для MikroTik/Keenetic
// =========================================================
function RouterLists() {
const [lists, setLists] = useState([]);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(null); // объект списка или null
const [creating, setCreating] = useState(false);
const load = async () => {
try { setLists(await api.get('/api/router-lists')); }
catch (e) { showToast(e.message, 'error'); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const del = async (id, name) => {
if (!confirm(`Удалить список "${name}"?`)) return;
try { await api.del(`/api/router-lists/${id}`); showToast('Удалён', 'success'); load(); }
catch (e) { showToast(e.message, 'error'); }
};
if (loading) return ;
if (creating) return { setCreating(false); load(); }} />;
if (editing) return { setEditing(null); load(); }} />;
return (
Списки для роутеров
Списки доменов/IP которые MikroTik и Keenetic забирают по HTTP. Отдаются в LAN без пароля.
setCreating(true)}>+ Новый список
{lists.length === 0 ? (
Создайте список, добавьте домены/IP — и роутер сможет забирать его по ссылке вида
/rl/имя.rsc (MikroTik) или /rl/имя.txt (универсально).
) : (
lists.map(l => (
{l.title || l.name}
{l.entry_count} записей
/rl/{l.name}
{l.description && (
{l.description}
)}
setEditing(l)}>✏️ Изменить
del(l.id, l.name)}>🗑
))
)}
);
}
function RouterListEdit({ list, onBack }) {
const isNew = !list;
const [title, setTitle] = useState(list?.title || '');
const [name, setName] = useState(list?.name || '');
const [description, setDescription] = useState(list?.description || '');
const [listName, setListName] = useState(list?.list_name || 'vemitreya');
const [entries, setEntries] = useState(list?.entries || '');
const [copiedKey, setCopiedKey] = useState(null);
const base = (localStorage.getItem('mp_url') || location.origin).replace(/\/$/, '');
const slug = (name || title || 'list').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'list';
const save = async () => {
if (!title && !name) { showToast('Укажите имя списка', 'warning'); return; }
try {
if (isNew) {
await api.post('/api/router-lists', { title, name: name || title, description, list_name: listName, entries });
showToast('✓ Список создан', 'success');
} else {
await api.put(`/api/router-lists/${list.id}`, { title, description, list_name: listName, entries });
showToast('✓ Сохранено', 'success');
}
onBack();
} catch (e) { showToast(e.message, 'error'); }
};
const copy = async (key, text) => {
try { await navigator.clipboard.writeText(text); }
catch {
const ta = document.createElement('textarea'); ta.value = text;
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch {}
document.body.removeChild(ta);
}
setCopiedKey(key); setTimeout(() => setCopiedKey(null), 2000);
};
// скачать файл напрямую (fetch + blob)
const download = async (url, filename) => {
try {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const blob = await r.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a); a.click();
document.body.removeChild(a);
URL.revokeObjectURL(a.href);
showToast(`Скачан ${filename}`, 'success');
} catch (e) { showToast('Ошибка скачивания: ' + e.message, 'error'); }
};
const entryCount = entries.split('\n').filter(e => {
const t = e.trim(); return t && !t.startsWith('#') && !t.startsWith('//');
}).length;
// Готовые команды
const srvIp = (() => {
const saved = localStorage.getItem('mp_setup_serverip');
if (saved) return saved;
try { return new URL(base).hostname; } catch { return '192.168.1.1'; }
})();
const urlRsc = `${base}/rl/${slug}.rsc`;
const urlTxt = `${base}/rl/${slug}.txt`;
const urlBat = `${base}/rl/${slug}.bat?gateway=${srvIp}`;
const urlKeen = `${base}/rl/${slug}.keenetic?gateway=${srvIp}`;
const mikrotikScript = `# MikroTik: загрузка списка в address-list "${listName}"
# Выполните один раз вручную, затем настройте scheduler ниже
/tool fetch url="${urlRsc}" mode=http dst-path=vemitreya-${slug}.rsc
/import file-name=vemitreya-${slug}.rsc`;
const mikrotikScheduler = `# MikroTik scheduler — обновлять список каждый час
/system scheduler
add name="vemitreya-${slug}" interval=1h on-event="/tool fetch url=\\"${urlRsc}\\" mode=http dst-path=vemitreya-${slug}.rsc; /import file-name=vemitreya-${slug}.rsc"`;
const keeneticScript = `REM Keenetic / Windows: скачать .bat с маршрутами и применить
REM Формат: route add <сеть> mask <маска> <шлюз>
REM Шлюз 0.0.0.0 — замените на IP вашего VPN-шлюза, или укажите ?gateway= в URL
curl -o ${slug}.bat "${urlBat}"
${slug}.bat`;
return (
← Назад
{isNew ? '+ Новый список' : `Список: ${list.name}`}
{isNew ? 'Создать' : 'Сохранить'}
setTitle(e.target.value)}
placeholder="VPN маршруты" />
{isNew && (
setName(e.target.value)}
placeholder={slug} />
)}
setDescription(e.target.value)}
placeholder="Сайты которые идут через VPN" />
Имя address-list в который попадут IP на роутере. Если у вас уже есть готовый
список (например blocked_sites) — впишите его сюда, чтобы записи
добавлялись в него. По умолчанию vemitreya.
Отдельные списки (Google, YouTube, kino-pub...) нужны чтобы точечно
обновлять IP конкретного сервиса и загружать только нужные —
при этом все они могут лежать в одном address-list. Обновление одного списка
не трогает записи других (у каждого своя метка vemitreya:имя).
setListName(e.target.value)}
placeholder="vemitreya" />
{!isNew && (
<>
{[
['MikroTik (.rsc)', urlRsc, 'rsc', `${slug}.rsc`],
['Keenetic/Windows (.bat)', urlBat, 'bat', `${slug}.bat`],
['Keenetic CLI (.keenetic)', urlKeen, 'kndmc', `${slug}.keenetic`],
['Универсальный (.txt)', urlTxt, 'txt', `${slug}.txt`],
['JSON', `${base}/rl/${slug}.json`, 'json', `${slug}.json`],
].map(([label, url, key, fname]) => (
{label}
{url}
copy('url-' + key, url)}>
{copiedKey === 'url-' + key ? '✓' : '📋'}
download(url, fname)}>⬇️
))}
copy('mt1', mikrotikScript)} />
copy('mt2', mikrotikScheduler)} />
copy('kn1', keeneticScript)} />
Если у вас несколько списков (Google, YouTube, Telegram...) — роутер может забрать
их одним запросом, каждая запись будет подписана своим списком:
{[
['Все списки MikroTik', `${base}/rl/_all.rsc`, 'allrsc'],
['Все списки Keenetic', `${base}/rl/_all.bat`, 'allbat'],
['Выбранные (пример)', `${base}/rl/_all.bat?lists=google,youtube`, 'allsel'],
].map(([label, url, key]) => (
{label}
{url}
copy('url-' + key, url)}>
{copiedKey === 'url-' + key ? '✓' : '📋'}
))}
>
)}
{isNew && (
После создания здесь появятся готовые ссылки и скрипты для MikroTik и Keenetic
с подставленным адресом сервера.
)}
);
}
// Переиспользуемый блок кода со скриптом + кнопка копировать
// =========================================================
// ROUTERS PAGE (v2.206) — обёртка: Списки маршрутов + Настройка роутеров
// =========================================================
function RoutersPage() {
const [tab, setTab] = useState(() => localStorage.getItem('mp_routers_tab') || 'lists');
const setTabPersist = (t) => { setTab(t); localStorage.setItem('mp_routers_tab', t); };
return (
{[['lists', '📋 Списки маршрутов'], ['setup', '⚙️ Настройка роутеров']].map(([id, label]) => (
setTabPersist(id)}
style={{
padding: '8px 16px', fontSize: 13, fontWeight: tab === id ? 600 : 400,
background: 'none', border: 'none', cursor: 'pointer',
color: tab === id ? 'var(--accent)' : 'var(--text-2)',
borderBottom: tab === id ? '2px solid var(--accent)' : '2px solid transparent',
marginBottom: -1,
}}>
{label}
))}
{tab === 'lists' ?
:
}
);
}
// Полная инструкция настройки роутеров — подвкладки MikroTik / Keenetic
function RouterSetup() {
const [sub, setSub] = useState(() => localStorage.getItem('mp_routers_setup') || 'mikrotik');
const setSubPersist = (s) => { setSub(s); localStorage.setItem('mp_routers_setup', s); };
const base = (localStorage.getItem('mp_url') || location.origin).replace(/\/$/, '');
const autoIp = (() => { try { return new URL(base).hostname; } catch { return '192.168.1.1'; } })();
const [serverIp, setServerIp] = useState(() => localStorage.getItem('mp_setup_serverip') || autoIp);
const [listName, setListName] = useState(() => localStorage.getItem('mp_setup_listname') || 'vemitreya');
const setServerIpP = (v) => { setServerIp(v); localStorage.setItem('mp_setup_serverip', v); };
const setListNameP = (v) => { setListName(v); localStorage.setItem('mp_setup_listname', v); };
// какой список выгружать — конкретный (slug) или _all (все)
const [which, setWhich] = useState('_all');
const [lists, setLists] = useState([]);
useEffect(() => {
api.get('/api/router-lists').then(r => setLists(r || [])).catch(() => {});
}, []);
// URL-фрагмент: _all или конкретный slug
const listSlug = which; // '_all' или name списка
const params = { base, serverIp, listName, listSlug };
return (
Какой список выгружать
setWhich(e.target.value)}
style={{ fontSize: 13, width: '100%' }}>
Все списки (одним документом)
{lists.map(l => (
{l.title || l.name} ({l.entry_count})
))}
Команды ниже обновятся под выбранный список.
{[['mikrotik', 'MikroTik'], ['keenetic', 'Keenetic']].map(([id, label]) => (
setSubPersist(id)}
className={`btn btn-sm ${sub === id ? 'btn-primary' : ''}`}>
{label}
))}
{sub === 'mikrotik' ?
:
}
);
}
function MikrotikGuide({ base, serverIp, listName, listSlug = '_all' }) {
const [copiedKey, setCopiedKey] = useState(null);
// URL для fetch использует введённый IP сервера (подменяем хост в base)
const serverBase = (() => {
try { const u = new URL(base); u.hostname = serverIp; return u.origin; }
catch { return `http://${serverIp}:8888`; }
})();
const copy = async (key, text) => {
try { await navigator.clipboard.writeText(text); }
catch {
const ta = document.createElement('textarea'); ta.value = text;
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch {}
document.body.removeChild(ta);
}
setCopiedKey(key); setTimeout(() => setCopiedKey(null), 2000);
};
// ОДНОСТРОЧНЫЕ команды через ';' — RouterOS 7 ест их при вставке,
const fname = `vemitreya-${listSlug}.rsc`;
const fetchUrl = `${serverBase}/rl/${listSlug}.rsc`;
const scriptName = `vemitreya-${listSlug}`;
// единый подход — скрипт делает ВСЮ работу, запуск и планировщик
// только вызывают его. Логика в одном месте, не дублируется.
const createScript = `/system script add name=${scriptName} dont-require-permissions=yes source="/tool fetch url=\\"${fetchUrl}\\" mode=http dst-path=${fname}; :delay 3s; /import file-name=${fname}; /file remove [find name=${fname}]"`;
const runScript = `/system script run ${scriptName}`;
const schedScript = `/system scheduler add name=${scriptName} interval=1d on-event="/system script run ${scriptName}"`;
return (
Роутер направляет трафик к нужным сайтам не напрямую, а на сервер Vemitreya
({serverIp}) в вашей сети. На сервере Mihomo заворачивает его в AWG/TrustTunnel/Hysteria2.
Схема: список IP → address-list → mangle помечает пакеты → policy-route шлёт
помеченное на шлюз {serverIp}. Список обновляется автоматически по расписанию.
Сервер {serverIp} должен принимать транзитный трафик (IP-форвардинг + Mihomo TPROXY/redirect).
Если он уже работает шлюзом — дополнительно ничего не нужно.
copy('m1', `/ip firewall mangle\nadd chain=prerouting action=mark-routing new-routing-mark=to-vpn dst-address-list=${listName} passthrough=no comment="vemitreya routing"`)} />
copy('m2', `/ip route\nadd dst-address=0.0.0.0/0 gateway=${serverIp} routing-mark=to-vpn comment="vemitreya via server"`)} />
{serverIp} (gateway) — IP сервера Vemitreya в вашей LAN
{listName} (dst-address-list) — имя address-list вашего списка
to-vpn — имя routing-mark (произвольное, совпадает в шагах 1 и 2)
copy('m3', createScript)} />
Скачивает свежий список с сервера → ждёт 3 сек → импортирует (внутри:
удаляет старые IP этого списка по метке vemitreya:{listSlug}
и добавляет новые) → удаляет временный файл. Создаётся один раз, дальше его
вызывают Шаг 4 и планировщик.
copy('m4', runScript)} />
copy('m5', schedScript)} />
/ip firewall address-list print where list={listName} — загруженные адреса.
/ip route print where routing-mark=to-vpn — маршрут на {serverIp}.
На клиенте LAN: tracert youtube.com — первый хоп должен быть {serverIp}.
);
}
function KeeneticGuide({ base, serverIp, listSlug = '_all' }) {
const [copiedKey, setCopiedKey] = useState(null);
const copy = async (key, text) => {
try { await navigator.clipboard.writeText(text); }
catch {
const ta = document.createElement('textarea'); ta.value = text;
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch {}
document.body.removeChild(ta);
}
setCopiedKey(key); setTimeout(() => setCopiedKey(null), 2000);
};
// URL берёт введённый IP сервера + выбранный список
const serverBase = (() => {
try { const u = new URL(base); u.hostname = serverIp; return u.origin; }
catch { return `http://${serverIp}:8888`; }
})();
const urlKeen = `${serverBase}/rl/${listSlug}.keenetic?gateway=${serverIp}`;
const cliExample = `ip route 142.250.0.0 255.255.0.0 ${serverIp}\nip route 157.240.0.0 255.240.0.0 ${serverIp}\nsystem configuration save`;
const entwareScript = `cat > /opt/etc/vemitreya-routes.sh << 'EOF'
#!/bin/sh
# Vemitreya — маршруты через сервер ${serverIp} (там Mihomo завернёт в VPN)
SERVER="${serverIp}"
URL="${serverBase}/rl/${listSlug}.keenetic?gateway=\${SERVER}"
curl -fsS "\$URL" -o /opt/tmp/vroutes.txt || exit 1
while read -r line; do
[ -n "\$line" ] && ndmc -c "\$line"
done < /opt/tmp/vroutes.txt
ndmc -c "system configuration save"
EOF
chmod +x /opt/etc/vemitreya-routes.sh
/opt/etc/vemitreya-routes.sh`;
const cronScript = `echo "0 * * * * /opt/etc/vemitreya-routes.sh" >> /opt/etc/crontab\n/opt/etc/init.d/S10cron restart`;
return (
Так же как MikroTik у вас: роутер направляет трафик к нужным сайтам не напрямую, а на
сервер Vemitreya ({serverIp}) в вашей сети. На сервере Mihomo уже
заворачивает этот трафик в AWG/TrustTunnel/Hysteria2. Keenetic не поднимает
VPN сам — он лишь добавляет статические маршруты «эти подсети → шлюз {serverIp}».
На сервере Vemitreya ({serverIp}) должны быть включены: IP-форвардинг и приём
транзитного трафика (Mihomo TPROXY/redirect). Если сервер уже работает шлюзом для
MikroTik — для Keenetic ничего дополнительно настраивать не нужно, тот же шлюз.
Шаг 1. Убедитесь что сервер доступен
Сервер {serverIp} должен быть в одной сети с Keenetic и пинговаться. Проверьте с роутера:
ping {serverIp}.
Подключитесь к Keenetic и добавьте маршруты вручную. Шлюз — IP сервера {serverIp}.
copy('k1', cliExample)} />
Как у MikroTik: роутер сам забирает свежий список с сервера по расписанию.
Нужен Entware (OPKG) на Keenetic — «Управление» → «Приложения», либо
через USB. Скрипт скачивает маршруты в формате CLI Keenetic (шлюз = {serverIp}) и применяет.
copy('k2', entwareScript)} />
copy('k3', cronScript)} />
{serverIp} — IP сервера Vemitreya в LAN (подставлен автоматически из адреса панели)
Сервер раздаёт {urlKeen} — готовые команды ip route СЕТЬ МАСКА {serverIp}
?lists=google,youtube — забрать только выбранные списки
ndmc -c "show ip route" — увидеть маршруты со шлюзом {serverIp}.
На клиенте: tracert youtube.com — первый хоп должен быть {serverIp}.
);
}
function SnippetBlock({ title, text, copied, onCopy }) {
return (
{title}
{copied ? '✓ Скопировано' : '📋 Копировать'}
{text}
);
}
// стильный «терминальный» блок кода с заголовком, нумерацией строк
// и кнопкой копировать. Для пошаговых команд (Настройка роутеров).
function TerminalBlock({ title, text, copied, onCopy }) {
const lines = String(text).split('\n');
const gutterW = String(lines.length).length;
return (
{/* Заголовок-бар */}
{title}
{copied ? '✓ Скопировано' : 'Копировать'}
{/* Тело с нумерацией */}
{lines.map((ln, i) => (
{i + 1}
{ln || ' '}
))}
);
}
// =========================================================
// APP SHELL
// =========================================================
// =========================================================
// SERVERS — управление TrustTunnel endpoint (серверная сторона)
// =========================================================
function ServersPage() {
const [info, setInfo] = useState(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [configs, setConfigs] = useState([]);
const [activeFile, setActiveFile] = useState(null);
const [edited, setEdited] = useState('');
const [logs, setLogs] = useState('');
const [logsLoading, setLogsLoading] = useState(false);
const [installing, setInstalling] = useState(false);
const [installLog, setInstallLog] = useState('');
const pollRef = useRef(null);
const load = async () => {
try {
const st = await api.get('/api/server/tt-endpoint/status');
setInfo(st);
if (st.installed) {
const cfg = await api.get('/api/server/tt-endpoint/configs');
setConfigs(cfg.configs || []);
if (!activeFile && cfg.configs?.length) {
const first = cfg.configs.find(c => c.exists) || cfg.configs[0];
setActiveFile(first.file);
setEdited(first.content || '');
}
}
} catch (e) { showToast(e.message, 'error'); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
useEffect(() => () => clearInterval(pollRef.current), []);
const pickFile = (f) => {
const c = configs.find(x => x.file === f);
setActiveFile(f);
setEdited(c?.content || '');
};
const saveConfig = async () => {
setBusy(true);
try {
await api.put('/api/server/tt-endpoint/config', { file: activeFile, content: edited });
showToast(`✓ ${activeFile} сохранён`, 'success');
await load();
if (confirm('Перезапустить endpoint, чтобы применить изменения?')) await doAction('restart');
} catch (e) { showToast(e.message, 'error'); }
finally { setBusy(false); }
};
const doAction = async (action) => {
setBusy(true);
try {
const r = await api.post('/api/server/tt-endpoint/action', { action });
if (r.ok) showToast(`✓ ${action}: ${r.active ? 'active' : 'inactive'}`, r.active || action === 'stop' ? 'success' : 'error');
else showToast(`Ошибка: ${r.error}`, 'error');
await load();
} catch (e) { showToast(e.message, 'error'); }
finally { setBusy(false); }
};
const loadLogs = async () => {
setLogsLoading(true);
try {
const r = await api.get('/api/server/tt-endpoint/logs?lines=200');
setLogs(r.logs || '(пусто)');
} catch (e) { showToast(e.message, 'error'); }
finally { setLogsLoading(false); }
};
const install = async () => {
if (!confirm('Установить/обновить TrustTunnel endpoint в /opt/trusttunnel?')) return;
setInstalling(true); setInstallLog('Запуск...');
try {
const { job_id } = await api.post('/api/server/tt-endpoint/install', {});
pollRef.current = setInterval(async () => {
try {
const j = await api.get(`/api/install/jobs/${job_id}`);
setInstallLog((j.logs || []).join('\n'));
if (j.status !== 'running') {
clearInterval(pollRef.current);
setInstalling(false);
showToast(j.status === 'done' && j.rc === 0 ? '✓ Установлено' : `Завершено: ${j.status} (rc=${j.rc})`,
j.rc === 0 ? 'success' : 'error');
load();
}
} catch {}
}, 1500);
} catch (e) { setInstalling(false); showToast(e.message, 'error'); }
};
if (loading) return ;
if (!info) return null;
// --- Не установлен ---
if (!info.installed) {
return (
TrustTunnel endpoint
Endpoint не установлен. Установка скачает официальный пакет
(TrustTunnel/TrustTunnel) в /opt/trusttunnel и создаст
systemd-сервис trusttunnel.service из шаблона.
После установки настройте конфиг (или используйте setup_wizard в каталоге).
{installing ? '⏳ Установка...' : '⬇️ Установить endpoint'}
{installLog && (
{installLog}
)}
);
}
// --- Установлен ---
return (
TrustTunnel endpoint
{info.version} · listen {info.listen || '—'} ·{' '}
{info.active ? 'running' : 'stopped'}
{' '}
{info.enabled ? '· enabled' : '· disabled'}
doAction('restart')} disabled={busy}>↻ Рестарт
{info.active
? doAction('stop')} disabled={busy}>■ Стоп
: doAction('start')} disabled={busy}>▶ Старт }
{info.dir} · setup_wizard: {info.has_setup_wizard ? 'есть' : 'нет'}
{/* Конфиги */}
Конфигурация
{configs.map(c => (
pickFile(c.file)}>
{c.file}{!c.exists && ' ∅'}
))}
{/* Логи */}
Логи (journalctl)
{logsLoading ? '⏳' : '↻ Обновить'}
{logs || 'Нажмите «Обновить» для загрузки логов.'}
{installing ? '⏳ Обновление...' : '⬆️ Переустановить / обновить endpoint'}
{installLog && (
{installLog}
)}
);
}
// WireGuard входящий (wg0) — карточка во вкладке «Серверы»
function WireGuardCard() {
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState(false);
const [conf, setConf] = useState('');
const load = async () => {
try { setInfo(await api.get('/api/server/wg/status')); }
catch (e) { showToast(e.message, 'error'); }
};
useEffect(() => { load(); }, []);
const openConfig = async () => {
try {
const r = await api.get('/api/server/wg/config');
setConf(r.content || '');
setEditing(true);
} catch (e) { showToast(e.message, 'error'); }
};
const saveConfig = async () => {
setBusy(true);
try {
await api.put('/api/server/wg/config', { content: conf });
showToast('✓ wg0.conf сохранён', 'success');
setEditing(false);
if (confirm('Перезапустить WireGuard (wg0), чтобы применить?')) await act('restart');
else load();
} catch (e) { showToast(e.message, 'error'); }
finally { setBusy(false); }
};
const act = async (action) => {
setBusy(true);
try {
const r = await api.post('/api/server/wg/action', { action });
if (r.ok) showToast(`✓ ${action}: ${r.active ? 'up' : 'down'}`, 'success');
else showToast(`Ошибка: ${r.error}`, 'error');
await load();
} catch (e) { showToast(e.message, 'error'); }
finally { setBusy(false); }
};
const downloadClient = async (path) => {
try {
const r = await api.get(`/api/server/wg/client-config?path=${encodeURIComponent(path)}`);
const blob = new Blob([r.content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = r.name || 'wg-client.conf';
document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
} catch (e) { showToast(e.message, 'error'); }
};
if (!info) return null;
const fmtAge = (ts) => {
if (!ts) return 'нет handshake';
const s = Math.floor(Date.now() / 1000) - ts;
if (s < 60) return `${s} с назад`;
if (s < 3600) return `${Math.floor(s / 60)} мин назад`;
return `${Math.floor(s / 3600)} ч назад`;
};
const fmtBytes = (b) => b > 1048576 ? `${(b / 1048576).toFixed(1)} MB` : b > 1024 ? `${(b / 1024).toFixed(0)} KB` : `${b} B`;
return (
WireGuard (входящий, {info.iface})
{info.installed
? <>порт {info.listen_port || '—'} · {info.active ? 'up' : 'down'} · пиров: {info.peers.length}>
: wireguard не установлен }
{info.installed && (
↻
act('restart')} disabled={busy}>↻ Рестарт
{info.active
? act('down')} disabled={busy}>■ Down
: act('up')} disabled={busy}>▶ Up }
)}
{info.installed && info.peers.length > 0 && (
Пир (pubkey)
AllowedIPs
Handshake
↓ / ↑
{info.peers.map(p => (
{p.pubkey.slice(0, 16)}…
{p.allowed_ips}
{fmtAge(p.latest_handshake)}
{fmtBytes(p.rx)} / {fmtBytes(p.tx)}
))}
)}
{info.installed && (
📄 {info.iface}.conf
{(info.client_configs || []).map(p => (
downloadClient(p)}>
⬇️ {p.split('/').pop()}
))}
)}
{editing && (
)}
);
}
function LogCollector() {
const [items, setItems] = useState([]); // [{ip, included}]
const [info, setInfo] = useState({}); // {plainIp: {country, cc, org, as}}
const [meta, setMeta] = useState({ total: 0, checked: 0, active: false, group: null });
const [loading, setLoading] = useState(true);
const [scanning, setScanning] = useState(false);
const [resolving, setResolving] = useState(false);
const [applying, setApplying] = useState(false);
const [targets, setTargets] = useState([]);
const [group, setGroup] = useState('');
const [ignored, setIgnored] = useState([]);
const [showIgnored, setShowIgnored] = useState(false);
const load = async () => {
try {
const [d, t, ig] = await Promise.all([
api.get('/api/logcollector/list'),
api.get('/api/mihomo/rules/targets').catch(() => ({ targets: [] })),
api.get('/api/logcollector/ignored').catch(() => ({ ips: [], geo: {} })),
]);
setItems(d.items || []);
setMeta({ total: d.total, checked: d.checked, active: d.active, group: d.group });
setIgnored(ig.ips || []);
// показать кэш расшифровок сразу (и для пула, и для игнор-листа)
setInfo(prev => ({ ...prev, ...(d.geo || {}), ...(ig.geo || {}) }));
setTargets(t.targets || []);
setGroup(g => d.group || g || (t.targets || []).find(x => /vpn/i.test(x)) || (t.targets || [])[0] || '');
} catch (e) { showToast(e.message, 'error'); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
// пока вкладка открыта — копим домены из активных соединений mihomo (best-effort)
useEffect(() => {
const i = setInterval(() => {
api.post('/api/logcollector/collect-domains', {})
.then(r => { if (r && r.new) load(); }).catch(() => {});
}, 8000);
return () => clearInterval(i);
}, []);
const scan = async () => {
setScanning(true);
try {
const r = await api.post('/api/logcollector/scan', {});
const src = r.sources ? ' (' + Object.entries(r.sources).map(([k, v]) => `${k}: ${v}`).join(', ') + ')' : '';
showToast(`Найдено ${r.found}${src}, новых ${r.added_count}, всего ${r.total}`, 'success');
load();
} catch (e) { showToast(e.message, 'error'); }
finally { setScanning(false); }
};
const resolve = async () => {
if (!items.length) return;
setResolving(true);
try {
const r = await api.post('/api/logcollector/resolve', { ips: items.map(i => i.ip) });
setInfo(r.info || {});
showToast('Расшифровано', 'success');
} catch (e) { showToast(e.message, 'error'); }
finally { setResolving(false); }
};
const toggle = async (ip) => {
const next = items.map(i => i.ip === ip ? { ...i, included: !i.included } : i);
setItems(next);
try { await api.post('/api/logcollector/set-checked', { ips: next.filter(i => i.included).map(i => i.ip) }); }
catch (e) { showToast(e.message, 'error'); load(); }
};
const removeIp = async (ip) => {
try { await api.post('/api/logcollector/remove', { ips: [ip] }); load(); }
catch (e) { showToast(e.message, 'error'); }
};
const ignore = async (ip) => {
try { await api.post('/api/logcollector/ignore', { ips: [ip] }); load(); }
catch (e) { showToast(e.message, 'error'); }
};
const unignore = async (ip) => {
try { await api.post('/api/logcollector/unignore', { ips: [ip] }); load(); }
catch (e) { showToast(e.message, 'error'); }
};
const clear = async () => {
if (!confirm('Очистить весь собранный список IP?')) return;
try { await api.del('/api/logcollector/clear'); setInfo({}); load(); }
catch (e) { showToast(e.message, 'error'); }
};
const apply = async () => {
if (!group) { showToast('Выберите группу назначения', 'warning'); return; }
const checked = items.filter(i => i.included).map(i => i.ip);
if (!checked.length) { showToast('Отметьте хотя бы один IP', 'warning'); return; }
setApplying(true);
try {
await api.post('/api/logcollector/set-checked', { ips: checked });
await api.post('/api/logcollector/apply', { group });
showToast(`✓ ${checked.length} IP направлены в «${group}»`, 'success');
load();
} catch (e) { showToast(e.message, 'error'); }
finally { setApplying(false); }
};
const unapply = async () => {
if (!confirm('Убрать правило? Собранные IP вернутся к DIRECT.')) return;
setApplying(true);
try { await api.post('/api/logcollector/unapply', {}); load(); }
catch (e) { showToast(e.message, 'error'); }
finally { setApplying(false); }
};
const flag = (cc) => cc && cc.length === 2
? String.fromCodePoint(...[...cc.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65)) : '';
if (loading) return ;
const checkedCount = items.filter(i => i.included).length;
return (
Сканирует журнал Mihomo и собирает IP с dial DIRECT … i/o timeout (не
достучаться напрямую — обычно заблокированы). Сними галочку , чтобы IP НЕ уходил
в VPN. «Расшифровать» покажет страну и организацию. «Отправить в VPN» направит
отмеченные IP в выбранную группу (RULE-SET); остальной трафик не
затрагивается. Для каскада туннелей выбери fallback-группу.
{scanning ? <> Скан…> : '🔍 Сканировать журнал'}
{resolving ? 'Расшифровка…' : '🌍 Расшифровать'}
Всего: {meta.total} · отмечено: {checkedCount}
{meta.active && ✓ в «{meta.group}» }
{meta.total > 0 &&
Очистить список }
Группа назначения
setGroup(e.target.value)}>
{!targets.length && нет групп }
{targets.map(t => {t} )}
{applying ? '…' : `Отправить в VPN (${checkedCount})`}
{meta.active &&
Убрать }
{items.length === 0
?
Список пуст — нажмите «Сканировать журнал»
:
{items.map(it => {
const nfo = info[it.ip.split('/')[0]];
return (
toggle(it.ip)} />
{it.is_new && NEW }
{it.ip}
{it.domain && {it.domain} }
{it.domain && nfo && !nfo.error ? ' · ' : ''}
{nfo && !nfo.error
? <>{flag(nfo.cc)} {nfo.country} · {nfo.org}{nfo.as ? ` · ${nfo.as}` : ''}>
: (!it.domain && nfo ? не определено : null)}
{ e.preventDefault(); ignore(it.ip); }}
title="Игнорировать — убрать и больше не собирать">🚫
{ e.preventDefault(); removeIp(it.ip); }}
title="Удалить из списка (вернётся при следующем скане)"
style={{ color: 'var(--error)' }}>×
);
})}
}
{/* Игнорируемые */}
setShowIgnored(s => !s)}>
{showIgnored ? '▼' : '▶'} Игнорируемые ({ignored.length})
{showIgnored && (ignored.length === 0
?
Список игнорируемых пуст
:
{ignored.map(ip => {
const nfo = info[ip.split('/')[0]];
return (
{ip}
{nfo && !nfo.error ? <>{flag(nfo.cc)} {nfo.country} · {nfo.org}> : null}
unignore(ip)}>↩ Вернуть
);
})}
)}
);
}
function Remotes() {
const [remotes, setRemotes] = useState([]);
const [loading, setLoading] = useState(true);
const [adding, setAdding] = useState(false);
const [form, setForm] = useState({ name: '', host: '', ssh_user: 'root', description: '' });
const [health, setHealth] = useState({}); // { id: {...} }
const [busy, setBusy] = useState({}); // { id: 'health'|'config'|'bin' }
const [logs, setLogs] = useState({}); // { id: {open, svc, text, services, loading} }
const load = async () => {
try { setRemotes(await api.get('/api/remotes')); }
catch (e) { showToast(e.message, 'error'); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const add = async () => {
if (!form.name.trim() || !form.host.trim()) { showToast('Имя и host обязательны', 'warning'); return; }
try {
await api.post('/api/remotes', form);
setForm({ name: '', host: '', ssh_user: 'root', description: '' });
setAdding(false); load(); showToast('Сервер добавлен', 'success');
} catch (e) { showToast(e.message, 'error'); }
};
const del = async (r) => {
if (!confirm(`Убрать ${r.name} из списка? (сам сервер не трогается)`)) return;
try { await api.del(`/api/remotes/${r.id}`); load(); }
catch (e) { showToast(e.message, 'error'); }
};
const check = async (r) => {
setBusy(b => ({ ...b, [r.id]: 'health' }));
try {
const hh = await api.get(`/api/remotes/${r.id}/health`);
setHealth(s => ({ ...s, [r.id]: hh }));
}
catch (e) { setHealth(s => ({ ...s, [r.id]: { reachable: false, error: e.message } })); }
finally { setBusy(b => ({ ...b, [r.id]: null })); }
};
const pushConfig = async (r) => {
if (!confirm(`Скопировать конфиг этого сервера на ${r.name} (${r.host})? Его mihomo перезапустится.`)) return;
setBusy(b => ({ ...b, [r.id]: 'config' }));
try {
const res = await api.post(`/api/remotes/${r.id}/push-config`, {});
showToast(`✓ ${r.name}: ${res.result || 'конфиг обновлён'}`, 'success'); check(r);
} catch (e) { showToast(`${r.name}: ${e.message}`, 'error'); }
finally { setBusy(b => ({ ...b, [r.id]: null })); }
};
const pushBin = async (r) => {
if (!confirm(`Скопировать бинарники (mihomo + trusttunnel_endpoint) на ${r.name}? Сервисы перезапустятся.`)) return;
setBusy(b => ({ ...b, [r.id]: 'bin' }));
try {
const res = await api.post(`/api/remotes/${r.id}/push-binaries`, {});
showToast(`✓ ${r.name}: ${(res.pushed || []).join(', ') || 'готово'}`, 'success'); check(r);
} catch (e) { showToast(`${r.name}: ${e.message}`, 'error'); }
finally { setBusy(b => ({ ...b, [r.id]: null })); }
};
const showLogs = async (r, svc) => {
const service = svc || logs[r.id]?.svc || 'mihomo';
setLogs(s => ({ ...s, [r.id]: { ...(s[r.id] || {}), open: true, svc: service, loading: true } }));
try {
const d = await api.get(`/api/remotes/${r.id}/logs?service=${encodeURIComponent(service)}&lines=200`);
setLogs(s => ({ ...s, [r.id]: { open: true, svc: service, text: d.logs, services: d.services, loading: false } }));
} catch (e) {
setLogs(s => ({ ...s, [r.id]: { ...(s[r.id] || {}), open: true, loading: false, text: e.message } }));
}
};
const closeLogs = (r) => setLogs(s => ({ ...s, [r.id]: { ...(s[r.id] || {}), open: false } }));
const logHl = (l) => {
if (/error|failed|fatal/i.test(l)) return 'log-error';
if (/warn/i.test(l)) return 'log-warn';
if (/success|started|listening|handshake/i.test(l)) return 'log-success';
if (/info/i.test(l)) return 'log-info';
return '';
};
const badge = (v) => {v || '?'} ;
const box = { border: '1px solid var(--border)', borderRadius: 6, padding: 14 };
if (loading) return ;
return (
Управление другими фронт-серверами по SSH с этого сервера. Проверить — статусы
mihomo / WireGuard / TrustTunnel. Обновить конфиг — копирует /opt/mihomo/config/
этого сервера на удалённый (его secret сохраняется) и перезапускает mihomo.
Бинарники — копирует mihomo + trusttunnel_endpoint и рестартует сервисы.
Серверов: {remotes.length}
setAdding(a => !a)}>
{adding ? 'Отмена' : '+ Добавить сервер'}
{adding && (
setForm(f => ({ ...f, name: e.target.value }))} />
setForm(f => ({ ...f, host: e.target.value }))} />
setForm(f => ({ ...f, ssh_user: e.target.value }))} />
setForm(f => ({ ...f, description: e.target.value }))} />
Добавить
)}
{remotes.length === 0
?
Нет серверов — добавьте первый
:
{remotes.map(r => {
const h = health[r.id], b = busy[r.id];
return (
{r.name}
{r.ssh_user}@{r.host}
{r.description &&
{r.description}
}
del(r)}>удалить
{h && (
{h.reachable === false
?
✗ недоступен: {h.error}
:
mihomo {badge(h.mihomo)}
wg {badge(h.wg)}
TT {badge(h.trusttunnel)}
{h.version} · {h.config_lines} строк конфига
}
)}
check(r)} disabled={!!b}>
{b === 'health' ? '…' : '🩺 Проверить статус'}
pushConfig(r)} disabled={!!b}>
{b === 'config' ? 'Копирую…' : '⬆ Обновить конфиг'}
pushBin(r)} disabled={!!b}>
{b === 'bin' ? 'Копирую…' : '📦 Скопировать бинарники'}
showLogs(r)}>📜 Получить логи
{logs[r.id]?.open && (
showLogs(r, e.target.value)}>
{(logs[r.id].services || ['mihomo', 'trusttunnel', 'wg-quick@wg0', 'vemitreya']).map(s =>
{s} )}
showLogs(r, logs[r.id].svc)}>↻
closeLogs(r)}>Скрыть
{logs[r.id].loading && загрузка… }
{(logs[r.id].text || '').split('\n').map((l, i) =>
{l}
)}
)}
);
})}
}
);
}
function AppVersion() {
// Версия панели из API (PANEL_VERSION на backend) — не хардкод, всегда актуальна.
const [v, setV] = useState(null);
useEffect(() => {
api.get('/api/panel/version').then(d => setV(d.current)).catch(() => {});
}, []);
return v{v || '…'}
;
}
function App() {
const [auth, setAuth] = useState(false);
// Запоминаем активную вкладку — чтобы после reload не падать на dashboard.
// миграция старых mp_tab значений в новую плоскую структуру.
const [tab, setTab] = useState(() => {
// Приоритет: URL hash → localStorage → dashboard. У групп hash вида #tunnels/proxies —
// берём первый сегмент (раздел), под-вкладку выберет TabbedPage из того же hash.
const fromHash = window.location.hash.replace(/^#\/?/, '').split('/')[0];
const saved = fromHash || localStorage.getItem('mp_tab') || 'dashboard';
const TOP_LEVEL = ['dashboard','connections','switch','groups','providers',
'rules','speedtest','tunnels','servers','system','router-lists','logs'];
if (TOP_LEVEL.includes(saved)) return saved;
// под-вкладки и старые id → их раздел
const MIGRATE = {
'awg': 'tunnels', 'tt': 'tunnels', 'proxies': 'tunnels',
'geo': 'rules', 'quick': 'rules',
'services': 'system', 'dns': 'system', 'updates': 'system',
'backup': 'system', 'telegram': 'system', 'cfg-mihomo': 'system',
'diagnostics': 'speedtest', 'health': 'speedtest',
};
return MIGRATE[saved] || 'dashboard';
});
const [theme, setTheme] = useState(() => localStorage.getItem('mp_theme') || 'dark');
useEffect(() => {
document.body.setAttribute('data-theme', theme);
localStorage.setItem('mp_theme', theme);
}, [theme]);
// Сохраняем раздел (localStorage) + отражаем в URL. Под-вкладку групп в hash
// пишет TabbedPage (#tunnels/proxies), поэтому трогаем hash только если сменился
// сам раздел (первый сегмент) — иначе затёрли бы под-вкладку.
useEffect(() => {
localStorage.setItem('mp_tab', tab);
const firstSeg = window.location.hash.replace(/^#\/?/, '').split('/')[0];
if (firstSeg !== tab) {
history.replaceState(null, '', '#' + tab);
}
}, [tab]);
// Навигация по URL: back/forward и ручной ввод #tab меняют раздел
useEffect(() => {
const onHash = () => {
const h = window.location.hash.replace(/^#\/?/, '').split('/')[0];
if (h) setTab(h);
};
window.addEventListener('hashchange', onHash);
return () => window.removeEventListener('hashchange', onHash);
}, []);
useEffect(() => {
const url = localStorage.getItem('mp_url');
const token = localStorage.getItem('mp_token');
if (url && token) {
api.configure(url, token);
api.get('/api/auth/check').then(() => setAuth(true)).catch(() => {});
}
}, []);
if (!auth) return setAuth(true)} />;
// Одиночные страницы + разделы-группы с под-вкладками.
// URL: одиночные → #id; группы → #id/subtab (под-вкладку пишет TabbedPage).
const sidebar = [
{ id: 'dashboard', icon: 'dashboard', label: 'Дашборд', kind: 'single', C: Dashboard },
{ id: 'connections', icon: 'activity', label: 'Соединения', kind: 'single', C: Connections },
{ id: 'switch', icon: 'shuffle', label: 'Переключение', kind: 'single', C: ProxySwitcher },
{ id: 'groups', icon: 'layers', label: 'Группы', kind: 'single', C: ProxyGroupsPage },
{ id: 'providers', icon: 'satellite', label: 'Подписки', kind: 'single', C: Providers },
{ id: 'rules', icon: 'list', label: 'Правила', kind: 'group', tabs: [
{ id: 'list', label: '📋 Список правил', C: RulesPage },
{ id: 'geo', label: '🌍 GEO (категории и страны)', C: GeoSitePanel },
{ id: 'collector', label: '🛡 Сборщик (timeout→VPN)', C: LogCollector },
{ id: 'dns', label: '🌐 DNS Mihomo', C: MihomoDNSPanel },
]},
{ id: 'speedtest', icon: 'gauge', label: 'Speedtest', kind: 'single', C: Speedtest },
{ id: 'tunnels', icon: 'shield', label: 'Туннели', kind: 'group', tabs: [
{ id: 'awg', label: 'AWG', C: AWGTunnels },
{ id: 'tt', label: 'TrustTunnel', C: TrustTunnels },
{ id: 'proxies', label: 'Прокси', C: MihomoProxies },
]},
{ id: 'servers', icon: 'route', label: 'Серверы', kind: 'single', C: ServersPage },
{ id: 'system', icon: 'sliders', label: 'Система', kind: 'group', tabs: [
{ id: 'services', label: 'Сервисы', C: Services },
{ id: 'remotes', label: '🛰 Remotes', C: Remotes },
{ id: 'updates', label: 'Обновления', C: MihomoUpdater },
{ id: 'backup', label: 'Бэкап', C: BackupRestore },
{ id: 'telegram', label: 'Telegram', C: TelegramSettings },
{ id: 'cfg-mihomo', label: 'Mihomo YAML', C: MihomoConfigEditor },
]},
{ id: 'router-lists', icon: 'list', label: 'Настройка роутеров', kind: 'single', C: RoutersPage },
{ id: 'logs', icon: 'terminal', label: 'Логи', kind: 'single', C: LogsView },
];
// Find current page
const cur = sidebar.find(i => i.id === tab) || sidebar[0];
const logout = () => {
if (!confirm('Выйти?')) return;
localStorage.removeItem('mp_token');
setAuth(false);
};
return (
{cur.kind === 'single' ? (
) : (
)}
);
}
// TabbedPage — раздел с под-вкладками. URL вида #pageId/tabId, под-вкладка
// пишется/читается из hash (прямые ссылки, back/forward работают).
function TabbedPage({ pageId, tabs }) {
const storageKey = `mp_subtab_${pageId}`;
const getInitialTab = () => {
const hash = window.location.hash.replace(/^#\/?/, '');
const [hPage, hTab] = hash.split('/');
if (hPage === pageId && hTab && tabs.some(t => t.id === hTab)) return hTab;
const saved = localStorage.getItem(storageKey);
if (saved && tabs.some(t => t.id === saved)) return saved;
return tabs[0].id;
};
const [active, setActive] = useState(getInitialTab);
useEffect(() => {
localStorage.setItem(storageKey, active);
const newHash = `${pageId}/${active}`;
if (window.location.hash.replace(/^#\/?/, '') !== newHash) {
history.replaceState(null, '', '#' + newHash);
}
}, [active, pageId, storageKey]);
// Смена раздела (pageId) — берём её сохранённую/hash под-вкладку
useEffect(() => {
setActive(getInitialTab());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageId]);
const activeTab = tabs.find(t => t.id === active) || tabs[0];
const Comp = activeTab.C;
return (
{tabs.map(t => (
setActive(t.id)}>
{t.label}
))}
);
}
ReactDOM.createRoot(document.getElementById('root')).render( );