242 lines
5.8 KiB
Go
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
|
|
}
|