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:
Luiz Costa
2025-11-18 09:59:36 -03:00
parent 5c0094d74e
commit f8360acab3
8 changed files with 134 additions and 31 deletions

1
backend/dist/placeholder.txt vendored Normal file
View File

@@ -0,0 +1 @@
Placeholder for embed

View File

@@ -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")

View File

@@ -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.

View File

@@ -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' }}>

View File

@@ -6,24 +6,31 @@ interface LogLineProps {
}
const escapeHtml = (text: string) => {
const map: { [key: string]: string } = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
return text.replace(/[&<>"']/g, m => map[m]);
const map: { [key: string]: string } = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;', '/': '&#x2F;' };
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 }} />;
};

View File

@@ -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) => (

View File

@@ -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" />

View File

@@ -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]);