feat: добавлена обработка трафика и интерфейс для его отображения
Refs: None
This commit is contained in:
@@ -26,6 +26,62 @@ const LOG_BUFFER_SIZE = 500;
|
||||
const logBuffer = [];
|
||||
const logSubscribers = new Set();
|
||||
|
||||
const TRAFFIC_BUFFER_SIZE = 500;
|
||||
const trafficBuffer = [];
|
||||
const trafficSubscribers = new Set();
|
||||
|
||||
// Паттерны для парсинга трафика из логов sing-box.
|
||||
// sing-box пишет строки вида:
|
||||
// outbound/direct[tag]: dial tcp connection to host:port from ip:port
|
||||
// [router] matched rule #0 [rule-name], outbound: vpn, domain: example.com
|
||||
// [TCP] DIRECT host:port --> direct
|
||||
const TRAFFIC_OUTBOUND_RE = /outbound[/\\]([a-z0-9_\-]+)|\boutbound:\s*([a-z0-9_\-]+)/i;
|
||||
const TRAFFIC_DEST_RE = /(?:to|dial|connection to|DIRECT|REJECT)\s+(?:tcp\s+|udp\s+)?(?:[^\s]*\s+to\s+)?([a-zA-Z0-9._\-]+|\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})/i;
|
||||
const TRAFFIC_DOMAIN_RE = /\bdomain:\s*([a-zA-Z0-9._\-]+)/i;
|
||||
const TRAFFIC_RULE_RE = /matched\s+rule\s+#\d+\s*\[([^\]]+)\]|matched\s+\[([^\]]+)\]/i;
|
||||
|
||||
function parseTrafficLine(line) {
|
||||
const clean = line.replace(/\x1b\[\d+m/g, "").trim();
|
||||
|
||||
const obMatch = clean.match(TRAFFIC_OUTBOUND_RE);
|
||||
if (!obMatch) return null;
|
||||
const outboundRaw = (obMatch[1] || obMatch[2] || "").toLowerCase();
|
||||
if (!outboundRaw) return null;
|
||||
|
||||
let category = "other";
|
||||
if (outboundRaw === "direct" || outboundRaw.startsWith("direct")) category = "direct";
|
||||
else if (outboundRaw === "block" || outboundRaw === "reject") category = "block";
|
||||
else if (outboundRaw !== "dns-out" && outboundRaw !== "dns") category = "vpn";
|
||||
else return null; // пропускаем DNS-аутбаунды
|
||||
|
||||
const domainMatch = clean.match(TRAFFIC_DOMAIN_RE);
|
||||
const destMatch = clean.match(TRAFFIC_DEST_RE);
|
||||
|
||||
const host = domainMatch?.[1] || destMatch?.[1] || null;
|
||||
const port = destMatch?.[2] ? parseInt(destMatch[2], 10) : null;
|
||||
if (!host && !port) return null;
|
||||
|
||||
const ruleMatch = clean.match(TRAFFIC_RULE_RE);
|
||||
const matchedRule = ruleMatch?.[1] || ruleMatch?.[2] || null;
|
||||
|
||||
return {
|
||||
ts: new Date().toISOString(),
|
||||
outbound: outboundRaw,
|
||||
category,
|
||||
host: host || "",
|
||||
port,
|
||||
matchedRule,
|
||||
};
|
||||
}
|
||||
|
||||
function pushTrafficEntry(entry) {
|
||||
trafficBuffer.push(entry);
|
||||
if (trafficBuffer.length > TRAFFIC_BUFFER_SIZE) trafficBuffer.shift();
|
||||
for (const sub of trafficSubscribers) {
|
||||
try { sub(entry); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function pushLog(level, line) {
|
||||
const entry = { ts: new Date().toISOString(), level, line };
|
||||
logBuffer.push(entry);
|
||||
@@ -35,6 +91,11 @@ function pushLog(level, line) {
|
||||
subscriber(entry);
|
||||
} catch {}
|
||||
}
|
||||
// Парсим трафик из info/debug строк
|
||||
if (level === "info" || level === "debug") {
|
||||
const traffic = parseTrafficLine(line);
|
||||
if (traffic) pushTrafficEntry(traffic);
|
||||
}
|
||||
}
|
||||
|
||||
// Sing-box пишет все логи в stderr, поэтому парсим уровень из содержимого строки.
|
||||
@@ -401,6 +462,28 @@ async function handleApi(req, res) {
|
||||
return handleLogsStream(req, res);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/traffic/stream") {
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache, no-transform",
|
||||
connection: "keep-alive",
|
||||
"x-accel-buffering": "no",
|
||||
});
|
||||
for (const entry of trafficBuffer.slice(-200)) {
|
||||
res.write(`data: ${JSON.stringify(entry)}\n\n`);
|
||||
}
|
||||
const sub = (entry) => res.write(`data: ${JSON.stringify(entry)}\n\n`);
|
||||
trafficSubscribers.add(sub);
|
||||
const keepalive = setInterval(() => { try { res.write(": ping\n\n"); } catch {} }, 15000);
|
||||
req.on("close", () => { clearInterval(keepalive); trafficSubscribers.delete(sub); });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "DELETE" && req.url === "/api/traffic") {
|
||||
trafficBuffer.splice(0);
|
||||
return sendJson(res, 200, { success: true });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/api/rules") {
|
||||
return sendJson(res, 200, {
|
||||
success: true,
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { formatTime } from '../utils/format.js';
|
||||
|
||||
const MAX_ENTRIES = 800;
|
||||
const MAX_TRAFFIC = 500;
|
||||
const GROUP_WINDOW_MS = 30_000;
|
||||
|
||||
function normalizeLine(line) {
|
||||
@@ -9,7 +10,6 @@ function normalizeLine(line) {
|
||||
}
|
||||
|
||||
function groupEntries(entries) {
|
||||
// Группируем повторы: одинаковая нормализованная строка + одинаковый level в окне 30 сек.
|
||||
const out = [];
|
||||
for (const e of entries) {
|
||||
const key = `${e.level}|${normalizeLine(e.line)}`;
|
||||
@@ -26,7 +26,142 @@ function groupEntries(entries) {
|
||||
return out;
|
||||
}
|
||||
|
||||
const CATEGORY_BADGE = {
|
||||
direct: { cls: 'success', label: 'direct' },
|
||||
vpn: { cls: 'info', label: 'VPN' },
|
||||
block: { cls: 'danger', label: 'block' },
|
||||
other: { cls: '', label: 'other' },
|
||||
};
|
||||
|
||||
function TrafficTab() {
|
||||
const [traffic, setTraffic] = useState([]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [filter, setFilter] = useState('all'); // all | direct | vpn | block
|
||||
const [search, setSearch] = useState('');
|
||||
const [autoscroll, setAutoscroll] = useState(true);
|
||||
const containerRef = useRef(null);
|
||||
const pausedRef = useRef(false);
|
||||
|
||||
useEffect(() => { pausedRef.current = paused; }, [paused]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = new EventSource('/api/traffic/stream');
|
||||
source.onmessage = (ev) => {
|
||||
if (pausedRef.current) return;
|
||||
try {
|
||||
const entry = JSON.parse(ev.data);
|
||||
setTraffic((prev) => {
|
||||
const next = [...prev, entry];
|
||||
if (next.length > MAX_TRAFFIC) next.splice(0, next.length - MAX_TRAFFIC);
|
||||
return next;
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
return () => source.close();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = traffic;
|
||||
if (filter !== 'all') list = list.filter((e) => e.category === filter);
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
list = list.filter((e) =>
|
||||
e.host?.toLowerCase().includes(s) ||
|
||||
String(e.port || '').includes(s) ||
|
||||
e.outbound?.toLowerCase().includes(s) ||
|
||||
e.matchedRule?.toLowerCase().includes(s),
|
||||
);
|
||||
}
|
||||
return list;
|
||||
}, [traffic, filter, search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoscroll || !containerRef.current) return;
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}, [filtered, autoscroll]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = { direct: 0, vpn: 0, block: 0 };
|
||||
for (const e of traffic) if (e.category in c) c[e.category]++;
|
||||
return c;
|
||||
}, [traffic]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden' }}>
|
||||
<div className="filter-bar" style={{ marginBottom: 12, flexWrap: 'wrap', gap: 8 }}>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Поиск: host, порт, правило…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 180 }}
|
||||
/>
|
||||
<select className="select" value={filter} onChange={(e) => setFilter(e.target.value)}>
|
||||
<option value="all">Все ({traffic.length})</option>
|
||||
<option value="direct">direct ({counts.direct})</option>
|
||||
<option value="vpn">VPN ({counts.vpn})</option>
|
||||
<option value="block">block ({counts.block})</option>
|
||||
</select>
|
||||
<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={() => { setTraffic([]); fetch('/api/traffic', { method: 'DELETE' }).catch(() => {}); }}
|
||||
>
|
||||
Очистить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{traffic.length === 0 ? (
|
||||
<div className="muted" style={{ padding: '20px 0', textAlign: 'center' }}>
|
||||
Ожидаем трафик… Убедитесь что sing-box запущен и уровень логов не выше INFO.
|
||||
</div>
|
||||
) : (
|
||||
<div ref={containerRef} style={{ flex: 1, overflow: 'auto' }}>
|
||||
<table className="table" style={{ fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 70 }}>Время</th>
|
||||
<th style={{ width: 70 }}>Туннель</th>
|
||||
<th>Хост / IP</th>
|
||||
<th style={{ width: 55 }}>Порт</th>
|
||||
<th>Правило</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((e, i) => {
|
||||
const badge = CATEGORY_BADGE[e.category] || CATEGORY_BADGE.other;
|
||||
return (
|
||||
<tr key={i} style={{ opacity: e.category === 'block' ? 0.6 : 1 }}>
|
||||
<td className="muted text-mono" style={{ whiteSpace: 'nowrap' }}>{formatTime(e.ts)}</td>
|
||||
<td>
|
||||
<span className={`badge ${badge.cls}`} style={{ fontSize: 11 }}>{badge.label}</span>
|
||||
</td>
|
||||
<td className="text-mono" style={{ wordBreak: 'break-all' }}>{e.host || '—'}</td>
|
||||
<td className="muted text-mono">{e.port || '—'}</td>
|
||||
<td>
|
||||
{e.matchedRule
|
||||
? <span className="badge info" style={{ fontSize: 11 }}>{e.matchedRule}</span>
|
||||
: <span className="muted" style={{ fontSize: 11 }}>—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LogsPage() {
|
||||
const [tab, setTab] = useState('traffic'); // traffic | logs
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [filter, setFilter] = useState('all');
|
||||
@@ -77,60 +212,69 @@ export function LogsPage() {
|
||||
<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 className="tabs" style={{ marginLeft: 'auto', marginBottom: 0 }}>
|
||||
<button className={`tab ${tab === 'traffic' ? 'active' : ''}`} onClick={() => setTab('traffic')}>Трафик</button>
|
||||
<button className={`tab ${tab === 'logs' ? 'active' : ''}`} onClick={() => setTab('logs')}>Системные логи</button>
|
||||
</div>
|
||||
</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>
|
||||
{tab === 'traffic' && <TrafficTab />}
|
||||
|
||||
<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>
|
||||
{tab === 'logs' && (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,201 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChipsInput } from './ChipsInput.jsx';
|
||||
import { isValidCidr, isValidPort, ruleErrors, hasErrors } from '../utils/validation.js';
|
||||
import { api } from '../api.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 RULE_SET_TAG = /^[a-z0-9][a-z0-9-]*$/i;
|
||||
const validDomain = (v) => DOMAIN.test(String(v).trim());
|
||||
const validRuleSetTag = (v) => RULE_SET_TAG.test(String(v).trim());
|
||||
|
||||
const RS_PAGE_SIZE = 100;
|
||||
const RS_TYPE_LABELS = { domain: 'домен', suffix: 'суффикс', keyword: 'ключ', cidr: 'CIDR', regex: 'regex' };
|
||||
|
||||
function RuleSetBrowseModal({ tag, url, rule, onPatch, onClose }) {
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [page, setPage] = useState(0);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.ruleSets.lookup(tag, url)
|
||||
.then((d) => { setData(d); setStatus('done'); })
|
||||
.catch((err) => { setError(err.message); setStatus('error'); });
|
||||
}, [tag, url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'done') setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}, [status]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!data?.entries) return [];
|
||||
const q = search.trim().toLowerCase();
|
||||
return data.entries.filter((e) => {
|
||||
if (typeFilter !== 'all' && e.type !== typeFilter) return false;
|
||||
if (!q) return true;
|
||||
return e.value.toLowerCase().includes(q);
|
||||
});
|
||||
}, [data, search, typeFilter]);
|
||||
|
||||
function onSearchChange(v) { setSearch(v); setPage(0); }
|
||||
function onTypeChange(v) { setTypeFilter(v); setPage(0); }
|
||||
|
||||
function addEntry(entry) {
|
||||
const val = entry.value;
|
||||
switch (entry.type) {
|
||||
case 'domain': {
|
||||
const cur = new Set(rule.domains || []);
|
||||
if (!cur.has(val)) onPatch({ domains: [...(rule.domains || []), val] });
|
||||
break;
|
||||
}
|
||||
case 'suffix': {
|
||||
const cur = new Set(rule.domainSuffixes || []);
|
||||
if (!cur.has(val)) onPatch({ domainSuffixes: [...(rule.domainSuffixes || []), val] });
|
||||
break;
|
||||
}
|
||||
case 'keyword': {
|
||||
const cur = new Set(rule.domainKeywords || []);
|
||||
if (!cur.has(val)) onPatch({ domainKeywords: [...(rule.domainKeywords || []), val] });
|
||||
break;
|
||||
}
|
||||
case 'cidr': {
|
||||
const cur = new Set(rule.ipCidrs || []);
|
||||
if (!cur.has(val)) onPatch({ ipCidrs: [...(rule.ipCidrs || []), val] });
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / RS_PAGE_SIZE);
|
||||
const pageItems = filtered.slice(page * RS_PAGE_SIZE, (page + 1) * RS_PAGE_SIZE);
|
||||
|
||||
const addedValues = useMemo(() => new Set([
|
||||
...(rule.domains || []),
|
||||
...(rule.domainSuffixes || []),
|
||||
...(rule.domainKeywords || []),
|
||||
...(rule.ipCidrs || []),
|
||||
]), [rule]);
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" style={{ zIndex: 1100 }} onClick={onClose}>
|
||||
<div
|
||||
className="modal lg"
|
||||
style={{ maxWidth: 680, 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">Кликните запись чтобы добавить в правило</small>
|
||||
</div>
|
||||
<button className="btn btn-ghost sm" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
|
||||
{status === 'loading' && (
|
||||
<div style={{ padding: 32, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
Скачивание и декомпиляция…<br />
|
||||
<small>Может занять 10–30 секунд</small>
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div className="conflict-banner danger"><span>✗</span><div>{error}</div></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'done' && data && (
|
||||
<>
|
||||
<div style={{ padding: '8px 20px', borderBottom: '1px solid var(--border)', display: 'flex', 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.cidr > 0 && <span className="badge">CIDR: {data.stats.cidr.toLocaleString()}</span>}
|
||||
</div>
|
||||
<div style={{ padding: '8px 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: 130 }} value={typeFilter} onChange={(e) => onTypeChange(e.target.value)}>
|
||||
<option value="all">Все типы</option>
|
||||
{Object.entries(RS_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: '6px 0' }}>
|
||||
{filtered.length.toLocaleString()} / {data.stats.total.toLocaleString()}
|
||||
{totalPages > 1 && ` · стр. ${page + 1}/${totalPages}`}
|
||||
<span className="muted" style={{ marginLeft: 12 }}>— нажмите строку чтобы добавить в правило</span>
|
||||
</div>
|
||||
<table className="table" style={{ fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr><th style={{ width: 70 }}>Тип</th><th>Значение</th><th style={{ width: 30 }}></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pageItems.map((e, i) => {
|
||||
const already = addedValues.has(e.value);
|
||||
return (
|
||||
<tr
|
||||
key={i}
|
||||
style={{ cursor: already ? 'default' : 'pointer', opacity: already ? 0.5 : 1 }}
|
||||
onClick={() => !already && addEntry(e)}
|
||||
title={already ? 'Уже добавлено' : `Добавить в ${e.type === 'cidr' ? 'IP/CIDR' : e.type === 'suffix' ? 'суффиксы' : e.type === 'keyword' ? 'ключевые слова' : 'домены'}`}
|
||||
>
|
||||
<td><span className="badge" style={{ fontSize: 10 }}>{RS_TYPE_LABELS[e.type] || e.type}</span></td>
|
||||
<td className="text-mono" style={{ wordBreak: 'break-all', userSelect: 'all' }}>{e.value}</td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 14 }}>{already ? '✓' : '+'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex" style={{ gap: 8, padding: '10px 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: 12 }}>{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>
|
||||
);
|
||||
}
|
||||
|
||||
export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder', availableRuleSets = [] }) {
|
||||
const [view, setView] = useState(mode); // builder | json
|
||||
const [jsonDraft, setJsonDraft] = useState(() => JSON.stringify(rule, null, 2));
|
||||
const [jsonError, setJsonError] = useState('');
|
||||
const [browseTag, setBrowseTag] = useState(null); // { tag, url } | null
|
||||
const errors = ruleErrors(rule);
|
||||
|
||||
// Индекс URL по тегу из доступных rule-sets
|
||||
const ruleSetUrlMap = useMemo(() => {
|
||||
const map = {};
|
||||
for (const rs of availableRuleSets) map[rs.tag] = rs.url;
|
||||
return map;
|
||||
}, [availableRuleSets]);
|
||||
|
||||
function patch(p) {
|
||||
onUpdate(rule.id, p);
|
||||
}
|
||||
@@ -71,23 +254,51 @@ export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder'
|
||||
placeholder="geosite-runet"
|
||||
validate={validRuleSetTag}
|
||||
/>
|
||||
{/* Кнопки просмотра содержимого для выбранных rule-sets */}
|
||||
{(rule.ruleSets || []).length > 0 && (
|
||||
<div className="field-hint" style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
|
||||
{(rule.ruleSets || []).map((tag) => {
|
||||
const url = ruleSetUrlMap[tag];
|
||||
return url ? (
|
||||
<button
|
||||
key={tag}
|
||||
className="btn btn-ghost sm"
|
||||
style={{ padding: '0 6px', fontSize: 11 }}
|
||||
onClick={() => setBrowseTag({ tag, url })}
|
||||
title={`Просмотреть содержимое ${tag}`}
|
||||
>
|
||||
🔍 {tag}
|
||||
</button>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{availableRuleSets.length > 0 && (
|
||||
<div className="field-hint">
|
||||
Доступны:{' '}
|
||||
{availableRuleSets.map((rs) => (
|
||||
<button
|
||||
key={rs.tag}
|
||||
className="btn btn-ghost sm"
|
||||
style={{ padding: '0 6px', marginRight: 4 }}
|
||||
onClick={() => {
|
||||
const current = new Set(rule.ruleSets || []);
|
||||
if (!current.has(rs.tag)) {
|
||||
patch({ ruleSets: [...(rule.ruleSets || []), rs.tag] });
|
||||
}
|
||||
}}
|
||||
>
|
||||
+ {rs.tag}
|
||||
</button>
|
||||
<span key={rs.tag} style={{ display: 'inline-flex', alignItems: 'center', marginRight: 4 }}>
|
||||
<button
|
||||
className="btn btn-ghost sm"
|
||||
style={{ padding: '0 6px', marginRight: 2 }}
|
||||
onClick={() => {
|
||||
const current = new Set(rule.ruleSets || []);
|
||||
if (!current.has(rs.tag)) {
|
||||
patch({ ruleSets: [...(rule.ruleSets || []), rs.tag] });
|
||||
}
|
||||
}}
|
||||
>
|
||||
+ {rs.tag}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost sm"
|
||||
style={{ padding: '0 4px', fontSize: 12 }}
|
||||
onClick={() => setBrowseTag({ tag: rs.tag, url: rs.url })}
|
||||
title="Просмотреть содержимое"
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -198,6 +409,16 @@ export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder'
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{browseTag && (
|
||||
<RuleSetBrowseModal
|
||||
tag={browseTag.tag}
|
||||
url={browseTag.url}
|
||||
rule={rule}
|
||||
onPatch={(p) => patch(p)}
|
||||
onClose={() => setBrowseTag(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user