All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 17s
338 lines
14 KiB
JavaScript
338 lines
14 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 getDeviceName(sourceIp, devices) {
|
||
if (!sourceIp || !devices?.length) return null;
|
||
for (const d of devices) {
|
||
if (d.enabled === false) continue;
|
||
const ip = d.ip || d.sourceIp || (d.sourceIps || [])[0];
|
||
const plain = ip?.endsWith('/32') ? ip.slice(0, -3) : ip;
|
||
if (plain === sourceIp) return d.name;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function groupTraffic(list, sortBy = 'time') {
|
||
const map = new Map();
|
||
for (const e of list) {
|
||
const key = `${e.sourceIp || ''}|${e.category}|${e.host}|${e.port}|${e.matchedRule || ''}`;
|
||
const ts = new Date(e.ts).getTime();
|
||
if (map.has(key)) {
|
||
const g = map.get(key);
|
||
g.count++;
|
||
g._lastTs = ts;
|
||
g.lastTs = e.ts;
|
||
} else {
|
||
map.set(key, { ...e, _key: key, _lastTs: ts, count: 1, lastTs: e.ts });
|
||
}
|
||
}
|
||
const arr = Array.from(map.values());
|
||
if (sortBy === 'count') return arr.sort((a, b) => b.count - a.count || b._lastTs - a._lastTs);
|
||
return arr.sort((a, b) => b._lastTs - a._lastTs);
|
||
}
|
||
|
||
function TrafficTab({ devices = [] }) {
|
||
const [traffic, setTraffic] = useState([]);
|
||
const [paused, setPaused] = useState(false);
|
||
const [filter, setFilter] = useState('all'); // all | direct | vpn | block
|
||
const [search, setSearch] = useState('');
|
||
const [grouped, setGrouped] = useState(true);
|
||
const [sortBy, setSortBy] = useState('count'); // time | count
|
||
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) ||
|
||
e.sourceIp?.toLowerCase().includes(s) ||
|
||
getDeviceName(e.sourceIp, devices)?.toLowerCase().includes(s),
|
||
);
|
||
}
|
||
return grouped ? groupTraffic(list, sortBy) : list;
|
||
}, [traffic, filter, search, grouped, sortBy, devices]);
|
||
|
||
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={grouped} onChange={(e) => setGrouped(e.target.checked)} />
|
||
Группировать
|
||
</label>
|
||
{grouped && (
|
||
<select className="select" value={sortBy} onChange={(e) => setSortBy(e.target.value)} style={{ width: 'auto' }}>
|
||
<option value="count">По частоте</option>
|
||
<option value="time">По времени</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 style={{ width: 110 }}>Устройство</th>
|
||
<th>Хост / IP</th>
|
||
<th style={{ width: 55 }}>Порт</th>
|
||
<th>Правило</th>
|
||
<th style={{ width: 40 }}></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filtered.map((e, i) => {
|
||
const badge = CATEGORY_BADGE[e.category] || CATEGORY_BADGE.other;
|
||
const deviceName = getDeviceName(e.sourceIp, devices);
|
||
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={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 110 }}>
|
||
{deviceName
|
||
? <span style={{ fontSize: 11 }}>{deviceName}</span>
|
||
: e.sourceIp
|
||
? <span className="muted" style={{ fontSize: 10 }}>{e.sourceIp}</span>
|
||
: <span className="muted" style={{ fontSize: 11 }}>—</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>
|
||
<td className="muted text-mono" style={{ textAlign: 'right', fontSize: 11 }}>
|
||
{e.count > 1 && <span className="repeat">×{e.count}</span>}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function LogsPage({ devices = [] }) {
|
||
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 devices={devices} />}
|
||
|
||
{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>
|
||
);
|
||
}
|