@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.
Files changed (38) hide show
  1. package/LICENSE.md +88 -0
  2. package/README.md +64 -0
  3. package/biome.jsonc +4 -0
  4. package/eslint.config.mjs +33 -0
  5. package/package.json +72 -0
  6. package/src/__tests__/setup.ts +4 -0
  7. package/src/chartjs.utils.ts +208 -0
  8. package/src/components/InsightsDashboard.test.ts +891 -0
  9. package/src/components/InsightsDashboard.vue +426 -0
  10. package/src/components/InsightsDataRangePicker.vue +195 -0
  11. package/src/components/InsightsDateRangeAlert.test.ts +134 -0
  12. package/src/components/InsightsDateRangeAlert.vue +89 -0
  13. package/src/components/InsightsPaywall.vue +42 -0
  14. package/src/components/InsightsSummary.test.ts +206 -0
  15. package/src/components/InsightsSummary.vue +357 -0
  16. package/src/components/InsightsUpgradeModal.vue +74 -0
  17. package/src/components/__snapshots__/InsightsSummary.test.ts.snap +446 -0
  18. package/src/components/charts/InsightsChartAverageRuntime.vue +67 -0
  19. package/src/components/charts/InsightsChartFailed.vue +57 -0
  20. package/src/components/charts/InsightsChartFailureRate.vue +58 -0
  21. package/src/components/charts/InsightsChartTimeSaved.vue +74 -0
  22. package/src/components/charts/InsightsChartTotal.vue +58 -0
  23. package/src/components/charts/insightChartProps.ts +9 -0
  24. package/src/components/tables/InsightsTableWorkflows.test.ts +509 -0
  25. package/src/components/tables/InsightsTableWorkflows.vue +279 -0
  26. package/src/index.ts +4 -0
  27. package/src/insights.api.test.ts +435 -0
  28. package/src/insights.api.ts +71 -0
  29. package/src/insights.constants.ts +69 -0
  30. package/src/insights.module.test.ts +59 -0
  31. package/src/insights.module.ts +45 -0
  32. package/src/insights.store.test.ts +118 -0
  33. package/src/insights.store.ts +88 -0
  34. package/src/insights.types.ts +16 -0
  35. package/src/insights.utils.test.ts +339 -0
  36. package/src/insights.utils.ts +172 -0
  37. package/stylelint.config.mjs +9 -0
  38. package/vite.config.ts +29 -0
