# Dashboard widgets API

> List, add, change, reorder, and remove the widgets of a dashboard, and read what each widget shows, over the integrator API.

A [dashboard](https://docs.probara.net/en/developers/api/dashboards/) 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

| `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`              |

- `rangeDays` is one of `7`, `14`, `30`, `90`, `180`, `365`, or `730` (default `30`), 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. A `heatmap` takes `365` days at most.
- A `distribution` groups test cases by a custom field of type `select_single`, `radio`, `checkbox`, `select_multi`, or `user_picker`, system or custom. An unknown field returns `404 not_found`; a field of another type, or a defect or run field, returns `422 validation_failed`.
- `width` is `third`, `half`, `two_thirds`, or `full`.
- `height` is the number of grid rows the widget spans, an integer from `2` to `8`. A `section_title` has no height and refuses one.
- `title` is up to 200 characters. On every type but `section_title` it is optional: `null` shows the type's default title.

## 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

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.

```json
"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`, or `closed` for defects; `passed`, `failed`, `skipped`, or `blocked` for results.
- `custom_field`: `customFieldUlid` names a field of the counted entity (test case, defect, or run) of type `select_single`, `radio`, `checkbox`, `select_multi`, or `user_picker`. Values are option ULIDs, member ULIDs for `user_picker`, or `"true"` / `"false"` for `checkbox`. `"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.

## List

`GET /api/v1/dashboards/{dashboardUlid}/widgets`

`200` with `{ "items": [...] }`, every widget by `position`. A dashboard holds at most 50 widgets, so the list is not paged.

## Add

`POST /api/v1/dashboards/{dashboardUlid}/widgets`

Adds 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](#response-fields).

```bash
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

`PATCH /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

`PUT /api/v1/dashboards/{dashboardUlid}/widgets/order`

Send `{ "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

`DELETE /api/v1/dashboards/{dashboardUlid}/widgets/{widgetUlid}`

`204`. The widgets after it move up one position. Deleting a dashboard removes its widgets.

## 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](https://docs.probara.net/en/developers/api/pql/) 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

`GET /api/v1/dashboards/{dashboardUlid}/widgets/{widgetUlid}/data`

Returns 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.

```json
{
  "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 a `selected` widget, the selected projects you cannot open (the same number as `projects.hiddenProjectCount` on the widget). For an `all` widget, 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`: `true` for a `selected` widget whose projects have all been deleted. Its value is `0`.

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

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.

```json
{
  "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

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:

```json
{
  "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:

```json
{
  "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.)

- `granularity` is `day` for ranges up to 31 days, `week` up to 186 days (weeks start on Monday), and `month` beyond. A `heatmap` is always `day`.
- Bucket boundaries fall on midnight in `timeZone`: the time zone in **your** profile, or `UTC` when 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.
- `buckets` are contiguous `[start, end)` windows in epoch milliseconds; the first starts at `range.from` and the last ends at `range.to`, so both can be partial.
- `series` holds 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

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

| Field                         | Type           | Notes                                                            |
| ----------------------------- | -------------- | ---------------------------------------------------------------- |
| `ulid`                        | string         | The widget's ULID                                                |
| `type`                        | string         | See [Catalog](#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)             |
| `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

`GET /api/v1/dashboards/{dashboardUlid}/widgets/{widgetUlid}/records`

Lists 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
```

```json
{
  "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
    }
  ]
}
```

- `kind` is the kind of every item: `test_case`, `defect`, `run`, `result` or `suite`.
- Each item carries what a row needs to show and link: its project (`projectUlid`, `projectCode`, `projectName`), its `ulid` and its `displayId`. A test case's display ID is its project code and number (`PAY-41`), a defect's is `D-` and its number, a run's is `R-` and its number, and a suite's is its project code, `S` and its number (`PAY-S4`).
- Items are newest first by the timestamp the metric uses, ties broken by ID. Pages are 1-based; `pageSize` is 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_failed` when the selector does not fit the widget (a `bucket` on a `count`, or a time series without `bucketStart`), and for a `section_title`, which has no records.
- `404 not_found` when the bucket is not one the widget shows (an option folded into `other`, or a `bucketStart` before the range), and for a widget or dashboard you cannot see.
- `403 forbidden` when the plan does not include dashboards.

Read-only: nothing is audited.

## 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](https://docs.probara.net/en/developers/api/audit-events/).

## MCP

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](https://docs.probara.net/en/developers/mcp-tools/).
