feat: добавлены новые компоненты для управления правилами и серверами
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 25s
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 25s
- Создан компонент RuleEditorDrawer для редактирования правил с поддержкой JSON. - Добавлен компонент ServersPage для отображения и управления серверами. - Реализован компонент SettingsPage для управления подписками и конфигурациями. - Создан компонент Sidebar для навигации по приложению. - Добавлен компонент StatusPane для отображения статуса сервера. - Реализован компонент Toasts для отображения уведомлений. - Создан компонент Topbar для отображения информации о текущем состоянии. - Добавлен модуль country.js для определения страны по тегу сервера. Refs: None
This commit is contained in:
136
src/web/components/LogsPage.jsx
Normal file
136
src/web/components/LogsPage.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { formatTime } from '../utils/format.js';
|
||||
|
||||
const MAX_ENTRIES = 800;
|
||||
const GROUP_WINDOW_MS = 30_000;
|
||||
|
||||
function normalizeLine(line) {
|
||||
return String(line || '').replace(/\x1b\[\d+m/g, '').trim();
|
||||
}
|
||||
|
||||
function groupEntries(entries) {
|
||||
// Группируем повторы: одинаковая нормализованная строка + одинаковый level в окне 30 сек.
|
||||
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;
|
||||
}
|
||||
|
||||
export function LogsPage() {
|
||||
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>
|
||||
<small className="muted">{entries.length} / {MAX_ENTRIES}</small>
|
||||
</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>
|
||||
|
||||
<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