feat: добавлены новые компоненты для управления правилами и серверами
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 25s

- Создан компонент RuleEditorDrawer для редактирования правил с поддержкой JSON.
- Добавлен компонент ServersPage для отображения и управления серверами.
- Реализован компонент SettingsPage для управления подписками и конфигурациями.
- Создан компонент Sidebar для навигации по приложению.
- Добавлен компонент StatusPane для отображения статуса сервера.
- Реализован компонент Toasts для отображения уведомлений.
- Создан компонент Topbar для отображения информации о текущем состоянии.
- Добавлен модуль country.js для определения страны по тегу сервера.

Refs: None
This commit is contained in:
2026-05-08 19:31:49 +03:00
parent a8f2c6f3f9
commit 8476ab16e5
27 changed files with 3014 additions and 1139 deletions

View File

@@ -0,0 +1,61 @@
import React, { useState } from 'react';
/**
* Chip input. Items separated by Enter, comma, или space (для CIDR/портов).
* Невалидные элементы помечаются красным.
*/
export function ChipsInput({ value = [], onChange, placeholder = '', validate, splitter = /[\s,]/ }) {
const [draft, setDraft] = useState('');
function commit(text) {
const parts = String(text).split(splitter).map((p) => p.trim()).filter(Boolean);
if (!parts.length) return;
const next = Array.from(new Set([...value, ...parts]));
onChange(next);
setDraft('');
}
function remove(item) {
onChange(value.filter((v) => v !== item));
}
function onKeyDown(e) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
if (draft.trim()) commit(draft);
} else if (e.key === 'Backspace' && !draft && value.length) {
onChange(value.slice(0, -1));
}
}
function onPaste(e) {
const text = e.clipboardData.getData('text');
if (text && splitter.test(text)) {
e.preventDefault();
commit(text);
}
}
return (
<div className="chips" onClick={(e) => e.currentTarget.querySelector('input')?.focus()}>
{value.map((item) => {
const invalid = validate ? !validate(item) : false;
return (
<span key={item} className={`chip ${invalid ? 'error' : ''}`}>
{item}
<button type="button" onClick={() => remove(item)} title="Убрать">×</button>
</span>
);
})}
<input
className="chip-input"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onKeyDown}
onPaste={onPaste}
onBlur={() => draft.trim() && commit(draft)}
placeholder={value.length ? '' : placeholder}
/>
</div>
);
}

View File

