Skip to content

UI Enrichment: Links, Badges, Formats, and Detail Panels

Probe User Details turns a handler-registered model into a tab in the Message Probe. On its own, a table field renders every row as plain text — useful, but a build id is more useful as a link to the CI system that produced it, an outcome is more useful as a colored badge than the string "failed", and a duration in milliseconds is more useful formatted than raw.

UI enrichment is a set of optional probe_field() and Column arguments that add exactly that presentation, without changing what the field is — a table field enriched with a link template is still a table field; the enrichment only changes how one column renders.

For a guided end-to-end walkthrough that builds one example through every option below, see the UI customization cookbook.

class BuildRow(BaseModel):
    build_id: str
    job_name: str
    duration_ms: int
    outcome: str


class MyProbeDetails(BaseModel):
    builds: list[BuildRow] = probe_field(
        section='Builds',
        view='table',
        default_factory=list,
        columns={
            'build_id': Column(link_template='{jenkins}/job/{row.job_name}/{value}', hint='Open build {value}'),
            'duration_ms': Column(format='duration_ms'),
            'outcome': Column(badge_colors={'passed': 'green', 'failed': 'red', '*': 'gray'}),
        },
    )

That declaration alone gets you a clickable build id, a human-readable duration, and a colored outcome pill — no client-side code, no per-row formatting logic in the handler.


A link template turns a scalar value (string view) or a table column into a clickable link. The grammar has three token kinds:

Token Resolves to Where it’s valid
{value} The field’s (or column’s) own value Anywhere
{row.<field>} Another field of the same row Table/tables/tree columns and detail elements only
{<base>} A named URL base from ui.link_bases Anywhere
link_template='{jenkins}/job/{row.job_name}/{value}'

This resolves {jenkins} from config, {row.job_name} from the sibling job_name field on the same row, and {value} from the column’s own build_id.

A template names which base to use ({jenkins}), never the base URL itself — that lives in config, so the same handler code produces working links in every environment without a redeploy:

ui:
  link_bases:
    jenkins: 'https://jenkins.internal.example.com'
    jira: 'https://jira.internal.example.com'
  • Base names are lower-case identifiers (^[a-z][a-z0-9_]*$).
  • Values must start with http:// or https://; a trailing / is stripped, so a template’s own leading /job/... doesn’t produce a doubled slash.
  • Templates are validated at startup regardless of config — a malformed template, an unknown token shape, or a {row.*} reference to a non-existent row field is a ProbeDetailsConfigError that stops the app from starting. Whether a named base actually resolves is not checked here, because config varies per environment.

Unresolved bases: a warning, not an error

A template that references a base your config doesn’t define is not a startup error — different environments legitimately configure different subsets of ui.link_bases. Instead:

  • DrakkarApp(...) logs one probe_details_link_bases_missing warning at startup, naming every missing base, so the gap is visible in logs instead of discovered by an operator clicking a dead link. The warning is skipped entirely when ui.enabled is false — no UI means no link is ever rendered.
  • At render time, the UI resolves what it can and falls back to plain text for anything it can’t — a value with an unresolved link never becomes a dead anchor.

Percent-encoding applies to the {value} and {row.*} substitutions in a URL, so a value containing / or spaces can’t break the link’s path structure. The base itself is inserted verbatim — it’s a trusted URL prefix from config, not row or user data, and encoding it would break every link that relies on its own / structure. Display text — hints and detail titles — substitutes the same tokens unencoded, since it’s read, not navigated.


Badges

view='badge' renders a scalar as a colored pill instead of plain text. badge_colors maps each expected value to one of six palette colors, plus an optional '*' fallback for anything not explicitly listed:

Color
green
red
yellow
blue
gray
purple
release_state: str = probe_field(
    section='Enrichment',
    view='badge',
    badge_colors={'shipped': 'green', 'blocked': 'red', '*': 'gray'},
    default='',
)

A value that matches no key and has no '*' fallback still renders as a badge — a neutral pill in the base (uncolored) badge style, never plain text and never a broken or blank pill.

Table columns get badges the same way, via Column(badge_colors=...) instead of a top-level view='badge' field — see Column subsets below.

badge_colors and link_template are exclusive on one column

A Column (or top-level field) that declares both badge_colors and link_template renders as a badge — the link is silently not rendered. This is intentional, not validated as an error, but it means the two options don’t compose: pick one presentation per column.


Formats

format applies a client-side display transform to a string-view scalar or table column, without changing the underlying value:

format Field type Before (raw value) After (rendered)
duration_ms int (milliseconds) 65000 1 m 5 s
bytes int (bytes) 10485760 10.0 MiB (binary units)
timestamp str (ISO-8601) "2026-08-09T10:00:00Z" 2026-08-09 10:00:00.000
number int or float 1234567 1,234,567
duration_ms: int = probe_field(section='Builds', view='string', format='duration_ms', default=0)

The other three formats follow the same shape — one probe_field() keyword each:

class ResourceUsage(BaseModel):
    payload_size: int = probe_field(section='Usage', view='string', format='bytes', default=0)
    started_at: str = probe_field(section='Usage', view='string', format='timestamp', default='')
    retry_count: int = probe_field(section='Usage', view='string', format='number', default=0)

Hovering a formatted value shows the raw underlying value in a tooltip, so the exact number is always one hover away — formatting never hides data, only presents it more readably.


Hints

hint attaches a small tooltip to a value or column header, using the same template grammar as link templates ({value} / {row.<field>} / {<base>}), rendered unencoded as display text:

Column(link_template='{jenkins}/job/{row.job_name}/{value}', hint='Open build {value} in Jenkins')

hint is valid on string-view fields and on table columns; it is not valid on row-bearing top-level fields directly (table, tables, tree) — put it on the relevant Column instead.


Column subsets and per-column options

By default, a table/tables/tree field’s columns are every field of the row model, in declaration order, with an auto-generated label. columns overrides that in two ways:

  • columns=['build_id', 'duration_ms'] — a subset, in the given order. Every name must be a real row-model field; unknown names are a startup error.
  • columns={'build_id': Column(...), 'duration_ms': Column(...)} — the keys are the displayed subset (in dict order), and each Column carries that column’s enrichment.
class Column(BaseModel):
    label: str | None = None
    link_template: str | None = None
    badge_colors: dict[str, str] | None = None
    format: Literal['duration_ms', 'bytes', 'timestamp', 'number'] | None = None
    hint: str | None = None
    renderer: str | None = None

Column.label overrides that one column’s auto-generated heading, the same way probe_field(label=...) does for a top-level field. Every other Column option means exactly what it means at the top level, scoped to that column.


Detail panels

A detail on a table/tables/tree field turns each row into a clickable entry (a affordance) that opens a right-side panel showing a richer view of that one row — built from Detail, Element, and Link:

class Link(BaseModel):
    label: str
    template: str


class Element(BaseModel):
    view: Literal['string', 'keyvalue', 'table', 'links', 'custom']
    field: str | None = None      # required for every view except 'links'
    label: str | None = None
    links: list[Link] | None = None  # required for view='links', forbidden otherwise
    renderer: str | None = None      # required for and exclusive to view='custom'


class Detail(BaseModel):
    title: str | None = None
    elements: list[Element]

A worked example — a build row whose detail panel shows the job name, its labels, its steps as a sub-table, and a link out to the Jenkins job:

class BuildStepRow(BaseModel):
    step_name: str
    status: str


class BuildRow(BaseModel):
    build_id: str
    job_name: str
    duration_ms: int
    outcome: str
    labels: dict[str, str]
    steps: list[BuildStepRow]


class MyProbeDetails(BaseModel):
    builds: list[BuildRow] = probe_field(
        section='Enrichment',
        view='table',
        default_factory=list,
        columns={
            'build_id': Column(link_template='{jenkins}/job/{row.job_name}/{value}', hint='Open build {value}'),
            'duration_ms': Column(format='duration_ms'),
            'outcome': Column(badge_colors={'passed': 'green', 'failed': 'red', '*': 'gray'}),
        },
        detail=Detail(
            title='Build {row.build_id}',
            elements=[
                Element(field='job_name', view='string'),
                Element(field='labels', view='keyvalue'),
                Element(field='steps', view='table'),
                Element(view='links', links=[Link(label='Jenkins job', template='{jenkins}/job/{row.job_name}')]),
            ],
        ),
    )

Clicking any row in the Builds table opens a panel titled with that row’s build_id, showing its job name as text, its labels as a flat key/value list, its steps as a sub-table, and a link to the Jenkins job page for that build.

Rules, enforced at startup:

  • field names a field of the row model — checked against the row model’s actual fields, the same way columns subset names are.
  • view='links' elements carry links and no field; every other view carries field and no links.
  • title uses the same template grammar as everything else, with {row.<field>} resolved from the clicked row.
  • link_template and detail are mutually exclusive on the same field — a string-view field can have a link, and a row-bearing field can have a detail panel, but nothing has both.

Custom cell renderers

Links, badges, and formats cover the common cases; a custom renderer is the escape hatch for presentation none of them can express — a mini chart, a composite card, a copy-to-clipboard chip. Instead of a built-in transform, renderer names a JavaScript function from a deployment-owned module that builds the cell’s DOM directly.

Config: ui.custom_renderers_path

ui:
  custom_renderers_path: /etc/drakkar/renderers.js

The file must exist at boot — a missing or unreadable path is a startup error, the same fail-fast posture as every other UI config mistake. Leave it at the default '' to keep the feature off: GET /api/v1/identity reports custom_renderers: false, and the UI never fetches or imports anything.

The module contract

The module’s default export is a plain object mapping renderer names to functions:

function orderCard(value, row, cell) {
  const el = document.createElement('div');
  el.className = 'order-card';
  el.textContent = `${row.order_id}${value}`;
  return el;
}

export default {
  orderCard,
  // ... more renderers
};

