talos/internal/pkg/dashboard/dashboard.go
Utku Ozdemir 7967ccfc13
feat: add config code entry screen to dashboard
Implement a screen for entering/managing the config `${code}` variable.

Enable this screen only when the platform is `metal` and there is a `${code}` variable in the `talos.config` kernel cmdline URL query.

Additionally, remove the "Delete" button and its functionality from the network config screen to avoid users accidentally deleting PlatformNetworkConfig parts that are not managed by the dashboard.

Add some tests for the form data parsing on the network config screen.
Remove the unnecessary lock on the summary tab - all updates come from the same goroutine.

Closes siderolabs/talos#6993.

Signed-off-by: Utku Ozdemir <utku.ozdemir@siderolabs.com>
2023-03-31 10:33:28 +02:00

477 lines
11 KiB
Go

// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
// Package dashboard implements a text-based UI dashboard.
package dashboard
import (
"context"
"errors"
"fmt"
"sort"
"time"
"github.com/gdamore/tcell/v2"
"github.com/gizak/termui/v3"
"github.com/rivo/tview"
"github.com/siderolabs/gen/maps"
"github.com/siderolabs/gen/slices"
"golang.org/x/sync/errgroup"
"github.com/siderolabs/talos/internal/pkg/dashboard/apidata"
"github.com/siderolabs/talos/internal/pkg/dashboard/components"
"github.com/siderolabs/talos/internal/pkg/dashboard/logdata"
"github.com/siderolabs/talos/internal/pkg/dashboard/resourcedata"
"github.com/siderolabs/talos/pkg/machinery/client"
)
func init() {
// set background to be left as the default color of the terminal
tview.Styles.PrimitiveBackgroundColor = tcell.ColorDefault
// set the titles of the termui (legacy) to be bold
termui.Theme.Block.Title.Modifier = termui.ModifierBold
}
// Screen is a dashboard screen.
type Screen string
const (
pageMain = "main"
// ScreenSummary is the summary screen.
ScreenSummary Screen = "Summary"
// ScreenMonitor is the monitor (metrics) screen.
ScreenMonitor Screen = "Monitor"
// ScreenNetworkConfig is the network configuration screen.
ScreenNetworkConfig Screen = "Network Config"
// ScreenConfigURL is the config URL screen.
ScreenConfigURL Screen = "Config URL"
)
// APIDataListener is a listener which is notified when API-sourced data is updated.
type APIDataListener interface {
OnAPIDataChange(node string, data *apidata.Data)
}
// ResourceDataListener is a listener which is notified when a resource is updated.
type ResourceDataListener interface {
OnResourceDataChange(data resourcedata.Data)
}
// LogDataListener is a listener which is notified when a log line is received.
type LogDataListener interface {
OnLogDataChange(node string, logLine string)
}
// NodeSetListener is a listener which is notified when the set of nodes changes.
type NodeSetListener interface {
OnNodeSetChange(nodes []string)
}
// NodeSelectListener is a listener which is notified when a node is selected.
type NodeSelectListener interface {
OnNodeSelect(node string)
}
type screenConfig struct {
screenKey string
screen Screen
keyCode tcell.Key
primitive screenSelectListener
allowNodeNavigation bool
}
// screenSelectListener is a listener which is notified when a screen is selected.
type screenSelectListener interface {
tview.Primitive
onScreenSelect(active bool)
}
// Dashboard implements the summary dashboard.
type Dashboard struct {
cli *client.Client
interval time.Duration
apiDataSource *apidata.Source
resourceDataSource *resourcedata.Source
logDataSource *logdata.Source
apiDataListeners []APIDataListener
resourceDataListeners []ResourceDataListener
logDataListeners []LogDataListener
nodeSelectListeners []NodeSelectListener
nodeSetChangeListeners []NodeSetListener
app *tview.Application
mainGrid *tview.Grid
pages *tview.Pages
summaryGrid *SummaryGrid
monitorGrid *MonitorGrid
networkConfigGrid *NetworkConfigGrid
configURLGrid *ConfigURLGrid
selectedScreenConfig *screenConfig
screenConfigs []screenConfig
footer *components.Footer
data *apidata.Data
selectedNodeIndex int
selectedNode string
nodeSet map[string]struct{}
nodes []string
}
// buildDashboard initializes the summary dashboard.
//
//nolint:gocyclo
func buildDashboard(ctx context.Context, cli *client.Client, opts ...Option) (*Dashboard, error) {
defOptions := defaultOptions()
for _, opt := range opts {
opt(defOptions)
}
dashboard := &Dashboard{
cli: cli,
interval: defOptions.interval,
app: tview.NewApplication(),
nodeSet: make(map[string]struct{}),
}
dashboard.mainGrid = tview.NewGrid().
SetRows(1, 0, 1).
SetColumns(0)
dashboard.pages = tview.NewPages().AddPage(pageMain, dashboard.mainGrid, true, true)
header := components.NewHeader()
dashboard.mainGrid.AddItem(header, 0, 0, 1, 1, 0, 0, false)
dashboard.summaryGrid = NewSummaryGrid(dashboard.app)
dashboard.monitorGrid = NewMonitorGrid(dashboard.app)
dashboard.networkConfigGrid = NewNetworkConfigGrid(ctx, dashboard)
dashboard.configURLGrid = NewConfigURLGrid(ctx, dashboard)
err := dashboard.initScreenConfigs(defOptions.screens)
if err != nil {
return nil, err
}
screenKeyToName := slices.ToMap(dashboard.screenConfigs, func(t screenConfig) (string, string) {
return t.screenKey, string(t.screen)
})
screenConfigByKeyCode := slices.ToMap(dashboard.screenConfigs, func(config screenConfig) (tcell.Key, screenConfig) {
return config.keyCode, config
})
dashboard.footer = components.NewFooter(screenKeyToName)
dashboard.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
config, screenOk := screenConfigByKeyCode[event.Key()]
allowNodeNavigation := dashboard.selectedScreenConfig != nil && dashboard.selectedScreenConfig.allowNodeNavigation
switch {
case screenOk:
dashboard.selectScreen(config.screen)
return nil
case allowNodeNavigation && (event.Key() == tcell.KeyLeft || event.Rune() == 'h'):
dashboard.selectNodeByIndex(dashboard.selectedNodeIndex - 1)
return nil
case allowNodeNavigation && (event.Key() == tcell.KeyRight || event.Rune() == 'l'):
dashboard.selectNodeByIndex(dashboard.selectedNodeIndex + 1)
return nil
case event.Key() == tcell.KeyCtrlC:
if defOptions.allowExitKeys {
dashboard.app.Stop()
}
return nil
}
return event
})
dashboard.mainGrid.AddItem(dashboard.footer, 2, 0, 1, 1, 0, 0, false)
dashboard.apiDataListeners = []APIDataListener{
header,
dashboard.summaryGrid,
dashboard.monitorGrid,
}
dashboard.resourceDataListeners = []ResourceDataListener{
header,
dashboard.summaryGrid,
dashboard.networkConfigGrid,
dashboard.configURLGrid,
}
dashboard.logDataListeners = []LogDataListener{
dashboard.summaryGrid,
}
dashboard.nodeSelectListeners = []NodeSelectListener{
header,
dashboard.summaryGrid,
dashboard.networkConfigGrid,
dashboard.configURLGrid,
dashboard.footer,
}
dashboard.nodeSetChangeListeners = []NodeSetListener{
dashboard.footer,
}
dashboard.apiDataSource = &apidata.Source{
Client: cli,
Interval: defOptions.interval,
}
dashboard.resourceDataSource = &resourcedata.Source{
COSI: cli.COSI,
}
dashboard.logDataSource = logdata.NewSource(cli)
return dashboard, nil
}
func (d *Dashboard) initScreenConfigs(screens []Screen) error {
primitiveForScreen := func(screen Screen) screenSelectListener {
switch screen {
case ScreenSummary:
return d.summaryGrid
case ScreenMonitor:
return d.monitorGrid
case ScreenNetworkConfig:
return d.networkConfigGrid
case ScreenConfigURL:
return d.configURLGrid
default:
return nil
}
}
d.screenConfigs = make([]screenConfig, 0, len(screens))
for i, screen := range screens {
primitive := primitiveForScreen(screen)
if primitive == nil {
return fmt.Errorf("unknown screen %s", screen)
}
config := screenConfig{
screenKey: fmt.Sprintf("F%d", i+1),
screen: screen,
keyCode: tcell.KeyF1 + tcell.Key(i),
primitive: primitive,
allowNodeNavigation: true,
}
if screen == ScreenNetworkConfig || screen == ScreenConfigURL {
config.allowNodeNavigation = false
}
d.screenConfigs = append(d.screenConfigs, config)
}
return nil
}
// Run starts the dashboard.
func Run(ctx context.Context, cli *client.Client, opts ...Option) error {
dashboard, err := buildDashboard(ctx, cli, opts...)
if err != nil {
return err
}
dashboard.selectScreen(ScreenSummary)
stopFunc := dashboard.startDataHandler(ctx)
defer stopFunc() //nolint:errcheck
if err = dashboard.app.
SetRoot(dashboard.pages, true).
SetFocus(dashboard.pages).
Run(); err != nil {
return err
}
return stopFunc()
}
// startDataHandler starts the data and log update handler and returns a function to stop it.
func (d *Dashboard) startDataHandler(ctx context.Context) func() error {
var eg errgroup.Group
ctx, cancel := context.WithCancel(ctx)
stopFunc := func() error {
cancel()
err := eg.Wait()
if errors.Is(err, context.Canceled) {
return nil
}
return err
}
eg.Go(func() error {
// start API data source
dataCh := d.apiDataSource.Run(ctx)
defer d.apiDataSource.Stop()
// start resources data source
d.resourceDataSource.Run(ctx)
defer d.resourceDataSource.Stop() //nolint:errcheck
// start logs data source
if err := d.logDataSource.Start(ctx); err != nil {
return err
}
defer d.logDataSource.Stop() //nolint:errcheck
lastLogTime := time.Now()
for {
select {
case <-ctx.Done():
return ctx.Err()
case nodeLog := <-d.logDataSource.LogCh:
if time.Since(lastLogTime) < 50*time.Millisecond {
d.app.QueueUpdate(func() {
d.processLog(nodeLog.Node, nodeLog.Log)
})
} else {
d.app.QueueUpdateDraw(func() {
d.processLog(nodeLog.Node, nodeLog.Log)
})
}
lastLogTime = time.Now()
case d.data = <-dataCh:
d.app.QueueUpdateDraw(func() {
d.processAPIData()
})
case nodeResource := <-d.resourceDataSource.NodeResourceCh:
d.app.QueueUpdateDraw(func() {
d.processNodeResource(nodeResource)
})
}
}
})
return stopFunc
}
func (d *Dashboard) selectNodeByIndex(index int) {
if len(d.nodes) == 0 {
return
}
if index < 0 {
index = 0
} else if index >= len(d.nodes) {
index = len(d.nodes) - 1
}
d.selectedNode = d.nodes[index]
d.selectedNodeIndex = index
d.processAPIData()
for _, listener := range d.nodeSelectListeners {
listener.OnNodeSelect(d.selectedNode)
}
}
// processAPIData re-renders the components with new API-sourced data.
func (d *Dashboard) processAPIData() {
if d.data == nil {
return
}
for _, node := range maps.Keys(d.data.Nodes) {
d.processSeenNode(node)
}
for _, component := range d.apiDataListeners {
component.OnAPIDataChange(d.selectedNode, d.data)
}
}
// processNodeResource re-renders the components with new resource data.
func (d *Dashboard) processNodeResource(nodeResource resourcedata.Data) {
d.processSeenNode(nodeResource.Node)
for _, component := range d.resourceDataListeners {
component.OnResourceDataChange(nodeResource)
}
}
// processLog re-renders the log components with new log data.
func (d *Dashboard) processLog(node, line string) {
for _, component := range d.logDataListeners {
component.OnLogDataChange(node, line)
}
}
func (d *Dashboard) processSeenNode(node string) {
_, exists := d.nodeSet[node]
if exists {
return
}
d.nodeSet[node] = struct{}{}
nodes := maps.Keys(d.nodeSet)
sort.Strings(nodes)
d.nodes = nodes
for _, listener := range d.nodeSetChangeListeners {
listener.OnNodeSetChange(nodes)
}
// we received a new node, so we re-select the first node
d.selectNodeByIndex(0)
}
func (d *Dashboard) selectScreen(screen Screen) {
for _, info := range d.screenConfigs {
info := info
if info.screen == screen {
d.selectedScreenConfig = &info
d.mainGrid.AddItem(info.primitive, 1, 0, 1, 1, 0, 0, false)
info.primitive.onScreenSelect(true)
continue
}
d.mainGrid.RemoveItem(info.primitive)
info.primitive.onScreenSelect(false)
}
d.footer.SelectScreen(string(screen))
}