feature: add pod support in the container pane

This commit is contained in:
christophe-duc 2026-01-07 17:11:26 -04:00
parent 71c53a5c5e
commit e5c209de93
16 changed files with 1460 additions and 622 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,66 @@
package commands
// ContainerListItem represents either a pod or a container in the unified list view.
// This allows the container panel to display both pods and containers.
type ContainerListItem struct {
IsPod bool
Pod *Pod // Set if IsPod is true
Container *Container // Set if IsPod is false
Indent int // 0 for pods/standalone containers, 2 for containers in pods
}
// ID returns the unique ID for the item.
func (c *ContainerListItem) ID() string {
if c.IsPod {
return c.Pod.ID
}
return c.Container.ID
}
// Name returns the display name for the item.
func (c *ContainerListItem) Name() string {
if c.IsPod {
return c.Pod.Name
}
return c.Container.Name
}
// State returns the state for the item.
func (c *ContainerListItem) State() string {
if c.IsPod {
return c.Pod.State()
}
return c.Container.Summary.State
}
// GetContainers returns the containers if this is a pod, nil otherwise.
func (c *ContainerListItem) GetContainers() []*Container {
if c.IsPod {
return c.Pod.Containers
}
return nil
}
// IsInPod returns true if this is a container that belongs to a pod.
func (c *ContainerListItem) IsInPod() bool {
if c.IsPod {
return false
}
return c.Container.Summary.Pod != ""
}
// PodID returns the pod ID if this container is in a pod, empty string otherwise.
func (c *ContainerListItem) PodID() string {
if c.IsPod {
return c.Pod.ID
}
return c.Container.Summary.Pod
}
// PodName returns the pod name if this container is in a pod, empty string otherwise.
func (c *ContainerListItem) PodName() string {
if c.IsPod {
return c.Pod.Name
}
return c.Container.Summary.PodName
}

36
pkg/commands/pod.go Normal file
View file

@ -0,0 +1,36 @@
package commands
import (
"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
}
// State returns the pod state.
func (p *Pod) State() string {
return p.Summary.Status
}
// HasContainers returns true if the pod has non-infra containers.
func (p *Pod) HasContainers() bool {
return len(p.Containers) > 0
}
// GetRunningContainerCount returns the number of running containers in the pod.
func (p *Pod) GetRunningContainerCount() int {
count := 0
for _, c := range p.Containers {
if c.Summary.State == "running" {
count++
}
}
return count
}

View file

