@adminforth/dashboard 1.11.2 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +43 -2
  2. package/custom/api/dashboardApi.ts +72 -10
  3. package/custom/model/dashboard.types.ts +6 -0
  4. package/custom/runtime/DashboardGroup.vue +15 -68
  5. package/custom/runtime/DashboardToolbarButton.vue +32 -0
  6. package/custom/runtime/DashboardToolbarIcon.vue +52 -0
  7. package/custom/runtime/WidgetShell.vue +15 -68
  8. package/custom/skills/adminforth-dashboard/SKILL.md +63 -13
  9. package/custom/widgets/chart/StackedBarChart.vue +64 -7
  10. package/dist/custom/api/dashboardApi.js +59 -10
  11. package/dist/custom/api/dashboardApi.ts +72 -10
  12. package/dist/custom/model/dashboard.types.d.ts +9 -0
  13. package/dist/custom/model/dashboard.types.ts +6 -0
  14. package/dist/custom/queries/useDashboardConfig.d.ts +80 -0
  15. package/dist/custom/queries/useWidgetData.d.ts +80 -0
  16. package/dist/custom/runtime/DashboardGroup.vue +15 -68
  17. package/dist/custom/runtime/DashboardToolbarButton.vue +32 -0
  18. package/dist/custom/runtime/DashboardToolbarIcon.vue +52 -0
  19. package/dist/custom/runtime/WidgetShell.vue +15 -68
  20. package/dist/custom/skills/adminforth-dashboard/SKILL.md +63 -13
  21. package/dist/custom/widgets/chart/StackedBarChart.vue +64 -7
  22. package/dist/schema/api.d.ts +742 -802
  23. package/dist/schema/api.js +2 -2
  24. package/dist/schema/widget.d.ts +75 -81
  25. package/dist/schema/widget.js +1 -1
  26. package/dist/schema/widgets/charts.d.ts +84 -160
  27. package/dist/schema/widgets/charts.js +2 -2
  28. package/dist/schema/widgets/common.d.ts +115 -0
  29. package/dist/schema/widgets/common.js +17 -1
  30. package/dist/schema/widgets/gauge-card.d.ts +8 -0
  31. package/dist/schema/widgets/kpi-card.d.ts +8 -0
  32. package/dist/schema/widgets/pivot-table.d.ts +8 -0
  33. package/dist/schema/widgets/table.d.ts +8 -0
  34. package/dist/services/widgetDataService.js +42 -0
  35. package/package.json +2 -2
  36. package/schema/api.ts +2 -1
  37. package/schema/widget.ts +2 -0
  38. package/schema/widgets/charts.ts +2 -1
  39. package/schema/widgets/common.ts +19 -1
  40. package/services/widgetDataService.ts +68 -0
package/README.md CHANGED
@@ -39,7 +39,7 @@ Each widget has common fields:
39
39
  | Widget target | Config field | Main settings | Data usage |
40
40
  | --- | --- | --- | --- |
41
41
  | `table` | `table` | `pagination`, `page_size`, `columns` | Uses `query` to display raw or aggregate rows. |
42
- | `chart` | `chart` | `type`, `x`, `y`, `label`, `value`, `series`, `buckets`, `color`, `colors` | Uses the same `query` shape for every chart type. Multi-resource charts use `query.source: steps`. |
42
+ | `chart` | `chart` | `type`, `x`, `y`, `label`, `value`, `series`, `buckets`, `color`, `colors` | Uses the same `query` shape for most chart types. Multi-resource charts use `query.source: steps`; add `query.bucket` for shared numeric buckets across resources. |
43
43
  | `kpi_card` | `card` | `value`, `subtitle`, `comparison`, `sparkline` | Reads the first returned query row. |
44
44
  | `gauge_card` | `card` | `value`, `target`, `progress`, `color` | Reads the first returned query row. |
45
45
  | `pivot_table` | `pivot` | `rows`, `columns`, `values` | Uses query rows to build a pivot table. |
@@ -53,7 +53,7 @@ Chart widget types:
53
53
  | `bar` | Uses `x` and `y`. |
54
54
  | `stacked_bar` | Uses `x`, `y`, and `series`. |
