mirror of
https://github.com/siderolabs/talos.git
synced 2025-10-30 16:01:12 +01:00
When the dashboard is used via the CLI through a proxy, e.g., through Omni, node names or IDs can be used in the `--nodes` flag instead of the IPs. This caused rendering inconsistencies in the dashboard, as some parts of it used the IPs and some used the names passed in the context. Fix this by collecting all node IPs on dashboard start, and map these IPs to the respective nodes passed as the `--nodes` flag. On the dashboard footer, we always display the node names as they are passed in the `--nodes` flag. As part of it, remove the node list change reactivity from the dashboard, so it will always take the passed nodes as the truth. The IP to node mapping collection at dashboard startup also solves another issue where the first API call by the dashboard triggered the interactive API authentication (e.g., the OIDC flow). Previously, because the terminal was already switched to the raw mode, it was not possible to authenticate properly. Signed-off-by: Utku Ozdemir <utku.ozdemir@siderolabs.com>
589 lines
15 KiB
Go
589 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/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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
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(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
|
|
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:
|
|
nodeAlias := d.attemptResolveIPToAlias(nodeLog.Node)
|
|
|
|
if time.Since(lastLogTime) < 50*time.Millisecond {
|
|
d.app.QueueUpdate(func() {
|
|
d.processLog(nodeAlias, nodeLog.Log)
|
|
})
|
|
} else {
|
|
d.app.QueueUpdateDraw(func() {
|
|
d.processLog(nodeAlias, nodeLog.Log)
|
|
})
|
|
}
|
|
|
|
lastLogTime = time.Now()
|
|
case d.data = <-dataCh:
|
|
d.data.Nodes = maps.Map(d.data.Nodes, func(key string, v *apidata.Node) (string, *apidata.Node) {
|
|
return d.attemptResolveIPToAlias(key), v
|
|
})
|
|
|
|
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, line string) {
|
|
for _, component := range d.logDataListeners {
|
|
component.OnLogDataChange(node, line)
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
// attemptResolveIPToAlias attempts to resolve the given node IP to its alias as it appears in "nodes" in the context.
|
|
// If the IP is not found in the context, the IP is returned as-is.
|
|
func (d *Dashboard) attemptResolveIPToAlias(node string) string {
|
|
if resolved, ok := d.ipsToNodeAliases[node]; ok {
|
|
return resolved
|
|
}
|
|
|
|
return node
|
|
}
|
|
|
|
// 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) // 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
|
|
}
|