diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index abd1b9b15167..7edc5acd18d3 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -36,6 +36,7 @@ jobs: uses: anthropics/claude-code-action@v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + allowed_bots: '*' prompt: | REPO: ${{ github.repository }} PR NUMBER: ${{ github.event.pull_request.number }} diff --git a/assets/components/ContainerTable.vue b/assets/components/ContainerTable.vue index 3bc26e20cb22..befac24ce360 100644 --- a/assets/components/ContainerTable.vue +++ b/assets/components/ContainerTable.vue @@ -87,7 +87,7 @@ @@ -96,7 +96,20 @@ {{ container.hostLabel }} - {{ container.state }} + + {{ container.state }} + + {{ container.statusBadge.text }} + + @@ -106,6 +119,25 @@ + +
+ + {{ totalLogRate(container) }} +
+ + + + {{ container.anomalyScore }} + + @@ -183,6 +215,24 @@ const fields: Record< mobileVisible: false, customClass: "min-w-48", }, + logs: { + label: "label.log-freq", + sortFunc: (a: Container, b: Container) => { + const aLast = a.logStatsHistory.at(-1); + const bLast = b.logStatsHistory.at(-1); + const aTotal = aLast ? aLast.info + aLast.warn + aLast.error + aLast.debug + aLast.fatal : 0; + const bTotal = bLast ? bLast.info + bLast.warn + bLast.error + bLast.debug + bLast.fatal : 0; + return (aTotal - bTotal) * direction.value; + }, + mobileVisible: false, + customClass: "min-w-40", + }, + hot: { + label: "label.hot", + sortFunc: (a: Container, b: Container) => (a.anomalyScore - b.anomalyScore) * direction.value, + mobileVisible: false, + customClass: "w-1", + }, }; const { containers } = defineProps<{ @@ -227,6 +277,14 @@ function sort(field: keys) { direction.value = 1; } } +function totalLogRate(container: Container): string { + const last = container.logStatsHistory.at(-1); + if (!last) return "0"; + const total = last.info + last.warn + last.error + last.debug + last.fatal; + if (total >= 1000) return `${(total / 1000).toFixed(1)}k`; + return `${total}`; +} + function isVisible(field: keys) { return fields[field].mobileVisible || !isMobile.value; } diff --git a/assets/components/LogFrequencyChart.vue b/assets/components/LogFrequencyChart.vue new file mode 100644 index 000000000000..7e07bc0066d3 --- /dev/null +++ b/assets/components/LogFrequencyChart.vue @@ -0,0 +1,104 @@ + + + diff --git a/assets/models/Container.ts b/assets/models/Container.ts index 941c1e1426ff..f28f101403c1 100644 --- a/assets/models/Container.ts +++ b/assets/models/Container.ts @@ -1,7 +1,8 @@ -import type { ContainerHealth, ContainerJson, ContainerStat, ContainerState } from "@/types/Container"; +import type { ContainerHealth, ContainerJson, ContainerStat, ContainerState, LogStat } from "@/types/Container"; import { Ref } from "vue"; export type Stat = Omit; +export type LogFreq = Omit; const hosts = computed(() => config.hosts.reduce( @@ -27,11 +28,23 @@ export class HistoricalContainer { ) {} } +const defaultLogFreq: LogFreq = { info: 0, warn: 0, error: 0, debug: 0, fatal: 0 }; +const LOG_STATS_HISTORY_SIZE = 60; +const RESTART_WINDOW_MS = 10 * 60 * 1000; // 10 minutes + export class Container { private _stat: Ref; private _name: string; private readonly _statsHistory: Ref; private readonly movingAverageStat: Ref; + private readonly _logStatsHistory: Ref; + private readonly _logStatsVersion: Ref; + + // Crash-loop / exit tracking + public lastExitCode: string | null = null; + private _restartTimestamps: number[] = []; + private _cachedRestartCount: number = 0; + private _restartCacheTime: number = 0; constructor( public readonly id: string, @@ -57,10 +70,21 @@ export class Container { const padding = Array(300 - recentStats.length).fill(defaultStat); this._statsHistory = ref([...padding, ...recentStats]); this.movingAverageStat = ref(stats.at(-1) || defaultStat); + const logPadding = Array(LOG_STATS_HISTORY_SIZE).fill(defaultLogFreq); + this._logStatsHistory = ref(logPadding); + this._logStatsVersion = ref(0); this._name = name; } + get logStatsHistory() { + return unref(this._logStatsHistory); + } + + get logStatsVersion(): number { + return isRef(this._logStatsVersion) ? this._logStatsVersion.value : (this._logStatsVersion as unknown as number); + } + get statsHistory() { return unref(this._statsHistory); } @@ -148,6 +172,85 @@ export class Container { } } + public updateLogStat(logStat: LogFreq) { + const history = isRef(this._logStatsHistory) + ? this._logStatsHistory.value + : (this._logStatsHistory as unknown as LogFreq[]); + history.push(logStat); + if (history.length > LOG_STATS_HISTORY_SIZE) { + history.shift(); + } + if (isRef(this._logStatsVersion)) { + this._logStatsVersion.value++; + } else { + // When unwrapped by reactive(), assign via property to trigger reactivity + (this as any)._logStatsVersion = (this._logStatsVersion as unknown as number) + 1; + } + } + + public recordDie(exitCode?: string) { + this.lastExitCode = exitCode ?? null; + } + + public recordRestart() { + const now = Date.now(); + this._restartTimestamps.push(now); + // Keep only timestamps within the window + const cutoff = now - RESTART_WINDOW_MS; + this._restartTimestamps = this._restartTimestamps.filter((t) => t > cutoff); + } + + get restartCount(): number { + const now = Date.now(); + // Re-compute at most once per second to avoid repeated Date.now()+filter calls + if (now - this._restartCacheTime < 1000) { + return this._cachedRestartCount; + } + const cutoff = now - RESTART_WINDOW_MS; + // Prune stale timestamps while counting + this._restartTimestamps = this._restartTimestamps.filter((t) => t > cutoff); + this._cachedRestartCount = this._restartTimestamps.length; + this._restartCacheTime = now; + return this._cachedRestartCount; + } + + get isCrashLooping(): boolean { + return this.restartCount >= 3; + } + + get statusBadge(): { text: string; type: "error" | "warning" | "info" } | null { + const restarts = this.restartCount; + if (restarts >= 3) { + return { text: `${restarts} restarts`, type: "error" }; + } + if (this.lastExitCode && this.lastExitCode !== "0" && this.state === "exited") { + const code = this.lastExitCode; + // 137=SIGKILL (may be OOM but not always), 143=SIGTERM + const label = code === "137" ? "Killed" : code === "143" ? "SIGTERM" : `Exit ${code}`; + return { text: label, type: "warning" }; + } + if (this.health === "unhealthy") { + return { text: "unhealthy", type: "warning" }; + } + return null; + } + + /** Anomaly score for "hot" sorting — higher means more attention needed */ + get anomalyScore(): number { + let score = 0; + const restarts = this.restartCount; + score += restarts * 20; + if (this.health === "unhealthy") score += 30; + if (this.lastExitCode && this.lastExitCode !== "0" && this.state === "exited") score += 15; + const recent = this.logStatsHistory.slice(-3); + for (const r of recent) { + score += r.error * 3 + r.fatal * 10 + r.warn; + } + if (this.movingAverage.cpu > 80) score += 10; + if (this.movingAverage.memory > 85) score += 10; + return score; + } + static fromJSON(c: ContainerJson): Container { return new Container( c.id, diff --git a/assets/stores/container.ts b/assets/stores/container.ts index 70b353f65f2d..6deaba1c0ab4 100644 --- a/assets/stores/container.ts +++ b/assets/stores/container.ts @@ -1,6 +1,6 @@ import { acceptHMRUpdate, defineStore } from "pinia"; import { Ref, UnwrapNestedRefs } from "vue"; -import type { ContainerHealth, ContainerJson, ContainerStat } from "@/types/Container"; +import type { ContainerHealth, ContainerJson, ContainerStat, LogStat } from "@/types/Container"; import { Container } from "@/models/Container"; import i18n from "@/modules/i18n"; import { Host } from "./hosts"; @@ -60,14 +60,34 @@ export const useContainerStore = defineStore("container", () => { container.updateStat(rest); } }); + es.addEventListener("container-log-stat", (e) => { + const logStat = JSON.parse((e as MessageEvent).data) as LogStat; + const container = allContainersById.value[logStat.id] as unknown as UnwrapNestedRefs; + if (container) { + const { id, ...rest } = logStat; + container.updateLogStat(rest); + } + }); es.addEventListener("container-event", (e) => { - const event = JSON.parse((e as MessageEvent).data) as { actorId: string; name: string; time: string }; + const event = JSON.parse((e as MessageEvent).data) as { + actorId: string; + name: string; + time: string; + actorAttributes?: Record; + }; const container = allContainersById.value[event.actorId]; if (container) { switch (event.name) { case "die": container.state = "exited"; container.finishedAt = new Date(event.time); + container.recordDie(event.actorAttributes?.exitCode); + break; + case "start": + if (container.state === "exited") { + container.recordRestart(); + } + container.state = "running"; break; case "destroy": container.state = "deleted"; diff --git a/assets/types/Container.d.ts b/assets/types/Container.d.ts index 0ee7367ffb8b..fb6a61de62b3 100644 --- a/assets/types/Container.d.ts +++ b/assets/types/Container.d.ts @@ -7,6 +7,15 @@ export interface ContainerStat { readonly networkTxTotal: number; } +export interface LogStat { + readonly id: string; + readonly info: number; + readonly warn: number; + readonly error: number; + readonly debug: number; + readonly fatal: number; +} + export type ContainerJson = { readonly id: string; readonly created: string; diff --git a/internal/agent/client_test.go b/internal/agent/client_test.go index f359bdecce01..eb0907c12ec3 100644 --- a/internal/agent/client_test.go +++ b/internal/agent/client_test.go @@ -85,6 +85,10 @@ func (m *MockedClientService) SubscribeContainersStarted(ctx context.Context, co m.Called(ctx, containers) } +func (m *MockedClientService) SubscribeLogStats(ctx context.Context, logStats chan<- container.LogStat) { + m.Called(ctx, logStats) +} + func (m *MockedClientService) StreamLogs(ctx context.Context, c container.Container, from time.Time, stdTypes container.StdType, events chan<- *container.LogEvent) error { args := m.Called(ctx, c, from, stdTypes, events) return args.Error(0) diff --git a/internal/agent/server.go b/internal/agent/server.go index c7b5abb6887a..eb328bf1c4bf 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -44,6 +44,7 @@ type ClientService interface { SubscribeStats(context.Context, chan<- container.ContainerStat) SubscribeEvents(context.Context, chan<- container.ContainerEvent) SubscribeContainersStarted(context.Context, chan<- container.Container) + SubscribeLogStats(context.Context, chan<- container.LogStat) StreamLogs(context.Context, container.Container, time.Time, container.StdType, chan<- *container.LogEvent) error Attach(context.Context, container.Container, container.ExecEventReader, io.Writer) error Exec(context.Context, container.Container, []string, container.ExecEventReader, io.Writer) error diff --git a/internal/container/container_store.go b/internal/container/container_store.go index 56e80daa2334..7dce03dc09fe 100644 --- a/internal/container/container_store.go +++ b/internal/container/container_store.go @@ -1,8 +1,11 @@ package container import ( + "bufio" "context" + "encoding/binary" "errors" + "io" "sync" "sync/atomic" "time" @@ -23,10 +26,12 @@ type ContainerStore struct { containers *xsync.Map[string, *Container] subscribers *xsync.Map[context.Context, chan<- ContainerEvent] newContainerSubscribers *xsync.Map[context.Context, chan<- Container] + logStatSubscribers *xsync.Map[context.Context, chan<- LogStat] client Client statsCollector StatsCollector wg sync.WaitGroup connected atomic.Bool + logStatsRunning atomic.Bool events chan ContainerEvent ctx context.Context labels ContainerLabels @@ -34,6 +39,7 @@ type ContainerStore struct { const defaultTimeout = 10 * time.Second const reconcileInterval = 30 * time.Second +const logStatsInterval = 5 * time.Second func NewContainerStore(ctx context.Context, client Client, statsCollect StatsCollector, labels ContainerLabels) *ContainerStore { log.Debug().Str("host", client.Host().Name).Interface("labels", labels).Msg("initializing container store") @@ -43,6 +49,7 @@ func NewContainerStore(ctx context.Context, client Client, statsCollect StatsCol client: client, subscribers: xsync.NewMap[context.Context, chan<- ContainerEvent](), newContainerSubscribers: xsync.NewMap[context.Context, chan<- Container](), + logStatSubscribers: xsync.NewMap[context.Context, chan<- LogStat](), statsCollector: statsCollect, wg: sync.WaitGroup{}, events: make(chan ContainerEvent), @@ -267,6 +274,148 @@ func (s *ContainerStore) SubscribeNewContainers(ctx context.Context, containers }() } +func (s *ContainerStore) SubscribeLogStats(ctx context.Context, logStats chan<- LogStat) { + s.logStatSubscribers.Store(ctx, logStats) + go func() { + <-ctx.Done() + s.logStatSubscribers.Delete(ctx) + }() +} + +// demuxDockerStream strips the 8-byte multiplexed headers from non-TTY Docker log streams, +// writing clean text to w. For TTY containers, it copies the stream as-is. +func demuxDockerStream(w io.Writer, r io.Reader, tty bool) error { + if tty { + _, err := io.Copy(w, r) + return err + } + header := make([]byte, 8) + for { + if _, err := io.ReadFull(r, header); err != nil { + if err == io.EOF || err == io.ErrUnexpectedEOF { + return nil + } + return err + } + size := binary.BigEndian.Uint32(header[4:]) + if size == 0 { + continue + } + if _, err := io.CopyN(w, r, int64(size)); err != nil { + return err + } + } +} + +// collectLogStats fetches recent logs for all running containers and counts by level. +func (s *ContainerStore) collectLogStats() { + type idTty struct { + id string + tty bool + } + var targets []idTty + s.containers.Range(func(id string, c *Container) bool { + if c.State == "running" { + targets = append(targets, idTty{id: id, tty: c.Tty}) + } + return true + }) + + if len(targets) == 0 { + return + } + + now := time.Now() + since := now.Add(-logStatsInterval) + + var wg sync.WaitGroup + sem := make(chan struct{}, 5) // max 5 concurrent + var mu sync.Mutex + var results []LogStat + +loop: + for _, t := range targets { + select { + case sem <- struct{}{}: // acquire + case <-s.ctx.Done(): + break loop + } + wg.Add(1) + go func(t idTty) { + defer wg.Done() + defer func() { <-sem }() // release + + ctx, cancel := context.WithTimeout(s.ctx, 2*time.Second) + defer cancel() + reader, err := s.client.ContainerLogsBetweenDates(ctx, t.id, since, now, STDALL) + if err != nil { + log.Debug().Err(err).Str("id", t.id).Msg("failed to fetch logs for log stats") + return + } + stat := s.collectLogStatForContainer(ctx, t.id, t.tty, reader) + reader.Close() + + mu.Lock() + results = append(results, stat) + mu.Unlock() + }(t) + } + wg.Wait() + + // Publish all results + for _, stat := range results { + s.logStatSubscribers.Range(func(c context.Context, ch chan<- LogStat) bool { + select { + case ch <- stat: + case <-c.Done(): + s.logStatSubscribers.Delete(c) + default: + // Drop stat if subscriber is slow — don't block the collector + } + return true + }) + } +} + +// collectLogStatForContainer demuxes and scans logs for a single container. +func (s *ContainerStore) collectLogStatForContainer(_ context.Context, id string, tty bool, reader io.ReadCloser) LogStat { + pr, pw := io.Pipe() + go func() { + err := demuxDockerStream(pw, reader, tty) + pw.CloseWithError(err) // always propagate (nil is fine) + }() + + stat := LogStat{ID: id} + scanner := bufio.NewScanner(pr) + scanner.Buffer(make([]byte, 0, 256*1024), 256*1024) + for scanner.Scan() { + line := scanner.Text() + if len(line) == 0 { + continue + } + level := GuessLogLevelFromLine(line) + switch level { + case "info": + stat.Info++ + case "warn": + stat.Warn++ + case "error": + stat.Error++ + case "debug": + stat.Debug++ + case "fatal": + stat.Fatal++ + default: + stat.Info++ + } + } + if err := scanner.Err(); err != nil { + log.Debug().Err(err).Str("id", id).Msg("scanner error during log stats collection") + } + pr.Close() + return stat +} + // reconcile compares the in-memory container map with the actual Docker state // and removes any containers that Docker no longer knows about. This handles // cases where destroy events are missed (network hiccups, Docker race conditions). @@ -330,6 +479,9 @@ func (s *ContainerStore) init() { reconcileTicker := time.NewTicker(reconcileInterval) defer reconcileTicker.Stop() + logStatsTicker := time.NewTicker(logStatsInterval) + defer logStatsTicker.Stop() + for { select { case event := <-s.events: @@ -478,6 +630,14 @@ func (s *ContainerStore) init() { case <-reconcileTicker.C: s.reconcile() + case <-logStatsTicker.C: + if s.logStatsRunning.CompareAndSwap(false, true) { + go func() { + defer s.logStatsRunning.Store(false) + s.collectLogStats() + }() + } + case <-s.ctx.Done(): return } diff --git a/internal/container/level_guesser.go b/internal/container/level_guesser.go index 720ce8170f30..6b512ae81701 100644 --- a/internal/container/level_guesser.go +++ b/internal/container/level_guesser.go @@ -118,6 +118,12 @@ var singleLetterToLevel = map[byte]string{ 'V': "trace", } +// GuessLogLevelFromLine guesses the log level from a raw log line string. +// Exported for use by log stats collection. +func GuessLogLevelFromLine(line string) string { + return guessFromString(line) +} + func guessFromString(value string) string { value = StripANSI(value) value = timestampRegex.ReplaceAllString(value, "") diff --git a/internal/container/types.go b/internal/container/types.go index 03abd8a180d5..c98e28ae83b5 100644 --- a/internal/container/types.go +++ b/internal/container/types.go @@ -115,6 +115,16 @@ type ContainerStat struct { NetworkTxTotal uint64 `json:"networkTxTotal"` } +// LogStat represents log frequency counts per level for a container over a time window +type LogStat struct { + ID string `json:"id"` + Info int `json:"info"` + Warn int `json:"warn"` + Error int `json:"error"` + Debug int `json:"debug"` + Fatal int `json:"fatal"` +} + // ContainerEvent represents events that are triggered type ContainerEvent struct { Name string `json:"name"` diff --git a/internal/support/container/agent_service.go b/internal/support/container/agent_service.go index 26a9e610fc7e..e0969c69ab4b 100644 --- a/internal/support/container/agent_service.go +++ b/internal/support/container/agent_service.go @@ -72,6 +72,10 @@ func (d *agentService) SubscribeContainersStarted(ctx context.Context, container go d.client.StreamNewContainers(ctx, containers) } +func (a *agentService) SubscribeLogStats(ctx context.Context, logStats chan<- container.LogStat) { + // Log stats not supported for remote agents yet +} + func (a *agentService) ContainerAction(ctx context.Context, container container.Container, action container.ContainerAction) error { return a.client.ContainerAction(ctx, container.ID, action) } diff --git a/internal/support/container/client_service.go b/internal/support/container/client_service.go index 9b586abe463f..d10a46beef62 100644 --- a/internal/support/container/client_service.go +++ b/internal/support/container/client_service.go @@ -22,6 +22,7 @@ type ClientService interface { SubscribeStats(context.Context, chan<- container.ContainerStat) SubscribeEvents(context.Context, chan<- container.ContainerEvent) SubscribeContainersStarted(context.Context, chan<- container.Container) + SubscribeLogStats(context.Context, chan<- container.LogStat) // Blocking streaming functions that should be used in a goroutine StreamLogs(context.Context, container.Container, time.Time, container.StdType, chan<- *container.LogEvent) error diff --git a/internal/support/docker/docker_service.go b/internal/support/docker/docker_service.go index d671d4de35e0..d430e2a9ecb5 100644 --- a/internal/support/docker/docker_service.go +++ b/internal/support/docker/docker_service.go @@ -103,6 +103,10 @@ func (d *DockerClientService) SubscribeStats(ctx context.Context, stats chan<- c d.store.SubscribeStats(ctx, stats) } +func (d *DockerClientService) SubscribeLogStats(ctx context.Context, logStats chan<- container.LogStat) { + d.store.SubscribeLogStats(ctx, logStats) +} + func (d *DockerClientService) SubscribeEvents(ctx context.Context, events chan<- container.ContainerEvent) { d.store.SubscribeEvents(ctx, events) } diff --git a/internal/support/docker/multi_host_service.go b/internal/support/docker/multi_host_service.go index fe5649f825ab..38935440df3c 100644 --- a/internal/support/docker/multi_host_service.go +++ b/internal/support/docker/multi_host_service.go @@ -129,6 +129,12 @@ func (m *MultiHostService) SubscribeEventsAndStats(ctx context.Context, events c } } +func (m *MultiHostService) SubscribeLogStats(ctx context.Context, logStats chan<- container.LogStat) { + for _, client := range m.manager.List() { + client.SubscribeLogStats(ctx, logStats) + } +} + func (m *MultiHostService) SubscribeContainersStarted(ctx context.Context, containers chan<- container.Container, filter container_support.ContainerFilter) { newContainers := make(chan container.Container) for _, client := range m.manager.List() { diff --git a/internal/support/k8s/k8s_cluster_service.go b/internal/support/k8s/k8s_cluster_service.go index 22e51ca15e33..b9ab7923e4f5 100644 --- a/internal/support/k8s/k8s_cluster_service.go +++ b/internal/support/k8s/k8s_cluster_service.go @@ -99,6 +99,10 @@ func (m *K8sClusterService) SubscribeEventsAndStats(ctx context.Context, events m.client.SubscribeStats(ctx, stats) } +func (m *K8sClusterService) SubscribeLogStats(ctx context.Context, logStats chan<- container.LogStat) { + m.client.SubscribeLogStats(ctx, logStats) +} + func (m *K8sClusterService) SubscribeContainersStarted(ctx context.Context, containers chan<- container.Container, filter container_support.ContainerFilter) { newContainers := make(chan container.Container) m.client.SubscribeContainersStarted(ctx, newContainers) diff --git a/internal/support/k8s/k8s_service.go b/internal/support/k8s/k8s_service.go index 403c00b89470..590da0344d92 100644 --- a/internal/support/k8s/k8s_service.go +++ b/internal/support/k8s/k8s_service.go @@ -92,6 +92,10 @@ func (k *K8sClientService) SubscribeContainersStarted(ctx context.Context, conta k.store.SubscribeNewContainers(ctx, containers) } +func (k *K8sClientService) SubscribeLogStats(ctx context.Context, logStats chan<- container.LogStat) { + k.store.SubscribeLogStats(ctx, logStats) +} + func (k *K8sClientService) Attach(ctx context.Context, c container.Container, events container.ExecEventReader, stdout io.Writer) error { cancelCtx, cancel := context.WithCancel(ctx) session, err := k.client.ContainerAttach(cancelCtx, c.ID) diff --git a/internal/web/events.go b/internal/web/events.go index ed9d87d170e9..8b870d9937a3 100644 --- a/internal/web/events.go +++ b/internal/web/events.go @@ -23,9 +23,11 @@ func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) { events := make(chan container.ContainerEvent) stats := make(chan container.ContainerStat) + logStats := make(chan container.LogStat, 50) availableHosts := make(chan container.Host) h.hostService.SubscribeEventsAndStats(r.Context(), events, stats) + h.hostService.SubscribeLogStats(r.Context(), logStats) h.hostService.SubscribeAvailableHosts(r.Context(), availableHosts) userLabels := h.config.Labels @@ -65,6 +67,11 @@ func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) { log.Error().Err(err).Msg("error writing event to event stream") return } + case logStat := <-logStats: + if err := sseWriter.Event("container-log-stat", logStat); err != nil { + log.Error().Err(err).Msg("error writing log stat to event stream") + return + } case event, ok := <-events: if !ok { return diff --git a/internal/web/routes.go b/internal/web/routes.go index d142bcb5fe26..7dcb784142da 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -70,6 +70,7 @@ type HostService interface { ListAllContainers(labels container.ContainerLabels) ([]container.Container, []error) ListAllContainersFiltered(userFilter container.ContainerLabels, filter container_support.ContainerFilter) ([]container.Container, []error) SubscribeEventsAndStats(ctx context.Context, events chan<- container.ContainerEvent, stats chan<- container.ContainerStat) + SubscribeLogStats(ctx context.Context, logStats chan<- container.LogStat) SubscribeContainersStarted(ctx context.Context, containers chan<- container.Container, filter container_support.ContainerFilter) Hosts() []container.Host LocalHost() (container.Host, error) diff --git a/locales/en.yml b/locales/en.yml index 1b5b78de3f5a..7fd7d2e09fcb 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -41,6 +41,8 @@ label: created: Created avg-cpu: Avg. CPU (%) avg-mem: Avg. MEM (%) + log-freq: Logs + hot: Hot pinned: Pinned per-page: Rows per page host-menu: Hosts and Containers