package api import ( "bufio" "context" "fmt" "io" "net/http" "os" "time" ) // tailLogFile streams new lines appended to path to w (SSE text/plain lines). // Sends existing content first (last 200 lines), then polls for new content. // Blocks until ctx is done. func tailLogFile(ctx context.Context, path string, w http.ResponseWriter, flusher http.Flusher) { f, err := os.Open(path) if err != nil { // File may not exist yet (agent hasn't written any logs). // Wait for it to appear. f = waitForFile(ctx, path) if f == nil { return // ctx cancelled before file appeared } } defer f.Close() // Seek to end minus ~50 KB to avoid dumping the whole file. // This gives "recent context" without overwhelming the SSE stream. const tailBytes = 50 * 1024 info, _ := f.Stat() if info != nil && info.Size() > tailBytes { _, _ = f.Seek(-tailBytes, io.SeekEnd) // Skip incomplete first line r := bufio.NewReader(f) _, _ = r.ReadString('\n') // Emit buffered remainder for { line, err := r.ReadString('\n') if line != "" { fmt.Fprintf(w, "event: log\ndata: %s\n\n", line) flusher.Flush() } if err != nil { break } } } // Tail the file: poll for new bytes every 200ms ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() reader := bufio.NewReader(f) for { select { case <-ctx.Done(): return case <-ticker.C: for { line, err := reader.ReadString('\n') if line != "" { fmt.Fprintf(w, "event: log\ndata: %s\n\n", line) flusher.Flush() } if err != nil { // io.EOF means no more data right now — wait next tick break } } } } } // waitForFile polls until path exists or ctx is done. func waitForFile(ctx context.Context, path string) *os.File { ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return nil case <-ticker.C: f, err := os.Open(path) if err == nil { return f } } } }