@ -347,15 +347,35 @@ func calculateMemoryPercentageFromEntry(stats ContainerStatsEntry) float64 {
return 0.0 return 0.0
} }
func (c *PodmanCommand) RefreshContainersAndServices(currentServices []*Service, currentContainers []*Container) ([]*Container, []*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()
// Extract existing containers from current items
var currentContainers []*Container
for _, item := range currentItems {
if !item.IsPod && item.Container != nil {
currentContainers = append(currentContainers, item.Container)
}
}
containers, err := c.GetContainers(currentContainers) containers, err := c.GetContainers(currentContainers)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
// Get pods
ctx := context.Background()
podSummaries, err := c.Runtime.ListPods(ctx)
if err != nil {
// Don't fail if pods can't be listed, just log and continue without pods
c.Log.Warnf("Failed to list pods: %v", err)
podSummaries = nil
}
// Build the unified list
items := c.buildContainerListItems(containers, podSummaries)
var services []*Service var services []*Service
// we only need to get these services once because they won't change in the runtime of the program // we only need to get these services once because they won't change in the runtime of the program
if currentServices != nil { if currentServices != nil {
@ -367,9 +387,88 @@ func (c *PodmanCommand) RefreshContainersAndServices(currentServices []*Service,
} }
} }
c.assignContainersToServices(containers, services) // Extract just containers for service assignment
var containersForServices []*Container
for _, item := range items {
if !item.IsPod && item.Container != nil {
containersForServices = append(containersForServices, item.Container)
}
}
c.assignContainersToServices(containersForServices, services)
return containers, services, nil return items, services, nil
}
// buildContainerListItems creates a unified list of pods and containers
func (c *PodmanCommand) buildContainerListItems(containers []*Container, podSummaries []PodSummary) []*ContainerListItem {
var items []*ContainerListItem
// Create a map of pod ID -> pod summary
podMap := make(map[string]PodSummary)
for _, ps := range podSummaries {
podMap[ps.ID] = ps
}
// Create a map of pod ID -> containers in that pod
podContainers := make(map[string][]*Container)
var standaloneContainers []*Container
for _, ctr := range containers {
// Skip infra containers
if ctr.Summary.IsInfra {
continue
}
if ctr.Summary.Pod != "" {
podContainers[ctr.Summary.Pod] = append(podContainers[ctr.Summary.Pod], ctr)
} else {
standaloneContainers = append(standaloneContainers, ctr)
}
}
// Add pods and their containers
for podID, ps := range podMap {
// Create pod object
pod := &Pod{
ID: ps.ID,
Name: ps.Name,
Summary: ps,
Containers: podContainers[podID],
OSCommand: c.OSCommand,
Log: c.Log,
}
// Add pod item
items = append(items, &ContainerListItem{
IsPod: true,
Pod: pod,
Indent: 0,
})
// Add containers in this pod with indent
for _, ctr := range podContainers[podID] {
// Set pod name on container if not already set
if ctr.Summary.PodName == "" {
ctr.Summary.PodName = ps.Name
}
items = append(items, &ContainerListItem{
IsPod: false,
Container: ctr,
Indent: 2,
})
}
}
// Add standalone containers
for _, ctr := range standaloneContainers {
items = append(items, &ContainerListItem{
IsPod: false,
Container: ctr,
Indent: 0,
})
}
return items
} }
func (c *PodmanCommand) assignContainersToServices(containers []*Container, services []*Service) { func (c *PodmanCommand) assignContainersToServices(containers []*Container, services []*Service) {

View file

@ -37,6 +37,9 @@ type ContainerRuntime interface {
RemoveNetwork(ctx context.Context, name string) error RemoveNetwork(ctx context.Context, name string) error
PruneNetworks(ctx context.Context) error PruneNetworks(ctx context.Context) error
// Pod operations
ListPods(ctx context.Context) ([]PodSummary, 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)

View file

@ -146,7 +146,7 @@ func (r *LibpodRuntime) ContainerTop(ctx context.Context, id string) ([]string,
// Parse the result: first line is headers, rest are process rows // Parse the result: first line is headers, rest are process rows
// Each line is space-separated fields // Each line is space-separated fields
headers := splitFields(result[0]) headers := splitFields(result[0])
var processes [][]string processes := make([][]string, 0, len(result)-1)
for _, line := range result[1:] { for _, line := range result[1:] {
processes = append(processes, splitFields(line)) processes = append(processes, splitFields(line))
} }
@ -309,6 +309,37 @@ func (r *LibpodRuntime) PruneNetworks(ctx context.Context) error {
return nil return nil
} }
// Pod operations
// ListPods returns all pods.
func (r *LibpodRuntime) ListPods(ctx context.Context) ([]PodSummary, error) {
pods, err := r.runtime.GetAllPods()
if err != nil {
return nil, err
}
result := make([]PodSummary, len(pods))
for i, pod := range pods {
status, err := pod.GetPodStatus()
if err != nil {
status = "unknown"
}
config := pod.Config()
infraID := ""
if config != nil {
infraID = config.InfraContainerID
}
result[i] = PodSummary{
ID: pod.ID(),
Name: pod.Name(),
Status: status,
Created: pod.CreatedTime(),
InfraID: infraID,
Labels: pod.Labels(),
}
}
return result, 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) {
@ -361,6 +392,14 @@ func convertLibpodContainerList(ctrs []*libpod.Container) ([]ContainerSummary, e
if len(config.Command) > 0 { if len(config.Command) > 0 {
command = config.Command[0] command = config.Command[0]
} }
// Get pod name if container is in a pod
podName := ""
if podID := ctr.PodID(); podID != "" {
// Try to get pod name from config labels or leave empty
if pn, ok := config.Labels["io.kubernetes.pod.name"]; ok {
podName = pn
}
}
result[i] = ContainerSummary{ result[i] = ContainerSummary{
ID: ctr.ID(), ID: ctr.ID(),
Names: []string{ctr.Name()}, Names: []string{ctr.Name()},
@ -372,6 +411,8 @@ func convertLibpodContainerList(ctrs []*libpod.Container) ([]ContainerSummary, e
Status: state.String(), Status: state.String(),
Labels: config.Labels, Labels: config.Labels,
Pod: ctr.PodID(), Pod: ctr.PodID(),
PodName: podName,
IsInfra: ctr.IsInfra(),
} }
} }
return result, nil return result, nil

View file

@ -128,6 +128,12 @@ func (r *LibpodRuntime) PruneNetworks(ctx context.Context) error {
return ErrLibpodNotAvailable return ErrLibpodNotAvailable
} }
// Pod operations - all return ErrLibpodNotAvailable
func (r *LibpodRuntime) ListPods(ctx context.Context) ([]PodSummary, error) {
return nil, ErrLibpodNotAvailable
}
// Events returns an error channel on non-Linux platforms. // Events returns an error channel on non-Linux platforms.
func (r *LibpodRuntime) Events(ctx context.Context) (<-chan Event, <-chan error) { func (r *LibpodRuntime) Events(ctx context.Context) (<-chan Event, <-chan error) {
errChan := make(chan error, 1) errChan := make(chan error, 1)

View file

@ -39,6 +39,9 @@ type MockRuntime struct {
RemoveNetworkFunc func(ctx context.Context, name string) error RemoveNetworkFunc func(ctx context.Context, name string) error
PruneNetworksFunc func(ctx context.Context) error PruneNetworksFunc func(ctx context.Context) error
// Pod operation mocks
ListPodsFunc func(ctx context.Context) ([]PodSummary, error)
// Event mock // Event mock
EventsFunc func(ctx context.Context) (<-chan Event, <-chan error) EventsFunc func(ctx context.Context) (<-chan Event, <-chan error)
@ -251,6 +254,16 @@ func (m *MockRuntime) PruneNetworks(ctx context.Context) error {
return ErrMockNotImplemented return ErrMockNotImplemented
} }
// Pod operations
func (m *MockRuntime) ListPods(ctx context.Context) ([]PodSummary, error) {
m.recordCall("ListPods")
if m.ListPodsFunc != nil {
return m.ListPodsFunc(ctx)
}
return nil, ErrMockNotImplemented
}
// Events // Events
func (m *MockRuntime) Events(ctx context.Context) (<-chan Event, <-chan error) { func (m *MockRuntime) Events(ctx context.Context) (<-chan Event, <-chan error) {

View file

@ -3,6 +3,7 @@ package commands
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"os/exec"
"strings" "strings"
"time" "time"
@ -275,6 +276,45 @@ func (r *SocketRuntime) PruneNetworks(ctx context.Context) error {
return err return err
} }
// podPsJSON represents the JSON output of `podman pod ps --format json`
type podPsJSON struct {
ID string `json:"Id"`
Name string `json:"Name"`
Status string `json:"Status"`
Created string `json:"Created"`
InfraID string `json:"InfraId"`
Labels map[string]string `json:"Labels"`
}
// ListPods returns all pods.
func (r *SocketRuntime) ListPods(ctx context.Context) ([]PodSummary, error) {
// Use podman CLI since there's no pods binding package
cmd := exec.CommandContext(ctx, "podman", "pod", "ps", "--format", "json")
output, err := cmd.Output()
if err != nil {
return nil, err
}
var pods []podPsJSON
if err := json.Unmarshal(output, &pods); err != nil {
return nil, err
}
result := make([]PodSummary, len(pods))
for i, p := range pods {
created, _ := time.Parse(time.RFC3339, p.Created)
result[i] = PodSummary{
ID: p.ID,
Name: p.Name,
Status: p.Status,
Created: created,
InfraID: p.InfraID,
Labels: p.Labels,
}
}
return result, nil
}
// 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)
@ -346,6 +386,7 @@ func convertPodmanContainerList(podmanContainers []entities.ListContainer) []Con
SizeRootFs: sizeRootFs, SizeRootFs: sizeRootFs,
Pod: c.Pod, Pod: c.Pod,
PodName: c.PodName, PodName: c.PodName,
IsInfra: c.IsInfra,
} }
} }
return result return result

View file

@ -20,6 +20,7 @@ type ContainerSummary struct {
// Podman-specific fields // Podman-specific fields
Pod string Pod string
PodName string PodName string
IsInfra bool
} }
// PortMapping represents a container port mapping. // PortMapping represents a container port mapping.
@ -424,3 +425,13 @@ type EventActor struct {
ID string ID string
Attributes map[string]string Attributes map[string]string
} }
// PodSummary provides runtime-agnostic pod information.
type PodSummary struct {
ID string
Name string
Status string
Created time.Time
InfraID string
Labels map[string]string
}

