v0.1.0 first commit

This commit is contained in:
2025-06-30 20:55:31 -04:00
commit 294771653c
11 changed files with 875 additions and 0 deletions

83
src/cmd/logwisp/main.go Normal file
View File

@ -0,0 +1,83 @@
// File: logwisp/src/cmd/logwisp/main.go
package main
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"logwisp/src/internal/config"
"logwisp/src/internal/monitor"
"logwisp/src/internal/stream"
)
func main() {
// Load configuration
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
os.Exit(1)
}
// Create context for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Setup signal handling
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Create components
streamer := stream.New(cfg.Stream.BufferSize)
mon := monitor.New(streamer.Publish)
// Add monitor targets from config
for _, target := range cfg.Monitor.Targets {
if err := mon.AddTarget(target.Path, target.Pattern); err != nil {
fmt.Fprintf(os.Stderr, "Failed to add target %s: %v\n", target.Path, err)
}
}
// Start monitoring
if err := mon.Start(ctx); err != nil {
fmt.Fprintf(os.Stderr, "Failed to start monitor: %v\n", err)
os.Exit(1)
}
// Setup HTTP server
mux := http.NewServeMux()
mux.Handle("/stream", streamer)
server := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.Port),
Handler: mux,
}
// Start server
go func() {
fmt.Printf("LogWisp streaming on http://localhost:%d/stream\n", cfg.Port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "Server error: %v\n", err)
}
}()
// Wait for shutdown signal
<-sigChan
fmt.Println("\nShutting down...")
// Graceful shutdown
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
fmt.Fprintf(os.Stderr, "Server shutdown error: %v\n", err)
}
cancel() // Stop monitor
mon.Stop()
streamer.Stop()
}

View File

@ -0,0 +1,120 @@
// File: logwisp/src/internal/config/config.go
package config
import (
"fmt"
"os"
"path/filepath"
"github.com/BurntSushi/toml"
)
// Config holds the complete configuration
type Config struct {
Port int `toml:"port"`
Monitor MonitorConfig `toml:"monitor"`
Stream StreamConfig `toml:"stream"`
}
// MonitorConfig holds monitoring settings
type MonitorConfig struct {
CheckIntervalMs int `toml:"check_interval_ms"`
Targets []MonitorTarget `toml:"targets"`
}
// MonitorTarget represents a path to monitor
type MonitorTarget struct {
Path string `toml:"path"`
Pattern string `toml:"pattern"`
}
// StreamConfig holds streaming settings
type StreamConfig struct {
BufferSize int `toml:"buffer_size"`
}
// DefaultConfig returns configuration with sensible defaults
func DefaultConfig() *Config {
return &Config{
Port: 8080,
Monitor: MonitorConfig{
CheckIntervalMs: 100,
Targets: []MonitorTarget{
{
Path: "./",
Pattern: "*.log",
},
},
},
Stream: StreamConfig{
BufferSize: 1000,
},
}
}
// Load reads configuration from default location or returns defaults
func Load() (*Config, error) {
cfg := DefaultConfig()
// Determine config path
homeDir, err := os.UserHomeDir()
if err != nil {
return cfg, nil // Return defaults if can't find home
}
// configPath := filepath.Join(homeDir, ".config", "logwisp.toml")
configPath := filepath.Join(homeDir, "git", "lixenwraith", "logwisp", "config", "logwisp.toml")
// Check if config file exists
if _, err := os.Stat(configPath); os.IsNotExist(err) {
// No config file, use defaults
return cfg, nil
}
// Read and parse config file
data, err := os.ReadFile(configPath)
if err != nil {
return nil, fmt.Errorf("failed to read config: %w", err)
}
if err := toml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
// Validate
if err := cfg.validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
return cfg, nil
}
// validate checks configuration sanity
func (c *Config) validate() error {
if c.Port < 1 || c.Port > 65535 {
return fmt.Errorf("invalid port: %d", c.Port)
}
if c.Monitor.CheckIntervalMs < 10 {
return fmt.Errorf("check interval too small: %d ms", c.Monitor.CheckIntervalMs)
}
if c.Stream.BufferSize < 1 {
return fmt.Errorf("buffer size must be positive: %d", c.Stream.BufferSize)
}
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 target.Pattern == "" {
return fmt.Errorf("target %d: empty pattern", i)
}
}
return nil
}

