Build a dashboard
Goal
Serve a work-queue dashboard at /guide/dashboard: three summary metrics, a table
of queues, and a review card in a responsive two-column layout. All totals come
from the same immutable snapshot. This example has no filters, sorting buttons,
export links, or other controls without handlers.
Prerequisites
- A component-scanned Spring MVC application running on JDK 21 or later with SimplyPages 1.2.1 installed locally. Start with the learning path, then serve pages and fragments.
- Spring static-resource handling for
/css/framework.css,/js/framework.js, and/webjars/htmx.org/dist/htmx.min.js.ShellBuilderlinks these assets; it does not install their HTTP mappings. - A root-mounted application for the asset paths used here.
The supplied snapshot is a small, explicitly authored dataset, not a connection to a monitoring system. Use only public or local example data. An internal operations dashboard must authenticate and authorize its viewers in the application before querying or rendering private data.
1. Add a controller with immutable view data
Save DashboardController.java below your application's component-scan root.
The package is com.example.app.guides; change it consistently to match your
application. The controller retains records and an immutable list, not built
components. Each GET creates a fresh page and its children.
package com.example.app.guides;
import io.mindspice.simplypages.builders.ShellBuilder;
import io.mindspice.simplypages.components.Header;
import io.mindspice.simplypages.components.Paragraph;
import io.mindspice.simplypages.components.display.Card;
import io.mindspice.simplypages.components.display.DataTable;
import io.mindspice.simplypages.layout.Column;
import io.mindspice.simplypages.layout.Page;
import io.mindspice.simplypages.layout.Row;
import io.mindspice.simplypages.modules.StatsModule;
import java.time.LocalDate;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class DashboardController {
private record WorkQueue(String name, int open, int overdue, String owner) {}
private record Snapshot(LocalDate date, List<WorkQueue> queues) {
Snapshot {
queues = List.copyOf(queues);
}
}
private final Snapshot snapshot = new Snapshot(LocalDate.of(2026, 3, 10), List.of(
new WorkQueue("Access", 7, 2, "Alex"),
new WorkQueue("Deploys", 4, 0, "Sam"),
new WorkQueue("Billing", 9, 1, "Jo")
));
@GetMapping(value = "/guide/dashboard", produces = "text/html")
public String page() {
Snapshot data = snapshot;
int open = data.queues().stream().mapToInt(WorkQueue::open).sum();
int overdue = data.queues().stream().mapToInt(WorkQueue::overdue).sum();
long affected = data.queues().stream().filter(q -> q.overdue() > 0).count();
StatsModule stats = StatsModule.create()
.withModuleId("dashboard-stats")
.withTitle("Queue summary")
.withColumns(3)
.addStat(Integer.toString(data.queues().size()), "Queues", "Tracked in this snapshot")
.addStat(Integer.toString(open), "Open items", "Includes overdue items")
.addStat(Integer.toString(overdue), "Overdue items", "Included in the open total");
DataTable<WorkQueue> table = DataTable.create(WorkQueue.class)
.addColumn("Queue", WorkQueue::name)
.addColumn("Open", q -> Integer.toString(q.open()))
.addColumn("Overdue", q -> Integer.toString(q.overdue()))
.addColumn("Owner", WorkQueue::owner)
.withData(data.queues())
.striped()
.bordered();
Card queues = Card.create()
.withHeader(Header.H2("Work queues"))
.withBody(table)
.withFooter("Snapshot date: " + data.date());
Card review = Card.create()
.withHeader(Header.H2("Review priorities"))
.withBody(new Paragraph(overdue == 0
? "No overdue items in this snapshot."
: overdue + " overdue items across " + affected + " queues need review."))
.withFooter("Read-only example; reload does not fetch new source data.");
Row details = new Row()
.addColumn(Column.create().withWidth(8).withChild(queues))
.addColumn(Column.create().withWidth(4).withChild(review));
Page page = Page.builder()
.addComponents(Header.H1("Operations dashboard"),
new Paragraph("Work-queue snapshot for " + data.date()), stats)
.addRow(details)
.build();
return ShellBuilder.create()
.withPageTitle("Operations dashboard")
.withContent(page)
.build();
}
}
2. Keep layout and rendering responsibilities separate
StatsModule composes the metric cards. DataTable extracts cell text from
WorkQueue records and escapes it; its extractors return strings, not HTML.
Card places its header, body, and footer in their defined slots. No demo-local
CSS or JavaScript is required.
The row contains explicit 8/12 and 4/12 Column containers. Their built-in
padding supplies the gutter; adding a flex gap to a full 12-column row can force
the second column to wrap. Set width on the containers, not on StatsModule:
module width methods are intentionally blocked. Framework CSS stacks the detail columns at mobile widths and reflows
the metric grid. Keep labels and table columns concise, and check your actual
data at narrow widths before adding more columns.
The route always returns a complete shell, even if a caller sends HX-Request.
There is no alternative fragment representation and no need to vary on that
header. Navigate to it with a normal link. If you later introduce fragment
refresh, use a stable target wrapper and the full/HX response contract from the
serving guide.
DataTable.sortable() adds class markers only; it does not install sorting.
This example does not call it or imply that clicking a heading changes order.
3. Check the result
Start your application and open /guide/dashboard.
- Expect 3 queues, 20 open items, and 3 overdue items. Overdue items are a subset of open items, not an additional workload total.
- Confirm the table has Access (7/2/Alex), Deploys (4/0/Sam), and Billing (9/1/Jo), in that order. The review card reports overdue work in 2 queues.
- Reload the URL directly. It returns a full document with one set of framework assets and the same snapshot date, not a live-data claim.
- At a desktop width, check the table and review card align in their 8/4 row. At a narrow mobile width, check the stacked cards, readable metric labels, and table cells for overlap or clipping. No text should cover adjacent cards.
- Confirm the page offers no nonfunctional refresh, edit, or export controls.
For a direct Java smoke check, construct new DashboardController() and call
page(). It should return a complete document containing the three queue names,
summary labels, and review text. Calling it twice must not duplicate table rows,
metric cards, or module IDs. This is the actual GET handler.
4. Replace the snapshot at the application boundary
To use live data, load one authorized, consistently scoped snapshot per request,
then calculate the metrics and render the table from that same snapshot. Enforce
resource limits and query bounds in the application; DataTable does not fetch
or paginate database rows for you. Set private-response caching policy when the
values belong to a user or organization.
Do not retain a built table in the singleton controller and append new data on
each request: withData(...) appends rather than replaces rows. Reconstruct
request-owned components as above, or use slots/templates where the structure
and sharing contract genuinely fit. SimplyPages provides presentation, not a
dashboard database, refresh scheduler, or authorization service.