@adminforth/dashboard 1.8.0 → 1.10.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 (39) hide show
  1. package/README.md +81 -55
  2. package/custom/api/dashboardApi.ts +73 -36
  3. package/custom/model/dashboard.types.ts +6 -13
  4. package/custom/runtime/DashboardRuntime.vue +26 -22
  5. package/custom/skills/adminforth-dashboard/SKILL.md +13 -20
  6. package/dist/custom/api/dashboardApi.d.ts +24 -18
  7. package/dist/custom/api/dashboardApi.js +42 -18
  8. package/dist/custom/api/dashboardApi.ts +73 -36
  9. package/dist/custom/model/dashboard.types.d.ts +0 -5
  10. package/dist/custom/model/dashboard.types.ts +6 -13
  11. package/dist/custom/queries/useDashboardConfig.d.ts +20 -120
  12. package/dist/custom/queries/useWidgetData.d.ts +20 -120
  13. package/dist/custom/runtime/DashboardRuntime.vue +26 -22
  14. package/dist/custom/skills/adminforth-dashboard/SKILL.md +13 -20
  15. package/dist/endpoint/groups.js +22 -20
  16. package/dist/endpoint/widgets.js +28 -26
  17. package/dist/schema/api.d.ts +230 -3936
  18. package/dist/schema/api.js +7 -12
  19. package/dist/schema/widget.d.ts +20 -200
  20. package/dist/schema/widgets/charts.d.ts +24 -240
  21. package/dist/schema/widgets/common.d.ts +2 -20
  22. package/dist/schema/widgets/common.js +1 -10
  23. package/dist/schema/widgets/gauge-card.d.ts +2 -20
  24. package/dist/schema/widgets/kpi-card.d.ts +2 -20
  25. package/dist/schema/widgets/pivot-table.d.ts +2 -20
  26. package/dist/schema/widgets/table.d.ts +2 -20
  27. package/dist/services/calc-evaluator.d.ts +2 -0
  28. package/dist/services/calc-evaluator.js +54 -0
  29. package/dist/services/dashboardFilterService.d.ts +5 -0
  30. package/dist/services/dashboardFilterService.js +125 -0
  31. package/dist/services/widgetDataService.js +15 -168
  32. package/endpoint/groups.ts +22 -20
  33. package/endpoint/widgets.ts +28 -26
  34. package/package.json +2 -1
  35. package/schema/api.ts +7 -12
  36. package/schema/widgets/common.ts +1 -11
  37. package/services/calc-evaluator.ts +71 -0
  38. package/services/dashboardFilterService.ts +162 -0
  39. package/services/widgetDataService.ts +26 -213
package/README.md CHANGED
@@ -29,7 +29,7 @@ Each widget has common fields:
29
29
  | `label` | Optional widget title. |
30
30
  | `target` | Widget type: `table`, `chart`, `kpi_card`, `pivot_table`, or `gauge_card`. |
31
31
  | `order` | Widget order inside its group. |
32
- | `variables` | Optional static maps/constants available inside widget `query.calcs` via `lookup($variables.path, field, default)`. |
32
+ | `variables` | Optional widget variables passed to widget data loading. Variables are not available inside `query.calcs`. |
33
33
  | `size` | Preset width: `small`, `medium`, `large`, `wide`, or `full`. |
34
34
  | `width`, `height`, `min_width`, `max_width` | Optional explicit layout constraints. |
35
35
  | `query` | Data query definition. |
@@ -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 `query`; step-based charts may use `query.steps` with optional `calcs`. |
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`. |
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. |
@@ -52,13 +52,14 @@ Chart widget types:
52
52
  | `pie` | Uses `label` and `value`. |
53
53
  | `bar` | Uses `x` and `y`. |
54
54
  | `stacked_bar` | Uses `x`, `y`, and `series`. |
55
- | `funnel` | Uses `query.steps` and optional `label`, `value`, `colors`. |
55
+ | `funnel` | Uses `label`, `value`, and optional `colors`. Data comes from the same `query` shapes as every other chart. |
56
56
  | `histogram` | Uses `x`, `y`, and optional `buckets`. |
57
57
 
58
58
  ## Query Shape
59
59
 
60
60
  ```ts
