1
0
mirror of https://github.com/taigrr/wtf synced 2025-01-18 04:03:14 -08:00

WTF-484 DigitalOcean module (#782)

* WTF-484 DigitalOcean module stubbed out
* WTF-484 Delete droplets via Ctrl-d
* WTF-484 Rebasing off master after a long time away
* WTF-484 Improve DigitalOcean display
* WTF-484 Can shutdown and restart the selected droplet
* WTF-484 Display info about the selected droplet using the ? key
* WTF-484 Display info about the selected droplet using the Return key
* WTF-484 Greatly improve the utils.Truncate function
* WTF-484 Display a droplet's features in the info modal
* WTF-484 Change reboot key from r to b to not conflict with refresh
* WTF-484 Panic if a keyboard control is mapped to the same character more than once
* WTF-484 Colorize droplet status indicator
* WTF-484 Extract view.InfoTable out into a reusable component
This commit is contained in:
Chris Cummer
2019-12-13 11:33:29 -08:00
committed by GitHub
parent e1f1d0a410
commit 58299c2efa
17 changed files with 627 additions and 58 deletions

View File

@@ -0,0 +1,40 @@
package digitalocean
import (
"fmt"
"strings"
"github.com/wtfutil/wtf/utils"
)
func (widget *Widget) content() (string, string, bool) {
title := widget.CommonSettings().Title
if widget.err != nil {
return title, widget.err.Error(), true
}
str := fmt.Sprintf(
" [%s]Droplets\n\n",
widget.settings.common.Colors.Subheading,
)
for idx, droplet := range widget.droplets {
dropletName := droplet.Name
row := fmt.Sprintf(
"[%s] %-8s %-24s %s",
widget.RowColor(idx),
droplet.Status,
dropletName,
utils.Truncate(strings.Join(droplet.Tags, ","), 24, true),
)
str += utils.HighlightableHelper(widget.View, row, idx, 33)
}
return title, str, false
}
func (widget *Widget) display() {
widget.ScrollableWidget.Redraw(widget.content)
}

View File

@@ -0,0 +1,79 @@
package digitalocean
import (
"fmt"
"strconv"
"strings"
"github.com/digitalocean/godo"
"github.com/wtfutil/wtf/utils"
"github.com/wtfutil/wtf/view"
)
type dropletPropertiesTable struct {
droplet *godo.Droplet
propertyMap map[string]string
colWidth0 int
colWidth1 int
tableHeight int
}
// newDropletPropertiesTable creates and returns an instance of DropletPropertiesTable
func newDropletPropertiesTable(droplet *godo.Droplet) *dropletPropertiesTable {
propTable := &dropletPropertiesTable{
droplet: droplet,
colWidth0: 24,
colWidth1: 47,
tableHeight: 16,
}
propTable.propertyMap = propTable.buildPropertyMap()
return propTable
}
/* -------------------- Unexported Functions -------------------- */
// buildPropertyMap creates a mapping of droplet property names to droplet property values
func (propTable *dropletPropertiesTable) buildPropertyMap() map[string]string {
propMap := map[string]string{}
if propTable.droplet == nil {
return propMap
}
publicV4, _ := propTable.droplet.PublicIPv4()
publicV6, _ := propTable.droplet.PublicIPv6()
propMap["CPUs"] = strconv.Itoa(propTable.droplet.Vcpus)
propMap["Created"] = propTable.droplet.Created
propMap["Disk"] = strconv.Itoa(propTable.droplet.Disk)
propMap["Features"] = utils.Truncate(strings.Join(propTable.droplet.Features, ","), propTable.colWidth1, true)
propMap["Image"] = fmt.Sprintf("%s (%s)", propTable.droplet.Image.Name, propTable.droplet.Image.Distribution)
propMap["Memory"] = strconv.Itoa(propTable.droplet.Memory)
propMap["Public IP v4"] = publicV4
propMap["Public IP v6"] = publicV6
propMap["Region"] = fmt.Sprintf("%s (%s)", propTable.droplet.Region.Name, propTable.droplet.Region.Slug)
propMap["Size"] = propTable.droplet.SizeSlug
propMap["Status"] = propTable.droplet.Status
propMap["Tags"] = utils.Truncate(strings.Join(propTable.droplet.Tags, ","), propTable.colWidth1, true)
propMap["URN"] = utils.Truncate(propTable.droplet.URN(), propTable.colWidth1, true)
propMap["VPC"] = propTable.droplet.VPCUUID
return propMap
}
// render creates a new Table and returns it as a displayable string
func (propTable *dropletPropertiesTable) render() string {
tbl := view.NewInfoTable(
[]string{"Property", "Value"},
propTable.propertyMap,
propTable.colWidth0,
propTable.colWidth1,
propTable.tableHeight,
)
return tbl.Render()
}

View File

@@ -0,0 +1,21 @@
package digitalocean
import "github.com/gdamore/tcell"
func (widget *Widget) initializeKeyboardControls() {
widget.InitializeCommonControls(widget.Refresh)
widget.SetKeyboardChar("?", widget.showInfo, "Show info about the selected droplet")
widget.SetKeyboardChar("b", widget.dropletRestart, "Reboot the selected droplet")
widget.SetKeyboardChar("j", widget.Prev, "Select previous item")
widget.SetKeyboardChar("k", widget.Next, "Select next item")
widget.SetKeyboardChar("p", widget.dropletEnabledPrivateNetworking, "Enable private networking for the selected drople")
widget.SetKeyboardChar("s", widget.dropletShutDown, "Shut down the selected droplet")
widget.SetKeyboardChar("u", widget.Unselect, "Clear selection")
widget.SetKeyboardKey(tcell.KeyCtrlD, widget.dropletDestroy, "Destroy the selected droplet")
widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item")
widget.SetKeyboardKey(tcell.KeyEnter, widget.showInfo, "Show info about the selected droplet")
widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous item")
}

View File

@@ -0,0 +1,35 @@
package digitalocean
import (
"os"
"github.com/olebedev/config"
"github.com/wtfutil/wtf/cfg"
"github.com/wtfutil/wtf/wtf"
)
const (
defaultFocusable = true
defaultTitle = "DigitalOcean"
)
// Settings defines the configuration properties for this module
type Settings struct {
common *cfg.Common
apiKey string `help:"Your DigitalOcean API key."`
dateFormat string `help:"The format to display dates and times in."`
}
// NewSettingsFromYAML creates a new settings instance from a YAML config block
func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings {
settings := Settings{
common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig),
apiKey: ymlConfig.UString("apiKey", ymlConfig.UString("apikey", os.Getenv("WTF_DIGITALOCEAN_API_KEY"))),
dateFormat: ymlConfig.UString("dateFormat", wtf.DateFormat),
}
return &settings
}

