568 lines
12 KiB
Go
568 lines
12 KiB
Go
package ui
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/derailed/k9s/internal/config"
|
|
"github.com/derailed/k9s/internal/model"
|
|
"github.com/derailed/k9s/internal/resource"
|
|
"github.com/derailed/tview"
|
|
"github.com/gdamore/tcell"
|
|
"github.com/rs/zerolog/log"
|
|
"github.com/sahilm/fuzzy"
|
|
"k8s.io/apimachinery/pkg/util/duration"
|
|
)
|
|
|
|
type (
|
|
// ColorerFunc represents a row colorer.
|
|
ColorerFunc func(ns string, evt *resource.RowEvent) tcell.Color
|
|
|
|
// SelectedRowFunc a table selection callback.
|
|
SelectedRowFunc func(r, c int)
|
|
)
|
|
|
|
// Table represents tabular data.
|
|
type Table struct {
|
|
*tview.Table
|
|
|
|
baseTitle string
|
|
data resource.TableData
|
|
actions KeyActions
|
|
cmdBuff *CmdBuff
|
|
styles *config.Styles
|
|
activeNS string
|
|
sortCol SortColumn
|
|
sortFn SortFn
|
|
colorerFn ColorerFunc
|
|
selectedItem string
|
|
selectedRow int
|
|
selectedFn func(string) string
|
|
selListeners []SelectedRowFunc
|
|
marks map[string]bool
|
|
}
|
|
|
|
// NewTable returns a new table view.
|
|
func NewTable(title string) *Table {
|
|
return &Table{
|
|
Table: tview.NewTable(),
|
|
actions: make(KeyActions),
|
|
cmdBuff: NewCmdBuff('/', FilterBuff),
|
|
baseTitle: title,
|
|
sortCol: SortColumn{0, 0, true},
|
|
marks: make(map[string]bool),
|
|
}
|
|
}
|
|
|
|
func (t *Table) Init(ctx context.Context) {
|
|
t.styles = ctx.Value(KeyStyles).(*config.Styles)
|
|
|
|
t.SetFixed(1, 0)
|
|
t.SetBorder(true)
|
|
t.SetBackgroundColor(config.AsColor(t.styles.Table().BgColor))
|
|
t.SetBorderColor(config.AsColor(t.styles.Table().FgColor))
|
|
t.SetBorderFocusColor(config.AsColor(t.styles.Frame().Border.FocusColor))
|
|
t.SetBorderAttributes(tcell.AttrBold)
|
|
t.SetBorderPadding(0, 0, 1, 1)
|
|
t.SetSelectable(true, false)
|
|
t.SetSelectedStyle(
|
|
tcell.ColorBlack,
|
|
config.AsColor(t.styles.Table().CursorColor),
|
|
tcell.AttrBold,
|
|
)
|
|
|
|
t.SetSelectionChangedFunc(t.selChanged)
|
|
t.SetInputCapture(t.keyboard)
|
|
|
|
}
|
|
|
|
// SendKey sends an keyboard event (testing only!).
|
|
func (t *Table) SendKey(evt *tcell.EventKey) {
|
|
t.keyboard(evt)
|
|
}
|
|
|
|
// GetRow retrieves the entire selected row.
|
|
func (t *Table) GetRow() resource.Row {
|
|
r := make(resource.Row, t.GetColumnCount())
|
|
for i := 0; i < t.GetColumnCount(); i++ {
|
|
c := t.GetCell(t.selectedRow, i)
|
|
r[i] = strings.TrimSpace(c.Text)
|
|
}
|
|
return r
|
|
}
|
|
|
|
// AddSelectedRowListener add a new selected row listener.
|
|
func (t *Table) AddSelectedRowListener(f SelectedRowFunc) {
|
|
t.selListeners = append(t.selListeners, f)
|
|
}
|
|
|
|
func (t *Table) selChanged(r, c int) {
|
|
t.selectedRow = r
|
|
t.updateSelectedItem(r)
|
|
if r == 0 {
|
|
return
|
|
}
|
|
|
|
cell := t.GetCell(r, c)
|
|
t.SetSelectedStyle(
|
|
tcell.ColorBlack,
|
|
cell.Color,
|
|
tcell.AttrBold,
|
|
)
|
|
|
|
for _, f := range t.selListeners {
|
|
f(r, c)
|
|
}
|
|
}
|
|
|
|
// UpdateSelection refresh selected row.
|
|
func (t *Table) updateSelection(broadcast bool) {
|
|
t.SelectRow(t.selectedRow, broadcast)
|
|
}
|
|
|
|
// SelectRow select a given row by index.
|
|
func (t *Table) SelectRow(r int, broadcast bool) {
|
|
if !broadcast {
|
|
t.SetSelectionChangedFunc(nil)
|
|
}
|
|
defer t.SetSelectionChangedFunc(t.selChanged)
|
|
t.Select(r, 0)
|
|
t.updateSelectedItem(r)
|
|
}
|
|
|
|
func (t *Table) updateSelectedItem(r int) {
|
|
if r == 0 || t.GetCell(r, 0) == nil {
|
|
t.selectedItem = ""
|
|
return
|
|
}
|
|
|
|
col0 := TrimCell(t, r, 0)
|
|
switch t.activeNS {
|
|
case resource.NotNamespaced:
|
|
t.selectedItem = col0
|
|
case resource.AllNamespace, resource.AllNamespaces:
|
|
t.selectedItem = path.Join(col0, TrimCell(t, r, 1))
|
|
default:
|
|
t.selectedItem = path.Join(t.activeNS, col0)
|
|
}
|
|
}
|
|
|
|
// SetSelectedFn defines a function that cleanse the current selection.
|
|
func (t *Table) SetSelectedFn(f func(string) string) {
|
|
t.selectedFn = f
|
|
}
|
|
|
|
// RowSelected checks if there is an active row selection.
|
|
func (t *Table) RowSelected() bool {
|
|
return t.selectedItem != ""
|
|
}
|
|
|
|
// GetSelectedCell returns the content of a cell for the currently selected row.
|
|
func (t *Table) GetSelectedCell(col int) string {
|
|
return TrimCell(t, t.selectedRow, col)
|
|
}
|
|
|
|
// GetSelectedRow fetch the currently selected row index.
|
|
func (t *Table) GetSelectedRowIndex() int {
|
|
return t.selectedRow
|
|
}
|
|
|
|
// GetSelectedItem returns the currently selected item name.
|
|
func (t *Table) GetSelectedItem() string {
|
|
if t.selectedFn != nil {
|
|
return t.selectedFn(t.selectedItem)
|
|
}
|
|
return t.selectedItem
|
|
}
|
|
|
|
// GetSelectedItems return currently marked or selected items names.
|
|
func (t *Table) GetSelectedItems() []string {
|
|
if len(t.marks) > 0 {
|
|
var items []string
|
|
for item, marked := range t.marks {
|
|
if marked {
|
|
items = append(items, item)
|
|
}
|
|
}
|
|
return items
|
|
}
|
|
return []string{t.GetSelectedItem()}
|
|
}
|
|
|
|
func (t *Table) keyboard(evt *tcell.EventKey) *tcell.EventKey {
|
|
key := evt.Key()
|
|
if key == tcell.KeyRune {
|
|
if t.SearchBuff().IsActive() {
|
|
t.SearchBuff().Add(evt.Rune())
|
|
t.ClearSelection()
|
|
t.doUpdate(t.filtered())
|
|
t.UpdateTitle()
|
|
t.SelectFirstRow()
|
|
return nil
|
|
}
|
|
key = asKey(evt)
|
|
}
|
|
|
|
if a, ok := t.actions[key]; ok {
|
|
return a.Action(evt)
|
|
}
|
|
|
|
return evt
|
|
}
|
|
|
|
// GetData fetch tabular data.
|
|
func (t *Table) GetData() resource.TableData {
|
|
return t.data
|
|
}
|
|
|
|
// GetFilteredData fetch filtered tabular data.
|
|
func (t *Table) GetFilteredData() resource.TableData {
|
|
return t.filtered()
|
|
}
|
|
|
|
// SetBaseTitle set the table title.
|
|
func (t *Table) SetBaseTitle(s string) {
|
|
t.baseTitle = s
|
|
}
|
|
|
|
// GetBaseTitle fetch the current title.
|
|
func (t *Table) GetBaseTitle() string {
|
|
return t.baseTitle
|
|
}
|
|
|
|
// SetColorerFn set the row colorer.
|
|
func (t *Table) SetColorerFn(f ColorerFunc) {
|
|
t.colorerFn = f
|
|
}
|
|
|
|
// ActiveNS get the resource namespace.
|
|
func (t *Table) ActiveNS() string {
|
|
return t.activeNS
|
|
}
|
|
|
|
// SetActiveNS set the resource namespace.
|
|
func (t *Table) SetActiveNS(ns string) {
|
|
t.activeNS = ns
|
|
}
|
|
|
|
// SetSortCol sets in sort column index and order.
|
|
func (t *Table) SetSortCol(index, count int, asc bool) {
|
|
t.sortCol.index, t.sortCol.colCount, t.sortCol.asc = index, count, asc
|
|
}
|
|
|
|
// Update table content.
|
|
func (t *Table) Update(data resource.TableData) {
|
|
t.data = data
|
|
if t.cmdBuff.Empty() {
|
|
t.doUpdate(t.data)
|
|
} else {
|
|
t.doUpdate(t.filtered())
|
|
}
|
|
t.UpdateTitle()
|
|
t.updateSelection(true)
|
|
}
|
|
|
|
func (t *Table) doUpdate(data resource.TableData) {
|
|
t.activeNS = data.Namespace
|
|
if t.activeNS == resource.AllNamespaces && t.activeNS != "*" {
|
|
t.actions[KeyShiftP] = NewKeyAction("Sort Namespace", t.SortColCmd(-2), false)
|
|
} else {
|
|
delete(t.actions, KeyShiftP)
|
|
}
|
|
t.Clear()
|
|
|
|
t.adjustSorter(data)
|
|
|
|
var row int
|
|
fg := config.AsColor(t.styles.Table().Header.FgColor)
|
|
bg := config.AsColor(t.styles.Table().Header.BgColor)
|
|
for col, h := range data.Header {
|
|
t.AddHeaderCell(data.NumCols[h], col, h)
|
|
c := t.GetCell(0, col)
|
|
c.SetBackgroundColor(bg)
|
|
c.SetTextColor(fg)
|
|
}
|
|
row++
|
|
|
|
t.sort(data, row)
|
|
}
|
|
|
|
// SortColCmd designates a sorted column.
|
|
func (t *Table) SortColCmd(col int) func(evt *tcell.EventKey) *tcell.EventKey {
|
|
return func(evt *tcell.EventKey) *tcell.EventKey {
|
|
t.sortCol.asc = true
|
|
switch col {
|
|
case -2:
|
|
t.sortCol.index = 0
|
|
case -1:
|
|
t.sortCol.index = t.GetColumnCount() - 1
|
|
default:
|
|
t.sortCol.index = t.NameColIndex() + col
|
|
|
|
}
|
|
t.Refresh()
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (t *Table) adjustSorter(data resource.TableData) {
|
|
// Going from namespace to non namespace or vice-versa?
|
|
switch {
|
|
case t.sortCol.colCount == 0:
|
|
case len(data.Header) > t.sortCol.colCount:
|
|
t.sortCol.index++
|
|
case len(data.Header) < t.sortCol.colCount:
|
|
t.sortCol.index--
|
|
}
|
|
t.sortCol.colCount = len(data.Header)
|
|
if t.sortCol.index < 0 {
|
|
t.sortCol.index = 0
|
|
}
|
|
}
|
|
|
|
func (t *Table) sort(data resource.TableData, row int) {
|
|
pads := make(MaxyPad, len(data.Header))
|
|
ComputeMaxColumns(pads, t.sortCol.index, data)
|
|
|
|
sortFn := defaultSort
|
|
if t.sortFn != nil {
|
|
sortFn = t.sortFn
|
|
}
|
|
prim, sec := sortAllRows(t.sortCol, data.Rows, sortFn)
|
|
for _, pk := range prim {
|
|
for _, sk := range sec[pk] {
|
|
t.buildRow(row, data, sk, pads)
|
|
row++
|
|
}
|
|
}
|
|
}
|
|
|
|
func (t *Table) buildRow(row int, data resource.TableData, sk string, pads MaxyPad) {
|
|
f := DefaultColorer
|
|
if t.colorerFn != nil {
|
|
f = t.colorerFn
|
|
}
|
|
m := t.isMarked(sk)
|
|
for col, field := range data.Rows[sk].Fields {
|
|
header := data.Header[col]
|
|
field, align := t.formatCell(data.NumCols[header], header, field+Deltas(data.Rows[sk].Deltas[col], field), pads[col])
|
|
c := tview.NewTableCell(field)
|
|
{
|
|
c.SetExpansion(1)
|
|
c.SetAlign(align)
|
|
c.SetTextColor(f(data.Namespace, data.Rows[sk]))
|
|
if m {
|
|
c.SetBackgroundColor(config.AsColor(t.styles.Table().MarkColor))
|
|
}
|
|
}
|
|
t.SetCell(row, col, c)
|
|
}
|
|
}
|
|
|
|
func (t *Table) formatCell(numerical bool, header, field string, padding int) (string, int) {
|
|
if header == "AGE" {
|
|
dur, err := time.ParseDuration(field)
|
|
if err == nil {
|
|
field = duration.HumanDuration(dur)
|
|
}
|
|
}
|
|
|
|
if numerical || cpuRX.MatchString(header) || memRX.MatchString(header) {
|
|
return field, tview.AlignRight
|
|
}
|
|
|
|
align := tview.AlignLeft
|
|
if IsASCII(field) {
|
|
return Pad(field, padding), align
|
|
}
|
|
|
|
return field, align
|
|
}
|
|
|
|
// Refresh update the table data.
|
|
func (t *Table) Refresh() {
|
|
t.Update(t.data)
|
|
}
|
|
|
|
// NameColIndex returns the index of the resource name column.
|
|
func (t *Table) NameColIndex() int {
|
|
col := 0
|
|
if t.activeNS == resource.AllNamespaces {
|
|
col++
|
|
}
|
|
return col
|
|
}
|
|
|
|
// AddHeaderCell configures a table cell header.
|
|
func (t *Table) AddHeaderCell(numerical bool, col int, name string) {
|
|
c := tview.NewTableCell(sortIndicator(t.sortCol, t.styles.Table(), col, name))
|
|
c.SetExpansion(1)
|
|
if numerical || cpuRX.MatchString(name) || memRX.MatchString(name) {
|
|
c.SetAlign(tview.AlignRight)
|
|
}
|
|
t.SetCell(0, col, c)
|
|
}
|
|
|
|
func (t *Table) filtered() resource.TableData {
|
|
if t.cmdBuff.Empty() || IsLabelSelector(t.cmdBuff.String()) {
|
|
return t.data
|
|
}
|
|
|
|
q := t.cmdBuff.String()
|
|
if isFuzzySelector(q) {
|
|
return t.fuzzyFilter(q[2:])
|
|
}
|
|
|
|
return t.rxFilter(q)
|
|
}
|
|
|
|
func (t *Table) rxFilter(q string) resource.TableData {
|
|
rx, err := regexp.Compile(`(?i)` + t.cmdBuff.String())
|
|
if err != nil {
|
|
log.Error().Err(errors.New("Invalid filter expression")).Msg("Regexp")
|
|
t.cmdBuff.Clear()
|
|
return t.data
|
|
}
|
|
|
|
filtered := resource.TableData{
|
|
Header: t.data.Header,
|
|
Rows: resource.RowEvents{},
|
|
Namespace: t.data.Namespace,
|
|
}
|
|
for k, row := range t.data.Rows {
|
|
f := strings.Join(row.Fields, " ")
|
|
if rx.MatchString(f) {
|
|
filtered.Rows[k] = row
|
|
}
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
func (t *Table) fuzzyFilter(q string) resource.TableData {
|
|
var ss, kk []string
|
|
for k, row := range t.data.Rows {
|
|
ss = append(ss, row.Fields[t.NameColIndex()])
|
|
kk = append(kk, k)
|
|
}
|
|
|
|
filtered := resource.TableData{
|
|
Header: t.data.Header,
|
|
Rows: resource.RowEvents{},
|
|
Namespace: t.data.Namespace,
|
|
}
|
|
mm := fuzzy.Find(q, ss)
|
|
for _, m := range mm {
|
|
filtered.Rows[kk[m.Index]] = t.data.Rows[kk[m.Index]]
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
// KeyBindings returns the bounded keys.
|
|
func (t *Table) KeyBindings() KeyActions {
|
|
return t.actions
|
|
}
|
|
|
|
// SearchBuff returns the associated command buffer.
|
|
func (t *Table) SearchBuff() *CmdBuff {
|
|
return t.cmdBuff
|
|
}
|
|
|
|
// ClearSelection reset selected row.
|
|
func (t *Table) ClearSelection() {
|
|
t.Select(0, 0)
|
|
t.ScrollToBeginning()
|
|
}
|
|
|
|
// SelectFirstRow select first data row if any.
|
|
func (t *Table) SelectFirstRow() {
|
|
if t.GetRowCount() > 0 {
|
|
t.Select(1, 0)
|
|
}
|
|
}
|
|
|
|
// ShowDeleted marks row as deleted.
|
|
func (t *Table) ShowDeleted() {
|
|
r, _ := t.GetSelection()
|
|
cols := t.GetColumnCount()
|
|
for x := 0; x < cols; x++ {
|
|
t.GetCell(r, x).SetAttributes(tcell.AttrDim)
|
|
}
|
|
}
|
|
|
|
// SetActions sets up keyboard action listener.
|
|
func (t *Table) AddActions(aa KeyActions) {
|
|
for k, a := range aa {
|
|
t.actions[k] = a
|
|
}
|
|
}
|
|
|
|
// RmAction delete a keyed action.
|
|
func (t *Table) RmAction(kk ...tcell.Key) {
|
|
for _, k := range kk {
|
|
delete(t.actions, k)
|
|
}
|
|
}
|
|
|
|
// Hints options
|
|
func (t *Table) Hints() model.MenuHints {
|
|
if t.actions != nil {
|
|
return t.actions.Hints()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateTitle refreshes the table title.
|
|
func (t *Table) UpdateTitle() {
|
|
var title string
|
|
|
|
rc := t.GetRowCount()
|
|
if rc > 0 {
|
|
rc--
|
|
}
|
|
switch t.activeNS {
|
|
case resource.NotNamespaced, "*":
|
|
title = skinTitle(fmt.Sprintf(titleFmt, t.baseTitle, rc), t.styles.Frame())
|
|
default:
|
|
ns := t.activeNS
|
|
if ns == resource.AllNamespaces {
|
|
ns = resource.AllNamespace
|
|
}
|
|
title = skinTitle(fmt.Sprintf(nsTitleFmt, t.baseTitle, ns, rc), t.styles.Frame())
|
|
}
|
|
|
|
if !t.cmdBuff.Empty() {
|
|
cmd := t.cmdBuff.String()
|
|
if IsLabelSelector(cmd) {
|
|
cmd = TrimLabelSelector(cmd)
|
|
}
|
|
title += skinTitle(fmt.Sprintf(SearchFmt, cmd), t.styles.Frame())
|
|
}
|
|
t.SetTitle(title)
|
|
}
|
|
|
|
// SortInvertCmd reverses sorting order.
|
|
func (t *Table) SortInvertCmd(evt *tcell.EventKey) *tcell.EventKey {
|
|
t.sortCol.asc = !t.sortCol.asc
|
|
t.Refresh()
|
|
|
|
return nil
|
|
}
|
|
|
|
// ToggleMark toggles marked row
|
|
func (t *Table) ToggleMark() {
|
|
t.marks[t.GetSelectedItem()] = !t.marks[t.GetSelectedItem()]
|
|
}
|
|
|
|
func (t *Table) isMarked(item string) bool {
|
|
return t.marks[item]
|
|
}
|