k9s/internal/view/app.go

789 lines
18 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright Authors of K9s
package view
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/signal"
"runtime"
"sort"
"strings"
"sync/atomic"
"syscall"
"time"
"github.com/cenkalti/backoff/v4"
"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/slogs"
"github.com/derailed/k9s/internal/ui"
"github.com/derailed/k9s/internal/ui/dialog"
"github.com/derailed/k9s/internal/view/cmd"
"github.com/derailed/k9s/internal/vul"
"github.com/derailed/k9s/internal/watch"
"github.com/derailed/tcell/v2"
"github.com/derailed/tview"
)
// ExitStatus indicates UI exit conditions.
var ExitStatus = ""
const (
splashDelay = 1 * time.Second
clusterRefresh = 15 * time.Second
clusterInfoWidth = 50
clusterInfoPad = 15
)
// App represents an application view.
type App struct {
version string
*ui.App
Content *PageStack
command *Command
factory *watch.Factory
cancelFn context.CancelFunc
clusterModel *model.ClusterInfo
cmdHistory *model.History
filterHistory *model.History
conRetry int32
showHeader bool
showLogo bool
showCrumbs bool
}
// NewApp returns a K9s app instance.
func NewApp(cfg *config.Config) *App {
a := App{
App: ui.NewApp(cfg, cfg.K9s.ActiveContextName()),
cmdHistory: model.NewHistory(model.MaxHistory),
filterHistory: model.NewHistory(model.MaxHistory),
Content: NewPageStack(),
}
a.ReloadStyles()
a.Views()["statusIndicator"] = ui.NewStatusIndicator(a.App, a.Styles)
a.Views()["clusterInfo"] = NewClusterInfo(&a)
return &a
}
// ReloadStyles reloads skin file.
func (a *App) ReloadStyles() {
a.RefreshStyles(a)
}
// UpdateClusterInfo updates clusterInfo panel
func (a *App) UpdateClusterInfo() {
if a.factory != nil {
a.clusterModel.Reset(a.factory)
}
}
// ConOK checks the connection is cool, returns false otherwise.
func (a *App) ConOK() bool {
return atomic.LoadInt32(&a.conRetry) == 0
}
// Init initializes the application.
func (a *App) Init(version string, rate int) error {
a.version = model.NormalizeVersion(version)
ctx := context.WithValue(context.Background(), internal.KeyApp, a)
if err := a.Content.Init(ctx); err != nil {
return err
}
a.Content.Stack.AddListener(a.Crumbs())
a.Content.Stack.AddListener(a.Menu())
a.App.Init()
a.SetInputCapture(a.keyboard)
a.bindKeys()
if a.Conn() == nil {
return errors.New("no client connection detected")
}
ns := a.Config.ActiveNamespace()
a.factory = watch.NewFactory(a.Conn())
a.initFactory(ns)
a.clusterModel = model.NewClusterInfo(a.factory, a.version, a.Config.K9s)
a.clusterModel.AddListener(a.clusterInfo())
a.clusterModel.AddListener(a.statusIndicator())
if a.Conn().ConnectionOK() {
a.clusterModel.Refresh()
a.clusterInfo().Init()
}
a.command = NewCommand(a)
if err := a.command.Init(a.Config.ContextAliasesPath()); err != nil {
return err
}
a.CmdBuff().SetSuggestionFn(a.suggestCommand())
a.layout(ctx)
a.initSignals()
if a.Config.K9s.ImageScans.Enable {
a.initImgScanner(version)
}
a.ReloadStyles()
return nil
}
func (a *App) stopImgScanner() {
if vul.ImgScanner != nil {
vul.ImgScanner.Stop()
}
}
func (a *App) initImgScanner(version string) {
defer func(t time.Time) {
slog.Debug("Scanner init time", slogs.Elapsed, time.Since(t))
}(time.Now())
vul.ImgScanner = vul.NewImageScanner(a.Config.K9s.ImageScans, slog.Default())
go vul.ImgScanner.Init("k9s", version)
}
func (a *App) layout(ctx context.Context) {
flash := ui.NewFlash(a.App)
go flash.Watch(ctx, a.Flash().Channel())
main := tview.NewFlex().SetDirection(tview.FlexRow)
main.AddItem(a.statusIndicator(), 1, 1, false)
main.AddItem(a.Content, 0, 10, true)
if !a.Config.K9s.IsCrumbsless() {
main.AddItem(a.Crumbs(), 1, 1, false)
}
main.AddItem(flash, 1, 1, false)
a.Main.AddPage("main", main, true, false)
a.Main.AddPage("splash", ui.NewSplash(a.Styles, a.version), true, true)
a.toggleHeader(!a.Config.K9s.IsHeadless(), !a.Config.K9s.IsLogoless())
}
func (a *App) initSignals() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGHUP)
go func(sig chan os.Signal) {
<-sig
os.Exit(0)
}(sig)
}
func (a *App) suggestCommand() model.SuggestionFunc {
contextNames, err := a.contextNames()
if err != nil {
slog.Error("Failed to list contexts", slogs.Error, err)
}
return func(s string) (entries sort.StringSlice) {
if s == "" {
if a.cmdHistory.Empty() {
return
}
return a.cmdHistory.List()
}
ls := strings.ToLower(s)
for _, k := range a.command.alias.Aliases.Keys() {
if suggest, ok := cmd.ShouldAddSuggest(ls, k); ok {
entries = append(entries, suggest)
}
}
namespaceNames, err := a.factory.Client().ValidNamespaceNames()
if err != nil {
slog.Error("Failed to obtain list of namespaces", slogs.Error, err)
}
entries = append(entries, cmd.SuggestSubCommand(s, namespaceNames, contextNames)...)
if len(entries) == 0 {
return nil
}
entries.Sort()
return
}
}
func (a *App) contextNames() ([]string, error) {
contexts, err := a.factory.Client().Config().Contexts()
if err != nil {
return nil, err
}
contextNames := make([]string, 0, len(contexts))
for ctxName := range contexts {
contextNames = append(contextNames, ctxName)
}
return contextNames, nil
}
func (a *App) keyboard(evt *tcell.EventKey) *tcell.EventKey {
if k, ok := a.HasAction(ui.AsKey(evt)); ok && !a.Content.IsTopDialog() {
return k.Action(evt)
}
return evt
}
func (a *App) bindKeys() {
a.AddActions(ui.NewKeyActionsFromMap(ui.KeyMap{
ui.KeyShift9: ui.NewSharedKeyAction("DumpGOR", a.dumpGOR, false),
tcell.KeyCtrlE: ui.NewSharedKeyAction("ToggleHeader", a.toggleHeaderCmd, false),
tcell.KeyCtrlG: ui.NewSharedKeyAction("toggleCrumbs", a.toggleCrumbsCmd, false),
ui.KeyHelp: ui.NewSharedKeyAction("Help", a.helpCmd, false),
ui.KeyLeftBracket: ui.NewSharedKeyAction("Go Back", a.previousCommand, false),
ui.KeyRightBracket: ui.NewSharedKeyAction("Go Forward", a.nextCommand, false),
ui.KeyDash: ui.NewSharedKeyAction("Last View", a.lastCommand, false),
tcell.KeyCtrlA: ui.NewSharedKeyAction("Aliases", a.aliasCmd, false),
tcell.KeyEnter: ui.NewKeyAction("Goto", a.gotoCmd, false),
tcell.KeyCtrlC: ui.NewKeyAction("Quit", a.quitCmd, false),
}))
}
func (a *App) dumpGOR(evt *tcell.EventKey) *tcell.EventKey {
slog.Debug("GOR", slogs.GOR, runtime.NumGoroutine())
bb := make([]byte, 5_000_000)
runtime.Stack(bb, true)
slog.Debug("GOR stack", slogs.Stack, string(bb))
return evt
}
// ActiveView returns the currently active view.
func (a *App) ActiveView() model.Component {
return a.Content.GetPrimitive("main").(model.Component)
}
func (a *App) toggleHeader(header, logo bool) {
a.showHeader, a.showLogo = header, logo
flex, ok := a.Main.GetPrimitive("main").(*tview.Flex)
if !ok {
slog.Error("Expecting flex view main panel. Exiting!")
os.Exit(1)
}
if a.showHeader {
flex.RemoveItemAtIndex(0)
flex.AddItemAtIndex(0, a.buildHeader(), 7, 1, false)
} else {
flex.RemoveItemAtIndex(0)
flex.AddItemAtIndex(0, a.statusIndicator(), 1, 1, false)
}
}
func (a *App) toggleCrumbs(flag bool) {
a.showCrumbs = flag
flex, ok := a.Main.GetPrimitive("main").(*tview.Flex)
if !ok {
slog.Error("Expecting valid flex view main panel. Exiting!")
os.Exit(1)
}
if a.showCrumbs {
if _, ok := flex.ItemAt(2).(*ui.Crumbs); !ok {
flex.AddItemAtIndex(2, a.Crumbs(), 1, 1, false)
}
} else {
flex.RemoveItemAtIndex(2)
}
}
func (a *App) buildHeader() tview.Primitive {
header := tview.NewFlex()
header.SetBackgroundColor(a.Styles.BgColor())
header.SetDirection(tview.FlexColumn)
if !a.showHeader {
return header
}
clWidth := clusterInfoWidth
if a.Conn().ConnectionOK() {
n, err := a.Conn().Config().CurrentClusterName()
if err == nil {
size := len(n) + clusterInfoPad
if size > clWidth {
clWidth = size
}
}
}
header.AddItem(a.clusterInfo(), clWidth, 1, false)
header.AddItem(a.Menu(), 0, 1, false)
if a.showLogo {
header.AddItem(a.Logo(), 26, 1, false)
}
return header
}
// Halt stop the application event loop.
func (a *App) Halt() {
if a.cancelFn != nil {
a.cancelFn()
a.cancelFn = nil
}
}
// Resume restarts the app event loop.
func (a *App) Resume() {
var ctx context.Context
ctx, a.cancelFn = context.WithCancel(context.Background())
go a.clusterUpdater(ctx)
if a.Config.K9s.UI.Reactive {
if err := a.ConfigWatcher(ctx, a); err != nil {
slog.Warn("ConfigWatcher failed", slogs.Error, err)
}
if err := a.SkinsDirWatcher(ctx, a); err != nil {
slog.Warn("SkinsWatcher failed", slogs.Error, err)
}
if err := a.CustomViewsWatcher(ctx, a); err != nil {
slog.Warn("CustomView watcher failed", slogs.Error, err)
}
}
}
func (a *App) clusterUpdater(ctx context.Context) {
if err := a.refreshCluster(ctx); err != nil {
slog.Error("Cluster updater failed!", slogs.Error, err)
return
}
bf := model.NewExpBackOff(ctx, clusterRefresh, 2*time.Minute)
delay := clusterRefresh
for {
select {
case <-ctx.Done():
slog.Debug("ClusterInfo updater canceled!")
return
case <-time.After(delay):
if err := a.refreshCluster(ctx); err != nil {
slog.Error("Cluster updates failed. Giving up ;(", slogs.Error, err)
if delay = bf.NextBackOff(); delay == backoff.Stop {
a.BailOut(1)
return
}
} else {
bf.Reset()
delay = clusterRefresh
}
}
}
}
func (a *App) refreshCluster(context.Context) error {
c := a.Content.Top()
if ok := a.Conn().CheckConnectivity(); ok {
if atomic.LoadInt32(&a.conRetry) > 0 {
atomic.StoreInt32(&a.conRetry, 0)
a.Status(model.FlashInfo, "K8s connectivity OK")
if c != nil {
c.Start()
}
} else {
a.ClearStatus(true)
}
a.factory.ValidatePortForwards()
} else if c != nil {
atomic.AddInt32(&a.conRetry, 1)
c.Stop()
}
count, maxConnRetry := atomic.LoadInt32(&a.conRetry), int32(a.Config.K9s.MaxConnRetry)
if count >= maxConnRetry {
slog.Error("Conn check failed. Bailing out!",
slogs.Retry, count,
slogs.MaxRetries, maxConnRetry,
)
ExitStatus = fmt.Sprintf("Lost K8s connection (%d). Bailing out!", count)
a.BailOut(1)
}
if count > 0 {
a.Status(model.FlashWarn, fmt.Sprintf("Dial K8s Toast [%d/%d]", count, maxConnRetry))
return fmt.Errorf("conn check failed (%d/%d)", count, maxConnRetry)
}
// Reload alias
go func() {
if err := a.command.Reset(a.Config.ContextAliasesPath(), false); err != nil {
slog.Warn("Command reset failed", slogs.Error, err)
a.QueueUpdateDraw(func() {
a.Logo().Warn("Aliases load failed!")
})
}
}()
// Update cluster info
a.clusterModel.Refresh()
return nil
}
func (a *App) switchNS(ns string) error {
if a.Config.ActiveNamespace() == ns {
return nil
}
if ns == client.ClusterScope {
ns = client.BlankNamespace
}
if err := a.Config.SetActiveNamespace(ns); err != nil {
return err
}
return a.factory.SetActiveNS(ns)
}
func (a *App) switchContext(ci *cmd.Interpreter, force bool) error {
contextName, ok := ci.HasContext()
if (!ok || a.Config.ActiveContextName() == contextName) && !force {
return nil
}
a.Halt()
defer a.Resume()
{
a.Config.Reset()
ct, err := a.Config.ActivateContext(contextName)
if err != nil {
return err
}
if cns, ok := ci.NSArg(); ok {
ct.Namespace.Active = cns
}
p := cmd.NewInterpreter(a.Config.ActiveView())
p.ResetContextArg()
if p.IsContextCmd() {
a.Config.SetActiveView("pod")
}
ns := a.Config.ActiveNamespace()
if !a.Conn().IsValidNamespace(ns) {
slog.Warn("Unable to validate namespace", slogs.Namespace, ns)
if err := a.Config.SetActiveNamespace(ns); err != nil {
return err
}
}
a.Flash().Infof("Using %q namespace", ns)
if err := a.Config.Save(true); err != nil {
slog.Error("Fail to save config to disk", slogs.Subsys, "config", slogs.Error, err)
}
a.initFactory(ns)
if err := a.command.Reset(a.Config.ContextAliasesPath(), true); err != nil {
return err
}
slog.Debug("Switching Context",
slogs.Context, contextName,
slogs.Namespace, ns,
slogs.View, a.Config.ActiveView(),
)
a.Flash().Infof("Switching context to %q::%q", contextName, ns)
a.ReloadStyles()
a.gotoResource(a.Config.ActiveView(), "", true, true)
a.clusterModel.Reset(a.factory)
}
return nil
}
func (a *App) initFactory(ns string) {
a.factory.Terminate()
a.factory.Start(ns)
}
// BailOut exists the application.
func (a *App) BailOut(exitCode int) {
defer func() {
if err := recover(); err != nil {
slog.Error("Bailout failed", slogs.Error, err)
}
}()
if err := nukeK9sShell(a); err != nil {
slog.Error("Unable to nuke k9s shell pod", slogs.Error, err)
}
a.stopImgScanner()
a.factory.Terminate()
a.App.BailOut(exitCode)
}
// Run starts the application loop.
func (a *App) Run() error {
a.Resume()
go func() {
<-time.After(splashDelay)
a.QueueUpdateDraw(func() {
a.Main.SwitchToPage("main")
// if command bar is already active, focus it
if a.CmdBuff().IsActive() {
a.SetFocus(a.Prompt())
}
})
}()
if err := a.command.defaultCmd(true); err != nil {
return err
}
a.SetRunning(true)
if err := a.Application.Run(); err != nil {
return err
}
return nil
}
// Status reports a new app status for display.
func (a *App) Status(l model.FlashLevel, msg string) {
a.QueueUpdateDraw(func() {
if a.showHeader {
a.setLogo(l, msg)
} else {
a.setIndicator(l, msg)
}
})
}
// IsBenchmarking check if benchmarks are active.
func (a *App) IsBenchmarking() bool {
return a.Logo().IsBenchmarking()
}
// ClearStatus reset logo back to normal.
func (a *App) ClearStatus(flash bool) {
a.QueueUpdate(func() {
a.Logo().Reset()
if flash {
a.Flash().Clear()
}
})
}
func (a *App) setLogo(l model.FlashLevel, msg string) {
switch l {
case model.FlashErr:
a.Logo().Err(msg)
case model.FlashWarn:
a.Logo().Warn(msg)
case model.FlashInfo:
a.Logo().Info(msg)
default:
a.Logo().Reset()
}
}
func (a *App) setIndicator(l model.FlashLevel, msg string) {
switch l {
case model.FlashErr:
a.statusIndicator().Err(msg)
case model.FlashWarn:
a.statusIndicator().Warn(msg)
case model.FlashInfo:
a.statusIndicator().Info(msg)
default:
a.statusIndicator().Reset()
}
}
// PrevCmd pops the command stack.
func (a *App) PrevCmd(evt *tcell.EventKey) *tcell.EventKey {
if !a.Content.IsLast() {
a.Content.Pop()
}
return nil
}
func (a *App) toggleHeaderCmd(evt *tcell.EventKey) *tcell.EventKey {
if a.Prompt().InCmdMode() {
return evt
}
a.QueueUpdateDraw(func() {
a.showHeader = !a.showHeader
a.toggleHeader(a.showHeader, a.showLogo)
})
return nil
}
func (a *App) toggleCrumbsCmd(evt *tcell.EventKey) *tcell.EventKey {
if a.Prompt().InCmdMode() {
return evt
}
a.QueueUpdateDraw(func() {
a.showCrumbs = !a.showCrumbs
a.toggleCrumbs(a.showCrumbs)
})
return nil
}
func (a *App) gotoCmd(evt *tcell.EventKey) *tcell.EventKey {
if a.CmdBuff().IsActive() && !a.CmdBuff().Empty() {
a.gotoResource(a.GetCmd(), "", true, true)
a.ResetCmd()
return nil
}
return evt
}
func (a *App) cowCmd(msg string) {
dialog.ShowError(a.Styles.Dialog(), a.Content.Pages, msg)
}
func (a *App) dirCmd(path string, pushCmd bool) error {
slog.Debug("Exec Dir command", slogs.Path, path)
_, err := os.Stat(path)
if err != nil {
return err
}
if path == "." {
dir, err := os.Getwd()
if err == nil {
path = dir
}
}
if pushCmd {
a.cmdHistory.Push("dir " + path)
}
return a.inject(NewDir(path), true)
}
func (a *App) quitCmd(evt *tcell.EventKey) *tcell.EventKey {
if a.InCmdMode() {
return evt
}
if !a.Config.K9s.NoExitOnCtrlC {
a.BailOut(0)
}
// overwrite the default ctrl-c behavior of tview
return nil
}
func (a *App) helpCmd(evt *tcell.EventKey) *tcell.EventKey {
if evt != nil && evt.Rune() == '?' && a.Prompt().InCmdMode() {
return evt
}
top := a.Content.Top()
if top != nil && top.Name() == "help" {
a.Content.Pop()
return nil
}
if err := a.inject(NewHelp(a), false); err != nil {
a.Flash().Err(err)
}
a.Prompt().Deactivate()
return nil
}
// previousCommand returns to the command prior to the current one in the history
func (a *App) previousCommand(evt *tcell.EventKey) *tcell.EventKey {
if evt != nil && evt.Rune() == rune(ui.KeyLeftBracket) && a.Prompt().InCmdMode() {
return evt
}
cmds := a.cmdHistory.List()
if !a.cmdHistory.Back() {
a.App.Flash().Warn("Can't go back any further")
return evt
}
a.gotoResource(cmds[a.cmdHistory.CurrentIndex()], "", true, false)
return nil
}
// nextCommand returns to the command subsequent to the current one in the history
func (a *App) nextCommand(evt *tcell.EventKey) *tcell.EventKey {
if evt != nil && evt.Rune() == rune(ui.KeyRightBracket) && a.Prompt().InCmdMode() {
return evt
}
cmds := a.cmdHistory.List()
if !a.cmdHistory.Forward() {
a.App.Flash().Warn("Can't go forward any further")
return evt
}
// We go to the resource before updating the history so that
// gotoResource doesn't add this command to the history
a.gotoResource(cmds[a.cmdHistory.CurrentIndex()], "", true, false)
return nil
}
// lastCommand switches between the last command and the current one a la `cd -`
func (a *App) lastCommand(evt *tcell.EventKey) *tcell.EventKey {
if evt != nil && evt.Rune() == ui.KeyDash && a.Prompt().InCmdMode() {
return evt
}
cmds := a.cmdHistory.List()
if len(cmds) < 1 {
a.App.Flash().Warn("No previous view to switch to")
return evt
} else {
a.cmdHistory.Last()
a.gotoResource(cmds[a.cmdHistory.CurrentIndex()], "", true, false)
}
return nil
}
func (a *App) aliasCmd(evt *tcell.EventKey) *tcell.EventKey {
if a.Content.Top() != nil && a.Content.Top().Name() == aliasTitle {
a.Content.Pop()
return nil
}
if err := a.inject(NewAlias(client.NewGVR("aliases")), false); err != nil {
a.Flash().Err(err)
}
return nil
}
func (a *App) gotoResource(c, path string, clearStack bool, pushCmd bool) {
err := a.command.run(cmd.NewInterpreter(c), path, clearStack, pushCmd)
if err != nil {
dialog.ShowError(a.Styles.Dialog(), a.Content.Pages, err.Error())
}
}
func (a *App) inject(c model.Component, clearStack bool) error {
ctx := context.WithValue(context.Background(), internal.KeyApp, a)
if err := c.Init(ctx); err != nil {
slog.Error("Component init failed",
slogs.Error, err,
slogs.CompName, c.Name(),
)
return err
}
if clearStack {
a.Content.Stack.Clear()
}
a.Content.Push(c)
return nil
}
func (a *App) clusterInfo() *ClusterInfo {
return a.Views()["clusterInfo"].(*ClusterInfo)
}
func (a *App) statusIndicator() *ui.StatusIndicator {
return a.Views()["statusIndicator"].(*ui.StatusIndicator)
}