diff --git a/.gitignore b/.gitignore index 2338955c..8f3369e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ ctop .idea /vendor/ -*.log \ No newline at end of file +*.log +.vscode/ \ No newline at end of file diff --git a/connector/docker.go b/connector/docker.go index 45add686..685cb840 100644 --- a/connector/docker.go +++ b/connector/docker.go @@ -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 } @@ -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 @@ -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 @@ -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 { @@ -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 @@ -189,19 +202,32 @@ 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 } @@ -209,11 +235,23 @@ func (cm *Docker) inspect(id string) (insp *api.Container, found bool, failed bo } 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() } @@ -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 + } + } +} diff --git a/container/main.go b/container/main.go index 3d913317..8cb69cc4 100644 --- a/container/main.go +++ b/container/main.go @@ -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, diff --git a/cwidgets/compact/text.go b/cwidgets/compact/text.go index 0fb029f6..1a5f919a 100644 --- a/cwidgets/compact/text.go +++ b/cwidgets/compact/text.go @@ -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 } @@ -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) { @@ -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 { @@ -81,12 +110,19 @@ 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 { @@ -94,7 +130,11 @@ type UptimeCol struct { } 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) { @@ -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 { @@ -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 {