v0.1.0 first commit
This commit is contained in:
120
src/internal/config/config.go
Normal file
120
src/internal/config/config.go
Normal 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
|
||||
}
|
||||
303
src/internal/monitor/monitor.go
Normal file
303
src/internal/monitor/monitor.go
Normal 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
|
||||
}
|
||||
156
src/internal/stream/stream.go
Normal file
156
src/internal/stream/stream.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user