talos/internal/pkg/dashboard/dashboard.go
Andrey Smirnov 8689bef5f1
docs: update documentation for Talos 1.4
Updated documentation, what's new, etc.

Also fix some minor UI issues in the dashboard.

Signed-off-by: Andrey Smirnov <andrey.smirnov@talos-systems.com>
2023-04-18 15:09:55 +04:00

475 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 defOptions.allowExitKeys && (event.Key() == tcell.KeyCtrlC || event.Rune() == 'q'):
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))
}