View file

@ -142,3 +142,74 @@ func (gui *Gui) writeContainerLogs(ctr *commands.Container, ctx context.Context,
return err return err
} }
} }
// Pod logs rendering
func (gui *Gui) renderPodLogsToMain(pod *commands.Pod) tasks.TaskFunc {
return gui.NewTickerTask(TickerTaskOpts{
Func: func(ctx context.Context, notifyStopped chan struct{}) {
gui.renderPodLogsToMainAux(pod, ctx, notifyStopped)
},
Duration: time.Millisecond * 200,
Before: func(ctx context.Context) { gui.clearMainView() },
Wrap: gui.Config.UserConfig.Gui.WrapMainPanel,
Autoscroll: true,
})
}
func (gui *Gui) renderPodLogsToMainAux(pod *commands.Pod, ctx context.Context, notifyStopped chan struct{}) {
gui.clearMainView()
defer func() {
notifyStopped <- struct{}{}
}()
mainView := gui.Views.Main
if err := gui.writePodLogs(pod, ctx, mainView); err != nil {
gui.Log.Error(err)
}
// Wait for context cancellation
<-ctx.Done()
}
func (gui *Gui) writePodLogs(pod *commands.Pod, ctx context.Context, writer io.Writer) error {
// Build podman pod logs command
args := []string{"pod", "logs", "--follow", "--color"}
if gui.Config.UserConfig.Logs.Timestamps {
args = append(args, "--timestamps")
}
if gui.Config.UserConfig.Logs.Since != "" {
args = append(args, "--since", gui.Config.UserConfig.Logs.Since)
}
if gui.Config.UserConfig.Logs.Tail != "" {
args = append(args, "--tail", gui.Config.UserConfig.Logs.Tail)
}
args = append(args, pod.Name)
cmd := pod.OSCommand.NewCmd("podman", args...)
cmd.Stdout = writer
cmd.Stderr = writer
if err := cmd.Start(); err != nil {
gui.Log.Error(err)
return err
}
// Wait for context cancellation or command completion
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case <-ctx.Done():
if err := pod.OSCommand.Kill(cmd); err != nil {
gui.Log.Warn(err)
}
return nil
case err := <-done:
return err
}
}

