TUI#
edg includes a built-in terminal interface for monitoring workload performance in real time without external tooling. It provides live stats and QPS charts directly in your terminal.
--tuirequires a Pro license.
Enabling the TUI#
Pass --tui to any command that runs a workload (run, all, perf breakpoint, or built-in workloads like workload bank run):
edg run \
--driver pgx \
--config workload.edg \
--url ${DATABASE_URL} \
--tui \
-w 10 \
-d 5mYou can also enable it with the EDG_TUI environment variable:
export EDG_TUI=true
--tuiand--overwriteare mutually exclusive. The TUI replaces the default scrolling log output with an interactive full-screen view.
X-axis mode#
The chart x-axis defaults to elapsed time from 00:00:00. Pass --x-axis clock to show wall clock time instead:
edg run --tui --x-axis clock --config workload.edg --url ${DATABASE_URL}| Value | Description |
|---|---|
start (default) | Elapsed time from 00:00:00 |
clock | Wall clock time |
Views#
The TUI has two views you can switch between with Tab.
Stats view#
Shows a live table of query and transaction metrics, updated every second:

Queries display columns for count, errors, average latency, p90/p95/p99 latencies, and current QPS.
Transactions display columns for commits, rollbacks, errors, average latency, p90/p95/p99 latencies, and current TPS.
Chart view#
Shows a live time-series chart with one line per query or transaction:

Each series is colour-coded and numbered in the legend at the bottom of the chart.
The chart defaults to QPS but you can cycle through five metrics using the ← / → arrow keys:
- QPS (queries/transactions per second)
- p90 latency (ms)
- p95 latency (ms)
- p99 latency (ms)
- Errors/s
The active metric is shown in the status bar at the bottom of the screen. Time window, series focus, and all other controls work the same regardless of which metric is selected.
Chart downsampling#
A run samples every --print-interval (default 1s), so a wide time window holds far more points than the chart has pixel columns - an hour of samples is roughly 3600 points across ~400 columns. Rather than plot them all and paint each column solid, the chart buckets points into one bucket per pixel column and plots a single value per bucket.
How a bucket collapses depends on the metric:
| Metric | Bucket value | Why |
|---|---|---|
| QPS, Errors/s | Mean | Rates average cleanly, so the mean is the rate over the bucket’s span. |
| p90, p95, p99 | Max | Percentiles describe the tail, so averaging them would hide latency spikes. |
Narrowing the time window with ↑ / ↓ shrinks each bucket and brings back detail; at 1m each sample usually gets a column to itself and nothing is aggregated.
The same 24 hour run, seen through each window with ↑ / ↓. Widening trades per-sample detail for shape - the noise averages away and the daily cycle emerges:
1 Minute Window#
Every sample gets its own column, so nothing is aggregated and you see the raw per-tick jitter.

10 Minute Window#
A couple of samples per column. Still dominated by noise, but the band each series occupies is now obvious.

30 Minute Window#
A handful of samples per bucket. Still a noise band rather than a line, but each series sits clearly in its own lane.

1 Hour Window#
Roughly an order of magnitude more samples than columns. The band narrows and a slow upward drift becomes readable underneath it.

6 Hour Window#
Noise is essentially gone. Each series is a clean line.

1 Day Window#
The whole run in one frame, with the daily peak and overnight trough clearly separated.