View File

@@ -0,0 +1,256 @@
package digitalocean
import (
"context"
"errors"
"fmt"
"github.com/digitalocean/godo"
"github.com/rivo/tview"
"github.com/wtfutil/wtf/utils"
"github.com/wtfutil/wtf/view"
"golang.org/x/oauth2"
)
/* -------------------- Oauth2 Token -------------------- */
type tokenSource struct {
AccessToken string
}
// Token creates and returns an Oauth2 token
func (t *tokenSource) Token() (*oauth2.Token, error) {
token := &oauth2.Token{
AccessToken: t.AccessToken,
}
return token, nil
}
/* -------------------- Widget -------------------- */
// Widget is the container for transmission data
type Widget struct {
view.KeyboardWidget
view.ScrollableWidget
app *tview.Application
client *godo.Client
droplets []godo.Droplet
pages *tview.Pages
settings *Settings
err error
}
// NewWidget creates a new instance of a widget
func NewWidget(app *tview.Application, pages *tview.Pages, settings *Settings) *Widget {
widget := Widget{
KeyboardWidget: view.NewKeyboardWidget(app, pages, settings.common),
ScrollableWidget: view.NewScrollableWidget(app, settings.common),
app: app,
pages: pages,
settings: settings,
}
widget.initializeKeyboardControls()
widget.View.SetInputCapture(widget.InputCapture)
widget.View.SetScrollable(true)
widget.KeyboardWidget.SetView(widget.View)
widget.SetRenderFunction(widget.display)
widget.createClient()
return &widget
}
/* -------------------- Exported Functions -------------------- */
// Fetch retrieves droplet data
func (widget *Widget) Fetch() error {
if widget.client == nil {
return errors.New("client could not be initialized")
}
var err error
widget.droplets, err = widget.dropletsFetch()
return err
}
// HelpText returns the help text for this widget
func (widget *Widget) HelpText() string {
return widget.KeyboardWidget.HelpText()
}
// Next selects the next item in the list
func (widget *Widget) Next() {
widget.ScrollableWidget.Next()
}
// Prev selects the previous item in the list
func (widget *Widget) Prev() {
widget.ScrollableWidget.Prev()
}
// Refresh updates the data for this widget and displays it onscreen
func (widget *Widget) Refresh() {
err := widget.Fetch()
if err != nil {
widget.err = err
widget.SetItemCount(0)
} else {
widget.err = nil
widget.SetItemCount(len(widget.droplets))
}
widget.display()
}
// Unselect clears the selection of list items
func (widget *Widget) Unselect() {
widget.ScrollableWidget.Unselect()
widget.RenderFunction()
}
/* -------------------- Unexported Functions -------------------- */
// createClient create a persisten DigitalOcean client for use in the calls below
func (widget *Widget) createClient() {
tokenSource := &tokenSource{
AccessToken: widget.settings.apiKey,
}
oauthClient := oauth2.NewClient(context.Background(), tokenSource)
widget.client = godo.NewClient(oauthClient)
}
// currentDroplet returns the currently-selected droplet, if there is one
// Returns nil if no droplet is selected
func (widget *Widget) currentDroplet() *godo.Droplet {
if len(widget.droplets) == 0 {
return nil
}
if len(widget.droplets) <= widget.Selected {
return nil
}
return &widget.droplets[widget.Selected]
}
// dropletsFetch uses the DigitalOcean API to fetch information about all the available droplets
func (widget *Widget) dropletsFetch() ([]godo.Droplet, error) {
dropletList := []godo.Droplet{}
opts := &godo.ListOptions{}
for {
droplets, resp, err := widget.client.Droplets.List(context.Background(), opts)
if err != nil {
return dropletList, err
}
for _, d := range droplets {
dropletList = append(dropletList, d)
}
if resp.Links == nil || resp.Links.IsLastPage() {
break
}
page, err := resp.Links.CurrentPage()
if err != nil {
return dropletList, err
}
// Set the page we want for the next request
opts.Page = page + 1
}
return dropletList, nil
}
/* -------------------- Droplet Actions -------------------- */
// dropletDestroy destroys the selected droplet
func (widget *Widget) dropletDestroy() {
currDroplet := widget.currentDroplet()
if currDroplet == nil {
return
}
widget.client.Droplets.Delete(context.Background(), currDroplet.ID)
widget.dropletRemoveSelected()
widget.Refresh()
}
// dropletEnabledPrivateNetworking enabled private networking on the selected droplet
func (widget *Widget) dropletEnabledPrivateNetworking() {
currDroplet := widget.currentDroplet()
if currDroplet == nil {
return
}
widget.client.DropletActions.EnablePrivateNetworking(context.Background(), currDroplet.ID)
widget.Refresh()
}
// dropletRemoveSelected removes the currently-selected droplet from the internal list of droplets
func (widget *Widget) dropletRemoveSelected() {
currDroplet := widget.currentDroplet()
if currDroplet != nil {
widget.droplets[len(widget.droplets)-1], widget.droplets[widget.Selected] = widget.droplets[widget.Selected], widget.droplets[len(widget.droplets)-1]
widget.droplets = widget.droplets[:len(widget.droplets)-1]
}
}
// dropletRestart restarts the selected droplet
func (widget *Widget) dropletRestart() {
currDroplet := widget.currentDroplet()
if currDroplet == nil {
return
}
widget.client.DropletActions.Reboot(context.Background(), currDroplet.ID)
widget.Refresh()
}
// dropletShutDown powers down the selected droplet
func (widget *Widget) dropletShutDown() {
currDroplet := widget.currentDroplet()
if currDroplet == nil {
return
}
widget.client.DropletActions.Shutdown(context.Background(), currDroplet.ID)
widget.Refresh()
}
/* -------------------- Common Actions -------------------- */
// showInfo shows a modal window with information about the selected droplet
func (widget *Widget) showInfo() {
droplet := widget.currentDroplet()
if droplet == nil {
return
}
closeFunc := func() {
widget.pages.RemovePage("info")
widget.app.SetFocus(widget.View)
}
propTable := newDropletPropertiesTable(droplet).render()
propTable += utils.CenterText("Esc to close", 80)
modal := view.NewBillboardModal(propTable, closeFunc)
modal.SetTitle(fmt.Sprintf(" %s ", droplet.Name))
widget.pages.AddPage("info", modal, false, true)
widget.app.SetFocus(modal)
widget.app.QueueUpdateDraw(func() {
widget.app.Draw()
})
}