55
55
  | `funnel` | Uses `label`, `value`, and optional `colors`. Data comes from the same `query` shapes as every other chart. |
56
- | `histogram` | Uses `x`, `y`, and optional `buckets`. |
56
+ | `histogram` | Uses `x`, `y`, and optional `buckets`. Current histogram runtime support is single-resource only: provide raw rows for the numeric field and let `chart.buckets` derive counts on the frontend. For multi-resource bucket distributions, use `stacked_bar` with `query.source: steps` and `query.bucket`. |
57
57
 
58
58
  ## Query Shape
59
59
 
@@ -89,6 +89,8 @@ type QueryConfig = {
89
89
  formatting?: Record<string, JsonValue>
90
90
  }
91
91
 
92
+ `source: 'steps'` returns one aggregate row per step by default. Each step supports aggregate `select` items plus optional `filters`; it does not support per-step `field` selects, `calc` selects, or `group_by`. Add `query.bucket` when multiple resources need the same numeric buckets, for example a stacked bar distribution by price range.
93
+
92
94
  type DashboardFilter =
93
95
  | { and: DashboardFilter[] }
94
96
  | { or: DashboardFilter[] }
@@ -155,6 +157,45 @@ query:
155
157
  as: value
156
158
  ```
157
159
 
160
+ Bucketed multi-resource queries use `query.bucket`. The dashboard runs each step once per bucket and returns rows with `label`, `name`, `resource`, and the selected aggregate aliases:
161
+
162
+ ```yaml
163
+ target: chart
164
+ label: Cars by price range and database
165
+ chart:
166
+ type: stacked_bar
167
+ title: Cars by price range and database
168
+ x:
169
+ field: label
170
+ y:
171
+ field: count
172
+ series:
173
+ field: name
174
+ query:
175
+ source: steps
176
+ bucket:
177
+ field: price
178
+ buckets:
179
+ - label: Budget
180
+ max: 3500
181
+ - label: Mid-range
182
+ min: 3500
183
+ max: 7000
184
+ - label: Premium
185
+ min: 7000
186
+ steps:
187
+ - name: SQLite
188
+ resource: cars_sl
189
+ select:
190
+ - agg: count
191
+ as: count
192
+ - name: MySQL
193
+ resource: cars_mysql
194
+ select:
195
+ - agg: count
196
+ as: count
197
+ ```
198
+
158
199
  Cost calculation example:
159
200
 
160
201
  ```yaml
@@ -62,20 +62,82 @@ export class DashboardApiError extends Error {
62
62
  }
63
63
 