61
61
  type QueryConfig = {
62
+ source?: 'resource'
62
63
  resource: string
63
64
  select?: Array<
64
65
  | { field: string; as?: string; grain?: 'day' | 'week' | 'month' | 'year' }
@@ -70,6 +71,22 @@ type QueryConfig = {
70
71
  order_by?: Array<{ field: string; direction?: 'asc' | 'desc' }>
71
72
  limit?: number
72
73
  offset?: number
74
+ bucket?: { field: string; buckets: Array<{ label: string; min?: number; max?: number }> }
75
+ calcs?: Array<{ calc: string; as: string }>
76
+ formatting?: Record<string, JsonValue>
77
+ } | {
78
+ source: 'steps'
79
+ steps: Array<{
80
+ name: string
81
+ resource: string
82
+ select: Array<{ agg: 'sum' | 'count' | 'count_distinct' | 'avg' | 'min' | 'max' | 'median'; field?: string; as: string; filters?: DashboardFilter | DashboardFilter[] }>
83
+ filters?: DashboardFilter | DashboardFilter[]
84
+ }>
85
+ calcs?: Array<{ calc: string; as: string }>
86
+ order_by?: Array<{ field: string; direction?: 'asc' | 'desc' }>
87
+ limit?: number
88
+ offset?: number
89
+ formatting?: Record<string, JsonValue>
73
90
  }
74
91
 
75
92
  type DashboardFilter =
@@ -77,91 +94,97 @@ type DashboardFilter =
77
94
  | { or: DashboardFilter[] }
78
95
  | {
79
96
  field: string
80
- eq?: JsonValue
81
- neq?: JsonValue
82
- gt?: JsonValue
83
- gte?: JsonValue
84
- lt?: JsonValue
85
- lte?: JsonValue
86
- in?: JsonValue[]
87
- not_in?: JsonValue[]
88
- like?: JsonValue
89
- ilike?: JsonValue
97
+ eq?: FilterValue
98
+ neq?: FilterValue
99
+ gt?: FilterValue
100
+ gte?: FilterValue
101
+ lt?: FilterValue
102
+ lte?: FilterValue
103
+ in?: FilterValue[]
104
+ not_in?: FilterValue[]
105
+ like?: FilterValue
106
+ ilike?: FilterValue
90
107
  }
91
108
 
92
109
  type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }
110
+ type RelativeDateValue = { now: true } | { now_minus: `${number}${'h' | 'd' | 'w' | 'mo' | 'y'}` }
111
+ type FilterValue = JsonValue | RelativeDateValue
112
+ ```
113
+
114
+ Use `filters` for rolling date ranges. Do not hard-code dates for dashboards that should move with time:
115
+
116
+ ```yaml
117
+ query:
118
+ resource: orders
119
+ filters:
120
+ and:
121
+ - field: created_at
122
+ gte:
123
+ now_minus: 30d
124
+ - field: created_at
125
+ lt:
126
+ now: true
93
127
  ```
94
128
 
95
- Step-based chart queries use `steps` and may include `calcs`:
129
+ Multi-resource queries use `source: steps`. Each step uses `select`, even if it has only one aggregate:
96
130
 
97
131
  ```yaml
98
132
  target: chart
99
133
  label: Average price by database
100
- variables:
101
- price_multipliers:
102
- cars_sl: 0.84
103
- cars_mysql: 1.12
104
- cars_pg: 0.91
105
- cars_mongo: 1.07
106
- cars_ch: 0.76
107
134
  chart:
108
135
  type: bar
109
136
  title: Average price by database
110
137
  x:
111
138
  field: name
112
139
  y:
