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