mirror of
https://github.com/siderolabs/talos.git
synced 2025-08-06 22:57:10 +02:00
Bump all dependencies, many small changes due to new golangci-lint version. Signed-off-by: Andrey Smirnov <andrey.smirnov@siderolabs.com>
573 lines
15 KiB
Go
573 lines
15 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"
|
|
"net/netip"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gdamore/tcell/v2"
|
|
_ "github.com/gdamore/tcell/v2/terminfo/l/linux" // linux terminal is used when running on the machine, but not included with tcell_minimal
|
|
"github.com/gizak/termui/v3"
|
|
"github.com/rivo/tview"
|
|
"github.com/siderolabs/gen/maps"
|
|
"github.com/siderolabs/gen/xslices"
|
|
"github.com/siderolabs/go-api-signature/pkg/message"
|
|
"golang.org/x/sync/errgroup"
|
|
"google.golang.org/grpc/metadata"
|
|
|
|
"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/resolver"
|
|
"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, logLine, logError 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
|
|
|
|
app *tview.Application
|
|
|
|
mainGrid *tview.Grid
|
|
|
|
pages *tview.Pages
|
|
|
|
selectedScreenConfig *screenConfig
|
|
screenConfigs []screenConfig
|
|
footer *components.Footer
|
|
|
|
data *apidata.Data
|
|
|
|
selectedNodeIndex int
|
|
selectedNode string
|
|
nodeSet map[string]struct{}
|
|
ipsToNodeAliases map[string]string
|
|
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)
|
|
}
|
|
|
|
// map node IPs to their aliases (names/IPs - as specified "nodes" in context).
|
|
// this will also trigger the interactive API authentication if needed - e.g., when the API is used through Omni.
|
|
ipsToNodeAliases, err := collectNodeIPsToNodeAliases(ctx, cli)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nodes := getSortedNodeAliases(ipsToNodeAliases)
|
|
|
|
dashboard := &Dashboard{
|
|
cli: cli,
|
|
interval: defOptions.interval,
|
|
app: tview.NewApplication(),
|
|
nodeSet: make(map[string]struct{}),
|
|
nodes: nodes,
|
|
ipsToNodeAliases: ipsToNodeAliases,
|
|
}
|
|
|
|
dashboard.mainGrid = tview.NewGrid().
|
|
SetRows(1, 0, 1).
|
|
SetColumns(0)
|
|
|
|
dashboard.pages = tview.NewPages().AddPage(pageMain, dashboard.mainGrid, true, true)
|
|
|
|
dashboard.app.SetRoot(dashboard.pages, true).SetFocus(dashboard.pages)
|
|
|
|
header := components.NewHeader()
|
|
dashboard.mainGrid.AddItem(header, 0, 0, 1, 1, 0, 0, false)
|
|
|
|
if err = dashboard.initScreenConfigs(ctx, defOptions.screens); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
screenKeyToName := xslices.ToMap(dashboard.screenConfigs, func(t screenConfig) (string, string) {
|
|
return t.screenKey, string(t.screen)
|
|
})
|
|
|
|
screenConfigByKeyCode := xslices.ToMap(dashboard.screenConfigs, func(config screenConfig) (tcell.Key, screenConfig) {
|
|
return config.keyCode, config
|
|
})
|
|
|
|
dashboard.footer = components.NewFooter(screenKeyToName, nodes)
|
|
|
|
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.resourceDataListeners = []ResourceDataListener{
|
|
header,
|
|
}
|
|
|
|
dashboard.logDataListeners = []LogDataListener{}
|
|
|
|
dashboard.nodeSelectListeners = []NodeSelectListener{
|
|
header,
|
|
dashboard.footer,
|
|
}
|
|
|
|
for _, config := range dashboard.screenConfigs {
|
|
screenPrimitive := config.primitive
|
|
|
|
apiDataListener, ok := screenPrimitive.(APIDataListener)
|
|
if ok {
|
|
dashboard.apiDataListeners = append(dashboard.apiDataListeners, apiDataListener)
|
|
}
|
|
|
|
resourceDataListener, ok := screenPrimitive.(ResourceDataListener)
|
|
if ok {
|
|
dashboard.resourceDataListeners = append(dashboard.resourceDataListeners, resourceDataListener)
|
|
}
|
|
|
|
logDataListener, ok := screenPrimitive.(LogDataListener)
|
|
if ok {
|
|
dashboard.logDataListeners = append(dashboard.logDataListeners, logDataListener)
|
|
}
|
|
|
|
nodeSelectListener, ok := screenPrimitive.(NodeSelectListener)
|
|
if ok {
|
|
dashboard.nodeSelectListeners = append(dashboard.nodeSelectListeners, nodeSelectListener)
|
|
}
|
|
}
|
|
|
|
nodeResolver := resolver.New(ipsToNodeAliases)
|
|
|
|
dashboard.apiDataSource = &apidata.Source{
|
|
Client: cli,
|
|
Interval: defOptions.interval,
|
|
Resolver: nodeResolver,
|
|
}
|
|
|
|
dashboard.resourceDataSource = &resourcedata.Source{
|
|
COSI: cli.COSI,
|
|
}
|
|
|
|
dashboard.logDataSource = logdata.NewSource(cli, nodeResolver)
|
|
|
|
return dashboard, nil
|
|
}
|
|
|
|
func (d *Dashboard) initScreenConfigs(ctx context.Context, screens []Screen) error {
|
|
primitiveForScreen := func(screen Screen) screenSelectListener {
|
|
switch screen {
|
|
case ScreenSummary:
|
|
return NewSummaryGrid(d.app)
|
|
case ScreenMonitor:
|
|
return NewMonitorGrid(d.app)
|
|
case ScreenNetworkConfig:
|
|
return NewNetworkConfigGrid(ctx, d)
|
|
case ScreenConfigURL:
|
|
return NewConfigURLGrid(ctx, d)
|
|
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) (runErr error) {
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
dashboard, err := buildDashboard(ctx, cli, opts...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
dashboard.selectNodeByIndex(0)
|
|
|
|
// handle panic & stop dashboard gracefully on exit
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
runErr = fmt.Errorf("dashboard panic: %v", r)
|
|
}
|
|
|
|
dashboard.app.Stop()
|
|
}()
|
|
|
|
dashboard.selectScreen(ScreenSummary)
|
|
|
|
eg, ctx := errgroup.WithContext(ctx)
|
|
|
|
stopFunc := dashboard.startDataHandler(ctx)
|
|
defer stopFunc() //nolint:errcheck
|
|
|
|
eg.Go(func() error {
|
|
defer cancel()
|
|
|
|
return dashboard.app.Run()
|
|
})
|
|
|
|
// stop dashboard when the context is canceled
|
|
eg.Go(func() error {
|
|
<-ctx.Done()
|
|
|
|
dashboard.app.Stop()
|
|
|
|
return nil
|
|
})
|
|
|
|
return eg.Wait()
|
|
}
|
|
|
|
// 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
|
|
d.logDataSource.Start(ctx)
|
|
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, nodeLog.Error)
|
|
})
|
|
} else {
|
|
d.app.QueueUpdateDraw(func() {
|
|
d.processLog(nodeLog.Node, nodeLog.Log, nodeLog.Error)
|
|
})
|
|
}
|
|
|
|
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 _, 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) {
|
|
for _, component := range d.resourceDataListeners {
|
|
component.OnResourceDataChange(nodeResource)
|
|
}
|
|
}
|
|
|
|
// processLog re-renders the log components with new log data.
|
|
func (d *Dashboard) processLog(node, logLine, logError string) {
|
|
for _, component := range d.logDataListeners {
|
|
component.OnLogDataChange(node, logLine, logError)
|
|
}
|
|
}
|
|
|
|
func (d *Dashboard) selectScreen(screen Screen) {
|
|
for _, info := range d.screenConfigs {
|
|
if info.screen == screen {
|
|
d.selectedScreenConfig = &info //nolint:exportloopref
|
|
|
|
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))
|
|
}
|
|
|
|
// collectNodeIPsToNodeAliases probes all nodes in the context for their IP addresses by calling their .Version endpoint and maps them to the node aliases in the context.
|
|
//
|
|
// Sample output:
|
|
//
|
|
// 172.20.0.6 -> node-1
|
|
//
|
|
// 10.42.0.1 -> node-1
|
|
//
|
|
// 172.20.0.7 -> node-2
|
|
//
|
|
// 10.42.0.2 -> node-2.
|
|
func collectNodeIPsToNodeAliases(ctx context.Context, c *client.Client) (map[string]string, error) {
|
|
ipsToNodeAliases := make(map[string]string)
|
|
|
|
nodes := nodeAliasesInContext(ctx)
|
|
for _, node := range nodes {
|
|
ctx = client.WithNodes(ctx, node) //nolint:fatcontext // do not replace this with "WithNode" - it would not return the IP in the response metadata
|
|
|
|
resp, err := c.Version(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get node %q version: %w", node, err)
|
|
}
|
|
|
|
if len(resp.GetMessages()) == 0 {
|
|
return nil, fmt.Errorf("node %q returned no messages in version response", node)
|
|
}
|
|
|
|
nodeIP := resp.GetMessages()[0].GetMetadata().GetHostname()
|
|
if nodeIP == "" {
|
|
return nil, fmt.Errorf("node %q returned no IP in version response", node)
|
|
}
|
|
|
|
ipsToNodeAliases[nodeIP] = node
|
|
}
|
|
|
|
return ipsToNodeAliases, nil
|
|
}
|
|
|
|
// nodeAliasesInContext extracts the node aliases (IP, name etc.) from the given context which are stored in the "node" or "nodes" GRPC metadata.
|
|
func nodeAliasesInContext(ctx context.Context) []string {
|
|
md, mdOk := metadata.FromOutgoingContext(ctx)
|
|
if !mdOk {
|
|
return nil
|
|
}
|
|
|
|
nodeVal := md.Get("node")
|
|
if len(nodeVal) > 0 {
|
|
return []string{nodeVal[0]}
|
|
}
|
|
|
|
nodesVal := md.Get(message.NodesHeaderKey)
|
|
|
|
return xslices.FlatMap(nodesVal, func(node string) []string {
|
|
return strings.Split(node, ",")
|
|
})
|
|
}
|
|
|
|
// getSortedNodeAliases returns the unique node aliases sorted by their IP address.
|
|
func getSortedNodeAliases(ipToNodeAliases map[string]string) []string {
|
|
if len(ipToNodeAliases) == 0 { // assume that it is the local node (running on TTY)
|
|
return []string{""}
|
|
}
|
|
|
|
nodeAliases := maps.Keys(xslices.ToSet(maps.Values(ipToNodeAliases))) // eliminate duplicates
|
|
|
|
// if the aliases are IP addresses, compare them as IPs
|
|
// otherwise, compare them as strings
|
|
// all IPs come before non-IPs
|
|
slices.SortFunc(nodeAliases, func(a, b string) int {
|
|
addrA, aErr := netip.ParseAddr(a)
|
|
|
|
addrB, bErr := netip.ParseAddr(b)
|
|
if aErr != nil && bErr != nil {
|
|
return strings.Compare(a, b)
|
|
}
|
|
|
|
if aErr != nil {
|
|
return 1
|
|
}
|
|
|
|
if bErr != nil {
|
|
return -1
|
|
}
|
|
|
|
return addrA.Compare(addrB)
|
|
})
|
|
|
|
return nodeAliases
|
|
}
|