diff --git a/config/columns.go b/config/columns.go new file mode 100644 index 0000000..b59a50e --- /dev/null +++ b/config/columns.go @@ -0,0 +1,145 @@ +package config + +import ( + "strings" +) + +// defaults +var defaultColumns = []Column{ + Column{ + Name: "status", + Label: "Status Indicator", + Enabled: true, + }, + Column{ + Name: "name", + Label: "Container Name", + Enabled: true, + }, + Column{ + Name: "id", + Label: "Container ID", + Enabled: true, + }, + Column{ + Name: "cpu", + Label: "CPU Usage", + Enabled: true, + }, + Column{ + Name: "mem", + Label: "Memory Usage", + Enabled: true, + }, + Column{ + Name: "net", + Label: "Network RX/TX", + Enabled: true, + }, + Column{ + Name: "io", + Label: "Disk IO Read/Write", + Enabled: true, + }, + Column{ + Name: "pids", + Label: "Container PID Count", + Enabled: true, + }, +} + +type Column struct { + Name string + Label string + Enabled bool +} + +// ColumnsString returns an ordered and comma-delimited string of currently enabled Columns +func ColumnsString() string { return strings.Join(EnabledColumns(), ",") } + +// EnabledColumns returns an ordered array of enabled column names +func EnabledColumns() (a []string) { + lock.RLock() + defer lock.RUnlock() + for _, col := range GlobalColumns { + if col.Enabled { + a = append(a, col.Name) + } + } + return a +} + +// ColumnToggle toggles the enabled status of a given column name +func ColumnToggle(name string) { + col := GlobalColumns[colIndex(name)] + col.Enabled = !col.Enabled + log.Noticef("config change [column-%s]: %t -> %t", col.Name, !col.Enabled, col.Enabled) +} + +// ColumnLeft moves the column with given name up one position, if possible +func ColumnLeft(name string) { + idx := colIndex(name) + if idx > 0 { + swapCols(idx, idx-1) + } +} + +// ColumnRight moves the column with given name up one position, if possible +func ColumnRight(name string) { + idx := colIndex(name) + if idx < len(GlobalColumns)-1 { + swapCols(idx, idx+1) + } +} + +// Set Column order and enabled status from one or more provided Column names +func SetColumns(names []string) { + var ( + n int + curColStr = ColumnsString() + newColumns = make([]*Column, len(GlobalColumns)) + ) + + lock.Lock() + + // add enabled columns by name + for _, name := range names { + newColumns[n] = popColumn(name) + newColumns[n].Enabled = true + n++ + } + + // extend with omitted columns as disabled + for _, col := range GlobalColumns { + newColumns[n] = col + newColumns[n].Enabled = false + n++ + } + + GlobalColumns = newColumns + lock.Unlock() + + log.Noticef("config change [columns]: %s -> %s", curColStr, ColumnsString()) +} + +func swapCols(i, j int) { GlobalColumns[i], GlobalColumns[j] = GlobalColumns[j], GlobalColumns[i] } + +func popColumn(name string) *Column { + idx := colIndex(name) + if idx < 0 { + panic("no such column name: " + name) + } + col := GlobalColumns[idx] + GlobalColumns = append(GlobalColumns[:idx], GlobalColumns[idx+1:]...) + return col +} + +// return index of column with given name, if any +func colIndex(name string) int { + for n, c := range GlobalColumns { + if c.Name == name { + return n + } + } + return -1 +} diff --git a/config/file.go b/config/file.go index 92bec57..2fff0ab 100644 --- a/config/file.go +++ b/config/file.go @@ -19,19 +19,28 @@ type File struct { } func exportConfig() File { + // update columns param from working config + Update("columns", ColumnsString()) + + lock.RLock() + defer lock.RUnlock() + c := File{ Options: make(map[string]string), Toggles: make(map[string]bool), } + for _, p := range GlobalParams { c.Options[p.Key] = p.Val } for _, sw := range GlobalSwitches { c.Toggles[sw.Key] = sw.Val } + return c } +// func Read() error { var config File @@ -43,13 +52,26 @@ func Read() error { if _, err := toml.DecodeFile(path, &config); err != nil { return err } - for k, v := range config.Options { Update(k, v) } for k, v := range config.Toggles { UpdateSwitch(k, v) } + + // set working column config, if provided + colStr := GetVal("columns") + if len(colStr) > 0 { + var colNames []string + for _, s := range strings.Split(colStr, ",") { + s = strings.TrimSpace(s) + if s != "" { + colNames = append(colNames, strings.TrimSpace(s)) + } + } + SetColumns(colNames) + } + return nil } diff --git a/config/main.go b/config/main.go index 2b4b997..d856ab5 100644 --- a/config/main.go +++ b/config/main.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "sync" "github.com/bcicen/ctop/logging" ) @@ -10,17 +11,24 @@ import ( var ( GlobalParams []*Param GlobalSwitches []*Switch + GlobalColumns []*Column + lock sync.RWMutex log = logging.Init() ) func Init() { - for _, p := range params { + for _, p := range defaultParams { GlobalParams = append(GlobalParams, p) - log.Infof("loaded config param: %s: %s", quote(p.Key), quote(p.Val)) + log.Infof("loaded default config param [%s]: %s", quote(p.Key), quote(p.Val)) } - for _, s := range switches { + for _, s := range defaultSwitches { GlobalSwitches = append(GlobalSwitches, s) - log.Infof("loaded config switch: %s: %t", quote(s.Key), s.Val) + log.Infof("loaded default config switch [%s]: %t", quote(s.Key), s.Val) + } + for _, c := range defaultColumns { + x := c + GlobalColumns = append(GlobalColumns, &x) + log.Infof("loaded default widget config [%s]: %t", quote(x.Name), x.Enabled) } } diff --git a/config/param.go b/config/param.go index 86f5828..dc334f5 100644 --- a/config/param.go +++ b/config/param.go @@ -1,7 +1,7 @@ package config // defaults -var params = []*Param{ +var defaultParams = []*Param{ &Param{ Key: "filterStr", Val: "", @@ -17,6 +17,11 @@ var params = []*Param{ Val: "sh", Label: "Shell", }, + &Param{ + Key: "columns", + Val: "status,name,id,cpu,mem,net,io,pids", + Label: "Enabled Columns", + }, } type Param struct { @@ -27,6 +32,9 @@ type Param struct { // Get Param by key func Get(k string) *Param { + lock.RLock() + defer lock.RUnlock() + for _, p := range GlobalParams { if p.Key == k { return p @@ -43,7 +51,10 @@ func GetVal(k string) string { // Set param value func Update(k, v string) { p := Get(k) - log.Noticef("config change: %s: %s -> %s", k, quote(p.Val), quote(v)) + log.Noticef("config change [%s]: %s -> %s", k, quote(p.Val), quote(v)) + + lock.Lock() + defer lock.Unlock() p.Val = v // log.Errorf("ignoring update for non-existant parameter: %s", k) } diff --git a/config/switch.go b/config/switch.go index ab3d7e0..dca9bc4 100644 --- a/config/switch.go +++ b/config/switch.go @@ -1,7 +1,7 @@ package config // defaults -var switches = []*Switch{ +var defaultSwitches = []*Switch{ &Switch{ Key: "sortReversed", Val: false, @@ -37,6 +37,9 @@ type Switch struct { // GetSwitch returns Switch by key func GetSwitch(k string) *Switch { + lock.RLock() + defer lock.RUnlock() + for _, sw := range GlobalSwitches { if sw.Key == k { return sw @@ -52,8 +55,12 @@ func GetSwitchVal(k string) bool { func UpdateSwitch(k string, val bool) { sw := GetSwitch(k) + + lock.Lock() + defer lock.Unlock() + if sw.Val != val { - log.Noticef("config change: %s: %t -> %t", k, sw.Val, val) + log.Noticef("config change [%s]: %t -> %t", k, sw.Val, val) sw.Val = val } } @@ -61,8 +68,11 @@ func UpdateSwitch(k string, val bool) { // Toggle a boolean switch func Toggle(k string) { sw := GetSwitch(k) - newVal := !sw.Val - log.Noticef("config change: %s: %t -> %t", k, sw.Val, newVal) - sw.Val = newVal + + lock.Lock() + defer lock.Unlock() + + sw.Val = !sw.Val + log.Noticef("config change [%s]: %t -> %t", k, !sw.Val, sw.Val) //log.Errorf("ignoring toggle for non-existant switch: %s", k) } diff --git a/container/main.go b/container/main.go index 88b3619..e0b0c3f 100644 --- a/container/main.go +++ b/container/main.go @@ -42,6 +42,12 @@ func New(id string, collector collector.Collector, manager manager.Manager) *Con } } +func (c *Container) RecreateWidgets() { + c.SetUpdater(cwidgets.NullWidgetUpdater{}) + c.Widgets = compact.NewCompactRow() + c.SetUpdater(c.Widgets) +} + func (c *Container) SetUpdater(u cwidgets.WidgetUpdater) { c.updater = u c.updater.SetMeta(c.Meta) diff --git a/cwidgets/compact/gauge.go b/cwidgets/compact/gauge.go index fdf3a03..0862569 100644 --- a/cwidgets/compact/gauge.go +++ b/cwidgets/compact/gauge.go @@ -5,6 +5,7 @@ import ( "github.com/bcicen/ctop/cwidgets" "github.com/bcicen/ctop/models" + ui "github.com/gizak/termui" ) diff --git a/cwidgets/compact/grid.go b/cwidgets/compact/grid.go index 710d7f2..aa1f376 100644 --- a/cwidgets/compact/grid.go +++ b/cwidgets/compact/grid.go @@ -17,11 +17,7 @@ type CompactGrid struct { func NewCompactGrid() *CompactGrid { cg := &CompactGrid{header: NewCompactHeader()} - for _, wFn := range allCols { - w := wFn() - cg.cols = append(cg.cols, w) - cg.header.addFieldPar(w.Header()) - } + cg.rebuildHeader() return cg } @@ -41,7 +37,11 @@ func (cg *CompactGrid) Align() { } } -func (cg *CompactGrid) Clear() { cg.Rows = []RowBufferer{} } +func (cg *CompactGrid) Clear() { + cg.Rows = []RowBufferer{} + cg.rebuildHeader() +} + func (cg *CompactGrid) GetHeight() int { return len(cg.Rows) + cg.header.Height } func (cg *CompactGrid) SetX(x int) { cg.X = x } func (cg *CompactGrid) SetY(y int) { cg.Y = y } @@ -89,3 +89,11 @@ func (cg *CompactGrid) Buffer() ui.Buffer { func (cg *CompactGrid) AddRows(rows ...RowBufferer) { cg.Rows = append(cg.Rows, rows...) } + +func (cg *CompactGrid) rebuildHeader() { + cg.cols = newRowWidgets() + cg.header.clearFieldPars() + for _, col := range cg.cols { + cg.header.addFieldPar(col.Header()) + } +} diff --git a/cwidgets/compact/header.go b/cwidgets/compact/header.go index 2d5ecc9..44fe5af 100644 --- a/cwidgets/compact/header.go +++ b/cwidgets/compact/header.go @@ -14,7 +14,10 @@ type CompactHeader struct { } func NewCompactHeader() *CompactHeader { - return &CompactHeader{Height: 2} + return &CompactHeader{ + X: rowPadding, + Height: 2, + } } func (row *CompactHeader) GetHeight() int { @@ -51,6 +54,10 @@ func (row *CompactHeader) Buffer() ui.Buffer { return buf } +func (row *CompactHeader) clearFieldPars() { + row.pars = []*ui.Par{} +} + func (row *CompactHeader) addFieldPar(s string) { p := ui.NewPar(s) p.Height = row.Height diff --git a/cwidgets/compact/status.go b/cwidgets/compact/status.go index fae7bdc..b5bb261 100644 --- a/cwidgets/compact/status.go +++ b/cwidgets/compact/status.go @@ -2,6 +2,7 @@ package compact import ( "github.com/bcicen/ctop/models" + ui "github.com/gizak/termui" ) diff --git a/cwidgets/compact/text.go b/cwidgets/compact/text.go index 3ff9440..4b419a0 100644 --- a/cwidgets/compact/text.go +++ b/cwidgets/compact/text.go @@ -5,6 +5,7 @@ import ( "github.com/bcicen/ctop/cwidgets" "github.com/bcicen/ctop/models" + ui "github.com/gizak/termui" ) @@ -34,6 +35,9 @@ func NewCIDCol() CompactCol { func (w *CIDCol) SetMeta(m models.Meta) { w.Text = m.Get("id") + if len(w.Text) > 12 { + w.Text = w.Text[:12] + } } type NetCol struct { diff --git a/cwidgets/compact/util.go b/cwidgets/compact/util.go index ec9b149..9722c54 100644 --- a/cwidgets/compact/util.go +++ b/cwidgets/compact/util.go @@ -10,18 +10,6 @@ import ( const colSpacing = 1 -// per-column width. 0 == auto width -var colWidths = []int{ - 5, // status - 0, // name - 0, // cid - 0, // cpu - 0, // memory - 0, // net - 0, // io - 4, // pids -} - func centerParText(p *ui.Par) { var text string var padding string diff --git a/cwidgets/main.go b/cwidgets/main.go index fd2f68b..2bb8e89 100644 --- a/cwidgets/main.go +++ b/cwidgets/main.go @@ -11,3 +11,11 @@ type WidgetUpdater interface { SetMeta(models.Meta) SetMetrics(models.Metrics) } + +type NullWidgetUpdater struct{} + +// NullWidgetUpdater implements WidgetUpdater +func (wu NullWidgetUpdater) SetMeta(models.Meta) {} + +// NullWidgetUpdater implements WidgetUpdater +func (wu NullWidgetUpdater) SetMetrics(models.Metrics) {} diff --git a/grid.go b/grid.go index 655e000..9efced3 100644 --- a/grid.go +++ b/grid.go @@ -191,6 +191,10 @@ func Display() bool { menu = SortMenu ui.StopLoop() }) + ui.Handle("/sys/kbd/c", func(ui.Event) { + menu = ColumnsMenu + ui.StopLoop() + }) ui.Handle("/sys/kbd/S", func(ui.Event) { path, err := config.Write() if err == nil { diff --git a/menus.go b/menus.go index 16c18b7..1b15281 100644 --- a/menus.go +++ b/menus.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "strings" "time" "github.com/bcicen/ctop/config" @@ -104,7 +105,90 @@ func SortMenu() MenuFn { HandleKeys("exit", ui.StopLoop) ui.Handle("/sys/kbd/", func(ui.Event) { - config.Update("sortField", m.SelectedItem().Val) + config.Update("sortField", m.SelectedValue()) + ui.StopLoop() + }) + + ui.Render(m) + ui.Loop() + return nil +} + +func ColumnsMenu() MenuFn { + const ( + enabledStr = "[X]" + disabledStr = "[ ]" + padding = 2 + ) + + ui.Clear() + ui.DefaultEvtStream.ResetHandlers() + defer ui.DefaultEvtStream.ResetHandlers() + + m := menu.NewMenu() + m.Selectable = true + m.SortItems = false + m.BorderLabel = "Columns" + //m.SubText = "Enabled Columns" + + rebuild := func() { + // get padding for right alignment of enabled status + var maxLen int + for _, col := range config.GlobalColumns { + if len(col.Label) > maxLen { + maxLen = len(col.Label) + } + } + maxLen += padding + + // rebuild menu items + m.ClearItems() + for _, col := range config.GlobalColumns { + txt := col.Label + strings.Repeat(" ", maxLen-len(col.Label)) + if col.Enabled { + txt += enabledStr + } else { + txt += disabledStr + } + m.AddItems(menu.Item{col.Name, txt}) + } + } + + upFn := func() { + config.ColumnLeft(m.SelectedValue()) + m.Up() + rebuild() + } + + downFn := func() { + config.ColumnRight(m.SelectedValue()) + m.Down() + rebuild() + } + + toggleFn := func() { + config.ColumnToggle(m.SelectedValue()) + rebuild() + } + + rebuild() + + HandleKeys("up", m.Up) + HandleKeys("down", m.Down) + HandleKeys("enter", toggleFn) + HandleKeys("pgup", upFn) + HandleKeys("pgdown", downFn) + + ui.Handle("/sys/kbd/x", func(ui.Event) { toggleFn() }) + ui.Handle("/sys/kbd/", func(ui.Event) { toggleFn() }) + + HandleKeys("exit", func() { + cSource, err := cursor.cSuper.Get() + if err == nil { + for _, c := range cSource.All() { + c.RecreateWidgets() + } + } ui.StopLoop() }) @@ -202,7 +286,7 @@ func ContainerMenu() MenuFn { }) ui.Handle("/sys/kbd/", func(ui.Event) { - selected = m.SelectedItem().Val + selected = m.SelectedValue() ui.StopLoop() }) ui.Handle("/sys/kbd/", func(ui.Event) { @@ -321,7 +405,7 @@ func Confirm(txt string, fn func()) MenuFn { ui.Handle("/sys/kbd/y", func(ui.Event) { yes() }) ui.Handle("/sys/kbd/", func(ui.Event) { - switch m.SelectedItem().Val { + switch m.SelectedValue() { case "cancel": no() case "yes": diff --git a/models/main.go b/models/main.go index 86168b2..aa6e539 100644 --- a/models/main.go +++ b/models/main.go @@ -14,14 +14,10 @@ type Meta map[string]string func NewMeta(kvs ...string) Meta { m := make(Meta) - var k string - for i := 0; i < len(kvs)-1; i++ { - if k == "" { - k = kvs[i] - } else { - m[k] = kvs[i] - k = "" - } + var i int + for i < len(kvs)-1 { + m[kvs[i]] = kvs[i+1] + i += 2 } return m diff --git a/widgets/menu/main.go b/widgets/menu/main.go index 4e4e83b..476bab8 100644 --- a/widgets/menu/main.go +++ b/widgets/menu/main.go @@ -11,13 +11,14 @@ type Padding [2]int // x,y padding type Menu struct { ui.Block SortItems bool // enable automatic sorting of menu items + Selectable bool // whether menu is navigable SubText string // optional text to display before items TextFgColor ui.Attribute TextBgColor ui.Attribute - Selectable bool cursorPos int items Items padding Padding + toolTip *ToolTip } func NewMenu() *Menu { @@ -55,6 +56,11 @@ func (m *Menu) DelItem(s string) (success bool) { return success } +// ClearItems removes all current menu items +func (m *Menu) ClearItems() { + m.items = m.items[:0] +} + // Move cursor to an position by Item value or label func (m *Menu) SetCursor(s string) (success bool) { for n, i := range m.items { @@ -66,19 +72,19 @@ func (m *Menu) SetCursor(s string) (success bool) { return false } -// Sort menu items(if enabled) and re-calculate window size -func (m *Menu) refresh() { - if m.SortItems { - sort.Sort(m.items) - } - m.calcSize() - ui.Render(m) +// SetToolTip sets an optional tooltip string to show at bottom of screen +func (m *Menu) SetToolTip(lines ...string) { + m.toolTip = NewToolTip(lines...) } func (m *Menu) SelectedItem() Item { return m.items[m.cursorPos] } +func (m *Menu) SelectedValue() string { + return m.items[m.cursorPos].Val +} + func (m *Menu) Buffer() ui.Buffer { var cell ui.Cell buf := m.Block.Buffer() @@ -108,6 +114,10 @@ func (m *Menu) Buffer() ui.Buffer { } } + if m.toolTip != nil { + buf.Merge(m.toolTip.Buffer()) + } + return buf } @@ -125,6 +135,15 @@ func (m *Menu) Down() { } } +// Sort menu items(if enabled) and re-calculate window size +func (m *Menu) refresh() { + if m.SortItems { + sort.Sort(m.items) + } + m.calcSize() + ui.Render(m) +} + // Set width and height based on menu items func (m *Menu) calcSize() { m.Width = 7 // minimum width diff --git a/widgets/menu/tooltip.go b/widgets/menu/tooltip.go new file mode 100644 index 0000000..1909ede --- /dev/null +++ b/widgets/menu/tooltip.go @@ -0,0 +1,55 @@ +package menu + +import ( + ui "github.com/gizak/termui" +) + +type ToolTip struct { + ui.Block + Lines []string + TextFgColor ui.Attribute + TextBgColor ui.Attribute + padding Padding +} + +func NewToolTip(lines ...string) *ToolTip { + t := &ToolTip{ + Block: *ui.NewBlock(), + Lines: lines, + TextFgColor: ui.ThemeAttr("menu.text.fg"), + TextBgColor: ui.ThemeAttr("menu.text.bg"), + padding: Padding{2, 1}, + } + t.BorderFg = ui.ThemeAttr("menu.border.fg") + t.BorderLabelFg = ui.ThemeAttr("menu.label.fg") + t.X = 1 + t.Align() + return t +} + +func (t *ToolTip) Buffer() ui.Buffer { + var cell ui.Cell + buf := t.Block.Buffer() + + y := t.Y + t.padding[1] + + for n, line := range t.Lines { + x := t.X + t.padding[0] + for _, ch := range line { + cell = ui.Cell{Ch: ch, Fg: t.TextFgColor, Bg: t.TextBgColor} + buf.Set(x, y+n, cell) + x++ + } + } + + return buf +} + +// Set width and height based on screen size +func (t *ToolTip) Align() { + t.Width = ui.TermWidth() - (t.padding[0] * 2) + t.Height = len(t.Lines) + (t.padding[1] * 2) + t.Y = ui.TermHeight() - t.Height + + t.Block.Align() +}