feat: Implement initial Go backend and React frontend project structure for the gotail application.
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/seu-usuario/go-react-web-tail/internal/models"
|
||||
)
|
||||
|
||||
// Broker gerencia clientes SSE e broadcasting de logs
|
||||
type Broker struct {
|
||||
clients map[chan models.LogEntry]struct{}
|
||||
mu sync.RWMutex
|
||||
register chan chan models.LogEntry
|
||||
unregister chan chan models.LogEntry
|
||||
broadcast chan models.LogEntry
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// Métricas
|
||||
totalBroadcasts uint64
|
||||
activeClients int
|
||||
}
|
||||
|
||||
// New cria uma nova instância do Broker
|
||||
func New(ctx context.Context) *Broker {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
b := &Broker{
|
||||
clients: make(map[chan models.LogEntry]struct{}),
|
||||
register: make(chan chan models.LogEntry),
|
||||
unregister: make(chan chan models.LogEntry),
|
||||
broadcast: make(chan models.LogEntry, 1000),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
go b.run()
|
||||
return b
|
||||
}
|
||||
|
||||
// run é o loop principal do broker
|
||||
func (b *Broker) run() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Info().Msg("Broker started")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-b.ctx.Done():
|
||||
log.Info().Msg("Broker shutting down")
|
||||
b.closeAllClients()
|
||||
return
|
||||
|
||||
case client := <-b.register:
|
||||
b.addClient(client)
|
||||
|
||||
case client := <-b.unregister:
|
||||
b.removeClient(client)
|
||||
|
||||
case entry := <-b.broadcast:
|
||||
b.broadcastToClients(entry)
|
||||
|
||||
case <-ticker.C:
|
||||
b.logMetrics()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe registra um novo cliente para receber logs
|
||||
func (b *Broker) Subscribe() chan models.LogEntry {
|
||||
ch := make(chan models.LogEntry, 100)
|
||||
b.register <- ch
|
||||
return ch
|
||||
}
|
||||
|
||||
// Unsubscribe remove um cliente
|
||||
func (b *Broker) Unsubscribe(ch chan models.LogEntry) {
|
||||
b.unregister <- ch
|
||||
}
|
||||
|
||||
// Broadcast envia uma entrada de log para todos os clientes
|
||||
func (b *Broker) Broadcast(entry models.LogEntry) {
|
||||
select {
|
||||
case b.broadcast <- entry:
|
||||
case <-b.ctx.Done():
|
||||
default:
|
||||
log.Warn().Msg("Broadcast channel full, dropping message")
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown encerra o broker graciosamente
|
||||
func (b *Broker) Shutdown() {
|
||||
log.Info().Msg("Broker shutdown requested")
|
||||
b.cancel()
|
||||
}
|
||||
|
||||
// addClient adiciona um novo cliente
|
||||
func (b *Broker) addClient(client chan models.LogEntry) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.clients[client] = struct{}{}
|
||||
b.activeClients = len(b.clients)
|
||||
log.Info().Int("active_clients", b.activeClients).Msg("Client subscribed")
|
||||
}
|
||||
|
||||
// removeClient remove um cliente
|
||||
func (b *Broker) removeClient(client chan models.LogEntry) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if _, ok := b.clients[client]; ok {
|
||||
delete(b.clients, client)
|
||||
close(client)
|
||||
b.activeClients = len(b.clients)
|
||||
log.Info().Int("active_clients", b.activeClients).Msg("Client unsubscribed")
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastToClients envia uma entrada para todos os clientes conectados
|
||||
func (b *Broker) broadcastToClients(entry models.LogEntry) {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
|
||||
b.totalBroadcasts++
|
||||
|
||||
for clientCh := range b.clients {
|
||||
select {
|
||||
case clientCh <- entry:
|
||||
// Enviado com sucesso
|
||||
default:
|
||||
// Cliente está lento, remover
|
||||
go b.Unsubscribe(clientCh)
|
||||
log.Warn().Msg("Removed slow client")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// closeAllClients fecha todos os canais de clientes
|
||||
func (b *Broker) closeAllClients() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for client := range b.clients {
|
||||
close(client)
|
||||
}
|
||||
b.clients = make(map[chan models.LogEntry]struct{})
|
||||
log.Info().Msg("All clients disconnected")
|
||||
}
|
||||
|
||||
// logMetrics registra métricas do broker
|
||||
func (b *Broker) logMetrics() {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
log.Debug().
|
||||
Int("active_clients", b.activeClients).
|
||||
Uint64("total_broadcasts", b.totalBroadcasts).
|
||||
Int("broadcast_buffer", len(b.broadcast)).
|
||||
Msg("Broker metrics")
|
||||
}
|
||||
|
||||
// GetMetrics retorna métricas atuais do broker
|
||||
func (b *Broker) GetMetrics() (activeClients int, totalBroadcasts uint64) {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.activeClients, b.totalBroadcasts
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/seu-usuario/go-react-web-tail/internal/models"
|
||||
)
|
||||
|
||||
func TestBroker_SubscribeUnsubscribe(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
b := New(ctx)
|
||||
defer b.Shutdown()
|
||||
|
||||
// Subscribe
|
||||
ch := b.Subscribe()
|
||||
if ch == nil {
|
||||
t.Fatal("Subscribe returned nil channel")
|
||||
}
|
||||
|
||||
// Give broker time to process
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
activeClients, _ := b.GetMetrics()
|
||||
if activeClients != 1 {
|
||||
t.Errorf("Expected 1 active client, got %d", activeClients)
|
||||
}
|
||||
|
||||
// Unsubscribe
|
||||
b.Unsubscribe(ch)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
activeClients, _ = b.GetMetrics()
|
||||
if activeClients != 0 {
|
||||
t.Errorf("Expected 0 active clients after unsubscribe, got %d", activeClients)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroker_Broadcast(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
b := New(ctx)
|
||||
defer b.Shutdown()
|
||||
|
||||
ch := b.Subscribe()
|
||||
defer b.Unsubscribe(ch)
|
||||
|
||||
entry := models.LogEntry{
|
||||
Filename: "test.log",
|
||||
Line: "Test message",
|
||||
}
|
||||
|
||||
// Broadcast
|
||||
b.Broadcast(entry)
|
||||
|
||||
// Receive
|
||||
select {
|
||||
case received := <-ch:
|
||||
if received.Filename != entry.Filename {
|
||||
t.Errorf("Expected filename %s, got %s", entry.Filename, received.Filename)
|
||||
}
|
||||
if received.Line != entry.Line {
|
||||
t.Errorf("Expected line %s, got %s", entry.Line, received.Line)
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Fatal("Timeout waiting for broadcast message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroker_MultipleClients(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
b := New(ctx)
|
||||
defer b.Shutdown()
|
||||
|
||||
// Subscribe multiple clients
|
||||
clients := make([]chan models.LogEntry, 3)
|
||||
for i := range clients {
|
||||
clients[i] = b.Subscribe()
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
activeClients, _ := b.GetMetrics()
|
||||
if activeClients != 3 {
|
||||
t.Errorf("Expected 3 active clients, got %d", activeClients)
|
||||
}
|
||||
|
||||
entry := models.LogEntry{
|
||||
Filename: "test.log",
|
||||
Line: "Broadcast to all",
|
||||
}
|
||||
|
||||
b.Broadcast(entry)
|
||||
|
||||
// All clients should receive
|
||||
for i, ch := range clients {
|
||||
select {
|
||||
case received := <-ch:
|
||||
if received.Line != entry.Line {
|
||||
t.Errorf("Client %d: expected line %s, got %s", i, entry.Line, received.Line)
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
t.Errorf("Client %d: timeout waiting for message", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
for _, ch := range clients {
|
||||
b.Unsubscribe(ch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroker_Shutdown(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
b := New(ctx)
|
||||
|
||||
ch := b.Subscribe()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
b.Shutdown()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// Channel should be closed
|
||||
_, ok := <-ch
|
||||
if ok {
|
||||
t.Error("Expected channel to be closed after shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroker_GetMetrics(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
b := New(ctx)
|
||||
defer b.Shutdown()
|
||||
|
||||
// Initial metrics
|
||||
activeClients, totalBroadcasts := b.GetMetrics()
|
||||
if activeClients != 0 {
|
||||
t.Errorf("Expected 0 active clients initially, got %d", activeClients)
|
||||
}
|
||||
if totalBroadcasts != 0 {
|
||||
t.Errorf("Expected 0 total broadcasts initially, got %d", totalBroadcasts)
|
||||
}
|
||||
|
||||
// Subscribe and broadcast
|
||||
ch := b.Subscribe()
|
||||
defer b.Unsubscribe(ch)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
b.Broadcast(models.LogEntry{
|
||||
Filename: "test.log",
|
||||
Line: "Test",
|
||||
})
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
activeClients, totalBroadcasts = b.GetMetrics()
|
||||
if activeClients != 1 {
|
||||
t.Errorf("Expected 1 active client, got %d", activeClients)
|
||||
}
|
||||
if totalBroadcasts != 5 {
|
||||
t.Errorf("Expected 5 total broadcasts, got %d", totalBroadcasts)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBroker_Broadcast(b *testing.B) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
broker := New(ctx)
|
||||
defer broker.Shutdown()
|
||||
|
||||
ch := broker.Subscribe()
|
||||
defer broker.Unsubscribe(ch)
|
||||
|
||||
// Drain channel in background
|
||||
go func() {
|
||||
for range ch {
|
||||
}
|
||||
}()
|
||||
|
||||
entry := models.LogEntry{
|
||||
Filename: "test.log",
|
||||
Line: "Benchmark message",
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
broker.Broadcast(entry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config contém todas as configurações da aplicação
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Auth AuthConfig
|
||||
Logging LoggingConfig
|
||||
Security SecurityConfig
|
||||
}
|
||||
|
||||
// ServerConfig contém configurações do servidor HTTP
|
||||
type ServerConfig struct {
|
||||
Port string
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
ShutdownTimeout time.Duration
|
||||
TLSEnabled bool
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
}
|
||||
|
||||
// AuthConfig contém configurações de autenticação
|
||||
type AuthConfig struct {
|
||||
Enabled bool
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// LoggingConfig contém configurações de logging
|
||||
type LoggingConfig struct {
|
||||
Level string // debug, info, warn, error
|
||||
Format string // json ou console
|
||||
}
|
||||
|
||||
// SecurityConfig contém configurações de segurança
|
||||
type SecurityConfig struct {
|
||||
RateLimitEnabled bool
|
||||
RateLimitRPS int
|
||||
CORSOrigins []string
|
||||
}
|
||||
|
||||
// Load carrega a configuração de múltiplas fontes (flags, env vars)
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{}
|
||||
|
||||
// Definir flags (mantém compatibilidade com versão anterior)
|
||||
flag.StringVar(&cfg.Server.Port, "port", getEnv("PORT", ":8080"), "Porta para o servidor web (ex: :8080, 9090)")
|
||||
flag.StringVar(&cfg.Auth.Username, "username", getEnv("USERNAME", "admin"), "Nome de usuário para autenticação")
|
||||
flag.StringVar(&cfg.Auth.Password, "password", getEnv("PASSWORD", ""), "Senha para autenticação")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// Carregar configurações de variáveis de ambiente
|
||||
cfg.Logging.Level = getEnv("LOG_LEVEL", "info")
|
||||
cfg.Logging.Format = getEnv("LOG_FORMAT", "console")
|
||||
|
||||
cfg.Security.RateLimitEnabled = getEnvBool("RATE_LIMIT_ENABLED", true)
|
||||
cfg.Security.RateLimitRPS = getEnvInt("RATE_LIMIT_RPS", 100)
|
||||
|
||||
corsOrigins := getEnv("CORS_ORIGINS", "")
|
||||
if corsOrigins != "" {
|
||||
cfg.Security.CORSOrigins = strings.Split(corsOrigins, ",")
|
||||
} else {
|
||||
cfg.Security.CORSOrigins = []string{"*"}
|
||||
}
|
||||
|
||||
cfg.Server.ReadTimeout = getEnvDuration("READ_TIMEOUT", 15*time.Second)
|
||||
cfg.Server.WriteTimeout = getEnvDuration("WRITE_TIMEOUT", 15*time.Second)
|
||||
cfg.Server.ShutdownTimeout = getEnvDuration("SHUTDOWN_TIMEOUT", 30*time.Second)
|
||||
|
||||
cfg.Server.TLSEnabled = getEnvBool("TLS_ENABLED", false)
|
||||
cfg.Server.TLSCertFile = getEnv("TLS_CERT_FILE", "")
|
||||
cfg.Server.TLSKeyFile = getEnv("TLS_KEY_FILE", "")
|
||||
|
||||
// Autenticação está habilitada se houver senha
|
||||
cfg.Auth.Enabled = cfg.Auth.Password != ""
|
||||
|
||||
// Validar configuração
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Validate valida a configuração
|
||||
func (c *Config) Validate() error {
|
||||
// Validar porta
|
||||
if c.Server.Port == "" {
|
||||
return fmt.Errorf("porta do servidor não pode ser vazia")
|
||||
}
|
||||
|
||||
// Validar TLS
|
||||
if c.Server.TLSEnabled {
|
||||
if c.Server.TLSCertFile == "" || c.Server.TLSKeyFile == "" {
|
||||
return fmt.Errorf("TLS habilitado mas certificado ou chave não especificados")
|
||||
}
|
||||
}
|
||||
|
||||
// Validar log level
|
||||
validLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true}
|
||||
if !validLevels[c.Logging.Level] {
|
||||
return fmt.Errorf("nível de log inválido: %s (use: debug, info, warn, error)", c.Logging.Level)
|
||||
}
|
||||
|
||||
// Validar log format
|
||||
if c.Logging.Format != "json" && c.Logging.Format != "console" {
|
||||
return fmt.Errorf("formato de log inválido: %s (use: json, console)", c.Logging.Format)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Funções auxiliares para ler variáveis de ambiente
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvBool(key string, defaultValue bool) bool {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
b, err := strconv.ParseBool(value)
|
||||
if err == nil {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
i, err := strconv.Atoi(value)
|
||||
if err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
d, err := time.ParseDuration(value)
|
||||
if err == nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfig_Validate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Valid config",
|
||||
config: &Config{
|
||||
Server: ServerConfig{
|
||||
Port: ":8080",
|
||||
},
|
||||
Logging: LoggingConfig{
|
||||
Level: "info",
|
||||
Format: "console",
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Empty port",
|
||||
config: &Config{
|
||||
Server: ServerConfig{
|
||||
Port: "",
|
||||
},
|
||||
Logging: LoggingConfig{
|
||||
Level: "info",
|
||||
Format: "console",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid log level",
|
||||
config: &Config{
|
||||
Server: ServerConfig{
|
||||
Port: ":8080",
|
||||
},
|
||||
Logging: LoggingConfig{
|
||||
Level: "invalid",
|
||||
Format: "console",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid log format",
|
||||
config: &Config{
|
||||
Server: ServerConfig{
|
||||
Port: ":8080",
|
||||
},
|
||||
Logging: LoggingConfig{
|
||||
Level: "info",
|
||||
Format: "invalid",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "TLS enabled without cert",
|
||||
config: &Config{
|
||||
Server: ServerConfig{
|
||||
Port: ":8080",
|
||||
TLSEnabled: true,
|
||||
TLSCertFile: "",
|
||||
TLSKeyFile: "",
|
||||
},
|
||||
Logging: LoggingConfig{
|
||||
Level: "info",
|
||||
Format: "console",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "TLS enabled with cert and key",
|
||||
config: &Config{
|
||||
Server: ServerConfig{
|
||||
Port: ":8080",
|
||||
TLSEnabled: true,
|
||||
TLSCertFile: "/path/to/cert.pem",
|
||||
TLSKeyFile: "/path/to/key.pem",
|
||||
},
|
||||
Logging: LoggingConfig{
|
||||
Level: "info",
|
||||
Format: "console",
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.Validate()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
defaultValue string
|
||||
envValue string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Environment variable set",
|
||||
key: "TEST_VAR",
|
||||
defaultValue: "default",
|
||||
envValue: "custom",
|
||||
want: "custom",
|
||||
},
|
||||
{
|
||||
name: "Environment variable not set",
|
||||
key: "UNSET_VAR",
|
||||
defaultValue: "default",
|
||||
envValue: "",
|
||||
want: "default",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envValue != "" {
|
||||
os.Setenv(tt.key, tt.envValue)
|
||||
defer os.Unsetenv(tt.key)
|
||||
}
|
||||
|
||||
got := getEnv(tt.key, tt.defaultValue)
|
||||
if got != tt.want {
|
||||
t.Errorf("getEnv() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvBool(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
defaultValue bool
|
||||
envValue string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "true value",
|
||||
key: "TEST_BOOL",
|
||||
defaultValue: false,
|
||||
envValue: "true",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "false value",
|
||||
key: "TEST_BOOL",
|
||||
defaultValue: true,
|
||||
envValue: "false",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "1 value",
|
||||
key: "TEST_BOOL",
|
||||
defaultValue: false,
|
||||
envValue: "1",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "invalid value uses default",
|
||||
key: "TEST_BOOL",
|
||||
defaultValue: true,
|
||||
envValue: "invalid",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "not set uses default",
|
||||
key: "UNSET_BOOL",
|
||||
defaultValue: true,
|
||||
envValue: "",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envValue != "" {
|
||||
os.Setenv(tt.key, tt.envValue)
|
||||
defer os.Unsetenv(tt.key)
|
||||
}
|
||||
|
||||
got := getEnvBool(tt.key, tt.defaultValue)
|
||||
if got != tt.want {
|
||||
t.Errorf("getEnvBool() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvInt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
defaultValue int
|
||||
envValue string
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "valid integer",
|
||||
key: "TEST_INT",
|
||||
defaultValue: 10,
|
||||
envValue: "42",
|
||||
want: 42,
|
||||
},
|
||||
{
|
||||
name: "invalid integer uses default",
|
||||
key: "TEST_INT",
|
||||
defaultValue: 10,
|
||||
envValue: "invalid",
|
||||
want: 10,
|
||||
},
|
||||
{
|
||||
name: "not set uses default",
|
||||
key: "UNSET_INT",
|
||||
defaultValue: 100,
|
||||
envValue: "",
|
||||
want: 100,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envValue != "" {
|
||||
os.Setenv(tt.key, tt.envValue)
|
||||
defer os.Unsetenv(tt.key)
|
||||
}
|
||||
|
||||
got := getEnvInt(tt.key, tt.defaultValue)
|
||||
if got != tt.want {
|
||||
t.Errorf("getEnvInt() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
defaultValue time.Duration
|
||||
envValue string
|
||||
want time.Duration
|
||||
}{
|
||||
{
|
||||
name: "valid duration",
|
||||
key: "TEST_DURATION",
|
||||
defaultValue: 10 * time.Second,
|
||||
envValue: "30s",
|
||||
want: 30 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "invalid duration uses default",
|
||||
key: "TEST_DURATION",
|
||||
defaultValue: 10 * time.Second,
|
||||
envValue: "invalid",
|
||||
want: 10 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "not set uses default",
|
||||
key: "UNSET_DURATION",
|
||||
defaultValue: 1 * time.Minute,
|
||||
envValue: "",
|
||||
want: 1 * time.Minute,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envValue != "" {
|
||||
os.Setenv(tt.key, tt.envValue)
|
||||
defer os.Unsetenv(tt.key)
|
||||
}
|
||||
|
||||
got := getEnvDuration(tt.key, tt.defaultValue)
|
||||
if got != tt.want {
|
||||
t.Errorf("getEnvDuration() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/seu-usuario/go-react-web-tail/internal/broker"
|
||||
"github.com/seu-usuario/go-react-web-tail/internal/models"
|
||||
)
|
||||
|
||||
// Handler contém os handlers HTTP da aplicação
|
||||
type Handler struct {
|
||||
broker *broker.Broker
|
||||
logFiles []string
|
||||
}
|
||||
|
||||
// New cria uma nova instância do Handler
|
||||
func New(broker *broker.Broker, logFiles []string) *Handler {
|
||||
return &Handler{
|
||||
broker: broker,
|
||||
logFiles: logFiles,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSSE gerencia conexões Server-Sent Events para streaming de logs
|
||||
func (h *Handler) 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")
|
||||
|
||||
clientChan := h.broker.Subscribe()
|
||||
defer h.broker.Unsubscribe(clientChan)
|
||||
|
||||
log.Info().Str("remote_addr", r.RemoteAddr).Msg("SSE client connected")
|
||||
|
||||
for {
|
||||
select {
|
||||
case entry := <-clientChan:
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to marshal log entry")
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
|
||||
case <-r.Context().Done():
|
||||
log.Info().Str("remote_addr", r.RemoteAddr).Msg("SSE client disconnected")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleFiles retorna a lista de arquivos de log monitorados
|
||||
func (h *Handler) HandleFiles(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(h.logFiles); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to encode log files")
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleLastLines retorna as últimas N linhas de um arquivo
|
||||
func (h *Handler) HandleLastLines(w http.ResponseWriter, r *http.Request) {
|
||||
file := r.URL.Query().Get("file")
|
||||
nParam := r.URL.Query().Get("n")
|
||||
|
||||
n := 200
|
||||
if nParam != "" {
|
||||
if parsed, err := strconv.Atoi(nParam); err == nil && parsed > 0 {
|
||||
n = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Validar que o arquivo está na lista permitida
|
||||
allowed := false
|
||||
for _, f := range h.logFiles {
|
||||
if f == file {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
log.Warn().Str("file", file).Str("remote_addr", r.RemoteAddr).Msg("Attempted access to unauthorized file")
|
||||
http.Error(w, "Invalid file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := h.getLastLines(file, n)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("file", file).Msg("Failed to read last lines")
|
||||
http.Error(w, "Failed to read file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(entries); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to encode entries")
|
||||
}
|
||||
}
|
||||
|
||||
// getLastLines lê as últimas N linhas de um arquivo
|
||||
func (h *Handler) getLastLines(filename string, n int) ([]models.LogEntry, error) {
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
scanner := bufio.NewScanner(f)
|
||||
buf := make([]string, 0, n)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if len(buf) == n {
|
||||
buf = buf[1:]
|
||||
}
|
||||
buf = append(buf, line)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries := make([]models.LogEntry, 0, len(buf))
|
||||
for _, line := range buf {
|
||||
entry := models.NewLogEntry(filename, strings.TrimRight(line, "\r"))
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// HandleHealth retorna o status de saúde da aplicação
|
||||
func (h *Handler) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "healthy",
|
||||
})
|
||||
}
|
||||
|
||||
// HandleMetrics retorna métricas da aplicação
|
||||
func (h *Handler) HandleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
activeClients, totalBroadcasts := h.broker.GetMetrics()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"active_clients": activeClients,
|
||||
"total_broadcasts": totalBroadcasts,
|
||||
"monitored_files": len(h.logFiles),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// AuthMiddleware implementa autenticação HTTP Basic
|
||||
type AuthMiddleware struct {
|
||||
enabled bool
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
// NewAuthMiddleware cria uma nova instância do middleware de autenticação
|
||||
func NewAuthMiddleware(enabled bool, username, password string) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
enabled: enabled,
|
||||
username: username,
|
||||
password: password,
|
||||
}
|
||||
}
|
||||
|
||||
// Handler retorna o handler HTTP com autenticação
|
||||
func (m *AuthMiddleware) Handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !m.enabled {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
m.requestAuth(w)
|
||||
return
|
||||
}
|
||||
|
||||
usernameMatch := subtle.ConstantTimeCompare([]byte(user), []byte(m.username)) == 1
|
||||
passwordMatch := subtle.ConstantTimeCompare([]byte(pass), []byte(m.password)) == 1
|
||||
|
||||
if usernameMatch && passwordMatch {
|
||||
next.ServeHTTP(w, r)
|
||||
} else {
|
||||
log.Warn().
|
||||
Str("username", user).
|
||||
Str("remote_addr", r.RemoteAddr).
|
||||
Msg("Authentication failed")
|
||||
m.requestAuth(w)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) requestAuth(w http.ResponseWriter) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="Web Tail Pro - Restricted Access"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte("Unauthorized\n"))
|
||||
}
|
||||
|
||||
// CORSMiddleware implementa CORS
|
||||
type CORSMiddleware struct {
|
||||
allowedOrigins []string
|
||||
}
|
||||
|
||||
// NewCORSMiddleware cria uma nova instância do middleware CORS
|
||||
func NewCORSMiddleware(origins []string) *CORSMiddleware {
|
||||
return &CORSMiddleware{allowedOrigins: origins}
|
||||
}
|
||||
|
||||
// Handler retorna o handler HTTP com CORS
|
||||
func (m *CORSMiddleware) Handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
|
||||
if m.isAllowedOrigin(origin) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *CORSMiddleware) isAllowedOrigin(origin string) bool {
|
||||
if len(m.allowedOrigins) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, allowed := range m.allowedOrigins {
|
||||
if allowed == "*" || allowed == origin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RateLimitMiddleware implementa rate limiting por IP
|
||||
type RateLimitMiddleware struct {
|
||||
limiters map[string]*rate.Limiter
|
||||
mu sync.RWMutex
|
||||
rps int
|
||||
}
|
||||
|
||||
// NewRateLimitMiddleware cria uma nova instância do middleware de rate limiting
|
||||
func NewRateLimitMiddleware(rps int) *RateLimitMiddleware {
|
||||
return &RateLimitMiddleware{
|
||||
limiters: make(map[string]*rate.Limiter),
|
||||
rps: rps,
|
||||
}
|
||||
}
|
||||
|
||||
// Handler retorna o handler HTTP com rate limiting
|
||||
func (m *RateLimitMiddleware) Handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip := m.getIP(r)
|
||||
limiter := m.getLimiter(ip)
|
||||
|
||||
if !limiter.Allow() {
|
||||
log.Warn().Str("ip", ip).Str("path", r.URL.Path).Msg("Rate limit exceeded")
|
||||
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *RateLimitMiddleware) getLimiter(ip string) *rate.Limiter {
|
||||
m.mu.RLock()
|
||||
limiter, exists := m.limiters[ip]
|
||||
m.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
m.mu.Lock()
|
||||
limiter = rate.NewLimiter(rate.Limit(m.rps), m.rps*2)
|
||||
m.limiters[ip] = limiter
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
return limiter
|
||||
}
|
||||
|
||||
func (m *RateLimitMiddleware) getIP(r *http.Request) string {
|
||||
// Tentar obter IP real de headers de proxy
|
||||
ip := r.Header.Get("X-Forwarded-For")
|
||||
if ip == "" {
|
||||
ip = r.Header.Get("X-Real-IP")
|
||||
}
|
||||
if ip == "" {
|
||||
ip = strings.Split(r.RemoteAddr, ":")[0]
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
// SecurityHeadersMiddleware adiciona headers de segurança
|
||||
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// LoggingMiddleware registra todas as requisições HTTP
|
||||
func LoggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// Wrapper para capturar status code
|
||||
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
log.Info().
|
||||
Str("method", r.Method).
|
||||
Str("path", r.URL.Path).
|
||||
Str("remote_addr", r.RemoteAddr).
|
||||
Int("status", wrapped.statusCode).
|
||||
Dur("duration", time.Since(start)).
|
||||
Msg("HTTP request")
|
||||
})
|
||||
}
|
||||
|
||||
// responseWriter é um wrapper para capturar o status code
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LogEntry representa uma linha de log com metadados extraídos
|
||||
type LogEntry struct {
|
||||
Filename string `json:"filename"`
|
||||
Line string `json:"line"`
|
||||
Timestamp time.Time `json:"timestamp,omitempty"`
|
||||
Level string `json:"level,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
// Regex para extrair timestamps em diferentes formatos
|
||||
timestampRegex1 = regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}`)
|
||||
timestampRegex2 = regexp.MustCompile(`\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}`)
|
||||
|
||||
// Regex para extrair nível de log
|
||||
logLevelRegex = regexp.MustCompile(`(?i)\b(DEBUG|INFO|WARN|WARNING|ERROR|FATAL|TRACE)\b`)
|
||||
)
|
||||
|
||||
// NewLogEntry cria uma nova entrada de log com parsing automático de timestamp e nível
|
||||
func NewLogEntry(filename, line string) LogEntry {
|
||||
entry := LogEntry{
|
||||
Filename: filename,
|
||||
Line: strings.TrimRight(line, "\r\n"),
|
||||
}
|
||||
|
||||
// Extrair timestamp
|
||||
if ts := timestampRegex1.FindString(line); ts != "" {
|
||||
if parsed, err := time.Parse("2006-01-02T15:04:05", ts); err == nil {
|
||||
entry.Timestamp = parsed
|
||||
}
|
||||
} else if ts := timestampRegex2.FindString(line); ts != "" {
|
||||
if parsed, err := time.Parse("2006-01-02 15:04:05", ts); err == nil {
|
||||
entry.Timestamp = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Extrair nível de log
|
||||
if level := logLevelRegex.FindString(line); level != "" {
|
||||
entry.Level = strings.ToUpper(level)
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
// TimestampString retorna o timestamp como string no formato original
|
||||
// Retorna string vazia se não houver timestamp
|
||||
func (e LogEntry) TimestampString() string {
|
||||
if e.Timestamp.IsZero() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Tentar detectar o formato original da linha
|
||||
if strings.Contains(e.Line, "T") {
|
||||
return e.Timestamp.Format("2006-01-02T15:04:05")
|
||||
}
|
||||
return e.Timestamp.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewLogEntry(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
line string
|
||||
wantLevel string
|
||||
wantTimestamp bool
|
||||
}{
|
||||
{
|
||||
name: "Log with ISO8601 timestamp and INFO level",
|
||||
filename: "app.log",
|
||||
line: "2025-11-19T10:00:00 INFO Application started",
|
||||
wantLevel: "INFO",
|
||||
wantTimestamp: true,
|
||||
},
|
||||
{
|
||||
name: "Log with space-separated timestamp and ERROR level",
|
||||
filename: "app.log",
|
||||
line: "2025-11-19 10:00:00 ERROR Connection failed",
|
||||
wantLevel: "ERROR",
|
||||
wantTimestamp: true,
|
||||
},
|
||||
{
|
||||
name: "Log with DEBUG level",
|
||||
filename: "debug.log",
|
||||
line: "DEBUG: Entering function processRequest",
|
||||
wantLevel: "DEBUG",
|
||||
wantTimestamp: false,
|
||||
},
|
||||
{
|
||||
name: "Log with WARN level",
|
||||
filename: "app.log",
|
||||
line: "WARN: Memory usage high",
|
||||
wantLevel: "WARN",
|
||||
wantTimestamp: false,
|
||||
},
|
||||
{
|
||||
name: "Log without level or timestamp",
|
||||
filename: "simple.log",
|
||||
line: "Simple log message",
|
||||
wantLevel: "",
|
||||
wantTimestamp: false,
|
||||
},
|
||||
{
|
||||
name: "Log with FATAL level",
|
||||
filename: "app.log",
|
||||
line: "FATAL: System crash",
|
||||
wantLevel: "FATAL",
|
||||
wantTimestamp: false,
|
||||
},
|
||||
{
|
||||
name: "Log with carriage return",
|
||||
filename: "windows.log",
|
||||
line: "Log line with CR\r\n",
|
||||
wantLevel: "",
|
||||
wantTimestamp: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
entry := NewLogEntry(tt.filename, tt.line)
|
||||
|
||||
if entry.Filename != tt.filename {
|
||||
t.Errorf("Filename = %v, want %v", entry.Filename, tt.filename)
|
||||
}
|
||||
|
||||
if entry.Level != tt.wantLevel {
|
||||
t.Errorf("Level = %v, want %v", entry.Level, tt.wantLevel)
|
||||
}
|
||||
|
||||
if tt.wantTimestamp && entry.Timestamp.IsZero() {
|
||||
t.Error("Expected timestamp to be parsed, but got zero value")
|
||||
}
|
||||
|
||||
if !tt.wantTimestamp && !entry.Timestamp.IsZero() {
|
||||
t.Error("Expected no timestamp, but got one")
|
||||
}
|
||||
|
||||
// Line should have CR/LF trimmed
|
||||
if entry.Line != tt.line && entry.Line != tt.line[:len(tt.line)-2] {
|
||||
t.Errorf("Line not properly trimmed: %q", entry.Line)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogEntry_TimestampString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
timestamp time.Time
|
||||
line string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ISO8601 format",
|
||||
timestamp: time.Date(2025, 11, 19, 10, 0, 0, 0, time.UTC),
|
||||
line: "2025-11-19T10:00:00 INFO Test",
|
||||
want: "2025-11-19T10:00:00",
|
||||
},
|
||||
{
|
||||
name: "Space-separated format",
|
||||
timestamp: time.Date(2025, 11, 19, 10, 0, 0, 0, time.UTC),
|
||||
line: "2025-11-19 10:00:00 INFO Test",
|
||||
want: "2025-11-19 10:00:00",
|
||||
},
|
||||
{
|
||||
name: "Zero timestamp",
|
||||
timestamp: time.Time{},
|
||||
line: "No timestamp",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
entry := LogEntry{
|
||||
Filename: "test.log",
|
||||
Line: tt.line,
|
||||
Timestamp: tt.timestamp,
|
||||
}
|
||||
|
||||
got := entry.TimestampString()
|
||||
if got != tt.want {
|
||||
t.Errorf("TimestampString() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkNewLogEntry(b *testing.B) {
|
||||
line := "2025-11-19T10:00:00 INFO Application started successfully"
|
||||
filename := "app.log"
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = NewLogEntry(filename, line)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package tail
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/nxadm/tail"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/seu-usuario/go-react-web-tail/internal/models"
|
||||
)
|
||||
|
||||
// Tailer gerencia o monitoramento de múltiplos arquivos de log
|
||||
type Tailer struct {
|
||||
files []string
|
||||
tails []*tail.Tail
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
onEntry func(models.LogEntry)
|
||||
}
|
||||
|
||||
// New cria uma nova instância do Tailer
|
||||
func New(ctx context.Context, files []string, onEntry func(models.LogEntry)) *Tailer {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
return &Tailer{
|
||||
files: files,
|
||||
tails: make([]*tail.Tail, 0, len(files)),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
onEntry: onEntry,
|
||||
}
|
||||
}
|
||||
|
||||
// Start inicia o monitoramento de todos os arquivos
|
||||
func (t *Tailer) Start() error {
|
||||
log.Info().Int("file_count", len(t.files)).Msg("Starting tailer")
|
||||
|
||||
for _, filename := range t.files {
|
||||
if err := t.startTailing(filename); err != nil {
|
||||
log.Error().Err(err).Str("file", filename).Msg("Failed to start tailing file")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// startTailing inicia o monitoramento de um arquivo específico
|
||||
func (t *Tailer) startTailing(filename string) error {
|
||||
config := tail.Config{
|
||||
Follow: true,
|
||||
ReOpen: true,
|
||||
Location: &tail.SeekInfo{Offset: 0, Whence: os.SEEK_END},
|
||||
Logger: tail.DiscardingLogger,
|
||||
}
|
||||
|
||||
tailFile, err := tail.TailFile(filename, config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t.mu.Lock()
|
||||
t.tails = append(t.tails, tailFile)
|
||||
t.mu.Unlock()
|
||||
|
||||
log.Info().Str("file", filename).Msg("Started tailing file")
|
||||
|
||||
go t.processTail(tailFile, filename)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processTail processa as linhas de um arquivo
|
||||
func (t *Tailer) processTail(tailFile *tail.Tail, filename string) {
|
||||
for {
|
||||
select {
|
||||
case <-t.ctx.Done():
|
||||
log.Debug().Str("file", filename).Msg("Stopping tail processing")
|
||||
return
|
||||
|
||||
case line, ok := <-tailFile.Lines:
|
||||
if !ok {
|
||||
log.Warn().Str("file", filename).Msg("Tail channel closed")
|
||||
return
|
||||
}
|
||||
|
||||
if line.Err != nil {
|
||||
log.Error().Err(line.Err).Str("file", filename).Msg("Error reading line")
|
||||
continue
|
||||
}
|
||||
|
||||
entry := models.NewLogEntry(filename, line.Text)
|
||||
t.onEntry(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown para o monitoramento de todos os arquivos
|
||||
func (t *Tailer) Shutdown() error {
|
||||
log.Info().Msg("Shutting down tailer")
|
||||
t.cancel()
|
||||
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
for _, tailFile := range t.tails {
|
||||
if err := tailFile.Stop(); err != nil {
|
||||
log.Error().Err(err).Msg("Error stopping tail")
|
||||
}
|
||||
}
|
||||
|
||||
log.Info().Msg("All tail operations stopped")
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user