785 lines
34 KiB
JavaScript
785 lines
34 KiB
JavaScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||
import { api } from '../api.js';
|
||
import { formatRelative } from '../utils/format.js';
|
||
|
||
const TYPE_LABELS = { domain: 'домен', suffix: 'суффикс', keyword: 'ключевое слово', cidr: 'CIDR', regex: 'regex' };
|
||
const PAGE_SIZE = 100;
|
||
|
||
function RuleSetLookupModal({ tag, url, onClose }) {
|
||
const [state, setState] = useState('idle'); // idle | loading | done | error
|
||
const [error, setError] = useState('');
|
||
const [data, setData] = useState(null); // { entries, stats, cachedAt }
|
||
const [search, setSearch] = useState('');
|
||
const [filterType, setFilterType] = useState('all');
|
||
const [page, setPage] = useState(0);
|
||
const inputRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
setState('loading');
|
||
api.ruleSets.lookup(tag, url)
|
||
.then((res) => { setData(res); setState('done'); })
|
||
.catch((err) => { setError(err.message); setState('error'); });
|
||
}, [tag, url]);
|
||
|
||
useEffect(() => {
|
||
if (state === 'done') setTimeout(() => inputRef.current?.focus(), 50);
|
||
}, [state]);
|
||
|
||
const filtered = useMemo(() => {
|
||
if (!data?.entries) return [];
|
||
const q = search.trim().toLowerCase();
|
||
return data.entries.filter((e) => {
|
||
if (filterType !== 'all' && e.type !== filterType) return false;
|
||
if (!q) return true;
|
||
return e.value.toLowerCase().includes(q);
|
||
});
|
||
}, [data, search, filterType]);
|
||
|
||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||
const pageItems = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||
|
||
function onSearchChange(v) { setSearch(v); setPage(0); }
|
||
function onTypeChange(v) { setFilterType(v); setPage(0); }
|
||
|
||
return (
|
||
<div className="modal-backdrop" onClick={onClose}>
|
||
<div className="modal lg" style={{ maxWidth: 720, maxHeight: '85vh', display: 'flex', flexDirection: 'column' }} onClick={(e) => e.stopPropagation()}>
|
||
<div className="modal-head">
|
||
<div>
|
||
<h3 style={{ margin: 0 }}>Содержимое: <code style={{ fontSize: 14 }}>{tag}</code></h3>
|
||
<small className="muted">{url}</small>
|
||
</div>
|
||
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
|
||
</div>
|
||
|
||
{state === 'loading' && (
|
||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||
Скачивание и декомпиляция…<br />
|
||
<small>Может занять 10–30 секунд</small>
|
||
</div>
|
||
)}
|
||
|
||
{state === 'error' && (
|
||
<div style={{ padding: 24 }}>
|
||
<div className="conflict-banner danger"><span>✗</span><div>{error}</div></div>
|
||
</div>
|
||
)}
|
||
|
||
{state === 'done' && data && (
|
||
<>
|
||
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border)' }}>
|
||
<div className="flex" style={{ gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<span className="badge info">всего: {data.stats.total.toLocaleString()}</span>
|
||
{data.stats.domain > 0 && <span className="badge">доменов: {data.stats.domain.toLocaleString()}</span>}
|
||
{data.stats.suffix > 0 && <span className="badge">суффиксов: {data.stats.suffix.toLocaleString()}</span>}
|
||
{data.stats.keyword > 0 && <span className="badge">ключ. слов: {data.stats.keyword.toLocaleString()}</span>}
|
||
{data.stats.cidr > 0 && <span className="badge">CIDR: {data.stats.cidr.toLocaleString()}</span>}
|
||
{data.stats.regex > 0 && <span className="badge">regex: {data.stats.regex.toLocaleString()}</span>}
|
||
<span className="muted" style={{ fontSize: 12, marginLeft: 'auto' }}>
|
||
кеш: {formatRelative(data.cachedAt)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div style={{ padding: '10px 20px', borderBottom: '1px solid var(--border)', display: 'flex', gap: 8 }}>
|
||
<input
|
||
ref={inputRef}
|
||
className="input"
|
||
style={{ flex: 1 }}
|
||
placeholder="Поиск: youtube, 149.154, .ru…"
|
||
value={search}
|
||
onChange={(e) => onSearchChange(e.target.value)}
|
||
/>
|
||
<select className="select" style={{ width: 140 }} value={filterType} onChange={(e) => onTypeChange(e.target.value)}>
|
||
<option value="all">Все типы</option>
|
||
{Object.entries(TYPE_LABELS).map(([k, v]) => (
|
||
<option key={k} value={k}>{v}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div style={{ flex: 1, overflow: 'auto', padding: '0 20px' }}>
|
||
{filtered.length === 0 ? (
|
||
<div className="muted" style={{ padding: '20px 0', textAlign: 'center' }}>Ничего не найдено</div>
|
||
) : (
|
||
<>
|
||
<div style={{ fontSize: 12, color: 'var(--text-muted)', padding: '8px 0' }}>
|
||
Найдено: {filtered.length.toLocaleString()} / {data.stats.total.toLocaleString()}
|
||
{totalPages > 1 && ` · страница ${page + 1} из ${totalPages}`}
|
||
</div>
|
||
<table className="table" style={{ fontSize: 13 }}>
|
||
<thead>
|
||
<tr><th style={{ width: 80 }}>Тип</th><th>Значение</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{pageItems.map((e, i) => (
|
||
<tr key={i}>
|
||
<td><span className="badge">{TYPE_LABELS[e.type] || e.type}</span></td>
|
||
<td className="text-mono" style={{ wordBreak: 'break-all', userSelect: 'all' }}>{e.value}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{totalPages > 1 && (
|
||
<div className="flex" style={{ gap: 8, padding: '12px 0', justifyContent: 'center' }}>
|
||
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage(0)}>«</button>
|
||
<button className="btn btn-ghost sm" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>‹</button>
|
||
<span className="muted" style={{ lineHeight: '28px', fontSize: 13 }}>{page + 1} / {totalPages}</span>
|
||
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage((p) => p + 1)}>›</button>
|
||
<button className="btn btn-ghost sm" disabled={page >= totalPages - 1} onClick={() => setPage(totalPages - 1)}>»</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Каталог готовых rule-set источников для sing-box (.srs формат)
|
||
// Источники: SagerNet (официальные, используются как встроенные), runetfreedom (RKN-реестр)
|
||
const RULE_SET_CATALOG = [
|
||
{
|
||
tag: 'geosite-runet',
|
||
url: 'https://github.com/runetfreedom/russia-blocked-geosite/releases/latest/download/rule-set/ru.srs',
|
||
source: 'runetfreedom',
|
||
category: 'RU',
|
||
description: 'Заблокированные в РФ домены по реестру РКН. Обновляется автоматически из официальных источников.',
|
||
examples: ['rutracker.org', 'youtube.com', 'instagram.com', 'facebook.com', 'twitter.com'],
|
||
use: 'vpn — маршрутизировать заблокированные домены через VPN.',
|
||
builtIn: false,
|
||
},
|
||
{
|
||
tag: 'geoip-ru',
|
||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs',
|
||
source: 'SagerNet/sing-geoip',
|
||
category: 'RU',
|
||
description: 'IP-диапазоны, зарегистрированные в России (RIPE NCC). Покрывает российские хостинги, банки, госсайты.',
|
||
examples: ['77.88.0.0/18 (Яндекс)', '95.173.128.0/19 (МТС)', '213.180.192.0/19 (Яндекс)'],
|
||
use: 'direct — российские сервисы без VPN.',
|
||
builtIn: true,
|
||
},
|
||
{
|
||
tag: 'geosite-category-ru',
|
||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs',
|
||
source: 'SagerNet/sing-geosite',
|
||
category: 'RU',
|
||
description: 'Домены российских сервисов: Яндекс, VK, Mail.ru, Сбербанк, банки, госуслуги. Не заблокированные, а просто российские.',
|
||
examples: ['yandex.ru', 'vk.com', 'mail.ru', 'sberbank.ru', 'gosuslugi.ru', 'ozon.ru'],
|
||
use: 'direct — чтобы российские сайты открывались с российским IP (нужно для оплаты и т.п.).',
|
||
builtIn: true,
|
||
},
|
||
{
|
||
tag: 'geosite-google',
|
||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-google.srs',
|
||
source: 'SagerNet/sing-geosite',
|
||
category: 'Сервисы',
|
||
description: 'Все домены Google: поиск, Gmail, YouTube, Drive, Maps, Google API, reCAPTCHA и пр.',
|
||
examples: ['google.com', 'googleapis.com', 'googlevideo.com', 'gstatic.com', 'ggpht.com'],
|
||
use: 'vpn — если Google заблокирован или нужна стабильная работа сервисов.',
|
||
builtIn: false,
|
||
},
|
||
{
|
||
tag: 'geosite-youtube',
|
||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-youtube.srs',
|
||
source: 'SagerNet/sing-geosite',
|
||
category: 'Сервисы',
|
||
description: 'Только домены YouTube и связанных CDN. Меньше чем полный Google.',
|
||
examples: ['youtube.com', 'youtu.be', 'ytimg.com', 'googlevideo.com'],
|
||
use: 'vpn — для разблокировки YouTube.',
|
||
builtIn: false,
|
||
},
|
||
{
|
||
tag: 'geosite-telegram',
|
||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-telegram.srs',
|
||
source: 'SagerNet/sing-geosite',
|
||
category: 'Сервисы',
|
||
description: 'Домены и IP Telegram. Включает CDN, API и голосовые серверы.',
|
||
examples: ['telegram.org', 't.me', 'telegra.ph', '149.154.160.0/20'],
|
||
use: 'vpn — разблокировка в РФ. direct — если хочешь избежать задержек.',
|
||
builtIn: false,
|
||
},
|
||
{
|
||
tag: 'geosite-openai',
|
||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-openai.srs',
|
||
source: 'SagerNet/sing-geosite',
|
||
category: 'Сервисы',
|
||
description: 'ChatGPT, OpenAI API, Dall-E и другие сервисы OpenAI.',
|
||
examples: ['openai.com', 'chatgpt.com', 'oaistatic.com', 'oaiusercontent.com'],
|
||
use: 'vpn — OpenAI заблокирован в РФ и ряде других стран.',
|
||
builtIn: false,
|
||
},
|
||
{
|
||
tag: 'geosite-apple',
|
||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-apple.srs',
|
||
source: 'SagerNet/sing-geosite',
|
||
category: 'Сервисы',
|
||
description: 'App Store, iCloud, Apple CDN, push-уведомления (APNs), iMessage.',
|
||
examples: ['apple.com', 'icloud.com', 'mzstatic.com', 'apple-cloudkit.com'],
|
||
use: 'direct — Apple обычно работает без VPN. vpn — если нужен другой регион App Store.',
|
||
builtIn: false,
|
||
},
|
||
{
|
||
tag: 'geosite-github',
|
||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-github.srs',
|
||
source: 'SagerNet/sing-geosite',
|
||
category: 'Разработка',
|
||
description: 'GitHub, GitHub Actions, GitHub Pages, raw.githubusercontent.com.',
|
||
examples: ['github.com', 'githubusercontent.com', 'github.io', 'githubassets.com'],
|
||
use: 'vpn — если GitHub замедлен или заблокирован.',
|
||
builtIn: false,
|
||
},
|
||
{
|
||
tag: 'geosite-category-ads-all',
|
||
url: 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ads-all.srs',
|
||
source: 'SagerNet/sing-geosite',
|
||
category: 'Блокировка',
|
||
description: 'Рекламные сети, трекеры, аналитика. Тысячи доменов.',
|
||
examples: ['doubleclick.net', 'googlesyndication.com', 'amazon-adsystem.com'],
|
||
use: 'block — блокировка рекламы и трекеров на уровне DNS.',
|
||
builtIn: false,
|
||
},
|
||
];
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
const CATALOG_CATEGORIES = ['Все', ...Array.from(new Set(RULE_SET_CATALOG.map((r) => r.category)))];
|
||
|
||
function CatalogEntry({ entry, added, busy, onAdd, onLookup }) {
|
||
const [open, setOpen] = useState(false);
|
||
return (
|
||
<div style={{ border: '1px solid var(--border)', borderRadius: 8, padding: '10px 14px', marginBottom: 8 }}>
|
||
<div className="flex" style={{ alignItems: 'center', gap: 8 }}>
|
||
<div style={{ flex: 1 }}>
|
||
<div className="flex" style={{ alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||
<strong className="text-mono" style={{ fontSize: 13 }}>{entry.tag}</strong>
|
||
<span className="badge info" style={{ fontSize: 11 }}>{entry.category}</span>
|
||
<span className="muted" style={{ fontSize: 12 }}>{entry.source}</span>
|
||
{entry.builtIn && (
|
||
<span className="badge success" style={{ fontSize: 11 }} title="Загружается автоматически при включённом RU direct">встроен</span>
|
||
)}
|
||
</div>
|
||
<div style={{ fontSize: 13, marginTop: 2, color: 'var(--text)' }}>{entry.description}</div>
|
||
</div>
|
||
<div className="flex" style={{ gap: 6, flexShrink: 0 }}>
|
||
<button
|
||
className="btn btn-ghost sm"
|
||
onClick={() => setOpen((o) => !o)}
|
||
title="Примеры и подсказка"
|
||
>
|
||
{open ? '▲' : '▼'}
|
||
</button>
|
||
<button
|
||
className="btn btn-ghost sm"
|
||
onClick={() => onLookup(entry)}
|
||
title="Просмотреть содержимое и искать внутри"
|
||
>
|
||
🔍
|
||
</button>
|
||
<button
|
||
className="btn btn-secondary sm"
|
||
disabled={busy || added}
|
||
onClick={() => onAdd(entry)}
|
||
>
|
||
{added ? '✓ добавлен' : '+ Добавить'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{open && (
|
||
<div style={{ marginTop: 10, paddingTop: 10, borderTop: '1px solid var(--border)' }}>
|
||
<div style={{ fontSize: 12, marginBottom: 6 }}>
|
||
<span className="muted">Примеры содержимого: </span>
|
||
{entry.examples.map((ex, i) => (
|
||
<span key={i}>
|
||
<code style={{ background: 'var(--bg-muted)', borderRadius: 3, padding: '1px 5px', fontSize: 11 }}>{ex}</code>
|
||
{i < entry.examples.length - 1 ? ' ' : ''}
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div style={{ fontSize: 12 }}>
|
||
<span className="muted">Рекомендуемый outbound: </span>
|
||
<span>{entry.use}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SagerNetSearchCard({ ruleSets, onAdd, busy }) {
|
||
const [open, setOpen] = useState(false);
|
||
const [status, setStatus] = useState('idle'); // idle | loading | done | error
|
||
const [catalog, setCatalog] = useState(null); // { geosite, geoip, cachedAt }
|
||
const [error, setError] = useState('');
|
||
const [query, setQuery] = useState('');
|
||
const [repoFilter, setRepoFilter] = useState('all'); // all | geosite | geoip
|
||
|
||
function load() {
|
||
if (status !== 'idle') return;
|
||
setStatus('loading');
|
||
api.ruleSets.sagernetCatalog()
|
||
.then((d) => { setCatalog(d); setStatus('done'); })
|
||
.catch((err) => { setError(err.message); setStatus('error'); });
|
||
}
|
||
|
||
function toggle() {
|
||
if (!open && status === 'idle') load();
|
||
setOpen((o) => !o);
|
||
}
|
||
|
||
const results = useMemo(() => {
|
||
if (!catalog) return [];
|
||
const q = query.trim().toLowerCase();
|
||
const toItem = (repo) => (name) => ({ name, repo, url: `https://cdn.jsdelivr.net/gh/SagerNet/sing-${repo}@rule-set/${name}.srs` });
|
||
const gs = repoFilter !== 'geoip' ? (catalog.geosite || []).map(toItem('geosite')) : [];
|
||
const gi = repoFilter !== 'geosite' ? (catalog.geoip || []).map(toItem('geoip')) : [];
|
||
const all = [...gs, ...gi];
|
||
if (!q) return all;
|
||
return all.filter((item) => item.name.includes(q));
|
||
}, [catalog, query, repoFilter]);
|
||
|
||
const addedTags = new Set(ruleSets.map((rs) => rs.tag));
|
||
|
||
return (
|
||
<div className="card">
|
||
<div className="card-header" style={{ cursor: 'pointer' }} onClick={toggle}>
|
||
<h2>Поиск в каталоге SagerNet</h2>
|
||
<div className="flex" style={{ gap: 8, alignItems: 'center' }}>
|
||
{status === 'done' && catalog && (
|
||
<span className="badge info" style={{ fontSize: 11 }}>
|
||
{(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} rule-sets
|
||
</span>
|
||
)}
|
||
<span className="muted" style={{ fontSize: 13 }}>{open ? '▲' : '▼'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{open && (
|
||
<>
|
||
{status === 'loading' && (
|
||
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||
Загрузка списка из GitHub…
|
||
</div>
|
||
)}
|
||
{status === 'error' && (
|
||
<div className="conflict-banner danger" style={{ marginTop: 8 }}>
|
||
<span>✗</span><div>{error}</div>
|
||
</div>
|
||
)}
|
||
{status === 'done' && (
|
||
<>
|
||
<small className="muted" style={{ display: 'block', marginBottom: 12 }}>
|
||
Полный список rule-sets из репозиториев <strong>SagerNet/sing-geosite</strong> и <strong>SagerNet/sing-geoip</strong>.
|
||
Ищите по имени: <code>steam</code>, <code>gaming</code>, <code>netflix</code>, <code>apple</code> и т.д.
|
||
Кеш обновляется раз в 24 ч.
|
||
</small>
|
||
{catalog.fallback && (
|
||
<div className="conflict-banner warning" style={{ marginBottom: 12 }}>
|
||
<span>!</span><div>{catalog.warning || 'Показан встроенный fallback-каталог.'}</div>
|
||
</div>
|
||
)}
|
||
<div className="flex" style={{ gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||
<input
|
||
className="input"
|
||
style={{ flex: 1, minWidth: 180 }}
|
||
placeholder="steam, gaming, netflix, cloudflare…"
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
<select className="select" style={{ width: 130 }} value={repoFilter} onChange={(e) => setRepoFilter(e.target.value)}>
|
||
<option value="all">geosite + geoip</option>
|
||
<option value="geosite">только geosite</option>
|
||
<option value="geoip">только geoip</option>
|
||
</select>
|
||
</div>
|
||
|
||
{query.trim() === '' ? (
|
||
<div className="muted" style={{ fontSize: 13, padding: '8px 0' }}>
|
||
Введите запрос — покажем совпадения ({(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} доступно)
|
||
</div>
|
||
) : results.length === 0 ? (
|
||
<div className="muted" style={{ fontSize: 13, padding: '8px 0' }}>Ничего не найдено</div>
|
||
) : (
|
||
<table className="table" style={{ fontSize: 13 }}>
|
||
<thead>
|
||
<tr>
|
||
<th style={{ width: 60 }}>Тип</th>
|
||
<th>Тег</th>
|
||
<th style={{ width: 120 }}></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{results.slice(0, 100).map((item) => (
|
||
<tr key={item.name}>
|
||
<td><span className={`badge ${item.repo === 'geosite' ? 'info' : ''}`} style={{ fontSize: 11 }}>{item.repo}</span></td>
|
||
<td className="text-mono">{item.name}</td>
|
||
<td style={{ textAlign: 'right' }}>
|
||
{addedTags.has(item.name) ? (
|
||
<span className="badge success" style={{ fontSize: 11 }}>✓ добавлен</span>
|
||
) : (
|
||
<button
|
||
className="btn btn-secondary sm"
|
||
disabled={busy}
|
||
onClick={() => onAdd({ tag: item.name, url: item.url })}
|
||
>
|
||
+ Добавить
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
{results.length > 100 && (
|
||
<div className="muted" style={{ fontSize: 12, marginTop: 8 }}>
|
||
Показано 100 из {results.length} — уточните запрос
|
||
</div>
|
||
)}
|
||
<div className="muted" style={{ fontSize: 11, marginTop: 12 }}>
|
||
кеш: {catalog.cachedAt ? formatRelative(catalog.cachedAt) : '—'}
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RuleSetsCard({ pushToast }) {
|
||
const [ruleSets, setRuleSets] = useState([]);
|
||
const [newTag, setNewTag] = useState('');
|
||
const [newUrl, setNewUrl] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
const [search, setSearch] = useState('');
|
||
const [category, setCategory] = useState('Все');
|
||
const [lookup, setLookup] = useState(null); // { tag, url }
|
||
|
||
useEffect(() => {
|
||
api.ruleSets.get().then((d) => setRuleSets(d.ruleSets || [])).catch(() => {});
|
||
}, []);
|
||
|
||
async function save(next) {
|
||
setBusy(true);
|
||
try {
|
||
const data = await api.ruleSets.save(next);
|
||
setRuleSets(data.ruleSets || []);
|
||
pushToast({ kind: 'success', title: 'Rule-sets сохранены' });
|
||
} catch (err) {
|
||
pushToast({ kind: 'danger', title: 'Ошибка', message: err.message });
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
function addNew() {
|
||
const tag = newTag.trim();
|
||
const url = newUrl.trim();
|
||
if (!tag || !url) return;
|
||
if (!/^[a-z0-9][a-z0-9_.@!-]*$/i.test(tag)) {
|
||
pushToast({ kind: 'danger', title: 'Невалидный тег', message: 'Буквы, цифры и символы - _ . @ !' });
|
||
return;
|
||
}
|
||
if (ruleSets.some((rs) => rs.tag === tag)) {
|
||
pushToast({ kind: 'danger', title: 'Тег уже существует' });
|
||
return;
|
||
}
|
||
const next = [...ruleSets, { tag, url }];
|
||
setNewTag('');
|
||
setNewUrl('');
|
||
save(next);
|
||
}
|
||
|
||
function remove(tag) {
|
||
save(ruleSets.filter((rs) => rs.tag !== tag));
|
||
}
|
||
|
||
function addFromCatalog(entry) {
|
||
if (ruleSets.some((rs) => rs.tag === entry.tag)) {
|
||
pushToast({ kind: 'info', title: `${entry.tag} уже добавлен` });
|
||
return;
|
||
}
|
||
save([...ruleSets, { tag: entry.tag, url: entry.url }]);
|
||
}
|
||
|
||
const q = search.trim().toLowerCase();
|
||
const filtered = RULE_SET_CATALOG.filter((entry) => {
|
||
if (category !== 'Все' && entry.category !== category) return false;
|
||
if (!q) return true;
|
||
return (
|
||
entry.tag.includes(q) ||
|
||
entry.description.toLowerCase().includes(q) ||
|
||
entry.source.toLowerCase().includes(q) ||
|
||
entry.examples.some((ex) => ex.toLowerCase().includes(q))
|
||
);
|
||
});
|
||
|
||
return (
|
||
<>
|
||
<div className="card">
|
||
<div className="card-header">
|
||
<h2>Источники (rule-sets)</h2>
|
||
</div>
|
||
<small className="muted" style={{ display: 'block', marginBottom: 16 }}>
|
||
Geo-базы в формате <strong>.srs</strong> (sing-box). Sing-box скачает их автоматически при применении.
|
||
<strong> .dat файлы (v2ray) не поддерживаются</strong>.
|
||
</small>
|
||
|
||
{ruleSets.length > 0 && (
|
||
<>
|
||
<div className="field-label" style={{ marginBottom: 6 }}>Подключённые</div>
|
||
<table className="table" style={{ marginBottom: 20 }}>
|
||
<thead>
|
||
<tr>
|
||
<th>Тег</th>
|
||
<th>URL</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{ruleSets.map((rs) => (
|
||
<tr key={rs.tag}>
|
||
<td className="text-mono" style={{ whiteSpace: 'nowrap' }}>{rs.tag}</td>
|
||
<td className="muted" style={{ fontSize: 12, wordBreak: 'break-all' }}>{rs.url}</td>
|
||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||
<button className="btn btn-ghost sm" style={{ marginRight: 4 }} onClick={() => setLookup(rs)} title="Просмотреть содержимое">🔍</button>
|
||
<button className="btn btn-ghost sm" disabled={busy} onClick={() => remove(rs.tag)}>×</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</>
|
||
)}
|
||
|
||
<div className="field-label" style={{ marginBottom: 8 }}>Каталог</div>
|
||
<div className="flex" style={{ gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||
<input
|
||
className="input"
|
||
style={{ flex: 1, minWidth: 180 }}
|
||
placeholder="Поиск: telegram, реклама, youtube…"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
<select className="select" style={{ width: 140 }} value={category} onChange={(e) => setCategory(e.target.value)}>
|
||
{CATALOG_CATEGORIES.map((c) => <option key={c}>{c}</option>)}
|
||
</select>
|
||
</div>
|
||
|
||
{filtered.length === 0 && (
|
||
<div className="muted" style={{ fontSize: 13, marginBottom: 12 }}>Ничего не найдено</div>
|
||
)}
|
||
{filtered.map((entry) => (
|
||
<CatalogEntry
|
||
key={entry.tag}
|
||
entry={entry}
|
||
added={ruleSets.some((rs) => rs.tag === entry.tag)}
|
||
busy={busy}
|
||
onAdd={addFromCatalog}
|
||
onLookup={(e) => setLookup(e)}
|
||
/>
|
||
))}
|
||
|
||
<div className="field" style={{ marginTop: 16 }}>
|
||
<span className="field-label">Добавить свой .srs</span>
|
||
<div className="flex" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||
<input
|
||
className="input"
|
||
style={{ width: 200 }}
|
||
placeholder="тег (напр. geosite-custom)"
|
||
value={newTag}
|
||
onChange={(e) => setNewTag(e.target.value)}
|
||
/>
|
||
<input
|
||
className="input"
|
||
style={{ flex: 1, minWidth: 200 }}
|
||
placeholder="https://…/rule-set.srs"
|
||
value={newUrl}
|
||
onChange={(e) => setNewUrl(e.target.value)}
|
||
/>
|
||
<button className="btn btn-primary" disabled={busy || !newTag || !newUrl} onClick={addNew}>
|
||
Добавить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<SagerNetSearchCard ruleSets={ruleSets} onAdd={addFromCatalog} busy={busy} />
|
||
{lookup && (
|
||
<RuleSetLookupModal
|
||
tag={lookup.tag}
|
||
url={lookup.url}
|
||
onClose={() => setLookup(null)}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function PortsCard({ state }) {
|
||
const isClient = state?.mode === 'client';
|
||
return (
|
||
<div className="card">
|
||
<div className="card-header"><h2>{isClient ? 'Локальные порты' : 'Порты и маршруты'}</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">HTTP/SOCKS proxy</span><span className="val text-mono">{isClient ? '127.0.0.1' : state?.proxyBindIp || '0.0.0.0'}:{state?.proxyPort || 8080}</span></div>
|
||
{!isClient && <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>
|
||
</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}
|
||
/>
|
||
<RuleSetsCard pushToast={pushToast} />
|
||
<PortsCard state={state} />
|
||
</div>
|
||
);
|
||
}
|