feat: initialize web tail pro application with frontend and backend
- Add frontend React application with log viewing components - Implement backend Go server for log tailing and SSE streaming - Include build scripts for both frontend and backend - Set up basic authentication and file handling - Add styling and log formatting capabilities
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
gotail
|
||||||
|
gotail.exe
|
||||||
|
|
||||||
|
# IDE specific files
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS specific files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
16
backend/go.mod
Normal file
16
backend/go.mod
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
module github.com/seu-usuario/go-react-web-tail
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/hpcloud/tail v1.0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.4.7 // indirect
|
||||||
|
github.com/golang/protobuf v1.3.2 // indirect
|
||||||
|
github.com/tomnomnom/linkheader v0.1.0 // indirect
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 // indirect
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.2.2 // indirect
|
||||||
|
)
|
||||||
0
backend/go.sum
Normal file
0
backend/go.sum
Normal file
206
backend/main.go
Normal file
206
backend/main.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/hpcloud/tail"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed ../frontend/dist/*
|
||||||
|
var staticFiles embed.FS
|
||||||
|
|
||||||
|
// Variáveis para os argumentos de linha de comando
|
||||||
|
var (
|
||||||
|
port string
|
||||||
|
password 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogEntry representa uma linha de log
|
||||||
|
type LogEntry struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Line string `json:"line"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broker gerencia os clientes SSE e broadcasting
|
||||||
|
type Broker struct {
|
||||||
|
clients map[chan LogEntry]bool
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var broker = &Broker{
|
||||||
|
clients: make(map[chan LogEntry]bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) Subscribe() chan LogEntry {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
ch := make(chan LogEntry, 100)
|
||||||
|
b.clients[ch] = true
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) Unsubscribe(ch chan LogEntry) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if _, ok := b.clients[ch]; ok {
|
||||||
|
delete(b.clients, ch)
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Broker) Broadcast(entry LogEntry) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
for clientCh := range b.clients {
|
||||||
|
select {
|
||||||
|
case clientCh <- entry:
|
||||||
|
default:
|
||||||
|
delete(b.clients, clientCh)
|
||||||
|
close(clientCh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSSE envia logs em tempo real via SSE
|
||||||
|
func handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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", "*")
|
||||||
|
|
||||||
|
clientChan := broker.Subscribe()
|
||||||
|
defer broker.Unsubscribe(clientChan)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case entry := <-clientChan:
|
||||||
|
data, _ := json.Marshal(entry)
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||||
|
flusher.Flush()
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleFiles lista os arquivos de log disponíveis
|
||||||
|
func handleFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(os.Args[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// startTailing inicia o monitoramento dos arquivos
|
||||||
|
func startTailing(filenames []string) {
|
||||||
|
for _, filename := range filenames {
|
||||||
|
tailConfig := tail.Config{
|
||||||
|
Follow: true,
|
||||||
|
ReOpen: true,
|
||||||
|
Location: &tail.SeekInfo{Offset: 0, Whence: os.SEEK_END},
|
||||||
|
}
|
||||||
|
t, err := tail.TailFile(filename, tailConfig)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Erro ao fazer tail do arquivo %s: %v", filename, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("Iniciando tail no arquivo: %s", filename)
|
||||||
|
go func(t *tail.Tail, fn string) {
|
||||||
|
for line := range t.Lines {
|
||||||
|
if line.Err != nil {
|
||||||
|
log.Printf("Erro lendo linha de %s: %v", fn, line.Err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
timestamp := ""
|
||||||
|
if match := regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}`).FindString(line.Text); match != "" {
|
||||||
|
timestamp = match
|
||||||
|
} else if match := regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`).FindString(line.Text); match != "" {
|
||||||
|
timestamp = match
|
||||||
|
}
|
||||||
|
broker.Broadcast(LogEntry{Filename: fn, Line: line.Text, Timestamp: timestamp})
|
||||||
|
}
|
||||||
|
}(t, filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// authMiddleware é um middleware que protege as rotas com HTTP Basic Auth
|
||||||
|
func authMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if password == "" {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, pass, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
requestAuth(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
usernameMatch := subtle.ConstantTimeCompare([]byte(user), []byte("admin")) == 1
|
||||||
|
passwordMatch := subtle.ConstantTimeCompare([]byte(pass), []byte(password)) == 1
|
||||||
|
if usernameMatch && passwordMatch {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
} else {
|
||||||
|
requestAuth(w)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestAuth envia o header 401 Unauthorized
|
||||||
|
func requestAuth(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="Web Tail Pro - Acesso Restrito"`)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write([]byte("Não autorizado.\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
logFiles := flag.Args()
|
||||||
|
if len(logFiles) < 1 {
|
||||||
|
fmt.Println("Uso: web-tail-pro [opções] <arquivo1.log> <arquivo2.log> ...")
|
||||||
|
fmt.Println("\nOpções:")
|
||||||
|
flag.PrintDefaults()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
startTailing(logFiles)
|
||||||
|
|
||||||
|
distFS, err := fs.Sub(staticFiles, "frontend/dist")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Erro ao criar sub-filesystem para arquivos estáticos:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/", http.FileServer(http.FS(distFS)))
|
||||||
|
mux.HandleFunc("/logs", handleSSE)
|
||||||
|
mux.HandleFunc("/api/files", handleFiles)
|
||||||
|
|
||||||
|
protectedHandler := authMiddleware(mux)
|
||||||
|
|
||||||
|
log.Printf("Servidor iniciado em http://localhost%s", port)
|
||||||
|
if password != "" {
|
||||||
|
log.Println("🔐 Autenticação por senha está ATIVADA.")
|
||||||
|
}
|
||||||
|
if err := http.ListenAndServe(port, protectedHandler); err != nil {
|
||||||
|
log.Fatal("Erro ao iniciar o servidor:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
35
build.bat
Normal file
35
build.bat
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
@echo off
|
||||||
|
echo 🚀 Iniciando o processo de build...
|
||||||
|
|
||||||
|
REM Passo 1: Build do Frontend (React)
|
||||||
|
echo 📦 Instalando dependências do frontend...
|
||||||
|
cd frontend
|
||||||
|
call npm install
|
||||||
|
|
||||||
|
echo 🔨 Construindo o frontend para produção...
|
||||||
|
call npm run build
|
||||||
|
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo ❌ Falha no build do frontend. Abortando.
|
||||||
|
exit /b %errorlevel%
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo ❌ Falha na compilação do backend. Abortando.
|
||||||
|
exit /b %errorlevel%
|
||||||
|
)
|
||||||
|
|
||||||
|
echo ✅ Build concluído com sucesso!
|
||||||
|
echo ➡️ Execute o binário gerado:
|
||||||
|
echo .\web-tail-pro.exe [opções] backend\logs\app.log backend\logs\access.log backend\logs\json.log
|
||||||
|
echo.
|
||||||
|
echo Exemplos:
|
||||||
|
echo .\web-tail-pro.exe -port :9090 -password s3nh4 backend\logs\*.log
|
||||||
|
echo .\web-tail-pro.exe -password s3nh4 backend\logs\app.log
|
||||||
|
echo .\web-tail-pro.exe -port 1234 backend\logs\*.log
|
||||||
36
build.sh
Normal file
36
build.sh
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "🚀 Iniciando o processo de build..."
|
||||||
|
|
||||||
|
# Passo 1: Build do Frontend (React)
|
||||||
|
echo "📦 Instalando dependências do frontend..."
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
|
||||||
|
echo "🔨 Construindo o frontend para produção..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ Falha no build do frontend. Abortando."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# Passo 2: Build do Backend (Go)
|
||||||
|
echo "🔨 Compilando o backend com arquivos embedados..."
|
||||||
|
go build -o web-tail-pro ./backend/main.go
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo "❌ Falha na compilação do backend. Abortando."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Build concluído com sucesso!"
|
||||||
|
echo "➡️ Execute o binário gerado:"
|
||||||
|
echo " ./web-tail-pro [opções] backend/logs/app.log backend/logs/access.log backend/logs/json.log"
|
||||||
|
echo ""
|
||||||
|
echo "Exemplos:"
|
||||||
|
echo " ./web-tail-pro -port :9090 -password 's3nh4' backend/logs/*.log"
|
||||||
|
echo " ./web-tail-pro -password 's3nh4' backend/logs/app.log"
|
||||||
|
echo " ./web-tail-pro -port 1234 backend/logs/*.log"
|
||||||
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "go-react-web-tail-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"antd": "^5.12.8",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||||
|
"@typescript-eslint/parser": "^6.14.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"eslint": "^8.55.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
frontend/public/index.html
Normal file
13
frontend/public/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Web Tail Pro</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
77
frontend/src/App.tsx
Normal file
77
frontend/src/App.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Layout, Tabs, Button, message, Space } from 'antd';
|
||||||
|
import { AppstoreOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { useSSE, LogEntry } from './hooks/useSSE';
|
||||||
|
import { LogPanel } from './components/LogPanel';
|
||||||
|
import { MergedLogPanel } from './components/MergedLogPanel';
|
||||||
|
import { fetchLogFiles } from './api';
|
||||||
|
|
||||||
|
const { Header, Content } = Layout;
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
const { logs, isConnected } = useSSE('/logs');
|
||||||
|
const [availableFiles, setAvailableFiles] = useState<string[]>([]);
|
||||||
|
const [activeTabKey, setActiveTabKey] = useState('1');
|
||||||
|
const [tabItems, setTabItems] = useState<any[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogFiles().then(res => {
|
||||||
|
const files = res.data;
|
||||||
|
setAvailableFiles(files);
|
||||||
|
const initialTabs = files.map((file, index) => ({
|
||||||
|
key: String(index + 1),
|
||||||
|
label: file,
|
||||||
|
children: <LogPanel title={file} logs={logs} filename={file} />,
|
||||||
|
closable: files.length > 1,
|
||||||
|
}));
|
||||||
|
initialTabs.push({
|
||||||
|
key: 'merged',
|
||||||
|
label: '🔀 Mesclado',
|
||||||
|
children: <MergedLogPanel logs={logs} />,
|
||||||
|
closable: false,
|
||||||
|
});
|
||||||
|
setTabItems(initialTabs);
|
||||||
|
}).catch(err => message.error("Falha ao buscar lista de arquivos de log."));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addTab = () => {
|
||||||
|
const unusedFiles = availableFiles.filter(f => !tabItems.some(item => item.label === f));
|
||||||
|
if (unusedFiles.length === 0) {
|
||||||
|
message.info("Todos os arquivos de log já estão abertos.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newFile = unusedFiles[0];
|
||||||
|
const newKey = String(Math.max(...tabItems.map(t => parseInt(t.key, 10))) + 1);
|
||||||
|
const newTab = { key: newKey, label: newFile, children: <LogPanel title={newFile} logs={logs} filename={newFile} />, closable: true };
|
||||||
|
setTabItems([...tabItems, newTab]);
|
||||||
|
setActiveTabKey(newKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEdit = (targetKey: string, action: 'add' | 'remove') => {
|
||||||
|
if (action === 'add') {
|
||||||
|
addTab();
|
||||||
|
} else {
|
||||||
|
const newTabs = tabItems.filter(item => item.key !== targetKey);
|
||||||
|
if (newTabs.length && activeTabKey === targetKey) {
|
||||||
|
setActiveTabKey(newTabs[newTabs.length - 1].key);
|
||||||
|
}
|
||||||
|
setTabItems(newTabs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ height: '100vh' }}>
|
||||||
|
<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>
|
||||||
|
</Space>
|
||||||
|
</Header>
|
||||||
|
<Content style={{ padding: '0 16px' }}>
|
||||||
|
<Tabs type="editable-card" onChange={setActiveTabKey} activeKey={activeTabKey} onEdit={onEdit} items={tabItems} style={{ marginTop: 16 }} />
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
7
frontend/src/api.ts
Normal file
7
frontend/src/api.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export const apiClient = axios.create({
|
||||||
|
baseURL: 'http://localhost:8080', // Padrão, pode ser sobrescrito se necessário
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchLogFiles = () => apiClient.get<string[]>('/api/files');
|
||||||
29
frontend/src/components/LogLine.tsx
Normal file
29
frontend/src/components/LogLine.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface LogLineProps {
|
||||||
|
line: string;
|
||||||
|
format: 'generic' | 'json' | 'apache';
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeHtml = (text: string) => {
|
||||||
|
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) {}
|
||||||
|
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>`;
|
||||||
|
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>`);
|
||||||
|
}
|
||||||
|
return <div className="log-line" dangerouslySetInnerHTML={{ __html: parsedLine }} />;
|
||||||
|
};
|
||||||
48
frontend/src/components/LogPanel.tsx
Normal file
48
frontend/src/components/LogPanel.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
|
import { Card, Input, Select, Space } from 'antd';
|
||||||
|
import { LogLine, LogEntry } from '../hooks/useSSE';
|
||||||
|
|
||||||
|
interface LogPanelProps {
|
||||||
|
title: string;
|
||||||
|
logs: LogEntry[];
|
||||||
|
filename?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogPanel: React.FC<LogPanelProps> = ({ title, logs, filename }) => {
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const [format, setFormat] = useState<'generic' | 'json' | 'apache'>('generic');
|
||||||
|
|
||||||
|
const filteredLogs = useMemo(() => {
|
||||||
|
if (!filename) return logs;
|
||||||
|
return logs.filter(log => log.filename === filename);
|
||||||
|
}, [logs, filename]);
|
||||||
|
|
||||||
|
const displayedLogs = useMemo(() => {
|
||||||
|
if (!filter) return filteredLogs;
|
||||||
|
return filteredLogs.filter(log => log.line.toLowerCase().includes(filter.toLowerCase()));
|
||||||
|
}, [filteredLogs, filter]);
|
||||||
|
|
||||||
|
const logEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [displayedLogs]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title={title} size="small" style={{ height: '100%' }} bodyStyle={{ padding: 0, height: 'calc(100% - 57px)' }}>
|
||||||
|
<Space style={{padding: '8px', borderBottom: '1px solid #333'}}>
|
||||||
|
<Select value={format} onChange={setFormat} style={{ width: 120 }}>
|
||||||
|
<Select.Option value="generic">Genérico</Select.Option>
|
||||||
|
<Select.Option value="json">JSON</Select.Option>
|
||||||
|
<Select.Option value="apache">Apache</Select.Option>
|
||||||
|
</Select>
|
||||||
|
<Input placeholder="Filtrar..." value={filter} onChange={(e) => setFilter(e.target.value)} style={{ width: 200 }} />
|
||||||
|
</Space>
|
||||||
|
<div className="log-output">
|
||||||
|
{displayedLogs.map((log, index) => (
|
||||||
|
<LogLine key={`${log.filename}-${index}`} line={log.line} format={format} />
|
||||||
|
))}
|
||||||
|
<div ref={logEndRef} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
33
frontend/src/components/MergedLogPanel.tsx
Normal file
33
frontend/src/components/MergedLogPanel.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
|
import { Card, Input } from 'antd';
|
||||||
|
import { LogLine, LogEntry } from '../hooks/useSSE';
|
||||||
|
|
||||||
|
export const MergedLogPanel: React.FC<{ logs: LogEntry[] }> = ({ logs }) => {
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
const sortedAndFilteredLogs = useMemo(() => {
|
||||||
|
const sorted = [...logs].sort((a, b) => {
|
||||||
|
if (!a.timestamp) return 1;
|
||||||
|
if (!b.timestamp) return -1;
|
||||||
|
return a.timestamp.localeCompare(b.timestamp);
|
||||||
|
});
|
||||||
|
if (!filter) return sorted;
|
||||||
|
return sorted.filter(log => log.line.toLowerCase().includes(filter.toLowerCase()));
|
||||||
|
}, [logs, filter]);
|
||||||
|
|
||||||
|
const logEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [sortedAndFilteredLogs]);
|
||||||
|
|
||||||
|
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)' }} />
|
||||||
|
<div className="log-output">
|
||||||
|
{sortedAndFilteredLogs.map((log, index) => (
|
||||||
|
<LogLine key={`merged-${log.filename}-${index}`} line={log.line} format="generic" />
|
||||||
|
))}
|
||||||
|
<div ref={logEndRef} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
38
frontend/src/hooks/useSSE.ts
Normal file
38
frontend/src/hooks/useSSE.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
filename: string;
|
||||||
|
line: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSSE = (url: string) => {
|
||||||
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const eventSource = new EventSource(url);
|
||||||
|
setIsConnected(true);
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
const newLog: LogEntry = JSON.parse(event.data);
|
||||||
|
setLogs(prevLogs => [...prevLogs, newLog].slice(-2000));
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (err) => {
|
||||||
|
console.error("SSE Error:", err);
|
||||||
|
setIsConnected(false);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (eventSource.readyState === EventSource.CLOSED) {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
return { logs, isConnected };
|
||||||
|
};
|
||||||
37
frontend/src/index.css
Normal file
37
frontend/src/index.css
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: #141414;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-output {
|
||||||
|
height: calc(100vh - 180px);
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line {
|
||||||
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-level-info { color: #4fc3f7; font-weight: bold; }
|
||||||
|
.log-level-warn { color: #ffb74d; font-weight: bold; }
|
||||||
|
.log-level-error { color: #f48fb1; font-weight: bold; }
|
||||||
|
.json-key { color: #9cdcfe; }
|
||||||
|
.json-value { color: #ce9178; }
|
||||||
|
.log-ip { color: #9cdcfe; }
|
||||||
|
.log-time { color: #ce9178; }
|
||||||
|
.log-method { color: #569cd6; font-weight: bold; }
|
||||||
|
.log-path { color: #dcdcaa; }
|
||||||
|
.log-status-200, .log-status-201 { color: #4ec9b0; }
|
||||||
|
.log-status-404, .log-status-500 { color: #f48fb1; font-weight: bold; }
|
||||||
11
frontend/src/main.tsx
Normal file
11
frontend/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import 'antd/dist/reset.css'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
0
frontend/src/vite-env.d.ts
vendored
Normal file
0
frontend/src/vite-env.d.ts
vendored
Normal file
13
frontend/vite.config.ts
Normal file
13
frontend/vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: './',
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: 'http://localhost:8080', changeOrigin: true },
|
||||||
|
'/logs': { target: 'http://localhost:8080', changeOrigin: true },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user