281 lines
11 KiB
JavaScript
281 lines
11 KiB
JavaScript
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 (
|
||
<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');
|
||
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 (
|
||
<div className="card" style={{ display: 'flex', flexDirection: 'column', minHeight: 'calc(100vh - 160px)' }}>
|
||
<div className="card-header">
|
||
<h2>Логи sing-box</h2>
|
||
<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>
|
||
|
||
{tab === 'traffic' && <TrafficTab />}
|
||
|
||
{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>
|
||
);
|
||
}
|