feat: добавлены компоненты для управления конфигурацией и логами
Добавлены новые компоненты для отображения и управления конфигурацией, логами и правилами маршрутизации. Реализована логика для работы с API, включая запросы на получение и сохранение данных. Также добавлены шаблоны правил и утилиты для валидации. Refs: None
This commit is contained in:
63
src/web/components/ConfigViewer.jsx
Normal file
63
src/web/components/ConfigViewer.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/web/components/LogsPanel.jsx
Normal file
75
src/web/components/LogsPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/web/components/RuleCard.jsx
Normal file
127
src/web/components/RuleCard.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/web/components/RulesPanel.jsx
Normal file
112
src/web/components/RulesPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/web/components/RuntimePanel.jsx
Normal file
86
src/web/components/RuntimePanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/web/components/ServerList.jsx
Normal file
32
src/web/components/ServerList.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/web/components/SubscriptionPanel.jsx
Normal file
58
src/web/components/SubscriptionPanel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user