| Serge Bazanski | 0d9e125 | 2024-09-03 12:16:47 +0200 | [diff] [blame] | 1 | package tconsole |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "strings" |
| Serge Bazanski | d735a3c | 2024-09-05 13:51:44 +0200 | [diff] [blame^] | 8 | "time" |
| Serge Bazanski | 0d9e125 | 2024-09-03 12:16:47 +0200 | [diff] [blame] | 9 | |
| 10 | "github.com/gdamore/tcell/v2" |
| 11 | |
| 12 | "source.monogon.dev/metropolis/node/core/network" |
| 13 | "source.monogon.dev/metropolis/node/core/roleserve" |
| 14 | cpb "source.monogon.dev/metropolis/proto/common" |
| 15 | "source.monogon.dev/osbase/event" |
| 16 | "source.monogon.dev/osbase/supervisor" |
| 17 | ) |
| 18 | |
| 19 | // Console is a Terminal Console (TConsole), a user-interactive informational |
| 20 | // display visible on the TTY of a running Metropolis instance. |
| 21 | type Console struct { |
| 22 | // Quit will be closed when the user press CTRL-C. A new channel will be created |
| 23 | // on each New call. |
| 24 | |
| 25 | Quit chan struct{} |
| 26 | ttyPath string |
| 27 | tty tcell.Tty |
| 28 | screen tcell.Screen |
| 29 | width int |
| 30 | height int |
| 31 | // palette chosen for the given terminal. |
| 32 | palette palette |
| Serge Bazanski | d735a3c | 2024-09-05 13:51:44 +0200 | [diff] [blame^] | 33 | // activePage expressed within [0...num pages). The number/layout of pages is |
| 34 | // constructed dynamically in Run. |
| 35 | activePage int |
| Serge Bazanski | 0d9e125 | 2024-09-03 12:16:47 +0200 | [diff] [blame] | 36 | |
| 37 | network event.Value[*network.Status] |
| 38 | roles event.Value[*cpb.NodeRoles] |
| 39 | curatorConn event.Value[*roleserve.CuratorConnection] |
| 40 | } |
| 41 | |
| 42 | // New creates a new Console, taking over the TTY at the given path. The given |
| 43 | // Terminal type selects between a Linux terminal (VTY) and a generic terminal |
| 44 | // for testing. |
| 45 | // |
| 46 | // network, roles, curatorConn point to various Metropolis subsystems that are |
| 47 | // used to populate the console data. |
| 48 | func New(terminal Terminal, ttyPath string, network event.Value[*network.Status], roles event.Value[*cpb.NodeRoles], curatorConn event.Value[*roleserve.CuratorConnection]) (*Console, error) { |
| 49 | tty, err := tcell.NewDevTtyFromDev(ttyPath) |
| 50 | if err != nil { |
| 51 | return nil, err |
| 52 | } |
| 53 | screen, err := tcell.NewTerminfoScreenFromTty(tty) |
| 54 | if err != nil { |
| 55 | return nil, err |
| 56 | } |
| 57 | if err := screen.Init(); err != nil { |
| 58 | return nil, err |
| 59 | } |
| 60 | screen.SetStyle(tcell.StyleDefault) |
| 61 | |
| 62 | var pal palette |
| 63 | switch terminal { |
| 64 | case TerminalLinux: |
| 65 | pal = paletteLinux |
| 66 | tty.Write([]byte(pal.setupLinuxConsole())) |
| 67 | case TerminalGeneric: |
| 68 | pal = paletteGeneric |
| 69 | } |
| 70 | |
| 71 | width, height := screen.Size() |
| Serge Bazanski | d735a3c | 2024-09-05 13:51:44 +0200 | [diff] [blame^] | 72 | |
| Serge Bazanski | 0d9e125 | 2024-09-03 12:16:47 +0200 | [diff] [blame] | 73 | return &Console{ |
| Serge Bazanski | d735a3c | 2024-09-05 13:51:44 +0200 | [diff] [blame^] | 74 | ttyPath: ttyPath, |
| 75 | tty: tty, |
| 76 | screen: screen, |
| 77 | width: width, |
| 78 | height: height, |
| 79 | network: network, |
| 80 | palette: pal, |
| 81 | Quit: make(chan struct{}), |
| 82 | activePage: 0, |
| Serge Bazanski | 0d9e125 | 2024-09-03 12:16:47 +0200 | [diff] [blame] | 83 | |
| 84 | roles: roles, |
| 85 | curatorConn: curatorConn, |
| 86 | }, nil |
| 87 | } |
| 88 | |
| 89 | // Cleanup should be called when the console exits. This is only used in testing, |
| 90 | // the Metropolis console always runs. |
| 91 | func (c *Console) Cleanup() { |
| 92 | c.screen.Fini() |
| 93 | } |
| 94 | |
| 95 | func (c *Console) processEvent(ev tcell.Event) { |
| 96 | switch ev := ev.(type) { |
| 97 | case *tcell.EventKey: |
| 98 | if ev.Key() == tcell.KeyCtrlC { |
| 99 | close(c.Quit) |
| 100 | } |
| Serge Bazanski | d735a3c | 2024-09-05 13:51:44 +0200 | [diff] [blame^] | 101 | if ev.Key() == tcell.KeyTab { |
| 102 | c.activePage += 1 |
| 103 | } |
| Serge Bazanski | 0d9e125 | 2024-09-03 12:16:47 +0200 | [diff] [blame] | 104 | case *tcell.EventResize: |
| 105 | c.width, c.height = ev.Size() |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | // Run blocks while displaying the Console to the user. |
| 110 | func (c *Console) Run(ctx context.Context) error { |
| 111 | // Build channel for console event processing. |
| 112 | evC := make(chan tcell.Event) |
| 113 | evQuitC := make(chan struct{}) |
| 114 | defer close(evQuitC) |
| 115 | go c.screen.ChannelEvents(evC, evQuitC) |
| 116 | |
| 117 | // Pipe event values into channels. |
| 118 | netAddrC := make(chan *network.Status) |
| 119 | rolesC := make(chan *cpb.NodeRoles) |
| 120 | curatorConnC := make(chan *roleserve.CuratorConnection) |
| 121 | if err := supervisor.Run(ctx, "netpipe", event.Pipe(c.network, netAddrC)); err != nil { |
| 122 | return err |
| 123 | } |
| 124 | if err := supervisor.Run(ctx, "rolespipe", event.Pipe(c.roles, rolesC)); err != nil { |
| 125 | return err |
| 126 | } |
| 127 | if err := supervisor.Run(ctx, "curatorpipe", event.Pipe(c.curatorConn, curatorConnC)); err != nil { |
| 128 | return err |
| 129 | } |
| 130 | supervisor.Signal(ctx, supervisor.SignalHealthy) |
| 131 | |
| Serge Bazanski | d735a3c | 2024-09-05 13:51:44 +0200 | [diff] [blame^] | 132 | // Per-page data. |
| Serge Bazanski | 0d9e125 | 2024-09-03 12:16:47 +0200 | [diff] [blame] | 133 | pageStatus := pageStatusData{ |
| 134 | netAddr: "Waiting...", |
| 135 | roles: "Waiting...", |
| 136 | id: "Waiting...", |
| 137 | fingerprint: "Waiting...", |
| 138 | } |
| 139 | |
| Serge Bazanski | d735a3c | 2024-09-05 13:51:44 +0200 | [diff] [blame^] | 140 | // Page references and names. |
| 141 | pages := []func(){ |
| 142 | func() { c.pageStatus(&pageStatus) }, |
| 143 | } |
| 144 | pageNames := []string{ |
| 145 | "Status", |
| 146 | } |
| 147 | |
| 148 | // Ticker used to maintain redraws at minimum 10Hz, to eg. update the clock in |
| 149 | // the status bar. |
| 150 | ticker := time.NewTicker(time.Second / 10) |
| 151 | defer ticker.Stop() |
| 152 | |
| Serge Bazanski | 0d9e125 | 2024-09-03 12:16:47 +0200 | [diff] [blame] | 153 | for { |
| Serge Bazanski | d735a3c | 2024-09-05 13:51:44 +0200 | [diff] [blame^] | 154 | // Draw active page. |
| 155 | c.activePage %= len(pages) |
| 156 | pages[c.activePage]() |
| 157 | |
| 158 | // Draw status bar. |
| 159 | c.statusBar(c.activePage, pageNames...) |
| 160 | |
| 161 | // Sync to screen. |
| 162 | c.screen.Show() |
| Serge Bazanski | 0d9e125 | 2024-09-03 12:16:47 +0200 | [diff] [blame] | 163 | |
| 164 | select { |
| Serge Bazanski | d735a3c | 2024-09-05 13:51:44 +0200 | [diff] [blame^] | 165 | case <-ticker.C: |
| Serge Bazanski | 0d9e125 | 2024-09-03 12:16:47 +0200 | [diff] [blame] | 166 | case <-ctx.Done(): |
| 167 | return ctx.Err() |
| 168 | case ev := <-evC: |
| 169 | c.processEvent(ev) |
| 170 | case t := <-netAddrC: |
| 171 | pageStatus.netAddr = t.ExternalAddress.String() |
| 172 | case t := <-rolesC: |
| 173 | var rlist []string |
| 174 | if t.ConsensusMember != nil { |
| 175 | rlist = append(rlist, "ConsensusMember") |
| 176 | } |
| 177 | if t.KubernetesController != nil { |
| 178 | rlist = append(rlist, "KubernetesController") |
| 179 | } |
| 180 | if t.KubernetesWorker != nil { |
| 181 | rlist = append(rlist, "KubernetesWorker") |
| 182 | } |
| 183 | pageStatus.roles = strings.Join(rlist, ", ") |
| 184 | if pageStatus.roles == "" { |
| 185 | pageStatus.roles = "none" |
| 186 | } |
| 187 | case t := <-curatorConnC: |
| 188 | pageStatus.id = t.Credentials.ID() |
| 189 | cert := t.Credentials.ClusterCA() |
| 190 | sum := sha256.New() |
| 191 | sum.Write(cert.Raw) |
| 192 | pageStatus.fingerprint = hex.EncodeToString(sum.Sum(nil)) |
| 193 | } |
| 194 | } |
| 195 | } |