@@ -0,0 +1,357 @@
1
+ <script setup lang="ts">
2
+ import type { DateValue } from '@internationalized/date';
3
+ import type { InsightsSummary } from '@n8n/api-types';
4
+ import { useTelemetry } from '@n8n/composables/useTelemetry';
5
+ import { N8nIcon, 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, useCssModule } from 'vue';
10
+ import { I18nT } from 'vue-i18n';
11
+ import { useRoute } from 'vue-router';
12
+
13
+ import { INSIGHT_IMPACT_TYPES, INSIGHTS_UNIT_IMPACT_MAPPING } from '../insights.constants';
14
+ import type { InsightsSummaryDisplay } from '../insights.types';
15
+ import { formatDateRange, getMatchingPreset, getTimeRangeLabels } from '../insights.utils';
16
+
17
+ const props = defineProps<{
18
+ summary: InsightsSummaryDisplay;
19
+ startDate?: DateValue;
20
+ endDate?: DateValue;
21
+ loading?: boolean;
22
+ }>();
23
+
24
+ const i18n = useI18n();
25
+ const route = useRoute();
26
+ const $style = useCssModule();
27
+ const telemetry = useTelemetry();
28
+
29
+ const timeRangeLabels = getTimeRangeLabels();
30
+
31
+ const displayDateRangeLabel = computed(() => {
32
+ const timeRangeKey = getMatchingPreset({
33
+ start: props.startDate,
34
+ end: props.endDate,
35
+ });
36
+
37
+ if (timeRangeKey) {
38
+ return timeRangeLabels[timeRangeKey];
39
+ }
40
+
41
+ return formatDateRange({ start: props.startDate, end: props.endDate });
42
+ });
43
+
44
+ const summaryTitles = computed<Record<keyof InsightsSummary, string>>(() => ({
45
+ total: i18n.baseText('insights.banner.title.total'),
46
+ failed: i18n.baseText('insights.banner.title.failed'),
47
+ failureRate: i18n.baseText('insights.banner.title.failureRate'),
48
+ timeSaved: i18n.baseText('insights.banner.title.timeSaved'),
49
+ averageRunTime: i18n.baseText('insights.banner.title.averageRunTime'),
50
+ }));
51
+
52
+ const summaryHasNoData = computed(() => {
53
+ const summaryValues = Object.values(props.summary);
54
+ return summaryValues.length > 0 && summaryValues.every((summary) => !summary.value);
55
+ });
56
+
57
+ const summaryWithRouteLocations = computed(() =>
58
+ props.summary.map((s) => ({
59
+ ...s,
60
+ to: { name: VIEWS.INSIGHTS, params: { insightType: s.id }, query: route.query },
61
+ })),
62
+ );
63
+
64
+ const getImpactStyle = (id: keyof InsightsSummary, value: number) => {
65
+ const impact = INSIGHTS_UNIT_IMPACT_MAPPING[id];
66
+ if (value === 0 || impact === INSIGHT_IMPACT_TYPES.NEUTRAL) {
67
+ return $style.neutral;
68
+ }
69
+ if (impact === INSIGHT_IMPACT_TYPES.POSITIVE) {
70
+ return value > 0 ? $style.positive : $style.negative;
71
+ }
72
+ if (impact === INSIGHT_IMPACT_TYPES.NEGATIVE) {
73
+ return value < 0 ? $style.positive : $style.negative;
74
+ }
75
+ return $style.neutral;
76
+ };
77
+
78
+ const trackTabClick = (insightType: keyof InsightsSummary) => {
79
+ telemetry.track(`User clicked ${summaryTitles.value[insightType]}`, {
80
+ referrer: route.name === VIEWS.INSIGHTS ? 'Dashboard' : 'Overview',
81
+ });
82
+ };
83
+ </script>
84
+
85
+ <template>
86
+ <div :class="$style.insightsWrapper">
87
+ <div :class="$style.insights">
88
+ <ul data-test-id="insights-summary-tabs">
89
+ <li
90
+ v-for="{ id, value, deviation, deviationUnit, unit, to } in summaryWithRouteLocations"
91
+ :key="id"
92
+ :data-test-id="`insights-summary-tab-${id}`"
93
+ >
94
+ <N8nTooltip
95
+ :placement="route.name === VIEWS.INSIGHTS ? 'bottom' : 'top'"
96
+ :disabled="!(summaryHasNoData && id === 'total')"
97
+ :show-after="500"
98
+ >
99
+ <template #content>
100
+ <I18nT keypath="insights.banner.noData.tooltip" scope="global">
101
+ <template #link>
102
+ <a
103
+ :href="i18n.baseText('insights.banner.noData.tooltip.link.url')"
104
+ target="_blank"
105
+ >
106
+ {{ i18n.baseText('insights.banner.noData.tooltip.link') }}
107
+ </a>
108
+ </template>
109
+ </I18nT>
110
+ </template>
111
+ <RouterLink :to="to" :exact-active-class="$style.activeTab" @click="trackTabClick(id)">
112
+ <strong>
113
+ <N8nTooltip placement="bottom" :disabled="id !== 'timeSaved'">
114
+ <template #content>
115
+ {{ i18n.baseText('insights.banner.title.timeSaved.tooltip') }}
116
+ </template>
117
+ {{ summaryTitles[id] }}
118
+ </N8nTooltip>
119
+ </strong>
120
+ <small :class="$style.days">
121
+ {{ displayDateRangeLabel }}
122
+ </small>
123
+ <span v-if="value === 0 && id === 'timeSaved'" :class="$style.empty">
124
+ <em>--</em>
125
+ <small>
126
+ <N8nTooltip placement="bottom">
127
+ <template #content>
128
+ <I18nT keypath="insights.banner.timeSaved.tooltip" scope="global">
129
+ <template #link>{{
130
+ i18n.baseText('insights.banner.timeSaved.tooltip.link.text')
131
+ }}</template>
132
+ </I18nT>
133
+ </template>
134
+ <N8nIcon :class="$style.icon" icon="info" size="medium" />
135
+ </N8nTooltip>
136
+ </small>
137
+ </span>
138
+ <span v-else>
139
+ <em
140
+ >{{ smartDecimal(value).toLocaleString('en-US') }} <i>{{ unit }}</i></em
141
+ >
142
+ <small v-if="deviation !== null" :class="getImpactStyle(id, deviation)">
143
+ <N8nIcon
144
+ :class="[$style.icon, getImpactStyle(id, deviation)]"
145
+ :icon="
146
+ deviation === 0
147
+ ? 'chevron-right'
148
+ : deviation > 0
149
+ ? 'chevron-up'
150
+ : 'chevron-down'
151
+ "
152
+ />
153
+ <N8nTooltip placement="bottom" :disabled="id !== 'failureRate'">
154
+ <template #content>
155
+ {{ i18n.baseText('insights.banner.failureRate.deviation.tooltip') }}
156
+ </template>
157
+ {{ smartDecimal(Math.abs(deviation)).toLocaleString('en-US')
158
+ }}{{ deviationUnit }}
159
+ </N8nTooltip>
160
+ </small>
161
+ </span>
162
+ </RouterLink>
163
+ </N8nTooltip>
164
+ </li>
165
+ </ul>
166
+ </div>
167
+ <!-- TODO: This should be removed after some time when the number issue is behind us -->
168
+ </div>
169
+ </template>
170
+
171
+ <style lang="scss" module>
172
+ .insightsWrapper {
173
+ position: relative;
174
+ padding: var(--spacing--xs) 0 0;
175
+ margin-bottom: var(--spacing--2xl);
176
+ }
177
+
178
+ .insights {
179
+ display: grid;
180
+ grid-template-rows: auto 1fr;
181
+
182
+ ul {
183
+ display: flex;
184
+ height: 101px;
185
+ align-items: stretch;
186
+ justify-content: space-evenly;
187
+ border: var(--border);
188
+ border-radius: 6px;
189
+ list-style: none;
190
+ overflow-x: auto;
191
+
192
+ li {
193
+ display: flex;
194
+ justify-content: stretch;
195
+ align-items: stretch;
196
+ flex: 1 0;
197
+ border-left: var(--border);
198
+
199
+ &:first-child {
200
+ border-left: 0;
201
+ }
202
+
203
+ > span {
204
+ display: flex;
205
+ width: 100%;
206
+ height: 100%;
207
+ }
208
+ }
209
+
210
+ a {
211
+ display: grid;
212
+ align-items: center;
213
+ align-content: center;
214
+ width: 100%;
215
+ height: 100%;
216
+ padding: var(--spacing--3xs) var(--spacing--lg) 0;
217
+ background-color: var(--background--surface);
218
+ border-bottom: 3px solid transparent;
219
+
220
+ &:hover {
221
+ background-color: var(--background-hover);
222
+ }
223
+
224
+ &.activeTab {
225
+ background-color: var(--color--background--light-3);
226
+ border-color: var(--color--primary);
227
+ transition: background-color 0.3s ease-in-out;
228
+ }
229
+
230
+ strong {
231
+ justify-self: flex-start;
232
+ color: var(--color--text--shade-1);
233
+ font-size: var(--font-size--sm);
234
+ font-weight: 400;
235
+ white-space: nowrap;
236
+ margin-bottom: var(--spacing--3xs);
237
+ }
238
+
239
+ .days {
240
+ padding: 0;
241
+ margin: 0 0 var(--spacing--xs);
242
+ color: var(--color--text--tint-1);
243
+ font-size: var(--font-size--2xs);
244
+ font-weight: var(--font-weight--regular);
245
+ }
246
+
247
+ span {
248
+ display: flex;
249
+ align-items: baseline;
250
+
251
+ &.empty {
252
+ em {
253
+ color: var(--color--text--tint-2);
254
+ body[data-theme='dark'] & {
255
+ color: var(--color--text--tint-1);
256
+ }
257
+ }
258
+ small {
259
+ padding: 0;
260
+ height: 21px;
261
+ font-weight: var(--font-weight--bold);
262
+
263
+ .icon {
264
+ top: 5px;
265
+ transform: translateY(0);
266
+ color: var(--color--text--tint-1);
267
+ }
268
+ }
269
+ }
270
+ }
271
+
272
+ em {
273
+ display: flex;
274
+ align-items: baseline;
275
+ justify-content: flex-start;
276
+ color: var(--color--text--shade-1);
277
+ font-size: 24px;
278
+ line-height: 100%;
279
+ font-weight: 600;
280
+ font-style: normal;
281
+ gap: var(--spacing--5xs);
282
+
283
+ i {
284
+ font-size: 22px;
285
+ font-style: normal;
286
+ }
287
+ }
288
+
289
+ small {
290
+ position: relative;
291
+ display: flex;
292
+ align-items: center;
293
+ padding: 0 0 0 14px;
294
+ margin: 0 0 0 var(--spacing--xs);
295
+ font-size: var(--font-size--2xs);
296
+ font-weight: var(--font-weight--bold);
297
+ white-space: nowrap;
298
+ }
299
+ }
300
+ }
301
+
302
+ .noData {
303
+ em {
304
+ color: var(--color--text--tint-1);
305
+ font-size: var(--font-size--md);
306
+ }
307
+ }
308
+ }
309
+
310
+ .positive {
311
+ color: var(--color--success);
312
+ }
313
+
314
+ .negative {
315
+ color: var(--color--danger);
316
+ }
317
+
318
+ .neutral {
319
+ color: var(--color--text--tint-1);
320
+
321
+ .icon {
322
+ font-size: 17px;
323
+ }
324
+ }
325
+
326
+ .icon {
327
+ position: absolute;
328
+ font-size: 17px;
329
+ left: 0;
330
+ top: 50%;
331
+ transform: translateY(-50%);
332
+ }
333
+
334
+ .loading {
335
+ display: flex;
336
+ align-self: stretch;
337
+ align-items: stretch;
338
+
339
+ > div {
340
+ margin: 0;
341
+ height: auto;
342
+ border-radius: inherit;
343
+ }
344
+ }
345
+
346
+ .queueModeWarning {
347
+ margin-bottom: var(--spacing--xs);
348
+
349
+ a {
350
+ text-decoration: none;
351
+
352
+ .underlined {
353
+ text-decoration: underline;
354
+ }
355
+ }
356
+ }
357
+ </style>
@@ -0,0 +1,74 @@
1
+ <script setup lang="ts">
2
+ import { N8nButton, N8nText } from '@n8n/design-system';
3
+ import { useI18n } from '@n8n/i18n';
4
+ import type { BaseTextKey } from '@n8n/i18n';
5
+ import { useBasePageRedirectionHelper } from '@n8n/stores/composables/useBasePageRedirectionHelper';
6
+ import { ElDialog } from 'element-plus';
7
+ import { computed } from 'vue';
8
+ const model = defineModel<boolean>();
9
+ const i18n = useI18n();
10
+
11
+ function goToUpgrade() {
12
+ model.value = false;
13
+ void useBasePageRedirectionHelper().goToUpgrade('insights', 'upgrade-insights');
14
+ }
15
+
16
+ const perks = computed(() =>
17
+ [...Array(3).keys()].map((index) =>
18
+ i18n.baseText(`insights.upgradeModal.perks.${index}` as BaseTextKey),
19
+ ),
20
+ );
21
+ </script>
22
+
23
+ <template>
24
+ <ElDialog v-model="model" :title="i18n.baseText('insights.upgradeModal.title')" width="500">
25
+ <div>
26
+ <N8nText tag="p" class="mb-s">
27
+ {{ i18n.baseText('insights.upgradeModal.content') }}
28
+ </N8nText>
29
+ <ul class="perks-list">
30
+ <N8nText v-for="perk in perks" :key="perk" color="text-dark" tag="li">
31
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16px" height="16px">
32
+ <path
33
+ d="M 16 8 C 16 12.418 12.418 16 8 16 C 3.582 16 0 12.418 0 8 C 0 3.582 3.582 0 8 0 C 12.418 0 16 3.582 16 8 Z M 3.97 9.03 L 5.97 11.03 L 6.5 11.561 L 7.03 11.03 L 12.53 5.53 L 11.47 4.47 L 6.5 9.439 L 5.03 7.97 L 3.97 9.03 Z"
34
+ fill="currentColor"
35
+ />
36
+ </svg>
37
+ {{ perk }}
38
+ </N8nText>
39
+ </ul>
40
+ </div>
41
+ <template #footer>
42
+ <div class="insight-modal-button-container">
43
+ <N8nButton variant="subtle" @click="model = false">
44
+ {{ i18n.baseText('insights.upgradeModal.button.dismiss') }}
45
+ </N8nButton>
46
+ <N8nButton variant="solid" @click="goToUpgrade">
47
+ {{ i18n.baseText('generic.upgrade') }}
48
+ </N8nButton>
49
+ </div>
50
+ </template>
51
+ </ElDialog>
52
+ </template>
53
+
54
+ <style scoped>
55
+ .perks-list {
56
+ margin: 0;
57
+ padding: 0;
58
+ list-style: none;
59
+ display: flex;
60
+ flex-direction: column;
61
+ gap: var(--spacing--sm);
62
+
63
+ > li {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: var(--spacing--2xs);
67
+ }
68
+ }
69
+ .insight-modal-button-container {
70
+ display: flex;
71
+ justify-content: flex-end;
72
+ gap: var(--spacing--2xs);
73
+ }
74
+ </style>