@@ -1,32 +1,37 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
export function ConfigViewer({ open, onClose }) {
const [config, setConfig] = useState(null);
const [error, setError] = useState('');
const [search, setSearch] = useState('');
useEffect(() => {
if (!open) return;
let cancelled = false;
api
.config()
.then((data) => {
if (!cancelled) setConfig(data.config);
})
.catch((err) => !cancelled && setError(err.message));
return () => {
cancelled = true;
};
setConfig(null);
setError('');
api.config()
.then((data) => { if (!cancelled) setConfig(data.config); })
.catch((err) => { if (!cancelled) setError(err.message); });
return () => { cancelled = true; };
}, [open]);
const text = useMemo(() => (config ? JSON.stringify(config, null, 2) : ''), [config]);
const highlighted = useMemo(() => {
if (!search || !text) return text;
try {
const re = new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
return text.split(re);
} catch {
return text;
}
}, [text, search]);
if (!open) return null;
const text = config ? JSON.stringify(config, null, 2) : '';
function copy() {
navigator.clipboard?.writeText(text).catch(() => {});
}
function copy() { navigator.clipboard?.writeText(text).catch(() => {}); }
function download() {
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
@@ -39,24 +44,41 @@ export function ConfigViewer({ open, onClose }) {
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h3>Текущий конфиг sing-box</h3>
<div className="rules-actions">
<button className="ghost-button" type="button" disabled={!config} onClick={copy}>
Скопировать
</button>
<button className="ghost-button" type="button" disabled={!config} onClick={download}>
Скачать
</button>
<button className="ghost-button solid" type="button" onClick={onClose}>
Закрыть
</button>
<div className="modal lg" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<div>
<h3>sing-box config</h3>
<small className="muted">Автогенерируемый, перезаписывается при apply</small>
</div>
<div className="btn-group">
<input
className="input"
placeholder="Поиск…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ width: 160 }}
/>
<button className="btn btn-ghost sm" disabled={!config} onClick={copy}>Копировать</button>
<button className="btn btn-ghost sm" disabled={!config} onClick={download}>Скачать</button>
<button className="btn btn-secondary sm" onClick={onClose}>Закрыть</button>
</div>
</div>
{error && <div className="error">{error}</div>}
{!error && !config && <p>Конфиг ещё не сгенерирован.</p>}
{config && <pre className="config-view">{text}</pre>}
<div className="modal-body">
{error && <div className="conflict-banner danger">{error}</div>}
{!error && !config && <p className="muted">Конфиг ещё не сгенерирован.</p>}
{config && (
<pre className="config-view">
{Array.isArray(highlighted)
? highlighted.map((part, i) => (
<React.Fragment key={i}>
{part}
{i < highlighted.length - 1 && <mark style={{ background: 'var(--warning-dim)', color: 'var(--warning)' }}>{search}</mark>}
</React.Fragment>
))
: text}
</pre>
)}
</div>
</div>
</div>
);

View File

@@ -0,0 +1,136 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { formatTime } from '../utils/format.js';
const MAX_ENTRIES = 800;
const GROUP_WINDOW_MS = 30_000;
function normalizeLine(line) {
return String(line || '').replace(/\x1b\[\d+m/g, '').trim();
}
function groupEntries(entries) {
// Группируем повторы: одинаковая нормализованная строка + одинаковый level в окне 30 сек.
const out = [];
for (const e of entries) {
const key = `${e.level}|${normalizeLine(e.line)}`;
const last = out[out.length - 1];
const ts = new Date(e.ts).getTime();
if (last && last._key === key && ts - last._lastTs < GROUP_WINDOW_MS) {
last.count += 1;
last._lastTs = ts;
last.lastTs = e.ts;
} else {
out.push({ ...e, _key: key, _lastTs: ts, count: 1, lastTs: e.ts });
}
}
return out;
}
export function LogsPage() {
const [entries, setEntries] = useState([]);
const [paused, setPaused] = useState(false);
const [filter, setFilter] = useState('all');
const [search, setSearch] = useState('');
const [autoscroll, setAutoscroll] = useState(true);
const [grouped, setGrouped] = useState(true);
const containerRef = useRef(null);
const pausedRef = useRef(false);
useEffect(() => { pausedRef.current = paused; }, [paused]);
useEffect(() => {
const source = new EventSource('/api/logs/stream');
source.onmessage = (event) => {
if (pausedRef.current) return;
try {
const entry = JSON.parse(event.data);
setEntries((prev) => {
const next = [...prev, entry];
if (next.length > MAX_ENTRIES) next.splice(0, next.length - MAX_ENTRIES);
return next;
});
} catch {}
};
return () => source.close();
}, []);
const filtered = useMemo(() => {
let list = entries;
if (filter !== 'all') list = list.filter((e) => e.level === filter);
if (search) {
const s = search.toLowerCase();
list = list.filter((e) => normalizeLine(e.line).toLowerCase().includes(s));
}
return grouped ? groupEntries(list) : list;
}, [entries, filter, search, grouped]);
useEffect(() => {
if (!autoscroll || !containerRef.current) return;
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}, [filtered, autoscroll]);
function copy(text) {
navigator.clipboard?.writeText(text).catch(() => {});
}
return (
<div className="card" style={{ display: 'flex', flexDirection: 'column', minHeight: 'calc(100vh - 160px)' }}>
<div className="card-header">
<h2>Логи sing-box</h2>
<small className="muted">{entries.length} / {MAX_ENTRIES}</small>
</div>
<div className="filter-bar" style={{ marginBottom: 12 }}>
<input
className="input"
placeholder="Поиск по тексту…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ flex: 1, minWidth: 200 }}
/>
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">Все уровни</option>
<option value="info">info</option>
<option value="warning">warning</option>
<option value="error">error</option>
<option value="debug">debug</option>
</select>
<label className="checkbox"><input type="checkbox" checked={grouped} onChange={(e) => setGrouped(e.target.checked)} /> Группировать</label>
<label className="checkbox"><input type="checkbox" checked={autoscroll} onChange={(e) => setAutoscroll(e.target.checked)} /> Автоскролл</label>
<button className="btn btn-ghost sm" onClick={() => setPaused((p) => !p)}>{paused ? '▶ Продолжить' : '⏸ Пауза'}</button>
<button className="btn btn-ghost sm" onClick={() => setEntries([])}>Очистить</button>
</div>
<div ref={containerRef} className="logs-stream">
{filtered.length === 0 && <p className="muted">Логов пока нет.</p>}
{filtered.map((entry, index) => {
const text = normalizeLine(entry.line);
if (grouped && entry.count > 1) {
return (
<div key={`${entry.ts}-${index}`} className="log-group">
<span className="log-time mono">{formatTime(entry.ts)}</span>
<span className={`log-level text-${entry.level === 'error' ? 'danger' : entry.level === 'warning' ? 'warning' : 'info'}`}>
{entry.level}
</span>
<span className="log-text">{text}</span>
<span className="repeat">×{entry.count}</span>
</div>
);
}
return (
<div
key={`${entry.ts}-${index}`}
className={`log-line ${entry.level}`}
onDoubleClick={() => copy(`${formatTime(entry.ts)} ${entry.level} ${text}`)}
title="Двойной клик — скопировать"
>
<span className="log-time">{formatTime(entry.ts)}</span>
<span className="log-level">{entry.level}</span>
<span className="log-text">{text}</span>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,75 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { formatTime } from '../utils/format.js';
export function LogsPanel() {
const [entries, setEntries] = useState([]);
const [paused, setPaused] = useState(false);
const [filter, setFilter] = useState('all');
const containerRef = useRef(null);
const pausedRef = useRef(false);
useEffect(() => {
pausedRef.current = paused;
}, [paused]);
useEffect(() => {
const source = new EventSource('/api/logs/stream');
source.onmessage = (event) => {
if (pausedRef.current) return;
try {
const entry = JSON.parse(event.data);
setEntries((prev) => {
const next = [...prev, entry];
if (next.length > 500) next.splice(0, next.length - 500);
return next;
});
} catch {}
};
source.onerror = () => {
// EventSource сам делает реконнект
};
return () => source.close();
}, []);
useEffect(() => {
if (paused || !containerRef.current) return;
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}, [entries, paused]);
const filtered = entries.filter((entry) => filter === 'all' || entry.level === filter);
return (
<section className="panel logs-panel">
<div className="rules-header">
<div className="section-title">
<span>5</span>
<h2>Логи sing-box</h2>
</div>
<div className="rules-actions">
<select value={filter} onChange={(event) => setFilter(event.target.value)}>
<option value="all">все уровни</option>
<option value="info">info</option>
<option value="error">error</option>
</select>
<button className="ghost-button" type="button" onClick={() => setPaused((p) => !p)}>
{paused ? 'Возобновить' : 'Пауза'}
</button>
<button className="ghost-button" type="button" onClick={() => setEntries([])}>
Очистить
</button>
</div>
</div>
<div ref={containerRef} className="logs-stream">
{filtered.length === 0 && <p className="empty">Логов пока нет.</p>}
{filtered.map((entry, index) => (
<p key={`${entry.ts}-${index}`} className={`log-line log-${entry.level}`}>
<span className="log-time">{formatTime(entry.ts)}</span>
<span className="log-level">{entry.level}</span>
<span className="log-text">{entry.line}</span>
</p>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,159 @@
import React, { useEffect, useState } from 'react';
import { formatRelative, formatBytes } from '../utils/format.js';
import { flagFor } from '../utils/country.js';
import { api } from '../api.js';
function StatusHero({ state, status }) {
const text = {
running: { title: '🟢 VPN-шлюз работает', kind: 'success' },
applying: { title: '🟠 Применяем изменения…', kind: 'warning' },
error: { title: '🔴 Ошибка', kind: 'danger' },
stopped: { title: '⚫ Шлюз остановлен', kind: 'neutral' },
no_config: { title: '⚪ Шлюз не настроен', kind: 'neutral' },
}[status];
const userInfo = state?.userInfo;
const traffic = userInfo
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))} / ${userInfo.total ? formatBytes(userInfo.total) : 'без лимита'}`
: 'нет данных';
return (
<div className="card">
<div className="flex-between">
<div>
<h2 style={{ marginBottom: 4 }}>{text.title}</h2>
<small className="muted">
{state?.appliedAt ? `Последнее применение: ${formatRelative(state.appliedAt)}` : 'Конфиг ещё не применялся'}
</small>
</div>
<span className={`badge ${text.kind}`}>{state?.singboxRunning ? 'sing-box online' : 'sing-box offline'}</span>
</div>
<div className="divider" />
<div className="grid-3">
<div>
<small className="muted">Активный сервер</small>
<div style={{ marginTop: 4 }}>
{state?.selectedTag ? (
<>
<strong>{flagFor({ tag: state.selectedTag })} {state.selectedTag}</strong>
</>
) : <span className="muted">Не выбран</span>}
</div>
</div>
<div>
<small className="muted">Трафик</small>
<div style={{ marginTop: 4 }}><strong>{traffic}</strong></div>
</div>
<div>
<small className="muted">Правил маршрутизации</small>
<div style={{ marginTop: 4 }}><strong>{(state?.customRules || []).filter(r => r.enabled).length} активных</strong></div>
</div>
</div>
</div>
);
}
function QuickActions({ state, busy, onRestart, onStop, onShowConfig, onNav }) {
return (
<div className="card">
<div className="card-header">
<h3>Быстрые действия</h3>
</div>
<div className="btn-group">
<button className="btn btn-primary" disabled={busy} onClick={() => onNav('servers')}>
Сменить сервер
</button>
<button className="btn btn-secondary" disabled={busy || !state?.configExists} onClick={onRestart}>
Перезапустить
</button>
<button className="btn btn-secondary" disabled={busy || !state?.singboxRunning} onClick={onStop}>
Остановить
</button>
<button className="btn btn-ghost" disabled={!state?.configExists} onClick={onShowConfig}>
Показать config
</button>
</div>
</div>
);
}
function RecentEvents({ onNav }) {
const [entries, setEntries] = useState([]);
useEffect(() => {
let cancelled = false;
fetch('/api/logs')
.then((r) => r.json())
.then((data) => {
if (cancelled) return;
const list = (data.logs || []).slice(-15).reverse();
setEntries(list);
})
.catch(() => {});
return () => { cancelled = true; };
}, []);
return (
<div className="card">
<div className="card-header">
<h3>Последние события</h3>
<button className="btn btn-link" onClick={() => onNav('logs')}>Открыть логи </button>
</div>
{entries.length === 0 ? (
<small className="muted">Пока ничего нет.</small>
) : (
<div className="events-list">
{entries.slice(0, 8).map((e, i) => {
const dot = e.level === 'error' ? 'danger'
: e.level === 'warning' ? 'warning'
: 'success';
const time = new Date(e.ts).toLocaleTimeString('ru-RU', { hour12: false });
return (
<div key={`${e.ts}-${i}`} className="event-row">
<span className={`dot ${dot}`} />
<span className="event-time">{time}</span>
<span className="text-truncate" title={e.line}>{e.line}</span>
</div>
);
})}
</div>
)}
</div>
);
}
function RoutingSummary({ state, onNav }) {
const rules = state?.customRules || [];
const enabled = rules.filter((r) => r.enabled).length;
return (
<div className="card">
<div className="card-header">
<h3>Маршрутизация</h3>
<button className="btn btn-link" onClick={() => onNav('routing')}>Открыть правила </button>
</div>
<div className="kv-list">
<div className="row"><span className="key">Private IP</span><span className="val text-success"> direct</span></div>
{state?.routingRuDirect && (
<div className="row"><span className="key">RU (geoip/geosite)</span><span className="val text-success"> direct</span></div>
)}
<div className="row"><span className="key">Custom правил</span><span className="val">{enabled} из {rules.length}</span></div>
<div className="row"><span className="key">Остальное</span><span className="val text-warning"> VPN</span></div>
</div>
</div>
);
}
export function OverviewPage({ state, status, busy, onRestart, onStop, onShowConfig, onNav }) {
return (
<div className="section-stack">
<StatusHero state={state} status={status} />
<div className="grid-2">
<QuickActions state={state} busy={busy} onRestart={onRestart} onStop={onStop} onShowConfig={onShowConfig} onNav={onNav} />
<RoutingSummary state={state} onNav={onNav} />
</div>
<RecentEvents onNav={onNav} />
</div>
);
}

View File

@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import { api } from '../api.js';
export function RouteChecker() {
const [host, setHost] = useState('');
const [port, setPort] = useState('443');
const [network, setNetwork] = useState('tcp');
const [busy, setBusy] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState('');
async function check() {
setBusy(true);
setError('');
setResult(null);
try {
const data = await api.route.check({ host, port: port || undefined, network });
setResult(data);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
const r = result?.result;
const kind = r?.outbound?.startsWith('direct') ? 'success'
: r?.outbound === 'block' ? 'danger'
: r?.outbound?.includes('VPN') || r?.outbound?.includes('vpn') ? 'info'
: 'warning';
return (
<div className="card flat compact">
<div className="card-header no-margin"><h3>Проверить маршрут</h3></div>
<div className="filter-bar" style={{ marginTop: 12 }}>
<input
className="input"
placeholder="домен или IP (riotgames.com)"
value={host}
onChange={(e) => setHost(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && check()}
style={{ minWidth: 220, flex: 1 }}
/>
<input
className="input"
placeholder="port"
value={port}
onChange={(e) => setPort(e.target.value)}
style={{ width: 90 }}
/>
<select className="select" value={network} onChange={(e) => setNetwork(e.target.value)} style={{ width: 90 }}>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
<button className="btn btn-primary" onClick={check} disabled={busy || !host}>Проверить</button>
</div>
{error && <div className="field-error" style={{ marginTop: 10 }}>{error}</div>}
{r && (
<div className="route-result" style={{ marginTop: 12 }}>
<div className="flex-between">
<strong>{r.ruleIndex >= 0 ? `Правило #${r.ruleIndex + 1}: ${r.ruleName}` : r.ruleName}</strong>
<span className={`badge ${kind}`}> {r.outbound}</span>
</div>
{result.resolvedIp && result.resolvedFrom && (
<small className="muted text-mono">DNS: {result.resolvedFrom} {result.resolvedIp}</small>
)}
<small className="muted">{r.reason}</small>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,223 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors,
} from '@dnd-kit/core';
import {
arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ruleTemplates } from '../templates/ruleTemplates.js';
import { ruleErrors, hasErrors } from '../utils/validation.js';
import { RuleEditorDrawer } from './RuleEditorDrawer.jsx';
import { RouteChecker } from './RouteChecker.jsx';
import { api } from '../api.js';
const OUTBOUND_KIND = {
direct: { kind: 'success', label: 'direct' },
vpn: { kind: 'info', label: 'VPN' },
block: { kind: 'danger', label: 'block' },
};
function summary(rule) {
const parts = [];
const totalDomains = (rule.domains?.length || 0) + (rule.domainSuffixes?.length || 0) + (rule.domainKeywords?.length || 0);
if (totalDomains) parts.push(`${totalDomains} дом.`);
if (rule.ipCidrs?.length) parts.push(`${rule.ipCidrs.length} CIDR`);
if (rule.ports?.length) parts.push(`${rule.ports.length} портов`);
if (rule.networks?.length) parts.push(rule.networks.join('/'));
return parts.join(' · ') || '—';
}
function SortableRuleRow({ rule, index, total, onEdit, onUpdate, onRemove, conflict }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: rule.id });
const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 };
const errors = ruleErrors(rule);
const invalid = hasErrors(errors);
const ob = OUTBOUND_KIND[rule.outbound] || OUTBOUND_KIND.direct;
return (
<tr ref={setNodeRef} style={style} className={`rule-row ${rule.enabled ? '' : 'disabled'} ${invalid ? 'invalid' : ''}`}>
<td style={{ width: 30 }}>
<span className="drag-handle" {...attributes} {...listeners} title="Перетащить"></span>
</td>
<td style={{ width: 36 }} className="muted text-mono">#{index + 1}</td>
<td>
<div className="flex" style={{ alignItems: 'center' }}>
<input
type="checkbox"
checked={rule.enabled !== false}
onChange={(e) => onUpdate(rule.id, { enabled: e.target.checked })}
style={{ accentColor: 'var(--accent)' }}
/>
<button className="btn btn-link" style={{ padding: 0, fontWeight: 600 }} onClick={() => onEdit(rule.id)}>
{rule.name || '(без названия)'}
</button>
{invalid && <span className="badge danger">ошибки</span>}
{conflict && <span className={`badge ${conflict.severity === 'warning' ? 'warning' : 'info'}`} title={`Перекрывается с #${conflict.conflictWithIndex + 1}`}>конфликт</span>}
</div>
</td>
<td><span className={`badge ${ob.kind}`}>{ob.label}</span></td>
<td className="muted" style={{ fontSize: 12 }}>{summary(rule)}</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button className="btn btn-ghost sm" onClick={() => onEdit(rule.id)}>Редактировать</button>
<button className="btn btn-ghost sm" onClick={() => { if (confirm('Удалить правило?')) onRemove(rule.id); }}>×</button>
</div>
</td>
</tr>
);
}
function TemplatesModal({ open, onClose, onAdd }) {
if (!open) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal lg" onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<h3>Шаблоны маршрутизации</h3>
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
</div>
<div className="modal-body">
<div className="template-grid">
{ruleTemplates.map((tpl) => (
<div key={tpl.key} className="template-card">
<h4>{tpl.label}</h4>
<small>{tpl.description}</small>
<button className="btn btn-secondary sm" onClick={() => { onAdd(tpl.build()); onClose(); }}>
+ Добавить
</button>
</div>
))}
</div>
</div>
</div>
</div>
);
}
export function RoutingPage({
rules, saveStatus, busy,
onAdd, onAddTemplate, onUpdate, onRemove, onSaveNow, onReorder,
}) {
const [editingId, setEditingId] = useState(null);
const [showTemplates, setShowTemplates] = useState(false);
const [conflicts, setConflicts] = useState([]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
useEffect(() => {
let cancelled = false;
const t = setTimeout(() => {
api.rules.conflicts().then((data) => { if (!cancelled) setConflicts(data.conflicts || []); }).catch(() => {});
}, 600);
return () => { cancelled = true; clearTimeout(t); };
}, [rules]);
const conflictsByRuleId = useMemo(() => {
const map = {};
for (const c of conflicts) map[c.ruleId] = c;
return map;
}, [conflicts]);
function handleDragEnd(event) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = rules.findIndex((r) => r.id === active.id);
const newIndex = rules.findIndex((r) => r.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
onReorder(arrayMove(rules, oldIndex, newIndex));
}
const editing = rules.find((r) => r.id === editingId) || null;
return (
<div className="section-stack">
<RouteChecker />
<div className="card">
<div className="card-header">
<h2>Правила маршрутизации</h2>
<div className="btn-group">
<button className="btn btn-secondary sm" onClick={() => setShowTemplates(true)}>Шаблоны</button>
<button className="btn btn-primary sm" onClick={() => { const newId = `rule-${Date.now()}`; onAdd(); setTimeout(() => setEditingId(newId), 50); }}>
+ Добавить
</button>
</div>
</div>
{conflicts.length > 0 && (
<div className="conflict-banner" style={{ marginBottom: 12 }}>
<span></span>
<div>
<strong>{conflicts.length} конфликт(ов) обнаружено</strong>
<div style={{ marginTop: 4 }}>
{conflicts.slice(0, 3).map((c, i) => (
<div key={i} style={{ fontSize: 12 }}>
#{c.ruleIndex + 1} «{c.ruleName}» перекрывается правилом #{c.conflictWithIndex + 1} «{c.conflictWithName}»
</div>
))}
</div>
</div>
</div>
)}
<small className="muted" style={{ display: 'block', marginBottom: 8 }}>
Применяются <strong>сверху вниз</strong>. Перетаскивай чтобы менять порядок.
</small>
{rules.length === 0 ? (
<div className="empty-state">
<h3>Правил пока нет</h3>
<p>Добавь шаблон (например «League of Legends direct») или создай пустое правило.</p>
<button className="btn btn-primary" onClick={() => setShowTemplates(true)} style={{ marginTop: 12 }}>
Открыть шаблоны
</button>
</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr>
<th></th>
<th>#</th>
<th>Правило</th>
<th>Outbound</th>
<th>Условия</th>
<th></th>
</tr>
</thead>
<tbody>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={rules.map((r) => r.id)} strategy={verticalListSortingStrategy}>
{rules.map((rule, i) => (
<SortableRuleRow
key={rule.id}
rule={rule}
index={i}
total={rules.length}
onEdit={setEditingId}
onUpdate={onUpdate}
onRemove={onRemove}
conflict={conflictsByRuleId[rule.id]}
/>
))}
</SortableContext>
</DndContext>
</tbody>
</table>
</div>
)}
</div>
<RuleEditorDrawer
rule={editing}
onUpdate={onUpdate}
onClose={() => setEditingId(null)}
onRemove={onRemove}
/>
<TemplatesModal open={showTemplates} onClose={() => setShowTemplates(false)} onAdd={onAddTemplate} />
</div>
);
}

View File

@@ -1,127 +0,0 @@
import React from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { ruleErrors, hasErrors } from '../utils/validation.js';
function listToText(value) {
return Array.isArray(value) ? value.join('\n') : '';
}
function textToList(value) {
return value
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter(Boolean);
}
export function RuleCard({ rule, index, total, onUpdate, onRemove }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: rule.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
const errors = ruleErrors(rule);
const errored = hasErrors(errors);
return (
<article ref={setNodeRef} style={style} className={errored ? 'rule-card invalid' : 'rule-card'}>
<div className="rule-top">
<span className="drag-handle" {...attributes} {...listeners} title="Перетащить">
#{index + 1}/{total}
</span>
<input
value={rule.name}
onChange={(event) => onUpdate(rule.id, { name: event.target.value })}
placeholder="Название списка"
/>
<label className="checkbox-label">
<input
type="checkbox"
checked={rule.enabled}
onChange={(event) => onUpdate(rule.id, { enabled: event.target.checked })}
/>
включено
</label>
</div>
<label className="field">
<span>Outbound</span>
<select value={rule.outbound} onChange={(event) => onUpdate(rule.id, { outbound: event.target.value })}>
<option value="direct">direct (напрямую)</option>
<option value="vpn">vpn (через выбранный сервер)</option>
<option value="block">block (заблокировать)</option>
</select>
</label>
<div className="rule-fields">
<label className={errors.domains.length ? 'field has-error' : 'field'}>
<span>Домены (точное совпадение)</span>
<textarea
value={listToText(rule.domains)}
onChange={(event) => onUpdate(rule.id, { domains: textToList(event.target.value) })}
placeholder="riotgames.com"
/>
{errors.domains.length > 0 && <small className="error">Невалидно: {errors.domains.join(', ')}</small>}
</label>
<label className={errors.domainSuffixes.length ? 'field has-error' : 'field'}>
<span>Суффиксы доменов</span>
<textarea
value={listToText(rule.domainSuffixes)}
onChange={(event) => onUpdate(rule.id, { domainSuffixes: textToList(event.target.value) })}
placeholder={'leagueoflegends.com\nriotcdn.net'}
/>
{errors.domainSuffixes.length > 0 && <small className="error">Невалидно: {errors.domainSuffixes.join(', ')}</small>}
</label>
<label className={errors.ipCidrs.length ? 'field has-error' : 'field'}>
<span>IP CIDR</span>
<textarea
value={listToText(rule.ipCidrs)}
onChange={(event) => onUpdate(rule.id, { ipCidrs: textToList(event.target.value) })}
placeholder="104.160.128.0/19"
/>
{errors.ipCidrs.length > 0 && <small className="error">Невалидно: {errors.ipCidrs.join(', ')}</small>}
</label>
<label className={errors.ports.length ? 'field has-error' : 'field'}>
<span>Порты</span>
<textarea
value={listToText(rule.ports)}
onChange={(event) => onUpdate(rule.id, { ports: textToList(event.target.value) })}
placeholder={'5000\n5223'}
/>
{errors.ports.length > 0 && <small className="error">Невалидно: {errors.ports.join(', ')}</small>}
</label>
</div>
<div className="rule-footer">
<label className="checkbox-label">
<input
type="checkbox"
checked={(rule.networks || []).includes('tcp')}
onChange={(event) => {
const set = new Set(rule.networks || []);
event.target.checked ? set.add('tcp') : set.delete('tcp');
onUpdate(rule.id, { networks: Array.from(set) });
}}
/>
tcp
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={(rule.networks || []).includes('udp')}
onChange={(event) => {
const set = new Set(rule.networks || []);
event.target.checked ? set.add('udp') : set.delete('udp');
onUpdate(rule.id, { networks: Array.from(set) });
}}
/>
udp
</label>
<button className="danger-button" type="button" onClick={() => onRemove(rule.id)}>
Удалить
</button>
</div>
</article>
);
}

View File

@@ -0,0 +1,195 @@
import React, { useState } from 'react';
import { ChipsInput } from './ChipsInput.jsx';
import { isValidCidr, isValidPort, ruleErrors, hasErrors } from '../utils/validation.js';
const DOMAIN = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/i;
const validDomain = (v) => DOMAIN.test(String(v).trim());
export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder' }) {
const [view, setView] = useState(mode); // builder | json
const [jsonDraft, setJsonDraft] = useState(() => JSON.stringify(rule, null, 2));
const [jsonError, setJsonError] = useState('');
const errors = ruleErrors(rule);
function patch(p) {
onUpdate(rule.id, p);
}
function applyJson() {
try {
const parsed = JSON.parse(jsonDraft);
onUpdate(rule.id, { ...parsed, id: rule.id });
setJsonError('');
} catch (err) {
setJsonError(err.message);
}
}
return (
<div className="drawer-body">
<div className="tabs">
<button className={`tab ${view === 'builder' ? 'active' : ''}`} onClick={() => setView('builder')}>Конструктор</button>
<button className={`tab ${view === 'json' ? 'active' : ''}`} onClick={() => { setJsonDraft(JSON.stringify(rule, null, 2)); setView('json'); }}>Raw JSON</button>
</div>
{view === 'builder' ? (
<>
<div className="field">
<span className="field-label">Название</span>
<input className="input" value={rule.name} onChange={(e) => patch({ name: e.target.value })} />
</div>
<div className="field-row">
<div className="field">
<span className="field-label">Outbound</span>
<select className="select" value={rule.outbound} onChange={(e) => patch({ outbound: e.target.value })}>
<option value="direct">direct (напрямую)</option>
<option value="vpn">vpn (через выбранный сервер)</option>
<option value="block">block (заблокировать)</option>
</select>
</div>
<div className="field">
<span className="field-label">Состояние</span>
<label className="checkbox">
<input
type="checkbox"
checked={rule.enabled !== false}
onChange={(e) => patch({ enabled: e.target.checked })}
/>
Правило включено
</label>
</div>
</div>
<div className="field">
<span className="field-label">Домены (точное совпадение)</span>
<ChipsInput
value={rule.domains || []}
onChange={(v) => patch({ domains: v })}
placeholder="riotgames.com"
validate={validDomain}
/>
{errors.domains.length > 0 && <span className="field-error">Невалидно: {errors.domains.join(', ')}</span>}
</div>
<div className="field">
<span className="field-label">Суффиксы доменов</span>
<ChipsInput
value={rule.domainSuffixes || []}
onChange={(v) => patch({ domainSuffixes: v })}
placeholder="riotcdn.net"
validate={validDomain}
/>
{errors.domainSuffixes.length > 0 && <span className="field-error">Невалидно: {errors.domainSuffixes.join(', ')}</span>}
</div>
<div className="field">
<span className="field-label">IP / CIDR</span>
<ChipsInput
value={rule.ipCidrs || []}
onChange={(v) => patch({ ipCidrs: v })}
placeholder="104.160.128.0/19"
validate={isValidCidr}
/>
{errors.ipCidrs.length > 0 && <span className="field-error">Невалидно: {errors.ipCidrs.join(', ')}</span>}
</div>
<div className="field">
<span className="field-label">Порты (число или диапазон 5000-6000)</span>
<ChipsInput
value={rule.ports || []}
onChange={(v) => patch({ ports: v })}
placeholder="443"
validate={(p) => {
const s = String(p);
if (s.includes('-')) {
const [a, b] = s.split('-');
return isValidPort(a) && isValidPort(b);
}
return isValidPort(p);
}}
/>
{errors.ports.length > 0 && <span className="field-error">Невалидно: {errors.ports.join(', ')}</span>}
</div>
<div className="field">
<span className="field-label">Протоколы</span>
<div className="flex">
<label className="checkbox">
<input
type="checkbox"
checked={(rule.networks || []).includes('tcp')}
onChange={(e) => {
const set = new Set(rule.networks || []);
e.target.checked ? set.add('tcp') : set.delete('tcp');
patch({ networks: Array.from(set) });
}}
/>
TCP
</label>
<label className="checkbox">
<input
type="checkbox"
checked={(rule.networks || []).includes('udp')}
onChange={(e) => {
const set = new Set(rule.networks || []);
e.target.checked ? set.add('udp') : set.delete('udp');
patch({ networks: Array.from(set) });
}}
/>
UDP
</label>
<span className="field-hint">Если ничего оба</span>
</div>
</div>
</>
) : (
<>
<div className="field">
<span className="field-label">Сырой JSON правила</span>
<textarea
className="textarea"
style={{ minHeight: 320 }}
value={jsonDraft}
onChange={(e) => setJsonDraft(e.target.value)}
/>
{jsonError && <span className="field-error">{jsonError}</span>}
</div>
<div className="btn-group">
<button className="btn btn-primary" onClick={applyJson}>Применить JSON</button>
<button className="btn btn-ghost" onClick={() => setJsonDraft(JSON.stringify(rule, null, 2))}>Сбросить</button>
</div>
</>
)}
</div>
);
}
export function RuleEditorDrawer({ rule, onUpdate, onClose, onRemove }) {
if (!rule) return null;
const errors = ruleErrors(rule);
const invalid = hasErrors(errors);
return (
<>
<div className="drawer-backdrop" onClick={onClose} />
<aside className="drawer">
<div className="drawer-head">
<div>
<h3>Редактирование правила</h3>
<small className="muted">{rule.name || '(без названия)'}</small>
</div>
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
</div>
<RuleEditor rule={rule} onUpdate={onUpdate} onClose={onClose} onRemove={onRemove} />
<div className="drawer-foot">
<button className="btn btn-danger" onClick={() => { if (confirm('Удалить правило?')) { onRemove(rule.id); onClose(); } }}>Удалить</button>
<div className="btn-group">
{invalid && <span className="badge danger">Есть ошибки</span>}
<button className="btn btn-primary" onClick={onClose}>Готово</button>
</div>
</div>
</aside>
</>
);
}

View File

@@ -1,112 +0,0 @@
import React, { useState } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { RuleCard } from './RuleCard.jsx';
import { ruleTemplates } from '../templates/ruleTemplates.js';
export function RulesPanel({ rules, saveStatus, busy, onAdd, onAddTemplate, onUpdate, onRemove, onSaveNow, onReorder }) {
const [templateKey, setTemplateKey] = useState('');
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
function handleDragEnd(event) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = rules.findIndex((rule) => rule.id === active.id);
const newIndex = rules.findIndex((rule) => rule.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
onReorder(arrayMove(rules, oldIndex, newIndex));
}
function handleAddTemplate() {
const tpl = ruleTemplates.find((t) => t.key === templateKey);
if (!tpl) return;
onAddTemplate(tpl.build());
setTemplateKey('');
}
const saveLabel =
saveStatus === 'saving'
? 'Сохраняем…'
: saveStatus === 'pending'
? 'Сохранить сейчас'
: saveStatus === 'error'
? 'Повторить сохранение'
: 'Сохранено';
return (
<section className="panel rules-panel">
<div className="rules-header">
<div className="section-title">
<span>4</span>
<h2>Правила маршрутизации</h2>
</div>
<div className="rules-actions">
<select value={templateKey} onChange={(event) => setTemplateKey(event.target.value)}>
<option value="">Шаблон</option>
{ruleTemplates.map((tpl) => (
<option key={tpl.key} value={tpl.key}>
{tpl.label}
</option>
))}
</select>
<button className="ghost-button" type="button" disabled={!templateKey} onClick={handleAddTemplate}>
Добавить шаблон
</button>
<button className="ghost-button" type="button" onClick={onAdd}>
Пустое правило
</button>
<button
className="ghost-button solid"
type="button"
disabled={busy || saveStatus === 'saving'}
onClick={onSaveNow}
>
{saveLabel}
</button>
</div>
</div>
<p className="rules-note">
Правила применяются <strong>сверху вниз</strong> (first match wins). Перетаскивай за «» чтобы менять порядок.
Они вставляются после safety private-direct и до RU-direct. Для игр указывай домены, суффиксы, CIDR или порты.
</p>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={rules.map((r) => r.id)} strategy={verticalListSortingStrategy}>
<div className="rule-grid">
{rules.length === 0 && (
<div className="empty rule-empty">
Нет правил. Добавь шаблон (например «League of Legends direct») или пустое правило.
</div>
)}
{rules.map((rule, index) => (
<RuleCard
key={rule.id}
rule={rule}
index={index}
total={rules.length}
onUpdate={onUpdate}
onRemove={onRemove}
/>
))}
</div>
</SortableContext>
</DndContext>
</section>
);
}

View File

@@ -1,86 +0,0 @@
import React from 'react';
import { formatBytes, formatRelative } from '../utils/format.js';
export function RuntimePanel({ state, log, busy, onStop, onRestart, onClear, onShowConfig }) {
const userInfo = state?.userInfo;
const traffic = userInfo
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))} / ${
userInfo.total ? formatBytes(userInfo.total) : 'без лимита'
}`
: 'нет данных';
return (
<aside className="panel details">
<div className="section-title">
<span>3</span>
<h2>Шлюз</h2>
</div>
<dl>
<div>
<dt>UI</dt>
<dd>:{state?.port || 3456}</dd>
</div>
<div>
<dt>Mixed proxy</dt>
<dd>:{state?.proxyPort || 8080}</dd>
</div>
<div>
<dt>TProxy</dt>
<dd>:{state?.tproxyPort || 7895}</dd>
</div>
<div>
<dt>RU direct</dt>
<dd>{state?.routingRuDirect ? 'включено' : 'выключено'}</dd>
</div>
<div>
<dt>Трафик</dt>
<dd>{traffic}</dd>
</div>
<div>
<dt>sing-box</dt>
<dd>
{state?.singboxRunning
? `работает${state.singboxStartedAt ? ` (${formatRelative(state.singboxStartedAt)})` : ''}`
: 'остановлен'}
</dd>
</div>
<div>
<dt>Применено</dt>
<dd>{state?.appliedAt ? formatRelative(state.appliedAt) : 'не применено'}</dd>
</div>
</dl>
<div className="runtime-actions">
<button className="ghost-button" type="button" disabled={busy || !state?.singboxRunning} onClick={onStop}>
Остановить
</button>
<button className="ghost-button" type="button" disabled={busy || !state?.configExists} onClick={onRestart}>
Перезапустить
</button>
<button className="ghost-button" type="button" disabled={busy || !state?.configExists} onClick={onClear}>
Сбросить конфиг
</button>
<button className="ghost-button" type="button" disabled={!state?.configExists} onClick={onShowConfig}>
Показать config
</button>
</div>
<div className="route-card">
<span>Политика роутинга</span>
<p>private IP direct</p>
<p>geoip-ru / geosite-category-ru direct</p>
<p>остальное выбранный VPN outbound</p>
</div>
<div className="logs">
{log.length === 0 && <p>Ожидание действий</p>}
{log.map((entry, index) => (
<p key={`${entry.time}-${index}`}>
<span>{entry.time}</span> {entry.message}
</p>
))}
</div>
</aside>
);
}

View File

@@ -1,32 +0,0 @@
import React from 'react';
export function ServerList({ servers, selectedTag, setSelectedTag, busy, onApply }) {
return (
<div className="primary-block">
<div className="section-title compact">
<span>2</span>
<h2>Серверы</h2>
</div>
<div className="server-list">
{servers.length === 0 && <div className="empty">Серверы ещё не загружены</div>}
{servers.map((server) => (
<button
key={server.tag}
className={server.tag === selectedTag ? 'server active' : 'server'}
onClick={() => setSelectedTag(server.tag)}
>
<strong>{server.tag}</strong>
<small>
{server.type} / {server.server}:{server.server_port}
</small>
</button>
))}
</div>
<button className="button apply" disabled={busy || !selectedTag} onClick={onApply}>
Применить выбранный сервер
</button>
</div>
);
}

View File

@@ -0,0 +1,210 @@
import React, { useMemo, useState } from 'react';
import { api } from '../api.js';
import { flagFor } from '../utils/country.js';
import { formatRelative } from '../utils/format.js';
function PingCell({ ping }) {
if (!ping) return <span className="muted"></span>;
if (ping.checking) return <span className="badge neutral pulse">проверяем</span>;
if (!ping.ok) return <span className="badge danger" title={ping.error}>offline</span>;
const ms = ping.latency;
const kind = ms < 80 ? 'success' : ms < 200 ? 'warning' : 'danger';
return <span className={`badge ${kind}`}>{ms} ms</span>;
}
function StatusCell({ ping }) {
if (!ping) return <span className="badge neutral">unknown</span>;
if (ping.checking) return <span className="badge neutral pulse"></span>;
return ping.ok
? <span className="badge success"> online</span>
: <span className="badge danger"> offline</span>;
}
export function ServersPage({
state,
servers,
selectedTag,
setSelectedTag,
pendingTag,
setPendingTag,
busy,
onApply,
onRollback,
pings,
setPings,
pushToast,
}) {
const [filter, setFilter] = useState('all'); // all | online
const [search, setSearch] = useState('');
async function pingOne(server) {
setPings((prev) => ({ ...prev, [server.tag]: { checking: true } }));
try {
const res = await api.servers.ping(server.server, server.server_port);
setPings((prev) => ({
...prev,
[server.tag]: { ok: res.ok, latency: res.latency, error: res.error, checkedAt: new Date().toISOString() },
}));
} catch (err) {
setPings((prev) => ({ ...prev, [server.tag]: { ok: false, error: err.message } }));
}
}
async function pingAll() {
setPings((prev) => {
const next = { ...prev };
for (const s of servers) next[s.tag] = { checking: true };
return next;
});
try {
const res = await api.servers.pingAll();
const map = {};
for (const r of res.results || []) {
map[r.tag] = { ok: r.ok, latency: r.latency, error: r.error, checkedAt: r.checkedAt };
}
setPings((prev) => ({ ...prev, ...map }));
pushToast({ kind: 'success', title: 'Пинг завершён' });
} catch (err) {
pushToast({ kind: 'danger', title: 'Ошибка пинга', message: err.message });
}
}
const filtered = useMemo(() => {
return servers.filter((s) => {
if (search && !s.tag.toLowerCase().includes(search.toLowerCase()) && !s.server.toLowerCase().includes(search.toLowerCase())) {
return false;
}
if (filter === 'online' && !pings[s.tag]?.ok) return false;
return true;
});
}, [servers, search, filter, pings]);
const pendingDifferent = pendingTag && pendingTag !== state?.selectedTag;
const activeServer = servers.find((s) => s.tag === state?.selectedTag);
const pendingServer = servers.find((s) => s.tag === pendingTag);
if (!servers.length) {
return (
<div className="card">
<div className="empty-state">
<h3>Серверы ещё не загружены</h3>
<p>Загрузите подписку в разделе «Настройки», чтобы получить список серверов.</p>
</div>
</div>
);
}
return (
<div className="section-stack">
{pendingDifferent && (
<div className="card" style={{ borderColor: 'var(--warning)' }}>
<div className="flex-between">
<div>
<strong>Выбран: {flagFor(pendingServer)} {pendingServer?.tag}</strong>
<div className="muted" style={{ fontSize: 12, marginTop: 4 }}>
Текущий: {state?.selectedTag ? `${flagFor(activeServer)} ${state.selectedTag}` : 'нет'}
</div>
</div>
<div className="btn-group">
<button className="btn btn-ghost" onClick={() => setPendingTag(state?.selectedTag || '')} disabled={busy}>
Отменить
</button>
<button className="btn btn-primary" onClick={() => onApply(pendingTag)} disabled={busy}>
Применить изменения
</button>
</div>
</div>
</div>
)}
<div className="card">
<div className="card-header">
<h2>Серверы ({servers.length})</h2>
<div className="btn-group">
<button className="btn btn-secondary sm" onClick={pingAll} disabled={busy}>
Проверить все
</button>
{state?.previousTag && (
<button className="btn btn-ghost sm" onClick={onRollback} disabled={busy}>
Откатить ({state.previousTag})
</button>
)}
</div>
</div>
<div className="filter-bar" style={{ marginBottom: 12 }}>
<input
className="input"
placeholder="Поиск по тегу или хосту…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">Все</option>
<option value="online">Только online</option>
</select>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr>
<th style={{ width: 16 }}></th>
<th>Сервер</th>
<th>Хост</th>
<th>Тип</th>
<th>Ping</th>
<th>Статус</th>
<th style={{ textAlign: 'right' }}>Действие</th>
</tr>
</thead>
<tbody>
{filtered.map((server) => {
const isActive = server.tag === state?.selectedTag;
const isPending = server.tag === pendingTag && !isActive;
const ping = pings[server.tag];
return (
<tr key={server.tag} className={isActive ? 'active' : ''}>
<td>{flagFor(server)}</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<strong>{server.tag}</strong>
{isActive && <span className="badge success">ACTIVE</span>}
{isPending && <span className="badge warning">pending</span>}
</div>
</td>
<td className="text-mono muted">{server.server}:{server.server_port}</td>
<td><span className="badge neutral">{server.type}</span></td>
<td><PingCell ping={ping} /></td>
<td><StatusCell ping={ping} /></td>
<td>
<div className="row-actions">
<button className="btn btn-ghost sm" onClick={() => pingOne(server)} disabled={busy}>
Ping
</button>
{isActive ? (
<button className="btn btn-secondary sm" disabled>Активен</button>
) : (
<button
className="btn btn-primary sm"
onClick={() => { setSelectedTag(server.tag); setPendingTag(server.tag); }}
disabled={busy}
>
Выбрать
</button>
)}
</div>
</td>
</tr>
);
})}
{!filtered.length && (
<tr><td colSpan={7} className="muted" style={{ padding: 24, textAlign: 'center' }}>Ничего не найдено</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,171 @@
import React, { useEffect, useState } from 'react';
import { api } from '../api.js';
import { formatRelative } from '../utils/format.js';
function SubscriptionCard({ state, subscriptionUrl, setSubscriptionUrl, busy, onFetch, onForget, pushToast }) {
const [editing, setEditing] = useState(!state?.hasSubscription);
useEffect(() => { if (!state?.hasSubscription) setEditing(true); }, [state?.hasSubscription]);
const masked = state?.hasSubscription && !editing;
return (
<div className="card">
<div className="card-header">
<h2>Подписка</h2>
{state?.hasSubscription && (
<span className="badge success"> активна</span>
)}
</div>
{masked ? (
<div className="kv-list">
<div className="row">
<span className="key">URL</span>
<span className="val text-mono">{state.subscriptionHost}</span>
</div>
<div className="row">
<span className="key">Серверов</span>
<span className="val">{state.servers?.length || 0}</span>
</div>
<div className="row">
<span className="key">Загружено</span>
<span className="val">{state.fetchedAt ? formatRelative(state.fetchedAt) : '—'}</span>
</div>
</div>
) : (
<div className="field">
<span className="field-label">Subscription URL</span>
<div className="subscription-input">
<input
className="input"
value={subscriptionUrl}
onChange={(e) => setSubscriptionUrl(e.target.value)}
placeholder="https://provider.example/sub/..."
/>
</div>
</div>
)}
<div className="btn-group" style={{ marginTop: 16 }}>
{masked ? (
<>
<button className="btn btn-secondary" onClick={() => setEditing(true)} disabled={busy}>Изменить URL</button>
<button className="btn btn-secondary" onClick={onFetch} disabled={busy}> Обновить серверы</button>
<button className="btn btn-danger" onClick={onForget} disabled={busy}>Удалить подписку</button>
</>
) : (
<>
<button
className="btn btn-primary"
onClick={async () => { await onFetch(); setEditing(false); }}
disabled={busy || !subscriptionUrl}
>
{busy ? 'Загрузка…' : 'Загрузить серверы'}
</button>
{state?.hasSubscription && (
<button className="btn btn-ghost" onClick={() => setEditing(false)}>Отмена</button>
)}
</>
)}
</div>
</div>
);
}
function ConfigCard({ state, busy, onShowConfig, onClearConfig, pushToast }) {
const [validation, setValidation] = useState(null);
const [validating, setValidating] = useState(false);
async function validate() {
setValidating(true);
try {
const data = await api.configValidate();
setValidation(data);
pushToast({
kind: data.valid ? 'success' : 'danger',
title: data.valid ? 'Config валиден' : 'Config невалиден',
message: data.error || data.note,
});
} catch (err) {
pushToast({ kind: 'danger', title: 'Ошибка проверки', message: err.message });
} finally {
setValidating(false);
}
}
return (
<div className="card">
<div className="card-header">
<h2>sing-box config</h2>
{validation && (
<span className={`badge ${validation.valid ? 'success' : 'danger'}`}>
{validation.valid ? '✓ валиден' : '✗ ошибка'}
</span>
)}
</div>
<div className="kv-list">
<div className="row"><span className="key">Файл</span><span className="val">{state?.configExists ? 'есть' : 'нет'}</span></div>
<div className="row"><span className="key">Применено</span><span className="val">{state?.appliedAt ? formatRelative(state.appliedAt) : '—'}</span></div>
</div>
<div className="btn-group" style={{ marginTop: 16 }}>
<button className="btn btn-secondary" disabled={!state?.configExists} onClick={onShowConfig}>Показать config</button>
<button className="btn btn-secondary" disabled={validating || !state?.configExists} onClick={validate}>
{validating ? 'Проверяем…' : '✓ Валидировать'}
</button>
<button className="btn btn-danger" disabled={busy || !state?.configExists} onClick={onClearConfig}>
Сбросить config
</button>
</div>
{validation && !validation.valid && validation.error && (
<div className="conflict-banner danger" style={{ marginTop: 12 }}>
<span></span><div>{validation.error}</div>
</div>
)}
</div>
);
}
function PortsCard({ state }) {
return (
<div className="card">
<div className="card-header"><h2>Порты и маршруты</h2></div>
<div className="kv-list">
<div className="row"><span className="key">UI</span><span className="val text-mono">:{state?.port || 3456}</span></div>
<div className="row"><span className="key">Mixed proxy (http+socks5)</span><span className="val text-mono">:{state?.proxyPort || 8080}</span></div>
<div className="row"><span className="key">TProxy</span><span className="val text-mono">:{state?.tproxyPort || 7895}</span></div>
<div className="row"><span className="key">RU direct (geoip-ru)</span><span className="val">{state?.routingRuDirect ? 'включено' : 'выключено'}</span></div>
</div>
<small className="muted" style={{ display: 'block', marginTop: 10 }}>
Эти параметры задаются в config.js на сервере.
</small>
</div>
);
}
export function SettingsPage({
state, subscriptionUrl, setSubscriptionUrl, busy,
onFetchSubscription, onForgetSubscription, onShowConfig, onClearConfig, pushToast,
}) {
return (
<div className="section-stack">
<SubscriptionCard
state={state}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
busy={busy}
onFetch={onFetchSubscription}
onForget={onForgetSubscription}
pushToast={pushToast}
/>
<ConfigCard
state={state}
busy={busy}
onShowConfig={onShowConfig}
onClearConfig={onClearConfig}
pushToast={pushToast}
/>
<PortsCard state={state} />
</div>
);
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
const NAV = [
{ id: 'overview', label: 'Обзор', ico: '◉' },
{ id: 'servers', label: 'Серверы', ico: '⋆' },
{ id: 'routing', label: 'Маршрутизация', ico: '⇅' },
{ id: 'logs', label: 'Логи', ico: '≡' },
{ id: 'settings', label: 'Настройки', ico: '⚙' },
];
export function Sidebar({ active, onChange, badges = {} }) {
return (
<nav className="sidebar">
{NAV.map((item) => {
const badge = badges[item.id];
return (
<button
key={item.id}
type="button"
className={`sidebar-item${active === item.id ? ' active' : ''}`}
onClick={() => onChange(item.id)}
>
<span className="ico">{item.ico}</span>
{item.label}
{badge && (
<span className={`badge ${badge.kind || ''}`}>{badge.text}</span>
)}
</button>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { formatBytes, formatRelative } from '../utils/format.js';
function StatusRow({ label, value, kind }) {
return (
<div className="row">
<span className="key">{label}</span>
<span className={`val ${kind ? 'text-' + kind : ''}`}>{value}</span>
</div>
);
}
export function StatusPane({ state, busy, onStop, onRestart, onShowConfig }) {
const userInfo = state?.userInfo;
const traffic = userInfo
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))} / ${userInfo.total ? formatBytes(userInfo.total) : '∞'}`
: '—';
let singboxStatus = 'Остановлен';
let singboxKind = 'muted';
if (state?.singboxRunning) {
singboxStatus = `работает · ${formatRelative(state.singboxStartedAt)}`;
singboxKind = 'success';
} else if (state?.configExists) {
singboxStatus = 'остановлен (конфиг есть)';
singboxKind = 'warning';
}
return (
<aside className="status-pane">
<div className="card compact flat">
<div className="card-header no-margin">
<h3>sing-box</h3>
<span className={`badge ${state?.singboxRunning ? 'success' : 'neutral'}`}>
{state?.singboxRunning ? '● online' : '○ offline'}
</span>
</div>
<div className="kv-list" style={{ marginTop: 12 }}>
<StatusRow label="Статус" value={singboxStatus} kind={singboxKind} />
<StatusRow label="UI порт" value={`:${state?.port || 3456}`} />
<StatusRow label="Mixed proxy" value={`:${state?.proxyPort || 8080}`} />
<StatusRow label="TProxy" value={`:${state?.tproxyPort || 7895}`} />
<StatusRow label="RU direct" value={state?.routingRuDirect ? 'включено' : 'выключено'} />
<StatusRow label="Трафик" value={traffic} />
<StatusRow
label="Применено"
value={state?.appliedAt ? formatRelative(state.appliedAt) : 'не применено'}
/>
</div>
<div className="btn-group" style={{ marginTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
<button
className="btn btn-secondary sm block"
disabled={busy || !state?.configExists}
onClick={onRestart}
>
Перезапустить
</button>
<button
className="btn btn-ghost sm block"
disabled={busy || !state?.singboxRunning}
onClick={onStop}
>
Остановить
</button>
<button
className="btn btn-ghost sm block"
disabled={!state?.configExists}
onClick={onShowConfig}
>
Показать config
</button>
</div>
</div>
{state?.appliedHistory?.length > 0 && (
<div className="card compact flat">
<h4 style={{ marginBottom: 8 }}>История применений</h4>
<div className="events-list">
{state.appliedHistory.slice(0, 5).map((h) => (
<div key={h.at} className="event-row" style={{ gridTemplateColumns: '1fr auto' }}>
<span className="text-truncate">{h.tag}</span>
<span className="event-time">{formatRelative(h.at)}</span>
</div>
))}
</div>
</div>
)}
</aside>
);
}

View File

@@ -1,58 +0,0 @@
import React from 'react';
export function SubscriptionPanel({
subscriptionUrl,
setSubscriptionUrl,
hasSubscription,
subscriptionHost,
busy,
onFetch,
onForget,
editing,
setEditing,
}) {
const masked = hasSubscription && !editing;
return (
<div className="primary-block">
<div className="section-title">
<span>1</span>
<h2>Подписка</h2>
</div>
<label className="field">
<span>Subscription URL</span>
{masked ? (
<div className="masked-row">
<code className="masked">{subscriptionHost}</code>
<button className="ghost-button" type="button" onClick={() => setEditing(true)}>
Изменить
</button>
<button className="danger-button" type="button" disabled={busy} onClick={onForget}>
Забыть
</button>
</div>
) : (
<input
value={subscriptionUrl}
onChange={(event) => setSubscriptionUrl(event.target.value)}
placeholder="https://provider.example/sub/..."
/>
)}
</label>
{!masked && (
<button
className="button"
disabled={busy || !subscriptionUrl}
onClick={() => {
onFetch();
setEditing(false);
}}
>
{busy ? 'Загрузка…' : 'Загрузить серверы'}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,32 @@
import React, { useEffect } from 'react';
export function Toasts({ items, onDismiss }) {
useEffect(() => {
const timers = items.map((t) =>
t.sticky ? null : setTimeout(() => onDismiss(t.id), t.duration || 4000),
);
return () => timers.forEach((t) => t && clearTimeout(t));
}, [items, onDismiss]);
if (!items.length) return null;
return (
<div className="toasts">
{items.map((t) => (
<div key={t.id} className={`toast ${t.kind || ''}`}>
<span className={`dot ${t.kind || ''}`} style={{ marginTop: 4 }} />
<div className="body">
<strong>{t.title}</strong>
{t.message && <small>{t.message}</small>}
{t.action && (
<button className="btn btn-link sm" onClick={t.action.onClick} style={{ marginTop: 4, padding: 0 }}>
{t.action.label}
</button>
)}
</div>
<button onClick={() => onDismiss(t.id)} title="Закрыть">×</button>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { formatBytes, formatRelative } from '../utils/format.js';
import { flagFor } from '../utils/country.js';
function StatusBadge({ status }) {
const map = {
running: { dot: 'success', text: 'Работает', cls: '' },
applying: { dot: 'warning pulse', text: 'Применяем…', cls: '' },
error: { dot: 'danger', text: 'Ошибка', cls: '' },
stopped: { dot: '', text: 'Остановлен', cls: '' },
no_config: { dot: '', text: 'Не настроен', cls: '' },
};
const cfg = map[status] || map.stopped;
return (
<span className="flex">
<span className={`dot ${cfg.dot}`} />
<strong>{cfg.text}</strong>
</span>
);
}
export function Topbar({ state, status, activeServer, dirty, onRestart, onTryApply }) {
const userInfo = state?.userInfo;
const traffic = userInfo
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))}${userInfo.total ? ' / ' + formatBytes(userInfo.total) : ''}`
: null;
return (
<header className="topbar">
<div className="topbar-brand">
<span className="logo-dot" />
VPN Gateway
</div>
<div className="topbar-status">
<StatusBadge status={status} />
{activeServer && (
<div className="status-text">
<strong>
{flagFor(activeServer)} {activeServer.tag}
</strong>
<small>
{activeServer.server}:{activeServer.server_port}
{state?.appliedAt ? ` · применено ${formatRelative(state.appliedAt)}` : ''}
</small>
</div>
)}
{!activeServer && (
<small className="muted">Сервер не выбран</small>
)}
{traffic && <span className="badge neutral">{traffic}</span>}
</div>
<div className="topbar-actions">
{dirty && (
<span className="badge warning"> Несохранённые изменения</span>
)}
{state?.previousTag && (
<button className="btn btn-ghost sm" onClick={onTryApply} title="Откатить">
Откат
</button>
)}
<button
className="btn btn-secondary sm"
onClick={onRestart}
disabled={!state?.configExists}
title="Перезапустить sing-box"
>
Restart
</button>
</div>
</header>
);
}