Merge pull request #5 from christophe-duc/feature/add_pod_stats

added pod stats feature
This commit is contained in:
Christophe 2026-01-07 20:28:41 -04:00 committed by GitHub
commit 48c9212c0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 548 additions and 18 deletions

View file

@ -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) {

View file

@ -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
}

View file

@ -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

View file

@ -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)

View file

@ -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) {

View file

@ -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
}

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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
}

View file

@ -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{}) {

View file

@ -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))