m/node/core/tconsole: implement log scrollback
Change-Id: I7ad6b42e16308366d8d34629c6d8d15ca5f1faf0
Reviewed-on: https://review.monogon.dev/c/monogon/+/4462
Tested-by: Jenkins CI
Reviewed-by: Lorenz Brun <lorenz@monogon.tech>
diff --git a/metropolis/node/core/tconsole/page_logs.go b/metropolis/node/core/tconsole/page_logs.go
index 525b4ce..8c7315a 100644
--- a/metropolis/node/core/tconsole/page_logs.go
+++ b/metropolis/node/core/tconsole/page_logs.go
@@ -3,28 +3,40 @@
package tconsole
-import "github.com/gdamore/tcell/v2"
+import (
+ "github.com/gdamore/tcell/v2"
-// pageLogsData encompasses all data to be shown within the logs page.
-type pageLogsData struct {
+ "source.monogon.dev/osbase/logtree"
+)
+
+// maxLines defines the maximum number of log lines that should be stored
+// at any point.
+const maxLines = 1024
+
+// pageLogs encompasses all data to be shown within the logs page.
+type pageLogs struct {
// log lines, simple deque with the newest log lines appended to the end.
- lines []string
+ lines []*logtree.LogEntry
+
+ // log lines for scrollback
+ scrollbackBuffer []*logtree.LogEntry
}
-func (p *pageLogsData) appendLine(s string) {
- p.lines = append(p.lines, s)
+func (p *pageLogs) appendLine(le *logtree.LogEntry) {
+ p.lines = append(p.lines, le)
+ p.compactData(maxLines)
}
// compactData ensures that there's no more lines stored than maxlines by
// discarding the oldest lines.
-func (p *pageLogsData) compactData(maxlines int) {
+func (p *pageLogs) compactData(maxlines int) {
if extra := len(p.lines) - maxlines; extra > 0 {
p.lines = p.lines[extra:]
}
}
-// pageLogs renders the logs page to the user given pageLogsData.
-func (c *Console) pageLogs(data *pageLogsData) {
+// render renders the logs page to the user.
+func (p *pageLogs) render(c *Console) {
c.screen.Clear()
sty1 := tcell.StyleDefault.Background(c.color(colorPink)).Foreground(c.color(colorBlack))
sty2 := tcell.StyleDefault.Background(c.color(colorBlue)).Foreground(c.color(colorBlack))
@@ -37,11 +49,17 @@
nlines := (c.height - 2) - 1
linelen := (c.width - 1) - 1
- // Compact and draw log lines.
- data.compactData(nlines)
+ // Discard everything outside of our visible window
+ p.compactData(nlines)
+
+ lines := p.lines
+ if p.scrollbackBuffer != nil {
+ lines = p.scrollbackBuffer
+ }
+
for y := 0; y < nlines; y++ {
- if y < len(data.lines) {
- line := data.lines[y]
+ if y < len(lines) {
+ line := lines[y].String()
if len(line) > linelen {
line = line[:linelen]
}
@@ -49,3 +67,82 @@
}
}
}
+
+func (p *pageLogs) processEvent(c *Console, ev tcell.Event) {
+ // Inner log area size.
+ nlines := (c.height - 2) - 1
+
+ var scrollInput int
+ switch ev := ev.(type) {
+ case *tcell.EventKey:
+ switch ev.Key() {
+ case tcell.KeyEnd:
+ scrollInput = 0
+ case tcell.KeyUp:
+ scrollInput = -1
+ case tcell.KeyDown:
+ scrollInput = 1
+ case tcell.KeyPgUp:
+ scrollInput = -nlines
+ case tcell.KeyPgDn:
+ scrollInput = nlines
+ default:
+ return
+ }
+ }
+
+ p.processScrollInput(c.config.LogTree, scrollInput, nlines)
+}
+
+func (p *pageLogs) processScrollInput(lt *logtree.LogTree, scrollInput int, nlines int) {
+ // Disable scrollback if the screen is not full or
+ // the user wants to exit it.
+ if len(p.lines) < nlines || scrollInput == 0 {
+ p.scrollbackBuffer = nil
+ return
+ }
+
+ // The position of the most recent line
+ maxPos := p.lines[len(p.lines)-1].Position
+
+ // Fetch our current scrollback position from either the scrollback buffer or
+ // or the most recent streamed line.
+ var oldPos int
+ if p.scrollbackBuffer != nil {
+ oldPos = p.scrollbackBuffer[len(p.scrollbackBuffer)-1].Position
+ } else {
+ oldPos = maxPos
+ }
+
+ // If our inputs scroll past the latest streamed line, disable it.
+ if oldPos+scrollInput > maxPos {
+ p.scrollbackBuffer = nil
+ return
+ }
+
+ // Update and limit the scroll position to the most recent line and
+ // at least a full screen.
+ newPos := min(maxPos, max(nlines, oldPos+scrollInput))
+
+ // Fetch the actual scrollback from the journal if we moved the scrollback
+ // position.
+ p.scrollbackBuffer = fetchScrollback(lt, nlines, newPos)
+}
+
+func fetchScrollback(logTree *logtree.LogTree, nlines int, position int) []*logtree.LogEntry {
+ reader, err := logTree.Read(
+ "",
+ logtree.WithChildren(),
+ logtree.WithBacklog(nlines),
+ // Add an offset of one to the position, as we are fetching messages before
+ // the given position, skipping the given position entirely.
+ logtree.WithStartPosition(position+1, logtree.ReadDirectionBefore),
+ )
+ // This should not happen as only invalid argument combinations are capable
+ // of returning an error.
+ if err != nil {
+ panic("unreachable")
+ }
+
+ return reader.Backlog
+}
diff --git a/metropolis/node/core/tconsole/page_status.go b/metropolis/node/core/tconsole/page_status.go
index 482dd6e..56145e8 100644
--- a/metropolis/node/core/tconsole/page_status.go
+++ b/metropolis/node/core/tconsole/page_status.go
@@ -16,16 +16,16 @@
//go:embed build/copyright_line.txt
var copyrightLine string
-// pageStatusData encompasses all data to be shown within the status page.
-type pageStatusData struct {
+// pageStatus encompasses all data to be shown within the status page.
+type pageStatus struct {
netAddr string
roles string
id string
fingerprint string
}
-// pageStatus renders the status page to the user given pageStatusData.
-func (c *Console) pageStatus(d *pageStatusData) {
+// render renders the status page to the user.
+func (d *pageStatus) render(c *Console) {
c.screen.Clear()
sty1 := tcell.StyleDefault.Background(c.color(colorPink)).Foreground(c.color(colorBlack))
sty2 := tcell.StyleDefault.Background(c.color(colorBlue)).Foreground(c.color(colorBlack))
@@ -94,3 +94,6 @@
c.drawLogo(splitH-logoWidth, splitV+center(c.height-splitV, logoHeight), sty2)
}
}
+
+func (d *pageStatus) processEvent(*Console, tcell.Event) {
+}
diff --git a/metropolis/node/core/tconsole/standalone/main.go b/metropolis/node/core/tconsole/standalone/main.go
index e1b23d2..394bba7 100644
--- a/metropolis/node/core/tconsole/standalone/main.go
+++ b/metropolis/node/core/tconsole/standalone/main.go
@@ -66,7 +66,7 @@
supervisor.Run(ctx, "log-dawdle", func(ctx context.Context) error {
for {
supervisor.Logger(ctx).Infof("It is currently: %s", time.Now().Format(time.DateTime))
- if err := delay(ctx, time.Second); err != nil {
+ if err := delay(ctx, time.Millisecond*100); err != nil {
return err
}
}
diff --git a/metropolis/node/core/tconsole/tconsole.go b/metropolis/node/core/tconsole/tconsole.go
index 315dca9..db69b01 100644
--- a/metropolis/node/core/tconsole/tconsole.go
+++ b/metropolis/node/core/tconsole/tconsole.go
@@ -21,6 +21,11 @@
"source.monogon.dev/osbase/supervisor"
)
+type page interface {
+ render(*Console)
+ processEvent(*Console, tcell.Event)
+}
+
type Config struct {
Terminal Terminal
LogTree *logtree.LogTree
@@ -46,8 +51,8 @@
// constructed dynamically in Run.
activePage int
- config Config
- reader *logtree.LogReader
+ config Config
+ logReader *logtree.LogReader
}
// New creates a new Console, taking over the TTY at the given path. The given
@@ -57,7 +62,12 @@
// network, roles, curatorConn point to various Metropolis subsystems that are
// used to populate the console data.
func New(config Config, ttyPath string) (*Console, error) {
- reader, err := config.LogTree.Read("", logtree.WithChildren(), logtree.WithStream())
+ reader, err := config.LogTree.Read(
+ "",
+ logtree.WithChildren(),
+ logtree.WithStream(),
+ logtree.WithBacklog(logtree.BacklogAllAvailable),
+ )
if err != nil {
return nil, fmt.Errorf("lt.Read: %w", err)
}
@@ -96,7 +106,7 @@
Quit: make(chan struct{}),
activePage: 0,
config: config,
- reader: reader,
+ logReader: reader,
}, nil
}
@@ -104,7 +114,7 @@
// the Metropolis console always runs.
func (c *Console) Cleanup() {
c.screen.Fini()
- c.reader.Close()
+ c.logReader.Close()
}
func (c *Console) processEvent(ev tcell.Event) {
@@ -145,18 +155,22 @@
supervisor.Signal(ctx, supervisor.SignalHealthy)
// Per-page data.
- pageStatus := pageStatusData{
+ pageStatus := pageStatus{
netAddr: "Waiting...",
roles: "Waiting...",
id: "Waiting...",
fingerprint: "Waiting...",
}
- pageLogs := pageLogsData{}
+ pageLogs := pageLogs{}
+ // Fetch backlog
+ for _, le := range c.logReader.Backlog {
+ pageLogs.appendLine(le)
+ }
// Page references and names.
- pages := []func(){
- func() { c.pageStatus(&pageStatus) },
- func() { c.pageLogs(&pageLogs) },
+ pages := []page{
+ &pageStatus,
+ &pageLogs,
}
pageNames := []string{
"Status", "Logs",
@@ -175,7 +189,8 @@
for {
// Draw active page.
c.activePage %= len(pages)
- pages[c.activePage]()
+ page := pages[c.activePage]
+ page.render(c)
// Draw status bar.
c.statusBar(c.activePage, pageNames...)
@@ -191,6 +206,7 @@
return ctx.Err()
case ev := <-evC:
c.processEvent(ev)
+ page.processEvent(c, ev)
case t := <-netAddrC:
pageStatus.netAddr = t.ExternalAddress.String()
case t := <-rolesC:
@@ -214,8 +230,8 @@
sum := sha256.New()
sum.Write(cert.Raw)
pageStatus.fingerprint = hex.EncodeToString(sum.Sum(nil))
- case le := <-c.reader.Stream:
- pageLogs.appendLine(le.String())
+ case le := <-c.logReader.Stream:
+ pageLogs.appendLine(le)
}
}
}