Files
Tobias GesellchenandClaude Opus 4.7 c3723dc0e6 feat(service): add Logs tab streaming the live stderr trace
Cloud-deploy operators on Discussion #295 needed to leave the
admin UI for docker logs / journalctl to see what the service was
doing. Mirror log.Default() output into an in-memory ring buffer
and expose it under /setup/logs so the admin UI can show a live
trace alongside the existing tabs.

The buffer is a second sink under log.SetOutput(io.MultiWriter(
os.Stderr, buf)) — stderr keeps receiving every line verbatim,
so docker logs / journalctl are unaffected. Default capacity
2000 lines (~400 KB), tunable via SOUNDTOUCH_LOG_BUFFER_LINES.

- pkg/service/logbuf: io.Writer ring with \n splitting,
  partial-line buffering, monotonic Seq, Since(since, limit)
  reporting dropped count when the caller falls behind.
- New /setup/logs (GET) returns {entries, nextSince, dropped,
  capacity}. Polls at 1.5s while the tab is active; paused on
  document.hidden.
- "8. Logs" tab with substring filter, tail-follow toggle
  (auto-disables when the user scrolls up), monospace dark view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 20:12:25 +02:00

265 lines
5.7 KiB
Go

package logbuf
import (
"fmt"
"strings"
"sync"
"testing"
"time"
)
func TestBuffer_SingleLineWrite(t *testing.T) {
b := New(8)
n, err := b.Write([]byte("hello\n"))
if err != nil {
t.Fatalf("Write: %v", err)
}
if n != len("hello\n") {
t.Errorf("expected %d bytes consumed, got %d", len("hello\n"), n)
}
got := b.Snapshot()
if len(got) != 1 {
t.Fatalf("expected 1 entry, got %d", len(got))
}
if got[0].Message != "hello" {
t.Errorf("expected message 'hello', got %q", got[0].Message)
}
if got[0].Seq != 1 {
t.Errorf("expected seq=1, got %d", got[0].Seq)
}
}
func TestBuffer_MultiLineSingleWrite(t *testing.T) {
b := New(8)
_, _ = b.Write([]byte("one\ntwo\nthree\n"))
got := b.Snapshot()
if len(got) != 3 {
t.Fatalf("expected 3 entries, got %d", len(got))
}
want := []string{"one", "two", "three"}
for i := range got {
if got[i].Message != want[i] {
t.Errorf("entry %d: want %q, got %q", i, want[i], got[i].Message)
}
if got[i].Seq != uint64(i+1) {
t.Errorf("entry %d: want seq=%d, got %d", i, i+1, got[i].Seq)
}
}
}
func TestBuffer_PartialLineBuffered(t *testing.T) {
b := New(8)
_, _ = b.Write([]byte("hel"))
_, _ = b.Write([]byte("lo "))
_, _ = b.Write([]byte("world\n"))
got := b.Snapshot()
if len(got) != 1 {
t.Fatalf("expected 1 entry, got %d", len(got))
}
if got[0].Message != "hello world" {
t.Errorf("expected reassembled line, got %q", got[0].Message)
}
}
func TestBuffer_PartialLineDoesNotLeakUntilNewline(t *testing.T) {
b := New(8)
_, _ = b.Write([]byte("no newline here"))
if b.Len() != 0 {
t.Errorf("expected zero entries for an unterminated write, got %d", b.Len())
}
}
func TestBuffer_RingEvictionAtCapacity(t *testing.T) {
const capacity = 3
b := New(capacity)
for i := 1; i <= 5; i++ {
_, _ = fmt.Fprintf(b, "line %d\n", i)
}
got := b.Snapshot()
if len(got) != capacity {
t.Fatalf("expected %d entries after eviction, got %d", capacity, len(got))
}
// Oldest surviving entry should be "line 3" with Seq=3.
if got[0].Message != "line 3" || got[0].Seq != 3 {
t.Errorf("oldest survivor: want line 3/seq 3, got %q/seq %d", got[0].Message, got[0].Seq)
}
if got[capacity-1].Message != "line 5" || got[capacity-1].Seq != 5 {
t.Errorf("newest entry: want line 5/seq 5, got %q/seq %d", got[capacity-1].Message, got[capacity-1].Seq)
}
}
func TestBuffer_SinceFilters(t *testing.T) {
b := New(8)
for i := 1; i <= 5; i++ {
_, _ = fmt.Fprintf(b, "line %d\n", i)
}
entries, nextSince, dropped := b.Since(2, 0)
if len(entries) != 3 {
t.Fatalf("expected 3 entries since=2, got %d", len(entries))
}
if entries[0].Seq != 3 {
t.Errorf("first entry seq: want 3, got %d", entries[0].Seq)
}
if nextSince != 5 {
t.Errorf("nextSince: want 5, got %d", nextSince)
}
if dropped != 0 {
t.Errorf("dropped: want 0, got %d", dropped)
}
}
func TestBuffer_SinceReportsDropped(t *testing.T) {
b := New(3)
for i := 1; i <= 10; i++ {
_, _ = fmt.Fprintf(b, "line %d\n", i)
}
// Capacity 3 → only seq 8,9,10 remain. Polling with since=2
// means seq 3..7 (five entries) were evicted before we saw them.
entries, nextSince, dropped := b.Since(2, 0)
if len(entries) != 3 {
t.Fatalf("expected 3 entries, got %d", len(entries))
}
if dropped != 5 {
t.Errorf("expected dropped=5, got %d", dropped)
}
if nextSince != 10 {
t.Errorf("nextSince: want 10, got %d", nextSince)
}
}
func TestBuffer_SinceRespectsLimit(t *testing.T) {
b := New(10)
for i := 1; i <= 5; i++ {
_, _ = fmt.Fprintf(b, "line %d\n", i)
}
entries, nextSince, _ := b.Since(0, 2)
if len(entries) != 2 {
t.Fatalf("expected limit=2 to cap result, got %d", len(entries))
}
if nextSince != 2 {
t.Errorf("nextSince after limit: want 2, got %d", nextSince)
}
}
func TestBuffer_SinceNoNewEntries(t *testing.T) {
b := New(4)
_, _ = b.Write([]byte("only\n"))
entries, nextSince, dropped := b.Since(5, 0)
if len(entries) != 0 {
t.Errorf("expected no entries, got %d", len(entries))
}
if nextSince != 5 {
t.Errorf("nextSince should pass through since when no matches: want 5, got %d", nextSince)
}
if dropped != 0 {
t.Errorf("dropped: want 0, got %d", dropped)
}
}
func TestBuffer_TimestampMonotonic(t *testing.T) {
b := New(4)
// Inject a deterministic clock so the test isn't time-flaky.
tick := time.Unix(1_700_000_000, 0)
b.now = func() time.Time {
t := tick
tick = tick.Add(time.Millisecond)
return t
}
_, _ = b.Write([]byte("a\nb\n"))
got := b.Snapshot()
if !got[1].Time.After(got[0].Time) {
t.Errorf("expected later entry to have a later timestamp, got %v vs %v", got[1].Time, got[0].Time)
}
}
func TestBuffer_ConcurrentWrites(t *testing.T) {
b := New(10000)
const writers = 8
const perWriter = 500
var wg sync.WaitGroup
for w := 0; w < writers; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for i := 0; i < perWriter; i++ {
_, _ = fmt.Fprintf(b, "w%d-i%d\n", id, i)
}
}(w)
}
wg.Wait()
got := b.Snapshot()
if len(got) != writers*perWriter {
t.Fatalf("expected %d entries, got %d", writers*perWriter, len(got))
}
// Seq must be strictly increasing and contiguous from 1.
for i := range got {
if got[i].Seq != uint64(i+1) {
t.Fatalf("seq gap at index %d: got %d", i, got[i].Seq)
}
if !strings.HasPrefix(got[i].Message, "w") {
t.Errorf("unexpected message: %q", got[i].Message)
}
}
}
func TestBuffer_ZeroCapacityClamped(t *testing.T) {
b := New(0)
if b.Capacity() == 0 {
t.Errorf("zero capacity should be clamped to a positive default")
}
}
func TestBuffer_EmptyWriteNoOp(t *testing.T) {
b := New(4)
n, err := b.Write(nil)
if err != nil {
t.Errorf("nil write: %v", err)
}
if n != 0 {
t.Errorf("nil write should consume 0 bytes, got %d", n)
}
if b.Len() != 0 {
t.Errorf("buffer should be empty, got %d entries", b.Len())
}
}