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:
40
modules/digitalocean/display.go
Normal file
40
modules/digitalocean/display.go
Normal 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)
|
||||
}
|
||||
79
modules/digitalocean/droplet_properties_table.go
Normal file
79
modules/digitalocean/droplet_properties_table.go
Normal 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()
|
||||
}
|
||||
21
modules/digitalocean/keyboard.go
Normal file
21
modules/digitalocean/keyboard.go
Normal 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")
|
||||
}
|
||||
35
modules/digitalocean/settings.go
Normal file
35
modules/digitalocean/settings.go
Normal 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
|
||||
}
|
||||
256
modules/digitalocean/widget.go
Normal file
256
modules/digitalocean/widget.go
Normal 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()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user