Skip to content

Redesign the charts on uPlot - #2448

Merged
jage merged 5 commits into
mainfrom
ui/uplot-charts
Oct 15, 2026
Merged

jage merged 5 commits into
mainfrom
ui/uplot-charts

Conversation

@jage

@jage jage commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Stacked on #2447.

Replaces Chart.js with uPlot and redesigns the charts around reading values instead of guessing them from the plot:

  • The legend is a table under the plot. It shows the latest value, or the value under the cursor while hovering, and the card heading shows the sample time.
  • Hovering one chart moves the cursor on every chart on the page.
  • Click a legend row to isolate a series, shift, cmd or ctrl-click to toggle one.
  • Charts rebuild from the server's history log on every refresh, spaced by the LavinMQ-Stats-Interval header, so a slower or paused refresh leaves no gaps.
  • Byte axes tick on powers of two. The y axis only shrinks after five polls below 60% of its range, so a spike leaving the window does not make the scale jump.
  • The nodes page gets a Disk usage chart. Memory and disk draw a capacity line, and their axes grow to it once usage passes half.

uPlot 1.6.32 replaces Chart.js, Luxon and the adapter. Chart assets go from 165 KiB to 48 KiB deflated, and main thread time per refresh on the overview from 68 ms to 2.6 ms, most of the difference being Chart.js's update animation.

Also carries a spec-only commit for whoami races in auth.spec.js that faster page loads exposed.

Before and after

Chart.js uPlot
Overview before Overview after
Overview hover before Overview hover after
Nodes before Nodes after

Rows: the overview, the overview while hovering the message rates chart, and the nodes page. Same load on both servers.

@jage jage added the UI/UX label Oct 14, 2026
@jage jage self-assigned this Oct 14, 2026
@jage
jage requested a review from Bianka2112 October 14, 2026 04:32
@claude

claude Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

No issues found.

@jage
jage added this pull request to stack #2449 October 14, 2026 04:36
@jage

jage commented Oct 14, 2026

Copy link
Copy Markdown
Member Author

Pinning the disk axis to capacity is intentional and matches the reference design; the description now says so. Whether disk should scale like memory is an open question we will settle before merging.

@jage
jage marked this pull request as ready for review October 14, 2026 05:27
@jage
jage requested a review from a team as a code owner October 14, 2026 05:27

@carlhoerberg carlhoerberg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code approved, not tested. Yes, disk should probably scale like memory, going from 1 to 50 MiB disk usage wont probably even register on a 1 TiB disk chart, but may still indicate something unexpected for the user.

@jage

jage commented Oct 15, 2026

Copy link
Copy Markdown
Member Author

How the two disk axis options look on real data, following up on the review comment about disk scaling.

Setup: two LavinMQ containers with a cgroup memory limit and a loop mounted ext4 volume for the data directory, stats every second. Each publishes persistent 8 KiB messages for 60 s, then purges the queue, so disk usage is a sawtooth.

  • tight: 2 GiB volume about 70% full (1.2 GiB of unrelated data), 128 MiB memory limit
  • roomy: 32 GiB volume with nothing else on it, 3 GiB memory limit

Option A is this PR as is: the disk axis is pinned to capacity. Option B gives disk the same scaling as memory: the axis follows usage, snaps to capacity once usage passes half of it, and pins the capacity label above the plot while capacity is off the axis. Memory is the same in both.

A: disk pinned to capacity B: disk scales like memory
tight tight, option A tight, option B
roomy roomy, option A roomy, option B
  • tight: barely any difference. Usage is past half of capacity, so B also scales to capacity.
  • roomy: A draws a flat line on a 31 GiB axis and the 150 MiB growth and purge do not show. B shows them.
  • B helps less than the roomy picture suggests when the volume has a large starting level. The 1.6 GiB baseline here is ext4's 5% reserved blocks, which disk_total - disk_free counts as used, so a change of a few MiB would still be small on that axis.
  • In B the pinned capacity label overlaps the line when usage sits near the top of the axis (roomy B). That would need a fix if we go with B.

B is a small change in nodes.js (the disk chart passes scaleCap and a pinAbove capacity line instead of fixedMax).

@jage

jage commented Oct 15, 2026

Copy link
Copy Markdown
Member Author

