diff --git a/src/server/index.js b/src/server/index.js
index ca04c42..5690839 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -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,
diff --git a/src/web/components/LogsPage.jsx b/src/web/components/LogsPage.jsx
index 153efe4..1112825 100644
--- a/src/web/components/LogsPage.jsx
+++ b/src/web/components/LogsPage.jsx
@@ -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 (
+
+
+ setSearch(e.target.value)}
+ style={{ flex: 1, minWidth: 180 }}
+ />
+
+
+
+
+
+
+ {traffic.length === 0 ? (
+
+ Ожидаем трафик… Убедитесь что sing-box запущен и уровень логов не выше INFO.
+
+ ) : (
+
+
+
+
+ | Время |
+ Туннель |
+ Хост / IP |
+ Порт |
+ Правило |
+
+
+
+ {filtered.map((e, i) => {
+ const badge = CATEGORY_BADGE[e.category] || CATEGORY_BADGE.other;
+ return (
+
+ | {formatTime(e.ts)} |
+
+ {badge.label}
+ |
+ {e.host || '—'} |
+ {e.port || '—'} |
+
+ {e.matchedRule
+ ? {e.matchedRule}
+ : —}
+ |
+
+ );
+ })}
+
+
+
+ )}
+
+ );
+}
+
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() {
Логи sing-box
-
{entries.length} / {MAX_ENTRIES}
+
+
+
+
-
- setSearch(e.target.value)}
- style={{ flex: 1, minWidth: 200 }}
- />
-
-
-
-
-
-
+ {tab === 'traffic' &&
}
-
- {filtered.length === 0 &&
Логов пока нет.
}
- {filtered.map((entry, index) => {
- const text = normalizeLine(entry.line);
- if (grouped && entry.count > 1) {
- return (
-
- {formatTime(entry.ts)}
-
- {entry.level}
-
- {text}
- ×{entry.count}
-
- );
- }
- return (
-
copy(`${formatTime(entry.ts)} ${entry.level} ${text}`)}
- title="Двойной клик — скопировать"
- >
- {formatTime(entry.ts)}
- {entry.level}
- {text}
-
- );
- })}
-
+ {tab === 'logs' && (
+ <>
+
+ setSearch(e.target.value)}
+ style={{ flex: 1, minWidth: 200 }}
+ />
+
+
+
+
+
+
+
+
+ {filtered.length === 0 &&
Логов пока нет.
}
+ {filtered.map((entry, index) => {
+ const text = normalizeLine(entry.line);
+ if (grouped && entry.count > 1) {
+ return (
+
+ {formatTime(entry.ts)}
+
+ {entry.level}
+
+ {text}
+ ×{entry.count}
+
+ );
+ }
+ return (
+
copy(`${formatTime(entry.ts)} ${entry.level} ${text}`)}
+ title="Двойной клик — скопировать"
+ >
+ {formatTime(entry.ts)}
+ {entry.level}
+ {text}
+
+ );
+ })}
+
+ >
+ )}
);
}
diff --git a/src/web/components/RuleEditorDrawer.jsx b/src/web/components/RuleEditorDrawer.jsx
index 5ee3d97..51b06e3 100644
--- a/src/web/components/RuleEditorDrawer.jsx
+++ b/src/web/components/RuleEditorDrawer.jsx
@@ -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 (
+
+
e.stopPropagation()}
+ >
+
+
+
Содержимое: {tag}
+ Кликните запись чтобы добавить в правило
+
+
+
+
+ {status === 'loading' && (
+
+ Скачивание и декомпиляция…
+ Может занять 10–30 секунд
+
+ )}
+ {status === 'error' && (
+
+ )}
+
+ {status === 'done' && data && (
+ <>
+
+ всего: {data.stats.total.toLocaleString()}
+ {data.stats.domain > 0 && доменов: {data.stats.domain.toLocaleString()}}
+ {data.stats.suffix > 0 && суффиксов: {data.stats.suffix.toLocaleString()}}
+ {data.stats.cidr > 0 && CIDR: {data.stats.cidr.toLocaleString()}}
+
+
+ onSearchChange(e.target.value)}
+ />
+
+
+
+ {filtered.length === 0 ? (
+
Ничего не найдено
+ ) : (
+ <>
+
+ {filtered.length.toLocaleString()} / {data.stats.total.toLocaleString()}
+ {totalPages > 1 && ` · стр. ${page + 1}/${totalPages}`}
+ — нажмите строку чтобы добавить в правило
+
+
+
+ | Тип | Значение | |
+
+
+ {pageItems.map((e, i) => {
+ const already = addedValues.has(e.value);
+ return (
+ !already && addEntry(e)}
+ title={already ? 'Уже добавлено' : `Добавить в ${e.type === 'cidr' ? 'IP/CIDR' : e.type === 'suffix' ? 'суффиксы' : e.type === 'keyword' ? 'ключевые слова' : 'домены'}`}
+ >
+ | {RS_TYPE_LABELS[e.type] || e.type} |
+ {e.value} |
+ {already ? '✓' : '+'} |
+
+ );
+ })}
+
+
+ {totalPages > 1 && (
+
+
+
+ {page + 1} / {totalPages}
+
+
+
+ )}
+ >
+ )}
+
+ >
+ )}
+
+
+ );
+}
+
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 && (
+
+ {(rule.ruleSets || []).map((tag) => {
+ const url = ruleSetUrlMap[tag];
+ return url ? (
+
+ ) : null;
+ })}
+
+ )}
{availableRuleSets.length > 0 && (
Доступны:{' '}
{availableRuleSets.map((rs) => (
-
+
+
+
+
))}
)}
@@ -198,6 +409,16 @@ export function RuleEditor({ rule, onUpdate, onClose, onRemove, mode = 'builder'
>
)}
+
+ {browseTag && (
+ patch(p)}
+ onClose={() => setBrowseTag(null)}
+ />
+ )}
);
}