Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
ctop
.idea
/vendor/
*.log
*.log
.vscode/
108 changes: 99 additions & 9 deletions connector/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import (
"sync"
"time"

"github.com/op/go-logging"
"github.com/hako/durafmt"
"github.com/op/go-logging"

api "github.com/fsouza/go-dockerclient"
"github.com/lordoverlord/ctop/connector/collector"
"github.com/lordoverlord/ctop/connector/manager"
"github.com/lordoverlord/ctop/container"
api "github.com/fsouza/go-dockerclient"
)

func init() { enabled["docker"] = NewDocker }
Expand Down Expand Up @@ -69,6 +69,7 @@ func NewDocker() (Connector, error) {

go cm.Loop()
go cm.LoopStatuses()
go cm.LoopUptimeUpdates()
cm.refreshAll()
go cm.watchEvents()
return cm, nil
Expand Down Expand Up @@ -143,6 +144,9 @@ func portsFormat(ports map[api.Port][]api.PortBinding) string {
}

func webPort(ports map[api.Port][]api.PortBinding) string {
var publishedWebPort string

outer:
for _, v := range ports {
if len(v) == 0 {
continue
Expand All @@ -152,11 +156,11 @@ func webPort(ports map[api.Port][]api.PortBinding) string {
if publishedIp == "0.0.0.0" {
publishedIp = "localhost"
}
publishedWebPort := fmt.Sprintf("%s:%s", publishedIp, binding.HostPort)
return publishedWebPort
publishedWebPort = fmt.Sprintf("%s:%s", publishedIp, binding.HostPort)
break outer
}
}
return ""
return publishedWebPort
}

func ipsFormat(networks map[string]api.ContainerNetwork) string {
Expand All @@ -173,6 +177,15 @@ func ipsFormat(networks map[string]api.ContainerNetwork) string {
func (cm *Docker) refresh(c *container.Container) {
insp, found, failed := cm.inspect(c.Id)
if failed {
// Inspection failed - retry after a delay (non-blocking)
go func(id string) {
time.Sleep(2 * time.Second)
select {
case cm.needsRefresh <- id:
default:
// drop if channel is full to avoid goroutine leak/block
}
}(c.Id)
return
}
// remove container if no longer exists
Expand All @@ -189,31 +202,56 @@ func (cm *Docker) refresh(c *container.Container) {
c.SetMeta("Web Port", webPort)
}
c.SetMeta("created", insp.Created.Format("Mon Jan 02 15:04:05 2006"))
c.SetMeta("uptime", calcUptime(insp))
c.SetMeta("health", insp.State.Health.Status)
c.SetMeta("started_at", insp.State.StartedAt.Format(time.RFC3339))
c.SetMeta("finished_at", insp.State.FinishedAt.Format(time.RFC3339))
// Only show uptime for running containers
if insp.State.Running {
c.SetMeta("uptime", calcUptime(insp))
} else {
c.SetMeta("uptime", "-")
}
// Show health status if available, otherwise set to "-"
health := insp.State.Health.Status
if health == "" {
health = "-"
}
c.SetMeta("health", health)
c.SetMeta("[ENV-VAR]", strings.Join(insp.Config.Env, ";"))
c.SetState(insp.State.Status)
}

func (cm *Docker) inspect(id string) (insp *api.Container, found bool, failed bool) {
c, err := cm.client.InspectContainer(id)
c, err := cm.client.InspectContainerWithOptions(
api.InspectContainerOptions{ID: id},
)
if err != nil {
if _, notFound := err.(*api.NoSuchContainer); notFound {
return c, false, false
}
// other error e.g. connection failed
log.Errorf("%s (%T)", err.Error(), err)
return c, false, true
}
return c, true, false
}

func calcUptime(insp *api.Container) string {
// Validate StartedAt timestamp
if insp.State.StartedAt.IsZero() || insp.State.StartedAt.Year() < 1971 {
return "-"
}

endTime := insp.State.FinishedAt
if endTime.IsZero() || insp.State.Running {
endTime = time.Now()
}

uptime := endTime.Sub(insp.State.StartedAt)

// Validate calculated uptime is reasonable
if uptime < 0 || uptime > 87600*time.Hour { // ~10 años
return "-"
}

return durafmt.Parse(uptime).LimitFirstN(1).String()
}

Expand Down Expand Up @@ -315,3 +353,55 @@ func (cm *Docker) All() (containers container.Containers) {
func shortName(name string) string {
return strings.TrimPrefix(name, "/")
}

// LoopUptimeUpdates periodically updates uptime for running containers
func (cm *Docker) LoopUptimeUpdates() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

for {
select {
case <-ticker.C:
cm.lock.RLock()
needsRefresh := []string{}

for _, c := range cm.containers {
// Only update uptime for running containers
// For stopped containers, refresh() sets uptime to "-"
if c.Meta["state"] == "running" {
startedAtStr := c.GetMeta("started_at")
if startedAtStr != "" {
startedAt, err := time.Parse(time.RFC3339, startedAtStr)
if err == nil && startedAt.Year() > 1971 {
uptime := time.Since(startedAt)
// Only show uptime if it's reasonable (not in the future, not > 10 years)
if uptime > 0 && uptime < 87600*time.Hour {
c.SetMeta("uptime", durafmt.Parse(uptime).LimitFirstN(1).String())
} else {
needsRefresh = append(needsRefresh, c.Id)
}
} else {
needsRefresh = append(needsRefresh, c.Id)
}
} else {
needsRefresh = append(needsRefresh, c.Id)
}
}
}

cm.lock.RUnlock()

// Queue containers with bad timestamps for refresh (non-blocking)
for _, id := range needsRefresh {
select {
case cm.needsRefresh <- id:
default:
// Channel full, skip
}
}

case <-cm.closed:
return
}
}
}
2 changes: 1 addition & 1 deletion container/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func New(id string, collector collector.Collector, manager manager.Manager) *Con
return &Container{
Metrics: models.NewMetrics(),
Id: id,
Meta: models.NewMeta("id", shortID),
Meta: models.NewMeta("id", shortID, "uptime", "-"),
Widgets: widgets,
updater: widgets,
collector: collector,
Expand Down
76 changes: 61 additions & 15 deletions cwidgets/compact/text.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,31 +21,47 @@ func (w *MetaCol) SetMeta(m models.Meta) {

func NewNameCol() CompactCol {
c := &MetaCol{NewTextCol("NAME"), "name"}
c.fWidth = 30
c.minWidth = 25
c.maxWidth = 40
c.growPriority = 1 // Highest priority
return c
}

func NewCIDCol() CompactCol {
c := &MetaCol{NewTextCol("CID"), "id"}
c.fWidth = 12
c.minWidth = 13
c.maxWidth = 15
c.growPriority = 2 // Lower priority
return c
}

func NewImageCol() CompactCol {
return &MetaCol{NewTextCol("IMAGE"), "image"}
c := &MetaCol{NewTextCol("IMAGE"), "image"}
c.minWidth = 15
c.maxWidth = 35
c.growPriority = 1 // Highest priority
return c
}

func NewPortsCol() CompactCol {
return &MetaCol{NewTextCol("PORTS"), "ports"}
c := &MetaCol{NewTextCol("PORTS"), "ports"}
c.minWidth = 10
c.maxWidth = 25
c.growPriority = 2 // Lower priority
return c
}

func NewIpsCol() CompactCol {
return &MetaCol{NewTextCol("IPs"), "IPs"}
c := &MetaCol{NewTextCol("IPs"), "IPs"}
c.minWidth = 10
c.maxWidth = 20
c.growPriority = 2 // Lower priority
return c
}

func NewCreatedCol() CompactCol {
c := &MetaCol{NewTextCol("CREATED"), "created"}
c.fWidth = 19 // Year will be stripped e.g. "Thu Nov 26 07:44:03" without 2020 at end
c.fWidth = 19 // Fixed width - e.g. "Thu Nov 26 07:44:03" without year
return c
}

Expand All @@ -54,7 +70,11 @@ type NetCol struct {
}

func NewNetCol() CompactCol {
return &NetCol{NewTextCol("NET RX/TX")}
c := &NetCol{NewTextCol("NET RX/TX")}
c.minWidth = 12
c.maxWidth = 14
c.growPriority = 2 // Lower priority
return c
}

func (w *NetCol) SetMetrics(m models.Metrics) {
Expand All @@ -67,12 +87,21 @@ type IOCol struct {
}

func NewIOCol() CompactCol {
return &IOCol{NewTextCol("IO R/W")}
c := &IOCol{NewTextCol("IO R/W")}
c.minWidth = 12
c.maxWidth = 14
c.growPriority = 2 // Lower priority
return c
}

func (w *IOCol) SetMetrics(m models.Metrics) {
label := fmt.Sprintf("%s / %s", cwidgets.ByteFormat64Short(m.IOBytesRead), cwidgets.ByteFormat64Short(m.IOBytesWrite))
w.setText(label)
// Show dash if BlockIO stats unavailable (requires kernel blkio cgroup support)
if m.IOBytesRead == 0 && m.IOBytesWrite == 0 {
w.setText("-")
} else {
label := fmt.Sprintf("%s / %s", cwidgets.ByteFormat64Short(m.IOBytesRead), cwidgets.ByteFormat64Short(m.IOBytesWrite))
w.setText(label)
}
}

type PIDCol struct {
Expand All @@ -81,20 +110,31 @@ type PIDCol struct {

func NewPIDCol() CompactCol {
w := &PIDCol{NewTextCol("PIDS")}
w.fWidth = 4
w.minWidth = 4
w.maxWidth = 6
w.growPriority = 2 // Lower priority
return w
}

func (w *PIDCol) SetMetrics(m models.Metrics) {
w.setText(fmt.Sprintf("%d", m.Pids))
// Show dash if PIDs stats unavailable (requires kernel cgroup support)
if m.Pids == 0 {
w.setText("-")
} else {
w.setText(fmt.Sprintf("%d", m.Pids))
}
}

type UptimeCol struct {
*TextCol
}

func NewUptimeCol() CompactCol {
return &UptimeCol{NewTextCol("UPTIME")}
c := &UptimeCol{NewTextCol("UPTIME")}
c.minWidth = 10
c.maxWidth = 15
c.growPriority = 2 // Lower priority
return c
}

func (w *UptimeCol) SetMeta(m models.Meta) {
Expand All @@ -103,8 +143,11 @@ func (w *UptimeCol) SetMeta(m models.Meta) {

type TextCol struct {
*ui.Par
header string
fWidth int
header string
fWidth int // fixed width (0 = growable)
minWidth int // minimum width
maxWidth int // maximum width
growPriority int // 0=fixed, 1=highest priority, 2=medium, 3=lowest
}

func NewTextCol(header string) *TextCol {
Expand Down Expand Up @@ -138,6 +181,9 @@ func (w *TextCol) SetMeta(models.Meta) {}
func (w *TextCol) SetMetrics(models.Metrics) {}
func (w *TextCol) Header() string { return w.header }
func (w *TextCol) FixedWidth() int { return w.fWidth }
func (w *TextCol) MinWidth() int { return w.minWidth }
func (w *TextCol) MaxWidth() int { return w.maxWidth }
func (w *TextCol) GrowPriority() int { return w.growPriority }

func (w *TextCol) setText(s string) {
if w.fWidth > 0 && len(s) > w.fWidth {
Expand Down
Loading