View File

@@ -3,7 +3,6 @@ package newrelic
import "github.com/gdamore/tcell"
func (widget *Widget) initializeKeyboardControls() {
widget.SetKeyboardChar("/", widget.ShowHelp, "Show/hide this help window")
widget.SetKeyboardKey(tcell.KeyLeft, widget.PrevSource, "Select previous application")
widget.SetKeyboardKey(tcell.KeyRight, widget.NextSource, "Select next application")
}

View File

@@ -26,10 +26,10 @@ type Widget struct {
view.ScrollableWidget
app *tview.Application
settings *Settings
filePath string
list checklist.Checklist
pages *tview.Pages
settings *Settings
}
// NewWidget creates a new instance of a widget
@@ -61,27 +61,11 @@ func NewWidget(app *tview.Application, pages *tview.Pages, settings *Settings) *
/* -------------------- Exported Functions -------------------- */
func (widget *Widget) Refresh() {
widget.load()
widget.display()
}
func (widget *Widget) SetList(list checklist.Checklist) {
widget.list = list
}
// HelpText returns the help text for this widget
func (widget *Widget) HelpText() string {
return widget.KeyboardWidget.HelpText()
}
/* -------------------- Unexported Functions -------------------- */
// isItemSelected returns weather any item of the todo is selected or not
func (widget *Widget) isItemSelected() bool {
return widget.Selected >= 0 && widget.Selected < len(widget.list.Items)
}
// SelectedItem returns the currently-selected checklist item or nil if no item is selected
func (widget *Widget) SelectedItem() *checklist.ChecklistItem {
var selectedItem *checklist.ChecklistItem
@@ -92,38 +76,18 @@ func (widget *Widget) SelectedItem() *checklist.ChecklistItem {
return selectedItem
}
// updateSelectedItem update the text of the selected item.
func (widget *Widget) updateSelectedItem(text string) {
selectedItem := widget.SelectedItem()
if selectedItem == nil {
return
}
selectedItem.Text = text
// Refresh updates the data for this widget and displays it onscreen
func (widget *Widget) Refresh() {
widget.load()
widget.display()
}
// updateSelected sets the text of the currently-selected item to the provided text
func (widget *Widget) updateSelected() {
if !widget.isItemSelected() {
return
}
form := widget.modalForm("Edit:", widget.SelectedItem().Text)
saveFctn := func() {
text := form.GetFormItem(0).(*tview.InputField).GetText()
widget.updateSelectedItem(text)
widget.persist()
widget.pages.RemovePage("modal")
widget.app.SetFocus(widget.View)
widget.display()
}
widget.addButtons(form, saveFctn)
widget.modalFocus(form)
func (widget *Widget) SetList(list checklist.Checklist) {
widget.list = list
}
/* -------------------- Unexported Functions -------------------- */
func (widget *Widget) init() {
_, err := cfg.CreateFile(widget.filePath)
if err != nil {
@@ -131,6 +95,11 @@ func (widget *Widget) init() {
}
}
// isItemSelected returns weather any item of the todo is selected or not
func (widget *Widget) isItemSelected() bool {
return widget.Selected >= 0 && widget.Selected < len(widget.list.Items)
}
// Loads the todo list from3 Yaml file
func (widget *Widget) load() {
confDir, _ := cfg.WtfConfigDir()
@@ -189,6 +158,38 @@ func (widget *Widget) setItemChecks() {
}
}
// updateSelected sets the text of the currently-selected item to the provided text
func (widget *Widget) updateSelected() {
if !widget.isItemSelected() {
return
}
form := widget.modalForm("Edit:", widget.SelectedItem().Text)
saveFctn := func() {
text := form.GetFormItem(0).(*tview.InputField).GetText()
widget.updateSelectedItem(text)
widget.persist()
widget.pages.RemovePage("modal")
widget.app.SetFocus(widget.View)
widget.display()
}
widget.addButtons(form, saveFctn)
widget.modalFocus(form)
}
// updateSelectedItem update the text of the selected item.
func (widget *Widget) updateSelectedItem(text string) {
selectedItem := widget.SelectedItem()
if selectedItem == nil {
return
}
selectedItem.Text = text
}
/* -------------------- Modal Form -------------------- */
func (widget *Widget) addButtons(form *tview.Form, saveFctn func()) {

View File

@@ -9,7 +9,7 @@ func (widget *Widget) initializeKeyboardControls() {
widget.SetKeyboardChar("k", widget.Next, "Select next item")
widget.SetKeyboardChar("u", widget.Unselect, "Clear selection")
widget.SetKeyboardKey(tcell.KeyCtrlD, widget.deleteSelectedTorrent, "Delete selected torrent")
widget.SetKeyboardKey(tcell.KeyCtrlD, widget.deleteSelectedTorrent, "Delete the selected torrent")
widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next item")
widget.SetKeyboardKey(tcell.KeyEnter, widget.pauseUnpauseTorrent, "Pause/unpause torrent")
widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection")