tconsole: init

This introduces the 'tconsole' (terminal console), the default
interface to show in /dev/tty1 on a Metropolis node.

Currently it just shows some basic status in a single page. Upcoming
changes will reintroduce a simple log dump on a different page, as well
as entirely new features like supervision tree inspection.

To iterate quickly on the console, a 'standalone' target is added which
exercises the console on the user's terminal with fake node data.
However only the actual console in Linux displays colours as intended.

Change-Id: I5cfba2bdb320daa080a073e76bf0494aeab6a4d4
Reviewed-on: https://review.monogon.dev/c/monogon/+/3371
Reviewed-by: Leopold Schabel <leo@monogon.tech>
Tested-by: Jenkins CI
diff --git a/metropolis/node/core/BUILD.bazel b/metropolis/node/core/BUILD.bazel
index c6fdb18..b511bb9 100644
--- a/metropolis/node/core/BUILD.bazel
+++ b/metropolis/node/core/BUILD.bazel
@@ -33,6 +33,7 @@
         "//metropolis/node/core/network",
         "//metropolis/node/core/roleserve",
         "//metropolis/node/core/rpc/resolver",
+        "//metropolis/node/core/tconsole",
         "//metropolis/node/core/time",
         "//metropolis/node/core/update",
         "//metropolis/proto/api",
diff --git a/metropolis/node/core/main.go b/metropolis/node/core/main.go
index 3d2969f..d103693 100644
--- a/metropolis/node/core/main.go
+++ b/metropolis/node/core/main.go
@@ -33,6 +33,7 @@
 	"source.monogon.dev/metropolis/node/core/network"
 	"source.monogon.dev/metropolis/node/core/roleserve"
 	"source.monogon.dev/metropolis/node/core/rpc/resolver"
+	"source.monogon.dev/metropolis/node/core/tconsole"
 	timesvc "source.monogon.dev/metropolis/node/core/time"
 	"source.monogon.dev/metropolis/node/core/update"
 	mversion "source.monogon.dev/metropolis/version"
@@ -52,13 +53,8 @@
 	// Root system logtree.
 	lt := logtree.New()
 
-	// Set up logger for Metropolis. Currently logs everything to /dev/tty0 and
-	// /dev/ttyS{0,1}.
-	consoles := []*console{
-		{
-			path:     "/dev/tty0",
-			maxWidth: 80,
-		},
+	// Set up logger for Metropolis. Currently logs everything to /dev/ttyS{0,1}.
+	serialConsoles := []*console{
 		{
 			path:     "/dev/ttyS0",
 			maxWidth: 120,
@@ -73,7 +69,7 @@
 	crash := make(chan string)
 
 	// Open up consoles and set up logging from logtree and crash channel.
-	for _, c := range consoles {
+	for _, c := range serialConsoles {
 		f, err := os.OpenFile(c.path, os.O_WRONLY, 0)
 		if err != nil {
 			continue
@@ -99,7 +95,7 @@
 	}
 
 	// Initialize persistent panic handler early
-	initPanicHandler(lt, consoles)
+	initPanicHandler(lt, serialConsoles)
 
 	// Initial logger. Used until we get to a supervisor.
 	logger := lt.MustLeveledFor("init")
@@ -215,6 +211,19 @@
 			return fmt.Errorf("when starting debug service: %w", err)
 		}
 
+		// Initialize interactive consoles.
+		interactiveConsoles := []string{"/dev/tty0"}
+		for _, c := range interactiveConsoles {
+			console, err := tconsole.New(tconsole.TerminalLinux, c, &networkSvc.Status, &rs.LocalRoles, &rs.CuratorConnection)
+			if err != nil {
+				logger.Info("Failed to initialize interactive console at %s: %v", c, err)
+				// TODO: fall back to logger
+			} else {
+				logger.Info("Started interactive console at %s", c)
+				supervisor.Run(ctx, "console-"+c, console.Run)
+			}
+		}
+
 		// Start cluster manager. This kicks off cluster membership machinery,
 		// which will either start a new cluster, enroll into one or join one.
 		m := cluster.NewManager(root, networkSvc, rs, updateSvc, nodeParams, haveTPM)
@@ -249,7 +258,7 @@
 	ctxC()
 	time.Sleep(time.Second)
 	// After a bit, kill all console log readers.
-	for _, c := range consoles {
+	for _, c := range serialConsoles {
 		if c.reader == nil {
 			continue
 		}
diff --git a/metropolis/node/core/tconsole/BUILD.bazel b/metropolis/node/core/tconsole/BUILD.bazel
new file mode 100644
index 0000000..c8ca99e
--- /dev/null
+++ b/metropolis/node/core/tconsole/BUILD.bazel
@@ -0,0 +1,24 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "tconsole",
+    srcs = [
+        "colors.go",
+        "draw.go",
+        "page_status.go",
+        "tconsole.go",
+    ],
+    importpath = "source.monogon.dev/metropolis/node/core/tconsole",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//metropolis/node/core/network",
+        "//metropolis/node/core/roleserve",
+        "//metropolis/proto/common",
+        "//metropolis/version",
+        "//osbase/event",
+        "//osbase/supervisor",
+        "//version",
+        "@com_github_gdamore_tcell_v2//:tcell",
+        "@com_github_rivo_uniseg//:uniseg",
+    ],
+)
diff --git a/metropolis/node/core/tconsole/colors.go b/metropolis/node/core/tconsole/colors.go
new file mode 100644
index 0000000..d1d5793
--- /dev/null
+++ b/metropolis/node/core/tconsole/colors.go
@@ -0,0 +1,87 @@
+package tconsole
+
+import (
+	"fmt"
+
+	"github.com/gdamore/tcell/v2"
+)
+
+// Terminal is a supported terminal kind. This is a simplistic alternative to
+// terminfo.
+type Terminal string
+
+const (
+	// TerminalLinux is a linux VTY console.
+	TerminalLinux Terminal = "linux"
+	// TerminalGeneric is any other terminal that is supported by tcell.
+	TerminalGeneric Terminal = "generic"
+)
+
+// color type separate from tcell.Color. This is necessary because tcell does not
+// support color remapping in Linux VTYs, only static colors, and we have to
+// build our own color system on top of it.
+//
+// Our color type is abstract, it denotes a given colour in terms of human
+// perception, not an actual colour code.
+type color string
+
+const (
+	// colorBlack is a deep black, suitable for use as a foreground.
+	colorBlack color = "black"
+	// colorPink is a light pink, suitable for use as a background.
+	colorPink color = "pink"
+	// colorBlue is a light blue, suitable for use as a background.
+	colorBlue color = "blue"
+)
+
+// colorDef is an entry for a palette and defines a tcell color to be used from
+// tcell, and an optional RGB colour to remap the given tcell.Color into.
+type colorDef struct {
+	ansiColor tcell.Color
+	rgbColor  uint32
+}
+
+type palette map[color]colorDef
+
+var (
+	// paletteLinux is the full-colour palette used on Linux consoles, with exact RGB
+	// color definitions.
+	paletteLinux = palette{
+		colorBlack: colorDef{tcell.ColorBlack + 0, 0x000000},
+		colorPink:  colorDef{tcell.ColorBlack + 1, 0xeedfda},
+		colorBlue:  colorDef{tcell.ColorBlack + 2, 0x919ba7},
+	}
+	// paletteGeneric is a fallback palette used on systems which do not support
+	// colour remapping and only have 2 colours: black and white.
+	paletteGeneric = palette{
+		colorBlack: colorDef{tcell.ColorBlack, 0},
+		colorPink:  colorDef{tcell.ColorWhite, 0},
+		colorBlue:  colorDef{tcell.ColorWhite, 0},
+	}
+)
+
+// linuxConsoleOverrideColor uses Linux-specific ANSI OSC codes to remap a given
+// colour to an RGB value. See console_codes(4) for more information.
+//
+// The function returns a string which must be sent to the terminal.
+func linuxConsoleOverrideColor(co tcell.Color, rgb uint32) string {
+	return fmt.Sprintf("\x1b]P%x%06x", int(co-tcell.ColorBlack), rgb)
+}
+
+// setupLinuxConsole configures a given palette with rgcColor date to be
+// displayed on the user screen.
+//
+// The function returns a string which must be sent to the terminal.
+func (p *palette) setupLinuxConsole() string {
+	res := ""
+	for _, v := range *p {
+		res += linuxConsoleOverrideColor(v.ansiColor, v.rgbColor)
+	}
+	return res
+}
+
+// color retrieves a tcell.Color to use for a given color, respecting the
+// currently set palette.
+func (c *Console) color(col color) tcell.Color {
+	return c.palette[col].ansiColor
+}
diff --git a/metropolis/node/core/tconsole/draw.go b/metropolis/node/core/tconsole/draw.go
new file mode 100644
index 0000000..d88e35b
--- /dev/null
+++ b/metropolis/node/core/tconsole/draw.go
@@ -0,0 +1,85 @@
+package tconsole
+
+import (
+	"strings"
+
+	"github.com/gdamore/tcell/v2"
+	"github.com/rivo/uniseg"
+)
+
+// drawText draws a single line of text from left to right, starting at x and y.
+func (c *Console) drawText(x, y int, text string, style tcell.Style) {
+	g := uniseg.NewGraphemes(text)
+	for g.Next() {
+		runes := g.Runes()
+		c.screen.SetContent(x, y, runes[0], runes[1:], style)
+		x += 1
+	}
+}
+
+// drawTextCentered draw a single line of text from left to right, starting at a
+// position so that the center of the line ends up at x and y.
+func (c *Console) drawTextCentered(x, y int, text string, style tcell.Style) {
+	g := uniseg.NewGraphemes(text)
+	var runes [][]rune
+	for g.Next() {
+		runes = append(runes, g.Runes())
+	}
+
+	x -= len(runes) / 2
+
+	for _, r := range runes {
+		c.screen.SetContent(x, y, r[0], r[1:], style)
+		x += 1
+	}
+}
+
+// fillRectangle fills a given rectangle [x0,x1) [y0,y1) with empty space of a
+// given style.
+func (c *Console) fillRectangle(x0, x1, y0, y1 int, style tcell.Style) {
+	for x := x0; x < x1; x++ {
+		for y := y0; y < y1; y++ {
+			c.screen.SetContent(x, y, ' ', nil, style)
+		}
+	}
+}
+
+const logo = `
+             _g@@@@g_         _g@@@@g_             
+           _@@@@@@@@@@a     g@@@@@@@@@@b           
+          @@@@@@@@@@@@@@___@@@@@@@@@@@@@@          
+         @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@         
+        @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@        
+       g@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@g       
+      g@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@g      
+     g@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@g     
+    ;@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@,    
+    |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"    
+     @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@     
+      %@@@@@@@@@@P<@@@@@@@@@@@@@>%@@@@@@@@@@P      
+        "+B@@B>'    "@@@@@@@@B"    '<B@@BP"        
+                        '"                         
+`
+
+// drawLogo draws the Monogon logo so that its top left corner is at x, y.
+func (c *Console) drawLogo(x, y int, style tcell.Style) {
+	for i, line := range strings.Split(logo, "\n") {
+		c.drawText(x, y+i, line, style)
+	}
+}
+
+// split calculates a mid-point in the [0, capacity) domain so that it splits it
+// into two parts fairly with minA and minB used as minimum size hints for each
+// section.
+func split(capacity, minA, minB int) int {
+	slack := capacity - (minA + minB)
+	propA := float64(minA) / float64(minA+minB)
+	slackA := int(propA * float64(slack))
+	return minA + slackA
+}
+
+// center calculates a point at which to start drawing a 'size'-sized element in
+// a 'capacity'-sized container so that it ends in the middle of said container.
+func center(capacity, size int) int {
+	return (capacity - size) / 2
+}
diff --git a/metropolis/node/core/tconsole/page_status.go b/metropolis/node/core/tconsole/page_status.go
new file mode 100644
index 0000000..13c4b63
--- /dev/null
+++ b/metropolis/node/core/tconsole/page_status.go
@@ -0,0 +1,78 @@
+package tconsole
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/gdamore/tcell/v2"
+
+	mversion "source.monogon.dev/metropolis/version"
+	"source.monogon.dev/version"
+)
+
+// pageStatusData encompasses all data to be shown within the status page.
+type pageStatusData 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) {
+	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))
+
+	logoWidth := len(strings.Split(logo, "\n")[1])
+	logoHeight := len(strings.Split(logo, "\n"))
+
+	// Vertical split between top copyright string and main display part.
+	splitV := split(c.height, 4, logoHeight)
+
+	// Colour the split.
+	c.fillRectangle(0, c.width, 0, splitV, sty1)
+	c.fillRectangle(0, c.width, splitV, c.height, sty2)
+
+	// Draw the top part.
+	c.drawTextCentered(c.width/2, splitV/2, "Monogon Cluster Operating System", sty1)
+	c.drawTextCentered(c.width/2, splitV/2+1, "Copyright 2022-2024 The Monogon Project Authors", sty1)
+
+	// Horizontal split between left logo and right status lines, a la 'fetch'.
+	splitH := split(c.width, logoWidth, 60)
+
+	// Status lines.
+	lines := []string{
+		fmt.Sprintf("Version: %s", version.Semver(mversion.Version)),
+		fmt.Sprintf("Node ID: %s", d.id),
+		fmt.Sprintf("CA fingerprint: %s", d.fingerprint),
+		fmt.Sprintf("Management address: %s", d.netAddr),
+		fmt.Sprintf("Roles: %s", d.roles),
+	}
+	// Calculate longest line.
+	maxLine := 0
+	for _, l := range lines {
+		if len(l) > maxLine {
+			maxLine = len(l)
+		}
+	}
+
+	// If logo wouldn't fit, don't bother, save space for important data.
+	drawLogo := true
+	if splitH < logoWidth {
+		drawLogo = false
+		splitH = center(c.width, maxLine)
+	}
+
+	// Draw lines.
+	for i, line := range lines {
+		c.drawText(splitH, splitV+center(c.height-splitV, len(lines))+i, line, sty2)
+	}
+
+	// Draw logo.
+	if drawLogo {
+		c.drawLogo(splitH-logoWidth, splitV+center(c.height-splitV, logoHeight), sty2)
+	}
+
+	c.screen.Show()
+}
diff --git a/metropolis/node/core/tconsole/standalone/BUILD.bazel b/metropolis/node/core/tconsole/standalone/BUILD.bazel
new file mode 100644
index 0000000..cd7a653
--- /dev/null
+++ b/metropolis/node/core/tconsole/standalone/BUILD.bazel
@@ -0,0 +1,22 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "standalone_lib",
+    srcs = ["main.go"],
+    importpath = "source.monogon.dev/metropolis/node/core/tconsole/standalone",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//metropolis/node/core/network",
+        "//metropolis/node/core/roleserve",
+        "//metropolis/node/core/tconsole",
+        "//metropolis/proto/common",
+        "//osbase/event/memory",
+        "//osbase/supervisor",
+    ],
+)
+
+go_binary(
+    name = "standalone",
+    embed = [":standalone_lib"],
+    visibility = ["//visibility:public"],
+)
diff --git a/metropolis/node/core/tconsole/standalone/main.go b/metropolis/node/core/tconsole/standalone/main.go
new file mode 100644
index 0000000..65b3976
--- /dev/null
+++ b/metropolis/node/core/tconsole/standalone/main.go
@@ -0,0 +1,90 @@
+package main
+
+// This is a standalone tconsole test application which works independently from
+// a Metropolis instance. It's intended to be used during tconsole development to
+// make the iteration cycle faster (not needing to boot up a whole node just to
+// test the console).
+
+import (
+	"context"
+	"fmt"
+	"log"
+	mrand "math/rand"
+	"net"
+	"os"
+	"os/signal"
+	"time"
+
+	"source.monogon.dev/metropolis/node/core/network"
+	"source.monogon.dev/metropolis/node/core/roleserve"
+	"source.monogon.dev/metropolis/node/core/tconsole"
+	cpb "source.monogon.dev/metropolis/proto/common"
+	"source.monogon.dev/osbase/event/memory"
+	"source.monogon.dev/osbase/supervisor"
+)
+
+func main() {
+	var netV memory.Value[*network.Status]
+	var rolesV memory.Value[*cpb.NodeRoles]
+	var curV memory.Value[*roleserve.CuratorConnection]
+
+	tc, err := tconsole.New(tconsole.TerminalGeneric, "/proc/self/fd/0", &netV, &rolesV, &curV)
+	if err != nil {
+		log.Fatalf("tconsole.New: %v", err)
+	}
+
+	ctx, ctxC := context.WithCancel(context.Background())
+	go func() {
+		<-tc.Quit
+		ctxC()
+	}()
+
+	delay := func(ctx context.Context, d time.Duration) error {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-time.After(d):
+			return nil
+		}
+	}
+
+	signal.Ignore(os.Interrupt)
+	supervisor.New(ctx, func(ctx context.Context) error {
+		supervisor.Run(ctx, "tconsole", tc.Run)
+		supervisor.Run(ctx, "net-dawdle", func(ctx context.Context) error {
+			supervisor.Signal(ctx, supervisor.SignalHealthy)
+			for {
+				if err := delay(ctx, time.Millisecond*1000); err != nil {
+					return err
+				}
+				netV.Set(&network.Status{
+					ExternalAddress: net.ParseIP(fmt.Sprintf("203.0.113.%d", mrand.Intn(256))),
+				})
+			}
+		})
+		supervisor.Run(ctx, "roles-dawdle", func(ctx context.Context) error {
+			supervisor.Signal(ctx, supervisor.SignalHealthy)
+			for {
+				if err := delay(ctx, time.Millisecond*1200); err != nil {
+					return err
+				}
+				nr := &cpb.NodeRoles{}
+				if mrand.Intn(2) == 0 {
+					nr.KubernetesWorker = &cpb.NodeRoles_KubernetesWorker{}
+				}
+				if mrand.Intn(2) == 0 {
+					nr.ConsensusMember = &cpb.NodeRoles_ConsensusMember{}
+				}
+				if mrand.Intn(2) == 0 {
+					nr.KubernetesController = &cpb.NodeRoles_KubernetesController{}
+				}
+				rolesV.Set(nr)
+			}
+		})
+		supervisor.Signal(ctx, supervisor.SignalHealthy)
+		<-ctx.Done()
+		return ctx.Err()
+	})
+	<-ctx.Done()
+	tc.Cleanup()
+}
diff --git a/metropolis/node/core/tconsole/tconsole.go b/metropolis/node/core/tconsole/tconsole.go
new file mode 100644
index 0000000..52885bd
--- /dev/null
+++ b/metropolis/node/core/tconsole/tconsole.go
@@ -0,0 +1,163 @@
+package tconsole
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"strings"
+
+	"github.com/gdamore/tcell/v2"
+
+	"source.monogon.dev/metropolis/node/core/network"
+	"source.monogon.dev/metropolis/node/core/roleserve"
+	cpb "source.monogon.dev/metropolis/proto/common"
+	"source.monogon.dev/osbase/event"
+	"source.monogon.dev/osbase/supervisor"
+)
+
+// Console is a Terminal Console (TConsole), a user-interactive informational
+// display visible on the TTY of a running Metropolis instance.
+type Console struct {
+	// Quit will be closed when the user press CTRL-C. A new channel will be created
+	// on each New call.
+
+	Quit    chan struct{}
+	ttyPath string
+	tty     tcell.Tty
+	screen  tcell.Screen
+	width   int
+	height  int
+	// palette chosen for the given terminal.
+	palette palette
+
+	network     event.Value[*network.Status]
+	roles       event.Value[*cpb.NodeRoles]
+	curatorConn event.Value[*roleserve.CuratorConnection]
+}
+
+// New creates a new Console, taking over the TTY at the given path. The given
+// Terminal type selects between a Linux terminal (VTY) and a generic terminal
+// for testing.
+//
+// network, roles, curatorConn point to various Metropolis subsystems that are
+// used to populate the console data.
+func New(terminal Terminal, ttyPath string, network event.Value[*network.Status], roles event.Value[*cpb.NodeRoles], curatorConn event.Value[*roleserve.CuratorConnection]) (*Console, error) {
+	tty, err := tcell.NewDevTtyFromDev(ttyPath)
+	if err != nil {
+		return nil, err
+	}
+	screen, err := tcell.NewTerminfoScreenFromTty(tty)
+	if err != nil {
+		return nil, err
+	}
+	if err := screen.Init(); err != nil {
+		return nil, err
+	}
+	screen.SetStyle(tcell.StyleDefault)
+
+	var pal palette
+	switch terminal {
+	case TerminalLinux:
+		pal = paletteLinux
+		tty.Write([]byte(pal.setupLinuxConsole()))
+	case TerminalGeneric:
+		pal = paletteGeneric
+	}
+
+	width, height := screen.Size()
+	return &Console{
+		ttyPath: ttyPath,
+		tty:     tty,
+		screen:  screen,
+		width:   width,
+		height:  height,
+		network: network,
+		palette: pal,
+		Quit:    make(chan struct{}),
+
+		roles:       roles,
+		curatorConn: curatorConn,
+	}, nil
+}
+
+// Cleanup should be called when the console exits. This is only used in testing,
+// the Metropolis console always runs.
+func (c *Console) Cleanup() {
+	c.screen.Fini()
+}
+
+func (c *Console) processEvent(ev tcell.Event) {
+	switch ev := ev.(type) {
+	case *tcell.EventKey:
+		if ev.Key() == tcell.KeyCtrlC {
+			close(c.Quit)
+		}
+	case *tcell.EventResize:
+		c.width, c.height = ev.Size()
+	}
+}
+
+// Run blocks while displaying the Console to the user.
+func (c *Console) Run(ctx context.Context) error {
+	// Build channel for console event processing.
+	evC := make(chan tcell.Event)
+	evQuitC := make(chan struct{})
+	defer close(evQuitC)
+	go c.screen.ChannelEvents(evC, evQuitC)
+
+	// Pipe event values into channels.
+	netAddrC := make(chan *network.Status)
+	rolesC := make(chan *cpb.NodeRoles)
+	curatorConnC := make(chan *roleserve.CuratorConnection)
+	if err := supervisor.Run(ctx, "netpipe", event.Pipe(c.network, netAddrC)); err != nil {
+		return err
+	}
+	if err := supervisor.Run(ctx, "rolespipe", event.Pipe(c.roles, rolesC)); err != nil {
+		return err
+	}
+	if err := supervisor.Run(ctx, "curatorpipe", event.Pipe(c.curatorConn, curatorConnC)); err != nil {
+		return err
+	}
+	supervisor.Signal(ctx, supervisor.SignalHealthy)
+
+	pageStatus := pageStatusData{
+		netAddr:     "Waiting...",
+		roles:       "Waiting...",
+		id:          "Waiting...",
+		fingerprint: "Waiting...",
+	}
+
+	for {
+		c.pageStatus(&pageStatus)
+
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case ev := <-evC:
+			c.processEvent(ev)
+		case t := <-netAddrC:
+			pageStatus.netAddr = t.ExternalAddress.String()
+		case t := <-rolesC:
+			var rlist []string
+			if t.ConsensusMember != nil {
+				rlist = append(rlist, "ConsensusMember")
+			}
+			if t.KubernetesController != nil {
+				rlist = append(rlist, "KubernetesController")
+			}
+			if t.KubernetesWorker != nil {
+				rlist = append(rlist, "KubernetesWorker")
+			}
+			pageStatus.roles = strings.Join(rlist, ", ")
+			if pageStatus.roles == "" {
+				pageStatus.roles = "none"
+			}
+		case t := <-curatorConnC:
+			pageStatus.id = t.Credentials.ID()
+			cert := t.Credentials.ClusterCA()
+			sum := sha256.New()
+			sum.Write(cert.Raw)
+			pageStatus.fingerprint = hex.EncodeToString(sum.Sum(nil))
+		}
+	}
+}