113
- field: adjusted_value
140
+ field: value
114
141
  query:
142
+ source: steps
115
143
  steps:
116
144
  - name: SQLite
117
145
  resource: cars_sl
118
- metric:
119
- agg: avg
120
- field: price
121
- as: value
146
+ select:
147
+ - agg: avg
148
+ field: price
149
+ as: value
122
150
  - name: MySQL
123
151
  resource: cars_mysql
124
- metric:
125
- agg: avg
126
- field: price
127
- as: value
128
- calcs:
129
- - calc: value * lookup($variables.price_multipliers, resource, 1)
130
- as: adjusted_value
152
+ select:
153
+ - agg: avg
154
+ field: price
155
+ as: value
131
156
  ```
132
157
 
133
- Widget-level variables example:
158
+ Cost calculation example:
134
159
 
135
160
  ```yaml
136
161
  target: chart
137
162
  label: Model costs
138
- variables:
139
- token_prices_per_1m:
140
- input:
141
- gpt-4.1: 2.00
142
- gpt-4.1-mini: 0.40
143
- gpt-4o-mini: 0.15
144
- output:
145
- gpt-4.1: 8.00
146
- gpt-4.1-mini: 1.60
147
- gpt-4o-mini: 0.60
148
- cached:
149
- gpt-4.1: 0.50
150
- gpt-4.1-mini: 0.10
151
- gpt-4o-mini: 0.075
152
163
  chart:
153
164
  type: stacked_bar
154
- title: LLM costs by model
165
+ title: GPT-5.4 costs by day
155
166
  x:
156
- field: model
167
+ field: day
157
168
  y:
158
169
  - field: input_cost
159
170
  - field: output_cost
160
171
  - field: cached_cost
161
172
  query:
162
173
  resource: model_usage
174
+ filters:
175
+ and:
176
+ - field: model
177
+ eq: gpt-5.4
178
+ - field: used_at
179
+ gte:
180
+ now_minus: 7d
181
+ - field: used_at
182
+ lt:
183
+ now: true
163
184
  select:
164
- - field: model
185
+ - field: used_at
186
+ as: day
187
+ grain: day
165
188
  - agg: sum
166
189
  field: input_tokens
167
190
  as: input_tokens
@@ -172,16 +195,19 @@ query:
172
195
  field: cached_tokens
173
196
  as: cached_tokens
174
197
  group_by:
175
- - model
198
+ - field: used_at
199
+ as: day
200
+ grain: day
176
201
  calcs:
177
- - calc: input_tokens / 1000000 * lookup($variables.token_prices_per_1m.input, model, 0)
202
+ - calc: input_tokens / 1000000 * 2.5
178
203
  as: input_cost
179
- - calc: output_tokens / 1000000 * lookup($variables.token_prices_per_1m.output, model, 0)
204
+ - calc: output_tokens / 1000000 * 15
180
205
  as: output_cost
181
- - calc: cached_tokens / 1000000 * lookup($variables.token_prices_per_1m.cached, model, 0)
206
+ - calc: cached_tokens / 1000000 * 0.25
182
207
  as: cached_cost
183
208
  ```
184
209
 
210
+
185
211
  ## Runtime Structure
186
212
 
187
213
  ```text
@@ -25,6 +25,13 @@ export type DashboardWidgetDataResponse = {
25
25
  data: unknown
26
26
  }
27
27
 
28
+ export type DashboardMutationResponse = {
29
+ ok: boolean
30
+ error?: string
31
+ groupId?: string
32
+ widgetId?: string
33
+ }
34
+
28
35
  export type DashboardWidgetDataRequest = {
29
36
  pagination?: {
30
37
  page: number
@@ -119,6 +126,36 @@ async function callDashboardApi(path: string, body: Record<string, unknown>): Pr
119
126
  }
120
127
  }
121
128
 