Each function has the signature (value, row, cell) => HTMLElement:

  • value — the cell’s raw decoded value, the same value the built-in link/badge/format path would have received.
  • row — the full row object for a table/tables/tree column, or the clicked row for a detail-panel element; undefined only for a scalar field (there is no sibling row to hand over).
  • cell — a small context object, {key}: the column key, the field name, or the detail element’s field, whichever declared the renderer.
  • The function must return a real HTMLElement synchronously. A string, a Promise, or undefined is treated as a failure — see Fallback below.

A renderer name is a plain identifier: ^[a-zA-Z_][a-zA-Z0-9_]*$.

Where How
probe_field(view='custom', renderer='name') Any field type — the renderer receives the raw JSON value, whatever shape it is. link_template, badge_colors, and format are forbidden alongside it.
Column(renderer='name') A table/tables/tree column. Column has no view field, so a column opts in with bare renderer= rather than a view='custom' concept — same authoring power as the scalar and detail-element forms. Exclusive with link_template, badge_colors, and format on that column.
Element(view='custom', field=..., renderer='name') A detail-panel element.
render_payload: dict = probe_field(
    section='Enrichment', view='custom', renderer='orderCard', default_factory=dict,
)
columns={'build_id': Column(renderer='buildChip', hint='Open build {value}')}
Element(view='custom', field='render_payload', renderer='orderCard')

hint is not in the exclusion list

A Column (or top-level field) may declare renderer and hint together — hint’s tooltip still resolves and lands on the cell, next to whatever the renderer mounts inside it. link_template, badge_colors, and format are the only options renderer excludes.

Page table columns (Declared UI Pages) accept renderer too — they reuse this same Column.

Fallback: never blank, never a crash

A custom renderer runs deployment code the framework doesn’t control, so every way it can go wrong degrades to the cell’s normal default content plus one console.warn — never a blank cell, never a broken page:

  1. The module 404s, or the fetch/dynamic-import fails (network error, syntax error in the module).
  2. The module’s default export is not a plain object.
  3. No entry exists under the declared renderer name.
  4. The named entry is not a function.
  5. The function throws.
  6. The function returns something other than an HTMLElement.

Serving and caching

GET /api/v1/ui/renderers.js serves the configured module byte-for-byte, same-origin, Content-Type: text/javascript, behind the same auth as every other /api/v1/* route (Authorization: Bearer <token> or ?token=<token> — the dynamic import() the UI uses to load it can’t carry a header, so the token rides as a query parameter). Cached with a content-hash ETag; a matching If-None-Match gets a 304. The route is always registered, on or off — 404 with {"enabled": false, "reason": "..."} in the body when ui.custom_renderers_path is unset.

The UI loads the module once at boot, via a dynamic import(), only when GET /api/v1/identity reports custom_renderers: true.

Trust model

The module is deployment-owned code, not content Drakkar generates — it runs with the same trust as the rest of your ui.* config (link templates, badge colors) and with full DOM access to whatever element it returns. Ship it with your worker the same way you ship its YAML config file. Serving it same-origin is deliberate: configuring ui.custom_renderers_path means choosing to run your own JS in the debug UI’s origin, the same way a link template already chooses the URL it points to.


Validation: what fails at boot vs what degrades at render

The same two-tier error model as the rest of probe-details applies here, with one addition specific to enrichment:

When Example What happens
Startup badge_colors on a non-badge view, an unknown badge color name, a malformed template, columns naming a field the row model doesn’t have, a detail element’s field not on the row model, link_template and detail both set ProbeDetailsConfigError — the app never starts.
Startup A duplicate column name in columns=[...] ProbeDetailsConfigError — the app never starts.
Startup An empty badge_colors={} on a Column ProbeDetailsConfigError — the app never starts.
Startup An empty elements=[] on a Detail ProbeDetailsConfigError — the app never starts.
Startup renderer set outside view='custom', view='custom' without renderer, an invalid renderer name, or renderer combined with link_template/badge_colors/format on the same field or Column ProbeDetailsConfigError — the app never starts.
Startup ui.custom_renderers_path set to a path that doesn’t exist or isn’t a file The app never starts.
Startup (warning only) A template references a base ({jenkins}) that ui.link_bases doesn’t configure One probe_details_link_bases_missing warning naming every missing base; the app still starts.
Render time The warned-about missing base The UI renders plain text instead of a broken link — never a dead anchor.
Render time A badge value with no matching color and no '*' fallback The UI renders a neutral (uncolored) pill instead of a colored one — never plain text or a blank/broken pill.
Render time Any of the custom-renderer failure modes The cell falls back to its plain-text default plus one console.warn — never a blank cell or a crashed page.

See also

  • Probe User Details — registering the model these options attach to, the six view kinds, and the probe.set / probe.append / probe.update fill API
  • Declared UI Pages — a table widget’s columns reuses this same Column verbatim, addressed by a declared page instead of a probe run
  • Configuration — the full ui.* config section, including ui.link_bases and ui.custom_renderers_path
  • Annotations — the message/task/window-scoped sibling of probe details