Files
harbor-net/src/web/components/LogsPage.jsx
Dmitriy Petrov aab7533438
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 17s
Refine routing defaults for global and device fallbacks
2026-05-09 09:53:12 +03:00

338 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}