feat: добавлена обработка трафика и интерфейс для его отображения
Refs: None
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user