Stats charts#
When a stats block uses post_print, the TUI Stats view renders an inline chart below the table. The chart type defaults to line but can be set to bar with the type node option.
Line chart (default)#
A line chart plots a numeric value over time. It is the default when no type is specified:
stats (rate: 1 / 1s) {
latency (
post_print: { key: 'p99', value: result().p99_ms }
) `SELECT p99_ms FROM latency_stats`
}Bar chart#
A bar chart shows the frequency distribution of a categorical value. Set type: bar on the node:
stats (rate: 1 / 1s) {
node (type: bar) (
post_print: { key: 'id', value: result().node_id }
) `SHOW node_id`
}One line per distinct value#
A bar chart shows you what the distribution is right now, but not how it got there. Set series: value on a line chart’s print expression to plot one coloured line per distinct value the expression returns, with the y-axis showing how many times per second that value was seen:
stats (rate: 20 / 1s) {
node (type: line) (
post_print: { key: 'node', value: result().node_id, series: value, window: 5s }
) `SHOW node_id`
}This is the shape you want for rolling upgrades, node drains, and rebalancing - anywhere the interesting signal is how a mix shifts over time. Poll fast (e.g. rate: 20 / 1s) so each line has enough samples to be smooth.
Series are named from the values themselves and keep their colour for the whole run. A value that stops appearing drops to zero rather than vanishing from the chart, so you can see a node leave the cluster or an old version drain away.
Values are treated as categorical even when they’re numeric, so the stats table shows a frequency breakdown (1=412 2=398 3=405) rather than min/avg/max. If more than one print expression on the same node uses series: value, the lines are prefixed with their key to make them easier to tell apart.
Aggregation window#
By default an aggregation covers the whole run, so a long run’s numbers converge and stop reacting to what’s happening now. Set window to a duration to aggregate over a sliding window instead:
stats (rate: 1 / 1s) {
latency (
post_print: { key: 'p99', value: result().p99_ms, window: 30s }
) `SELECT p99_ms FROM latency_stats`
}The window is rounded to the nearest multiple of --print-interval (default 1s) and covers at least one interval, so window: 500ms behaves the same as window: 1s. Values older than the window age out of the table, the bar chart, and the series: value rates.
How the three rates interact#
A windowed stats query has three separate numbers, and it’s easy to assume they’re related when they aren’t:
stats (rate: 1 / 1s) { # block rate
version_over_time(
type: line,
rate: 20 / 1s, # query rate
post_print: {
key: 'version',
value: result().version,
series: value,
window: 10s # sliding window size
}
) `SELECT split_part(version(), ' ', 3) AS version`
}| What it controls | |
|---|---|
Block rate | The default execution rate for queries in the block that don’t set their own rate (ignored by any query with a per-query rate). In the example above nothing uses it, so it’s dead config. |
Query rate | How often this query executes, and therefore how many observations land in the window per second. A higher rate means a smoother line, same time span. |
window | The time span the sliding window covers. |
The window is divided into buckets of one --print-interval each (not one block tick each). With the default --print-interval of 1s, a window: 10s is a ring of 10 buckets, each holding one second of observations. At rate: 20 / 1s that’s ~20 samples per bucket and ~200 across the window.
The consequences:
- Changing
--print-intervalchanges the resolution, not the span. At--print-interval 2s, awindow: 10sbecomes 5 buckets - still 10 seconds, but a change in the data snaps in 2-second steps. - Changing the query
ratechanges samples per bucket only. It makes the line smoother or noisier; it does not make the window longer or shorter. - Changing
windowchanges how long a value lingers after it stops appearing.
Counts in the stats table read slightly below rate * window, because the newest bucket is still filling when the table is rendered. At 20/s over a 10s window, expect roughly 180–200 rather than exactly 200.
Series that stop appearing#
The chart and the stats table treat a value that drops out of the window differently:
- The chart knows every value seen for the lifetime of the run and plots a missing one as zero, so a node leaving the cluster or an old version draining away falls to the axis instead of vanishing.
- The stats table only lists values actually seen inside the window. Once a value ages out completely, its
key=countentry disappears from the row.
So during a rolling upgrade with window: 10s, the table shows both versions only for the ~10 seconds they overlap in the window:
version_over_time version v25.3.1=54 v26.2.4=138 # mid-switch
version_over_time version v26.2.4=196 # 10s laterWiden the window if you want the old value to stay visible in the table for longer. Frequency rows list at most 10 values, sorted by value.
Keyboard shortcuts#
| Key | Action |
|---|---|
| Tab | Switch between Stats and Chart views |
| ← / → | Cycle chart metric (QPS, p90, p95, p99, Errors/s) |
| ↑ / ↓ | Change chart time window (1m, 10m, 30m, 1h, 6h, 1d) |
| 1–9 | Focus a single series in the chart (press again to show all) |
| q / Esc | Quit |