mirror of
https://github.com/jesseduffield/lazydocker.git
synced 2026-07-25 08:31:03 +00:00
Merge pull request #5 from christophe-duc/feature/add_pod_stats
added pod stats feature
This commit is contained in:
commit
48c9212c0d
12 changed files with 548 additions and 18 deletions
|
|
@ -175,6 +175,9 @@ func (c *Container) eraseOldHistory(maxDuration time.Duration) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All entries are older than maxDuration, clear the history
|
||||||
|
c.StatHistory = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Container) GetLastStats() (*RecordedStats, bool) {
|
func (c *Container) GetLastStats() (*RecordedStats, bool) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package commands
|
package commands
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sasha-s/go-deadlock"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -12,6 +15,16 @@ type Pod struct {
|
||||||
Containers []*Container // Non-infra containers in this pod
|
Containers []*Container // Non-infra containers in this pod
|
||||||
OSCommand *OSCommand
|
OSCommand *OSCommand
|
||||||
Log *logrus.Entry
|
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.
|
// State returns the pod state.
|
||||||
|
|
@ -34,3 +47,40 @@ func (p *Pod) GetRunningContainerCount() int {
|
||||||
}
|
}
|
||||||
return count
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -348,6 +348,35 @@ func calculateMemoryPercentageFromEntry(stats ContainerStatsEntry) float64 {
|
||||||
return 0.0
|
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) {
|
func (c *PodmanCommand) RefreshContainersAndServices(currentServices []*Service, currentItems []*ContainerListItem) ([]*ContainerListItem, []*Service, error) {
|
||||||
c.ServiceMutex.Lock()
|
c.ServiceMutex.Lock()
|
||||||
defer c.ServiceMutex.Unlock()
|
defer c.ServiceMutex.Unlock()
|
||||||
|
|
@ -455,6 +484,7 @@ func (c *PodmanCommand) buildContainerListItems(containers []*Container, podSumm
|
||||||
Containers: podCtrs,
|
Containers: podCtrs,
|
||||||
OSCommand: c.OSCommand,
|
OSCommand: c.OSCommand,
|
||||||
Log: c.Log,
|
Log: c.Log,
|
||||||
|
Runtime: c.Runtime,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add pod item
|
// Add pod item
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ type ContainerRuntime interface {
|
||||||
|
|
||||||
// Pod operations
|
// Pod operations
|
||||||
ListPods(ctx context.Context) ([]PodSummary, error)
|
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 streams container/image/volume/network events
|
||||||
Events(ctx context.Context) (<-chan Event, <-chan error)
|
Events(ctx context.Context) (<-chan Event, <-chan error)
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,106 @@ func (r *LibpodRuntime) ListPods(ctx context.Context) ([]PodSummary, error) {
|
||||||
return result, nil
|
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.
|
// Events streams container runtime events.
|
||||||
// For libpod, we use a polling approach since direct event streaming requires more setup.
|
// 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) {
|
func (r *LibpodRuntime) Events(ctx context.Context) (<-chan Event, <-chan error) {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
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 := make(chan error, 1)
|
||||||
errChan <- ErrLibpodNotAvailable
|
errChan <- ErrLibpodNotAvailable
|
||||||
close(errChan)
|
close(errChan)
|
||||||
return nil, errChan
|
return statsChan, errChan
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image operations - all return ErrLibpodNotAvailable
|
// Image operations - all return ErrLibpodNotAvailable
|
||||||
|
|
@ -134,10 +136,21 @@ func (r *LibpodRuntime) ListPods(ctx context.Context) ([]PodSummary, error) {
|
||||||
return nil, ErrLibpodNotAvailable
|
return nil, ErrLibpodNotAvailable
|
||||||
}
|
}
|
||||||
|
|
||||||
// Events returns an error channel on non-Linux platforms.
|
func (r *LibpodRuntime) PodStats(ctx context.Context, id string, stream bool) (<-chan PodStatsEntry, <-chan error) {
|
||||||
func (r *LibpodRuntime) Events(ctx context.Context) (<-chan Event, <-chan error) {
|
statsChan := make(chan PodStatsEntry)
|
||||||
|
close(statsChan)
|
||||||
errChan := make(chan error, 1)
|
errChan := make(chan error, 1)
|
||||||
errChan <- ErrLibpodNotAvailable
|
errChan <- ErrLibpodNotAvailable
|
||||||
close(errChan)
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ type MockRuntime struct {
|
||||||
|
|
||||||
// Pod operation mocks
|
// Pod operation mocks
|
||||||
ListPodsFunc func(ctx context.Context) ([]PodSummary, error)
|
ListPodsFunc func(ctx context.Context) ([]PodSummary, error)
|
||||||
|
PodStatsFunc func(ctx context.Context, id string, stream bool) (<-chan PodStatsEntry, <-chan error)
|
||||||
|
|
||||||
// Event mock
|
// Event mock
|
||||||
EventsFunc func(ctx context.Context) (<-chan Event, <-chan error)
|
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 {
|
if m.ContainerStatsFunc != nil {
|
||||||
return m.ContainerStatsFunc(ctx, id, stream)
|
return m.ContainerStatsFunc(ctx, id, stream)
|
||||||
}
|
}
|
||||||
|
statsCh := make(chan ContainerStatsEntry)
|
||||||
|
close(statsCh)
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
errCh <- ErrMockNotImplemented
|
errCh <- ErrMockNotImplemented
|
||||||
close(errCh)
|
close(errCh)
|
||||||
return nil, errCh
|
return statsCh, errCh
|
||||||
}
|
}
|
||||||
|
|
||||||
// Image operations
|
// Image operations
|
||||||
|
|
@ -264,6 +267,19 @@ func (m *MockRuntime) ListPods(ctx context.Context) ([]PodSummary, error) {
|
||||||
return nil, ErrMockNotImplemented
|
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
|
// Events
|
||||||
|
|
||||||
func (m *MockRuntime) Events(ctx context.Context) (<-chan Event, <-chan error) {
|
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 {
|
if m.EventsFunc != nil {
|
||||||
return m.EventsFunc(ctx)
|
return m.EventsFunc(ctx)
|
||||||
}
|
}
|
||||||
|
eventsCh := make(chan Event)
|
||||||
|
close(eventsCh)
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
errCh <- ErrMockNotImplemented
|
errCh <- ErrMockNotImplemented
|
||||||
close(errCh)
|
close(errCh)
|
||||||
return nil, errCh
|
return eventsCh, errCh
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package commands
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -297,6 +298,181 @@ func (r *SocketRuntime) ListPods(ctx context.Context) ([]PodSummary, error) {
|
||||||
return result, nil
|
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.
|
// Events streams container runtime events.
|
||||||
func (r *SocketRuntime) Events(ctx context.Context) (<-chan Event, <-chan error) {
|
func (r *SocketRuntime) Events(ctx context.Context) (<-chan Event, <-chan error) {
|
||||||
eventChan := make(chan Event)
|
eventChan := make(chan Event)
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,11 @@ func TestMockRuntimeContainerStats(t *testing.T) {
|
||||||
|
|
||||||
t.Run("returns error channel when not implemented", func(t *testing.T) {
|
t.Run("returns error channel when not implemented", func(t *testing.T) {
|
||||||
statsChan, errChan := mock.ContainerStats(ctx, "container1", false)
|
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
|
err := <-errChan
|
||||||
assert.Equal(t, ErrMockNotImplemented, err)
|
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) {
|
t.Run("returns error when not implemented", func(t *testing.T) {
|
||||||
eventChan, errChan := mock.Events(ctx)
|
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
|
err := <-errChan
|
||||||
assert.Equal(t, ErrMockNotImplemented, err)
|
assert.Equal(t, ErrMockNotImplemented, err)
|
||||||
|
|
|
||||||
|
|
@ -435,3 +435,19 @@ type PodSummary struct {
|
||||||
InfraID string
|
InfraID string
|
||||||
Labels map[string]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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -205,10 +205,7 @@ func (gui *Gui) renderContainerListItemLogs(item *commands.ContainerListItem) ta
|
||||||
|
|
||||||
func (gui *Gui) renderContainerListItemStats(item *commands.ContainerListItem) tasks.TaskFunc {
|
func (gui *Gui) renderContainerListItemStats(item *commands.ContainerListItem) tasks.TaskFunc {
|
||||||
if item.IsPod {
|
if item.IsPod {
|
||||||
return gui.NewSimpleRenderStringTask(func() string {
|
return gui.renderPodStats(item.Pod)
|
||||||
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.renderContainerStats(item.Container)
|
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 {
|
func (gui *Gui) renderContainerTop(container *commands.Container) tasks.TaskFunc {
|
||||||
return gui.NewTickerTask(TickerTaskOpts{
|
return gui.NewTickerTask(TickerTaskOpts{
|
||||||
Func: func(ctx context.Context, notifyStopped chan struct{}) {
|
Func: func(ctx context.Context, notifyStopped chan struct{}) {
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,102 @@ func plotGraph(container *commands.Container, spec config.GraphConfig, width int
|
||||||
), nil
|
), 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
|
// from Dave C's answer at https://stackoverflow.com/questions/20767724/converting-unknown-interface-to-float64-in-golang
|
||||||
func getFloat(unk interface{}) (float64, error) {
|
func getFloat(unk interface{}) (float64, error) {
|
||||||
floatType := reflect.TypeOf(float64(0))
|
floatType := reflect.TypeOf(float64(0))
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue