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:
Luiz Costa
2025-11-18 08:27:21 -03:00
commit 3a5cbe12ef
18 changed files with 670 additions and 0 deletions

41
.gitignore vendored Normal file
View 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
View 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
View File

206
backend/main.go Normal file
View 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
View 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
View 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
View 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"
}
}

View 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
View 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
View 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');

View 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 } = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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 }} />;
};

View 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>
);
};

View 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>
);
};

View 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
View 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
View 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
View File

13
frontend/vite.config.ts Normal file
View 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 },
}
}
})