64
64
  function normalizeValidationErrors(response: any): DashboardWidgetConfigValidationError[] {
65
- if (Array.isArray(response?.validationErrors)) {
66
- return response.validationErrors
65
+ const errors = Array.isArray(response?.validationErrors)
66
+ ? response.validationErrors
67
+ : Array.isArray(response?.details)
68
+ ? response.details.map((detail: any) => ({
69
+ field: getValidationErrorField(detail),
70
+ message: String(detail.message || 'Invalid value'),
71
+ }))
72
+ : []
73
+
74
+ return simplifyValidationErrors(errors)
75
+ }
76
+
77
+ function getValidationErrorField(detail: any) {
78
+ return Array.isArray(detail.instancePath)
79
+ ? detail.instancePath.join('.')
80
+ : String(detail.instancePath || detail.path || 'config').replace(/^\//, '').replaceAll('/', '.')
81
+ }
82
+
83
+ function simplifyValidationErrors(errors: DashboardWidgetConfigValidationError[]) {
84
+ const collapsed = new Map<string, DashboardWidgetConfigValidationError>()
85
+
86
+ for (const error of errors) {
87
+ const selectItemMatch = error.field.match(/^config\.query\.select\.(\d+)$/)
88
+
89
+ if (selectItemMatch) {
90
+ const field = error.field
91
+ collapsed.set(field, {
92
+ field,
93
+ message: 'must be a valid select item: field, aggregate, or calc',
94
+ })
95
+ continue
96
+ }
97
+
98
+ if (isUnionBranchNoise(error)) {
99
+ continue
100
+ }
101
+
102
+ const key = `${error.field}:${error.message}`
103
+ collapsed.set(key, error)
67
104
  }
68
105
 
69
- if (Array.isArray(response?.details)) {
70
- return response.details.map((detail: any) => ({
71
- field: Array.isArray(detail.instancePath)
72
- ? detail.instancePath.join('.')
73
- : String(detail.instancePath || detail.path || 'config').replace(/^\//, '').replaceAll('/', '.'),
74
- message: String(detail.message || 'Invalid value'),
75
- }))
106
+ const simplifiedErrors = Array.from(collapsed.values())
107
+
108
+ if (simplifiedErrors.length) {
109
+ return simplifiedErrors
110
+ }
111
+
112
+ return dedupeValidationErrors(errors).filter((error) => error.message !== 'must match a schema in anyOf').slice(0, 5)
113
+ }
114
+
115
+ function dedupeValidationErrors(errors: DashboardWidgetConfigValidationError[]) {
116
+ const deduped = new Map<string, DashboardWidgetConfigValidationError>()
117
+
118
+ for (const error of errors) {
119
+ deduped.set(`${error.field}:${error.message}`, error)
120
+ }
121
+
122
+ return Array.from(deduped.values())
123
+ }
124
+
125
+ function isUnionBranchNoise(error: DashboardWidgetConfigValidationError) {
126
+ if (error.field !== 'config') {
127
+ return false
76
128
  }
77
129
 
78
- return []
130
+ return error.message === 'must NOT have additional properties'
131
+ || error.message === 'must match a schema in anyOf'
132
+ || error.message === 'must match exactly one schema in oneOf'
133
+ || error.message === 'must have required property \'chart\''
134
+ || error.message === 'must have required property "chart"'
135
+ || error.message === 'must have required property \'card\''
136
+ || error.message === 'must have required property "card"'
137
+ || error.message === 'must have required property \'table\''
138
+ || error.message === 'must have required property "table"'
139
+ || error.message === 'must have required property \'pivot\''
140
+ || error.message === 'must have required property "pivot"'
79
141
  }
80
142
 
81
143
  async function parseDashboardResponse(rawResponse: Response) {
@@ -140,6 +140,11 @@ export type ResourceQueryConfig = {
140
140
  formatting?: Record<string, JsonValue>
141
141
  }
142
142
 
143
+ export type QueryBucketConfig = {
144
+ field: string
145
+ buckets: Array<{ label: string, min?: number, max?: number }>
146
+ }
147
+
143
148
  export type StepsQueryStepConfig = {
144
149
  name: string
145
150
  resource: string
@@ -150,6 +155,7 @@ export type StepsQueryStepConfig = {
150
155
  export type StepsQueryConfig = {
151
156
  source: 'steps'
152
157
  steps: StepsQueryStepConfig[]
158
+ bucket?: QueryBucketConfig
153
159
  calcs?: QueryCalcSelectItem[]
154
160
  order_by?: QueryOrderByItem[]
155
161
  limit?: number
@@ -14,91 +14,36 @@
14
14
  v-if="isAdmin"
15
15
  class="absolute right-3 top-3 flex gap-1 opacity-0 transition-opacity group-hover/dashboard:opacity-100"
16
16
  >
17
- <button
18
- type="button"
19
- class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText shadow-sm hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover"
17
+ <DashboardToolbarButton
20
18
  title="Edit JSON"
21
19
  @click="emit('edit-group', group)"
22
20
  >
23
- <svg
24
- class="h-4 w-4"
25
- viewBox="0 0 24 24"
26
- fill="none"
27
- stroke="currentColor"
28
- stroke-width="1.8"
29
- stroke-linecap="round"
30
- stroke-linejoin="round"
31
- aria-hidden="true"
32
- >
33
- <path d="M15.5 7.5a3 3 0 1 1 1 2.2l-6.8 6.8H7.5v2.2H5.3v2.2H2.8v-2.5l7.5-7.5a5.5 5.5 0 1 1 5.2 1.6" />
34
- </svg>
35
- </button>
21
+ <DashboardToolbarIcon name="edit" />
22
+ </DashboardToolbarButton>
36
23
 
37
- <button
38
- type="button"
39
- class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText shadow-sm hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover disabled:opacity-45 dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover"
24
+ <DashboardToolbarButton
40
25
  title="Move up"
41
26
  :disabled="!canMoveUp"
42
27
  @click="emit('move-up')"
43
28
  >
44
- <svg
45
- class="h-4 w-4"
46
- viewBox="0 0 24 24"
47
- fill="none"
48
- stroke="currentColor"
49
- stroke-width="2"
50
- stroke-linecap="round"
51
- stroke-linejoin="round"
52
- aria-hidden="true"
53
- >
54
- <path d="m18 15-6-6-6 6" />
55
- </svg>
56
- </button>
29
+ <DashboardToolbarIcon name="move-up" />
30
+ </DashboardToolbarButton>
57
31
 
58
- <button
59
- type="button"
60
- class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText shadow-sm hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover disabled:opacity-45 dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover"
32
+ <DashboardToolbarButton
61
33
  title="Move down"
62
34
  :disabled="!canMoveDown"
63
35
  @click="emit('move-down')"
64
36
  >
65
- <svg
66
- class="h-4 w-4"
67
- viewBox="0 0 24 24"
68
- fill="none"
69
- stroke="currentColor"
70
- stroke-width="2"
71
- stroke-linecap="round"
72
- stroke-linejoin="round"
73
- aria-hidden="true"
74
- >
75
- <path d="m6 9 6 6 6-6" />
76
- </svg>
77
- </button>
37
+ <DashboardToolbarIcon name="move-down" />
38
+ </DashboardToolbarButton>
78
39
 
79
- <button
80
- type="button"
81
- class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightInputErrorColor/30 bg-lightSecondary text-lightInputErrorColor shadow-sm hover:bg-lightListViewButtonBackgroundHover dark:bg-darkSecondary dark:hover:bg-darkListViewButtonBackgroundHover"
40
+ <DashboardToolbarButton
82
41
  title="Remove"
42
+ variant="danger"
83
43
  @click="emit('remove-group')"
84
44
  >
85
- <svg
86
- class="h-4 w-4"
87
- viewBox="0 0 24 24"
88
- fill="none"
89
- stroke="currentColor"
90
- stroke-width="2"
91
- stroke-linecap="round"
92
- stroke-linejoin="round"
93
- aria-hidden="true"
94
- >
95
- <path d="M3 6h18" />
96
- <path d="M8 6V4h8v2" />
97
- <path d="M19 6l-1 14H6L5 6" />
98
- <path d="M10 11v5" />
99
- <path d="M14 11v5" />
100
- </svg>
101
- </button>
45
+ <DashboardToolbarIcon name="remove" />
46
+ </DashboardToolbarButton>
102
47
  </div>
103
48
  </header>
104
49
 
@@ -158,6 +103,8 @@
158
103
 
159
104
  <script setup lang="ts">
160
105
  import { Button } from '@/afcl'
106
+ import DashboardToolbarButton from './DashboardToolbarButton.vue'
107
+ import DashboardToolbarIcon from './DashboardToolbarIcon.vue'
161
108
  import WidgetRenderer from './WidgetRenderer.vue'
162
109
  import WidgetShell from './WidgetShell.vue'
163
110
  import type { DashboardGroupConfig, DashboardWidgetConfig } from '../model/dashboard.types.js'
@@ -0,0 +1,32 @@
1
+ <template>
2
+ <button
3
+ type="button"
4
+ class="flex h-8 w-8 items-center justify-center rounded-lg border shadow-sm"
5
+ :class="buttonClass"
6
+ :title="title"
7
+ :disabled="disabled"
8
+ >
9
+ <slot />
10
+ </button>
11
+ </template>
12
+
13
+ <script setup lang="ts">
14
+ import { computed } from 'vue'
15
+
16
+ const props = withDefaults(defineProps<{
17
+ title: string
18
+ disabled?: boolean
19
+ variant?: 'default' | 'danger'
20
+ }>(), {
21
+ disabled: false,
22
+ variant: 'default',
23
+ })
24
+
25
+ const buttonClass = computed(() => {
26
+ if (props.variant === 'danger') {
27
+ return 'border-lightInputErrorColor/30 bg-lightSecondary text-lightInputErrorColor hover:bg-lightListViewButtonBackgroundHover dark:bg-darkSecondary dark:hover:bg-darkListViewButtonBackgroundHover'
28
+ }
29
+
30
+ return 'border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover disabled:opacity-45 dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover'
31
+ })
32
+ </script>
@@ -0,0 +1,52 @@
1
+ <template>
2
+ <svg
3
+ v-if="name === 'edit'"
4
+ xmlns="http://www.w3.org/2000/svg"
5
+ width="16"
6
+ height="16"
7
+ viewBox="0 0 90 90"
8
+ fill="none"
9
+ aria-hidden="true"
10
+ >
11
+ <g transform="translate(90 0) scale(-1 1)">
12
+ <path
13
+ fill="currentColor"
14
+ d="M69.243 90c-5.389 0-10.67-2.107-14.643-6.081-5.433-5.432-7.391-13.514-5.115-20.831L26.912 40.515c-7.313 2.276-15.397.32-20.832-5.114C.147 29.468-1.625 20.617 1.566 12.852l.846-2.059 12.493 12.493c2.311 2.31 6.07 2.311 8.381 0 2.31-2.311 2.31-6.071 0-8.381L10.794 2.413l2.059-.846c7.766-3.191 16.616-1.418 22.549 4.514 5.433 5.433 7.39 13.516 5.114 20.831l22.572 22.573c7.314-2.278 15.398-.32 20.832 5.114 5.934 5.933 7.704 14.784 4.514 22.549l-.847 2.059-12.492-12.493c-1.113-1.113-2.601-1.726-4.191-1.726s-3.077.613-4.191 1.726c-2.31 2.311-2.31 6.071 0 8.381l12.493 12.492-2.06.847C74.582 89.487 71.899 89.999 69.243 90zM27.692 37.1l25.206 25.207-.322.887c-2.345 6.469-.728 13.779 4.119 18.626 4.538 4.538 11.069 6.235 17.152 4.603l-9.232-9.232c-3.466-3.467-3.466-9.109 0-12.576 3.466-3.468 9.109-3.468 12.576 0l9.232 9.232c1.633-6.083-.064-12.615-4.602-17.153-4.847-4.847-12.161-6.464-18.627-4.118l-.887.322L37.101 27.692l.322-.887c2.345-6.469.729-13.78-4.118-18.627-4.539-4.538-11.07-6.235-17.152-4.603l9.232 9.232c3.467 3.467 3.467 9.109 0 12.576s-9.109 3.468-12.576 0l-9.233-9.232c-1.634 6.083.064 12.614 4.602 17.153 4.848 4.848 12.16 6.464 18.628 4.118l.886-.322z"
15
+ />
16
+ </g>
17
+ </svg>
18
+
19
+ <svg
20
+ v-else
21
+ class="h-4 w-4"
22
+ viewBox="0 0 24 24"
23
+ fill="none"
24
+ stroke="currentColor"
25
+ stroke-width="2"
26
+ stroke-linecap="round"
27
+ stroke-linejoin="round"
28
+ aria-hidden="true"
29
+ >
30
+ <path
31
+ v-if="name === 'move-up'"
32
+ d="m18 15-6-6-6 6"
33
+ />
34
+ <path
35
+ v-else-if="name === 'move-down'"
36
+ d="m6 9 6 6 6-6"
37
+ />
38
+ <template v-else>
39
+ <path d="M3 6h18" />
40
+ <path d="M8 6V4h8v2" />
41
+ <path d="M19 6l-1 14H6L5 6" />
42
+ <path d="M10 11v5" />
43
+ <path d="M14 11v5" />
44
+ </template>
45
+ </svg>
46
+ </template>
47
+
48
+ <script setup lang="ts">
49
+ defineProps<{
50
+ name: 'edit' | 'move-up' | 'move-down' | 'remove'
51
+ }>()
52
+ </script>
@@ -10,91 +10,36 @@
10
10
  v-if="isAdmin"
11
11
  class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"
12
12
  >
13
- <button
14
- type="button"
15
- class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText shadow-sm hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover"
13
+ <DashboardToolbarButton
16
14
  title="Edit JSON"
17
15
  @click="emit('edit')"
18
16
  >
19
- <svg
20
- class="h-4 w-4"
21
- viewBox="0 0 24 24"
22
- fill="none"
23
- stroke="currentColor"
24
- stroke-width="1.8"
25
- stroke-linecap="round"
26
- stroke-linejoin="round"
27
- aria-hidden="true"
28
- >
29
- <path d="M15.5 7.5a3 3 0 1 1 1 2.2l-6.8 6.8H7.5v2.2H5.3v2.2H2.8v-2.5l7.5-7.5a5.5 5.5 0 1 1 5.2 1.6" />
30
- </svg>
31
- </button>
17
+ <DashboardToolbarIcon name="edit" />
18
+ </DashboardToolbarButton>
32
19
 
33
- <button
34
- type="button"
35
- class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText shadow-sm hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover disabled:opacity-45 dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover"
20
+ <DashboardToolbarButton
36
21
  title="Move up"
37
22
  :disabled="!canMoveUp"
38
23
  @click="emit('move-up')"
39
24
  >
40
- <svg
41
- class="h-4 w-4"
42
- viewBox="0 0 24 24"
43
- fill="none"
44
- stroke="currentColor"
45
- stroke-width="2"
46
- stroke-linecap="round"
47
- stroke-linejoin="round"
48
- aria-hidden="true"
49
- >
50
- <path d="m18 15-6-6-6 6" />
51
- </svg>
52
- </button>
25
+ <DashboardToolbarIcon name="move-up" />
26
+ </DashboardToolbarButton>
53
27
 
54
- <button
55
- type="button"
56
- class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightListViewButtonBorder bg-lightListViewButtonBackground text-lightListViewButtonText shadow-sm hover:bg-lightListViewButtonBackgroundHover hover:text-lightListViewButtonTextHover disabled:opacity-45 dark:border-darkListViewButtonBorder dark:bg-darkListViewButtonBackground dark:text-darkListViewButtonText dark:hover:bg-darkListViewButtonBackgroundHover dark:hover:text-darkListViewButtonTextHover"
28
+ <DashboardToolbarButton
57
29
  title="Move down"
58
30
  :disabled="!canMoveDown"
59
31
  @click="emit('move-down')"
60
32
  >
61
- <svg
62
- class="h-4 w-4"
63
- viewBox="0 0 24 24"
64
- fill="none"
65
- stroke="currentColor"
66
- stroke-width="2"
67
- stroke-linecap="round"
68
- stroke-linejoin="round"
69
- aria-hidden="true"
70
- >
71
- <path d="m6 9 6 6 6-6" />
72
- </svg>
73
- </button>
33
+ <DashboardToolbarIcon name="move-down" />
34
+ </DashboardToolbarButton>
74
35
 
75
- <button
76
- type="button"
77
- class="flex h-8 w-8 items-center justify-center rounded-lg border border-lightInputErrorColor/30 bg-lightSecondary text-lightInputErrorColor shadow-sm hover:bg-lightListViewButtonBackgroundHover dark:bg-darkSecondary dark:hover:bg-darkListViewButtonBackgroundHover"
36
+ <DashboardToolbarButton
78
37
  title="Remove"
38
+ variant="danger"
79
39
  @click="emit('remove')"
80
40
  >
81
- <svg
82
- class="h-4 w-4"
83
- viewBox="0 0 24 24"
84
- fill="none"
85
- stroke="currentColor"
86
- stroke-width="2"
87
- stroke-linecap="round"
88
- stroke-linejoin="round"
89
- aria-hidden="true"
90
- >
91
- <path d="M3 6h18" />
92
- <path d="M8 6V4h8v2" />
93
- <path d="M19 6l-1 14H6L5 6" />
94
- <path d="M10 11v5" />
95
- <path d="M14 11v5" />
96
- </svg>
97
- </button>
41
+ <DashboardToolbarIcon name="remove" />
42
+ </DashboardToolbarButton>
98
43
  </div>
99
44
  </div>
100
45
  </template>
@@ -105,6 +50,8 @@
105
50
  import { computed } from 'vue'
106
51
  import type { CSSProperties } from 'vue'
107
52
  import type { WidgetLayout } from '../model/dashboard.types.js'
53
+ import DashboardToolbarButton from './DashboardToolbarButton.vue'
54
+ import DashboardToolbarIcon from './DashboardToolbarIcon.vue'
108
55
 
109
56
  const DEFAULT_WIDGET_HEIGHT = 500
110
57
 
@@ -253,6 +253,50 @@ query:
253
253
  direction: asc
254
254
  ```
255
255
 
256
+ Note: use `stacked_bar` with a normal single-resource grouped query when you need a dynamic series dimension such as `series.field: purpose`. For the same numeric buckets across multiple resources, use `query.source: steps` with `query.bucket`; set `chart.x.field: label`, `chart.y.field` to the aggregate alias, and `chart.series.field: name`.
257
+
258
+ Example bucketed multi-resource stacked bar:
259
+
260
+ ```yaml
261
+ target: chart
262
+ label: Cars by price range and database
263
+ size: wide
264
+ chart:
265
+ type: stacked_bar
266
+ x:
267
+ field: label
268
+ label: Price range
269
+ y:
270
+ field: count
271
+ label: Cars
272
+ series:
273
+ field: name
274
+ label: Database
275
+ query:
276
+ source: steps
277
+ bucket:
278
+ field: price
279
+ buckets:
280
+ - label: Budget
281
+ max: 3500
282
+ - label: Mid-range
283
+ min: 3500
284
+ max: 7000
285
+ - label: Premium
286
+ min: 7000
287
+ steps:
288
+ - name: SQLite
289
+ resource: cars_sl
290
+ select:
291
+ - agg: count
292
+ as: count
293
+ - name: MySQL
294
+ resource: cars_mysql
295
+ select:
296
+ - agg: count
297
+ as: count
298
+ ```
299
+
256
300
  Example `dashboard_configure_pie_chart_widget` config:
257
301
 
258
302
  ```yaml
@@ -291,26 +335,24 @@ chart:
291
335
  field: total_tokens
292
336
  label: Tokens
293
337
  y:
294
- field: requests
338
+ field: count
295
339
  label: Requests
340
+ buckets:
341
+ - label: Small
342
+ max: 1000
343
+ - label: Medium
344
+ min: 1000
345
+ max: 10000
346
+ - label: Large
347
+ min: 10000
296
348
  query:
297
349
  resource: llm_usage
298
350
  select:
299
351
  - field: total_tokens
300
- - agg: count
301
- as: requests
302
- bucket:
303
- field: total_tokens
304
- buckets:
305
- - label: Small
306
- max: 1000
307
- - label: Medium
308
- min: 1000
309
- max: 10000
310
- - label: Large
311
- min: 10000
312
352
  ```
313
353
 
354
+ Note: for the current dashboard runtime, histogram buckets are computed on the frontend from raw rows using `chart.buckets`. Do not rely on `query.bucket` for histogram widgets, do not use `query.source: steps`, and do not aggregate the source rows first. Histogram widgets should use a single-resource plain query with raw numeric rows, for example `select: - field: total_tokens`; the histogram component will derive the per-bucket `count` values itself.
355
+
314
356
  Example `dashboard_configure_funnel_chart_widget` config:
315
357
 
316
358
  ```yaml
@@ -476,6 +518,14 @@ query:
476
518
 
477
519
  Do not use bare query.steps without source: steps.
478
520
  Do not use metric. Use select even when a step has only one aggregate.
521
+ Each `steps[]` item supports only:
522
+ - name
523
+ - resource
524
+ - select with aggregate items only, for example `agg: count`, `agg: sum`, `agg: avg`
525
+ - optional filters
526
+ Do not put `field` selects, `calc` selects, `group_by`, `order_by`, `limit`, `offset`, or `bucket` inside a step.
527
+ Without `query.bucket`, `query.source: steps` produces one output row per step, with built-in `name` and `resource` fields plus the aggregate aliases from that step.
528
+ For per-bucket comparisons across multiple resources, put `bucket` at the query level, not inside a step. Use stacked_bar with `chart.x.field: label`, `chart.series.field: name`, and `chart.y.field` set to the aggregate alias such as `count`.
479
529
  All filters, including aggregate select item filters, must use filter expression shape.
480
530
  Use `filters: { field: model, eq: gpt-5.4 }`, not shorthand maps like `filters: { model: gpt-5.4 }`.
481
531
  When grouping by a derived date alias, repeat the source field object in `group_by`.