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) { return String(line || '').replace(/\x1b\[\d+m/g, '').trim(); } function groupEntries(entries) { const out = []; for (const e of entries) { const key = `${e.level}|${normalizeLine(e.line)}`; const last = out[out.length - 1]; const ts = new Date(e.ts).getTime(); if (last && last._key === key && ts - last._lastTs < GROUP_WINDOW_MS) { last.count += 1; last._lastTs = ts; last.lastTs = e.ts; } else { out.push({ ...e, _key: key, _lastTs: ts, count: 1, lastTs: e.ts }); } } 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.
) : (
{filtered.map((e, i) => { const badge = CATEGORY_BADGE[e.category] || CATEGORY_BADGE.other; return ( ); })}
Время Туннель Хост / IP Порт Правило
{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'); const [search, setSearch] = useState(''); const [autoscroll, setAutoscroll] = useState(true); const [grouped, setGrouped] = useState(true); 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 > MAX_ENTRIES) next.splice(0, next.length - MAX_ENTRIES); return next; }); } catch {} }; return () => source.close(); }, []); const filtered = useMemo(() => { let list = entries; if (filter !== 'all') list = list.filter((e) => e.level === filter); if (search) { const s = search.toLowerCase(); list = list.filter((e) => normalizeLine(e.line).toLowerCase().includes(s)); } return grouped ? groupEntries(list) : list; }, [entries, filter, search, grouped]); useEffect(() => { if (!autoscroll || !containerRef.current) return; containerRef.current.scrollTop = containerRef.current.scrollHeight; }, [filtered, autoscroll]); function copy(text) { navigator.clipboard?.writeText(text).catch(() => {}); } return (

Логи sing-box

{tab === 'traffic' && } {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}
); })}
)}
); }