feat: добавлены компоненты для управления конфигурацией и логами

Добавлены новые компоненты для отображения и управления конфигурацией, логами и правилами маршрутизации. Реализована логика для работы с API, включая запросы на получение и сохранение данных. Также добавлены шаблоны правил и утилиты для валидации.

Refs: None
This commit is contained in:
2026-05-08 18:23:29 +03:00
parent 7d41dd86e7
commit 8789496ae6
24 changed files with 2987 additions and 364 deletions

View File

@@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react';
import { api } from '../api.js';
export function ConfigViewer({ open, onClose }) {
const [config, setConfig] = useState(null);
const [error, setError] = 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;
};
}, [open]);
if (!open) return null;
const text = config ? JSON.stringify(config, null, 2) : '';
function copy() {
navigator.clipboard?.writeText(text).catch(() => {});
}
function download() {
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'sing-box-config.json';
a.click();
URL.revokeObjectURL(url);
}
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>
</div>
{error && <div className="error">{error}</div>}
{!error && !config && <p>Конфиг ещё не сгенерирован.</p>}
{config && <pre className="config-view">{text}</pre>}
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
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,127 @@
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,112 @@
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

@@ -0,0 +1,86 @@
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

@@ -0,0 +1,32 @@
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,58 @@
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>
);
}