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:
1
backend/dist/placeholder.txt
vendored
Normal file
1
backend/dist/placeholder.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Placeholder for embed
|
||||
@@ -10,8 +10,10 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"regexp"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/hpcloud/tail"
|
||||
)
|
||||
@@ -23,12 +25,14 @@ var staticFiles embed.FS
|
||||
var (
|
||||
port string
|
||||
password string
|
||||
username string
|
||||
)
|
||||
|
||||
// init() é executado antes de main(). É o lugar ideal para configurar flags.
|
||||
func init() {
|
||||
flag.StringVar(&port, "port", ":8080", "Porta para o servidor web (ex: :8080, 9090)")
|
||||
flag.StringVar(&password, "password", "", "Senha para autenticar o acesso à interface web")
|
||||
flag.StringVar(&username, "username", "admin", "Nome de usuário para autenticar o acesso à interface web")
|
||||
}
|
||||
|
||||
// LogEntry representa uma linha de log
|
||||
@@ -88,7 +92,9 @@ func handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "http://localhost:8080")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
|
||||
clientChan := broker.Subscribe()
|
||||
defer broker.Unsubscribe(clientChan)
|
||||
@@ -96,7 +102,11 @@ func handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
for {
|
||||
select {
|
||||
case entry := <-clientChan:
|
||||
data, _ := json.Marshal(entry)
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
log.Printf("Erro ao serializar entrada de log: %v", err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
case <-r.Context().Done():
|
||||
@@ -155,7 +165,7 @@ func authMiddleware(next http.Handler) http.Handler {
|
||||
requestAuth(w)
|
||||
return
|
||||
}
|
||||
usernameMatch := subtle.ConstantTimeCompare([]byte(user), []byte("admin")) == 1
|
||||
usernameMatch := subtle.ConstantTimeCompare([]byte(user), []byte(username)) == 1
|
||||
passwordMatch := subtle.ConstantTimeCompare([]byte(pass), []byte(password)) == 1
|
||||
if usernameMatch && passwordMatch {
|
||||
next.ServeHTTP(w, r)
|
||||
@@ -182,6 +192,15 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Configurar tratamento de sinais para desligamento gracioso
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
sig := <-sigChan
|
||||
log.Printf("Recebido sinal %v, encerrando servidor...", sig)
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
startTailing(logFiles)
|
||||
|
||||
distFS, err := fs.Sub(staticFiles, "dist")
|
||||
|
||||
@@ -18,7 +18,14 @@ cd ..
|
||||
|
||||
REM Passo 2: Build do Backend (Go)
|
||||
echo 🔨 Compilando o backend com arquivos embedados...
|
||||
go build -o web-tail-pro.exe ./backend/main.go
|
||||
echo 📁 Copiando artefatos do frontend para backend/dist...
|
||||
if exist backend\dist rmdir /s /q backend\dist
|
||||
mkdir backend\dist
|
||||
xcopy /E /I /Y frontend\dist\* backend\dist\
|
||||
cd backend
|
||||
go mod tidy
|
||||
go build -o ../web-tail-pro.exe ./main.go
|
||||
cd ..
|
||||
|
||||
if %errorlevel% neq 0 (
|
||||
echo ❌ Falha na compilação do backend. Abortando.
|
||||
|
||||
@@ -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