@n8n/frontend-module-insights 0.1.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/LICENSE.md +88 -0
- package/README.md +64 -0
- package/biome.jsonc +4 -0
- package/eslint.config.mjs +33 -0
- package/package.json +72 -0
- package/src/__tests__/setup.ts +4 -0
- package/src/chartjs.utils.ts +208 -0
- package/src/components/InsightsDashboard.test.ts +891 -0
- package/src/components/InsightsDashboard.vue +426 -0
- package/src/components/InsightsDataRangePicker.vue +195 -0
- package/src/components/InsightsDateRangeAlert.test.ts +134 -0
- package/src/components/InsightsDateRangeAlert.vue +89 -0
- package/src/components/InsightsPaywall.vue +42 -0
- package/src/components/InsightsSummary.test.ts +206 -0
- package/src/components/InsightsSummary.vue +357 -0
- package/src/components/InsightsUpgradeModal.vue +74 -0
- package/src/components/__snapshots__/InsightsSummary.test.ts.snap +446 -0
- package/src/components/charts/InsightsChartAverageRuntime.vue +67 -0
- package/src/components/charts/InsightsChartFailed.vue +57 -0
- package/src/components/charts/InsightsChartFailureRate.vue +58 -0
- package/src/components/charts/InsightsChartTimeSaved.vue +74 -0
- package/src/components/charts/InsightsChartTotal.vue +58 -0
- package/src/components/charts/insightChartProps.ts +9 -0
- package/src/components/tables/InsightsTableWorkflows.test.ts +509 -0
- package/src/components/tables/InsightsTableWorkflows.vue +279 -0
- package/src/index.ts +4 -0
- package/src/insights.api.test.ts +435 -0
- package/src/insights.api.ts +71 -0
- package/src/insights.constants.ts +69 -0
- package/src/insights.module.test.ts +59 -0
- package/src/insights.module.ts +45 -0
- package/src/insights.store.test.ts +118 -0
- package/src/insights.store.ts +88 -0
- package/src/insights.types.ts +16 -0
- package/src/insights.utils.test.ts +339 -0
- package/src/insights.utils.ts +172 -0
- package/stylelint.config.mjs +9 -0
- package/vite.config.ts +29 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
<script lang="ts" setup="">
|
|
2
|
+
import type { InsightsByWorkflow } from '@n8n/api-types';
|
|
3
|
+
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
|
4
|
+
import type { TableHeader } from '@n8n/design-system';
|
|
5
|
+
import { N8nDataTableServer, N8nHeading, N8nTooltip } from '@n8n/design-system';
|
|
6
|
+
import { VIEWS } from '@n8n/frontend-constants/views';
|
|
7
|
+
import { useI18n } from '@n8n/i18n';
|
|
8
|
+
import { smartDecimal } from '@n8n/utils/number/smart-decimal';
|
|
9
|
+
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
|
10
|
+
import { type RouteLocationRaw, type LocationQueryRaw, RouterLink } from 'vue-router';
|
|
11
|
+
|
|
12
|
+
import { INSIGHTS_UNIT_MAPPING } from '../../insights.constants';
|
|
13
|
+
import {
|
|
14
|
+
transformInsightsAverageRunTime,
|
|
15
|
+
transformInsightsFailureRate,
|
|
16
|
+
transformInsightsTimeSaved,
|
|
17
|
+
} from '../../insights.utils';
|
|
18
|
+
const InsightsPaywall = defineAsyncComponent(async () => await import('../InsightsPaywall.vue'));
|
|
19
|
+
|
|
20
|
+
const props = defineProps<{
|
|
21
|
+
data: InsightsByWorkflow;
|
|
22
|
+
loading?: boolean;
|
|
23
|
+
isDashboardEnabled?: boolean;
|
|
24
|
+
}>();
|
|
25
|
+
|
|
26
|
+
const i18n = useI18n();
|
|
27
|
+
const telemetry = useTelemetry();
|
|
28
|
+
|
|
29
|
+
type Item = InsightsByWorkflow['data'][number];
|
|
30
|
+
|
|
31
|
+
const sampleWorkflows: InsightsByWorkflow['data'] = Array.from({ length: 10 }, (_, i) => ({
|
|
32
|
+
workflowId: `sample-workflow-${i + 1}`,
|
|
33
|
+
workflowName: `Sample Workflow ${i + 1}`,
|
|
34
|
+
hasReadAccess: true,
|
|
35
|
+
total: Math.floor(Math.random() * 2000) + 500,
|
|
36
|
+
failed: Math.floor(Math.random() * 100) + 20,
|
|
37
|
+
failureRate: Math.random() * 100,
|
|
38
|
+
timeSaved: Math.floor(Math.random() * 300000) + 60000,
|
|
39
|
+
averageRunTime: Math.floor(Math.random() * 60000) + 15000,
|
|
40
|
+
projectName: `Sample Project ${i + 1}`,
|
|
41
|
+
projectId: `sample-project-${i + 1}`,
|
|
42
|
+
succeeded: Math.floor(Math.random() * 2000) + 500,
|
|
43
|
+
runTime: Math.floor(Math.random() * 60000) + 15000,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
const sampleData: InsightsByWorkflow = {
|
|
47
|
+
data: sampleWorkflows,
|
|
48
|
+
count: sampleWorkflows.length,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const tableData = computed(() => (props.isDashboardEnabled ? props.data : sampleData));
|
|
52
|
+
const rows = computed(() => tableData.value.data);
|
|
53
|
+
|
|
54
|
+
const headers = ref<Array<TableHeader<Item>>>([
|
|
55
|
+
{
|
|
56
|
+
title: 'Name',
|
|
57
|
+
key: 'workflowName',
|
|
58
|
+
width: 400,
|
|
59
|
+
disableSort: !props.isDashboardEnabled,
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
title: i18n.baseText('insights.banner.title.total'),
|
|
63
|
+
key: 'total',
|
|
64
|
+
value(row) {
|
|
65
|
+
return row.total.toLocaleString('en-US');
|
|
66
|
+
},
|
|
67
|
+
disableSort: !props.isDashboardEnabled,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
title: i18n.baseText('insights.banner.title.failed'),
|
|
71
|
+
key: 'failed',
|
|
72
|
+
value(row) {
|
|
73
|
+
return row.failed.toLocaleString('en-US');
|
|
74
|
+
},
|
|
75
|
+
disableSort: !props.isDashboardEnabled,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
title: i18n.baseText('insights.banner.title.failureRate'),
|
|
79
|
+
key: 'failureRate',
|
|
80
|
+
value(row) {
|
|
81
|
+
return (
|
|
82
|
+
smartDecimal(transformInsightsFailureRate(row.failureRate)) +
|
|
83
|
+
INSIGHTS_UNIT_MAPPING.failureRate(row.failureRate)
|
|
84
|
+
);
|
|
85
|
+
},
|
|
86
|
+
disableSort: !props.isDashboardEnabled,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
title: i18n.baseText('insights.banner.title.timeSaved'),
|
|
90
|
+
key: 'timeSaved',
|
|
91
|
+
value(row) {
|
|
92
|
+
return (
|
|
93
|
+
smartDecimal(transformInsightsTimeSaved(row.timeSaved)) +
|
|
94
|
+
INSIGHTS_UNIT_MAPPING.timeSaved(row.timeSaved)
|
|
95
|
+
);
|
|
96
|
+
},
|
|
97
|
+
disableSort: !props.isDashboardEnabled,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
title: i18n.baseText('insights.banner.title.averageRunTime'),
|
|
101
|
+
key: 'averageRunTime',
|
|
102
|
+
value(row) {
|
|
103
|
+
return (
|
|
104
|
+
smartDecimal(transformInsightsAverageRunTime(row.averageRunTime)) +
|
|
105
|
+
INSIGHTS_UNIT_MAPPING.averageRunTime(row.averageRunTime)
|
|
106
|
+
);
|
|
107
|
+
},
|
|
108
|
+
disableSort: !props.isDashboardEnabled,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
title: i18n.baseText('insights.dashboard.table.projectName'),
|
|
112
|
+
key: 'projectName',
|
|
113
|
+
disableSort: true,
|
|
114
|
+
},
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
const sortBy = defineModel<Array<{ id: string; desc: boolean }>>('sortBy');
|
|
118
|
+
const currentPage = ref(0);
|
|
119
|
+
const itemsPerPage = ref(25);
|
|
120
|
+
|
|
121
|
+
const emit = defineEmits<{
|
|
122
|
+
'update:options': [
|
|
123
|
+
payload: {
|
|
124
|
+
page: number;
|
|
125
|
+
itemsPerPage: number;
|
|
126
|
+
sortBy: Array<{ id: string; desc: boolean }>;
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
}>();
|
|
130
|
+
|
|
131
|
+
const getWorkflowLink = (item: Item, query?: LocationQueryRaw): RouteLocationRaw => ({
|
|
132
|
+
name: VIEWS.WORKFLOW,
|
|
133
|
+
params: {
|
|
134
|
+
workflowId: item.workflowId,
|
|
135
|
+
},
|
|
136
|
+
query,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const trackWorkflowClick = (item: Item) => {
|
|
140
|
+
telemetry.track('User clicked on workflow from insights table', {
|
|
141
|
+
workflow_id: item.workflowId,
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
watch(sortBy, (newValue) => {
|
|
146
|
+
telemetry.track('User sorted insights table', {
|
|
147
|
+
sorted_by: (newValue ?? []).map((item) => ({
|
|
148
|
+
...item,
|
|
149
|
+
label: headers.value.find((header) => header.key === item.id)?.title,
|
|
150
|
+
})),
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
</script>
|
|
154
|
+
|
|
155
|
+
<template>
|
|
156
|
+
<div>
|
|
157
|
+
<N8nHeading bold tag="h3" size="medium" class="mb-s">{{
|
|
158
|
+
i18n.baseText('insights.dashboard.table.title')
|
|
159
|
+
}}</N8nHeading>
|
|
160
|
+
<N8nDataTableServer
|
|
161
|
+
v-model:sort-by="sortBy"
|
|
162
|
+
v-model:page="currentPage"
|
|
163
|
+
v-model:items-per-page="itemsPerPage"
|
|
164
|
+
:items="rows"
|
|
165
|
+
:headers="headers"
|
|
166
|
+
:items-length="tableData.count"
|
|
167
|
+
@update:options="emit('update:options', $event)"
|
|
168
|
+
>
|
|
169
|
+
<template #[`item.workflowName`]="{ item }">
|
|
170
|
+
<component
|
|
171
|
+
:is="item.workflowId && item.hasReadAccess ? RouterLink : 'span'"
|
|
172
|
+
:class="[$style.nameCell, { [$style.link]: item.workflowId && item.hasReadAccess }]"
|
|
173
|
+
v-bind="
|
|
174
|
+
item.workflowId && item.hasReadAccess
|
|
175
|
+
? {
|
|
176
|
+
to: getWorkflowLink(item),
|
|
177
|
+
onClick: () => trackWorkflowClick(item),
|
|
178
|
+
}
|
|
179
|
+
: {}
|
|
180
|
+
"
|
|
181
|
+
>
|
|
182
|
+
<N8nTooltip
|
|
183
|
+
:content="
|
|
184
|
+
item.hasReadAccess
|
|
185
|
+
? item.workflowName
|
|
186
|
+
: i18n.baseText('insights.dashboard.table.noAccess')
|
|
187
|
+
"
|
|
188
|
+
placement="top"
|
|
189
|
+
>
|
|
190
|
+
<div :class="$style.ellipsis">
|
|
191
|
+
{{ item.workflowName }}
|
|
192
|
+
</div>
|
|
193
|
+
</N8nTooltip>
|
|
194
|
+
</component>
|
|
195
|
+
</template>
|
|
196
|
+
<template #[`item.timeSaved`]="{ item, value }">
|
|
197
|
+
<RouterLink
|
|
198
|
+
v-if="!item.timeSaved && item.workflowId && item.hasReadAccess"
|
|
199
|
+
:to="getWorkflowLink(item, { settings: 'true' })"
|
|
200
|
+
:class="$style.link"
|
|
201
|
+
>
|
|
202
|
+
{{ i18n.baseText('insights.dashboard.table.estimate') }}
|
|
203
|
+
</RouterLink>
|
|
204
|
+
<template v-else>
|
|
205
|
+
{{ value }}
|
|
206
|
+
</template>
|
|
207
|
+
</template>
|
|
208
|
+
<template #[`item.projectName`]="{ item }">
|
|
209
|
+
<N8nTooltip v-if="item.projectName" :content="item.projectName" placement="top">
|
|
210
|
+
<div :class="$style.ellipsis">
|
|
211
|
+
{{ item.projectName }}
|
|
212
|
+
</div>
|
|
213
|
+
</N8nTooltip>
|
|
214
|
+
<template v-else> - </template>
|
|
215
|
+
</template>
|
|
216
|
+
<template v-if="!isDashboardEnabled" #cover>
|
|
217
|
+
<div :class="$style.blurryCover">
|
|
218
|
+
<InsightsPaywall />
|
|
219
|
+
</div>
|
|
220
|
+
</template>
|
|
221
|
+
</N8nDataTableServer>
|
|
222
|
+
</div>
|
|
223
|
+
</template>
|
|
224
|
+
|
|
225
|
+
<style lang="scss" module>
|
|
226
|
+
.ellipsis {
|
|
227
|
+
white-space: nowrap;
|
|
228
|
+
overflow: hidden;
|
|
229
|
+
text-overflow: ellipsis;
|
|
230
|
+
line-height: 1.2;
|
|
231
|
+
max-width: 100%;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.nameCell {
|
|
235
|
+
display: flex;
|
|
236
|
+
height: 100%;
|
|
237
|
+
align-items: center;
|
|
238
|
+
max-width: 100%;
|
|
239
|
+
overflow: hidden;
|
|
240
|
+
min-width: 0;
|
|
241
|
+
& > * {
|
|
242
|
+
min-width: 0;
|
|
243
|
+
overflow: hidden;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.link {
|
|
248
|
+
color: var(--color--text);
|
|
249
|
+
text-decoration: underline;
|
|
250
|
+
&:hover {
|
|
251
|
+
color: var(--color--text--shade-1);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.blurryCover {
|
|
256
|
+
position: absolute;
|
|
257
|
+
top: 0;
|
|
258
|
+
left: 0;
|
|
259
|
+
width: 100%;
|
|
260
|
+
height: 100%;
|
|
261
|
+
display: flex;
|
|
262
|
+
justify-content: center;
|
|
263
|
+
align-items: center;
|
|
264
|
+
z-index: 1;
|
|
265
|
+
backdrop-filter: blur(10px);
|
|
266
|
+
|
|
267
|
+
&::before {
|
|
268
|
+
content: '';
|
|
269
|
+
position: absolute;
|
|
270
|
+
top: 0;
|
|
271
|
+
left: 0;
|
|
272
|
+
width: 100%;
|
|
273
|
+
height: 100%;
|
|
274
|
+
background: var(--color--foreground--tint-2);
|
|
275
|
+
opacity: 0.5;
|
|
276
|
+
z-index: -1;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
</style>
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { getLocalTimeZone } from '@internationalized/date';
|
|
2
|
+
import type {
|
|
3
|
+
InsightsSummary,
|
|
4
|
+
InsightsByTime,
|
|
5
|
+
InsightsByWorkflow,
|
|
6
|
+
ListInsightsWorkflowQueryDto,
|
|
7
|
+
InsightsDateFilterDto,
|
|
8
|
+
} from '@n8n/api-types';
|
|
9
|
+
import { makeRestApiRequest } from '@n8n/rest-api-client';
|
|
10
|
+
import { expect } from 'vitest';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
fetchInsightsSummary,
|
|
14
|
+
fetchInsightsByTime,
|
|
15
|
+
fetchInsightsTimeSaved,
|
|
16
|
+
fetchInsightsByWorkflow,
|
|
17
|
+
serializeInsightsFilter,
|
|
18
|
+
} from './insights.api';
|
|
19
|
+
|
|
20
|
+
vi.mock('@n8n/rest-api-client', () => ({
|
|
21
|
+
makeRestApiRequest: vi.fn(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
describe('insights.api', () => {
|
|
25
|
+
const mockContext = { baseUrl: '/rest', pushRef: 'test-push-ref' };
|
|
26
|
+
const timeZone = getLocalTimeZone();
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('serializeInsightsFilter', () => {
|
|
33
|
+
it('should return undefined when filter is undefined', () => {
|
|
34
|
+
const result = serializeInsightsFilter(undefined);
|
|
35
|
+
expect(result).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should serialize filter with Date objects to ISO strings', () => {
|
|
39
|
+
const startDate = new Date('2025-01-01T00:00:00.000Z');
|
|
40
|
+
const endDate = new Date('2025-01-31T23:59:59.999Z');
|
|
41
|
+
|
|
42
|
+
const filter: InsightsDateFilterDto = {
|
|
43
|
+
startDate,
|
|
44
|
+
endDate,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const result = serializeInsightsFilter(filter);
|
|
48
|
+
|
|
49
|
+
expect(result).toEqual({
|
|
50
|
+
startDate: '2025-01-01T00:00:00.000Z',
|
|
51
|
+
endDate: '2025-01-31T23:59:59.999Z',
|
|
52
|
+
timeZone,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should handle filter with only startDate', () => {
|
|
57
|
+
const startDate = new Date('2025-01-01T00:00:00.000Z');
|
|
58
|
+
|
|
59
|
+
const filter: InsightsDateFilterDto = {
|
|
60
|
+
startDate,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const result = serializeInsightsFilter(filter);
|
|
64
|
+
|
|
65
|
+
expect(result).toEqual({
|
|
66
|
+
startDate: '2025-01-01T00:00:00.000Z',
|
|
67
|
+
timeZone,
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should handle filter with only endDate', () => {
|
|
72
|
+
const endDate = new Date('2025-01-31T23:59:59.999Z');
|
|
73
|
+
|
|
74
|
+
const filter: InsightsDateFilterDto = {
|
|
75
|
+
endDate,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const result = serializeInsightsFilter(filter);
|
|
79
|
+
|
|
80
|
+
expect(result).toEqual({
|
|
81
|
+
endDate: '2025-01-31T23:59:59.999Z',
|
|
82
|
+
timeZone,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should preserve additional filter properties', () => {
|
|
87
|
+
const startDate = new Date('2025-01-01T00:00:00.000Z');
|
|
88
|
+
const endDate = new Date('2025-01-31T23:59:59.999Z');
|
|
89
|
+
|
|
90
|
+
const filter: ListInsightsWorkflowQueryDto = {
|
|
91
|
+
startDate,
|
|
92
|
+
endDate,
|
|
93
|
+
take: 10,
|
|
94
|
+
skip: 0,
|
|
95
|
+
sortBy: 'workflowName:asc',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const result = serializeInsightsFilter(filter);
|
|
99
|
+
|
|
100
|
+
expect(result).toEqual({
|
|
101
|
+
startDate: '2025-01-01T00:00:00.000Z',
|
|
102
|
+
endDate: '2025-01-31T23:59:59.999Z',
|
|
103
|
+
take: 10,
|
|
104
|
+
skip: 0,
|
|
105
|
+
sortBy: 'workflowName:asc',
|
|
106
|
+
timeZone,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should handle empty filter object', () => {
|
|
111
|
+
const filter: InsightsDateFilterDto = {};
|
|
112
|
+
|
|
113
|
+
const result = serializeInsightsFilter(filter);
|
|
114
|
+
|
|
115
|
+
expect(result).toEqual({ timeZone });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should include the local timeZone in the serialized output', () => {
|
|
119
|
+
const filter: InsightsDateFilterDto = {
|
|
120
|
+
startDate: new Date('2025-01-01T00:00:00.000Z'),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const result = serializeInsightsFilter(filter);
|
|
124
|
+
|
|
125
|
+
expect(result?.timeZone).toBe(getLocalTimeZone());
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('fetchInsightsSummary', () => {
|
|
130
|
+
it('should make GET request to /insights/summary without filter', async () => {
|
|
131
|
+
const mockSummary: InsightsSummary = {
|
|
132
|
+
total: { value: 100, deviation: null, unit: 'count' },
|
|
133
|
+
failed: { value: 20, deviation: 5, unit: 'count' },
|
|
134
|
+
failureRate: { value: 0.2, deviation: -0.05, unit: 'ratio' },
|
|
135
|
+
timeSaved: { value: 120, deviation: 30, unit: 'minute' },
|
|
136
|
+
averageRunTime: { value: 5000, deviation: 200, unit: 'millisecond' },
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
vi.mocked(makeRestApiRequest).mockResolvedValue(mockSummary);
|
|
140
|
+
|
|
141
|
+
const result = await fetchInsightsSummary(mockContext);
|
|
142
|
+
|
|
143
|
+
expect(makeRestApiRequest).toHaveBeenCalledWith(
|
|
144
|
+
mockContext,
|
|
145
|
+
'GET',
|
|
146
|
+
'/insights/summary',
|
|
147
|
+
undefined,
|
|
148
|
+
);
|
|
149
|
+
expect(result).toEqual(mockSummary);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should make GET request to /insights/summary with serialized filter', async () => {
|
|
153
|
+
const startDate = new Date('2025-01-01T00:00:00.000Z');
|
|
154
|
+
const endDate = new Date('2025-01-31T23:59:59.999Z');
|
|
155
|
+
|
|
156
|
+
const filter: InsightsDateFilterDto = {
|
|
157
|
+
startDate,
|
|
158
|
+
endDate,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const mockSummary: InsightsSummary = {
|
|
162
|
+
total: { value: 50, deviation: null, unit: 'count' },
|
|
163
|
+
failed: { value: 10, deviation: 2, unit: 'count' },
|
|
164
|
+
failureRate: { value: 0.2, deviation: -0.03, unit: 'ratio' },
|
|
165
|
+
timeSaved: { value: 60, deviation: 15, unit: 'minute' },
|
|
166
|
+
averageRunTime: { value: 3000, deviation: 100, unit: 'millisecond' },
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
vi.mocked(makeRestApiRequest).mockResolvedValue(mockSummary);
|
|
170
|
+
|
|
171
|
+
const result = await fetchInsightsSummary(mockContext, filter);
|
|
172
|
+
|
|
173
|
+
expect(makeRestApiRequest).toHaveBeenCalledWith(mockContext, 'GET', '/insights/summary', {
|
|
174
|
+
startDate: '2025-01-01T00:00:00.000Z',
|
|
175
|
+
endDate: '2025-01-31T23:59:59.999Z',
|
|
176
|
+
timeZone,
|
|
177
|
+
});
|
|
178
|
+
expect(result).toEqual(mockSummary);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('fetchInsightsByTime', () => {
|
|
183
|
+
it('should make GET request to /insights/by-time without filter', async () => {
|
|
184
|
+
const mockInsightsByTime: InsightsByTime[] = [
|
|
185
|
+
{
|
|
186
|
+
date: '2025-01-01T00:00:00.000Z',
|
|
187
|
+
values: {
|
|
188
|
+
total: 10,
|
|
189
|
+
succeeded: 8,
|
|
190
|
+
failed: 2,
|
|
191
|
+
failureRate: 0.2,
|
|
192
|
+
averageRunTime: 5000,
|
|
193
|
+
timeSaved: 30,
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
date: '2025-01-02T00:00:00.000Z',
|
|
198
|
+
values: {
|
|
199
|
+
total: 15,
|
|
200
|
+
succeeded: 12,
|
|
201
|
+
failed: 3,
|
|
202
|
+
failureRate: 0.2,
|
|
203
|
+
averageRunTime: 4500,
|
|
204
|
+
timeSaved: 45,
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
];
|
|
208
|
+
|
|
209
|
+
vi.mocked(makeRestApiRequest).mockResolvedValue(mockInsightsByTime);
|
|
210
|
+
|
|
211
|
+
const result = await fetchInsightsByTime(mockContext);
|
|
212
|
+
|
|
213
|
+
expect(makeRestApiRequest).toHaveBeenCalledWith(
|
|
214
|
+
mockContext,
|
|
215
|
+
'GET',
|
|
216
|
+
'/insights/by-time',
|
|
217
|
+
undefined,
|
|
218
|
+
);
|
|
219
|
+
expect(result).toEqual(mockInsightsByTime);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should make GET request to /insights/by-time with serialized filter', async () => {
|
|
223
|
+
const startDate = new Date('2025-01-01T00:00:00.000Z');
|
|
224
|
+
const endDate = new Date('2025-01-31T23:59:59.999Z');
|
|
225
|
+
|
|
226
|
+
const filter: InsightsDateFilterDto = {
|
|
227
|
+
startDate,
|
|
228
|
+
endDate,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const mockInsightsByTime: InsightsByTime[] = [
|
|
232
|
+
{
|
|
233
|
+
date: '2025-01-15T00:00:00.000Z',
|
|
234
|
+
values: {
|
|
235
|
+
total: 20,
|
|
236
|
+
succeeded: 18,
|
|
237
|
+
failed: 2,
|
|
238
|
+
failureRate: 0.1,
|
|
239
|
+
averageRunTime: 4000,
|
|
240
|
+
timeSaved: 60,
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
vi.mocked(makeRestApiRequest).mockResolvedValue(mockInsightsByTime);
|
|
246
|
+
|
|
247
|
+
const result = await fetchInsightsByTime(mockContext, filter);
|
|
248
|
+
|
|
249
|
+
expect(makeRestApiRequest).toHaveBeenCalledWith(mockContext, 'GET', '/insights/by-time', {
|
|
250
|
+
startDate: '2025-01-01T00:00:00.000Z',
|
|
251
|
+
endDate: '2025-01-31T23:59:59.999Z',
|
|
252
|
+
timeZone,
|
|
253
|
+
});
|
|
254
|
+
expect(result).toEqual(mockInsightsByTime);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe('fetchInsightsTimeSaved', () => {
|
|
259
|
+
it('should make GET request to /insights/by-time/time-saved without filter', async () => {
|
|
260
|
+
const mockTimeSaved: InsightsByTime[] = [
|
|
261
|
+
{
|
|
262
|
+
date: '2025-01-01T00:00:00.000Z',
|
|
263
|
+
values: {
|
|
264
|
+
total: 10,
|
|
265
|
+
succeeded: 8,
|
|
266
|
+
failed: 2,
|
|
267
|
+
failureRate: 0.2,
|
|
268
|
+
averageRunTime: 5000,
|
|
269
|
+
timeSaved: 60,
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
date: '2025-01-02T00:00:00.000Z',
|
|
274
|
+
values: {
|
|
275
|
+
total: 15,
|
|
276
|
+
succeeded: 12,
|
|
277
|
+
failed: 3,
|
|
278
|
+
failureRate: 0.2,
|
|
279
|
+
averageRunTime: 4500,
|
|
280
|
+
timeSaved: 90,
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
vi.mocked(makeRestApiRequest).mockResolvedValue(mockTimeSaved);
|
|
286
|
+
|
|
287
|
+
const result = await fetchInsightsTimeSaved(mockContext);
|
|
288
|
+
|
|
289
|
+
expect(makeRestApiRequest).toHaveBeenCalledWith(
|
|
290
|
+
mockContext,
|
|
291
|
+
'GET',
|
|
292
|
+
'/insights/by-time/time-saved',
|
|
293
|
+
undefined,
|
|
294
|
+
);
|
|
295
|
+
expect(result).toEqual(mockTimeSaved);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should make GET request to /insights/by-time/time-saved with serialized filter', async () => {
|
|
299
|
+
const startDate = new Date('2025-01-01T00:00:00.000Z');
|
|
300
|
+
const endDate = new Date('2025-01-31T23:59:59.999Z');
|
|
301
|
+
|
|
302
|
+
const filter: InsightsDateFilterDto = {
|
|
303
|
+
startDate,
|
|
304
|
+
endDate,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const mockTimeSaved: InsightsByTime[] = [
|
|
308
|
+
{
|
|
309
|
+
date: '2025-01-15T00:00:00.000Z',
|
|
310
|
+
values: {
|
|
311
|
+
total: 20,
|
|
312
|
+
succeeded: 18,
|
|
313
|
+
failed: 2,
|
|
314
|
+
failureRate: 0.1,
|
|
315
|
+
averageRunTime: 4000,
|
|
316
|
+
timeSaved: 120,
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
vi.mocked(makeRestApiRequest).mockResolvedValue(mockTimeSaved);
|
|
322
|
+
|
|
323
|
+
const result = await fetchInsightsTimeSaved(mockContext, filter);
|
|
324
|
+
|
|
325
|
+
expect(makeRestApiRequest).toHaveBeenCalledWith(
|
|
326
|
+
mockContext,
|
|
327
|
+
'GET',
|
|
328
|
+
'/insights/by-time/time-saved',
|
|
329
|
+
{
|
|
330
|
+
startDate: '2025-01-01T00:00:00.000Z',
|
|
331
|
+
endDate: '2025-01-31T23:59:59.999Z',
|
|
332
|
+
timeZone,
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
expect(result).toEqual(mockTimeSaved);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
describe('fetchInsightsByWorkflow', () => {
|
|
340
|
+
it('should make GET request to /insights/by-workflow without filter', async () => {
|
|
341
|
+
const mockInsightsByWorkflow: InsightsByWorkflow = {
|
|
342
|
+
data: [
|
|
343
|
+
{
|
|
344
|
+
workflowId: 'workflow-1',
|
|
345
|
+
workflowName: 'Test Workflow 1',
|
|
346
|
+
hasReadAccess: true,
|
|
347
|
+
projectId: 'project-1',
|
|
348
|
+
projectName: 'Test Project',
|
|
349
|
+
total: 50,
|
|
350
|
+
succeeded: 40,
|
|
351
|
+
failed: 10,
|
|
352
|
+
failureRate: 0.2,
|
|
353
|
+
runTime: 250000,
|
|
354
|
+
averageRunTime: 5000,
|
|
355
|
+
timeSaved: 150,
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
count: 1,
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
vi.mocked(makeRestApiRequest).mockResolvedValue(mockInsightsByWorkflow);
|
|
362
|
+
|
|
363
|
+
const result = await fetchInsightsByWorkflow(mockContext);
|
|
364
|
+
|
|
365
|
+
expect(makeRestApiRequest).toHaveBeenCalledWith(
|
|
366
|
+
mockContext,
|
|
367
|
+
'GET',
|
|
368
|
+
'/insights/by-workflow',
|
|
369
|
+
undefined,
|
|
370
|
+
);
|
|
371
|
+
expect(result).toEqual(mockInsightsByWorkflow);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should make GET request to /insights/by-workflow with serialized filter', async () => {
|
|
375
|
+
const startDate = new Date('2025-01-01T00:00:00.000Z');
|
|
376
|
+
const endDate = new Date('2025-01-31T23:59:59.999Z');
|
|
377
|
+
|
|
378
|
+
const filter: ListInsightsWorkflowQueryDto = {
|
|
379
|
+
startDate,
|
|
380
|
+
endDate,
|
|
381
|
+
take: 10,
|
|
382
|
+
skip: 0,
|
|
383
|
+
sortBy: 'workflowName:asc',
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const mockInsightsByWorkflow: InsightsByWorkflow = {
|
|
387
|
+
data: [
|
|
388
|
+
{
|
|
389
|
+
workflowId: 'workflow-1',
|
|
390
|
+
workflowName: 'Test Workflow 1',
|
|
391
|
+
hasReadAccess: true,
|
|
392
|
+
projectId: 'project-1',
|
|
393
|
+
projectName: 'Test Project 1',
|
|
394
|
+
total: 30,
|
|
395
|
+
succeeded: 25,
|
|
396
|
+
failed: 5,
|
|
397
|
+
failureRate: 0.17,
|
|
398
|
+
runTime: 120000,
|
|
399
|
+
averageRunTime: 4000,
|
|
400
|
+
timeSaved: 90,
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
workflowId: 'workflow-2',
|
|
404
|
+
workflowName: 'Test Workflow 2',
|
|
405
|
+
hasReadAccess: true,
|
|
406
|
+
projectId: 'project-2',
|
|
407
|
+
projectName: 'Test Project 2',
|
|
408
|
+
total: 20,
|
|
409
|
+
succeeded: 18,
|
|
410
|
+
failed: 2,
|
|
411
|
+
failureRate: 0.1,
|
|
412
|
+
runTime: 80000,
|
|
413
|
+
averageRunTime: 4000,
|
|
414
|
+
timeSaved: 60,
|
|
415
|
+
},
|
|
416
|
+
],
|
|
417
|
+
count: 2,
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
vi.mocked(makeRestApiRequest).mockResolvedValue(mockInsightsByWorkflow);
|
|
421
|
+
|
|
422
|
+
const result = await fetchInsightsByWorkflow(mockContext, filter);
|
|
423
|
+
|
|
424
|
+
expect(makeRestApiRequest).toHaveBeenCalledWith(mockContext, 'GET', '/insights/by-workflow', {
|
|
425
|
+
startDate: '2025-01-01T00:00:00.000Z',
|
|
426
|
+
endDate: '2025-01-31T23:59:59.999Z',
|
|
427
|
+
take: 10,
|
|
428
|
+
skip: 0,
|
|
429
|
+
sortBy: 'workflowName:asc',
|
|
430
|
+
timeZone,
|
|
431
|
+
});
|
|
432
|
+
expect(result).toEqual(mockInsightsByWorkflow);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
});
|