485 lines
12 KiB
Go
485 lines
12 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright Authors of K9s
|
|
|
|
package ui
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/derailed/k9s/internal"
|
|
"github.com/derailed/k9s/internal/client"
|
|
"github.com/derailed/k9s/internal/config"
|
|
"github.com/derailed/k9s/internal/model"
|
|
"github.com/derailed/k9s/internal/render"
|
|
"github.com/derailed/k9s/internal/vul"
|
|
"github.com/derailed/tcell/v2"
|
|
"github.com/derailed/tview"
|
|
"github.com/rs/zerolog/log"
|
|
"golang.org/x/text/cases"
|
|
"golang.org/x/text/language"
|
|
)
|
|
|
|
const maxTruncate = 50
|
|
|
|
type (
|
|
// ColorerFunc represents a row colorer.
|
|
ColorerFunc func(ns string, evt render.RowEvent) tcell.Color
|
|
|
|
// DecorateFunc represents a row decorator.
|
|
DecorateFunc func(*render.TableData)
|
|
|
|
// SelectedRowFunc a table selection callback.
|
|
SelectedRowFunc func(r int)
|
|
)
|
|
|
|
// Table represents tabular data.
|
|
type Table struct {
|
|
gvr client.GVR
|
|
sortCol SortColumn
|
|
manualSort bool
|
|
header render.Header
|
|
Path string
|
|
Extras string
|
|
*SelectTable
|
|
actions KeyActions
|
|
cmdBuff *model.FishBuff
|
|
styles *config.Styles
|
|
viewSetting *config.ViewSetting
|
|
colorerFn render.ColorerFunc
|
|
decorateFn DecorateFunc
|
|
wide bool
|
|
toast bool
|
|
hasMetrics bool
|
|
}
|
|
|
|
// NewTable returns a new table view.
|
|
func NewTable(gvr client.GVR) *Table {
|
|
return &Table{
|
|
SelectTable: &SelectTable{
|
|
Table: tview.NewTable(),
|
|
model: model.NewTable(gvr),
|
|
marks: make(map[string]struct{}),
|
|
},
|
|
gvr: gvr,
|
|
actions: make(KeyActions),
|
|
cmdBuff: model.NewFishBuff('/', model.FilterBuffer),
|
|
sortCol: SortColumn{asc: true},
|
|
}
|
|
}
|
|
|
|
// Init initializes the component.
|
|
func (t *Table) Init(ctx context.Context) {
|
|
t.SetFixed(1, 0)
|
|
t.SetBorder(true)
|
|
t.SetBorderAttributes(tcell.AttrBold)
|
|
t.SetBorderPadding(0, 0, 1, 1)
|
|
t.SetSelectable(true, false)
|
|
t.SetSelectionChangedFunc(t.selectionChanged)
|
|
t.SetBackgroundColor(tcell.ColorDefault)
|
|
t.Select(1, 0)
|
|
if cfg, ok := ctx.Value(internal.KeyViewConfig).(*config.CustomView); ok && cfg != nil {
|
|
cfg.AddListener(t.GVR().String(), t)
|
|
}
|
|
t.styles = mustExtractStyles(ctx)
|
|
t.StylesChanged(t.styles)
|
|
}
|
|
|
|
// GVR returns a resource descriptor.
|
|
func (t *Table) GVR() client.GVR { return t.gvr }
|
|
|
|
// ViewSettingsChanged notifies listener the view configuration changed.
|
|
func (t *Table) ViewSettingsChanged(settings config.ViewSetting) {
|
|
t.viewSetting, t.manualSort = &settings, false
|
|
t.Refresh()
|
|
}
|
|
|
|
// StylesChanged notifies the skin changed.
|
|
func (t *Table) StylesChanged(s *config.Styles) {
|
|
t.SetBackgroundColor(s.Table().BgColor.Color())
|
|
t.SetBorderColor(s.Frame().Border.FgColor.Color())
|
|
t.SetBorderFocusColor(s.Frame().Border.FocusColor.Color())
|
|
t.SetSelectedStyle(
|
|
tcell.StyleDefault.Foreground(t.styles.Table().CursorFgColor.Color()).
|
|
Background(t.styles.Table().CursorBgColor.Color()).Attributes(tcell.AttrBold))
|
|
t.selFgColor = s.Table().CursorFgColor.Color()
|
|
t.selBgColor = s.Table().CursorBgColor.Color()
|
|
t.Refresh()
|
|
}
|
|
|
|
// ResetToast resets toast flag.
|
|
func (t *Table) ResetToast() {
|
|
t.toast = false
|
|
t.Refresh()
|
|
}
|
|
|
|
// ToggleToast toggles to show toast resources.
|
|
func (t *Table) ToggleToast() {
|
|
t.toast = !t.toast
|
|
t.Refresh()
|
|
}
|
|
|
|
// ToggleWide toggles wide col display.
|
|
func (t *Table) ToggleWide() {
|
|
t.wide = !t.wide
|
|
t.Refresh()
|
|
}
|
|
|
|
// Actions returns active menu bindings.
|
|
func (t *Table) Actions() KeyActions {
|
|
return t.actions
|
|
}
|
|
|
|
// Styles returns styling configurator.
|
|
func (t *Table) Styles() *config.Styles {
|
|
return t.styles
|
|
}
|
|
|
|
// FilterInput filters user commands.
|
|
func (t *Table) FilterInput(r rune) bool {
|
|
if !t.cmdBuff.IsActive() {
|
|
return false
|
|
}
|
|
t.cmdBuff.Add(r)
|
|
t.ClearSelection()
|
|
t.doUpdate(t.filtered(t.GetModel().Peek()))
|
|
t.UpdateTitle()
|
|
t.SelectFirstRow()
|
|
|
|
return true
|
|
}
|
|
|
|
// Filter filters out table data.
|
|
func (t *Table) Filter(q string) {
|
|
t.ClearSelection()
|
|
t.doUpdate(t.filtered(t.GetModel().Peek()))
|
|
t.UpdateTitle()
|
|
t.SelectFirstRow()
|
|
}
|
|
|
|
// Hints returns the view hints.
|
|
func (t *Table) Hints() model.MenuHints {
|
|
return t.actions.Hints()
|
|
}
|
|
|
|
// ExtraHints returns additional hints.
|
|
func (t *Table) ExtraHints() map[string]string {
|
|
return nil
|
|
}
|
|
|
|
// GetFilteredData fetch filtered tabular data.
|
|
func (t *Table) GetFilteredData() *render.TableData {
|
|
return t.filtered(t.GetModel().Peek())
|
|
}
|
|
|
|
// SetDecorateFn specifies the default row decorator.
|
|
func (t *Table) SetDecorateFn(f DecorateFunc) {
|
|
t.decorateFn = f
|
|
}
|
|
|
|
// SetColorerFn specifies the default colorer.
|
|
func (t *Table) SetColorerFn(f render.ColorerFunc) {
|
|
t.colorerFn = f
|
|
}
|
|
|
|
// SetSortCol sets in sort column index and order.
|
|
func (t *Table) SetSortCol(name string, asc bool) {
|
|
t.sortCol.name, t.sortCol.asc = name, asc
|
|
}
|
|
|
|
// Update table content.
|
|
func (t *Table) Update(data *render.TableData, hasMetrics bool) {
|
|
t.header = data.Header
|
|
if t.decorateFn != nil {
|
|
t.decorateFn(data)
|
|
}
|
|
t.hasMetrics = hasMetrics
|
|
t.doUpdate(t.filtered(data))
|
|
t.UpdateTitle()
|
|
}
|
|
|
|
func (t *Table) doUpdate(data *render.TableData) {
|
|
if client.IsAllNamespaces(data.Namespace) {
|
|
t.actions[KeyShiftP] = NewKeyAction("Sort Namespace", t.SortColCmd("NAMESPACE", true), false)
|
|
} else {
|
|
t.actions.Delete(KeyShiftP)
|
|
}
|
|
|
|
cols := t.header.Columns(t.wide)
|
|
if t.viewSetting != nil && len(t.viewSetting.Columns) > 0 {
|
|
cols = t.viewSetting.Columns
|
|
}
|
|
custData := data.Customize(cols, t.wide)
|
|
// The sortColumn settings in the configuration file are only used
|
|
// if the sortCol has not been modified manually
|
|
if t.viewSetting != nil && t.viewSetting.SortColumn != "" && !t.manualSort {
|
|
tokens := strings.Split(t.viewSetting.SortColumn, ":")
|
|
if custData.Header.IndexOf(tokens[0], false) >= 0 && !t.manualSort {
|
|
t.sortCol.name, t.sortCol.asc = tokens[0], true
|
|
if len(tokens) == 2 && tokens[1] == "desc" {
|
|
t.sortCol.asc = false
|
|
}
|
|
}
|
|
}
|
|
|
|
if t.sortCol.name == "" && client.IsAllNamespaces(data.Namespace) {
|
|
t.sortCol.name = "NAMESPACE"
|
|
}
|
|
if t.sortCol.name == "" || (t.sortCol.name == "NAMESPACE" && !client.IsAllNamespaces(data.Namespace)) && len(custData.Header) > 0 {
|
|
if idx := custData.Header.IndexOf("NAME", false); idx >= 0 {
|
|
t.sortCol.name = custData.Header[idx].Name
|
|
} else {
|
|
t.sortCol.name = custData.Header[0].Name
|
|
}
|
|
}
|
|
|
|
t.Clear()
|
|
fg := t.styles.Table().Header.FgColor.Color()
|
|
bg := t.styles.Table().Header.BgColor.Color()
|
|
|
|
var col int
|
|
for _, h := range custData.Header {
|
|
if h.Name == "NAMESPACE" && !t.GetModel().ClusterWide() {
|
|
continue
|
|
}
|
|
if h.MX && !t.hasMetrics {
|
|
continue
|
|
}
|
|
if h.VS && vul.ImgScanner == nil {
|
|
continue
|
|
}
|
|
|
|
t.AddHeaderCell(col, h)
|
|
c := t.GetCell(0, col)
|
|
c.SetBackgroundColor(bg)
|
|
c.SetTextColor(fg)
|
|
col++
|
|
}
|
|
colIndex := custData.Header.IndexOf(t.sortCol.name, false)
|
|
custData.RowEvents.Sort(
|
|
custData.Namespace,
|
|
colIndex,
|
|
custData.Header.IsTimeCol(colIndex),
|
|
custData.Header.IsMetricsCol(colIndex),
|
|
custData.Header.IsCapacityCol(colIndex),
|
|
t.sortCol.asc,
|
|
)
|
|
|
|
pads := make(MaxyPad, len(custData.Header))
|
|
ComputeMaxColumns(pads, t.sortCol.name, custData.Header, custData.RowEvents)
|
|
for row, re := range custData.RowEvents {
|
|
idx, _ := data.RowEvents.FindIndex(re.Row.ID)
|
|
t.buildRow(row+1, re, data.RowEvents[idx], custData.Header, pads)
|
|
}
|
|
t.updateSelection(true)
|
|
}
|
|
|
|
func (t *Table) buildRow(r int, re, ore render.RowEvent, h render.Header, pads MaxyPad) {
|
|
color := render.DefaultColorer
|
|
if t.colorerFn != nil {
|
|
color = t.colorerFn
|
|
}
|
|
|
|
marked := t.IsMarked(re.Row.ID)
|
|
var col int
|
|
for c, field := range re.Row.Fields {
|
|
if c >= len(h) {
|
|
log.Error().Msgf("field/header overflow detected for %q -- %d::%d. Check your mappings!", t.GVR(), c, len(h))
|
|
continue
|
|
}
|
|
|
|
if h[c].Name == "NAMESPACE" && !t.GetModel().ClusterWide() {
|
|
continue
|
|
}
|
|
if h[c].MX && !t.hasMetrics {
|
|
continue
|
|
}
|
|
if h[c].VS && vul.ImgScanner == nil {
|
|
continue
|
|
}
|
|
|
|
if !re.Deltas.IsBlank() && !h.IsTimeCol(c) {
|
|
field += Deltas(re.Deltas[c], field)
|
|
}
|
|
|
|
if h[c].Decorator != nil {
|
|
field = h[c].Decorator(field)
|
|
}
|
|
if h[c].Align == tview.AlignLeft {
|
|
field = formatCell(field, pads[c])
|
|
}
|
|
|
|
cell := tview.NewTableCell(field)
|
|
cell.SetExpansion(1)
|
|
cell.SetAlign(h[c].Align)
|
|
fgColor := color(t.GetModel().GetNamespace(), t.header, ore)
|
|
cell.SetTextColor(fgColor)
|
|
if marked {
|
|
cell.SetTextColor(t.styles.Table().MarkColor.Color())
|
|
}
|
|
if col == 0 {
|
|
cell.SetReference(re.Row.ID)
|
|
}
|
|
t.SetCell(r, col, cell)
|
|
col++
|
|
}
|
|
}
|
|
|
|
// SortColCmd designates a sorted column.
|
|
func (t *Table) SortColCmd(name string, asc bool) func(evt *tcell.EventKey) *tcell.EventKey {
|
|
return func(evt *tcell.EventKey) *tcell.EventKey {
|
|
t.manualSort = true
|
|
t.sortCol.asc = !t.sortCol.asc
|
|
if t.sortCol.name != name {
|
|
t.sortCol.asc = asc
|
|
}
|
|
t.sortCol.name = name
|
|
t.manualSort = true
|
|
t.Refresh()
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// SortInvertCmd reverses sorting order.
|
|
func (t *Table) SortInvertCmd(evt *tcell.EventKey) *tcell.EventKey {
|
|
t.sortCol.asc = !t.sortCol.asc
|
|
t.Refresh()
|
|
|
|
return nil
|
|
}
|
|
|
|
// ClearMarks clear out marked items.
|
|
func (t *Table) ClearMarks() {
|
|
t.SelectTable.ClearMarks()
|
|
t.Refresh()
|
|
}
|
|
|
|
// Refresh update the table data.
|
|
func (t *Table) Refresh() {
|
|
data := t.model.Peek()
|
|
if len(data.Header) == 0 {
|
|
return
|
|
}
|
|
// BOZO!! Really want to tell model reload now. Refactor!
|
|
t.Update(data, t.hasMetrics)
|
|
}
|
|
|
|
// GetSelectedRow returns the entire selected row.
|
|
func (t *Table) GetSelectedRow(path string) (render.Row, bool) {
|
|
data := t.model.Peek()
|
|
i, ok := data.RowEvents.FindIndex(path)
|
|
if !ok {
|
|
return render.Row{}, ok
|
|
}
|
|
return data.RowEvents[i].Row, true
|
|
}
|
|
|
|
// NameColIndex returns the index of the resource name column.
|
|
func (t *Table) NameColIndex() int {
|
|
col := 0
|
|
if client.IsClusterScoped(t.GetModel().GetNamespace()) {
|
|
return col
|
|
}
|
|
if t.GetModel().ClusterWide() {
|
|
col++
|
|
}
|
|
return col
|
|
}
|
|
|
|
// AddHeaderCell configures a table cell header.
|
|
func (t *Table) AddHeaderCell(col int, h render.HeaderColumn) {
|
|
sortCol := h.Name == t.sortCol.name
|
|
c := tview.NewTableCell(sortIndicator(sortCol, t.sortCol.asc, t.styles.Table(), h.Name))
|
|
c.SetExpansion(1)
|
|
c.SetAlign(h.Align)
|
|
t.SetCell(0, col, c)
|
|
}
|
|
|
|
func (t *Table) filtered(data *render.TableData) *render.TableData {
|
|
filtered := data
|
|
if t.toast {
|
|
filtered = filterToast(data)
|
|
}
|
|
if t.cmdBuff.Empty() || IsLabelSelector(t.cmdBuff.GetText()) {
|
|
return filtered
|
|
}
|
|
|
|
q := t.cmdBuff.GetText()
|
|
if IsFuzzySelector(q) {
|
|
return fuzzyFilter(q[2:], filtered)
|
|
}
|
|
|
|
filtered, err := rxFilter(q, IsInverseSelector(q), filtered)
|
|
if err != nil {
|
|
log.Error().Err(errors.New("Invalid filter expression")).Msg("Regexp")
|
|
// t.cmdBuff.ClearText(true)
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
// CmdBuff returns the associated command buffer.
|
|
func (t *Table) CmdBuff() *model.FishBuff {
|
|
return t.cmdBuff
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// UpdateTitle refreshes the table title.
|
|
func (t *Table) UpdateTitle() {
|
|
t.SetTitle(t.styleTitle())
|
|
}
|
|
|
|
func (t *Table) styleTitle() string {
|
|
rc := int64(t.GetRowCount())
|
|
if rc > 0 {
|
|
rc--
|
|
}
|
|
|
|
base := cases.Title(language.Und, cases.NoLower).String(t.gvr.R())
|
|
ns := t.GetModel().GetNamespace()
|
|
if client.IsClusterWide(ns) || ns == client.NotNamespaced {
|
|
ns = client.NamespaceAll
|
|
}
|
|
path := t.Path
|
|
if path != "" {
|
|
cns, n := client.Namespaced(path)
|
|
if cns == client.ClusterScope {
|
|
ns = n
|
|
} else {
|
|
ns = path
|
|
}
|
|
}
|
|
if t.Extras != "" {
|
|
ns = t.Extras
|
|
}
|
|
var title string
|
|
if ns == client.ClusterScope {
|
|
title = SkinTitle(fmt.Sprintf(TitleFmt, base, render.AsThousands(rc)), t.styles.Frame())
|
|
} else {
|
|
title = SkinTitle(fmt.Sprintf(NSTitleFmt, base, ns, render.AsThousands(rc)), t.styles.Frame())
|
|
}
|
|
|
|
buff := t.cmdBuff.GetText()
|
|
if IsLabelSelector(buff) {
|
|
buff = truncate(TrimLabelSelector(buff), maxTruncate)
|
|
} else if l := t.GetModel().GetLabelFilter(); l != "" {
|
|
buff = truncate(l, maxTruncate)
|
|
}
|
|
|
|
if buff == "" {
|
|
return title
|
|
}
|
|
|
|
return title + SkinTitle(fmt.Sprintf(SearchFmt, buff), t.styles.Frame())
|
|
}
|