We also have these to understand percentage etc.

Screenshot 2026-10-15 at 07 28 37@2x

@carlhoerberg

Copy link
Copy Markdown
Member

B, scale like memory

Base automatically changed from stats-interval-header to main October 15, 2026 13:42
jage added 5 commits October 15, 2026 15:42
layout.js requests api/whoami when the page loads and caches the
response in localStorage. page.goto resolves on the load event, which
does not wait for that request, so the whoAmI specs could navigate
away while it was still in flight. The aborted request was never
cached and the next page load fetched it again, failing the call
count.

The user tag state class specs lost the same race from the other side.
The saved login state already holds user-is-administrator in
localStorage and layout.js applies it on load, so a spec that read the
class list right after page.goto saw the cached classes rather than
the tags from the mocked whoami.

The race only lost often enough to notice once the chart library got
smaller and pages started loading faster. The whoAmI specs now wait
until the response has been cached before the next navigation, and
the state class specs use retrying assertions on the html element.
The charts are redesigned around reading values rather than guessing
them from the plot. Chart.js drew everything on a canvas: the legend
sat inside the plot and the only way to read a value was a tooltip
that followed the mouse. The new charts put the legend in a table
under the plot, aligned with the plot area, and the table is where the
values are read: it shows the latest value until the cursor enters a
plot, then the value under the cursor. The plot itself is only the
shape.

uPlot replaces Chart.js as the engine. It draws just the plot on a
small canvas and leaves the legend to the page, which is what makes
the table possible, and it is the engine Grafana uses.

What the user sees:

- The legend table has a colour marker, the series name and its value.
  Click a row to isolate that series, shift, cmd or ctrl-click to
  toggle a single one. Each row's tooltip explains the series.
- The card heading shows the timestamp of the sample the legend is
  showing, or "Paused at hh:mm:ss" while refresh is paused.
- Hovering any chart moves the cursor on every chart on the page, so
  the legends all report the same instant.
- Byte axes tick on powers of two (128 KiB, 256 KiB) and byte legend
  rows share the unit of the largest value. The y axis only shrinks
  after five polls below 60% of its range, so a spike leaving the
  window doesn't make the scale jump.
- The nodes page gets a Disk usage chart next to Memory usage. Both
  draw a dashed line at the capacity (mem_limit, disk_total) and grow
  toward it once usage passes half. The CPU axis starts at 100% and
  grows past it on multi-core nodes. IO is renamed Disk I/O.
- Charts redraw when the theme changes instead of keeping the old grid
  colours.

How the data flows:

Chart.js charts appended one point per poll and had to fill gaps when
polling was slower than the stats interval or paused. uPlot charts
rebuild from the history log in every response instead, so there is
nothing to fill: the resolution is the server's stats interval
(advertised in LavinMQ-Stats-Interval) no matter how often the page
polls, and pausing leaves no hole. The Chart.js gap spec goes with it.

Chart.update accepts the API's stats objects as before, or an array of
{ key, label, value, log, desc } for derived series such as disk used
and CPU percent. Pages used to build fake API-shaped objects for those.

The y axis keeps its range until the data has sat below 60% of it for
five polls. That hysteresis lives in stableMax, which counts polls
rather than redraws so legend toggles cannot collapse the axis, and is
exported so the spec can drive it: the axis is drawn on a canvas and
has nothing in the DOM to assert on.

uPlot 1.6.32 is fetched and checksummed by the Makefile, replacing
Chart.js, Luxon and the Luxon adapter.
The disk chart pinned its y axis to disk_total, so on a large volume any
change smaller than a few percent of capacity drew as a flat line. It now
uses the same scaleCap as memory: the axis follows usage and snaps to
capacity once usage passes half of it, with the capacity label pinned
above the plot while the line is off scale.
The label for a capacity line pinned above the plot was drawn inside
the plot area, so it covered the series whenever usage sat near the top
of the axis, which is common once disk scales with usage. The plot now
reserves a fixed 16 px at the top, and every capacity label is drawn in
the space above its line: in that padding when the line is off the
axis, otherwise just above the dashed line.
@jage
jage merged commit 95d291b into main Oct 15, 2026
25 checks passed
@jage
jage deleted the ui/uplot-charts branch October 15, 2026 13:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants