Build

Build a terminal dashboard

Build a live terminal dashboard with std:tui: progress bars, tables, boxes and charts as text, a model you can test, and a redraw loop that reads keys.

6 steps · about 20 minutes · uses tui, thread

You'll build a dashboard that shows how a few services are doing, lets you pick one with the arrow keys and refreshes itself. Most of std:tui is functions that return strings, so they print the same text every time and run on this page. Only the live loop needs a real terminal. Colours are ANSI codes in a terminal; this page strips them.

What you'll have at the end
down 2 true
down 3 true
down 3 true
up 2 true
x 2 true
q 2 false
+---------+-----+-----+--------+
| service | cpu | mem | status |
+---------+-----+-----+--------+
|   api   | 34% | 61% | up     |
|   db    | 87% | 79% | DOWN   |
| > cache | 1%  | 33% | up     |
+---------+-----+-----+--------+
┌─ cache ────────────────────────┐
│ cpu [░░░░░░░░░░░░░░░░░░░░] 1%  │
│ mem [██████░░░░░░░░░░░░░░] 33% │
└────────────────────────────────┘
████████████████████
api             ███████ 34.00
db              ████████████████████ 87.00
cache            1.00

Step 1 of 6

Draw a bar

tui.progress(current, total, width) returns a bar. It clamps to the total, so a value over 100 stays full.

Try changing the numbers, or the width.

Your turn

Label the load

Make level return HIGH from 90, busy from 50, else ok.

main.osl
Ctrl Enter runs
Expected output
ok busy HIGH
main.osl
import "std:tui"

log "api   " ++ tui.progress(42, 100, 20)
log "db    " ++ tui.progress(87, 100, 20)
log "cache " ++ tui.progress(8, 100, 20)
Click the code to edit it
Step 2 of 6

Show a table

A service is a struct. tui.table(headers, rows) takes an array of rows, each an array of text, and sizes the columns for you. cellColor is called for every body cell and returns a colour name, so a down service shows red.

main.osl
import "std:tui"

struct Service (
  string name
  int cpu
  int memory
  bool up
)

Service[] services = [
  Service("api", 42, 61, true),
  Service("db", 87, 74, true),
  Service("cache", 8, 30, false),
]

string[][] rows = []
for s in services (
  string status = if s.up then "up" else "DOWN"
  rows.append([s.name, s.cpu ++ "%", s.memory ++ "%", status])
)

log tui.table(["service", "cpu", "mem", "status"], rows, {
  cellColor: def(any cell) string -> if cell == "DOWN" then "red" else "white"
})
Click the code to edit it

Highlighted lines are new in this step.

Step 3 of 6

Add a box and a chart

tui.box(title, text) draws a frame around any text, even several lines. tui.barChart(data, width, colored) takes [label, value] pairs: a scale line, then a bar per item scaled to the largest. Strings join with ++, so a table, a box and a chart stack into one screen.

Your turn

A third bar

Add ["cache", 8] to the chart data.

main.osl
Ctrl Enter runs
Expected output
██████████
api             ████ 42.00
db              ██████████ 87.00
cache            8.00
Quick check

Why do the tui functions return strings and not print?

main.osl
import "std:tui"

struct Service (
  string name
  int cpu
  int memory
  bool up
)

Service[] services = [
  Service("api", 42, 61, true),
  Service("db", 87, 74, true),
  Service("cache", 8, 30, false),
]

string[][] rows = []
string[][] bars = []
for s in services (
  string status = if s.up then "up" else "DOWN"
  rows.append([s.name, s.cpu ++ "%", s.memory ++ "%", status])
  bars.append([s.name, s.cpu])
)

log tui.table(["service", "cpu", "mem", "status"], rows, {
  cellColor: def(any cell) string -> if cell == "DOWN" then "red" else "white"
})
log tui.box("db", "cpu " ++ tui.progress(87, 100, 20) ++ "\nmem " ++ tui.progress(74, 100, 20))
log tui.barChart(bars, 20, true)
Click the code to edit it

Highlighted lines are new in this step.

Step 4 of 6

Keep the data in a model

A Dashboard class holds the services and the selection, and render(d) returns the whole screen as a string. sample() nudges every reading and sometimes takes a service down. The try-it tab seeds std:random, so this page always shows the same numbers. For real ones, replace sample() with readings from std:sys or std:requests.

Try changing the seed, or the number of samples.

Your turn

Busiest service

Write busiest: the name of the service with the highest cpu.

main.osl
Ctrl Enter runs
Expected output
db
import "std:tui"
import "std:random"

struct Service (
  string name
  int cpu
  int memory
  bool up
)

def clampInt(int v, int low, int high) int (
  return max(low, min(v, high))
)

class Dashboard (
  Service[] services = []
  int selected = 1

  def add(string name, int cpu, int memory) (
    self.services.append(Service(name, cpu, memory, true))
  )

  def sample() (
    Service[] next = []
    for s in self.services (
      int cpu = clampInt(s.cpu + random.int(-15, 15), 1, 99)
      int memory = clampInt(s.memory + random.int(-5, 5), 5, 95)
      bool up = random.int(1, 20) != 1
      next.append(Service(s.name, cpu, memory, up))
    )
    self.services = next
  )
)

def render(Dashboard d) string (
  string[][] rows = []
  string[][] bars = []
  for i, s in d.services (
    string marker = if i == d.selected then ">" else " "
    string status = if s.up then "up" else "DOWN"
    rows.append([marker ++ " " ++ s.name, s.cpu ++ "%", s.memory ++ "%", status])
    bars.append([s.name, s.cpu])
  )
  Service chosen = d.services[d.selected]
  string table = tui.table(["service", "cpu", "mem", "status"], rows, {
    cellColor: def(any cell) string -> if cell == "DOWN" then "red" else "white"
  })
  string load = "cpu " ++ tui.progress(chosen.cpu, 100, 20)
  string mem = "mem " ++ tui.progress(chosen.memory, 100, 20)
  string detail = tui.box(chosen.name, load ++ "\n" ++ mem)
  return table ++ "\n" ++ detail ++ "\n" ++ tui.barChart(bars, 20, true)
)
Click the code to edit it

Highlighted lines are new in this step.

Step 5 of 6

Handle keys

handleKey takes a key name ("up", "down", "q", "enter", "ctrl+c"...) and returns false when it's time to quit. It's a plain method, so the try-it tab tests it with a list of keys, no keyboard needed. selected is clamped to the list, and unknown keys do nothing.

Your turn

More keys

Make up move the selection up (not below 1) and home jump to 1.

main.osl
Ctrl Enter runs
Expected output
2
1
1
Quick check

In the live loop, why check tui.keyPressed() before tui.readKey()?

import "std:tui"
import "std:random"

struct Service (
  string name
  int cpu
  int memory
  bool up
)

def clampInt(int v, int low, int high) int (
  return max(low, min(v, high))
)

class Dashboard (
  Service[] services = []
  int selected = 1

  def add(string name, int cpu, int memory) (
    self.services.append(Service(name, cpu, memory, true))
  )

  def sample() (
    Service[] next = []
    for s in self.services (
      int cpu = clampInt(s.cpu + random.int(-15, 15), 1, 99)
      int memory = clampInt(s.memory + random.int(-5, 5), 5, 95)
      bool up = random.int(1, 20) != 1
      next.append(Service(s.name, cpu, memory, up))
    )
    self.services = next
  )

  def handleKey(string key) boolean (
    switch key (
      case "up"
        self.selected = clampInt(self.selected - 1, 1, self.services.len)
      case "down"
        self.selected = clampInt(self.selected + 1, 1, self.services.len)
      case "q"
        return false
    )
    return true
  )
)

def render(Dashboard d) string (
  string[][] rows = []
  string[][] bars = []
  for i, s in d.services (
    string marker = if i == d.selected then ">" else " "
    string status = if s.up then "up" else "DOWN"
    rows.append([marker ++ " " ++ s.name, s.cpu ++ "%", s.memory ++ "%", status])
    bars.append([s.name, s.cpu])
  )
  Service chosen = d.services[d.selected]
  string table = tui.table(["service", "cpu", "mem", "status"], rows, {
    cellColor: def(any cell) string -> if cell == "DOWN" then "red" else "white"
  })
  string load = "cpu " ++ tui.progress(chosen.cpu, 100, 20)
  string mem = "mem " ++ tui.progress(chosen.memory, 100, 20)
  string detail = tui.box(chosen.name, load ++ "\n" ++ mem)
  return table ++ "\n" ++ detail ++ "\n" ++ tui.barChart(bars, 20, true)
)
Click the code to edit it

Highlighted lines are new in this step.

Step 6 of 6

Go live

The loop moves the cursor to the top-left, draws the screen, then waits a second in ten 100 ms slices, checking the keyboard each time so keys feel instant. Then it samples again. Add import "std:thread" to the top of the program and put this at the bottom, in place of the try-it code:

osl
Dashboard d = Dashboard()
d.add("api", 42, 61)
d.add("db", 87, 74)
d.add("cache", 8, 30)
d.sample()

tui.hideCursor()
tui.clear()
bool running = true
while running (
  tui.moveTo(1, 1)
  log tui.style("bold", "Dashboard") ++ "  (up/down to select, q to quit)"
  log render(d)
  for _ in 1 to 10 (
    if tui.keyPressed() (
      string key = tui.readKey().unwrap()
      running = d.handleKey(key)
    )
    thread.sleep(100)
  )
  d.sample()
)
tui.showCursor()
tui.clear()

tui.hideCursor() and tui.showCursor() bracket the loop. The live loop needs a real terminal, so run this one yourself with osl run dashboard.osl. For full-screen layouts there's also tui.newScreen, a buffer of cells you set and render. Next: tui, sys, or a chat bot.