View file

@ -18,7 +18,7 @@ import (
"github.com/samber/lo" "github.com/samber/lo"
) )
func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.Container] { func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.ContainerListItem] {
// Standalone containers are containers which are either one-off containers, or whose service is not part of this docker-compose context. // Standalone containers are containers which are either one-off containers, or whose service is not part of this docker-compose context.
isStandaloneContainer := func(container *commands.Container) bool { isStandaloneContainer := func(container *commands.Container) bool {
if container.OneOff || container.ServiceName == "" { if container.OneOff || container.ServiceName == "" {
@ -30,58 +30,60 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.Container]
}) })
} }
return &panels.SideListPanel[*commands.Container]{ return &panels.SideListPanel[*commands.ContainerListItem]{
ContextState: &panels.ContextState[*commands.Container]{ ContextState: &panels.ContextState[*commands.ContainerListItem]{
GetMainTabs: func() []panels.MainTab[*commands.Container] { GetMainTabs: func() []panels.MainTab[*commands.ContainerListItem] {
return []panels.MainTab[*commands.Container]{ return []panels.MainTab[*commands.ContainerListItem]{
{ {
Key: "logs", Key: "logs",
Title: gui.Tr.LogsTitle, Title: gui.Tr.LogsTitle,
Render: gui.renderContainerLogsToMain, Render: gui.renderContainerListItemLogs,
}, },
{ {
Key: "stats", Key: "stats",
Title: gui.Tr.StatsTitle, Title: gui.Tr.StatsTitle,
Render: gui.renderContainerStats, Render: gui.renderContainerListItemStats,
}, },
{ {
Key: "env", Key: "env",
Title: gui.Tr.EnvTitle, Title: gui.Tr.EnvTitle,
Render: gui.renderContainerEnv, Render: gui.renderContainerListItemEnv,
}, },
{ {
Key: "config", Key: "config",
Title: gui.Tr.ConfigTitle, Title: gui.Tr.ConfigTitle,
Render: gui.renderContainerConfig, Render: gui.renderContainerListItemConfig,
}, },
{ {
Key: "top", Key: "top",
Title: gui.Tr.TopTitle, Title: gui.Tr.TopTitle,
Render: gui.renderContainerTop, Render: gui.renderContainerListItemTop,
}, },
} }
}, },
GetItemContextCacheKey: func(container *commands.Container) string { GetItemContextCacheKey: func(item *commands.ContainerListItem) string {
// Including the container state in the cache key so that if the container // Including the state in the cache key so that if the container/pod
// restarts we re-read the logs. In the past we've had some glitchiness // restarts we re-read the logs.
// where a container restarts but the new logs don't get read. return "containers-" + item.ID() + "-" + item.State()
// Note that this might be jarring if we have a lot of logs and the container
// restarts a lot, so let's keep an eye on it.
return "containers-" + container.ID + "-" + container.Summary.State
}, },
}, },
ListPanel: panels.ListPanel[*commands.Container]{ ListPanel: panels.ListPanel[*commands.ContainerListItem]{
List: panels.NewFilteredList[*commands.Container](), List: panels.NewFilteredList[*commands.ContainerListItem](),
View: gui.Views.Containers, View: gui.Views.Containers,
}, },
NoItemsMessage: gui.Tr.NoContainers, NoItemsMessage: gui.Tr.NoContainers,
Gui: gui.intoInterface(), Gui: gui.intoInterface(),
// sortedContainers returns containers sorted by state if c.SortContainersByState is true (follows 1- running, 2- exited, 3- created) // Sort items: pods first (with their containers grouped), then standalone containers
// and sorted by name if c.SortContainersByState is false Sort: func(a *commands.ContainerListItem, b *commands.ContainerListItem) bool {
Sort: func(a *commands.Container, b *commands.Container) bool { return sortContainerListItems(a, b, gui.Config.UserConfig.Gui.LegacySortContainers)
return sortContainers(a, b, gui.Config.UserConfig.Gui.LegacySortContainers)
}, },
Filter: func(container *commands.Container) bool { Filter: func(item *commands.ContainerListItem) bool {
// Pods are always shown
if item.IsPod {
return true
}
container := item.Container
// Note that this is O(N*M) time complexity where N is the number of services // Note that this is O(N*M) time complexity where N is the number of services
// and M is the number of containers. We expect N to be small but M may be large, // and M is the number of containers. We expect N to be small but M may be large,
// so we will need to keep an eye on this. // so we will need to keep an eye on this.
@ -95,8 +97,8 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.Container]
return true return true
}, },
GetTableCells: func(container *commands.Container) []string { GetTableCells: func(item *commands.ContainerListItem) []string {
return presentation.GetContainerDisplayStrings(&gui.Config.UserConfig.Gui, container) return presentation.GetContainerListItemDisplayStrings(&gui.Config.UserConfig.Gui, item)
}, },
} }
} }
@ -121,6 +123,137 @@ func sortContainers(a *commands.Container, b *commands.Container, legacySort boo
return containerStates[a.Summary.State] < containerStates[b.Summary.State] return containerStates[a.Summary.State] < containerStates[b.Summary.State]
} }
// sortContainerListItems sorts items to group pods with their containers.
// Order: pods first (sorted by state/name), then their containers indented,
// then standalone containers (sorted by state/name).
func sortContainerListItems(a *commands.ContainerListItem, b *commands.ContainerListItem, legacySort bool) bool {
// If both are in the same pod, sort by indent (pod first) then by name
if a.PodID() != "" && a.PodID() == b.PodID() {
// Pod comes before its containers
if a.IsPod && !b.IsPod {
return true
}
if !a.IsPod && b.IsPod {
return false
}
// Both are containers in the same pod, sort by name
return a.Name() < b.Name()
}
// Get the effective sort key (pod name for items in pods, own name for standalone)
aKey := a.Name()
bKey := b.Name()
if a.PodID() != "" && !a.IsPod {
aKey = a.PodName() + "\x00" + a.Name() // Sort after the pod
}
if b.PodID() != "" && !b.IsPod {
bKey = b.PodName() + "\x00" + b.Name()
}
if a.IsPod {
aKey = a.Name() + "\x00" // Pod sorts before its containers
}
if b.IsPod {
bKey = b.Name() + "\x00"
}
// Pods and their containers sort together, standalone containers at the end
aInPod := a.IsPod || a.PodID() != ""
bInPod := b.IsPod || b.PodID() != ""
if aInPod && !bInPod {
return true // Pods/pod containers before standalone
}
if !aInPod && bInPod {
return false
}
// Both in same category (pod-related or standalone)
if legacySort {
return aKey < bKey
}
// Sort by state, then by key
stateA := containerStates[a.State()]
stateB := containerStates[b.State()]
if stateA == stateB {
return aKey < bKey
}
return stateA < stateB
}
// Wrapper functions that delegate to container or pod rendering
func (gui *Gui) renderContainerListItemLogs(item *commands.ContainerListItem) tasks.TaskFunc {
if item.IsPod {
return gui.renderPodLogsToMain(item.Pod)
}
return gui.renderContainerLogsToMain(item.Container)
}
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.renderContainerStats(item.Container)
}
func (gui *Gui) renderContainerListItemEnv(item *commands.ContainerListItem) tasks.TaskFunc {
if item.IsPod {
return gui.NewSimpleRenderStringTask(func() string {
return "Environment variables not available for pods. Select a container to view environment."
})
}
return gui.renderContainerEnv(item.Container)
}
func (gui *Gui) renderContainerListItemConfig(item *commands.ContainerListItem) tasks.TaskFunc {
if item.IsPod {
return gui.renderPodConfig(item.Pod)
}
return gui.renderContainerConfig(item.Container)
}
func (gui *Gui) renderContainerListItemTop(item *commands.ContainerListItem) tasks.TaskFunc {
if item.IsPod {
return gui.NewSimpleRenderStringTask(func() string {
return "Process list not available for pods. Select a container to view processes."
})
}
return gui.renderContainerTop(item.Container)
}
func (gui *Gui) renderPodConfig(pod *commands.Pod) tasks.TaskFunc {
return gui.NewSimpleRenderStringTask(func() string {
padding := 10
output := ""
output += utils.ColoredString("Pod Information\n\n", color.FgCyan)
output += utils.WithPadding("ID: ", padding) + pod.ID + "\n"
output += utils.WithPadding("Name: ", padding) + pod.Name + "\n"
output += utils.WithPadding("Status: ", padding) + pod.State() + "\n"
output += utils.WithPadding("Created: ", padding) + pod.Summary.Created.Format(time.RFC3339) + "\n"
output += fmt.Sprintf("%s%d\n", utils.WithPadding("Containers: ", padding), len(pod.Containers))
if len(pod.Containers) > 0 {
output += "\n" + utils.ColoredString("Containers in this pod:\n", color.FgYellow)
for _, c := range pod.Containers {
stateColor := color.FgWhite
switch c.Summary.State {
case "running":
stateColor = color.FgGreen
case "exited":
stateColor = color.FgRed
}
output += fmt.Sprintf(" - %s (%s)\n", c.Name, utils.ColoredString(c.Summary.State, stateColor))
}
}
return output
})
}
func (gui *Gui) renderContainerEnv(container *commands.Container) tasks.TaskFunc { func (gui *Gui) renderContainerEnv(container *commands.Container) tasks.TaskFunc {
return gui.NewSimpleRenderStringTask(func() string { return gui.containerEnv(container) }) return gui.NewSimpleRenderStringTask(func() string { return gui.containerEnv(container) })
} }
@ -179,9 +312,9 @@ func (gui *Gui) containerConfigStr(container *commands.Container) string {
output += "\n" output += "\n"
for _, mount := range container.Details.Mounts { for _, mount := range container.Details.Mounts {
if mount.Type == "volume" { if mount.Type == "volume" {
output += fmt.Sprintf("%s%s %s\n", strings.Repeat(" ", padding), utils.ColoredString(string(mount.Type)+":", color.FgYellow), mount.Name) output += fmt.Sprintf("%s%s %s\n", strings.Repeat(" ", padding), utils.ColoredString(mount.Type+":", color.FgYellow), mount.Name)
} else { } else {
output += fmt.Sprintf("%s%s %s:%s\n", strings.Repeat(" ", padding), utils.ColoredString(string(mount.Type)+":", color.FgYellow), mount.Source, mount.Destination) output += fmt.Sprintf("%s%s %s:%s\n", strings.Repeat(" ", padding), utils.ColoredString(mount.Type+":", color.FgYellow), mount.Source, mount.Destination)
} }
} }
} else { } else {
@ -302,11 +435,16 @@ func (gui *Gui) handleHideStoppedContainers(g *gocui.Gui, v *gocui.View) error {
} }
func (gui *Gui) handleContainersRemoveMenu(g *gocui.Gui, v *gocui.View) error { func (gui *Gui) handleContainersRemoveMenu(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem() item, err := gui.Panels.Containers.GetSelectedItem()
if err != nil { if err != nil {
return nil return nil
} }
if item.IsPod {
return gui.createErrorPanel("Pod operations not yet supported")
}
ctr := item.Container
handleMenuPress := func(force bool, removeVolumes bool) error { handleMenuPress := func(force bool, removeVolumes bool) error {
return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error { return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error {
if err := ctr.Remove(force, removeVolumes); err != nil { if err := ctr.Remove(force, removeVolumes); err != nil {
@ -357,20 +495,29 @@ func (gui *Gui) PauseContainer(container *commands.Container) error {
} }
func (gui *Gui) handleContainerPause(g *gocui.Gui, v *gocui.View) error { func (gui *Gui) handleContainerPause(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem() item, err := gui.Panels.Containers.GetSelectedItem()
if err != nil { if err != nil {
return nil return nil
} }
return gui.PauseContainer(ctr) if item.IsPod {
return gui.createErrorPanel("Pod operations not yet supported")
}
return gui.PauseContainer(item.Container)
} }
func (gui *Gui) handleContainerStop(g *gocui.Gui, v *gocui.View) error { func (gui *Gui) handleContainerStop(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem() item, err := gui.Panels.Containers.GetSelectedItem()
if err != nil { if err != nil {
return nil return nil
} }
if item.IsPod {
return gui.createErrorPanel("Pod operations not yet supported")
}
ctr := item.Container
return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.StopContainer, func(g *gocui.Gui, v *gocui.View) error { return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.StopContainer, func(g *gocui.Gui, v *gocui.View) error {
return gui.WithWaitingStatus(gui.Tr.StoppingStatus, func() error { return gui.WithWaitingStatus(gui.Tr.StoppingStatus, func() error {
if err := ctr.Stop(); err != nil { if err := ctr.Stop(); err != nil {
@ -383,11 +530,16 @@ func (gui *Gui) handleContainerStop(g *gocui.Gui, v *gocui.View) error {
} }
func (gui *Gui) handleContainerRestart(g *gocui.Gui, v *gocui.View) error { func (gui *Gui) handleContainerRestart(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem() item, err := gui.Panels.Containers.GetSelectedItem()
if err != nil { if err != nil {
return nil return nil
} }
if item.IsPod {
return gui.createErrorPanel("Pod operations not yet supported")
}
ctr := item.Container
return gui.WithWaitingStatus(gui.Tr.RestartingStatus, func() error { return gui.WithWaitingStatus(gui.Tr.RestartingStatus, func() error {
if err := ctr.Restart(); err != nil { if err := ctr.Restart(); err != nil {
return gui.createErrorPanel(err.Error()) return gui.createErrorPanel(err.Error())
@ -398,11 +550,16 @@ func (gui *Gui) handleContainerRestart(g *gocui.Gui, v *gocui.View) error {
} }
func (gui *Gui) handleContainerAttach(g *gocui.Gui, v *gocui.View) error { func (gui *Gui) handleContainerAttach(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem() item, err := gui.Panels.Containers.GetSelectedItem()
if err != nil { if err != nil {
return nil return nil
} }
if item.IsPod {
return gui.createErrorPanel("Pod operations not yet supported")
}
ctr := item.Container
c, err := ctr.Attach() c, err := ctr.Attach()
if err != nil { if err != nil {
return gui.createErrorPanel(err.Error()) return gui.createErrorPanel(err.Error())
@ -424,23 +581,32 @@ func (gui *Gui) handlePruneContainers() error {
} }
func (gui *Gui) handleContainerViewLogs(g *gocui.Gui, v *gocui.View) error { func (gui *Gui) handleContainerViewLogs(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem() item, err := gui.Panels.Containers.GetSelectedItem()
if err != nil { if err != nil {
return nil return nil
} }
gui.renderLogsToStdout(ctr) if item.IsPod {
// TODO: implement pod logs to stdout
return gui.createErrorPanel("Pod logs to stdout not yet supported")
}
gui.renderLogsToStdout(item.Container)
return nil return nil
} }
func (gui *Gui) handleContainersExecShell(g *gocui.Gui, v *gocui.View) error { func (gui *Gui) handleContainersExecShell(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem() item, err := gui.Panels.Containers.GetSelectedItem()
if err != nil { if err != nil {
return nil return nil
} }
return gui.containerExecShell(ctr) if item.IsPod {
return gui.createErrorPanel("Cannot exec into a pod. Select a container instead.")
}
return gui.containerExecShell(item.Container)
} }
func (gui *Gui) containerExecShell(container *commands.Container) error { func (gui *Gui) containerExecShell(container *commands.Container) error {
@ -456,13 +622,17 @@ func (gui *Gui) containerExecShell(container *commands.Container) error {
} }
func (gui *Gui) handleContainersCustomCommand(g *gocui.Gui, v *gocui.View) error { func (gui *Gui) handleContainersCustomCommand(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem() item, err := gui.Panels.Containers.GetSelectedItem()
if err != nil { if err != nil {
return nil return nil
} }
if item.IsPod {
return gui.createErrorPanel("Custom commands not yet supported for pods")
}
commandObject := gui.PodmanCommand.NewCommandObject(commands.CommandObject{ commandObject := gui.PodmanCommand.NewCommandObject(commands.CommandObject{
Container: ctr, Container: item.Container,
}) })
customCommands := gui.Config.UserConfig.CustomCommands.Containers customCommands := gui.Config.UserConfig.CustomCommands.Containers
@ -473,9 +643,11 @@ func (gui *Gui) handleContainersCustomCommand(g *gocui.Gui, v *gocui.View) error
func (gui *Gui) handleStopContainers() error { func (gui *Gui) handleStopContainers() error {
return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.ConfirmStopContainers, func(g *gocui.Gui, v *gocui.View) error { return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.ConfirmStopContainers, func(g *gocui.Gui, v *gocui.View) error {
return gui.WithWaitingStatus(gui.Tr.StoppingStatus, func() error { return gui.WithWaitingStatus(gui.Tr.StoppingStatus, func() error {
for _, ctr := range gui.Panels.Containers.List.GetAllItems() { for _, item := range gui.Panels.Containers.List.GetAllItems() {
if err := ctr.Stop(); err != nil { if !item.IsPod && item.Container != nil {
gui.Log.Error(err) if err := item.Container.Stop(); err != nil {
gui.Log.Error(err)
}
} }
} }
@ -487,9 +659,11 @@ func (gui *Gui) handleStopContainers() error {
func (gui *Gui) handleRemoveContainers() error { func (gui *Gui) handleRemoveContainers() error {
return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.ConfirmRemoveContainers, func(g *gocui.Gui, v *gocui.View) error { return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.ConfirmRemoveContainers, func(g *gocui.Gui, v *gocui.View) error {
return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error { return gui.WithWaitingStatus(gui.Tr.RemovingStatus, func() error {
for _, ctr := range gui.Panels.Containers.List.GetAllItems() { for _, item := range gui.Panels.Containers.List.GetAllItems() {
if err := ctr.Remove(true, false); err != nil { if !item.IsPod && item.Container != nil {
gui.Log.Error(err) if err := item.Container.Remove(true, false); err != nil {
gui.Log.Error(err)
}
} }
} }
@ -522,12 +696,16 @@ func (gui *Gui) handleContainersBulkCommand(g *gocui.Gui, v *gocui.View) error {
// Open first port in browser // Open first port in browser
func (gui *Gui) handleContainersOpenInBrowserCommand(g *gocui.Gui, v *gocui.View) error { func (gui *Gui) handleContainersOpenInBrowserCommand(g *gocui.Gui, v *gocui.View) error {
ctr, err := gui.Panels.Containers.GetSelectedItem() item, err := gui.Panels.Containers.GetSelectedItem()
if err != nil { if err != nil {
return nil return nil
} }
return gui.openContainerInBrowser(ctr) if item.IsPod {
return gui.createErrorPanel("Cannot open pod in browser. Select a container instead.")
}
return gui.openContainerInBrowser(item.Container)
} }
func (gui *Gui) openContainerInBrowser(ctr *commands.Container) error { func (gui *Gui) openContainerInBrowser(ctr *commands.Container) error {

View file

@ -48,7 +48,7 @@ type Gui struct {
type Panels struct { type Panels struct {
Projects *panels.SideListPanel[*commands.Project] Projects *panels.SideListPanel[*commands.Project]
Services *panels.SideListPanel[*commands.Service] Services *panels.SideListPanel[*commands.Service]
Containers *panels.SideListPanel[*commands.Container] Containers *panels.SideListPanel[*commands.ContainerListItem]
Images *panels.SideListPanel[*commands.Image] Images *panels.SideListPanel[*commands.Image]
Volumes *panels.SideListPanel[*commands.Volume] Volumes *panels.SideListPanel[*commands.Volume]
Networks *panels.SideListPanel[*commands.Network] Networks *panels.SideListPanel[*commands.Network]
@ -290,7 +290,14 @@ func (gui *Gui) setPanels() {
} }
func (gui *Gui) updateContainerDetails() error { func (gui *Gui) updateContainerDetails() error {
return gui.PodmanCommand.RefreshContainerDetails(gui.Panels.Containers.List.GetAllItems()) // Extract containers from ContainerListItems
var containers []*commands.Container
for _, item := range gui.Panels.Containers.List.GetAllItems() {
if !item.IsPod && item.Container != nil {
containers = append(containers, item.Container)
}
}
return gui.PodmanCommand.RefreshContainerDetails(containers)
} }
func (gui *Gui) refresh() { func (gui *Gui) refresh() {
@ -469,9 +476,9 @@ func (gui *Gui) monitorContainerStats(ctx context.Context) {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
for _, container := range gui.Panels.Containers.List.GetAllItems() { for _, item := range gui.Panels.Containers.List.GetAllItems() {
if !container.MonitoringStats { if !item.IsPod && item.Container != nil && !item.Container.MonitoringStats {
go gui.PodmanCommand.CreateClientStatMonitor(container) go gui.PodmanCommand.CreateClientStatMonitor(item.Container)
} }
} }
} }

View file

@ -8,11 +8,11 @@ import (
"strings" "strings"
"time" "time"
"github.com/fatih/color"
"github.com/jesseduffield/asciigraph"
"github.com/christophe-duc/lazypodman/pkg/commands" "github.com/christophe-duc/lazypodman/pkg/commands"
"github.com/christophe-duc/lazypodman/pkg/config" "github.com/christophe-duc/lazypodman/pkg/config"
"github.com/christophe-duc/lazypodman/pkg/utils" "github.com/christophe-duc/lazypodman/pkg/utils"
"github.com/fatih/color"
"github.com/jesseduffield/asciigraph"
"github.com/mcuadros/go-lookup" "github.com/mcuadros/go-lookup"
"github.com/samber/lo" "github.com/samber/lo"
) )

View file

@ -24,6 +24,91 @@ func GetContainerDisplayStrings(guiConfig *config.GuiConfig, container *commands
} }
} }
// GetContainerListItemDisplayStrings returns display strings for a ContainerListItem (pod or container)
func GetContainerListItemDisplayStrings(guiConfig *config.GuiConfig, item *commands.ContainerListItem) []string {
if item.IsPod {
return GetPodDisplayStrings(guiConfig, item.Pod)
}
// Add indentation for containers in pods
strings := GetContainerDisplayStrings(guiConfig, item.Container)
if item.Indent > 0 {
strings[2] = fmt.Sprintf("%s%s", createIndent(item.Indent), strings[2])
}
return strings
}
// GetPodDisplayStrings returns display strings for a pod
func GetPodDisplayStrings(guiConfig *config.GuiConfig, pod *commands.Pod) []string {
return []string{
getPodDisplayStatus(guiConfig, pod),
"", // No substatus for pods
utils.ColoredString(pod.Name, color.FgCyan),
"", // No CPU% for pods
"", // No ports for pods
utils.ColoredString(fmt.Sprintf("(%d containers)", len(pod.Containers)), color.FgMagenta),
}
}
func getPodDisplayStatus(guiConfig *config.GuiConfig, pod *commands.Pod) string {
shortStatusMap := map[string]string{
"Running": "R",
"Degraded": "D",
"Exited": "X",
"Dead": "!",
"Created": "C",
}
iconStatusMap := map[string]rune{
"Running": '▶',
"Degraded": '◐',
"Exited": '',
"Dead": '!',
"Created": '+',
}
var podState string
switch guiConfig.ContainerStatusHealthStyle {
case "short":
if s, ok := shortStatusMap[pod.State()]; ok {
podState = s
} else {
podState = "?"
}
case "icon":
if r, ok := iconStatusMap[pod.State()]; ok {
podState = string(r)
} else {
podState = "?"
}
default:
podState = pod.State()
}
return utils.ColoredString(podState, getPodColor(pod))
}
func getPodColor(pod *commands.Pod) color.Attribute {
switch pod.State() {
case "Running":
return color.FgGreen
case "Degraded":
return color.FgYellow
case "Exited":
return color.FgRed
case "Dead":
return color.FgRed
case "Created":
return color.FgCyan
default:
return color.FgWhite
}
}
func createIndent(spaces int) string {
return fmt.Sprintf("%*s", spaces, "")
}
func displayContainerImage(container *commands.Container) string { func displayContainerImage(container *commands.Container) string {
return strings.TrimPrefix(container.Summary.Image, "sha256:") return strings.TrimPrefix(container.Summary.Image, "sha256:")
} }

View file

@ -109,18 +109,17 @@ type TranslationSet struct {
FilterList string FilterList string
OpenInBrowser string OpenInBrowser string
SortContainersByState string SortContainersByState string
LogsTitle string
LogsTitle string ConfigTitle string
ConfigTitle string EnvTitle string
EnvTitle string ComposeConfigTitle string
ComposeConfigTitle string StatsTitle string
StatsTitle string CreditsTitle string
CreditsTitle string ContainerConfigTitle string
ContainerConfigTitle string ContainerEnvTitle string
ContainerEnvTitle string NothingToDisplay string
NothingToDisplay string NoContainerForService string
NoContainerForService string CannotDisplayEnvVariables string
CannotDisplayEnvVariables string
No string No string
Yes string Yes string