blob: bbdca8200c7d06ad832cecfbbdec14593fbd78c4 [file] [log] [blame]
package tconsole
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"source.monogon.dev/metropolis/node/core/network"
"source.monogon.dev/metropolis/node/core/roleserve"
cpb "source.monogon.dev/metropolis/proto/common"
"source.monogon.dev/osbase/event"
"source.monogon.dev/osbase/logtree"
"source.monogon.dev/osbase/supervisor"
)
// Console is a Terminal Console (TConsole), a user-interactive informational
// display visible on the TTY of a running Metropolis instance.
type Console struct {
// Quit will be closed when the user press CTRL-C. A new channel will be created
// on each New call.
Quit chan struct{}
ttyPath string
tty tcell.Tty
screen tcell.Screen
width int
height int
// palette chosen for the given terminal.
palette palette
// activePage expressed within [0...num pages). The number/layout of pages is
// constructed dynamically in Run.
activePage int
reader *logtree.LogReader
network event.Value[*network.Status]
roles event.Value[*cpb.NodeRoles]
curatorConn event.Value[*roleserve.CuratorConnection]
}
// New creates a new Console, taking over the TTY at the given path. The given
// Terminal type selects between a Linux terminal (VTY) and a generic terminal
// for testing.
//
// network, roles, curatorConn point to various Metropolis subsystems that are
// used to populate the console data.
func New(terminal Terminal, ttyPath string, lt *logtree.LogTree, network event.Value[*network.Status], roles event.Value[*cpb.NodeRoles], curatorConn event.Value[*roleserve.CuratorConnection]) (*Console, error) {
reader, err := lt.Read("", logtree.WithChildren(), logtree.WithStream())
if err != nil {
return nil, fmt.Errorf("lt.Read: %v", err)
}
tty, err := tcell.NewDevTtyFromDev(ttyPath)
if err != nil {
return nil, err
}
screen, err := tcell.NewTerminfoScreenFromTty(tty)
if err != nil {
return nil, err
}
if err := screen.Init(); err != nil {
return nil, err
}
screen.SetStyle(tcell.StyleDefault)
var pal palette
switch terminal {
case TerminalLinux:
pal = paletteLinux
tty.Write([]byte(pal.setupLinuxConsole()))
case TerminalGeneric:
pal = paletteGeneric
}
width, height := screen.Size()
return &Console{
ttyPath: ttyPath,
tty: tty,
screen: screen,
width: width,
height: height,
network: network,
palette: pal,
Quit: make(chan struct{}),
activePage: 0,
reader: reader,
roles: roles,
curatorConn: curatorConn,
}, nil
}
// Cleanup should be called when the console exits. This is only used in testing,
// the Metropolis console always runs.
func (c *Console) Cleanup() {
c.screen.Fini()
c.reader.Close()
}
func (c *Console) processEvent(ev tcell.Event) {
switch ev := ev.(type) {
case *tcell.EventKey:
if ev.Key() == tcell.KeyCtrlC {
close(c.Quit)
}
if ev.Key() == tcell.KeyTab {
c.activePage += 1
}
case *tcell.EventResize:
c.width, c.height = ev.Size()
}
}
// Run blocks while displaying the Console to the user.
func (c *Console) Run(ctx context.Context) error {
// Build channel for console event processing.
evC := make(chan tcell.Event)
evQuitC := make(chan struct{})
defer close(evQuitC)
go c.screen.ChannelEvents(evC, evQuitC)
// Pipe event values into channels.
netAddrC := make(chan *network.Status)
rolesC := make(chan *cpb.NodeRoles)
curatorConnC := make(chan *roleserve.CuratorConnection)
if err := supervisor.Run(ctx, "netpipe", event.Pipe(c.network, netAddrC)); err != nil {
return err
}
if err := supervisor.Run(ctx, "rolespipe", event.Pipe(c.roles, rolesC)); err != nil {
return err
}
if err := supervisor.Run(ctx, "curatorpipe", event.Pipe(c.curatorConn, curatorConnC)); err != nil {
return err
}
supervisor.Signal(ctx, supervisor.SignalHealthy)
// Per-page data.
pageStatus := pageStatusData{
netAddr: "Waiting...",
roles: "Waiting...",
id: "Waiting...",
fingerprint: "Waiting...",
}
pageLogs := pageLogsData{}
// Page references and names.
pages := []func(){
func() { c.pageStatus(&pageStatus) },
func() { c.pageLogs(&pageLogs) },
}
pageNames := []string{
"Status", "Logs",
}
// Ticker used to maintain redraws at minimum 10Hz, to eg. update the clock in
// the status bar.
ticker := time.NewTicker(time.Second / 10)
defer ticker.Stop()
for {
// Draw active page.
c.activePage %= len(pages)
pages[c.activePage]()
// Draw status bar.
c.statusBar(c.activePage, pageNames...)
// Sync to screen.
c.screen.Show()
select {
case <-ticker.C:
case <-ctx.Done():
return ctx.Err()
case ev := <-evC:
c.processEvent(ev)
case t := <-netAddrC:
pageStatus.netAddr = t.ExternalAddress.String()
case t := <-rolesC:
var rlist []string
if t.ConsensusMember != nil {
rlist = append(rlist, "ConsensusMember")
}
if t.KubernetesController != nil {
rlist = append(rlist, "KubernetesController")
}
if t.KubernetesWorker != nil {
rlist = append(rlist, "KubernetesWorker")
}
pageStatus.roles = strings.Join(rlist, ", ")
if pageStatus.roles == "" {
pageStatus.roles = "none"
}
case t := <-curatorConnC:
pageStatus.id = t.Credentials.ID()
cert := t.Credentials.ClusterCA()
sum := sha256.New()
sum.Write(cert.Raw)
pageStatus.fingerprint = hex.EncodeToString(sum.Sum(nil))
case le := <-c.reader.Stream:
pageLogs.appendLine(le.String())
}
}
}