Appearance
Add a KPI or aggregation
What you're doing
Putting a number on a page — "12 total", "8 available" — computed server-side. The building block is a Data Service with operation: "count". A page's mount loader calls it and drops the result into page state; a core.kpi-card displays it.
For a row of several KPIs you wrap the counts in one composite Data Service so the page makes one call, not five.
The complete example
One count per metric
spk-assembly/metadata/data_service/oeq-equipment-count-total.json:
json
{
"name": "oeq-equipment-count-total",
"description": "Total equipment.",
"definition": {
"operation": "count",
"source": { "kind": "entity", "entityName": "oeq_equipment" },
"filters": [],
"parameters": []
},
"metadata": {}, "modules": []
}oeq-equipment-count-available.json is the same with a filter:
json
"filters": [ { "field": "status", "operator": "eq", "value": "AVAILABLE" } ]
filterson acountis a list of{ field, operator, value }. Operators:eq,neq,gt,gte,lt,lte,contains. For "no groupBy, just a filtered number" this is the whole recipe.
The composite wrapper
oeq-equipment-kpis.json:
json
{
"name": "oeq-equipment-kpis",
"description": "Composite KPI fan-out for the Equipment Register page.",
"definition": {
"operation": "composite",
"source": { "kind": "entity", "entityName": "oeq_equipment" },
"steps": [
{ "as": "total", "service": "oeq-equipment-count-total", "parameters": {} },
{ "as": "available", "service": "oeq-equipment-count-available", "parameters": {} },
{ "as": "assigned", "service": "oeq-equipment-count-assigned", "parameters": {} }
]
},
"metadata": {}, "modules": []
}The page mount loader
json
"events": { "mounted": { "source": "action-chain", "actions": [
{ "id": "a0", "order": 0, "type": "callApi",
"config": { "connectionRef": "self", "path": "/api/v1/data-services/oeq-equipment-kpis/execute", "httpMethod": "POST", "params": {} },
"output": "kpi" },
{ "id": "a1", "order": 1, "type": "setValue", "config": { "field": "page.kpiTotal", "value": "${kpi.results.total.value}" } },
{ "id": "a2", "order": 2, "type": "setValue", "config": { "field": "page.kpiAvailable", "value": "${kpi.results.available.value}" } },
{ "id": "a3", "order": 3, "type": "setValue", "config": { "field": "page.kpiAssigned", "value": "${kpi.results.assigned.value}" } }
] } }The KPI card
json
{
"blockType": "core.kpi-card",
"properties": {
"value": { "source": "binding", "binding": { "scope": "page", "key": "kpiTotal" } },
"labelKey": { "source": "static", "value": "office-equipment.equipment-register.kpi.total" },
"format": { "source": "static", "value": "number" },
"colorToken": { "source": "static", "value": "primary" },
"icon": { "source": "static", "value": "devices" }
}
}Line by line — the two traps
The
outputName: "value"/ no-groupBy pattern. Acountreturns exactly{ "value": <number> }. You do not get.total(that's the pagination envelope of the entity list endpoint, unrelated). Acompositenests each step's envelope underresults.<step.as>, so a composite count step is${out.results.<as>.value}.you called you bind a bare countservice${out.value}a compositeof counts${out.results.<step>.value}Call once on mount, store in page state. The KPI card binds a page-state key, not the API directly. The mount loader is a hidden
core.container; see Build a page.
Recomputing after a write
Add the same callApi + setValue steps to the end of your Save action chain, so creating a row updates the counts without a page reload.
How to verify it worked
bash
erp api post "/api/v1/data-services/oeq-equipment-kpis/execute" --body "{}"Actual response from the tutorial module right after install (no rows yet):
json
{
"value": null, "rows": null, "total": null, "record": null,
"results": {
"total": { "value": 0 },
"available": { "value": 0 },
"assigned": { "value": 0 }
}
}The top-level value/rows/total are always-present nulls for a composite — ignore them, read results.
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
card shows undefined | bound ${out.value} on a composite | use ${out.results.<step>.value} |
card shows 0 when there is data | count filter value doesn't match the enum casing | check typeParams.enumValues |
| every KPI is one HTTP call | separate callApi per count on mount | wrap them in one composite |
| card never updates after a save | KPI steps not appended to the Save chain | append callApi + setValue there too |
What to read next
- Add a data provider, data view, or data service
- Add a cross-row aggregation job — for rollups stored back into a table
- Recipe: KPI dashboard page