129
+ async function callDashboardMutationApi(path: string, body: Record<string, unknown>): Promise<DashboardMutationResponse> {
130
+ const rawResponse = await fetch(path, {
131
+ method: 'POST',
132
+ headers: {
133
+ 'Content-Type': 'application/json',
134
+ 'accept-language': localStorage.getItem('af_lang') || 'en',
135
+ },
136
+ body: JSON.stringify(body),
137
+ })
138
+
139
+ const response = await parseDashboardResponse(rawResponse)
140
+
141
+ if (!rawResponse.ok) {
142
+ throw new DashboardApiError(
143
+ response?.error || rawResponse.statusText || `Dashboard request failed (${rawResponse.status})`,
144
+ normalizeValidationErrors(response),
145
+ )
146
+ }
147
+
148
+ if (!response || response.error || response.ok === false) {
149
+ throw new DashboardApiError(response?.error || 'Dashboard request failed', normalizeValidationErrors(response))
150
+ }
151
+
152
+ return {
153
+ ok: true,
154
+ groupId: response.groupId,
155
+ widgetId: response.widgetId,
156
+ }
157
+ }
158
+
122
159
  async function callDashboardWidgetDataApi(
123
160
  path: string,
124
161
  body: Record<string, unknown>,
@@ -156,39 +193,39 @@ export const dashboardApi = {
156
193
  return callDashboardApi('/adminapi/v1/dashboard/get-config', { slug })
157
194
  },
158
195
 
159
- async addDashboardGroup(slug: string): Promise<DashboardResponse> {
160
- return callDashboardApi('/adminapi/v1/dashboard/add_dashboard_group', { slug })
196
+ async addDashboardGroup(slug: string): Promise<DashboardMutationResponse> {
197
+ return callDashboardMutationApi('/adminapi/v1/dashboard/add_dashboard_group', { slug })
161
198
  },
162
199
 
163
200
  async moveDashboardGroup(
164
201
  slug: string,
165
202
  groupId: string,
166
203
  direction: DashboardGroupMoveDirection,
167
- ): Promise<DashboardResponse> {
168
- return callDashboardApi('/adminapi/v1/dashboard/move_dashboard_group', {
204
+ ): Promise<DashboardMutationResponse> {
205
+ return callDashboardMutationApi('/adminapi/v1/dashboard/move_dashboard_group', {
169
206
  slug,
170
207
  groupId,
171
208
  direction,
172
209
  })
173
210
  },
174
211
 
175
- async removeDashboardGroup(slug: string, groupId: string): Promise<DashboardResponse> {
176
- return callDashboardApi('/adminapi/v1/dashboard/remove_dashboard_group', {
212
+ async removeDashboardGroup(slug: string, groupId: string): Promise<DashboardMutationResponse> {
213
+ return callDashboardMutationApi('/adminapi/v1/dashboard/remove_dashboard_group', {
177
214
  slug,
178
215
  groupId,
179
216
  })
180
217
  },
181
218
 
182
- async setDashboardGroupConfig(slug: string, groupId: string, config: EditableDashboardGroupConfig): Promise<DashboardResponse> {
183
- return callDashboardApi('/adminapi/v1/dashboard/set_dashboard_group_config', {
219
+ async setDashboardGroupConfig(slug: string, groupId: string, config: EditableDashboardGroupConfig): Promise<DashboardMutationResponse> {
220
+ return callDashboardMutationApi('/adminapi/v1/dashboard/set_dashboard_group_config', {
184
221
  slug,
185
222
  groupId,
186
223
  config,
187
224
  })
188
225
  },
189
226
 
190
- async addDashboardWidget(slug: string, groupId: string): Promise<DashboardResponse> {
191
- return callDashboardApi('/adminapi/v1/dashboard/add_dashboard_widget', {
227
+ async addDashboardWidget(slug: string, groupId: string): Promise<DashboardMutationResponse> {
228
+ return callDashboardMutationApi('/adminapi/v1/dashboard/add_dashboard_widget', {
192
229
  slug,
193
230
  groupId,
194
231
  })
@@ -198,23 +235,23 @@ export const dashboardApi = {
198
235
  slug: string,
199
236
  widgetId: string,
200
237
  direction: DashboardWidgetMoveDirection,
201
- ): Promise<DashboardResponse> {
202
- return callDashboardApi('/adminapi/v1/dashboard/move_dashboard_widget', {
238
+ ): Promise<DashboardMutationResponse> {
239
+ return callDashboardMutationApi('/adminapi/v1/dashboard/move_dashboard_widget', {
203
240
  slug,
204
241
  widgetId,
205
242
  direction,
206
243
  })
207
244
  },
208
245
 
209
- async removeDashboardWidget(slug: string, widgetId: string): Promise<DashboardResponse> {
210
- return callDashboardApi('/adminapi/v1/dashboard/remove_dashboard_widget', {
246
+ async removeDashboardWidget(slug: string, widgetId: string): Promise<DashboardMutationResponse> {
247
+ return callDashboardMutationApi('/adminapi/v1/dashboard/remove_dashboard_widget', {
211
248
  slug,
212
249
  widgetId,
213
250
  })
214
251
  },
215
252
 
216
- async setWidgetConfig(slug: string, widgetId: string, config: unknown): Promise<DashboardResponse> {
217
- return callDashboardApi('/adminapi/v1/dashboard/set_widget_config', {
253
+ async setWidgetConfig(slug: string, widgetId: string, config: unknown): Promise<DashboardMutationResponse> {
254
+ return callDashboardMutationApi('/adminapi/v1/dashboard/set_widget_config', {
218
255
  slug,
219
256
  widgetId,
220
257
  config,
@@ -225,8 +262,8 @@ export const dashboardApi = {
225
262
  slug: string,
226
263
  widgetId: string,
227
264
  config: ConfigurableTableWidgetConfig,
228
- ): Promise<DashboardResponse> {
229
- return callDashboardApi('/adminapi/v1/dashboard/configure_table_widget', {
265
+ ): Promise<DashboardMutationResponse> {
266
+ return callDashboardMutationApi('/adminapi/v1/dashboard/configure_table_widget', {
230
267
  slug,
231
268
  widgetId,
232
269
  config,
@@ -237,8 +274,8 @@ export const dashboardApi = {
237
274
  slug: string,
238
275
  widgetId: string,
239
276
  config: ConfigurableKpiCardWidgetConfig,
240
- ): Promise<DashboardResponse> {
241
- return callDashboardApi('/adminapi/v1/dashboard/configure_kpi_card_widget', {
277
+ ): Promise<DashboardMutationResponse> {
278
+ return callDashboardMutationApi('/adminapi/v1/dashboard/configure_kpi_card_widget', {
242
279
  slug,
243
280
  widgetId,
244
281
  config,
@@ -249,8 +286,8 @@ export const dashboardApi = {
249
286
  slug: string,
250
287
  widgetId: string,
251
288
  config: ConfigurableGaugeCardWidgetConfig,
252
- ): Promise<DashboardResponse> {
253
- return callDashboardApi('/adminapi/v1/dashboard/configure_gauge_card_widget', {
289
+ ): Promise<DashboardMutationResponse> {
290
+ return callDashboardMutationApi('/adminapi/v1/dashboard/configure_gauge_card_widget', {
254
291
  slug,
255
292
  widgetId,
256
293
  config,
@@ -261,8 +298,8 @@ export const dashboardApi = {
261
298
  slug: string,
262
299
  widgetId: string,
263
300
  config: ConfigurableLineChartWidgetConfig,
264
- ): Promise<DashboardResponse> {
265
- return callDashboardApi('/adminapi/v1/dashboard/configure_line_chart_widget', {
301
+ ): Promise<DashboardMutationResponse> {
302
+ return callDashboardMutationApi('/adminapi/v1/dashboard/configure_line_chart_widget', {
266
303
  slug,
267
304
  widgetId,
268
305
  config,
@@ -273,8 +310,8 @@ export const dashboardApi = {
273
310
  slug: string,
274
311
  widgetId: string,
275
312
  config: ConfigurableBarChartWidgetConfig,
276
- ): Promise<DashboardResponse> {
277
- return callDashboardApi('/adminapi/v1/dashboard/configure_bar_chart_widget', {
313
+ ): Promise<DashboardMutationResponse> {
314
+ return callDashboardMutationApi('/adminapi/v1/dashboard/configure_bar_chart_widget', {
278
315
  slug,
279
316
  widgetId,
280
317
  config,
@@ -285,8 +322,8 @@ export const dashboardApi = {
285
322
  slug: string,
286
323
  widgetId: string,
287
324
  config: ConfigurableStackedBarChartWidgetConfig,
288
- ): Promise<DashboardResponse> {
289
- return callDashboardApi('/adminapi/v1/dashboard/configure_stacked_bar_chart_widget', {
325
+ ): Promise<DashboardMutationResponse> {
326
+ return callDashboardMutationApi('/adminapi/v1/dashboard/configure_stacked_bar_chart_widget', {
290
327
  slug,
291
328
  widgetId,
292
329
  config,
@@ -297,8 +334,8 @@ export const dashboardApi = {
297
334
  slug: string,
298
335
  widgetId: string,
299
336
  config: ConfigurablePieChartWidgetConfig,
300
- ): Promise<DashboardResponse> {
301
- return callDashboardApi('/adminapi/v1/dashboard/configure_pie_chart_widget', {
337
+ ): Promise<DashboardMutationResponse> {
338
+ return callDashboardMutationApi('/adminapi/v1/dashboard/configure_pie_chart_widget', {
302
339
  slug,
303
340
  widgetId,
304
341
  config,
@@ -309,8 +346,8 @@ export const dashboardApi = {
309
346
  slug: string,
310
347
  widgetId: string,
311
348
  config: ConfigurableHistogramChartWidgetConfig,
312
- ): Promise<DashboardResponse> {
313
- return callDashboardApi('/adminapi/v1/dashboard/configure_histogram_chart_widget', {
349
+ ): Promise<DashboardMutationResponse> {
350
+ return callDashboardMutationApi('/adminapi/v1/dashboard/configure_histogram_chart_widget', {
314
351
  slug,
315
352
  widgetId,
316
353
  config,
@@ -321,8 +358,8 @@ export const dashboardApi = {
321
358
  slug: string,
322
359
  widgetId: string,
323
360
  config: ConfigurableFunnelChartWidgetConfig,
324
- ): Promise<DashboardResponse> {
325
- return callDashboardApi('/adminapi/v1/dashboard/configure_funnel_chart_widget', {
361
+ ): Promise<DashboardMutationResponse> {
362
+ return callDashboardMutationApi('/adminapi/v1/dashboard/configure_funnel_chart_widget', {
326
363
  slug,
327
364
  widgetId,
328
365
  config,
@@ -333,8 +370,8 @@ export const dashboardApi = {
333
370
  slug: string,
334
371
  widgetId: string,
335
372
  config: ConfigurablePivotTableWidgetConfig,
336
- ): Promise<DashboardResponse> {
337
- return callDashboardApi('/adminapi/v1/dashboard/configure_pivot_table_widget', {
373
+ ): Promise<DashboardMutationResponse> {
374
+ return callDashboardMutationApi('/adminapi/v1/dashboard/configure_pivot_table_widget', {
338
375
  slug,
339
376
  widgetId,
340
377
  config,
@@ -140,19 +140,12 @@ export type ResourceQueryConfig = {
140
140
  formatting?: Record<string, JsonValue>
141
141
  }
142
142
 
143
- export type StepsQueryStepConfig =
144
- | {
145
- name: string
146
- resource: string
147
- metric: QueryAggregateSelectItem
148
- filters?: FilterExpression
149
- }
150
- | {
151
- name: string
152
- resource: string
153
- select: QueryAggregateSelectItem[]
154
- filters?: FilterExpression
155
- }
143
+ export type StepsQueryStepConfig = {
144
+ name: string
145
+ resource: string
146
+ select: QueryAggregateSelectItem[]
147
+ filters?: FilterExpression
148
+ }
156
149
 
157
150
  export type StepsQueryConfig = {
158
151
  source: 'steps'
@@ -286,7 +286,8 @@ async function addGroup() {
286
286
  }
287
287
 
288
288
  try {
289
- applyDashboardResponse(await dashboardApi.addDashboardGroup(props.dashboardSlug))
289
+ await dashboardApi.addDashboardGroup(props.dashboardSlug)
290
+ await refreshDashboardConfig()
290
291
  } catch (error) {
291
292
  console.error('Failed to add dashboard group', error)
292
293
  }
@@ -298,7 +299,8 @@ async function addWidget(groupId: string) {
298
299
  }
299
300
 
300
301
  try {
301
- applyDashboardResponse(await dashboardApi.addDashboardWidget(props.dashboardSlug, groupId))
302
+ await dashboardApi.addDashboardWidget(props.dashboardSlug, groupId)
303
+ await refreshDashboardConfig()
302
304
  } catch (error) {
303
305
  console.error('Failed to add dashboard widget', error)
304
306
  }
@@ -310,9 +312,8 @@ async function moveGroup(groupId: string, direction: DashboardGroupMoveDirection
310
312
  }
311
313
 
312
314
  try {
313
- applyDashboardResponse(
314
- await dashboardApi.moveDashboardGroup(props.dashboardSlug, groupId, direction),
315
- )
315
+ await dashboardApi.moveDashboardGroup(props.dashboardSlug, groupId, direction)
316
+ await refreshDashboardConfig()
316
317
  } catch (error) {
317
318
  console.error('Failed to move dashboard group', error)
318
319
  }
@@ -324,7 +325,8 @@ async function removeGroup(groupId: string) {
324
325
  }
325
326
 
326
327
  try {
327
- applyDashboardResponse(await dashboardApi.removeDashboardGroup(props.dashboardSlug, groupId))
328
+ await dashboardApi.removeDashboardGroup(props.dashboardSlug, groupId)
329
+ await refreshDashboardConfig()
328
330
  } catch (error) {
329
331
  console.error('Failed to remove dashboard group', error)
330
332
  }
@@ -348,13 +350,12 @@ async function saveGroupConfig() {
348
350
  try {
349
351
  const groupConfig = parseYaml(groupConfigCode.value) as EditableDashboardGroupConfig
350
352
 
351
- applyDashboardResponse(
352
- await dashboardApi.setDashboardGroupConfig(
353
- props.dashboardSlug,
354
- editingGroupId.value,
355
- groupConfig,
356
- ),
353
+ await dashboardApi.setDashboardGroupConfig(
354
+ props.dashboardSlug,
355
+ editingGroupId.value,
356
+ groupConfig,
357
357
  )
358
+ await refreshDashboardConfig()
358
359
  closeGroupConfigEditor()
359
360
  } catch (error) {
360
361
  groupConfigError.value = error instanceof Error ? error.message : 'Invalid group config'
@@ -373,9 +374,8 @@ async function moveWidget(widgetId: string, direction: DashboardWidgetMoveDirect
373
374
  }
374
375
 
375
376
  try {
376
- applyDashboardResponse(
377
- await dashboardApi.moveDashboardWidget(props.dashboardSlug, widgetId, direction),
378
- )
377
+ await dashboardApi.moveDashboardWidget(props.dashboardSlug, widgetId, direction)
378
+ await refreshDashboardConfig()
379
379
  } catch (error) {
380
380
  console.error('Failed to move dashboard widget', error)
381
381
  }
@@ -387,7 +387,8 @@ async function removeWidget(widgetId: string) {
387
387
  }
388
388
 
389
389
  try {
390
- applyDashboardResponse(await dashboardApi.removeDashboardWidget(props.dashboardSlug, widgetId))
390
+ await dashboardApi.removeDashboardWidget(props.dashboardSlug, widgetId)
391
+ await refreshDashboardConfig()
391
392
  } catch (error) {
392
393
  console.error('Failed to remove dashboard widget', error)
393
394
  }
@@ -410,13 +411,12 @@ async function saveWidgetConfig() {
410
411
  widgetConfigFieldErrors.value = []
411
412
  const widgetConfig = parseYaml(widgetConfigCode.value) as DashboardWidgetConfig
412
413
 
413
- applyDashboardResponse(
414
- await dashboardApi.setWidgetConfig(
415
- props.dashboardSlug,
416
- editingWidgetId.value,
417
- serializeDashboardWidgetConfigForEditor(widgetConfig),
418
- ),
414
+ await dashboardApi.setWidgetConfig(
415
+ props.dashboardSlug,
416
+ editingWidgetId.value,
417
+ serializeDashboardWidgetConfigForEditor(widgetConfig),
419
418
  )
419
+ await refreshDashboardConfig()
420
420
  closeWidgetConfigEditor()
421
421
  } catch (error) {
422
422
  widgetConfigError.value = error instanceof Error ? error.message : 'Invalid widget config'
@@ -431,6 +431,10 @@ function closeWidgetConfigEditor() {
431
431
  widgetConfigFieldErrors.value = []
432
432
  }
433
433
 
434
+ async function refreshDashboardConfig() {
435
+ applyDashboardResponse(await dashboardApi.getDashboardConfig(props.dashboardSlug))
436
+ }
437
+
434
438
  function applyDashboardResponse(response: DashboardResponse) {
435
439
  draftConfig.value = cloneConfig(response.config)
436
440
  currentRevision.value = response.revision
@@ -169,21 +169,18 @@ query:
169
169
  steps:
170
170
  - name: Leads
171
171
  resource: leads
172
- metric:
173
- agg: count
174
- as: value
172
+ select:
173
+ - agg: count
174
+ as: value
175
175
  - name: Customers
176
176
  resource: orders
177
- metric:
178
- agg: count_distinct
179
- field: customer_id
180
- as: value
181
-
182
- Each step may use either:
183
- - metric for one aggregate
184
- - select for multiple aggregate fields
177
+ select:
178
+ - agg: count_distinct
179
+ field: customer_id
180
+ as: value
185
181
 
186
182
  Do not use bare query.steps without source: steps.
183
+ Do not use metric. Use select even when a step has only one aggregate.
187
184
 
188
185
  ## Date range rules
189
186
 
@@ -224,22 +221,18 @@ select raw token totals:
224
221
  - sum output_tokens as output_tokens
225
222
 
226
223
  then query.calcs:
227
- - calculate total_spend from those aliases and lookup variables
224
+ - calculate total_spend from those aliases with explicit constants
228
225
 
229
226
  For today vs yesterday KPI, use multiple aggregate select items with filters and distinct aliases, then calcs.
230
227
 
231
- ## Calc variables
228
+ ## Calc rules
232
229
 
233
- Use variables for static maps/rates.
234
- Use lookup($variables.some.map, row_field, default_number) in query.calcs.
230
+ Calcs can reference only fields already present in the current row.
231
+ Use explicit constants for rates.
235
232
 
236
233
  Minimal example:
237
234
 
238
- variables:
239
- prices:
240
- gpt-5.4: 2.5
241
-
242
235
  query:
243
236
  calcs:
244
- - calc: tokens / 1000000 * lookup($variables.prices, model, 0)
237
+ - calc: tokens / 1000000 * 2.5
245
238
  as: cost