diff --git a/pkg/commands/container_stats.go b/pkg/commands/container_stats.go index 419782dd..f011194f 100644 --- a/pkg/commands/container_stats.go +++ b/pkg/commands/container_stats.go @@ -175,6 +175,9 @@ func (c *Container) eraseOldHistory(maxDuration time.Duration) { return } } + + // All entries are older than maxDuration, clear the history + c.StatHistory = nil } func (c *Container) GetLastStats() (*RecordedStats, bool) { diff --git a/pkg/commands/pod.go b/pkg/commands/pod.go index 4a692a08..92928c8c 100644 --- a/pkg/commands/pod.go +++ b/pkg/commands/pod.go @@ -1,17 +1,30 @@ package commands import ( + "time" + + "github.com/sasha-s/go-deadlock" "github.com/sirupsen/logrus" ) // Pod represents a Podman pod with its containers. type Pod struct { - ID string - Name string - Summary PodSummary - Containers []*Container // Non-infra containers in this pod - OSCommand *OSCommand - Log *logrus.Entry + ID string + Name string + Summary PodSummary + Containers []*Container // Non-infra containers in this pod + OSCommand *OSCommand + Log *logrus.Entry + Runtime ContainerRuntime + StatHistory []*RecordedPodStats + MonitoringStats bool + StatsMutex deadlock.Mutex +} + +// RecordedPodStats contains the pod stats we've received from Podman. +type RecordedPodStats struct { + Stats PodStatsEntry + RecordedAt time.Time } // State returns the pod state. @@ -34,3 +47,40 @@ func (p *Pod) GetRunningContainerCount() int { } return count } + +// appendStats adds a new stats entry to the pod's history. +func (p *Pod) appendStats(stats *RecordedPodStats, maxDuration time.Duration) { + p.StatsMutex.Lock() + defer p.StatsMutex.Unlock() + + p.StatHistory = append(p.StatHistory, stats) + p.eraseOldHistory(maxDuration) +} + +// eraseOldHistory removes any history before the user-specified max duration. +func (p *Pod) eraseOldHistory(maxDuration time.Duration) { + if maxDuration == 0 { + return + } + + for i, stat := range p.StatHistory { + if time.Since(stat.RecordedAt) < maxDuration { + p.StatHistory = p.StatHistory[i:] + return + } + } + + // All entries are older than maxDuration, clear the history + p.StatHistory = nil +} + +// GetLastStats returns the most recent stats entry. +func (p *Pod) GetLastStats() (*RecordedPodStats, bool) { + p.StatsMutex.Lock() + defer p.StatsMutex.Unlock() + history := p.StatHistory + if len(history) == 0 { + return nil, false + } + return history[len(history)-1], true +} diff --git a/pkg/commands/podman.go b/pkg/commands/podman.go index 57a3ab03..dbe2544f 100644 --- a/pkg/commands/podman.go +++ b/pkg/commands/podman.go @@ -348,6 +348,35 @@ func calculateMemoryPercentageFromEntry(stats ContainerStatsEntry) float64 { return 0.0 } +// CreatePodStatMonitor starts monitoring stats for a pod +func (c *PodmanCommand) CreatePodStatMonitor(pod *Pod) { + pod.MonitoringStats = true + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + statsChan, errChan := c.Runtime.PodStats(ctx, pod.ID, true) + + go func() { + for err := range errChan { + if err != nil { + c.Log.Error(err) + } + } + }() + + for stats := range statsChan { + recordedStats := &RecordedPodStats{ + Stats: stats, + RecordedAt: time.Now(), + } + + pod.appendStats(recordedStats, c.Config.UserConfig.Stats.MaxDuration) + } + + pod.MonitoringStats = false +} + func (c *PodmanCommand) RefreshContainersAndServices(currentServices []*Service, currentItems []*ContainerListItem) ([]*ContainerListItem, []*Service, error) { c.ServiceMutex.Lock() defer c.ServiceMutex.Unlock() @@ -455,6 +484,7 @@ func (c *PodmanCommand) buildContainerListItems(containers []*Container, podSumm Containers: podCtrs, OSCommand: c.OSCommand, Log: c.Log, + Runtime: c.Runtime, } // Add pod item diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 84e73019..0b88a9c0 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -39,6 +39,7 @@ type ContainerRuntime interface { // Pod operations ListPods(ctx context.Context) ([]PodSummary, error) + PodStats(ctx context.Context, id string, stream bool) (<-chan PodStatsEntry, <-chan error) // Events streams container/image/volume/network events Events(ctx context.Context) (<-chan Event, <-chan error) diff --git a/pkg/commands/runtime_libpod.go b/pkg/commands/runtime_libpod.go index dffd7ead..7aed0c34 100644 --- a/pkg/commands/runtime_libpod.go +++ b/pkg/commands/runtime_libpod.go @@ -336,6 +336,106 @@ func (r *LibpodRuntime) ListPods(ctx context.Context) ([]PodSummary, error) { return result, nil } +// PodStats streams pod statistics by aggregating stats from all containers in the pod. +func (r *LibpodRuntime) PodStats(ctx context.Context, id string, stream bool) (<-chan PodStatsEntry, <-chan error) { + statsChan := make(chan PodStatsEntry) + errChan := make(chan error, 1) + + go func() { + defer close(statsChan) + defer close(errChan) + + pod, err := r.runtime.LookupPod(id) + if err != nil { + errChan <- err + return + } + + for { + entry, err := r.aggregatePodContainerStats(ctx, pod) + if err != nil { + errChan <- err + return + } + + select { + case statsChan <- entry: + case <-ctx.Done(): + return + } + + if !stream { + return + } + time.Sleep(time.Second) + } + }() + + return statsChan, errChan +} + +// aggregatePodContainerStats collects and aggregates stats from all containers in a pod. +func (r *LibpodRuntime) aggregatePodContainerStats(ctx context.Context, pod *libpod.Pod) (PodStatsEntry, error) { + ctrs, err := pod.AllContainers() + if err != nil { + return PodStatsEntry{}, err + } + + var entry PodStatsEntry + entry.PodID = pod.ID() + entry.PodName = pod.Name() + + var totalMemUsage, totalMemLimit uint64 + var totalNetIn, totalNetOut uint64 + var totalBlockIn, totalBlockOut uint64 + var totalPIDs uint64 + var totalCPU float64 + + for _, ctr := range ctrs { + // Skip infra containers + if ctr.IsInfra() { + continue + } + + state, err := ctr.State() + if err != nil || state != define.ContainerStateRunning { + continue + } + + stats, err := ctr.GetContainerStats(nil) + if err != nil { + continue // Skip containers that fail to get stats + } + + totalCPU += stats.CPU + totalMemUsage += stats.MemUsage + totalMemLimit += stats.MemLimit + totalPIDs += stats.PIDs + + // Aggregate network stats + totalNetIn += stats.NetInput + totalNetOut += stats.NetOutput + + // Aggregate block I/O stats + totalBlockIn += stats.BlockInput + totalBlockOut += stats.BlockOutput + } + + entry.CPU = totalCPU + entry.MemUsage = totalMemUsage + entry.MemLimit = totalMemLimit + if totalMemLimit > 0 { + entry.Memory = float64(totalMemUsage) / float64(totalMemLimit) * 100 + } + entry.NetInput = totalNetIn + entry.NetOutput = totalNetOut + entry.BlockInput = totalBlockIn + entry.BlockOutput = totalBlockOut + entry.PIDs = totalPIDs + + return entry, nil +} + // Events streams container runtime events. // For libpod, we use a polling approach since direct event streaming requires more setup. func (r *LibpodRuntime) Events(ctx context.Context) (<-chan Event, <-chan error) { diff --git a/pkg/commands/runtime_libpod_stub.go b/pkg/commands/runtime_libpod_stub.go index d0e6ddbb..3e242382 100644 --- a/pkg/commands/runtime_libpod_stub.go +++ b/pkg/commands/runtime_libpod_stub.go @@ -72,10 +72,12 @@ func (r *LibpodRuntime) PruneContainers(ctx context.Context) error { } func (r *LibpodRuntime) ContainerStats(ctx context.Context, id string, stream bool) (<-chan ContainerStatsEntry, <-chan error) { + statsChan := make(chan ContainerStatsEntry) + close(statsChan) errChan := make(chan error, 1) errChan <- ErrLibpodNotAvailable close(errChan) - return nil, errChan + return statsChan, errChan } // Image operations - all return ErrLibpodNotAvailable @@ -134,10 +136,21 @@ func (r *LibpodRuntime) ListPods(ctx context.Context) ([]PodSummary, error) { return nil, ErrLibpodNotAvailable } -// Events returns an error channel on non-Linux platforms. -func (r *LibpodRuntime) Events(ctx context.Context) (<-chan Event, <-chan error) { +func (r *LibpodRuntime) PodStats(ctx context.Context, id string, stream bool) (<-chan PodStatsEntry, <-chan error) { + statsChan := make(chan PodStatsEntry) + close(statsChan) errChan := make(chan error, 1) errChan <- ErrLibpodNotAvailable close(errChan) - return nil, errChan + return statsChan, errChan +} + +// Events returns an error channel on non-Linux platforms. +func (r *LibpodRuntime) Events(ctx context.Context) (<-chan Event, <-chan error) { + eventsChan := make(chan Event) + close(eventsChan) + errChan := make(chan error, 1) + errChan <- ErrLibpodNotAvailable + close(errChan) + return eventsChan, errChan } diff --git a/pkg/commands/runtime_mock.go b/pkg/commands/runtime_mock.go index 16fb8b66..5a437ea1 100644 --- a/pkg/commands/runtime_mock.go +++ b/pkg/commands/runtime_mock.go @@ -41,6 +41,7 @@ type MockRuntime struct { // Pod operation mocks ListPodsFunc func(ctx context.Context) ([]PodSummary, error) + PodStatsFunc func(ctx context.Context, id string, stream bool) (<-chan PodStatsEntry, <-chan error) // Event mock EventsFunc func(ctx context.Context) (<-chan Event, <-chan error) @@ -154,10 +155,12 @@ func (m *MockRuntime) ContainerStats(ctx context.Context, id string, stream bool if m.ContainerStatsFunc != nil { return m.ContainerStatsFunc(ctx, id, stream) } + statsCh := make(chan ContainerStatsEntry) + close(statsCh) errCh := make(chan error, 1) errCh <- ErrMockNotImplemented close(errCh) - return nil, errCh + return statsCh, errCh } // Image operations @@ -264,6 +267,19 @@ func (m *MockRuntime) ListPods(ctx context.Context) ([]PodSummary, error) { return nil, ErrMockNotImplemented } +func (m *MockRuntime) PodStats(ctx context.Context, id string, stream bool) (<-chan PodStatsEntry, <-chan error) { + m.recordCall("PodStats", id, stream) + if m.PodStatsFunc != nil { + return m.PodStatsFunc(ctx, id, stream) + } + statsCh := make(chan PodStatsEntry) + close(statsCh) + errCh := make(chan error, 1) + errCh <- ErrMockNotImplemented + close(errCh) + return statsCh, errCh +} + // Events func (m *MockRuntime) Events(ctx context.Context) (<-chan Event, <-chan error) { @@ -271,10 +287,12 @@ func (m *MockRuntime) Events(ctx context.Context) (<-chan Event, <-chan error) { if m.EventsFunc != nil { return m.EventsFunc(ctx) } + eventsCh := make(chan Event) + close(eventsCh) errCh := make(chan error, 1) errCh <- ErrMockNotImplemented close(errCh) - return nil, errCh + return eventsCh, errCh } // Lifecycle diff --git a/pkg/commands/runtime_socket.go b/pkg/commands/runtime_socket.go index cf100aa8..5870ff0f 100644 --- a/pkg/commands/runtime_socket.go +++ b/pkg/commands/runtime_socket.go @@ -3,6 +3,7 @@ package commands import ( "context" "encoding/json" + "fmt" "strings" "time" @@ -297,6 +298,181 @@ func (r *SocketRuntime) ListPods(ctx context.Context) ([]PodSummary, error) { return result, nil } +// PodStats streams pod statistics. The Podman API returns stats for each container +// in the pod, so we aggregate them into a single PodStatsEntry. +func (r *SocketRuntime) PodStats(ctx context.Context, id string, stream bool) (<-chan PodStatsEntry, <-chan error) { + statsChan := make(chan PodStatsEntry) + errChan := make(chan error, 1) + + go func() { + defer close(statsChan) + defer close(errChan) + + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + for { + reports, err := pods.Stats(r.conn, []string{id}, nil) + if err != nil { + errChan <- err + return + } + + entry := aggregatePodStats(reports) + select { + case statsChan <- entry: + case <-ctx.Done(): + return + } + + if !stream { + return + } + + select { + case <-ticker.C: + case <-ctx.Done(): + return + } + } + }() + + return statsChan, errChan +} + +// aggregatePodStats combines stats from all containers in a pod into a single entry. +func aggregatePodStats(reports []*types.PodStatsReport) PodStatsEntry { + if len(reports) == 0 { + return PodStatsEntry{} + } + + var entry PodStatsEntry + var totalCPU, totalMem float64 + var totalMemUsage, totalMemLimit uint64 + var totalNetIn, totalNetOut uint64 + var totalBlockIn, totalBlockOut uint64 + var totalPIDs uint64 + + for _, r := range reports { + // Parse CPU percentage (e.g., "75.5%" -> 75.5) + cpu := parsePercentage(r.CPU) + totalCPU += cpu + + // Parse memory percentage + mem := parsePercentage(r.Mem) + totalMem += mem + + // Parse memory usage bytes (e.g., "1000000 / 4000000") + memUsage, memLimit := parseMemoryBytes(r.MemUsageBytes) + totalMemUsage += memUsage + totalMemLimit += memLimit + + // Parse network I/O (e.g., "1.5kB / 2.3kB") + netIn, netOut := parseIOBytes(r.NetIO) + totalNetIn += netIn + totalNetOut += netOut + + // Parse block I/O + blockIn, blockOut := parseIOBytes(r.BlockIO) + totalBlockIn += blockIn + totalBlockOut += blockOut + + // Parse PIDs + pids := parseUint(r.PIDS) + totalPIDs += pids + + // Use the first report's pod info + if entry.PodID == "" { + entry.PodID = r.Pod + entry.PodName = r.Name + } + } + + entry.CPU = totalCPU + entry.Memory = totalMem + entry.MemUsage = totalMemUsage + entry.MemLimit = totalMemLimit + entry.NetInput = totalNetIn + entry.NetOutput = totalNetOut + entry.BlockInput = totalBlockIn + entry.BlockOutput = totalBlockOut + entry.PIDs = totalPIDs + + return entry +} + +// parsePercentage parses a percentage string like "75.5%" into a float64. +func parsePercentage(s string) float64 { + s = strings.TrimSuffix(strings.TrimSpace(s), "%") + var val float64 + fmt.Sscanf(s, "%f", &val) + return val +} + +// parseMemoryBytes parses memory usage string like "1000000 / 4000000" into usage and limit. +func parseMemoryBytes(s string) (usage, limit uint64) { + parts := strings.Split(s, "/") + if len(parts) != 2 { + return 0, 0 + } + usage = parseByteValue(strings.TrimSpace(parts[0])) + limit = parseByteValue(strings.TrimSpace(parts[1])) + return +} + +// parseIOBytes parses I/O string like "1.5kB / 2.3kB" into input and output bytes. +func parseIOBytes(s string) (input, output uint64) { + parts := strings.Split(s, "/") + if len(parts) != 2 { + return 0, 0 + } + input = parseByteValue(strings.TrimSpace(parts[0])) + output = parseByteValue(strings.TrimSpace(parts[1])) + return +} + +// parseByteValue parses a byte value string with optional unit (e.g., "1.5kB", "10MB", "1000000"). +func parseByteValue(s string) uint64 { + s = strings.TrimSpace(s) + if s == "" || s == "--" { + return 0 + } + + // Remove commas from numbers + s = strings.ReplaceAll(s, ",", "") + + var val float64 + var unit string + n, _ := fmt.Sscanf(s, "%f%s", &val, &unit) + if n == 0 { + return 0 + } + + unit = strings.ToLower(strings.TrimSpace(unit)) + switch unit { + case "b", "": + return uint64(val) + case "kb", "kib": + return uint64(val * 1024) + case "mb", "mib": + return uint64(val * 1024 * 1024) + case "gb", "gib": + return uint64(val * 1024 * 1024 * 1024) + case "tb", "tib": + return uint64(val * 1024 * 1024 * 1024 * 1024) + default: + return uint64(val) + } +} + +// parseUint parses a string to uint64. +func parseUint(s string) uint64 { + s = strings.TrimSpace(s) + var val uint64 + fmt.Sscanf(s, "%d", &val) + return val +} + // Events streams container runtime events. func (r *SocketRuntime) Events(ctx context.Context) (<-chan Event, <-chan error) { eventChan := make(chan Event) diff --git a/pkg/commands/runtime_test.go b/pkg/commands/runtime_test.go index 46dca711..18b0730c 100644 --- a/pkg/commands/runtime_test.go +++ b/pkg/commands/runtime_test.go @@ -196,7 +196,11 @@ func TestMockRuntimeContainerStats(t *testing.T) { t.Run("returns error channel when not implemented", func(t *testing.T) { statsChan, errChan := mock.ContainerStats(ctx, "container1", false) - assert.Nil(t, statsChan) + assert.NotNil(t, statsChan) + + // Stats channel should be closed (empty) + _, ok := <-statsChan + assert.False(t, ok, "stats channel should be closed") err := <-errChan assert.Equal(t, ErrMockNotImplemented, err) @@ -392,7 +396,11 @@ func TestMockRuntimeEvents(t *testing.T) { t.Run("returns error when not implemented", func(t *testing.T) { eventChan, errChan := mock.Events(ctx) - assert.Nil(t, eventChan) + assert.NotNil(t, eventChan) + + // Events channel should be closed (empty) + _, ok := <-eventChan + assert.False(t, ok, "events channel should be closed") err := <-errChan assert.Equal(t, ErrMockNotImplemented, err) diff --git a/pkg/commands/runtime_types.go b/pkg/commands/runtime_types.go index e0167fbb..9370b5cb 100644 --- a/pkg/commands/runtime_types.go +++ b/pkg/commands/runtime_types.go @@ -435,3 +435,19 @@ type PodSummary struct { InfraID string Labels map[string]string } + +// PodStatsEntry provides runtime-agnostic pod stats. +// This aggregates stats from all containers in a pod. +type PodStatsEntry struct { + CPU float64 // CPU percentage + Memory float64 // Memory percentage + MemUsage uint64 // Memory usage in bytes + MemLimit uint64 // Memory limit in bytes + NetInput uint64 // Network bytes received + NetOutput uint64 // Network bytes sent + BlockInput uint64 // Block I/O read bytes + BlockOutput uint64 // Block I/O write bytes + PIDs uint64 // Total PIDs + PodID string + PodName string +} diff --git a/pkg/gui/containers_panel.go b/pkg/gui/containers_panel.go index 00afbc63..051ab004 100644 --- a/pkg/gui/containers_panel.go +++ b/pkg/gui/containers_panel.go @@ -205,10 +205,7 @@ func (gui *Gui) renderContainerListItemLogs(item *commands.ContainerListItem) ta func (gui *Gui) renderContainerListItemStats(item *commands.ContainerListItem) tasks.TaskFunc { if item.IsPod { - return gui.NewSimpleRenderStringTask(func() string { - return fmt.Sprintf("Pod: %s\nStatus: %s\nContainers: %d\n\nStats not available for pods. Select a container to view stats.", - item.Pod.Name, item.Pod.State(), len(item.Pod.Containers)) - }) + return gui.renderPodStats(item.Pod) } return gui.renderContainerStats(item.Container) } @@ -373,6 +370,28 @@ func (gui *Gui) renderContainerStats(container *commands.Container) tasks.TaskFu }) } +func (gui *Gui) renderPodStats(pod *commands.Pod) tasks.TaskFunc { + return gui.NewTickerTask(TickerTaskOpts{ + Func: func(ctx context.Context, notifyStopped chan struct{}) { + // Start monitoring if not already + if !pod.MonitoringStats { + go gui.PodmanCommand.CreatePodStatMonitor(pod) + } + + contents, err := presentation.RenderPodStats(pod, gui.Views.Main.Width()) + if err != nil { + _ = gui.createErrorPanel(err.Error()) + } + + gui.reRenderStringMain(contents) + }, + Duration: time.Second, + Before: func(ctx context.Context) { gui.clearMainView() }, + Wrap: false, + Autoscroll: false, + }) +} + func (gui *Gui) renderContainerTop(container *commands.Container) tasks.TaskFunc { return gui.NewTickerTask(TickerTaskOpts{ Func: func(ctx context.Context, notifyStopped chan struct{}) { diff --git a/pkg/gui/presentation/container_stats.go b/pkg/gui/presentation/container_stats.go index 74b0658c..1e54c835 100644 --- a/pkg/gui/presentation/container_stats.go +++ b/pkg/gui/presentation/container_stats.go @@ -105,6 +105,102 @@ func plotGraph(container *commands.Container, spec config.GraphConfig, width int ), nil } +// RenderPodStats renders pod statistics as a string +func RenderPodStats(pod *commands.Pod, viewWidth int) (string, error) { + stats, ok := pod.GetLastStats() + if !ok { + return "Collecting pod stats...", nil + } + + s := stats.Stats + + // Format stats display + var output strings.Builder + + output.WriteString(utils.ColoredString("\nPod Statistics\n\n", color.FgCyan)) + + // CPU usage + output.WriteString(fmt.Sprintf("CPU Usage: %.2f%%\n", s.CPU)) + + // Memory usage + memUsed := utils.FormatBinaryBytes(int(s.MemUsage)) + memLimit := utils.FormatBinaryBytes(int(s.MemLimit)) + output.WriteString(fmt.Sprintf("Memory Usage: %s / %s (%.2f%%)\n", memUsed, memLimit, s.Memory)) + + // Network I/O + netIn := utils.FormatDecimalBytes(int(s.NetInput)) + netOut := utils.FormatDecimalBytes(int(s.NetOutput)) + output.WriteString(fmt.Sprintf("Network I/O: %s / %s\n", netIn, netOut)) + + // Block I/O + blockIn := utils.FormatDecimalBytes(int(s.BlockInput)) + blockOut := utils.FormatDecimalBytes(int(s.BlockOutput)) + output.WriteString(fmt.Sprintf("Block I/O: %s / %s\n", blockIn, blockOut)) + + // PIDs + output.WriteString(fmt.Sprintf("PIDs: %d\n", s.PIDs)) + + // Render graphs if there's history + pod.StatsMutex.Lock() + historyLen := len(pod.StatHistory) + pod.StatsMutex.Unlock() + + if historyLen > 1 { + output.WriteString("\n") + + // CPU graph + cpuGraph := plotPodGraph(pod, "Stats.CPU", "CPU %", viewWidth-10, 0, 100) + output.WriteString(utils.ColoredString(cpuGraph, color.FgGreen)) + output.WriteString("\n\n") + + // Memory graph + memGraph := plotPodGraph(pod, "Stats.Memory", "Memory %", viewWidth-10, 0, 100) + output.WriteString(utils.ColoredString(memGraph, color.FgBlue)) + } + + return output.String(), nil +} + +// plotPodGraph plots a graph for pod statistics +func plotPodGraph(pod *commands.Pod, statPath, caption string, width int, min, max float64) string { + pod.StatsMutex.Lock() + defer pod.StatsMutex.Unlock() + + if len(pod.StatHistory) == 0 { + return "" + } + + data := make([]float64, len(pod.StatHistory)) + + for i, stats := range pod.StatHistory { + value, err := lookup.LookupString(stats, statPath) + if err != nil { + continue + } + floatValue, err := getFloat(value.Interface()) + if err != nil { + continue + } + data[i] = floatValue + } + + captionStr := fmt.Sprintf( + "%s: %0.2f (%v)", + caption, + data[len(data)-1], + time.Since(pod.StatHistory[0].RecordedAt).Round(time.Second), + ) + + return asciigraph.Plot( + data, + asciigraph.Height(10), + asciigraph.Width(width), + asciigraph.Min(min), + asciigraph.Max(max), + asciigraph.Caption(captionStr), + ) +} + // from Dave C's answer at https://stackoverflow.com/questions/20767724/converting-unknown-interface-to-float64-in-golang func getFloat(unk interface{}) (float64, error) { floatType := reflect.TypeOf(float64(0))