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