feat: improve log viewer with auto-scroll and security enhancements
- Add auto-scroll toggle to log panels - Enhance SSE connection handling with reconnection logic - Improve HTML escaping in log lines for security - Update build script to properly handle frontend artifacts - Add graceful shutdown to backend server - Update authentication to support configurable username
This commit is contained in:
+11
-2
@@ -9,11 +9,18 @@ import { fetchLogFiles } from './api';
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
|
||||
interface TabItem {
|
||||
key: string;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
closable: boolean;
|
||||
}
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { logs, isConnected } = useSSE('/logs');
|
||||
const [availableFiles, setAvailableFiles] = useState<string[]>([]);
|
||||
const [activeTabKey, setActiveTabKey] = useState('1');
|
||||
const [tabItems, setTabItems] = useState<any[]>([]);
|
||||
const [tabItems, setTabItems] = useState<TabItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogFiles().then(res => {
|
||||
@@ -66,7 +73,9 @@ const App: React.FC = () => {
|
||||
<Header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h1 style={{ color: 'white', margin: 0 }}>📋 Web Tail Pro</h1>
|
||||
<Space>
|
||||
<Button icon={<AppstoreOutlined />} disabled>Dividir Tela (Em breve)</Button>
|
||||
<span style={{ color: isConnected ? '#4fc3f7' : '#f48fb1' }}>
|
||||
{isConnected ? '🟢 Conectado' : '🔴 Desconectado'}
|
||||
</span>
|
||||
</Space>
|
||||
</Header>
|
||||
<Content style={{ padding: '0 16px' }}>
|
||||
|
||||
@@ -6,24 +6,31 @@ interface LogLineProps {
|
||||
}
|
||||
|
||||
const escapeHtml = (text: string) => {
|
||||
const map: { [key: string]: string } = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
return text.replace(/[&<>"']/g, m => map[m]);
|
||||
const map: { [key: string]: string } = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '/': '/' };
|
||||
return text.replace(/[&<>"'\/]/g, m => map[m]);
|
||||
}
|
||||
|
||||
export const LogLine: React.FC<LogLineProps> = ({ line, format }) => {
|
||||
let parsedLine = escapeHtml(line);
|
||||
switch (format) {
|
||||
case 'json':
|
||||
try { const obj = JSON.parse(line); parsedLine = Object.keys(obj).map(k => `<span class="json-key">${k}:</span> <span class="json-value">${escapeHtml(String(obj[k]))}</span>`).join(' '); } catch (e) {}
|
||||
try {
|
||||
const obj = JSON.parse(line);
|
||||
parsedLine = Object.keys(obj).map(k => `<span class="json-key">${escapeHtml(k)}:</span> <span class="json-value">${escapeHtml(String(obj[k]))}</span>`).join(' ');
|
||||
} catch (e) {
|
||||
// Se falhar o parse JSON, mantém a linha original escapada
|
||||
}
|
||||
break;
|
||||
case 'apache':
|
||||
const re = /^(\S+) \S+ \S+ \[([\w:/]+\s[+\-]\d{4})\] "(\S+) (\S+) (\S+)" (\d{3}) (\d+|-)/;
|
||||
const matches = re.exec(line);
|
||||
if (matches) parsedLine = `<span class="log-ip">${matches[1]}</span> - - [<span class="log-time">${matches[2]}</span>] "<span class="log-method">${matches[3]}</span> <span class="log-path">${matches[4]}</span> <span class="log-proto">${matches[5]}</span>" <span class="log-status-${matches[6]}">${matches[6]}</span> <span class="log-size">${matches[7]}</span>`;
|
||||
if (matches) {
|
||||
parsedLine = `<span class="log-ip">${escapeHtml(matches[1])}</span> - - [<span class="log-time">${escapeHtml(matches[2])}</span>] "<span class="log-method">${escapeHtml(matches[3])}</span> <span class="log-path">${escapeHtml(matches[4])}</span> <span class="log-proto">${escapeHtml(matches[5])}</span>" <span class="log-status-${matches[6]}">${escapeHtml(matches[6])}</span> <span class="log-size">${escapeHtml(matches[7])}</span>`;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
const levelRe = /\b(INFO|WARN|ERROR|DEBUG|FATAL)\b/gi;
|
||||
parsedLine = parsedLine.replace(levelRe, s => `<span class="log-level-${s.toLowerCase()}">${s}</span>`);
|
||||
parsedLine = parsedLine.replace(levelRe, s => `<span class="log-level-${s.toLowerCase()}">${escapeHtml(s)}</span>`);
|
||||
}
|
||||
return <div className="log-line" dangerouslySetInnerHTML={{ __html: parsedLine }} />;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { Card, Input, Select, Space } from 'antd';
|
||||
import { Card, Input, Select, Space, Button } from 'antd';
|
||||
import { LogEntry } from '../hooks/useSSE';
|
||||
import { LogLine } from './LogLine';
|
||||
|
||||
@@ -12,6 +12,7 @@ interface LogPanelProps {
|
||||
export const LogPanel: React.FC<LogPanelProps> = ({ title, logs, filename }) => {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [format, setFormat] = useState<'generic' | 'json' | 'apache'>('generic');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
|
||||
const filteredLogs = useMemo(() => {
|
||||
if (!filename) return logs;
|
||||
@@ -20,13 +21,17 @@ export const LogPanel: React.FC<LogPanelProps> = ({ title, logs, filename }) =>
|
||||
|
||||
const displayedLogs = useMemo(() => {
|
||||
if (!filter) return filteredLogs;
|
||||
return filteredLogs.filter(log => log.line.toLowerCase().includes(filter.toLowerCase()));
|
||||
const lowerFilter = filter.toLowerCase();
|
||||
return filteredLogs.filter(log => log.line.toLowerCase().includes(lowerFilter));
|
||||
}, [filteredLogs, filter]);
|
||||
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [displayedLogs]);
|
||||
if (autoScroll && logEndRef.current) {
|
||||
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [displayedLogs, autoScroll]);
|
||||
|
||||
return (
|
||||
<Card title={title} size="small" style={{ height: '100%' }} bodyStyle={{ padding: 0, height: 'calc(100% - 57px)' }}>
|
||||
@@ -37,6 +42,13 @@ export const LogPanel: React.FC<LogPanelProps> = ({ title, logs, filename }) =>
|
||||
<Select.Option value="apache">Apache</Select.Option>
|
||||
</Select>
|
||||
<Input placeholder="Filtrar..." value={filter} onChange={(e) => setFilter(e.target.value)} style={{ width: 200 }} />
|
||||
<Button
|
||||
type={autoScroll ? 'primary' : 'default'}
|
||||
size="small"
|
||||
onClick={() => setAutoScroll(!autoScroll)}
|
||||
>
|
||||
Auto Scroll: {autoScroll ? 'ON' : 'OFF'}
|
||||
</Button>
|
||||
</Space>
|
||||
<div className="log-output">
|
||||
{displayedLogs.map((log, index) => (
|
||||
|
||||
@@ -1,28 +1,51 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { Card, Input } from 'antd';
|
||||
import { Card, Input, Space, Button } from 'antd';
|
||||
import { LogEntry } from '../hooks/useSSE';
|
||||
import { LogLine } from './LogLine';
|
||||
|
||||
export const MergedLogPanel: React.FC<{ logs: LogEntry[] }> = ({ logs }) => {
|
||||
const [filter, setFilter] = useState('');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
|
||||
const sortedAndFilteredLogs = useMemo(() => {
|
||||
// Primeiro ordena os logs
|
||||
const sorted = [...logs].sort((a, b) => {
|
||||
if (!a.timestamp) return 1;
|
||||
if (!b.timestamp) return -1;
|
||||
return a.timestamp.localeCompare(b.timestamp);
|
||||
});
|
||||
|
||||
// Depois filtra se necessário
|
||||
if (!filter) return sorted;
|
||||
return sorted.filter(log => log.line.toLowerCase().includes(filter.toLowerCase()));
|
||||
const lowerFilter = filter.toLowerCase();
|
||||
return sorted.filter(log => log.line.toLowerCase().includes(lowerFilter));
|
||||
}, [logs, filter]);
|
||||
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [sortedAndFilteredLogs]);
|
||||
if (autoScroll && logEndRef.current) {
|
||||
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [sortedAndFilteredLogs, autoScroll]);
|
||||
|
||||
return (
|
||||
<Card title="🔀 Mesclado" size="small" style={{ height: '100%' }} bodyStyle={{ padding: 0, height: 'calc(100% - 57px)' }}>
|
||||
<Input placeholder="Filtrar logs mesclados..." value={filter} onChange={(e) => setFilter(e.target.value)} style={{ margin: '8px', width: 'calc(100% - 16px)' }} />
|
||||
<Space.Compact style={{ margin: '8px', width: 'calc(100% - 16px)' }}>
|
||||
<Input
|
||||
placeholder="Filtrar logs mesclados..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
type={autoScroll ? 'primary' : 'default'}
|
||||
size="small"
|
||||
onClick={() => setAutoScroll(!autoScroll)}
|
||||
>
|
||||
Auto Scroll: {autoScroll ? 'ON' : 'OFF'}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<div className="log-output">
|
||||
{sortedAndFilteredLogs.map((log, index) => (
|
||||
<LogLine key={`merged-${log.filename}-${index}`} line={log.line} format="generic" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
|
||||
export interface LogEntry {
|
||||
filename: string;
|
||||
@@ -6,31 +6,56 @@ export interface LogEntry {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const MAX_LOG_ENTRIES = 2000;
|
||||
const RECONNECT_DELAY = 5000;
|
||||
|
||||
export const useSSE = (url: string) => {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const reconnectTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const connect = () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource(url);
|
||||
eventSourceRef.current = eventSource;
|
||||
setIsConnected(true);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
const newLog: LogEntry = JSON.parse(event.data);
|
||||
setLogs(prevLogs => [...prevLogs, newLog].slice(-2000));
|
||||
setLogs(prevLogs => [...prevLogs, newLog].slice(-MAX_LOG_ENTRIES));
|
||||
};
|
||||
|
||||
eventSource.onerror = (err) => {
|
||||
console.error("SSE Error:", err);
|
||||
eventSource.onerror = () => {
|
||||
console.error("SSE Error: Conexão perdida");
|
||||
setIsConnected(false);
|
||||
setTimeout(() => {
|
||||
if (eventSource.readyState === EventSource.CLOSED) {
|
||||
eventSource.close();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// Tenta reconectar após um delay
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (eventSourceRef.current?.readyState === EventSource.CLOSED) {
|
||||
connect();
|
||||
}
|
||||
}, RECONNECT_DELAY);
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
eventSource.close();
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user