View File

@ -0,0 +1,303 @@
// File: logwisp/src/internal/monitor/monitor.go
package monitor
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"time"
)
// LogEntry represents a log line to be streamed
type LogEntry struct {
Time time.Time `json:"time"`
Source string `json:"source"`
Level string `json:"level,omitempty"`
Message string `json:"message"`
Fields json.RawMessage `json:"fields,omitempty"`
}
// Monitor watches files and directories for log entries
type Monitor struct {
callback func(LogEntry)
targets []target
watchers map[string]*fileWatcher
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
}
type target struct {
path string
pattern string
}
// New creates a new monitor instance
func New(callback func(LogEntry)) *Monitor {
return &Monitor{
callback: callback,
watchers: make(map[string]*fileWatcher),
}
}
// AddTarget adds a path to monitor
func (m *Monitor) AddTarget(path, pattern string) error {
// Validate path exists
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("invalid path %s: %w", path, err)
}
// Store target
m.mu.Lock()
m.targets = append(m.targets, target{
path: path,
pattern: pattern,
})
m.mu.Unlock()
// If monitoring a file directly
if !info.IsDir() {
pattern = filepath.Base(path)
path = filepath.Dir(path)
}
return nil
}
// Start begins monitoring all targets
func (m *Monitor) Start(ctx context.Context) error {
m.ctx, m.cancel = context.WithCancel(ctx)
// Start monitor loop
m.wg.Add(1)
go m.monitorLoop()
return nil
}
// Stop halts monitoring
func (m *Monitor) Stop() {
if m.cancel != nil {
m.cancel()
}
m.wg.Wait()
// Close all watchers
m.mu.Lock()
for _, w := range m.watchers {
w.close()
}
m.mu.Unlock()
}
// monitorLoop periodically checks for new files and monitors them
func (m *Monitor) monitorLoop() {
defer m.wg.Done()
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-m.ctx.Done():
return
case <-ticker.C:
m.checkTargets()
}
}
}
// checkTargets scans for files matching patterns
func (m *Monitor) checkTargets() {
m.mu.RLock()
targets := make([]target, len(m.targets))
copy(targets, m.targets)
m.mu.RUnlock()
for _, t := range targets {
matches, err := filepath.Glob(filepath.Join(t.path, t.pattern))
if err != nil {
continue
}
for _, file := range matches {
m.ensureWatcher(file)
}
}
}
// ensureWatcher creates a watcher if it doesn't exist
func (m *Monitor) ensureWatcher(path string) {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.watchers[path]; exists {
return
}
w := newFileWatcher(path, m.callback)
m.watchers[path] = w
m.wg.Add(1)
go func() {
defer m.wg.Done()
w.watch(m.ctx)
// Remove watcher when done
m.mu.Lock()
delete(m.watchers, path)
m.mu.Unlock()
}()
}
// fileWatcher monitors a single file
type fileWatcher struct {
path string
callback func(LogEntry)
position int64
mu sync.Mutex
}
func newFileWatcher(path string, callback func(LogEntry)) *fileWatcher {
return &fileWatcher{
path: path,
callback: callback,
}
}
// watch monitors the file for new content
func (w *fileWatcher) watch(ctx context.Context) {
// Initial read to position at end
if err := w.seekToEnd(); err != nil {
return
}
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.checkFile()
}
}
}
// seekToEnd positions at the end of file
func (w *fileWatcher) seekToEnd() error {
file, err := os.Open(w.path)
if err != nil {
return err
}
defer file.Close()
pos, err := file.Seek(0, io.SeekEnd)
if err != nil {
return err
}
w.mu.Lock()
w.position = pos
w.mu.Unlock()
return nil
}
// checkFile reads new content
func (w *fileWatcher) checkFile() error {
file, err := os.Open(w.path)
if err != nil {
return err
}
defer file.Close()
// Get current file size
info, err := file.Stat()
if err != nil {
return err
}
w.mu.Lock()
pos := w.position
w.mu.Unlock()
// Check for rotation (file smaller than position)
if info.Size() < pos {
pos = 0
}
// Seek to last position
if _, err := file.Seek(pos, io.SeekStart); err != nil {
return err
}
// Read new lines
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
entry := w.parseLine(line)
w.callback(entry)
}
// Update position
newPos, err := file.Seek(0, io.SeekCurrent)
if err == nil {
w.mu.Lock()
w.position = newPos
w.mu.Unlock()
}
return nil
}
// parseLine attempts to parse JSON or returns plain text
func (w *fileWatcher) parseLine(line string) LogEntry {
// Try to parse as JSON log
var jsonLog struct {
Time string `json:"time"`
Level string `json:"level"`
Message string `json:"msg"`
Fields json.RawMessage `json:"fields"`
}
if err := json.Unmarshal([]byte(line), &jsonLog); err == nil {
// Parse timestamp
timestamp, err := time.Parse(time.RFC3339Nano, jsonLog.Time)
if err != nil {
timestamp = time.Now()
}
return LogEntry{
Time: timestamp,
Source: filepath.Base(w.path),
Level: jsonLog.Level,
Message: jsonLog.Message,
Fields: jsonLog.Fields,
}
}
// Plain text log
return LogEntry{
Time: time.Now(),
Source: filepath.Base(w.path),
Message: line,
}
}
// close cleans up the watcher
func (w *fileWatcher) close() {
// Nothing to clean up in this simple implementation
}

