mirror of
https://github.com/siderolabs/talos.git
synced 2025-10-09 14:41:31 +02:00
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>
475 lines
11 KiB
Go
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))
|
|
}
|