mirror of
https://github.com/siderolabs/talos.git
synced 2025-10-08 22:21:16 +02:00
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>
477 lines
11 KiB
Go
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))
|
|
}
|