// SPDX-License-Identifier: Apache-2.0 // Copyright Authors of K9s package view import ( "context" "fmt" "log/slog" "strconv" "strings" "github.com/derailed/k9s/internal/dao" "github.com/derailed/k9s/internal/slogs" "github.com/derailed/k9s/internal/ui" "github.com/derailed/tcell/v2" "github.com/derailed/tview" ) // ScaleExtender adds scaling extensions. type ScaleExtender struct { ResourceViewer } // NewScaleExtender returns a new extender. func NewScaleExtender(r ResourceViewer) ResourceViewer { s := ScaleExtender{ResourceViewer: r} s.AddBindKeysFn(s.bindKeys) return &s } func (s *ScaleExtender) bindKeys(aa *ui.KeyActions) { if s.App().Config.IsReadOnly() { return } meta, err := dao.MetaAccess.MetaFor(s.GVR()) if err != nil { slog.Error("No meta information found", slogs.GVR, s.GVR(), slogs.Error, err, ) return } if dao.IsScalable(meta) { aa.Add(ui.KeyS, ui.NewKeyActionWithOpts("Scale", s.scaleCmd, ui.ActionOpts{ Visible: true, Dangerous: true, }, )) } } func (s *ScaleExtender) scaleCmd(*tcell.EventKey) *tcell.EventKey { paths := s.GetTable().GetSelectedItems() if len(paths) == 0 { return nil } s.Stop() defer s.Start() s.showScaleDialog(paths) return nil } func (s *ScaleExtender) showScaleDialog(paths []string) { form, err := s.makeScaleForm(paths) if err != nil { s.App().Flash().Err(err) return } confirm := tview.NewModalForm("", form) msg := fmt.Sprintf("Scale %s %s?", singularize(s.GVR().R()), paths[0]) if len(paths) > 1 { msg = fmt.Sprintf("Scale [%d] %s?", len(paths), s.GVR().R()) } confirm.SetText(msg) confirm.SetDoneFunc(func(int, string) { s.dismissDialog() }) s.App().Content.AddPage(scaleDialogKey, confirm, false, false) s.App().Content.ShowPage(scaleDialogKey) } func (s *ScaleExtender) valueOf(col string) (string, error) { colIdx, ok := s.GetTable().HeaderIndex(col) if !ok { return "", fmt.Errorf("no column index for %s", col) } return s.GetTable().GetSelectedCell(colIdx), nil } func (s *ScaleExtender) replicasFromReady(_ string) (string, error) { replicas, err := s.valueOf("READY") if err != nil { return "", err } tokens := strings.Split(replicas, "/") if len(tokens) < 2 { return "", fmt.Errorf("unable to locate replicas from %s", replicas) } return strings.TrimRight(tokens[1], ui.DeltaSign), nil } func (s *ScaleExtender) replicasFromScaleSubresource(sel string) (string, error) { res, err := dao.AccessorFor(s.App().factory, s.GVR()) if err != nil { return "", err } replicasGetter, ok := res.(dao.ReplicasGetter) if !ok { return "", fmt.Errorf("expecting a replicasGetter resource for %q", s.GVR()) } ctx, cancel := context.WithTimeout(context.Background(), s.App().Conn().Config().CallTimeout()) defer cancel() replicas, err := replicasGetter.Replicas(ctx, sel) if err != nil { return "", err } return strconv.Itoa(int(replicas)), nil } func (s *ScaleExtender) makeScaleForm(fqns []string) (*tview.Form, error) { factor := "0" if len(fqns) == 1 { // If the CRD resource supports scaling, then first try to // read the replicas directly from the CRD. if meta, _ := dao.MetaAccess.MetaFor(s.GVR()); dao.IsScalable(meta) { replicas, err := s.replicasFromScaleSubresource(fqns[0]) if err == nil && replicas != "" { factor = replicas } } // For built-in resources or cases where we can't get the replicas from the CRD, we can // only try to get the number of copies from the READY field. if factor == "0" { replicas, err := s.replicasFromReady(fqns[0]) if err != nil { return nil, err } factor = replicas } } styles := s.App().Styles.Dialog() f := tview.NewForm(). SetItemPadding(0). SetButtonsAlign(tview.AlignCenter). SetButtonBackgroundColor(styles.ButtonBgColor.Color()). SetButtonTextColor(styles.ButtonFgColor.Color()). SetLabelColor(styles.LabelFgColor.Color()). SetFieldTextColor(styles.FieldFgColor.Color()) f.AddInputField("Replicas:", factor, 4, func(textToCheck string, _ rune) bool { _, err := strconv.Atoi(textToCheck) return err == nil }, func(changed string) { factor = changed }) f.AddButton("OK", func() { defer s.dismissDialog() count, err := strconv.Atoi(factor) if err != nil { s.App().Flash().Err(err) return } ctx, cancel := context.WithTimeout(context.Background(), s.App().Conn().Config().CallTimeout()) defer cancel() for _, fqn := range fqns { if err := s.scale(ctx, fqn, int32(count)); err != nil { slog.Error("Unable to scale resource", slogs.FQN, fqn) s.App().Flash().Err(err) return } } if len(fqns) != 1 { s.App().Flash().Infof("[%d] %s scaled successfully", len(fqns), singularize(s.GVR().R())) } else { s.App().Flash().Infof("%s %s scaled successfully", s.GVR().R(), fqns[0]) } }) f.AddButton("Cancel", func() { s.dismissDialog() }) for i := range 2 { if b := f.GetButton(i); b != nil { b.SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()) b.SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) } } for i := range f.GetButtonCount() { f.GetButton(i). SetBackgroundColorActivated(styles.ButtonFocusBgColor.Color()). SetLabelColorActivated(styles.ButtonFocusFgColor.Color()) } return f, nil } func (s *ScaleExtender) dismissDialog() { s.App().Content.RemovePage(scaleDialogKey) } func (s *ScaleExtender) scale(ctx context.Context, path string, replicas int32) error { res, err := dao.AccessorFor(s.App().factory, s.GVR()) if err != nil { return err } scaler, ok := res.(dao.Scalable) if !ok { return fmt.Errorf("expecting a scalable resource for %q", s.GVR()) } return scaler.Scale(ctx, path, replicas) }