Pages & panels
Pages & panels
Most screens in the dashboard are pages made of panels. A panel is a
plain PHP class extending Cbox\TelemetryUi\Panels\Panel: it runs its queries
and returns one JSON payload from data(). The React app fetches that payload
from GET {path}/api/v2/panels/{id} and renders it. There is no view, no
component state and no JavaScript on your side.
Pages form the sidebar (grouped: Activity, Queues, Frontend, Infrastructure, …). There is no difference between a built-in panel and a third-party one — the Requests page is built from the same primitives a queue-autoscale package would use.
Explore, entity pages, the trace view and error groups are not panel pages; they have their own endpoints. See dimensions & Explore.
Registering
Config-declared dashboard panels (rendered first, in order):
// config/telemetry-ui.php
'panels' => [
\Cbox\TelemetryUi\Panels\Builtin\RequestsActivity::class,
],
Runtime registration from any service provider:
use Cbox\TelemetryUi\Facades\TelemetryUi;
public function boot(): void
{
TelemetryUi::page('autoscale', 'Autoscale', group: 'Queues');
TelemetryUi::panel(AutoscaleDecisions::class, page: 'autoscale');
}
Both calls are data-only (arrays of class-strings), so registering costs
nothing at boot. A registered page is reachable at {path}/p/{slug} with no
route of your own.
| Method | Does |
|---|---|
page($slug, $label, group:, icon:, detectMetric:, hidden:) |
Register (or overwrite) a page. |
removePage($slug) |
Remove a page and its panels. |
panel($class, page: 'dashboard') |
Append a panel to a page. |
setPanels($page, [...]) |
Replace a page's panels ([] blanks it). |
removePanel($class, $page = 'dashboard') |
Drop one panel. |
panels($page = 'dashboard') |
The effective list, config panels first. |
A panel's id is its kebab-cased class basename (RoutesTable →
routes-table). Its grid width is static span() (1–3 columns, default 1).
Autodetected pages
Pages registered with a detectMetric pattern only appear when the metrics
backend actually contains matching metric names:
TelemetryUi::page('autoscale', 'Autoscale', group: 'Queues', detectMetric: 'autoscale_.*');
Detection is cached per pattern (TTL telemetry-ui.detection.ttl, default
300s) and scoped to the selected service/environment. Undetected pages are left
out of the navigation that GET /api/v2/bootstrap returns, and
GET /api/v2/pages/{page} answers 404. If the backend is unreachable,
detection fails open — the page stays visible and its panels show their own
error states.
Every registered pattern is resolved in one backend call per request: the
driver returns the metric names matching any of them and each pattern is
decided against that list. On Prometheus/Mimir that is a single
/api/v1/label/__name__/values with a match[] selector. A driver that does
not implement EnumeratesMetricNames
falls back to one count({__name__=~"autoscale_.*"}) query per pattern.
The built-in Statamic group works this way: install
cboxdk/statamic-telemetry
in any monitored app and its statamic_* metrics light up the Static Cache,
Stache, Glide, Forms, Content and Inventory pages — the dashboard app itself
does not need to be a Statamic app.
What a panel gets for free
use Cbox\TelemetryUi\Panels\Panel;
final class AutoscaleDecisions extends Panel
{
public static function span(): int
{
return 2;
}
public function data(): array
{
return $this->promChart(
'Scaling decisions',
$this->metric('autoscale_scaling_events_total')->rate($this->rateWindow())->sumBy('queue')->times(60),
seriesLabel: 'queue',
type: 'bar',
unit: 'events/min',
span: 2,
);
}
}
- The constructor receives the request's
RequestScope, sorange(),period(),promDuration()andrateWindow()reflect the global time window (preset or brushed range) andmetric(),traceQuery()andlogSelector()are already scoped to the selected service/environment and the viewer's tenancy lock. metrics(),traces(),logs()andissues()return the configured drivers.- Public properties marked
#[Param('x')]are filled from?x=(see custom panels). - The SPA re-fetches the panel whenever the scope, the filters or its params change, and on each auto-refresh tick. One slow panel never blocks the page: each is its own request.
A panel must not throw from data(). Catch SourceException and return the
payload with an error key — the chart helpers do this for you.
The payload contract
Every payload is an array with a kind. Cbox\TelemetryUi\Panels\Ui has a
builder for most kinds, and the chart helpers on Panel build chart. The
shapes are mirrored 1:1 by resources/app/src/api/types.ts.
| Kind | Build with | Renders |
|---|---|---|
chart |
promChart() · chartCard() |
Line/area/bar time series with stat tiles, deploy annotations and brush-to-zoom. |
stats |
Ui::stats($title, $items) |
A row of stat tiles (stat() / statDelta() items, optional sparkline). |
table |
Ui::table($title, $columns, $rows) |
A sortable table. Rows map column key → Ui::cell(); _link on a row makes the whole row a drill-down. |
bars |
Ui::bars($title, $items) |
Ranked label → value bars (top pages, countries). |
composite |
Ui::composite($title, $parts) |
Several payloads stacked in one panel. |
header |
Ui::header($title, $subtitle, $stats) |
An entity/detail header with headline stats and a back link. |
kv |
Ui::kv($title, $items) |
Label/value pairs. |
code |
Ui::code($title, $text, $language) |
A code block (SQL, JSON, a stack trace). |
callout |
Ui::callout($title, $message, $tone) |
A message; tone info, warn, danger or ok. |
hidden |
Ui::hidden() |
Nothing — the panel decided not to show. |
heatmap |
plain array | {xs, ys, cells: [[xi, yi, value]], unit?, link?} |
graph |
plain array | {nodes: [{id, label, …}], edges: [{source, target, count, …}]} (the service graph) |
logs |
plain array | {entries: [{time, ms, level, tone, message, labels, traceId?}], stream?} — stream: {signal, params} turns on SSE live tail. |
Optional keys on any kind: title, subtitle, span, error, empty (the
empty-state message), note, drill (a link in the header) and controls
(Ui::select() / Ui::search() bound to a panel param).
Cells
Ui::cell($value, $opts) takes raw (the sort value), tone, mono, link,
spark (a sparkline), bar (0–1 inline bar), badge, sub (a second line)
and dim (['key' => 'http.route', 'value' => …], which makes the cell a
dimension chip: filter, exclude, group by, open the entity page).
Ui::col() / Ui::num() declare columns; num() right-aligns.
Links
Links are data, never URLs. The SPA owns routing and the base path, so a link says what it opens:
| Builder | Opens |
|---|---|
Ui::entity($type, $value) |
An entity page. |
Ui::page($page, $params) |
A registered page with extra params. |
Ui::trace($traceId) |
The trace, in the drawer. |
Ui::error($group) |
An error group, in the drawer. |
Ui::issue($id) |
A tracker issue, in the drawer. |
Ui::explore($signal, $where) |
Explore, pre-filtered (['http.route=/checkout']). |
Ui::param($param, $value) |
Set one of this panel's own params and re-fetch. |
Ui::url($href) |
An external URL. |
Custom pages and panels
See custom panels for the full guide and custom detail pages for drill-down pages scoped to one entity.