Files
homarr/tools/homarr-dev/internal/logs/registry.go
T

242 lines
5.8 KiB
Go

// Package logs keeps live `docker logs --follow` streams for running
// containers so the sidebar can show output as it happens instead of polling.
package logs
import (
"context"
"strings"
"sync"
"time"
"github.com/homarr-labs/homarr/tools/homarr-dev/internal/docker"
)
// retainedLines bounds memory per container. Homarr is chatty at boot, so the
// buffer has to be deep enough to still contain the startup banner once the
// app settles.
const retainedLines = 2000
// maxStreams bounds how many containers are followed at once. Each stream is a
// `docker logs --follow` process plus a buffer, which is cheap for the handful
// of instances a developer normally runs but not for thirty. The least recently
// viewed stream is dropped when the cap is reached; re-attaching replays the
// tail, so nothing is lost beyond the scrollback.
const maxStreams = 6
// State describes what a stream is currently doing.
type State int
const (
StateStarting State = iota
StateStreaming
StateStopped
StateFailed
)
// Stream is the retained output of one container.
type Stream struct {
mu sync.Mutex
name string
lines []string
state State
err error
cancel context.CancelFunc
since time.Time
used time.Time
}
// Registry owns every active log stream and notifies a single listener when new
// output arrives.
type Registry struct {
mu sync.Mutex
streams map[string]*Stream
notify chan struct{}
}
func NewRegistry() *Registry {
return &Registry{streams: make(map[string]*Stream), notify: make(chan struct{}, 1)}
}
// Changed is a coalescing wake-up channel, mirroring task.Manager. A container
// logging a thousand lines a second still costs one redraw per frame.
func (r *Registry) Changed() <-chan struct{} { return r.notify }
func (r *Registry) touch() {
select {
case r.notify <- struct{}{}:
default:
}
}
// Attach starts following a container's logs if it is not already being
// followed. It returns immediately; output arrives on the Changed channel.
func (r *Registry) Attach(name string) {
if name == "" {
return
}
r.mu.Lock()
if _, exists := r.streams[name]; exists {
r.mu.Unlock()
return
}
now := time.Now()
ctx, cancel := context.WithCancel(context.Background())
stream := &Stream{name: name, state: StateStarting, cancel: cancel, since: now, used: now}
r.streams[name] = stream
evicted := r.evictLocked()
r.mu.Unlock()
for _, stale := range evicted {
stale.cancel()
}
go r.follow(ctx, stream)
r.touch()
}
// evictLocked drops the least recently viewed streams above the cap. The caller
// holds the lock and cancels the returned streams outside it.
func (r *Registry) evictLocked() []*Stream {
evicted := make([]*Stream, 0)
for len(r.streams) > maxStreams {
var oldest *Stream
for _, stream := range r.streams {
if oldest == nil || stream.used.Before(oldest.used) {
oldest = stream
}
}
if oldest == nil {
break
}
delete(r.streams, oldest.name)
evicted = append(evicted, oldest)
}
return evicted
}
func (r *Registry) follow(ctx context.Context, stream *Stream) {
lines, err := docker.StreamContainerLogs(ctx, stream.name, 400)
if err != nil {
stream.set(StateFailed, err)
r.touch()
return
}
stream.set(StateStreaming, nil)
r.touch()
for line := range lines {
if line.Done {
if ctx.Err() != nil {
stream.set(StateStopped, nil)
} else if line.Err != nil {
stream.set(StateFailed, line.Err)
} else {
stream.set(StateStopped, nil)
}
r.touch()
return
}
stream.append(line.Text)
r.touch()
}
}
func (s *Stream) append(line string) {
s.mu.Lock()
defer s.mu.Unlock()
s.lines = append(s.lines, line)
if len(s.lines) > retainedLines {
s.lines = s.lines[len(s.lines)-retainedLines:]
}
}
func (s *Stream) set(state State, err error) {
s.mu.Lock()
defer s.mu.Unlock()
s.state, s.err = state, err
}
// Detach stops following a container and drops its buffer.
func (r *Registry) Detach(name string) {
r.mu.Lock()
stream, exists := r.streams[name]
delete(r.streams, name)
r.mu.Unlock()
if exists && stream.cancel != nil {
stream.cancel()
}
r.touch()
}
// Retain stops streams for containers that no longer exist, so a removed
// instance does not keep a dead `docker logs` process around.
func (r *Registry) Retain(names []string) {
live := make(map[string]bool, len(names))
for _, name := range names {
live[name] = true
}
r.mu.Lock()
stale := make([]*Stream, 0)
for name, stream := range r.streams {
if !live[name] {
stale = append(stale, stream)
delete(r.streams, name)
}
}
r.mu.Unlock()
for _, stream := range stale {
if stream.cancel != nil {
stream.cancel()
}
}
if len(stale) > 0 {
r.touch()
}
}
// Close stops every stream.
func (r *Registry) Close() { r.Retain(nil) }
// Snapshot is an immutable view of one stream.
type Snapshot struct {
Name string
Lines []string
State State
Err error
}
// Text joins the retained lines for rendering into a viewport.
func (s Snapshot) Text() string { return strings.Join(s.Lines, "\n") }
// Snapshot returns the retained output for a container. The second result is
// false when the container is not being followed.
func (r *Registry) Snapshot(name string) (Snapshot, bool) {
r.mu.Lock()
stream, exists := r.streams[name]
if exists {
// Viewing a stream is what keeps it alive under the cap.
stream.used = time.Now()
}
r.mu.Unlock()
if !exists {
return Snapshot{}, false
}
stream.mu.Lock()
defer stream.mu.Unlock()
return Snapshot{
Name: stream.name,
Lines: append([]string(nil), stream.lines...),
State: stream.state,
Err: stream.err,
}, true
}
// Attached lists the containers currently being followed.
func (r *Registry) Attached() []string {
r.mu.Lock()
defer r.mu.Unlock()
names := make([]string, 0, len(r.streams))
for name := range r.streams {
names = append(names, name)
}
return names
}