Custom panels
Custom panels
Any package (or the app itself) can contribute pages and panels. A panel is a PHP class that returns a typed payload; the SPA renders it. No JavaScript build, no views, and whatever PromQL/TraceQL/LogQL fits — including metrics and spans the core UI knows nothing about.
// e.g. in cboxdk/queue-autoscale's service provider
use Cbox\TelemetryUi\Facades\TelemetryUi;
public function boot(): void
{
if (class_exists(TelemetryUi::class)) {
TelemetryUi::page('autoscale', 'Autoscale', group: 'Queues');
TelemetryUi::panel(\Cbox\QueueAutoscale\Ui\ScalingDecisions::class, page: 'autoscale');
TelemetryUi::panel(\Cbox\QueueAutoscale\Ui\WorkerFleet::class, page: 'autoscale');
}
}
That's all the wiring you need — no routes to register. The page is served
at {path}/p/autoscale, its panel list at /api/v2/pages/autoscale, and each
panel at /api/v2/panels/{id}, where the id is the kebab-cased class basename
(ScalingDecisions → scaling-decisions). Keep basenames unique across the
panels you register; the first match wins.
page() also takes an optional detectMetric: name pattern: the page only
shows when a matching metric exists in the backend — the same autodetection
the built-in Statamic group uses. group: places it under a sidebar heading.
A chart panel
The terse path for "run a PromQL range query, draw it" is promChart():
use Cbox\TelemetryUi\Panels\Panel;
final class ScalingDecisions 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,
);
}
}
It queries the range, converts the series, catches backend errors and returns
a chart payload with deploy annotations and range bounds, all within the
current scope. A grouped query (sum by (x)) yields one line per group. Pass
stat: 'Now' for a headline tile.
For more control, build the series yourself and call chartCard():
public function data(): array
{
[$start, $end] = $this->range();
try {
$series = $this->metrics()->queryRange($this->metric('queue_size')->sumBy('queue'), $start, $end);
} catch (SourceException $e) {
return $this->chartCard('Queue depth', error: $e->getMessage());
}
return $this->chartCard(
title: 'Queue depth',
series: $this->toChartSeries($series, label: 'queue'),
stats: [$this->stat('Queues', (string) count($series))],
type: 'area',
unit: 'number',
);
}
A table panel
use Cbox\TelemetryUi\Connectors\SourceException;
use Cbox\TelemetryUi\Panels\Panel;
use Cbox\TelemetryUi\Panels\Ui;
use Cbox\TelemetryUi\Support\Format;
final class WorkerFleet extends Panel
{
public function data(): array
{
$columns = [Ui::col('host', 'Host'), Ui::num('workers', 'Workers')];
try {
$samples = $this->metrics()->query($this->metric('autoscale_workers')->sumBy('host_name'));
} catch (SourceException $e) {
return Ui::table('Worker fleet', $columns, [], ['error' => $e->getMessage()]);
}
$rows = [];
foreach ($samples as $sample) {
$host = $sample->labels['host_name'] ?? '?';
$rows[] = [
'_link' => Ui::entity('host', $host),
'host' => Ui::cell($host, ['dim' => ['key' => 'host.name', 'value' => $host]]),
'workers' => Ui::cell(Format::count($sample->value), ['raw' => $sample->value, 'mono' => true]),
];
}
return Ui::table('Worker fleet', $columns, $rows, ['empty' => 'No workers reporting.']);
}
}
_link makes the whole row open the host's entity page. dim turns the cell
into a dimension chip (filter, exclude, group by, open). The full list of kinds,
cell options and link builders is in
pages & panels.
Params and controls
Public properties marked #[Param('x')] are filled from the ?x= query
parameter before data() runs (string, int, float and bool are cast).
Pair one with a control and the SPA renders it in the panel header and
re-fetches the panel with the new value:
use Cbox\TelemetryUi\Panels\Attributes\Param;
final class SlowJobs extends Panel
{
#[Param('min_ms')]
public int $minMs = 1000;
#[Param('queue')]
public string $queue = '';
public function data(): array
{
// …query with $this->minMs and $this->queue…
return Ui::table('Slow jobs', $columns, $rows, [
'controls' => [
Ui::select('queue', 'Queue', $this->queue, Ui::options($queues)),
Ui::search('min_ms', 'Min ms', (string) $this->minMs, '1000'),
],
]);
}
}
Ui::param('queue', 'emails') as a cell link sets the param from a click
(filter the panel to a value). Override boot() if a param needs normalising
after binding.
The panel toolkit
Everything below is a protected method on Panel:
| Group | Methods | What you get |
|---|---|---|
| Time | range() · period() · rangeSeconds() · promDuration() · rateWindow() |
The selected window (preset or brushed range) and PromQL-ready durations. |
| Scope | metric($name, $extra = '') · traceQuery(...$conditions) · traceScope($extra = '') · logSelector() · scopeMatchers() (override) · escapeLabelValue() |
Queries scoped to the active service/environment and the viewer's scope lock. |
| Backends | metrics() · traces() · logs() · issues() (optional connection name) |
The configured drivers, resolved lazily. |
| Query helpers | total($query) · sumSamples($samples) · counterIncrease($query) · trendByKey($query, $start, $end, $key) |
Common aggregations and per-row sparkline data. |
| Charts | promChart(...) · chartCard(...) · toChartSeries($series, $label) · stat() · statDelta() |
chart payloads and stat tiles. |
| Annotations | annotations() · annotationMarks() |
Deploy/incident markers for the scope. |
| Links | pageLink($page, $extra) |
A Ui::page() link with empty params dropped. |
The scope is also available as $this->scope (the RequestScope), and
$this->period, $this->from, $this->to, $this->service and
$this->environment are set from it.
Add, replace, remove
use Cbox\TelemetryUi\Facades\TelemetryUi;
use Cbox\TelemetryUi\Panels\Builtin\JobsOverview;
// Add — append a panel to any page (default: the dashboard).
TelemetryUi::panel(MyPanel::class, page: 'requests');
// Replace — swap a page's whole panel list for your own (a branded dashboard).
TelemetryUi::setPanels('dashboard', [MyHeadline::class, MyChart::class]);
// Remove — drop a single built-in panel…
TelemetryUi::removePanel(JobsOverview::class, 'dashboard');
// …or a whole page from the navigation and the API.
TelemetryUi::removePage('users');
Re-registering a page slug with TelemetryUi::page(...) overwrites it, so you
can relabel or regroup a built-in page. To extend a built-in panel instead of
replacing it, subclass it (the overview panels that detail pages reuse are not
final) and register your subclass.
Conventions
- Query through
$this->metrics()/traces()/logs()so named connections, custom drivers and tenancy keep working. - Never throw from
data(). CatchSourceExceptionand return the payload with anerrorkey; a broken backend must not take the page down. - Respect
$this->range(); don't hardcode time windows. - Return links as
Ui::*link arrays, never URLs — the SPA owns routing and the base path. - Boot stays cheap: register class-strings, never instantiate connectors in a service provider.
Offering an action on a row
A panel can put buttons on a row: Ui::action() inside a cell's actions.
The dashboard renders them as a quiet menu that appears on hover, posts to
the endpoint on click, and refetches every panel afterwards — a write can
change what any of them is showing.
Ui::cell($issue->status, ['actions' => [
Ui::action('Resolve', "insights/issues/{$issue->fingerprint}", ['action' => 'resolve']),
Ui::action('Ignore', "insights/issues/{$issue->fingerprint}", ['action' => 'ignore'],
confirm: 'Never hear about this again?', tone: 'danger'),
]])
The endpoint is a path under this dashboard's own API, never a URL —
Ui::action() refuses an absolute one. A panel payload is data, and data
must not be able to make someone's browser post to another host;
constraining it to a relative path keeps every action inside the same
origin, gate and throttle as the rest of the dashboard.
An action in a payload is an offer, not an authorization. Whether the
viewer may actually do it is the endpoint's decision — check
manageTelemetryUi (or your own ability) there, and return the usual error
shape on refusal. The menu shows the message it gets back rather than
pretending the action worked.