@adminforth/dashboard 1.2.0 → 1.4.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.
- package/README.md +116 -39
- package/custom/api/dashboardApi.ts +4 -0
- package/custom/composables/useElementSize.ts +17 -2
- package/custom/model/dashboard.types.ts +337 -236
- package/custom/skills/adminforth-dashboard/SKILL.md +113 -2
- package/custom/widgets/chart/ChartWidget.vue +38 -53
- package/custom/widgets/chart/bar/BarChart.vue +20 -12
- package/custom/widgets/chart/chart.types.ts +17 -66
- package/custom/widgets/chart/chart.utils.ts +11 -0
- package/custom/widgets/chart/funnel/FunnelChart.vue +6 -4
- package/custom/widgets/chart/line/LineChart.vue +23 -15
- package/custom/widgets/chart/stacked-bar/StackedBarChart.vue +28 -43
- package/custom/widgets/gauge-card/GaugeCardWidget.vue +7 -12
- package/custom/widgets/kpi-card/KpiCardWidget.vue +6 -8
- package/custom/widgets/pivot-table/PivotTableWidget.vue +8 -7
- package/custom/widgets/table/TableWidget.vue +8 -3
- package/dist/custom/api/dashboardApi.d.ts +1 -0
- package/dist/custom/api/dashboardApi.js +5 -0
- package/dist/custom/api/dashboardApi.ts +4 -0
- package/dist/custom/composables/useElementSize.js +14 -2
- package/dist/custom/composables/useElementSize.ts +17 -2
- package/dist/custom/model/dashboard.types.d.ts +181 -61
- package/dist/custom/model/dashboard.types.js +82 -93
- package/dist/custom/model/dashboard.types.ts +337 -236
- package/dist/custom/queries/useDashboardConfig.d.ts +852 -66
- package/dist/custom/queries/useWidgetData.d.ts +848 -62
- package/dist/custom/skills/adminforth-dashboard/SKILL.md +113 -2
- package/dist/custom/widgets/chart/ChartWidget.vue +38 -53
- package/dist/custom/widgets/chart/bar/BarChart.vue +20 -12
- package/dist/custom/widgets/chart/chart.types.d.ts +13 -22
- package/dist/custom/widgets/chart/chart.types.js +2 -25
- package/dist/custom/widgets/chart/chart.types.ts +17 -66
- package/dist/custom/widgets/chart/chart.utils.d.ts +1 -0
- package/dist/custom/widgets/chart/chart.utils.js +7 -0
- package/dist/custom/widgets/chart/chart.utils.ts +11 -0
- package/dist/custom/widgets/chart/funnel/FunnelChart.vue +6 -4
- package/dist/custom/widgets/chart/line/LineChart.vue +23 -15
- package/dist/custom/widgets/chart/stacked-bar/StackedBarChart.vue +28 -43
- package/dist/custom/widgets/gauge-card/GaugeCardWidget.vue +7 -12
- package/dist/custom/widgets/kpi-card/KpiCardWidget.vue +6 -8
- package/dist/custom/widgets/pivot-table/PivotTableWidget.vue +8 -7
- package/dist/custom/widgets/table/TableWidget.vue +8 -3
- package/dist/endpoint/dashboard.d.ts +7 -2
- package/dist/endpoint/dashboard.js +45 -1
- package/dist/endpoint/widgets.d.ts +2 -1
- package/dist/endpoint/widgets.js +6 -2
- package/dist/schema/api.d.ts +2773 -736
- package/dist/schema/api.js +5 -0
- package/dist/schema/widget.d.ts +1648 -476
- package/dist/schema/widget.js +208 -139
- package/dist/services/widgetConfigValidator.js +16 -40
- package/dist/services/widgetDataService.d.ts +2 -1
- package/dist/services/widgetDataService.js +389 -82
- package/endpoint/dashboard.ts +77 -4
- package/endpoint/widgets.ts +11 -4
- package/package.json +1 -1
- package/schema/api.ts +6 -0
- package/schema/widget.ts +225 -139
- package/services/widgetConfigValidator.ts +29 -53
- package/services/widgetDataService.ts +522 -100
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Filters, Sorts } from 'adminforth';
|
|
2
2
|
import type {
|
|
3
3
|
IAdminForth,
|
|
4
4
|
IAdminForthAndOrFilter,
|
|
@@ -6,11 +6,20 @@ import type {
|
|
|
6
6
|
IAdminForthSort,
|
|
7
7
|
} from 'adminforth';
|
|
8
8
|
import type {
|
|
9
|
-
AggregationRule,
|
|
10
9
|
DashboardWidgetConfig,
|
|
11
10
|
DashboardWidgetData,
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
DashboardVariables,
|
|
12
|
+
FilterExpression,
|
|
13
|
+
FunnelQueryConfig,
|
|
14
|
+
QueryAggregateOperation,
|
|
15
|
+
QueryAggregateSelectItem,
|
|
16
|
+
QueryCalcSelectItem,
|
|
17
|
+
QueryConfig,
|
|
18
|
+
QueryFieldSelectItem,
|
|
19
|
+
QueryGroupByItem,
|
|
20
|
+
QueryOrderByItem,
|
|
21
|
+
QuerySelectItem,
|
|
22
|
+
TimeGrain,
|
|
14
23
|
} from '../custom/model/dashboard.types.js';
|
|
15
24
|
|
|
16
25
|
export type DashboardWidgetDataOptions = {
|
|
@@ -18,6 +27,7 @@ export type DashboardWidgetDataOptions = {
|
|
|
18
27
|
page: number;
|
|
19
28
|
pageSize: number;
|
|
20
29
|
};
|
|
30
|
+
variables?: DashboardVariables;
|
|
21
31
|
};
|
|
22
32
|
|
|
23
33
|
type DashboardWidgetFilters =
|
|
@@ -25,6 +35,17 @@ type DashboardWidgetFilters =
|
|
|
25
35
|
| IAdminForthAndOrFilter
|
|
26
36
|
| Array<IAdminForthSingleFilter | IAdminForthAndOrFilter>;
|
|
27
37
|
|
|
38
|
+
type QueryRowGroup = {
|
|
39
|
+
rows: Record<string, unknown>[];
|
|
40
|
+
values: Record<string, unknown>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const NOW_MINUS_RE = /^(\d+)([dhw])$/;
|
|
44
|
+
const CALC_IDENTIFIER_RE = /\b[a-zA-Z_][a-zA-Z0-9_]*\b/g;
|
|
45
|
+
const LOOKUP_CALL_RE = /lookup\(\s*(\$variables(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)\s*,\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/g;
|
|
46
|
+
const VARIABLE_PATH_PREFIX_RE = /^\$variables\.?/;
|
|
47
|
+
const SAFE_CALC_EXPRESSION_RE = /^[\d+\-*/().\s]+$/;
|
|
48
|
+
|
|
28
49
|
export type WidgetDataService = {
|
|
29
50
|
getWidgetData: (widget: DashboardWidgetConfig, options?: DashboardWidgetDataOptions) => Promise<DashboardWidgetData | null>;
|
|
30
51
|
};
|
|
@@ -34,163 +55,564 @@ export async function getWidgetData(
|
|
|
34
55
|
widget: DashboardWidgetConfig,
|
|
35
56
|
options: DashboardWidgetDataOptions = {},
|
|
36
57
|
): Promise<DashboardWidgetData | null> {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (!dataSource) {
|
|
58
|
+
if (!('query' in widget)) {
|
|
40
59
|
return null;
|
|
41
60
|
}
|
|
42
61
|
|
|
43
|
-
|
|
44
|
-
|
|
62
|
+
const data = 'steps' in widget.query
|
|
63
|
+
? await getFunnelWidgetData(adminforth, widget.query, options.variables ?? {})
|
|
64
|
+
: await getQueryWidgetData(adminforth, widget.query, options.variables ?? {});
|
|
65
|
+
|
|
66
|
+
if (widget.target !== 'table' || !options.pagination) {
|
|
67
|
+
return data;
|
|
45
68
|
}
|
|
46
69
|
|
|
47
|
-
|
|
70
|
+
const page = options.pagination.page;
|
|
71
|
+
const pageSize = options.pagination.pageSize;
|
|
72
|
+
const offset = (page - 1) * pageSize;
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
...data,
|
|
76
|
+
rows: data.rows.slice(offset, offset + pageSize),
|
|
77
|
+
pagination: {
|
|
78
|
+
page,
|
|
79
|
+
pageSize,
|
|
80
|
+
total: data.rows.length,
|
|
81
|
+
totalPages: Math.max(Math.ceil(data.rows.length / pageSize), 1),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
48
84
|
}
|
|
49
85
|
|
|
50
|
-
async function
|
|
86
|
+
async function getFunnelWidgetData(
|
|
51
87
|
adminforth: IAdminForth,
|
|
52
|
-
|
|
53
|
-
|
|
88
|
+
query: FunnelQueryConfig,
|
|
89
|
+
variables: DashboardVariables,
|
|
54
90
|
): Promise<DashboardWidgetData> {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
limit ?? undefined,
|
|
65
|
-
offset,
|
|
66
|
-
sort,
|
|
67
|
-
);
|
|
91
|
+
const rows = await Promise.all(query.steps.map(async (step) => {
|
|
92
|
+
const valueField = step.metric.as;
|
|
93
|
+
const sourceRows = await getResourceRows(adminforth, step.resource, step.filters);
|
|
94
|
+
|
|
95
|
+
const row: Record<string, unknown> = {
|
|
96
|
+
name: step.name,
|
|
97
|
+
resource: step.resource,
|
|
98
|
+
[valueField]: calculateAggregate(sourceRows, step.metric),
|
|
99
|
+
};
|
|
68
100
|
|
|
69
|
-
|
|
70
|
-
|
|
101
|
+
for (const calc of query.calcs ?? []) {
|
|
102
|
+
row[calc.as] = evaluateCalc(calc.calc, row, variables);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return row;
|
|
106
|
+
}));
|
|
71
107
|
|
|
72
108
|
return {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
pageSize: pagination.pageSize,
|
|
81
|
-
total,
|
|
82
|
-
totalPages: Math.max(Math.ceil(total / pagination.pageSize), 1),
|
|
83
|
-
},
|
|
84
|
-
} : {}),
|
|
109
|
+
kind: 'aggregate',
|
|
110
|
+
columns: [
|
|
111
|
+
'name',
|
|
112
|
+
...Array.from(new Set(query.steps.map((step) => step.metric.as))),
|
|
113
|
+
...Array.from(new Set((query.calcs ?? []).map((calc) => calc.as))),
|
|
114
|
+
],
|
|
115
|
+
rows,
|
|
85
116
|
};
|
|
86
117
|
}
|
|
87
118
|
|
|
88
|
-
async function
|
|
119
|
+
async function getQueryWidgetData(
|
|
89
120
|
adminforth: IAdminForth,
|
|
90
|
-
|
|
121
|
+
query: QueryConfig,
|
|
122
|
+
variables: DashboardVariables,
|
|
91
123
|
): Promise<DashboardWidgetData> {
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
]),
|
|
100
|
-
),
|
|
101
|
-
dataSource.groupBy ? createGroupByRule(dataSource.groupBy) : undefined,
|
|
102
|
-
);
|
|
103
|
-
const columns = Object.keys(rows[0] ?? {});
|
|
124
|
+
const rows = await getResourceRows(adminforth, query.resource, query.filters, getBackendSort(query.orderBy));
|
|
125
|
+
const selectedRows = buildQueryRows(rows, query, variables);
|
|
126
|
+
const orderedRows = sortRows(selectedRows, query.orderBy);
|
|
127
|
+
const slicedRows = typeof query.limit === 'number'
|
|
128
|
+
? orderedRows.slice(query.offset ?? 0, (query.offset ?? 0) + query.limit)
|
|
129
|
+
: orderedRows.slice(query.offset ?? 0);
|
|
130
|
+
const columns = getColumns(slicedRows, query);
|
|
104
131
|
|
|
105
|
-
if (
|
|
106
|
-
const values =
|
|
132
|
+
if (isAggregateQuery(query)) {
|
|
133
|
+
const values = slicedRows.length === 1 ? slicedRows[0] : undefined;
|
|
107
134
|
|
|
108
135
|
return {
|
|
109
136
|
kind: 'aggregate',
|
|
110
|
-
columns
|
|
111
|
-
rows:
|
|
112
|
-
values,
|
|
137
|
+
columns,
|
|
138
|
+
rows: slicedRows,
|
|
139
|
+
...(values ? { values } : {}),
|
|
113
140
|
};
|
|
114
141
|
}
|
|
115
142
|
|
|
116
143
|
return {
|
|
117
|
-
kind: '
|
|
144
|
+
kind: 'table',
|
|
118
145
|
columns,
|
|
119
|
-
rows,
|
|
146
|
+
rows: slicedRows,
|
|
120
147
|
};
|
|
121
148
|
}
|
|
122
149
|
|
|
123
|
-
function
|
|
124
|
-
|
|
125
|
-
|
|
150
|
+
async function getResourceRows(
|
|
151
|
+
adminforth: IAdminForth,
|
|
152
|
+
resourceId: string,
|
|
153
|
+
filters: unknown,
|
|
154
|
+
sort?: IAdminForthSort | IAdminForthSort[],
|
|
155
|
+
) {
|
|
156
|
+
return adminforth.resource(resourceId).list(
|
|
157
|
+
normalizeFilters(filters),
|
|
158
|
+
undefined,
|
|
159
|
+
0,
|
|
160
|
+
sort,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function buildQueryRows(rows: Record<string, unknown>[], query: QueryConfig, variables: DashboardVariables) {
|
|
165
|
+
const select = query.select ?? getDefaultSelect(rows);
|
|
166
|
+
const groupBy = query.groupBy ?? [];
|
|
167
|
+
|
|
168
|
+
if (isAggregateQuery(query)) {
|
|
169
|
+
return buildGroupedRows(rows, select, groupBy, variables, query.calcs);
|
|
126
170
|
}
|
|
127
171
|
|
|
128
|
-
return
|
|
172
|
+
return rows.map((row) => buildPlainRow(row, select, query.calcs, variables));
|
|
129
173
|
}
|
|
130
174
|
|
|
131
|
-
function
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
175
|
+
function buildGroupedRows(
|
|
176
|
+
rows: Record<string, unknown>[],
|
|
177
|
+
select: QuerySelectItem[],
|
|
178
|
+
groupBy: QueryGroupByItem[],
|
|
179
|
+
variables: DashboardVariables,
|
|
180
|
+
calcs: QueryCalcSelectItem[] = [],
|
|
181
|
+
) {
|
|
182
|
+
const groups = new Map<string, QueryRowGroup>();
|
|
183
|
+
const effectiveGroupBy = groupBy.length
|
|
184
|
+
? groupBy
|
|
185
|
+
: select.filter(isFieldSelectItem).map((item) => ({ field: item.field, as: item.as, grain: item.grain }));
|
|
136
186
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return
|
|
187
|
+
if (!effectiveGroupBy.length) {
|
|
188
|
+
const values = calculateGroupValues(rows, select, calcs, variables);
|
|
189
|
+
return Object.keys(values).length ? [values] : [];
|
|
140
190
|
}
|
|
141
191
|
|
|
142
|
-
|
|
143
|
-
|
|
192
|
+
for (const row of rows) {
|
|
193
|
+
const values = Object.fromEntries(effectiveGroupBy.map((item) => {
|
|
194
|
+
const field = getGroupByField(item);
|
|
195
|
+
const alias = getGroupByAlias(item);
|
|
196
|
+
const grain = getGroupByGrain(item);
|
|
197
|
+
|
|
198
|
+
return [alias, formatGroupValue(row[field], grain)];
|
|
199
|
+
}));
|
|
200
|
+
const key = JSON.stringify(values);
|
|
201
|
+
const group = groups.get(key) ?? { values, rows: [] };
|
|
202
|
+
|
|
203
|
+
group.rows.push(row);
|
|
204
|
+
groups.set(key, group);
|
|
144
205
|
}
|
|
145
206
|
|
|
146
|
-
return
|
|
207
|
+
return Array.from(groups.values()).map((group) => ({
|
|
208
|
+
...group.values,
|
|
209
|
+
...calculateGroupValues(group.rows, select, calcs, variables, group.values),
|
|
210
|
+
}));
|
|
147
211
|
}
|
|
148
212
|
|
|
149
|
-
function
|
|
150
|
-
|
|
151
|
-
|
|
213
|
+
function calculateGroupValues(
|
|
214
|
+
rows: Record<string, unknown>[],
|
|
215
|
+
select: QuerySelectItem[],
|
|
216
|
+
calcs: QueryCalcSelectItem[],
|
|
217
|
+
variables: DashboardVariables,
|
|
218
|
+
baseValues: Record<string, unknown> = {},
|
|
219
|
+
) {
|
|
220
|
+
const values: Record<string, unknown> = { ...baseValues };
|
|
221
|
+
|
|
222
|
+
for (const item of select) {
|
|
223
|
+
if (isAggregateSelectItem(item)) {
|
|
224
|
+
const filteredRows = item.filters
|
|
225
|
+
? rows.filter((row) => matchesFilterExpression(row, item.filters as FilterExpression))
|
|
226
|
+
: rows;
|
|
227
|
+
|
|
228
|
+
values[item.as] = calculateAggregate(filteredRows, item);
|
|
229
|
+
}
|
|
152
230
|
}
|
|
153
231
|
|
|
154
|
-
|
|
155
|
-
|
|
232
|
+
for (const item of [...select.filter(isCalcSelectItem), ...calcs]) {
|
|
233
|
+
values[item.as] = evaluateCalc(item.calc, values, variables);
|
|
156
234
|
}
|
|
157
235
|
|
|
158
|
-
|
|
159
|
-
|
|
236
|
+
return values;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function buildPlainRow(
|
|
240
|
+
row: Record<string, unknown>,
|
|
241
|
+
select: QuerySelectItem[],
|
|
242
|
+
calcs: QueryCalcSelectItem[] = [],
|
|
243
|
+
variables: DashboardVariables,
|
|
244
|
+
) {
|
|
245
|
+
const values: Record<string, unknown> = {};
|
|
246
|
+
|
|
247
|
+
for (const item of select) {
|
|
248
|
+
if (isFieldSelectItem(item)) {
|
|
249
|
+
values[item.as ?? item.field] = item.grain
|
|
250
|
+
? formatGroupValue(row[item.field], item.grain)
|
|
251
|
+
: row[item.field];
|
|
252
|
+
}
|
|
160
253
|
}
|
|
161
254
|
|
|
162
|
-
|
|
163
|
-
|
|
255
|
+
for (const item of [...select.filter(isCalcSelectItem), ...calcs]) {
|
|
256
|
+
values[item.as] = evaluateCalc(item.calc, values, variables);
|
|
164
257
|
}
|
|
165
258
|
|
|
166
|
-
return
|
|
259
|
+
return values;
|
|
167
260
|
}
|
|
168
261
|
|
|
169
|
-
function
|
|
170
|
-
switch (
|
|
171
|
-
case 'sum':
|
|
172
|
-
return Aggregates.sum(rule.field!);
|
|
262
|
+
function calculateAggregate(rows: Record<string, unknown>[], item: QueryAggregateSelectItem) {
|
|
263
|
+
switch (item.agg) {
|
|
173
264
|
case 'count':
|
|
174
|
-
return
|
|
265
|
+
return rows.length;
|
|
266
|
+
case 'count_distinct':
|
|
267
|
+
return new Set(rows.map((row) => row[item.field!])).size;
|
|
268
|
+
case 'sum':
|
|
269
|
+
return aggregateNumbers(rows, item.field!, (values) => values.reduce((sum, value) => sum + value, 0));
|
|
175
270
|
case 'avg':
|
|
176
|
-
return
|
|
271
|
+
return aggregateNumbers(rows, item.field!, (values) => values.length
|
|
272
|
+
? values.reduce((sum, value) => sum + value, 0) / values.length
|
|
273
|
+
: 0);
|
|
177
274
|
case 'min':
|
|
178
|
-
return
|
|
275
|
+
return aggregateNumbers(rows, item.field!, (values) => values.length ? Math.min(...values) : 0);
|
|
179
276
|
case 'max':
|
|
180
|
-
return
|
|
277
|
+
return aggregateNumbers(rows, item.field!, (values) => values.length ? Math.max(...values) : 0);
|
|
181
278
|
case 'median':
|
|
182
|
-
return
|
|
279
|
+
return aggregateNumbers(rows, item.field!, calculateMedian);
|
|
183
280
|
default:
|
|
184
|
-
throw new Error(`Unsupported aggregation operation: ${(
|
|
281
|
+
throw new Error(`Unsupported aggregation operation: ${(item as { agg: QueryAggregateOperation }).agg}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function aggregateNumbers(
|
|
286
|
+
rows: Record<string, unknown>[],
|
|
287
|
+
field: string,
|
|
288
|
+
aggregate: (values: number[]) => number,
|
|
289
|
+
) {
|
|
290
|
+
return aggregate(rows.map((row) => toFiniteNumber(row[field])).filter(Number.isFinite));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function calculateMedian(values: number[]) {
|
|
294
|
+
if (!values.length) {
|
|
295
|
+
return 0;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
299
|
+
const middle = Math.floor(sorted.length / 2);
|
|
300
|
+
|
|
301
|
+
return sorted.length % 2
|
|
302
|
+
? sorted[middle]
|
|
303
|
+
: (sorted[middle - 1] + sorted[middle]) / 2;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function evaluateCalc(calc: string, values: Record<string, unknown>, variables: DashboardVariables) {
|
|
307
|
+
const expression = calc
|
|
308
|
+
.replace(LOOKUP_CALL_RE, (_match, path: string, keyField: string, defaultValue: string) => {
|
|
309
|
+
const map = resolveVariablePath(variables, path);
|
|
310
|
+
const key = String(values[keyField] ?? '');
|
|
311
|
+
|
|
312
|
+
return String(toFiniteNumber(isRecord(map) && Object.prototype.hasOwnProperty.call(map, key)
|
|
313
|
+
? map[key]
|
|
314
|
+
: Number(defaultValue)));
|
|
315
|
+
})
|
|
316
|
+
.replace(CALC_IDENTIFIER_RE, (name) => String(toFiniteNumber(values[name])));
|
|
317
|
+
|
|
318
|
+
if (!SAFE_CALC_EXPRESSION_RE.test(expression)) {
|
|
319
|
+
throw new Error(`Unsupported calc expression: ${calc}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return Function(`"use strict"; return (${expression});`)();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function resolveVariablePath(variables: DashboardVariables, path: string) {
|
|
326
|
+
return path
|
|
327
|
+
.replace(VARIABLE_PATH_PREFIX_RE, '')
|
|
328
|
+
.split('.')
|
|
329
|
+
.filter(Boolean)
|
|
330
|
+
.reduce<unknown>((current, segment) => isRecord(current) ? current[segment] : undefined, variables);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function sortRows(rows: Record<string, unknown>[], orderBy: QueryOrderByItem[] = []) {
|
|
334
|
+
if (!orderBy.length) {
|
|
335
|
+
return rows;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return [...rows].sort((left, right) => {
|
|
339
|
+
for (const order of orderBy) {
|
|
340
|
+
const direction = order.direction === 'asc' ? 1 : -1;
|
|
341
|
+
const result = compareValues(left[order.field], right[order.field]);
|
|
342
|
+
|
|
343
|
+
if (result !== 0) {
|
|
344
|
+
return result * direction;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return 0;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function compareValues(left: unknown, right: unknown) {
|
|
353
|
+
if (typeof left === 'number' && typeof right === 'number') {
|
|
354
|
+
return left - right;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return String(left ?? '').localeCompare(String(right ?? ''));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function getBackendSort(orderBy: QueryOrderByItem[] | undefined) {
|
|
361
|
+
if (!orderBy?.length) {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return orderBy.map((order) => order.direction === 'asc'
|
|
366
|
+
? Sorts.ASC(order.field)
|
|
367
|
+
: Sorts.DESC(order.field));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function getColumns(rows: Record<string, unknown>[], query: QueryConfig) {
|
|
371
|
+
const selectColumns = [
|
|
372
|
+
...(query.groupBy ?? []).map(getGroupByAlias),
|
|
373
|
+
...(query.select ?? []).map(getSelectAlias),
|
|
374
|
+
...(query.calcs ?? []).map((item) => item.as),
|
|
375
|
+
].filter(Boolean);
|
|
376
|
+
|
|
377
|
+
return Array.from(new Set(selectColumns.length ? selectColumns : Object.keys(rows[0] ?? {})));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function getDefaultSelect(rows: Record<string, unknown>[]): QuerySelectItem[] {
|
|
381
|
+
return Object.keys(rows[0] ?? {}).map((field) => ({ field }));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function isAggregateQuery(query: QueryConfig) {
|
|
385
|
+
return Boolean(
|
|
386
|
+
query.groupBy?.length
|
|
387
|
+
|| query.select?.some((item) => isAggregateSelectItem(item)),
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function isFieldSelectItem(item: QuerySelectItem): item is QueryFieldSelectItem {
|
|
392
|
+
return 'field' in item && !('agg' in item);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function isAggregateSelectItem(item: QuerySelectItem): item is QueryAggregateSelectItem {
|
|
396
|
+
return 'agg' in item;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function isCalcSelectItem(item: QuerySelectItem): item is QueryCalcSelectItem {
|
|
400
|
+
return 'calc' in item;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function getSelectAlias(item: QuerySelectItem) {
|
|
404
|
+
if (isFieldSelectItem(item)) {
|
|
405
|
+
return item.as ?? item.field;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return item.as;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function getGroupByField(item: QueryGroupByItem) {
|
|
412
|
+
return typeof item === 'string' ? item : item.field;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function getGroupByAlias(item: QueryGroupByItem) {
|
|
416
|
+
return typeof item === 'string' ? item : item.as ?? item.field;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function getGroupByGrain(item: QueryGroupByItem) {
|
|
420
|
+
return typeof item === 'string' ? undefined : item.grain;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function formatGroupValue(value: unknown, grain: TimeGrain | undefined) {
|
|
424
|
+
if (!grain) {
|
|
425
|
+
return value;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const date = new Date(String(value));
|
|
429
|
+
|
|
430
|
+
if (!Number.isFinite(date.getTime())) {
|
|
431
|
+
return value;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (grain === 'year') {
|
|
435
|
+
return `${date.getUTCFullYear()}`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (grain === 'quarter') {
|
|
439
|
+
return `${date.getUTCFullYear()}-Q${Math.floor(date.getUTCMonth() / 3) + 1}`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
443
|
+
|
|
444
|
+
if (grain === 'month') {
|
|
445
|
+
return `${date.getUTCFullYear()}-${month}`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const day = String(date.getUTCDate()).padStart(2, '0');
|
|
449
|
+
|
|
450
|
+
if (grain === 'week') {
|
|
451
|
+
const weekStart = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
452
|
+
weekStart.setUTCDate(weekStart.getUTCDate() - weekStart.getUTCDay());
|
|
453
|
+
return weekStart.toISOString().slice(0, 10);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (grain === 'day') {
|
|
457
|
+
return `${date.getUTCFullYear()}-${month}-${day}`;
|
|
185
458
|
}
|
|
459
|
+
|
|
460
|
+
const hour = String(date.getUTCHours()).padStart(2, '0');
|
|
461
|
+
return `${date.getUTCFullYear()}-${month}-${day}T${hour}:00:00.000Z`;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function normalizeFilters(filters: unknown): DashboardWidgetFilters {
|
|
465
|
+
if (Array.isArray(filters)) {
|
|
466
|
+
return filters.map((filter) => normalizeFilterNode(filter)) as DashboardWidgetFilters;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (isRecord(filters)) {
|
|
470
|
+
return normalizeFilterNode(filters);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return [];
|
|
186
474
|
}
|
|
187
475
|
|
|
188
|
-
function
|
|
189
|
-
if (
|
|
190
|
-
return
|
|
476
|
+
function normalizeFilterNode(filter: unknown): IAdminForthSingleFilter | IAdminForthAndOrFilter {
|
|
477
|
+
if (!isRecord(filter)) {
|
|
478
|
+
return filter as IAdminForthSingleFilter;
|
|
191
479
|
}
|
|
192
480
|
|
|
193
|
-
|
|
481
|
+
if (Array.isArray(filter.and)) {
|
|
482
|
+
return Filters.AND(filter.and.map((item) => normalizeFilterNode(item)));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (Array.isArray(filter.or)) {
|
|
486
|
+
return Filters.OR(filter.or.map((item) => normalizeFilterNode(item)));
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (typeof filter.field === 'string') {
|
|
490
|
+
if (Object.prototype.hasOwnProperty.call(filter, 'eq')) {
|
|
491
|
+
return Filters.EQ(filter.field, normalizeFilterValue(filter.eq));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (Object.prototype.hasOwnProperty.call(filter, 'neq')) {
|
|
495
|
+
return Filters.NEQ(filter.field, normalizeFilterValue(filter.neq));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (Object.prototype.hasOwnProperty.call(filter, 'gt')) {
|
|
499
|
+
return Filters.GT(filter.field, normalizeFilterValue(filter.gt));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (Object.prototype.hasOwnProperty.call(filter, 'gte')) {
|
|
503
|
+
return Filters.GTE(filter.field, normalizeFilterValue(filter.gte));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (Object.prototype.hasOwnProperty.call(filter, 'lt')) {
|
|
507
|
+
return Filters.LT(filter.field, normalizeFilterValue(filter.lt));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (Object.prototype.hasOwnProperty.call(filter, 'lte')) {
|
|
511
|
+
return Filters.LTE(filter.field, normalizeFilterValue(filter.lte));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (Object.prototype.hasOwnProperty.call(filter, 'in')) {
|
|
515
|
+
return Filters.IN(filter.field, normalizeFilterValue(filter.in));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (Object.prototype.hasOwnProperty.call(filter, 'not_in')) {
|
|
519
|
+
return Filters.NOT_IN(filter.field, normalizeFilterValue(filter.not_in));
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return filter as IAdminForthSingleFilter | IAdminForthAndOrFilter;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function matchesFilterExpression(row: Record<string, unknown>, filter: FilterExpression): boolean {
|
|
527
|
+
if (Array.isArray(filter)) {
|
|
528
|
+
return filter.every((item) => matchesFilterExpression(row, item));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if ('and' in filter) {
|
|
532
|
+
return filter.and.every((item) => matchesFilterExpression(row, item));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if ('or' in filter) {
|
|
536
|
+
return filter.or.some((item) => matchesFilterExpression(row, item));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const value = row[filter.field];
|
|
540
|
+
|
|
541
|
+
if (Object.prototype.hasOwnProperty.call(filter, 'eq')) {
|
|
542
|
+
return value === normalizeFilterValue(filter.eq);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (Object.prototype.hasOwnProperty.call(filter, 'neq')) {
|
|
546
|
+
return value !== normalizeFilterValue(filter.neq);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (Object.prototype.hasOwnProperty.call(filter, 'gt')) {
|
|
550
|
+
return compareComparableValues(value, normalizeFilterValue(filter.gt)) > 0;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (Object.prototype.hasOwnProperty.call(filter, 'gte')) {
|
|
554
|
+
return compareComparableValues(value, normalizeFilterValue(filter.gte)) >= 0;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (Object.prototype.hasOwnProperty.call(filter, 'lt')) {
|
|
558
|
+
return compareComparableValues(value, normalizeFilterValue(filter.lt)) < 0;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (Object.prototype.hasOwnProperty.call(filter, 'lte')) {
|
|
562
|
+
return compareComparableValues(value, normalizeFilterValue(filter.lte)) <= 0;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (Object.prototype.hasOwnProperty.call(filter, 'in')) {
|
|
566
|
+
return filter.in?.includes(value) ?? false;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (Object.prototype.hasOwnProperty.call(filter, 'not_in')) {
|
|
570
|
+
return !(filter.not_in?.includes(value) ?? false);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function compareComparableValues(left: unknown, right: unknown) {
|
|
577
|
+
const leftNumber = Number(left);
|
|
578
|
+
const rightNumber = Number(right);
|
|
579
|
+
|
|
580
|
+
if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) {
|
|
581
|
+
return leftNumber - rightNumber;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return String(left ?? '').localeCompare(String(right ?? ''));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function normalizeFilterValue(value: unknown) {
|
|
588
|
+
if (!isRecord(value) || typeof value.now_minus !== 'string') {
|
|
589
|
+
return value;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const match = value.now_minus.match(NOW_MINUS_RE);
|
|
593
|
+
|
|
594
|
+
if (!match) {
|
|
595
|
+
return value;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const amount = Number(match[1]);
|
|
599
|
+
const unit = match[2];
|
|
600
|
+
const date = new Date();
|
|
601
|
+
|
|
602
|
+
if (unit === 'h') {
|
|
603
|
+
date.setHours(date.getHours() - amount);
|
|
604
|
+
} else if (unit === 'w') {
|
|
605
|
+
date.setDate(date.getDate() - amount * 7);
|
|
606
|
+
} else {
|
|
607
|
+
date.setDate(date.getDate() - amount);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return date.toISOString();
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function toFiniteNumber(value: unknown) {
|
|
614
|
+
const numberValue = typeof value === 'number' ? value : Number(value);
|
|
615
|
+
return Number.isFinite(numberValue) ? numberValue : 0;
|
|
194
616
|
}
|
|
195
617
|
|
|
196
618
|
function isRecord(value: unknown): value is Record<string, any> {
|