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