Dashboard widgets API
A dashboard holds a grid of widgets. Each widget has a server-assigned ULID, a type from a fixed catalog, the configuration fields of that type, a width, a height (every type but a section title), a position in the grid, and, for every type except a section title, a project scope.
Everything on this page follows the dashboard’s rules: the plan gate (403 forbidden without a plan that includes dashboards), 404 not_found for a dashboard you cannot see, and 403 forbidden for a change by someone who is not the dashboard’s creator or an organization owner or admin. Anyone who can see a dashboard can list its widgets.
Catalog
Section titled “Catalog”type | Configuration fields | Default width | Default height |
|---|---|---|---|
section_title | None. title is required: the widget is a heading that splits the grid | full | None |
count | metric: test_cases, open_defects, active_runs, suites, flaky_cases | third | 2 |
created_count | metric: test_cases, defects, results, runs; rangeDays | third | 2 |
distribution | customFieldUlid: a test case custom field; chart: donut (default) or bar | half | 4 |
time_series | metric: open_defects, result_events, case_events, run_pass_rate, run_average_duration; rangeDays; chart: auto (default) or heatmap, for result_events and case_events only, over 365 days at most | two_thirds | 4 |
pass_rate | rangeDays; chart: gauge (default) or bullet; target: a whole number from 1 to 100 (default 80), which the bullet is measured against | third | 3 |
query | savedQueryUlid: the saved query it draws, read live; render: table, bar, line, donut or value (default null, the query’s own visualization); no filters | half | 4 |
rangeDaysis one of7,14,30,90,180,365, or730(default30), counted back from the day the widget is viewed. Each widget keeps its own range, and everyone who views the dashboard sees the same one. Aheatmaptakes365days at most.- A
distributiongroups test cases by a custom field of typeselect_single,radio,checkbox,select_multi, oruser_picker, system or custom. An unknown field returns404 not_found; a field of another type, or a defect or run field, returns422 validation_failed. widthisthird,half,two_thirds, orfull.heightis the number of grid rows the widget spans, an integer from2to8. Asection_titlehas no height and refuses one.titleis up to 200 characters. On every type butsection_titleit is optional:nullshows the type’s default title.
Project scope
Section titled “Project scope”projects sets which projects a widget counts:
{ "mode": "all" }(the default): every project the viewer can open.{ "mode": "selected", "projectUlids": [...] }: 1 to 50 listed projects, without repeats. On a change, the list may be empty (see below).
What a widget counts always depends on who views it. A member who cannot open a selected project (a private project they have no access to, a locked project, or an archived one) does not see that project in projects.projectUlids; projects.hiddenProjectCount tells them how many selected projects are hidden from them. Their ULIDs are never returned.
When you add or change a widget, every project you list must be one you can open; otherwise 404 not_found names the ULID and nothing is written. Changing a selected scope replaces only the projects you can open: selected projects hidden from you stay as they are. So you may send an empty projectUlids to deselect every project you can open while hidden ones remain; a selected scope that would be left with no project at all returns 422 validation_failed. Changing the scope to all removes every selected project, hidden ones included. When a project is deleted, it leaves every widget that selected it.
Filters
Section titled “Filters”Every type but section_title can hold filters, which narrow its number for everyone who views the dashboard. Only the dashboard’s editors change them.
"filters": [ { "field": "assignee", "values": ["01J9Z3Q4M8R2T6V1W5X7Y9A0C2", "01J9Z3Q4M8R2T6V1W5X7Y9A0C3"] }, { "field": "custom_field", "customFieldUlid": "01J9Z3Q4M8R2T6V1W5X7Y9A0BF", "values": ["01J9Z3Q4M8R2T6V1W5X7Y9A0C0"] }]- At most 10 filters, one per
field(one per custom field), each with 1 to 50 values, without repeats. - Values of one filter are OR’ed; different filters are AND’ed. The example counts defects assigned to either person and holding that option.
The fields a widget offers depend on what its metric counts:
| Counts | Widgets | field |
|---|---|---|
| Test cases | count test_cases and flaky_cases, created_count test_cases, time_series case_events, distribution | author, suite, tag, milestone, custom_field |
| Defects | count and time_series open_defects, created_count defects | reporter, assignee, milestone, tag, custom_field; status on created_count only |
| Runs | count active_runs, created_count runs, time_series run_pass_rate and run_average_duration, pass_rate | author, environment, milestone, test_plan, tag, custom_field |
| Results | created_count results, time_series result_events | status, executed_by, environment, milestone |
A count suites widget takes no filters. A result’s environment and milestone are those of its run. A suite filter also matches the cases of the suites below it.
What each value is:
author,reporter,assignee,executed_by: member ULIDs.suite,tag,milestone,environment,test_plan: their ULIDs. Suites, milestones, environments, and test plans must be in projects you can open.status:open,in_progress,resolved, orclosedfor defects;passed,failed,skipped, orblockedfor results.custom_field:customFieldUlidnames a field of the counted entity (test case, defect, or run) of typeselect_single,radio,checkbox,select_multi, oruser_picker. Values are option ULIDs, member ULIDs foruser_picker, or"true"/"false"forcheckbox."false"matches only items that store an unchecked value.
A filter the metric does not offer, or a value that names nothing you can use (an unknown member, tag, field, or option, or a suite, milestone, environment, or test plan in a project you cannot open), returns 422 validation_failed; details gives the path of each refused value, and nothing is written. If a filtered entity is deleted later, its value stays on the widget, with a null label, and matches nothing until an editor removes it.
Filters are stored, and returned, in a fixed order with their values sorted, so sending the same filters in another order changes nothing.
/api/v1/dashboards/{dashboardUlid}/widgets200 with { "items": [...] }, every widget by position. A dashboard holds at most 50 widgets, so the list is not paged.
/api/v1/dashboards/{dashboardUlid}/widgetsAdds a widget after the last one. The body takes type, the type’s configuration fields, and optionally title, width, height, projects, and filters (none of the last three on a section_title). The body is strict: a field the type does not have returns 422 validation_failed. A dashboard that already holds 50 widgets returns 409 conflict.
201 with the widget. See Response fields.
curl -sS -X POST \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"type":"time_series","metric":"run_pass_rate","rangeDays":90,"projects":{"mode":"selected","projectUlids":["01J9Z3Q4M8R2T6V1W5X7Y9A0BC"]}}' \ "https://app.probara.net/api/v1/dashboards/$DASHBOARD_ULID/widgets"Change
Section titled “Change”/api/v1/dashboards/{dashboardUlid}/widgets/{widgetUlid}Send at least one of title, width, height, projects, metric, rangeDays, customFieldUlid, chart, target, or filters. A widget’s type never changes: remove it and add another. A field the widget’s type does not have, or a value the type does not accept (such as a count metric on a time_series), returns 422 validation_failed. A section title keeps its title (null is refused) and takes no height or projects. A body that changes nothing returns the widget unchanged.
filters replaces the whole list; [] removes every filter. Values the widget already stores are kept even if their entity was deleted; new values are checked. Changing metric while the widget holds a filter the new metric does not offer returns 422 validation_failed, unless the same body sends filters.
200 with the widget.
Reorder
Section titled “Reorder”/api/v1/dashboards/{dashboardUlid}/widgets/orderSend { "widgetUlids": [...] } with every widget of the dashboard exactly once, in the new order; anything else returns 422 validation_failed. 200 with the widgets in their new order.
Remove
Section titled “Remove”/api/v1/dashboards/{dashboardUlid}/widgets/{widgetUlid}204. The widgets after it move up one position. Deleting a dashboard removes its widgets.
Query widgets
Section titled “Query widgets”A query widget draws a saved query live, so an edit of the query reaches the widget. Create one with "type":"query", savedQueryUlid (a saved query you can see) and, optionally, render to override the query’s own visualization (null, the default, keeps it). It takes width, height and projects like any data widget, and no filters: the query’s own text decides its rows. A change may repoint it at another query (savedQueryUlid) or change its render. A widget whose saved query was deleted keeps its place with savedQueryUlid set to null; an editor removes it.
Its data is the saved query run with your own project access and narrowed to the widget’s projects: savedQueryUlid, savedQueryName, queryDeleted, canOpenQuery (whether you may open the query, which is when a link to it applies), render (the drawing in use), scope (the counts every data widget reports) and result, the first page of the query’s answer in the shape the query endpoint answers: rows, or groups when the query aggregates. A viewer who can see the dashboard sees that page even when the query is private to someone else, without its text. A deleted query answers queryDeleted: true with savedQueryUlid, savedQueryName, render and result all null.
The records of a query widget are not listed here: its rows are read on its saved query, so the records endpoint answers 422 validation_failed.
Widget data
Section titled “Widget data”/api/v1/dashboards/{dashboardUlid}/widgets/{widgetUlid}/dataReturns what a widget shows. Anyone who can see the dashboard can read it, and the numbers are always computed for you: they cover only the projects of the widget’s scope that you can open. Organization owners and admins open every project that is not locked. Locked and archived projects are never counted.
{ "type": "created_count", "widgetUlid": "01J9Z3Q4M8R2T6V1W5X7Y9A0BD", "metric": "defects", "scope": { "includedProjectCount": 3, "hiddenProjectCount": 1, "emptySelection": false }, "range": { "requestedDays": 365, "effectiveDays": 90, "from": 1751976000000, "to": 1759752000000, "clampedByRetention": true }, "data": { "value": 42 }}scope tells you whether the number is complete:
includedProjectCount: how many projects were counted.hiddenProjectCount: how many were left out. For aselectedwidget, the selected projects you cannot open (the same number asprojects.hiddenProjectCounton the widget). For anallwidget, the organization’s projects that are not archived and that you cannot open, locked ones included. When it is above 0, the value is partial: show it as such, not as the total.emptySelection:truefor aselectedwidget whose projects have all been deleted. Its value is0.
What each metric counts:
type | metric | data.value |
|---|---|---|
count | test_cases | Test cases that are not archived |
count | open_defects | Defects whose status is open or in_progress |
count | active_runs | Test runs that are not closed |
count | suites | Suites |
count | flaky_cases | Test cases that are not archived and have Is flaky checked |
created_count | test_cases | Test cases created in the range, including ones archived since |
created_count | defects | Defects created in the range |
created_count | results | Results recorded in the range, by execution time |
created_count | runs | Test runs started in the range |
A created_count widget also returns range, the window it counted: from from (included) to to (excluded), in epoch milliseconds, ending at the moment of the request. The window is the widget’s rangeDays, cut to your plan’s history retention: older closed runs are deleted, so a longer window would undercount. effectiveDays is the length actually counted, and clampedByRetention is true when the retention cut it.
Distribution
Section titled “Distribution”A distribution widget counts the test cases of the scope that are not archived and belong to a project where its field applies, grouped by the value they hold for the field.
{ "type": "distribution", "widgetUlid": "01J9Z3Q4M8R2T6V1W5X7Y9A0BE", "customFieldUlid": "01J9Z3Q4M8R2T6V1W5X7Y9A0BF", "scope": { "includedProjectCount": 2, "hiddenProjectCount": 0, "emptySelection": false }, "data": { "fieldRemoved": false, "field": { "ulid": "01J9Z3Q4M8R2T6V1W5X7Y9A0BF", "title": "Priority", "type": "select_single" }, "multiValue": false, "totalCases": 120, "buckets": [ { "kind": "option", "key": "01J9Z3Q4M8R2T6V1W5X7Y9A0C0", "label": "High", "color": "#a890e8", "count": 30 }, { "kind": "option", "key": "01J9Z3Q4M8R2T6V1W5X7Y9A0C1", "label": "Low", "color": "#a89fb2", "count": 75 }, { "kind": "not_set", "key": null, "count": 15 } ] }}Each bucket has a kind:
kind | key | Notes |
|---|---|---|
option | Option ULID | Select and radio fields. Every option is listed in the field’s order, empty ones included. label is the name in the request’s language |
checkbox | true/false | Checked and unchecked |
user | User ULID | User fields, largest first. formerMember is true once the person left the organization; label is null when the organization no longer knows them |
other | other | Past 20 buckets, the smallest are folded into this one; groupedCount says how many |
not_set | null | Always last: cases without a value, or whose value names an option that was deleted |
totalCases is how many cases were counted. On a multi-select field, multiValue is true: a case counts once in each option it holds, so the buckets can add up to more than totalCases.
When the field was deleted after the widget was added, the response is still 200, with data set to { "fieldRemoved": true, "buckets": [] }.
Time series
Section titled “Time series”A pass_rate widget returns range and one data object. value is the pooled pass rate of the runs closed in the range, as a percentage with one decimal: passed over executed, the cases passed, failed, blocked or skipped. Aborted runs are left out, and value is null when nothing was executed. closedRuns counts the runs it pools, and its records list the same runs:
{ "type": "pass_rate", "widgetUlid": "01J9Z3Q4M8R2T6V1W5X7Y9A0BC", "scope": { "includedProjectCount": 2, "hiddenProjectCount": 0, "emptySelection": false }, "range": { "requestedDays": 30, "effectiveDays": 30, "from": 1756742400000, "to": 1759334400000, "clampedByRetention": false }, "data": { "value": 83.3, "passed": 10, "executed": 12, "closedRuns": 2 }}A time_series widget returns range, cut to your plan’s retention exactly like a created_count widget, split into buckets:
{ "type": "time_series", "widgetUlid": "01J9Z3Q4M8R2T6V1W5X7Y9A0BG", "metric": "run_pass_rate", "scope": { "includedProjectCount": 2, "hiddenProjectCount": 0, "emptySelection": false }, "range": { "requestedDays": 7, "effectiveDays": 7, "from": 1759147200000, "to": 1759752000000, "clampedByRetention": false }, "data": { "granularity": "day", "timeZone": "America/Argentina/Buenos_Aires", "buckets": [ { "start": 1759147200000, "end": 1759201200000 }, { "start": 1759201200000, "end": 1759287600000 } ], "series": [{ "key": "passRate", "values": [null, 83.3] }] }}(Shortened: a 7-day range has 8 buckets.)
granularityisdayfor ranges up to 31 days,weekup to 186 days (weeks start on Monday), andmonthbeyond. Aheatmapis alwaysday.- Bucket boundaries fall on midnight in
timeZone: the time zone in your profile, orUTCwhen you have not chosen one. Two people can see different boundaries on the same widget. Across a daylight saving change, a bucket can be an hour longer or shorter. bucketsare contiguous[start, end)windows in epoch milliseconds; the first starts atrange.fromand the last ends atrange.to, so both can be partial.seriesholds one value per bucket, in bucket order, for each series of the metric:
metric | Series | Value per bucket |
|---|---|---|
open_defects | open | Defects open (open or in_progress) at the end of the bucket. Deleted defects never count |
result_events | passed, failed, skipped, blocked | Results recorded in the bucket with that status, by execution time |
case_events | created, edited | Test cases created, and edits saved: a save, an import or a restore. Changes made through a shared step are not counted as edits |
run_pass_rate | passRate | Of the runs closed in the bucket (aborted runs left out), passed cases ÷ executed cases, as a percentage with one decimal. null when nothing was executed |
run_average_duration | averageDurationMs | Average time from start to close of the runs closed in the bucket (aborted runs left out; a run closed before it started counts as 0). null when no run closed |
The pass rate adds up the cases of every run in the bucket before dividing: a run with 100 executed cases weighs more than a run with 2. Executed cases are passed, failed, blocked, and skipped ones; untested cases and cases waiting to be retested are not.
Errors
Section titled “Errors”A section_title has no data and returns 422 validation_failed. An unknown widget, or a dashboard you cannot see, returns 404 not_found.
Response fields
Section titled “Response fields”| Field | Type | Notes |
|---|---|---|
ulid | string | The widget’s ULID |
type | string | See Catalog |
title | string | null | Always a string on a section_title |
width | string | third, half, two_thirds, or full |
height | number | Grid rows, 2 to 8; absent on a section_title |
position | number | 0-based place in the grid |
| configuration fields | — | Those of the widget’s type, for example metric and rangeDays |
projects | object | null | null on a section_title |
projects.mode | string | all or selected |
projects.projectUlids | string[] | The selected projects you can open; empty for all |
projects.hiddenProjectCount | number | Selected projects you cannot open |
filters | object[] | Absent on a section_title. See Filters |
filters[].values[].label | string | null | See below |
createdAt, updatedAt | number | Epoch milliseconds; reordering does not move updatedAt |
Each filter in a response repeats field (and customFieldUlid, plus customFieldTitle and customFieldType, both null once the field is deleted), and lists its values as { "value", "label" }. label is the member’s name, or the name of the suite, tag, milestone, environment, test plan, or option; custom field titles and option names follow the request’s language. It is null for a status or a checkbox state, for a deleted entity, and for an entity in a project you cannot open.
Every change to a widget also moves the dashboard’s updatedAt.
Widget records
Section titled “Widget records”/api/v1/dashboards/{dashboardUlid}/widgets/{widgetUlid}/recordsLists the records behind one value of a widget, one page at a time: the test cases, defects, runs, results or suites that the number counts. It follows the same rules as the widget’s data. Anyone who can see the dashboard can read it, and only the projects of the widget’s scope that you can open are listed. The total is the value the widget’s data shows for the same selector. For run_pass_rate and run_average_duration, total is the number of runs behind the point, not the plotted pass rate or average.
Choose the value with the selector the widget takes:
| Widget | Selector | Lists |
|---|---|---|
count, created_count | none: the whole value | the records the metric counts |
distribution | bucket: a bucket key from data.buckets (an option or user ULID, true, false, not_set or other) | the test cases of that bucket |
time_series | bucketStart: a bucket start from data.buckets, and series where the metric has several (result_events: passed, failed, skipped or blocked; case_events: created or edited) | the records of that bucket and that series |
A time_series metric with one series takes its own series name, optionally: open_defects takes open, run_pass_rate takes passRate, and run_average_duration takes averageDurationMs. The series is checked against the metric and changes nothing; any other series returns 422 validation_failed. On a heatmap widget, bucketStart names one local day, and series follows the same rules. A cell counts every series together, so the records of one series are only part of that day’s count; the web app does not open a cell’s records.
GET /api/v1/dashboards/01J9Z3Q4M8R2T6V1W5X7Y9A0BD/widgets/01J9Z3Q4M8R2T6V1W5X7Y9A0BG/records?bucket=not_set&page=1&pageSize=50{ "kind": "test_case", "total": 2, "page": 1, "pageSize": 50, "items": [ { "kind": "test_case", "projectUlid": "01J9Z3Q4M8R2T6V1W5X7Y9A0BK", "projectCode": "PAY", "projectName": "Payments", "ulid": "01J9Z3Q4M8R2T6V1W5X7Y9A0BM", "displayId": "PAY-41", "title": "Refund a card", "suiteName": "Checkout", "archived": false, "createdAt": 1759600000000, "revision": null } ]}kindis the kind of every item:test_case,defect,run,resultorsuite.- Each item carries what a row needs to show and link: its project (
projectUlid,projectCode,projectName), itsulidand itsdisplayId. A test case’s display ID is its project code and number (PAY-41), a defect’s isD-and its number, a run’s isR-and its number, and a suite’s is its project code,Sand its number (PAY-S4). - Items are newest first by the timestamp the metric uses, ties broken by ID. Pages are 1-based;
pageSizeis 50 by default and at most 200.
The fields that follow the kind:
kind | Fields |
|---|---|
test_case | title, suiteName, archived, createdAt, and revision (number, source, savedAt) on a case_events edited record, where it is null otherwise |
defect | title, status (the status now: a defect open in the past may have closed since), createdAt, closedAt |
run | name, state, status, startedAt, closedAt, passed, executed, passRate (null when nothing was executed), durationMs (null while the run is open) |
result | status, executedAt, executedByName (null when the organization no longer knows the executor), runUlid, runDisplayId, runCaseUlid (null once the case has left the run), testCaseUlid, testCaseDisplayId, testCaseTitle (the title when it was executed; the case fields are null once the case is deleted) |
suite | name, createdAt |
A pass rate is passed added up over the items, divided by executed added up over the items, as the series computes it. An average duration is the mean of durationMs over the items.
A bucketStart names the bucket of the local day, week or month it falls in. The first bucket starts at the start of the range, which moves with time, so a start taken from an earlier response still finds it. In a select_multi distribution, a case that holds two options of other is one record there, as it is in the bucket’s count.
Errors:
422 validation_failedwhen the selector does not fit the widget (abucketon acount, or a time series withoutbucketStart), and for asection_title, which has no records.404 not_foundwhen the bucket is not one the widget shows (an option folded intoother, or abucketStartbefore the range), and for a widget or dashboard you cannot see.403 forbiddenwhen the plan does not include dashboards.
Read-only: nothing is audited.
Audit events
Section titled “Audit events”Each change records an event on the dashboard (entity type dashboard, labelled with its name) with the widget’s widgetUlid and type in its metadata: dashboard.widget_added, dashboard.widget_updated (plus changedFields, the names of the fields that changed, such as title, width, height, projects, rangeDays, or filters), and dashboard.widget_removed. A reorder records dashboard.widgets_reordered with widgetUlids in their new order. A change that changes nothing records nothing. See Audit events.
The same operations are available as the MCP tools list_dashboard_widgets, create_dashboard_widget, update_dashboard_widget, reorder_dashboard_widgets, delete_dashboard_widget, get_dashboard_widget_data, and list_dashboard_widget_records, with the same rules. See the MCP tool reference.