View File

@ -0,0 +1,156 @@
// File: logwisp/src/internal/stream/stream.go
package stream
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"logwisp/src/internal/monitor"
)
// Streamer handles Server-Sent Events streaming
type Streamer struct {
clients map[string]chan monitor.LogEntry
register chan *client
unregister chan string
broadcast chan monitor.LogEntry
mu sync.RWMutex
bufferSize int
done chan struct{}
}
type client struct {
id string
channel chan monitor.LogEntry
}
// New creates a new SSE streamer
func New(bufferSize int) *Streamer {
s := &Streamer{
clients: make(map[string]chan monitor.LogEntry),
register: make(chan *client),
unregister: make(chan string),
broadcast: make(chan monitor.LogEntry, bufferSize),
bufferSize: bufferSize,
done: make(chan struct{}),
}
go s.run()
return s
}
// run manages client connections
func (s *Streamer) run() {
for {
select {
case c := <-s.register:
s.mu.Lock()
s.clients[c.id] = c.channel
s.mu.Unlock()
case id := <-s.unregister:
s.mu.Lock()
if ch, ok := s.clients[id]; ok {
close(ch)
delete(s.clients, id)
}
s.mu.Unlock()
case entry := <-s.broadcast:
s.mu.RLock()
for id, ch := range s.clients {
select {
case ch <- entry:
// Sent successfully
default:
// Client buffer full, skip this entry
// In production, might want to close slow clients
_ = id
}
}
s.mu.RUnlock()
case <-s.done:
return
}
}
}
// Publish sends a log entry to all connected clients
func (s *Streamer) Publish(entry monitor.LogEntry) {
select {
case s.broadcast <- entry:
// Sent to broadcast channel
default:
// Broadcast buffer full, drop entry
// In production, might want to log this
}
}
// ServeHTTP implements http.Handler for SSE
func (s *Streamer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Set SSE headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
// Create client
clientID := fmt.Sprintf("%d", time.Now().UnixNano())
ch := make(chan monitor.LogEntry, s.bufferSize)
c := &client{
id: clientID,
channel: ch,
}
// Register client
s.register <- c
defer func() {
s.unregister <- clientID
}()
// Send initial connection event
fmt.Fprintf(w, "event: connected\ndata: {\"client_id\":\"%s\"}\n\n", clientID)
w.(http.Flusher).Flush()
// Create ticker for heartbeat
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
// Stream events
for {
select {
case <-r.Context().Done():
return
case entry := <-ch:
data, err := json.Marshal(entry)
if err != nil {
continue
}
fmt.Fprintf(w, "data: %s\n\n", data)
w.(http.Flusher).Flush()
case <-ticker.C:
fmt.Fprintf(w, ": heartbeat\n\n")
w.(http.Flusher).Flush()
}
}
}
// Stop gracefully shuts down the streamer
func (s *Streamer) Stop() {
close(s.done)
// Close all client channels
s.mu.Lock()
for id := range s.clients {
s.unregister <- id
}
s.mu.Unlock()
}