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)
 		}
 	}
 }