v0.1.5 multi-target support, package refactoring
This commit is contained in:
@ -7,27 +7,19 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"logwisp/src/internal/config"
|
||||
"logwisp/src/internal/monitor"
|
||||
"logwisp/src/internal/stream"
|
||||
"logwisp/src/internal/logstream"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Parse CLI flags
|
||||
var (
|
||||
configFile = flag.String("config", "", "Config file path")
|
||||
// Flags
|
||||
httpPort = flag.Int("http-port", 0, "HTTP server port")
|
||||
httpBuffer = flag.Int("http-buffer-size", 0, "HTTP server buffer size")
|
||||
tcpPort = flag.Int("tcp-port", 0, "TCP server port")
|
||||
tcpBuffer = flag.Int("tcp-buffer-size", 0, "TCP server buffer size")
|
||||
enableTCP = flag.Bool("enable-tcp", false, "Enable TCP server")
|
||||
enableHTTP = flag.Bool("enable-http", false, "Enable HTTP server")
|
||||
checkInterval = flag.Int("check-interval", 0, "File check interval in ms")
|
||||
useRouter = flag.Bool("router", false, "Use HTTP router for path-based routing")
|
||||
// routerPort = flag.Int("router-port", 0, "Override router port (default: first HTTP port)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
@ -35,39 +27,8 @@ func main() {
|
||||
os.Setenv("LOGWISP_CONFIG_FILE", *configFile)
|
||||
}
|
||||
|
||||
// Build CLI args for config
|
||||
var cliArgs []string
|
||||
|
||||
// Flags
|
||||
if *httpPort > 0 {
|
||||
cliArgs = append(cliArgs, fmt.Sprintf("--httpserver.port=%d", *httpPort))
|
||||
}
|
||||
if *httpBuffer > 0 {
|
||||
cliArgs = append(cliArgs, fmt.Sprintf("--httpserver.buffer_size=%d", *httpBuffer))
|
||||
}
|
||||
if *tcpPort > 0 {
|
||||
cliArgs = append(cliArgs, fmt.Sprintf("--tcpserver.port=%d", *tcpPort))
|
||||
}
|
||||
if *tcpBuffer > 0 {
|
||||
cliArgs = append(cliArgs, fmt.Sprintf("--tcpserver.buffer_size=%d", *tcpBuffer))
|
||||
}
|
||||
if flag.Lookup("enable-tcp").DefValue != flag.Lookup("enable-tcp").Value.String() {
|
||||
cliArgs = append(cliArgs, fmt.Sprintf("--tcpserver.enabled=%v", *enableTCP))
|
||||
}
|
||||
if flag.Lookup("enable-http").DefValue != flag.Lookup("enable-http").Value.String() {
|
||||
cliArgs = append(cliArgs, fmt.Sprintf("--httpserver.enabled=%v", *enableHTTP))
|
||||
}
|
||||
if *checkInterval > 0 {
|
||||
cliArgs = append(cliArgs, fmt.Sprintf("--monitor.check_interval_ms=%d", *checkInterval))
|
||||
}
|
||||
|
||||
// Parse monitor targets from remaining args
|
||||
for _, arg := range flag.Args() {
|
||||
cliArgs = append(cliArgs, fmt.Sprintf("--monitor.targets.add=%s", arg))
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.LoadWithCLI(cliArgs)
|
||||
cfg, err := config.LoadWithCLI(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
|
||||
os.Exit(1)
|
||||
@ -81,147 +42,154 @@ func main() {
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// Create monitor
|
||||
mon := monitor.New()
|
||||
mon.SetCheckInterval(time.Duration(cfg.Monitor.CheckIntervalMs) * time.Millisecond)
|
||||
// Create log stream service
|
||||
service := logstream.New(ctx)
|
||||
|
||||
// Add targets
|
||||
for _, target := range cfg.Monitor.Targets {
|
||||
if err := mon.AddTarget(target.Path, target.Pattern, target.IsFile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to add target %s: %v\n", target.Path, err)
|
||||
}
|
||||
// Create HTTP router if requested
|
||||
var router *logstream.HTTPRouter
|
||||
if *useRouter {
|
||||
router = logstream.NewHTTPRouter(service)
|
||||
fmt.Println("HTTP router mode enabled")
|
||||
}
|
||||
|
||||
// Start monitor
|
||||
if err := mon.Start(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to start monitor: %v\n", err)
|
||||
// Initialize streams
|
||||
successCount := 0
|
||||
for _, streamCfg := range cfg.Streams {
|
||||
fmt.Printf("Initializing stream '%s'...\n", streamCfg.Name)
|
||||
|
||||
// Set router mode BEFORE creating stream
|
||||
if *useRouter && streamCfg.HTTPServer != nil && streamCfg.HTTPServer.Enabled {
|
||||
// Temporarily disable standalone server startup
|
||||
originalEnabled := streamCfg.HTTPServer.Enabled
|
||||
streamCfg.HTTPServer.Enabled = false
|
||||
|
||||
if err := service.CreateStream(streamCfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create stream '%s': %v\n", streamCfg.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the created stream and configure for router mode
|
||||
stream, _ := service.GetStream(streamCfg.Name)
|
||||
if stream.HTTPServer != nil {
|
||||
stream.HTTPServer.SetRouterMode()
|
||||
// Restore enabled state
|
||||
stream.Config.HTTPServer.Enabled = originalEnabled
|
||||
|
||||
if err := router.RegisterStream(stream); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to register stream '%s' with router: %v\n",
|
||||
streamCfg.Name, err)
|
||||
} else {
|
||||
fmt.Printf("Stream '%s' registered with router\n", streamCfg.Name)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Standard standalone mode
|
||||
if err := service.CreateStream(streamCfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to create stream '%s': %v\n", streamCfg.Name, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
successCount++
|
||||
|
||||
// Display endpoints
|
||||
displayStreamEndpoints(streamCfg, *useRouter)
|
||||
}
|
||||
|
||||
if successCount == 0 {
|
||||
fmt.Fprintln(os.Stderr, "No streams successfully started")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var tcpServer *stream.TCPStreamer
|
||||
var httpServer *stream.HTTPStreamer
|
||||
fmt.Printf("\n%d stream(s) running. Press Ctrl+C to stop.\n", successCount)
|
||||
|
||||
// Start TCP server if enabled
|
||||
if cfg.TCPServer.Enabled {
|
||||
tcpChan := mon.Subscribe()
|
||||
tcpServer = stream.NewTCPStreamer(tcpChan, cfg.TCPServer)
|
||||
|
||||
// Start TCP server in separate goroutine without blocking wg.Wait()
|
||||
tcpStarted := make(chan error, 1)
|
||||
go func() {
|
||||
tcpStarted <- tcpServer.Start()
|
||||
}()
|
||||
|
||||
// Check if TCP server started successfully
|
||||
select {
|
||||
case err := <-tcpStarted:
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "TCP server failed to start: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
// Server is running
|
||||
}
|
||||
|
||||
fmt.Printf("TCP streaming on port %d\n", cfg.TCPServer.Port)
|
||||
}
|
||||
|
||||
// Start HTTP server if enabled
|
||||
if cfg.HTTPServer.Enabled {
|
||||
httpChan := mon.Subscribe()
|
||||
httpServer = stream.NewHTTPStreamer(httpChan, cfg.HTTPServer)
|
||||
|
||||
// Start HTTP server in separate goroutine without blocking wg.Wait()
|
||||
httpStarted := make(chan error, 1)
|
||||
go func() {
|
||||
httpStarted <- httpServer.Start()
|
||||
}()
|
||||
|
||||
// Check if HTTP server started successfully
|
||||
select {
|
||||
case err := <-httpStarted:
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "HTTP server failed to start: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case <-time.After(1 * time.Second):
|
||||
// Server is running
|
||||
}
|
||||
|
||||
fmt.Printf("HTTP/SSE streaming on http://localhost:%d/stream\n", cfg.HTTPServer.Port)
|
||||
fmt.Printf("Status available at http://localhost:%d/status\n", cfg.HTTPServer.Port)
|
||||
}
|
||||
|
||||
if !cfg.TCPServer.Enabled && !cfg.HTTPServer.Enabled {
|
||||
fmt.Fprintln(os.Stderr, "No servers enabled. Enable at least one server in config.")
|
||||
os.Exit(1)
|
||||
}
|
||||
// Start periodic status display
|
||||
go statusReporter(service)
|
||||
|
||||
// Wait for shutdown
|
||||
<-sigChan
|
||||
fmt.Println("\nShutting down...")
|
||||
|
||||
// Create shutdown group for concurrent server stops
|
||||
var shutdownWg sync.WaitGroup
|
||||
|
||||
// Stop servers first (concurrently)
|
||||
if tcpServer != nil {
|
||||
shutdownWg.Add(1)
|
||||
go func() {
|
||||
defer shutdownWg.Done()
|
||||
tcpServer.Stop()
|
||||
}()
|
||||
}
|
||||
if httpServer != nil {
|
||||
shutdownWg.Add(1)
|
||||
go func() {
|
||||
defer shutdownWg.Done()
|
||||
httpServer.Stop()
|
||||
}()
|
||||
// Shutdown router first if using it
|
||||
if router != nil {
|
||||
fmt.Println("Shutting down HTTP router...")
|
||||
router.Shutdown()
|
||||
}
|
||||
|
||||
// Cancel context to stop monitor
|
||||
cancel()
|
||||
// Shutdown service (handles all streams)
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
// Wait for servers to stop with timeout
|
||||
serversDone := make(chan struct{})
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
shutdownWg.Wait()
|
||||
close(serversDone)
|
||||
service.Shutdown()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Stop monitor after context cancellation
|
||||
monitorDone := make(chan struct{})
|
||||
go func() {
|
||||
mon.Stop()
|
||||
close(monitorDone)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
fmt.Println("Shutdown complete")
|
||||
case <-shutdownCtx.Done():
|
||||
fmt.Println("Shutdown timeout - forcing exit")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all components with proper timeout
|
||||
shutdownTimeout := 5 * time.Second
|
||||
shutdownTimer := time.NewTimer(shutdownTimeout)
|
||||
defer shutdownTimer.Stop()
|
||||
func displayStreamEndpoints(cfg config.StreamConfig, routerMode bool) {
|
||||
if cfg.TCPServer != nil && cfg.TCPServer.Enabled {
|
||||
fmt.Printf(" TCP: port %d\n", cfg.TCPServer.Port)
|
||||
}
|
||||
|
||||
serversShutdown := false
|
||||
monitorShutdown := false
|
||||
if cfg.HTTPServer != nil && cfg.HTTPServer.Enabled {
|
||||
if routerMode {
|
||||
fmt.Printf(" HTTP: /%s%s (stream), /%s%s (status)\n",
|
||||
cfg.Name, cfg.HTTPServer.StreamPath,
|
||||
cfg.Name, cfg.HTTPServer.StatusPath)
|
||||
} else {
|
||||
fmt.Printf(" HTTP: http://localhost:%d%s (stream), http://localhost:%d%s (status)\n",
|
||||
cfg.HTTPServer.Port, cfg.HTTPServer.StreamPath,
|
||||
cfg.HTTPServer.Port, cfg.HTTPServer.StatusPath)
|
||||
}
|
||||
|
||||
for !serversShutdown || !monitorShutdown {
|
||||
select {
|
||||
case <-serversDone:
|
||||
serversShutdown = true
|
||||
case <-monitorDone:
|
||||
monitorShutdown = true
|
||||
case <-shutdownTimer.C:
|
||||
if !serversShutdown {
|
||||
fmt.Println("Warning: Server shutdown timeout")
|
||||
}
|
||||
if !monitorShutdown {
|
||||
fmt.Println("Warning: Monitor shutdown timeout")
|
||||
}
|
||||
fmt.Println("Forcing exit")
|
||||
os.Exit(1)
|
||||
if cfg.Auth != nil && cfg.Auth.Type != "none" {
|
||||
fmt.Printf(" Auth: %s\n", cfg.Auth.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Shutdown complete")
|
||||
func statusReporter(service *logstream.Service) {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
stats := service.GetGlobalStats()
|
||||
totalStreams := stats["total_streams"].(int)
|
||||
if totalStreams == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\n[%s] Active streams: %d\n",
|
||||
time.Now().Format("15:04:05"), totalStreams)
|
||||
|
||||
for name, streamStats := range stats["streams"].(map[string]interface{}) {
|
||||
s := streamStats.(map[string]interface{})
|
||||
fmt.Printf(" %s: ", name)
|
||||
|
||||
if monitor, ok := s["monitor"].(map[string]interface{}); ok {
|
||||
fmt.Printf("watchers=%d entries=%d ",
|
||||
monitor["active_watchers"],
|
||||
monitor["total_entries"])
|
||||
}
|
||||
|
||||
if tcp, ok := s["tcp"].(map[string]interface{}); ok && tcp["enabled"].(bool) {
|
||||
fmt.Printf("tcp_conns=%d ", tcp["connections"])
|
||||
}
|
||||
|
||||
if http, ok := s["http"].(map[string]interface{}); ok && http["enabled"].(bool) {
|
||||
fmt.Printf("http_conns=%d ", http["connections"])
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/internal/config/auth.go
Normal file
56
src/internal/config/auth.go
Normal file
@ -0,0 +1,56 @@
|
||||
// FILE: src/internal/config/auth.go
|
||||
package config
|
||||
|
||||
type AuthConfig struct {
|
||||
// Authentication type: "none", "basic", "bearer", "mtls"
|
||||
Type string `toml:"type"`
|
||||
|
||||
// Basic auth
|
||||
BasicAuth *BasicAuthConfig `toml:"basic_auth"`
|
||||
|
||||
// Bearer token auth
|
||||
BearerAuth *BearerAuthConfig `toml:"bearer_auth"`
|
||||
|
||||
// IP-based access control
|
||||
IPWhitelist []string `toml:"ip_whitelist"`
|
||||
IPBlacklist []string `toml:"ip_blacklist"`
|
||||
}
|
||||
|
||||
type BasicAuthConfig struct {
|
||||
// Static users (for simple deployments)
|
||||
Users []BasicAuthUser `toml:"users"`
|
||||
|
||||
// External auth file
|
||||
UsersFile string `toml:"users_file"`
|
||||
|
||||
// Realm for WWW-Authenticate header
|
||||
Realm string `toml:"realm"`
|
||||
}
|
||||
|
||||
type BasicAuthUser struct {
|
||||
Username string `toml:"username"`
|
||||
// Password hash (bcrypt)
|
||||
PasswordHash string `toml:"password_hash"`
|
||||
}
|
||||
|
||||
type BearerAuthConfig struct {
|
||||
// Static tokens
|
||||
Tokens []string `toml:"tokens"`
|
||||
|
||||
// JWT validation
|
||||
JWT *JWTConfig `toml:"jwt"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
// JWKS URL for key discovery
|
||||
JWKSURL string `toml:"jwks_url"`
|
||||
|
||||
// Static signing key (if not using JWKS)
|
||||
SigningKey string `toml:"signing_key"`
|
||||
|
||||
// Expected issuer
|
||||
Issuer string `toml:"issuer"`
|
||||
|
||||
// Expected audience
|
||||
Audience string `toml:"audience"`
|
||||
}
|
||||
@ -1,245 +1,14 @@
|
||||
// FILE: src/internal/config/config.go
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
lconfig "github.com/lixenwraith/config"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Monitor MonitorConfig `toml:"monitor"`
|
||||
TCPServer TCPConfig `toml:"tcpserver"`
|
||||
HTTPServer HTTPConfig `toml:"httpserver"`
|
||||
// Global monitor settings
|
||||
Monitor MonitorConfig `toml:"monitor"`
|
||||
|
||||
// Stream configurations
|
||||
Streams []StreamConfig `toml:"streams"`
|
||||
}
|
||||
|
||||
type MonitorConfig struct {
|
||||
CheckIntervalMs int `toml:"check_interval_ms"`
|
||||
Targets []MonitorTarget `toml:"targets"`
|
||||
}
|
||||
|
||||
type MonitorTarget struct {
|
||||
Path string `toml:"path"`
|
||||
Pattern string `toml:"pattern"`
|
||||
IsFile bool `toml:"is_file"`
|
||||
}
|
||||
|
||||
type TCPConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Port int `toml:"port"`
|
||||
BufferSize int `toml:"buffer_size"`
|
||||
SSLEnabled bool `toml:"ssl_enabled"`
|
||||
SSLCertFile string `toml:"ssl_cert_file"`
|
||||
SSLKeyFile string `toml:"ssl_key_file"`
|
||||
Heartbeat HeartbeatConfig `toml:"heartbeat"`
|
||||
}
|
||||
|
||||
type HTTPConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Port int `toml:"port"`
|
||||
BufferSize int `toml:"buffer_size"`
|
||||
SSLEnabled bool `toml:"ssl_enabled"`
|
||||
SSLCertFile string `toml:"ssl_cert_file"`
|
||||
SSLKeyFile string `toml:"ssl_key_file"`
|
||||
Heartbeat HeartbeatConfig `toml:"heartbeat"`
|
||||
}
|
||||
|
||||
type HeartbeatConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
IntervalSeconds int `toml:"interval_seconds"`
|
||||
IncludeTimestamp bool `toml:"include_timestamp"`
|
||||
IncludeStats bool `toml:"include_stats"`
|
||||
Format string `toml:"format"` // "comment" or "json"
|
||||
}
|
||||
|
||||
func defaults() *Config {
|
||||
return &Config{
|
||||
Monitor: MonitorConfig{
|
||||
CheckIntervalMs: 100,
|
||||
Targets: []MonitorTarget{
|
||||
{Path: "./", Pattern: "*.log", IsFile: false},
|
||||
},
|
||||
},
|
||||
TCPServer: TCPConfig{
|
||||
Enabled: false,
|
||||
Port: 9090,
|
||||
BufferSize: 1000,
|
||||
Heartbeat: HeartbeatConfig{
|
||||
Enabled: false,
|
||||
IntervalSeconds: 30,
|
||||
IncludeTimestamp: true,
|
||||
IncludeStats: false,
|
||||
Format: "json",
|
||||
},
|
||||
},
|
||||
HTTPServer: HTTPConfig{
|
||||
Enabled: true,
|
||||
Port: 8080,
|
||||
BufferSize: 1000,
|
||||
Heartbeat: HeartbeatConfig{
|
||||
Enabled: true,
|
||||
IntervalSeconds: 30,
|
||||
IncludeTimestamp: true,
|
||||
IncludeStats: false,
|
||||
Format: "comment",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func LoadWithCLI(cliArgs []string) (*Config, error) {
|
||||
configPath := GetConfigPath()
|
||||
|
||||
cfg, err := lconfig.NewBuilder().
|
||||
WithDefaults(defaults()).
|
||||
WithEnvPrefix("LOGWISP_").
|
||||
WithFile(configPath).
|
||||
WithArgs(cliArgs).
|
||||
WithEnvTransform(customEnvTransform).
|
||||
WithSources(
|
||||
lconfig.SourceCLI,
|
||||
lconfig.SourceEnv,
|
||||
lconfig.SourceFile,
|
||||
lconfig.SourceDefault,
|
||||
).
|
||||
Build()
|
||||
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := handleMonitorTargetsEnv(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
finalConfig := &Config{}
|
||||
if err := cfg.Scan("", finalConfig); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan config: %w", err)
|
||||
}
|
||||
|
||||
return finalConfig, finalConfig.validate()
|
||||
}
|
||||
|
||||
func customEnvTransform(path string) string {
|
||||
env := strings.ReplaceAll(path, ".", "_")
|
||||
env = strings.ToUpper(env)
|
||||
env = "LOGWISP_" + env
|
||||
return env
|
||||
}
|
||||
|
||||
func GetConfigPath() string {
|
||||
if configFile := os.Getenv("LOGWISP_CONFIG_FILE"); configFile != "" {
|
||||
if filepath.IsAbs(configFile) {
|
||||
return configFile
|
||||
}
|
||||
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
||||
return filepath.Join(configDir, configFile)
|
||||
}
|
||||
return configFile
|
||||
}
|
||||
|
||||
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
||||
return filepath.Join(configDir, "logwisp.toml")
|
||||
}
|
||||
|
||||
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(homeDir, ".config", "logwisp.toml")
|
||||
}
|
||||
|
||||
return "logwisp.toml"
|
||||
}
|
||||
|
||||
func handleMonitorTargetsEnv(cfg *lconfig.Config) error {
|
||||
if targetsStr := os.Getenv("LOGWISP_MONITOR_TARGETS"); targetsStr != "" {
|
||||
cfg.Set("monitor.targets", []MonitorTarget{})
|
||||
|
||||
parts := strings.Split(targetsStr, ",")
|
||||
for i, part := range parts {
|
||||
targetParts := strings.Split(part, ":")
|
||||
if len(targetParts) >= 1 && targetParts[0] != "" {
|
||||
path := fmt.Sprintf("monitor.targets.%d.path", i)
|
||||
cfg.Set(path, targetParts[0])
|
||||
|
||||
if len(targetParts) >= 2 && targetParts[1] != "" {
|
||||
pattern := fmt.Sprintf("monitor.targets.%d.pattern", i)
|
||||
cfg.Set(pattern, targetParts[1])
|
||||
} else {
|
||||
pattern := fmt.Sprintf("monitor.targets.%d.pattern", i)
|
||||
cfg.Set(pattern, "*.log")
|
||||
}
|
||||
|
||||
if len(targetParts) >= 3 {
|
||||
isFile := fmt.Sprintf("monitor.targets.%d.is_file", i)
|
||||
cfg.Set(isFile, targetParts[2] == "true")
|
||||
} else {
|
||||
isFile := fmt.Sprintf("monitor.targets.%d.is_file", i)
|
||||
cfg.Set(isFile, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
if c.Monitor.CheckIntervalMs < 10 {
|
||||
return fmt.Errorf("check interval too small: %d ms", c.Monitor.CheckIntervalMs)
|
||||
}
|
||||
|
||||
if len(c.Monitor.Targets) == 0 {
|
||||
return fmt.Errorf("no monitor targets specified")
|
||||
}
|
||||
|
||||
for i, target := range c.Monitor.Targets {
|
||||
if target.Path == "" {
|
||||
return fmt.Errorf("target %d: empty path", i)
|
||||
}
|
||||
if strings.Contains(target.Path, "..") {
|
||||
return fmt.Errorf("target %d: path contains directory traversal", i)
|
||||
}
|
||||
}
|
||||
|
||||
if c.TCPServer.Enabled {
|
||||
if c.TCPServer.Port < 1 || c.TCPServer.Port > 65535 {
|
||||
return fmt.Errorf("invalid TCP port: %d", c.TCPServer.Port)
|
||||
}
|
||||
if c.TCPServer.BufferSize < 1 {
|
||||
return fmt.Errorf("TCP buffer size must be positive: %d", c.TCPServer.BufferSize)
|
||||
}
|
||||
}
|
||||
|
||||
if c.HTTPServer.Enabled {
|
||||
if c.HTTPServer.Port < 1 || c.HTTPServer.Port > 65535 {
|
||||
return fmt.Errorf("invalid HTTP port: %d", c.HTTPServer.Port)
|
||||
}
|
||||
if c.HTTPServer.BufferSize < 1 {
|
||||
return fmt.Errorf("HTTP buffer size must be positive: %d", c.HTTPServer.BufferSize)
|
||||
}
|
||||
}
|
||||
|
||||
if c.TCPServer.Enabled && c.TCPServer.Heartbeat.Enabled {
|
||||
if c.TCPServer.Heartbeat.IntervalSeconds < 1 {
|
||||
return fmt.Errorf("TCP heartbeat interval must be positive: %d", c.TCPServer.Heartbeat.IntervalSeconds)
|
||||
}
|
||||
if c.TCPServer.Heartbeat.Format != "json" && c.TCPServer.Heartbeat.Format != "comment" {
|
||||
return fmt.Errorf("TCP heartbeat format must be 'json' or 'comment': %s", c.TCPServer.Heartbeat.Format)
|
||||
}
|
||||
}
|
||||
|
||||
if c.HTTPServer.Enabled && c.HTTPServer.Heartbeat.Enabled {
|
||||
if c.HTTPServer.Heartbeat.IntervalSeconds < 1 {
|
||||
return fmt.Errorf("HTTP heartbeat interval must be positive: %d", c.HTTPServer.Heartbeat.IntervalSeconds)
|
||||
}
|
||||
if c.HTTPServer.Heartbeat.Format != "json" && c.HTTPServer.Heartbeat.Format != "comment" {
|
||||
return fmt.Errorf("HTTP heartbeat format must be 'json' or 'comment': %s", c.HTTPServer.Heartbeat.Format)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
CheckIntervalMs int `toml:"check_interval_ms"`
|
||||
}
|
||||
102
src/internal/config/loader.go
Normal file
102
src/internal/config/loader.go
Normal file
@ -0,0 +1,102 @@
|
||||
// FILE: src/internal/config/loader.go
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
lconfig "github.com/lixenwraith/config"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func defaults() *Config {
|
||||
return &Config{
|
||||
Monitor: MonitorConfig{
|
||||
CheckIntervalMs: 100,
|
||||
},
|
||||
Streams: []StreamConfig{
|
||||
{
|
||||
Name: "default",
|
||||
Monitor: &StreamMonitorConfig{
|
||||
Targets: []MonitorTarget{
|
||||
{Path: "./", Pattern: "*.log", IsFile: false},
|
||||
},
|
||||
},
|
||||
HTTPServer: &HTTPConfig{
|
||||
Enabled: true,
|
||||
Port: 8080,
|
||||
BufferSize: 1000,
|
||||
StreamPath: "/stream",
|
||||
StatusPath: "/status",
|
||||
Heartbeat: HeartbeatConfig{
|
||||
Enabled: true,
|
||||
IntervalSeconds: 30,
|
||||
IncludeTimestamp: true,
|
||||
IncludeStats: false,
|
||||
Format: "comment",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func LoadWithCLI(cliArgs []string) (*Config, error) {
|
||||
configPath := GetConfigPath()
|
||||
|
||||
cfg, err := lconfig.NewBuilder().
|
||||
WithDefaults(defaults()).
|
||||
WithEnvPrefix("LOGWISP_").
|
||||
WithFile(configPath).
|
||||
WithArgs(cliArgs).
|
||||
WithEnvTransform(customEnvTransform).
|
||||
WithSources(
|
||||
lconfig.SourceCLI,
|
||||
lconfig.SourceEnv,
|
||||
lconfig.SourceFile,
|
||||
lconfig.SourceDefault,
|
||||
).
|
||||
Build()
|
||||
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "not found") {
|
||||
return nil, fmt.Errorf("failed to load config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
finalConfig := &Config{}
|
||||
if err := cfg.Scan("", finalConfig); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan config: %w", err)
|
||||
}
|
||||
|
||||
return finalConfig, finalConfig.validate()
|
||||
}
|
||||
|
||||
func customEnvTransform(path string) string {
|
||||
env := strings.ReplaceAll(path, ".", "_")
|
||||
env = strings.ToUpper(env)
|
||||
env = "LOGWISP_" + env
|
||||
return env
|
||||
}
|
||||
|
||||
func GetConfigPath() string {
|
||||
if configFile := os.Getenv("LOGWISP_CONFIG_FILE"); configFile != "" {
|
||||
if filepath.IsAbs(configFile) {
|
||||
return configFile
|
||||
}
|
||||
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
||||
return filepath.Join(configDir, configFile)
|
||||
}
|
||||
return configFile
|
||||
}
|
||||
|
||||
if configDir := os.Getenv("LOGWISP_CONFIG_DIR"); configDir != "" {
|
||||
return filepath.Join(configDir, "logwisp.toml")
|
||||
}
|
||||
|
||||
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||
return filepath.Join(homeDir, ".config", "logwisp.toml")
|
||||
}
|
||||
|
||||
return "logwisp.toml"
|
||||
}
|
||||
62
src/internal/config/server.go
Normal file
62
src/internal/config/server.go
Normal file
@ -0,0 +1,62 @@
|
||||
// FILE: src/internal/config/server.go
|
||||
package config
|
||||
|
||||
type TCPConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Port int `toml:"port"`
|
||||
BufferSize int `toml:"buffer_size"`
|
||||
|
||||
// SSL/TLS Configuration
|
||||
SSL *SSLConfig `toml:"ssl"`
|
||||
|
||||
// Rate limiting
|
||||
RateLimit *RateLimitConfig `toml:"rate_limit"`
|
||||
|
||||
// Heartbeat
|
||||
Heartbeat HeartbeatConfig `toml:"heartbeat"`
|
||||
}
|
||||
|
||||
type HTTPConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Port int `toml:"port"`
|
||||
BufferSize int `toml:"buffer_size"`
|
||||
|
||||
// Endpoint paths
|
||||
StreamPath string `toml:"stream_path"`
|
||||
StatusPath string `toml:"status_path"`
|
||||
|
||||
// SSL/TLS Configuration
|
||||
SSL *SSLConfig `toml:"ssl"`
|
||||
|
||||
// Rate limiting
|
||||
RateLimit *RateLimitConfig `toml:"rate_limit"`
|
||||
|
||||
// Heartbeat
|
||||
Heartbeat HeartbeatConfig `toml:"heartbeat"`
|
||||
}
|
||||
|
||||
type HeartbeatConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
IntervalSeconds int `toml:"interval_seconds"`
|
||||
IncludeTimestamp bool `toml:"include_timestamp"`
|
||||
IncludeStats bool `toml:"include_stats"`
|
||||
Format string `toml:"format"` // "comment" or "json"
|
||||
}
|
||||
|
||||
type RateLimitConfig struct {
|
||||
// Enable rate limiting
|
||||
Enabled bool `toml:"enabled"`
|
||||
|
||||
// Requests per second per client
|
||||
RequestsPerSecond float64 `toml:"requests_per_second"`
|
||||
|
||||
// Burst size (token bucket)
|
||||
BurstSize int `toml:"burst_size"`
|
||||
|
||||
// Rate limit by: "ip", "user", "token"
|
||||
LimitBy string `toml:"limit_by"`
|
||||
|
||||
// Response when rate limited
|
||||
ResponseCode int `toml:"response_code"` // Default: 429
|
||||
ResponseMessage string `toml:"response_message"` // Default: "Rate limit exceeded"
|
||||
}
|
||||
20
src/internal/config/ssl.go
Normal file
20
src/internal/config/ssl.go
Normal file
@ -0,0 +1,20 @@
|
||||
// FILE: src/internal/config/ssl.go
|
||||
package config
|
||||
|
||||
type SSLConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
CertFile string `toml:"cert_file"`
|
||||
KeyFile string `toml:"key_file"`
|
||||
|
||||
// Client certificate authentication
|
||||
ClientAuth bool `toml:"client_auth"`
|
||||
ClientCAFile string `toml:"client_ca_file"`
|
||||
VerifyClientCert bool `toml:"verify_client_cert"`
|
||||
|
||||
// TLS version constraints
|
||||
MinVersion string `toml:"min_version"` // "TLS1.2", "TLS1.3"
|
||||
MaxVersion string `toml:"max_version"`
|
||||
|
||||
// Cipher suites (comma-separated list)
|
||||
CipherSuites string `toml:"cipher_suites"`
|
||||
}
|
||||
42
src/internal/config/stream.go
Normal file
42
src/internal/config/stream.go
Normal file
@ -0,0 +1,42 @@
|
||||
// FILE: src/internal/config/stream.go
|
||||
package config
|
||||
|
||||
type StreamConfig struct {
|
||||
// Stream identifier (used in logs and metrics)
|
||||
Name string `toml:"name"`
|
||||
|
||||
// Monitor configuration for this stream
|
||||
Monitor *StreamMonitorConfig `toml:"monitor"`
|
||||
|
||||
// Server configurations
|
||||
TCPServer *TCPConfig `toml:"tcpserver"`
|
||||
HTTPServer *HTTPConfig `toml:"httpserver"`
|
||||
|
||||
// Authentication/Authorization
|
||||
Auth *AuthConfig `toml:"auth"`
|
||||
}
|
||||
|
||||
type StreamMonitorConfig struct {
|
||||
CheckIntervalMs *int `toml:"check_interval_ms"`
|
||||
Targets []MonitorTarget `toml:"targets"`
|
||||
}
|
||||
|
||||
type MonitorTarget struct {
|
||||
Path string `toml:"path"`
|
||||
Pattern string `toml:"pattern"`
|
||||
IsFile bool `toml:"is_file"`
|
||||
}
|
||||
|
||||
func (s *StreamConfig) GetTargets(defaultTargets []MonitorTarget) []MonitorTarget {
|
||||
if s.Monitor != nil && len(s.Monitor.Targets) > 0 {
|
||||
return s.Monitor.Targets
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StreamConfig) GetCheckInterval(defaultInterval int) int {
|
||||
if s.Monitor != nil && s.Monitor.CheckIntervalMs != nil {
|
||||
return *s.Monitor.CheckIntervalMs
|
||||
}
|
||||
return defaultInterval
|
||||
}
|
||||
187
src/internal/config/validation.go
Normal file
187
src/internal/config/validation.go
Normal file
@ -0,0 +1,187 @@
|
||||
// FILE: src/internal/config/validation.go
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (c *Config) validate() error {
|
||||
if c.Monitor.CheckIntervalMs < 10 {
|
||||
return fmt.Errorf("check interval too small: %d ms", c.Monitor.CheckIntervalMs)
|
||||
}
|
||||
|
||||
if len(c.Streams) == 0 {
|
||||
return fmt.Errorf("no streams configured")
|
||||
}
|
||||
|
||||
// Validate each stream
|
||||
streamNames := make(map[string]bool)
|
||||
streamPorts := make(map[int]string)
|
||||
|
||||
for i, stream := range c.Streams {
|
||||
if stream.Name == "" {
|
||||
return fmt.Errorf("stream %d: missing name", i)
|
||||
}
|
||||
|
||||
if streamNames[stream.Name] {
|
||||
return fmt.Errorf("stream %d: duplicate name '%s'", i, stream.Name)
|
||||
}
|
||||
streamNames[stream.Name] = true
|
||||
|
||||
// Stream must have targets
|
||||
if stream.Monitor == nil || len(stream.Monitor.Targets) == 0 {
|
||||
return fmt.Errorf("stream '%s': no monitor targets specified", stream.Name)
|
||||
}
|
||||
|
||||
for j, target := range stream.Monitor.Targets {
|
||||
if target.Path == "" {
|
||||
return fmt.Errorf("stream '%s' target %d: empty path", stream.Name, j)
|
||||
}
|
||||
if strings.Contains(target.Path, "..") {
|
||||
return fmt.Errorf("stream '%s' target %d: path contains directory traversal", stream.Name, j)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate TCP server
|
||||
if stream.TCPServer != nil && stream.TCPServer.Enabled {
|
||||
if stream.TCPServer.Port < 1 || stream.TCPServer.Port > 65535 {
|
||||
return fmt.Errorf("stream '%s': invalid TCP port: %d", stream.Name, stream.TCPServer.Port)
|
||||
}
|
||||
if existing, exists := streamPorts[stream.TCPServer.Port]; exists {
|
||||
return fmt.Errorf("stream '%s': TCP port %d already used by stream '%s'",
|
||||
stream.Name, stream.TCPServer.Port, existing)
|
||||
}
|
||||
streamPorts[stream.TCPServer.Port] = stream.Name + "-tcp"
|
||||
|
||||
if stream.TCPServer.BufferSize < 1 {
|
||||
return fmt.Errorf("stream '%s': TCP buffer size must be positive: %d",
|
||||
stream.Name, stream.TCPServer.BufferSize)
|
||||
}
|
||||
|
||||
if err := validateHeartbeat("TCP", stream.Name, &stream.TCPServer.Heartbeat); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateSSL("TCP", stream.Name, stream.TCPServer.SSL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Validate HTTP server
|
||||
if stream.HTTPServer != nil && stream.HTTPServer.Enabled {
|
||||
if stream.HTTPServer.Port < 1 || stream.HTTPServer.Port > 65535 {
|
||||
return fmt.Errorf("stream '%s': invalid HTTP port: %d", stream.Name, stream.HTTPServer.Port)
|
||||
}
|
||||
if existing, exists := streamPorts[stream.HTTPServer.Port]; exists {
|
||||
return fmt.Errorf("stream '%s': HTTP port %d already used by stream '%s'",
|
||||
stream.Name, stream.HTTPServer.Port, existing)
|
||||
}
|
||||
streamPorts[stream.HTTPServer.Port] = stream.Name + "-http"
|
||||
|
||||
if stream.HTTPServer.BufferSize < 1 {
|
||||
return fmt.Errorf("stream '%s': HTTP buffer size must be positive: %d",
|
||||
stream.Name, stream.HTTPServer.BufferSize)
|
||||
}
|
||||
|
||||
// Validate paths
|
||||
if stream.HTTPServer.StreamPath == "" {
|
||||
stream.HTTPServer.StreamPath = "/stream"
|
||||
}
|
||||
if stream.HTTPServer.StatusPath == "" {
|
||||
stream.HTTPServer.StatusPath = "/status"
|
||||
}
|
||||
if !strings.HasPrefix(stream.HTTPServer.StreamPath, "/") {
|
||||
return fmt.Errorf("stream '%s': stream path must start with /: %s",
|
||||
stream.Name, stream.HTTPServer.StreamPath)
|
||||
}
|
||||
if !strings.HasPrefix(stream.HTTPServer.StatusPath, "/") {
|
||||
return fmt.Errorf("stream '%s': status path must start with /: %s",
|
||||
stream.Name, stream.HTTPServer.StatusPath)
|
||||
}
|
||||
|
||||
if err := validateHeartbeat("HTTP", stream.Name, &stream.HTTPServer.Heartbeat); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateSSL("HTTP", stream.Name, stream.HTTPServer.SSL); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// At least one server must be enabled
|
||||
tcpEnabled := stream.TCPServer != nil && stream.TCPServer.Enabled
|
||||
httpEnabled := stream.HTTPServer != nil && stream.HTTPServer.Enabled
|
||||
if !tcpEnabled && !httpEnabled {
|
||||
return fmt.Errorf("stream '%s': no servers enabled", stream.Name)
|
||||
}
|
||||
|
||||
// Validate auth if present
|
||||
if err := validateAuth(stream.Name, stream.Auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHeartbeat(serverType, streamName string, hb *HeartbeatConfig) error {
|
||||
if hb.Enabled {
|
||||
if hb.IntervalSeconds < 1 {
|
||||
return fmt.Errorf("stream '%s' %s: heartbeat interval must be positive: %d",
|
||||
streamName, serverType, hb.IntervalSeconds)
|
||||
}
|
||||
if hb.Format != "json" && hb.Format != "comment" {
|
||||
return fmt.Errorf("stream '%s' %s: heartbeat format must be 'json' or 'comment': %s",
|
||||
streamName, serverType, hb.Format)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSSL(serverType, streamName string, ssl *SSLConfig) error {
|
||||
if ssl != nil && ssl.Enabled {
|
||||
if ssl.CertFile == "" || ssl.KeyFile == "" {
|
||||
return fmt.Errorf("stream '%s' %s: SSL enabled but cert/key files not specified",
|
||||
streamName, serverType)
|
||||
}
|
||||
|
||||
if ssl.ClientAuth && ssl.ClientCAFile == "" {
|
||||
return fmt.Errorf("stream '%s' %s: client auth enabled but CA file not specified",
|
||||
streamName, serverType)
|
||||
}
|
||||
|
||||
// Validate TLS versions
|
||||
validVersions := map[string]bool{"TLS1.0": true, "TLS1.1": true, "TLS1.2": true, "TLS1.3": true}
|
||||
if ssl.MinVersion != "" && !validVersions[ssl.MinVersion] {
|
||||
return fmt.Errorf("stream '%s' %s: invalid min TLS version: %s",
|
||||
streamName, serverType, ssl.MinVersion)
|
||||
}
|
||||
if ssl.MaxVersion != "" && !validVersions[ssl.MaxVersion] {
|
||||
return fmt.Errorf("stream '%s' %s: invalid max TLS version: %s",
|
||||
streamName, serverType, ssl.MaxVersion)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAuth(streamName string, auth *AuthConfig) error {
|
||||
if auth == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
validTypes := map[string]bool{"none": true, "basic": true, "bearer": true, "mtls": true}
|
||||
if !validTypes[auth.Type] {
|
||||
return fmt.Errorf("stream '%s': invalid auth type: %s", streamName, auth.Type)
|
||||
}
|
||||
|
||||
if auth.Type == "basic" && auth.BasicAuth == nil {
|
||||
return fmt.Errorf("stream '%s': basic auth type specified but config missing", streamName)
|
||||
}
|
||||
|
||||
if auth.Type == "bearer" && auth.BearerAuth == nil {
|
||||
return fmt.Errorf("stream '%s': bearer auth type specified but config missing", streamName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
107
src/internal/logstream/httprouter.go
Normal file
107
src/internal/logstream/httprouter.go
Normal file
@ -0,0 +1,107 @@
|
||||
// FILE: src/internal/logstream/httprouter.go
|
||||
package logstream
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/valyala/fasthttp"
|
||||
)
|
||||
|
||||
type HTTPRouter struct {
|
||||
service *Service
|
||||
servers map[int]*routerServer // port -> server
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewHTTPRouter(service *Service) *HTTPRouter {
|
||||
return &HTTPRouter{
|
||||
service: service,
|
||||
servers: make(map[int]*routerServer),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *HTTPRouter) RegisterStream(stream *LogStream) error {
|
||||
if stream.HTTPServer == nil || stream.Config.HTTPServer == nil {
|
||||
return nil // No HTTP server configured
|
||||
}
|
||||
|
||||
port := stream.Config.HTTPServer.Port
|
||||
|
||||
r.mu.Lock()
|
||||
rs, exists := r.servers[port]
|
||||
if !exists {
|
||||
// Create new server for this port
|
||||
rs = &routerServer{
|
||||
port: port,
|
||||
routes: make(map[string]*LogStream),
|
||||
}
|
||||
rs.server = &fasthttp.Server{
|
||||
Handler: rs.requestHandler,
|
||||
DisableKeepalive: false,
|
||||
StreamRequestBody: true,
|
||||
}
|
||||
r.servers[port] = rs
|
||||
|
||||
// Start server in background
|
||||
go func() {
|
||||
addr := fmt.Sprintf(":%d", port)
|
||||
if err := rs.server.ListenAndServe(addr); err != nil {
|
||||
// Log error but don't crash
|
||||
fmt.Printf("Router server on port %d failed: %v\n", port, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
// Register routes for this stream
|
||||
rs.routeMu.Lock()
|
||||
defer rs.routeMu.Unlock()
|
||||
|
||||
// Use stream name as path prefix
|
||||
pathPrefix := "/" + stream.Name
|
||||
|
||||
// Check for conflicts
|
||||
for existingPath, existingStream := range rs.routes {
|
||||
if strings.HasPrefix(pathPrefix, existingPath) || strings.HasPrefix(existingPath, pathPrefix) {
|
||||
return fmt.Errorf("path conflict: '%s' conflicts with existing stream '%s' at '%s'",
|
||||
pathPrefix, existingStream.Name, existingPath)
|
||||
}
|
||||
}
|
||||
|
||||
rs.routes[pathPrefix] = stream
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *HTTPRouter) UnregisterStream(streamName string) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, rs := range r.servers {
|
||||
rs.routeMu.Lock()
|
||||
for path, stream := range rs.routes {
|
||||
if stream.Name == streamName {
|
||||
delete(rs.routes, path)
|
||||
}
|
||||
}
|
||||
rs.routeMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *HTTPRouter) Shutdown() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for port, rs := range r.servers {
|
||||
wg.Add(1)
|
||||
go func(p int, s *routerServer) {
|
||||
defer wg.Done()
|
||||
if err := s.server.Shutdown(); err != nil {
|
||||
fmt.Printf("Error shutting down router server on port %d: %v\n", p, err)
|
||||
}
|
||||
}(port, rs)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
124
src/internal/logstream/logstream.go
Normal file
124
src/internal/logstream/logstream.go
Normal file
@ -0,0 +1,124 @@
|
||||
// FILE: src/internal/logstream/logstream.go
|
||||
package logstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"logwisp/src/internal/config"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (ls *LogStream) Shutdown() {
|
||||
// Stop servers first
|
||||
var wg sync.WaitGroup
|
||||
|
||||
if ls.TCPServer != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ls.TCPServer.Stop()
|
||||
}()
|
||||
}
|
||||
|
||||
if ls.HTTPServer != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
ls.HTTPServer.Stop()
|
||||
}()
|
||||
}
|
||||
|
||||
// Cancel context
|
||||
ls.cancel()
|
||||
|
||||
// Wait for servers
|
||||
wg.Wait()
|
||||
|
||||
// Stop monitor
|
||||
ls.Monitor.Stop()
|
||||
}
|
||||
|
||||
func (ls *LogStream) GetStats() map[string]interface{} {
|
||||
monStats := ls.Monitor.GetStats()
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"name": ls.Name,
|
||||
"uptime_seconds": int(time.Since(ls.Stats.StartTime).Seconds()),
|
||||
"monitor": monStats,
|
||||
}
|
||||
|
||||
if ls.TCPServer != nil {
|
||||
currentConnections := ls.TCPServer.GetActiveConnections()
|
||||
|
||||
stats["tcp"] = map[string]interface{}{
|
||||
"enabled": true,
|
||||
"port": ls.Config.TCPServer.Port,
|
||||
"connections": currentConnections, // Use current value
|
||||
}
|
||||
}
|
||||
|
||||
if ls.HTTPServer != nil {
|
||||
currentConnections := ls.HTTPServer.GetActiveConnections()
|
||||
|
||||
stats["http"] = map[string]interface{}{
|
||||
"enabled": true,
|
||||
"port": ls.Config.HTTPServer.Port,
|
||||
"connections": currentConnections, // Use current value
|
||||
"stream_path": ls.Config.HTTPServer.StreamPath,
|
||||
"status_path": ls.Config.HTTPServer.StatusPath,
|
||||
}
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
func (ls *LogStream) UpdateTargets(targets []config.MonitorTarget) error {
|
||||
// Clear existing targets
|
||||
for _, watcher := range ls.Monitor.GetActiveWatchers() {
|
||||
ls.Monitor.RemoveTarget(watcher.Path)
|
||||
}
|
||||
|
||||
// Add new targets
|
||||
for _, target := range targets {
|
||||
if err := ls.Monitor.AddTarget(target.Path, target.Pattern, target.IsFile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ls *LogStream) startStatsUpdater(ctx context.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
// Update cached values
|
||||
if ls.TCPServer != nil {
|
||||
oldTCP := ls.Stats.TCPConnections
|
||||
ls.Stats.TCPConnections = ls.TCPServer.GetActiveConnections()
|
||||
if oldTCP != ls.Stats.TCPConnections {
|
||||
// This debug should now show changes
|
||||
fmt.Printf("[STATS DEBUG] %s TCP: %d -> %d\n",
|
||||
ls.Name, oldTCP, ls.Stats.TCPConnections)
|
||||
}
|
||||
}
|
||||
if ls.HTTPServer != nil {
|
||||
oldHTTP := ls.Stats.HTTPConnections
|
||||
ls.Stats.HTTPConnections = ls.HTTPServer.GetActiveConnections()
|
||||
if oldHTTP != ls.Stats.HTTPConnections {
|
||||
// This debug should now show changes
|
||||
fmt.Printf("[STATS DEBUG] %s HTTP: %d -> %d\n",
|
||||
ls.Name, oldHTTP, ls.Stats.HTTPConnections)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
118
src/internal/logstream/routerserver.go
Normal file
118
src/internal/logstream/routerserver.go
Normal file
@ -0,0 +1,118 @@
|
||||
// FILE: src/internal/config/routerserver.go
|
||||
package logstream
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/valyala/fasthttp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type routerServer struct {
|
||||
port int
|
||||
server *fasthttp.Server
|
||||
routes map[string]*LogStream // path prefix -> stream
|
||||
routeMu sync.RWMutex
|
||||
}
|
||||
|
||||
func (rs *routerServer) requestHandler(ctx *fasthttp.RequestCtx) {
|
||||
path := string(ctx.Path())
|
||||
|
||||
// Special case: global status at /status
|
||||
if path == "/status" {
|
||||
rs.handleGlobalStatus(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
// Find matching stream
|
||||
rs.routeMu.RLock()
|
||||
var matchedStream *LogStream
|
||||
var matchedPrefix string
|
||||
var remainingPath string
|
||||
|
||||
for prefix, stream := range rs.routes {
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
// Use longest prefix match
|
||||
if len(prefix) > len(matchedPrefix) {
|
||||
matchedPrefix = prefix
|
||||
matchedStream = stream
|
||||
remainingPath = strings.TrimPrefix(path, prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
rs.routeMu.RUnlock()
|
||||
|
||||
if matchedStream == nil {
|
||||
rs.handleNotFound(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
// Route to stream's handler
|
||||
if matchedStream.HTTPServer != nil {
|
||||
// Rewrite path to remove stream prefix
|
||||
ctx.URI().SetPath(remainingPath)
|
||||
matchedStream.HTTPServer.RouteRequest(ctx)
|
||||
} else {
|
||||
ctx.SetStatusCode(fasthttp.StatusServiceUnavailable)
|
||||
ctx.SetContentType("application/json")
|
||||
json.NewEncoder(ctx).Encode(map[string]string{
|
||||
"error": "Stream HTTP server not available",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (rs *routerServer) handleGlobalStatus(ctx *fasthttp.RequestCtx) {
|
||||
ctx.SetContentType("application/json")
|
||||
|
||||
rs.routeMu.RLock()
|
||||
streams := make(map[string]interface{})
|
||||
for prefix, stream := range rs.routes {
|
||||
streams[stream.Name] = map[string]interface{}{
|
||||
"path_prefix": prefix,
|
||||
"config": map[string]interface{}{
|
||||
"stream_path": stream.Config.HTTPServer.StreamPath,
|
||||
"status_path": stream.Config.HTTPServer.StatusPath,
|
||||
},
|
||||
"stats": stream.GetStats(),
|
||||
}
|
||||
}
|
||||
rs.routeMu.RUnlock()
|
||||
|
||||
status := map[string]interface{}{
|
||||
"service": "LogWisp Router",
|
||||
"port": rs.port,
|
||||
"streams": streams,
|
||||
"total_streams": len(streams),
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(status)
|
||||
ctx.SetBody(data)
|
||||
}
|
||||
|
||||
func (rs *routerServer) handleNotFound(ctx *fasthttp.RequestCtx) {
|
||||
ctx.SetStatusCode(fasthttp.StatusNotFound)
|
||||
ctx.SetContentType("application/json")
|
||||
|
||||
rs.routeMu.RLock()
|
||||
availableRoutes := make([]string, 0, len(rs.routes)*2+1)
|
||||
availableRoutes = append(availableRoutes, "/status (global status)")
|
||||
|
||||
for prefix, stream := range rs.routes {
|
||||
if stream.Config.HTTPServer != nil {
|
||||
availableRoutes = append(availableRoutes,
|
||||
fmt.Sprintf("%s%s (stream: %s)", prefix, stream.Config.HTTPServer.StreamPath, stream.Name),
|
||||
fmt.Sprintf("%s%s (status: %s)", prefix, stream.Config.HTTPServer.StatusPath, stream.Name),
|
||||
)
|
||||
}
|
||||
}
|
||||
rs.routeMu.RUnlock()
|
||||
|
||||
response := map[string]interface{}{
|
||||
"error": "Not Found",
|
||||
"available_routes": availableRoutes,
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(response)
|
||||
ctx.SetBody(data)
|
||||
}
|
||||
235
src/internal/logstream/service.go
Normal file
235
src/internal/logstream/service.go
Normal file
@ -0,0 +1,235 @@
|
||||
// FILE: src/internal/logstream/service.go
|
||||
package logstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"logwisp/src/internal/config"
|
||||
"logwisp/src/internal/monitor"
|
||||
"logwisp/src/internal/stream"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
streams map[string]*LogStream
|
||||
mu sync.RWMutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
type LogStream struct {
|
||||
Name string
|
||||
Config config.StreamConfig
|
||||
Monitor monitor.Monitor
|
||||
TCPServer *stream.TCPStreamer
|
||||
HTTPServer *stream.HTTPStreamer
|
||||
Stats *StreamStats
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
type StreamStats struct {
|
||||
StartTime time.Time
|
||||
MonitorStats monitor.Stats
|
||||
TCPConnections int32
|
||||
HTTPConnections int32
|
||||
TotalBytesServed uint64
|
||||
TotalEntriesServed uint64
|
||||
}
|
||||
|
||||
func New(ctx context.Context) *Service {
|
||||
serviceCtx, cancel := context.WithCancel(ctx)
|
||||
return &Service{
|
||||
streams: make(map[string]*LogStream),
|
||||
ctx: serviceCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) CreateStream(cfg config.StreamConfig) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, exists := s.streams[cfg.Name]; exists {
|
||||
return fmt.Errorf("stream '%s' already exists", cfg.Name)
|
||||
}
|
||||
|
||||
// Create stream context
|
||||
streamCtx, streamCancel := context.WithCancel(s.ctx)
|
||||
|
||||
// Create monitor
|
||||
mon := monitor.New()
|
||||
mon.SetCheckInterval(time.Duration(cfg.GetCheckInterval(100)) * time.Millisecond)
|
||||
|
||||
// Add targets
|
||||
for _, target := range cfg.GetTargets(nil) {
|
||||
if err := mon.AddTarget(target.Path, target.Pattern, target.IsFile); err != nil {
|
||||
streamCancel()
|
||||
return fmt.Errorf("failed to add target %s: %w", target.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Start monitor
|
||||
if err := mon.Start(streamCtx); err != nil {
|
||||
streamCancel()
|
||||
return fmt.Errorf("failed to start monitor: %w", err)
|
||||
}
|
||||
|
||||
// Create log stream
|
||||
ls := &LogStream{
|
||||
Name: cfg.Name,
|
||||
Config: cfg,
|
||||
Monitor: mon,
|
||||
Stats: &StreamStats{
|
||||
StartTime: time.Now(),
|
||||
},
|
||||
ctx: streamCtx,
|
||||
cancel: streamCancel,
|
||||
}
|
||||
|
||||
// Start TCP server if configured
|
||||
if cfg.TCPServer != nil && cfg.TCPServer.Enabled {
|
||||
tcpChan := mon.Subscribe()
|
||||
ls.TCPServer = stream.NewTCPStreamer(tcpChan, *cfg.TCPServer)
|
||||
|
||||
if err := s.startTCPServer(ls); err != nil {
|
||||
ls.Shutdown()
|
||||
return fmt.Errorf("TCP server failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Start HTTP server if configured
|
||||
if cfg.HTTPServer != nil && cfg.HTTPServer.Enabled {
|
||||
httpChan := mon.Subscribe()
|
||||
ls.HTTPServer = stream.NewHTTPStreamer(httpChan, *cfg.HTTPServer)
|
||||
|
||||
if err := s.startHTTPServer(ls); err != nil {
|
||||
ls.Shutdown()
|
||||
return fmt.Errorf("HTTP server failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
ls.startStatsUpdater(streamCtx)
|
||||
|
||||
s.streams[cfg.Name] = ls
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetStream(name string) (*LogStream, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
stream, exists := s.streams[name]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("stream '%s' not found", name)
|
||||
}
|
||||
return stream, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListStreams() []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(s.streams))
|
||||
for name := range s.streams {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (s *Service) RemoveStream(name string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
stream, exists := s.streams[name]
|
||||
if !exists {
|
||||
return fmt.Errorf("stream '%s' not found", name)
|
||||
}
|
||||
|
||||
stream.Shutdown()
|
||||
delete(s.streams, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Shutdown() {
|
||||
s.mu.Lock()
|
||||
streams := make([]*LogStream, 0, len(s.streams))
|
||||
for _, stream := range s.streams {
|
||||
streams = append(streams, stream)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Stop all streams concurrently
|
||||
var wg sync.WaitGroup
|
||||
for _, stream := range streams {
|
||||
wg.Add(1)
|
||||
go func(ls *LogStream) {
|
||||
defer wg.Done()
|
||||
ls.Shutdown()
|
||||
}(stream)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
s.cancel()
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func (s *Service) GetGlobalStats() map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"streams": make(map[string]interface{}),
|
||||
"total_streams": len(s.streams),
|
||||
}
|
||||
|
||||
for name, stream := range s.streams {
|
||||
stats["streams"].(map[string]interface{})[name] = stream.GetStats()
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
func (s *Service) startTCPServer(ls *LogStream) error {
|
||||
errChan := make(chan error, 1)
|
||||
s.wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
if err := ls.TCPServer.Start(); err != nil {
|
||||
errChan <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Check startup
|
||||
select {
|
||||
case err := <-errChan:
|
||||
return err
|
||||
case <-time.After(time.Second):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) startHTTPServer(ls *LogStream) error {
|
||||
errChan := make(chan error, 1)
|
||||
s.wg.Add(1)
|
||||
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
if err := ls.HTTPServer.Start(); err != nil {
|
||||
errChan <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Check startup
|
||||
select {
|
||||
case err := <-errChan:
|
||||
return err
|
||||
case <-time.After(time.Second):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
// FILE: src/internal/monitor/file_watcher.go
|
||||
package monitor
|
||||
|
||||
import (
|
||||
@ -11,32 +12,38 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fileWatcher struct {
|
||||
path string
|
||||
callback func(LogEntry)
|
||||
position int64
|
||||
size int64
|
||||
inode uint64
|
||||
modTime time.Time
|
||||
mu sync.Mutex
|
||||
stopped bool
|
||||
rotationSeq int
|
||||
path string
|
||||
callback func(LogEntry)
|
||||
position int64
|
||||
size int64
|
||||
inode uint64
|
||||
modTime time.Time
|
||||
mu sync.Mutex
|
||||
stopped bool
|
||||
rotationSeq int
|
||||
entriesRead atomic.Uint64
|
||||
lastReadTime atomic.Value // time.Time
|
||||
}
|
||||
|
||||
func newFileWatcher(path string, callback func(LogEntry)) *fileWatcher {
|
||||
return &fileWatcher{
|
||||
w := &fileWatcher{
|
||||
path: path,
|
||||
callback: callback,
|
||||
position: -1,
|
||||
}
|
||||
w.lastReadTime.Store(time.Time{})
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *fileWatcher) watch(ctx context.Context) {
|
||||
func (w *fileWatcher) watch(ctx context.Context) error {
|
||||
if err := w.seekToEnd(); err != nil {
|
||||
return
|
||||
return fmt.Errorf("seekToEnd failed: %w", err)
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
@ -45,12 +52,15 @@ func (w *fileWatcher) watch(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
if w.isStopped() {
|
||||
return
|
||||
return fmt.Errorf("watcher stopped")
|
||||
}
|
||||
if err := w.checkFile(); err != nil {
|
||||
// Log error but continue watching
|
||||
fmt.Printf("[WARN] checkFile error for %s: %v\n", w.path, err)
|
||||
}
|
||||
w.checkFile()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -58,6 +68,17 @@ func (w *fileWatcher) watch(ctx context.Context) {
|
||||
func (w *fileWatcher) seekToEnd() error {
|
||||
file, err := os.Open(w.path)
|
||||
if err != nil {
|
||||
// For non-existent files, initialize position to 0
|
||||
// This allows watching files that don't exist yet
|
||||
if os.IsNotExist(err) {
|
||||
w.mu.Lock()
|
||||
w.position = 0
|
||||
w.size = 0
|
||||
w.modTime = time.Now()
|
||||
w.inode = 0
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
@ -67,16 +88,21 @@ func (w *fileWatcher) seekToEnd() error {
|
||||
return err
|
||||
}
|
||||
|
||||
pos, err := file.Seek(0, io.SeekEnd)
|
||||
if err != nil {
|
||||
return err
|
||||
w.mu.Lock()
|
||||
// Only seek to end if position was never set (-1)
|
||||
// This preserves position = 0 for new files while allowing
|
||||
// directory-discovered files to start reading from current position
|
||||
if w.position == -1 {
|
||||
pos, err := file.Seek(0, io.SeekEnd)
|
||||
if err != nil {
|
||||
w.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
w.position = pos
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
w.position = pos
|
||||
w.size = info.Size()
|
||||
w.modTime = info.ModTime()
|
||||
|
||||
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||
w.inode = stat.Ino
|
||||
}
|
||||
@ -88,6 +114,10 @@ func (w *fileWatcher) seekToEnd() error {
|
||||
func (w *fileWatcher) checkFile() error {
|
||||
file, err := os.Open(w.path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// File doesn't exist yet, keep watching
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
@ -112,36 +142,49 @@ func (w *fileWatcher) checkFile() error {
|
||||
currentInode = stat.Ino
|
||||
}
|
||||
|
||||
// Handle first time seeing a file that didn't exist before
|
||||
if oldInode == 0 && currentInode != 0 {
|
||||
// File just appeared, don't treat as rotation
|
||||
w.mu.Lock()
|
||||
w.inode = currentInode
|
||||
w.size = currentSize
|
||||
w.modTime = currentModTime
|
||||
// Keep position at 0 to read from beginning if this is a new file
|
||||
// or seek to end if we want to skip existing content
|
||||
if oldSize == 0 && w.position == 0 {
|
||||
// First time seeing this file, seek to end to skip existing content
|
||||
w.position = currentSize
|
||||
}
|
||||
w.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check for rotation
|
||||
rotated := false
|
||||
rotationReason := ""
|
||||
|
||||
if oldInode != 0 && currentInode != 0 && currentInode != oldInode {
|
||||
rotated = true
|
||||
rotationReason = "inode change"
|
||||
}
|
||||
|
||||
if !rotated && currentSize < oldSize {
|
||||
} else if currentSize < oldSize {
|
||||
rotated = true
|
||||
rotationReason = "size decrease"
|
||||
}
|
||||
|
||||
if !rotated && currentModTime.Before(oldModTime) && currentSize <= oldSize {
|
||||
} else if currentModTime.Before(oldModTime) && currentSize <= oldSize {
|
||||
rotated = true
|
||||
rotationReason = "modification time reset"
|
||||
}
|
||||
|
||||
if !rotated && oldPos > currentSize+1024 {
|
||||
} else if oldPos > currentSize+1024 {
|
||||
rotated = true
|
||||
rotationReason = "position beyond file size"
|
||||
}
|
||||
|
||||
newPos := oldPos
|
||||
startPos := oldPos
|
||||
if rotated {
|
||||
newPos = 0
|
||||
startPos = 0
|
||||
w.mu.Lock()
|
||||
w.rotationSeq++
|
||||
seq := w.rotationSeq
|
||||
w.inode = currentInode
|
||||
w.position = 0 // Reset position on rotation
|
||||
w.mu.Unlock()
|
||||
|
||||
w.callback(LogEntry{
|
||||
@ -152,32 +195,52 @@ func (w *fileWatcher) checkFile() error {
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := file.Seek(newPos, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
// Only read if there's new content
|
||||
if currentSize > startPos {
|
||||
if _, err := file.Seek(startPos, io.SeekStart); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entry := w.parseLine(line)
|
||||
w.callback(entry)
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
entry := w.parseLine(line)
|
||||
w.callback(entry)
|
||||
w.entriesRead.Add(1)
|
||||
w.lastReadTime.Store(time.Now())
|
||||
}
|
||||
|
||||
// Update position after successful read
|
||||
if currentPos, err := file.Seek(0, io.SeekCurrent); err == nil {
|
||||
w.mu.Lock()
|
||||
w.position = currentPos
|
||||
w.size = currentSize
|
||||
w.modTime = currentModTime
|
||||
if !rotated && currentInode != 0 {
|
||||
w.inode = currentInode
|
||||
}
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
if currentPos, err := file.Seek(0, io.SeekCurrent); err == nil {
|
||||
w.mu.Lock()
|
||||
w.position = currentPos
|
||||
w.size = currentSize
|
||||
w.modTime = currentModTime
|
||||
w.mu.Unlock()
|
||||
// Update metadata even if no new content
|
||||
w.mu.Lock()
|
||||
w.size = currentSize
|
||||
w.modTime = currentModTime
|
||||
if currentInode != 0 {
|
||||
w.inode = currentInode
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
return scanner.Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *fileWatcher) parseLine(line string) LogEntry {
|
||||
@ -244,6 +307,25 @@ func globToRegex(glob string) string {
|
||||
return "^" + regex + "$"
|
||||
}
|
||||
|
||||
func (w *fileWatcher) getInfo() WatcherInfo {
|
||||
w.mu.Lock()
|
||||
info := WatcherInfo{
|
||||
Path: w.path,
|
||||
Size: w.size,
|
||||
Position: w.position,
|
||||
ModTime: w.modTime,
|
||||
EntriesRead: w.entriesRead.Load(),
|
||||
Rotations: w.rotationSeq,
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
if lastRead, ok := w.lastReadTime.Load().(time.Time); ok {
|
||||
info.LastReadTime = lastRead
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
func (w *fileWatcher) close() {
|
||||
w.stop()
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -20,15 +21,48 @@ type LogEntry struct {
|
||||
Fields json.RawMessage `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
type Monitor struct {
|
||||
subscribers []chan LogEntry
|
||||
targets []target
|
||||
watchers map[string]*fileWatcher
|
||||
mu sync.RWMutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
checkInterval time.Duration
|
||||
type Monitor interface {
|
||||
Start(ctx context.Context) error
|
||||
Stop()
|
||||
Subscribe() chan LogEntry
|
||||
AddTarget(path, pattern string, isFile bool) error
|
||||
RemoveTarget(path string) error
|
||||
SetCheckInterval(interval time.Duration)
|
||||
GetStats() Stats
|
||||
GetActiveWatchers() []WatcherInfo
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
ActiveWatchers int
|
||||
TotalEntries uint64
|
||||
DroppedEntries uint64
|
||||
StartTime time.Time
|
||||
LastEntryTime time.Time
|
||||
}
|
||||
|
||||
type WatcherInfo struct {
|
||||
Path string
|
||||
Size int64
|
||||
Position int64
|
||||
ModTime time.Time
|
||||
EntriesRead uint64
|
||||
LastReadTime time.Time
|
||||
Rotations int
|
||||
}
|
||||
|
||||
type monitor struct {
|
||||
subscribers []chan LogEntry
|
||||
targets []target
|
||||
watchers map[string]*fileWatcher
|
||||
mu sync.RWMutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
checkInterval time.Duration
|
||||
totalEntries atomic.Uint64
|
||||
droppedEntries atomic.Uint64
|
||||
startTime time.Time
|
||||
lastEntryTime atomic.Value // time.Time
|
||||
}
|
||||
|
||||
type target struct {
|
||||
@ -38,14 +72,17 @@ type target struct {
|
||||
regex *regexp.Regexp
|
||||
}
|
||||
|
||||
func New() *Monitor {
|
||||
return &Monitor{
|
||||
func New() Monitor {
|
||||
m := &monitor{
|
||||
watchers: make(map[string]*fileWatcher),
|
||||
checkInterval: 100 * time.Millisecond,
|
||||
startTime: time.Now(),
|
||||
}
|
||||
m.lastEntryTime.Store(time.Time{})
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Monitor) Subscribe() chan LogEntry {
|
||||
func (m *monitor) Subscribe() chan LogEntry {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@ -54,26 +91,29 @@ func (m *Monitor) Subscribe() chan LogEntry {
|
||||
return ch
|
||||
}
|
||||
|
||||
func (m *Monitor) publish(entry LogEntry) {
|
||||
func (m *monitor) publish(entry LogEntry) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
m.totalEntries.Add(1)
|
||||
m.lastEntryTime.Store(entry.Time)
|
||||
|
||||
for _, ch := range m.subscribers {
|
||||
select {
|
||||
case ch <- entry:
|
||||
default:
|
||||
// Drop message if channel full
|
||||
m.droppedEntries.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) SetCheckInterval(interval time.Duration) {
|
||||
func (m *monitor) SetCheckInterval(interval time.Duration) {
|
||||
m.mu.Lock()
|
||||
m.checkInterval = interval
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Monitor) AddTarget(path, pattern string, isFile bool) error {
|
||||
func (m *monitor) AddTarget(path, pattern string, isFile bool) error {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid path %s: %w", path, err)
|
||||
@ -100,14 +140,41 @@ func (m *Monitor) AddTarget(path, pattern string, isFile bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Monitor) Start(ctx context.Context) error {
|
||||
func (m *monitor) RemoveTarget(path string) error {
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid path %s: %w", path, err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Remove from targets
|
||||
newTargets := make([]target, 0, len(m.targets))
|
||||
for _, t := range m.targets {
|
||||
if t.path != absPath {
|
||||
newTargets = append(newTargets, t)
|
||||
}
|
||||
}
|
||||
m.targets = newTargets
|
||||
|
||||
// Stop any watchers for this path
|
||||
if w, exists := m.watchers[absPath]; exists {
|
||||
w.stop()
|
||||
delete(m.watchers, absPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *monitor) Start(ctx context.Context) error {
|
||||
m.ctx, m.cancel = context.WithCancel(ctx)
|
||||
m.wg.Add(1)
|
||||
go m.monitorLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Monitor) Stop() {
|
||||
func (m *monitor) Stop() {
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
@ -123,7 +190,34 @@ func (m *Monitor) Stop() {
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Monitor) monitorLoop() {
|
||||
func (m *monitor) GetStats() Stats {
|
||||
lastEntry, _ := m.lastEntryTime.Load().(time.Time)
|
||||
|
||||
m.mu.RLock()
|
||||
watcherCount := len(m.watchers)
|
||||
m.mu.RUnlock()
|
||||
|
||||
return Stats{
|
||||
ActiveWatchers: watcherCount,
|
||||
TotalEntries: m.totalEntries.Load(),
|
||||
DroppedEntries: m.droppedEntries.Load(),
|
||||
StartTime: m.startTime,
|
||||
LastEntryTime: lastEntry,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *monitor) GetActiveWatchers() []WatcherInfo {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
info := make([]WatcherInfo, 0, len(m.watchers))
|
||||
for _, w := range m.watchers {
|
||||
info = append(info, w.getInfo())
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func (m *monitor) monitorLoop() {
|
||||
defer m.wg.Done()
|
||||
|
||||
m.checkTargets()
|
||||
@ -155,7 +249,7 @@ func (m *Monitor) monitorLoop() {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Monitor) checkTargets() {
|
||||
func (m *monitor) checkTargets() {
|
||||
m.mu.RLock()
|
||||
targets := make([]target, len(m.targets))
|
||||
copy(targets, m.targets)
|
||||
@ -165,10 +259,13 @@ func (m *Monitor) checkTargets() {
|
||||
if t.isFile {
|
||||
m.ensureWatcher(t.path)
|
||||
} else {
|
||||
// Directory scanning for pattern matching
|
||||
files, err := m.scanDirectory(t.path, t.regex)
|
||||
if err != nil {
|
||||
fmt.Printf("[DEBUG] Error scanning directory %s: %v\n", t.path, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
m.ensureWatcher(file)
|
||||
}
|
||||
@ -178,7 +275,7 @@ func (m *Monitor) checkTargets() {
|
||||
m.cleanupWatchers()
|
||||
}
|
||||
|
||||
func (m *Monitor) scanDirectory(dir string, pattern *regexp.Regexp) ([]string, error) {
|
||||
func (m *monitor) scanDirectory(dir string, pattern *regexp.Regexp) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -199,7 +296,7 @@ func (m *Monitor) scanDirectory(dir string, pattern *regexp.Regexp) ([]string, e
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (m *Monitor) ensureWatcher(path string) {
|
||||
func (m *monitor) ensureWatcher(path string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@ -207,17 +304,17 @@ func (m *Monitor) ensureWatcher(path string) {
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
|
||||
w := newFileWatcher(path, m.publish)
|
||||
m.watchers[path] = w
|
||||
|
||||
fmt.Printf("[DEBUG] Created watcher for: %s\n", path)
|
||||
|
||||
m.wg.Add(1)
|
||||
go func() {
|
||||
defer m.wg.Done()
|
||||
w.watch(m.ctx)
|
||||
if err := w.watch(m.ctx); err != nil {
|
||||
fmt.Printf("[ERROR] Watcher for %s failed: %v\n", path, err)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
delete(m.watchers, path)
|
||||
@ -225,7 +322,7 @@ func (m *Monitor) ensureWatcher(path string) {
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Monitor) cleanupWatchers() {
|
||||
func (m *monitor) cleanupWatchers() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// FILE: src/internal/stream/http.go
|
||||
// FILE: src/internal/stream/httpstreamer.go
|
||||
package stream
|
||||
|
||||
import (
|
||||
@ -25,23 +25,53 @@ type HTTPStreamer struct {
|
||||
startTime time.Time
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
|
||||
// Path configuration
|
||||
streamPath string
|
||||
statusPath string
|
||||
|
||||
// For router integration
|
||||
standalone bool
|
||||
}
|
||||
|
||||
func NewHTTPStreamer(logChan chan monitor.LogEntry, cfg config.HTTPConfig) *HTTPStreamer {
|
||||
// Set default paths if not configured
|
||||
streamPath := cfg.StreamPath
|
||||
if streamPath == "" {
|
||||
streamPath = "/stream"
|
||||
}
|
||||
statusPath := cfg.StatusPath
|
||||
if statusPath == "" {
|
||||
statusPath = "/status"
|
||||
}
|
||||
|
||||
return &HTTPStreamer{
|
||||
logChan: logChan,
|
||||
config: cfg,
|
||||
startTime: time.Now(),
|
||||
done: make(chan struct{}),
|
||||
logChan: logChan,
|
||||
config: cfg,
|
||||
startTime: time.Now(),
|
||||
done: make(chan struct{}),
|
||||
streamPath: streamPath,
|
||||
statusPath: statusPath,
|
||||
standalone: true, // Default to standalone mode
|
||||
}
|
||||
}
|
||||
|
||||
// SetRouterMode configures the streamer for use with a router
|
||||
func (h *HTTPStreamer) SetRouterMode() {
|
||||
h.standalone = false
|
||||
}
|
||||
|
||||
func (h *HTTPStreamer) Start() error {
|
||||
if !h.standalone {
|
||||
// In router mode, don't start our own server
|
||||
return nil
|
||||
}
|
||||
|
||||
h.server = &fasthttp.Server{
|
||||
Handler: h.requestHandler,
|
||||
DisableKeepalive: false,
|
||||
StreamRequestBody: true,
|
||||
Logger: nil, // Suppress fasthttp logs
|
||||
Logger: nil,
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf(":%d", h.config.Port)
|
||||
@ -69,13 +99,10 @@ func (h *HTTPStreamer) Stop() {
|
||||
// Signal all client handlers to stop
|
||||
close(h.done)
|
||||
|
||||
// Shutdown HTTP server
|
||||
if h.server != nil {
|
||||
// Create context with timeout for server shutdown
|
||||
// Shutdown HTTP server if in standalone mode
|
||||
if h.standalone && h.server != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Use ShutdownWithContext for graceful shutdown
|
||||
h.server.ShutdownWithContext(ctx)
|
||||
}
|
||||
|
||||
@ -83,16 +110,26 @@ func (h *HTTPStreamer) Stop() {
|
||||
h.wg.Wait()
|
||||
}
|
||||
|
||||
func (h *HTTPStreamer) RouteRequest(ctx *fasthttp.RequestCtx) {
|
||||
h.requestHandler(ctx)
|
||||
}
|
||||
|
||||
func (h *HTTPStreamer) requestHandler(ctx *fasthttp.RequestCtx) {
|
||||
path := string(ctx.Path())
|
||||
|
||||
switch path {
|
||||
case "/stream":
|
||||
case h.streamPath:
|
||||
h.handleStream(ctx)
|
||||
case "/status":
|
||||
case h.statusPath:
|
||||
h.handleStatus(ctx)
|
||||
default:
|
||||
ctx.SetStatusCode(fasthttp.StatusNotFound)
|
||||
ctx.SetContentType("application/json")
|
||||
json.NewEncoder(ctx).Encode(map[string]interface{}{
|
||||
"error": "Not Found",
|
||||
"message": fmt.Sprintf("Available endpoints: %s (SSE stream), %s (status)",
|
||||
h.streamPath, h.statusPath),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -104,13 +141,6 @@ func (h *HTTPStreamer) handleStream(ctx *fasthttp.RequestCtx) {
|
||||
ctx.Response.Header.Set("Access-Control-Allow-Origin", "*")
|
||||
ctx.Response.Header.Set("X-Accel-Buffering", "no")
|
||||
|
||||
h.activeClients.Add(1)
|
||||
h.wg.Add(1) // Track this client handler
|
||||
defer func() {
|
||||
h.activeClients.Add(-1)
|
||||
h.wg.Done() // Mark handler as done
|
||||
}()
|
||||
|
||||
// Create subscription for this client
|
||||
clientChan := make(chan monitor.LogEntry, h.config.BufferSize)
|
||||
clientDone := make(chan struct{})
|
||||
@ -128,14 +158,14 @@ func (h *HTTPStreamer) handleStream(ctx *fasthttp.RequestCtx) {
|
||||
case clientChan <- entry:
|
||||
case <-clientDone:
|
||||
return
|
||||
case <-h.done: // Check for server shutdown
|
||||
case <-h.done:
|
||||
return
|
||||
default:
|
||||
// Drop if client buffer full
|
||||
}
|
||||
case <-clientDone:
|
||||
return
|
||||
case <-h.done: // Check for server shutdown
|
||||
case <-h.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -143,11 +173,28 @@ func (h *HTTPStreamer) handleStream(ctx *fasthttp.RequestCtx) {
|
||||
|
||||
// Define the stream writer function
|
||||
streamFunc := func(w *bufio.Writer) {
|
||||
defer close(clientDone)
|
||||
newCount := h.activeClients.Add(1)
|
||||
fmt.Printf("[HTTP DEBUG] Client connected on port %d. Count now: %d\n",
|
||||
h.config.Port, newCount)
|
||||
|
||||
h.wg.Add(1)
|
||||
defer func() {
|
||||
newCount := h.activeClients.Add(-1)
|
||||
fmt.Printf("[HTTP DEBUG] Client disconnected on port %d. Count now: %d\n",
|
||||
h.config.Port, newCount)
|
||||
h.wg.Done()
|
||||
}()
|
||||
|
||||
// Send initial connected event
|
||||
clientID := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
fmt.Fprintf(w, "event: connected\ndata: {\"client_id\":\"%s\"}\n\n", clientID)
|
||||
connectionInfo := map[string]interface{}{
|
||||
"client_id": clientID,
|
||||
"stream_path": h.streamPath,
|
||||
"status_path": h.statusPath,
|
||||
"buffer_size": h.config.BufferSize,
|
||||
}
|
||||
data, _ := json.Marshal(connectionInfo)
|
||||
fmt.Fprintf(w, "event: connected\ndata: %s\n\n", data)
|
||||
w.Flush()
|
||||
|
||||
var ticker *time.Ticker
|
||||
@ -184,7 +231,7 @@ func (h *HTTPStreamer) handleStream(ctx *fasthttp.RequestCtx) {
|
||||
}
|
||||
}
|
||||
|
||||
case <-h.done: // ADDED: Check for server shutdown
|
||||
case <-h.done:
|
||||
// Send final disconnect event
|
||||
fmt.Fprintf(w, "event: disconnect\ndata: {\"reason\":\"server_shutdown\"}\n\n")
|
||||
w.Flush()
|
||||
@ -240,13 +287,48 @@ func (h *HTTPStreamer) handleStatus(ctx *fasthttp.RequestCtx) {
|
||||
status := map[string]interface{}{
|
||||
"service": "LogWisp",
|
||||
"version": "3.0.0",
|
||||
"http_server": map[string]interface{}{
|
||||
"server": map[string]interface{}{
|
||||
"type": "http",
|
||||
"port": h.config.Port,
|
||||
"active_clients": h.activeClients.Load(),
|
||||
"buffer_size": h.config.BufferSize,
|
||||
"uptime_seconds": int(time.Since(h.startTime).Seconds()),
|
||||
"mode": map[string]bool{"standalone": h.standalone, "router": !h.standalone},
|
||||
},
|
||||
"endpoints": map[string]string{
|
||||
"stream": h.streamPath,
|
||||
"status": h.statusPath,
|
||||
},
|
||||
"features": map[string]interface{}{
|
||||
"heartbeat": map[string]interface{}{
|
||||
"enabled": h.config.Heartbeat.Enabled,
|
||||
"interval": h.config.Heartbeat.IntervalSeconds,
|
||||
"format": h.config.Heartbeat.Format,
|
||||
},
|
||||
"ssl": map[string]bool{
|
||||
"enabled": h.config.SSL != nil && h.config.SSL.Enabled,
|
||||
},
|
||||
"rate_limit": map[string]bool{
|
||||
"enabled": h.config.RateLimit != nil && h.config.RateLimit.Enabled,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(status)
|
||||
ctx.SetBody(data)
|
||||
}
|
||||
|
||||
// GetActiveConnections returns the current number of active clients
|
||||
func (h *HTTPStreamer) GetActiveConnections() int32 {
|
||||
return h.activeClients.Load()
|
||||
}
|
||||
|
||||
// GetStreamPath returns the configured stream endpoint path
|
||||
func (h *HTTPStreamer) GetStreamPath() string {
|
||||
return h.streamPath
|
||||
}
|
||||
|
||||
// GetStatusPath returns the configured status endpoint path
|
||||
func (h *HTTPStreamer) GetStatusPath() string {
|
||||
return h.statusPath
|
||||
}
|
||||
52
src/internal/stream/tcpserver.go
Normal file
52
src/internal/stream/tcpserver.go
Normal file
@ -0,0 +1,52 @@
|
||||
// FILE: src/internal/monitor/tcpserver.go
|
||||
package stream
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/panjf2000/gnet/v2"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type tcpServer struct {
|
||||
gnet.BuiltinEventEngine
|
||||
streamer *TCPStreamer
|
||||
connections sync.Map
|
||||
}
|
||||
|
||||
func (s *tcpServer) OnBoot(eng gnet.Engine) gnet.Action {
|
||||
// Store engine reference for shutdown
|
||||
s.streamer.engine = &eng
|
||||
return gnet.None
|
||||
}
|
||||
|
||||
func (s *tcpServer) OnOpen(c gnet.Conn) (out []byte, action gnet.Action) {
|
||||
s.connections.Store(c, struct{}{})
|
||||
|
||||
oldCount := s.streamer.activeConns.Load()
|
||||
newCount := s.streamer.activeConns.Add(1)
|
||||
fmt.Printf("[TCP ATOMIC] OnOpen: %d -> %d (expected: %d)\n", oldCount, newCount, oldCount+1)
|
||||
|
||||
fmt.Printf("[TCP DEBUG] Connection opened. Count now: %d\n", newCount)
|
||||
return nil, gnet.None
|
||||
}
|
||||
|
||||
func (s *tcpServer) OnClose(c gnet.Conn, err error) gnet.Action {
|
||||
s.connections.Delete(c)
|
||||
|
||||
oldCount := s.streamer.activeConns.Load()
|
||||
newCount := s.streamer.activeConns.Add(-1)
|
||||
fmt.Printf("[TCP ATOMIC] OnClose: %d -> %d (expected: %d)\n", oldCount, newCount, oldCount-1)
|
||||
|
||||
fmt.Printf("[TCP DEBUG] Connection closed. Count now: %d (err: %v)\n", newCount, err)
|
||||
return gnet.None
|
||||
}
|
||||
|
||||
func (s *tcpServer) OnTraffic(c gnet.Conn) gnet.Action {
|
||||
// We don't expect input from clients, just discard
|
||||
c.Discard(-1)
|
||||
return gnet.None
|
||||
}
|
||||
|
||||
func (t *TCPStreamer) GetActiveConnections() int32 {
|
||||
return t.activeConns.Load()
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
// FILE: src/internal/stream/tcp.go
|
||||
// FILE: src/internal/stream/tcpstreamer.go
|
||||
package stream
|
||||
|
||||
import (
|
||||
@ -18,25 +18,19 @@ type TCPStreamer struct {
|
||||
logChan chan monitor.LogEntry
|
||||
config config.TCPConfig
|
||||
server *tcpServer
|
||||
done chan struct{}
|
||||
activeConns atomic.Int32
|
||||
startTime time.Time
|
||||
done chan struct{}
|
||||
engine *gnet.Engine
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
type tcpServer struct {
|
||||
gnet.BuiltinEventEngine
|
||||
streamer *TCPStreamer
|
||||
connections sync.Map
|
||||
}
|
||||
|
||||
func NewTCPStreamer(logChan chan monitor.LogEntry, cfg config.TCPConfig) *TCPStreamer {
|
||||
return &TCPStreamer{
|
||||
logChan: logChan,
|
||||
config: cfg,
|
||||
startTime: time.Now(),
|
||||
done: make(chan struct{}),
|
||||
startTime: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,14 +44,14 @@ func (t *TCPStreamer) Start() error {
|
||||
t.broadcastLoop()
|
||||
}()
|
||||
|
||||
// Configure gnet with no-op logger
|
||||
// Configure gnet
|
||||
addr := fmt.Sprintf("tcp://:%d", t.config.Port)
|
||||
|
||||
// Run gnet in separate goroutine to avoid blocking
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
err := gnet.Run(t.server, addr,
|
||||
gnet.WithLogger(noopLogger{}), // No-op logger: discard everything
|
||||
gnet.WithLogger(noopLogger{}),
|
||||
gnet.WithMulticore(true),
|
||||
gnet.WithReusePort(true),
|
||||
)
|
||||
@ -83,7 +77,6 @@ func (t *TCPStreamer) Stop() {
|
||||
|
||||
// Stop gnet engine if running
|
||||
if t.engine != nil {
|
||||
// Use Stop() method to gracefully shutdown gnet
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
t.engine.Stop(ctx)
|
||||
@ -107,7 +100,7 @@ func (t *TCPStreamer) broadcastLoop() {
|
||||
select {
|
||||
case entry, ok := <-t.logChan:
|
||||
if !ok {
|
||||
return // Channel closed
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
@ -155,27 +148,4 @@ func (t *TCPStreamer) formatHeartbeat() []byte {
|
||||
|
||||
jsonData, _ := json.Marshal(data)
|
||||
return append(jsonData, '\n')
|
||||
}
|
||||
|
||||
func (s *tcpServer) OnBoot(eng gnet.Engine) gnet.Action {
|
||||
s.streamer.engine = &eng
|
||||
return gnet.None
|
||||
}
|
||||
|
||||
func (s *tcpServer) OnOpen(c gnet.Conn) (out []byte, action gnet.Action) {
|
||||
s.connections.Store(c, struct{}{})
|
||||
s.streamer.activeConns.Add(1)
|
||||
return nil, gnet.None
|
||||
}
|
||||
|
||||
func (s *tcpServer) OnClose(c gnet.Conn, err error) gnet.Action {
|
||||
s.connections.Delete(c)
|
||||
s.streamer.activeConns.Add(-1)
|
||||
return gnet.None
|
||||
}
|
||||
|
||||
func (s *tcpServer) OnTraffic(c gnet.Conn) gnet.Action {
|
||||
// We don't expect input from clients, just discard
|
||||
c.Discard(-1)
|
||||
return gnet.None
|
||||
}
|
||||
Reference in New Issue
Block a user