blob: bd4bd08a3703b841149ce25e4f70dd0825f3df00 [file] [log] [blame]
Serge Bazanski0d9e1252024-09-03 12:16:47 +02001package tconsole
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
Serge Bazanski73c632f2024-09-05 13:51:57 +02007 "fmt"
Serge Bazanski0d9e1252024-09-03 12:16:47 +02008 "strings"
Serge Bazanskid735a3c2024-09-05 13:51:44 +02009 "time"
Serge Bazanski0d9e1252024-09-03 12:16:47 +020010
11 "github.com/gdamore/tcell/v2"
12
13 "source.monogon.dev/metropolis/node/core/network"
14 "source.monogon.dev/metropolis/node/core/roleserve"
15 cpb "source.monogon.dev/metropolis/proto/common"
16 "source.monogon.dev/osbase/event"
Serge Bazanski73c632f2024-09-05 13:51:57 +020017 "source.monogon.dev/osbase/logtree"
Serge Bazanski0d9e1252024-09-03 12:16:47 +020018 "source.monogon.dev/osbase/supervisor"
19)
20
21// Console is a Terminal Console (TConsole), a user-interactive informational
22// display visible on the TTY of a running Metropolis instance.
23type Console struct {
24 // Quit will be closed when the user press CTRL-C. A new channel will be created
25 // on each New call.
26
27 Quit chan struct{}
28 ttyPath string
29 tty tcell.Tty
30 screen tcell.Screen
31 width int
32 height int
33 // palette chosen for the given terminal.
34 palette palette
Serge Bazanskid735a3c2024-09-05 13:51:44 +020035 // activePage expressed within [0...num pages). The number/layout of pages is
36 // constructed dynamically in Run.
37 activePage int
Serge Bazanski0d9e1252024-09-03 12:16:47 +020038
Serge Bazanski73c632f2024-09-05 13:51:57 +020039 reader *logtree.LogReader
Serge Bazanski0d9e1252024-09-03 12:16:47 +020040 network event.Value[*network.Status]
41 roles event.Value[*cpb.NodeRoles]
42 curatorConn event.Value[*roleserve.CuratorConnection]
43}
44
45// New creates a new Console, taking over the TTY at the given path. The given
46// Terminal type selects between a Linux terminal (VTY) and a generic terminal
47// for testing.
48//
49// network, roles, curatorConn point to various Metropolis subsystems that are
50// used to populate the console data.
Serge Bazanski73c632f2024-09-05 13:51:57 +020051func 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) {
52 reader, err := lt.Read("", logtree.WithChildren(), logtree.WithStream())
53 if err != nil {
54 return nil, fmt.Errorf("lt.Read: %v", err)
55 }
56
Serge Bazanski0d9e1252024-09-03 12:16:47 +020057 tty, err := tcell.NewDevTtyFromDev(ttyPath)
58 if err != nil {
59 return nil, err
60 }
61 screen, err := tcell.NewTerminfoScreenFromTty(tty)
62 if err != nil {
63 return nil, err
64 }
65 if err := screen.Init(); err != nil {
66 return nil, err
67 }
68 screen.SetStyle(tcell.StyleDefault)
69
70 var pal palette
71 switch terminal {
72 case TerminalLinux:
73 pal = paletteLinux
74 tty.Write([]byte(pal.setupLinuxConsole()))
75 case TerminalGeneric:
76 pal = paletteGeneric
77 }
78
79 width, height := screen.Size()
Serge Bazanskid735a3c2024-09-05 13:51:44 +020080
Serge Bazanski0d9e1252024-09-03 12:16:47 +020081 return &Console{
Serge Bazanskid735a3c2024-09-05 13:51:44 +020082 ttyPath: ttyPath,
83 tty: tty,
84 screen: screen,
85 width: width,
86 height: height,
87 network: network,
88 palette: pal,
89 Quit: make(chan struct{}),
90 activePage: 0,
Serge Bazanski73c632f2024-09-05 13:51:57 +020091 reader: reader,
Serge Bazanski0d9e1252024-09-03 12:16:47 +020092
93 roles: roles,
94 curatorConn: curatorConn,
95 }, nil
96}
97
98// Cleanup should be called when the console exits. This is only used in testing,
99// the Metropolis console always runs.
100func (c *Console) Cleanup() {
101 c.screen.Fini()
Serge Bazanski73c632f2024-09-05 13:51:57 +0200102 c.reader.Close()
Serge Bazanski0d9e1252024-09-03 12:16:47 +0200103}
104
105func (c *Console) processEvent(ev tcell.Event) {
106 switch ev := ev.(type) {
107 case *tcell.EventKey:
108 if ev.Key() == tcell.KeyCtrlC {
109 close(c.Quit)
110 }
Serge Bazanskid735a3c2024-09-05 13:51:44 +0200111 if ev.Key() == tcell.KeyTab {
112 c.activePage += 1
113 }
Serge Bazanski0d9e1252024-09-03 12:16:47 +0200114 case *tcell.EventResize:
115 c.width, c.height = ev.Size()
116 }
117}
118
119// Run blocks while displaying the Console to the user.
120func (c *Console) Run(ctx context.Context) error {
121 // Build channel for console event processing.
122 evC := make(chan tcell.Event)
123 evQuitC := make(chan struct{})
124 defer close(evQuitC)
125 go c.screen.ChannelEvents(evC, evQuitC)
126
127 // Pipe event values into channels.
128 netAddrC := make(chan *network.Status)
129 rolesC := make(chan *cpb.NodeRoles)
130 curatorConnC := make(chan *roleserve.CuratorConnection)
131 if err := supervisor.Run(ctx, "netpipe", event.Pipe(c.network, netAddrC)); err != nil {
132 return err
133 }
134 if err := supervisor.Run(ctx, "rolespipe", event.Pipe(c.roles, rolesC)); err != nil {
135 return err
136 }
137 if err := supervisor.Run(ctx, "curatorpipe", event.Pipe(c.curatorConn, curatorConnC)); err != nil {
138 return err
139 }
140 supervisor.Signal(ctx, supervisor.SignalHealthy)
141
Serge Bazanskid735a3c2024-09-05 13:51:44 +0200142 // Per-page data.
Serge Bazanski0d9e1252024-09-03 12:16:47 +0200143 pageStatus := pageStatusData{
144 netAddr: "Waiting...",
145 roles: "Waiting...",
146 id: "Waiting...",
147 fingerprint: "Waiting...",
148 }
Serge Bazanski73c632f2024-09-05 13:51:57 +0200149 pageLogs := pageLogsData{}
Serge Bazanski0d9e1252024-09-03 12:16:47 +0200150
Serge Bazanskid735a3c2024-09-05 13:51:44 +0200151 // Page references and names.
152 pages := []func(){
153 func() { c.pageStatus(&pageStatus) },
Serge Bazanski73c632f2024-09-05 13:51:57 +0200154 func() { c.pageLogs(&pageLogs) },
Serge Bazanskid735a3c2024-09-05 13:51:44 +0200155 }
156 pageNames := []string{
Serge Bazanski73c632f2024-09-05 13:51:57 +0200157 "Status", "Logs",
Serge Bazanskid735a3c2024-09-05 13:51:44 +0200158 }
159
160 // Ticker used to maintain redraws at minimum 10Hz, to eg. update the clock in
161 // the status bar.
Serge Bazanski154e6d92024-09-11 17:26:31 +0200162 tickerDraw := time.NewTicker(time.Second / 10)
163 defer tickerDraw.Stop()
164
165 // Ticker used to fully resync the screen every 10 seconds, in case something
166 // scribbled over the TTY.
167 tickerSync := time.NewTicker(time.Second * 10)
168 defer tickerSync.Stop()
Serge Bazanskid735a3c2024-09-05 13:51:44 +0200169
Serge Bazanski0d9e1252024-09-03 12:16:47 +0200170 for {
Serge Bazanskid735a3c2024-09-05 13:51:44 +0200171 // Draw active page.
172 c.activePage %= len(pages)
173 pages[c.activePage]()
174
175 // Draw status bar.
176 c.statusBar(c.activePage, pageNames...)
177
178 // Sync to screen.
179 c.screen.Show()
Serge Bazanski0d9e1252024-09-03 12:16:47 +0200180
181 select {
Serge Bazanski154e6d92024-09-11 17:26:31 +0200182 case <-tickerDraw.C:
183 case <-tickerSync.C:
184 c.screen.Sync()
Serge Bazanski0d9e1252024-09-03 12:16:47 +0200185 case <-ctx.Done():
186 return ctx.Err()
187 case ev := <-evC:
188 c.processEvent(ev)
189 case t := <-netAddrC:
190 pageStatus.netAddr = t.ExternalAddress.String()
191 case t := <-rolesC:
192 var rlist []string
193 if t.ConsensusMember != nil {
194 rlist = append(rlist, "ConsensusMember")
195 }
196 if t.KubernetesController != nil {
197 rlist = append(rlist, "KubernetesController")
198 }
199 if t.KubernetesWorker != nil {
200 rlist = append(rlist, "KubernetesWorker")
201 }
202 pageStatus.roles = strings.Join(rlist, ", ")
203 if pageStatus.roles == "" {
204 pageStatus.roles = "none"
205 }
206 case t := <-curatorConnC:
207 pageStatus.id = t.Credentials.ID()
208 cert := t.Credentials.ClusterCA()
209 sum := sha256.New()
210 sum.Write(cert.Raw)
211 pageStatus.fingerprint = hex.EncodeToString(sum.Sum(nil))
Serge Bazanski73c632f2024-09-05 13:51:57 +0200212 case le := <-c.reader.Stream:
213 pageLogs.appendLine(le.String())
Serge Bazanski0d9e1252024-09-03 12:16:47 +0200214 }
215 }
216}