@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,426 @@
1
+ <script setup lang="ts">
2
+ import type { DateValue } from '@internationalized/date';
3
+ import { getLocalTimeZone, parseDate, today } from '@internationalized/date';
4
+ import type { InsightsSummaryType } from '@n8n/api-types';
5
+ import { useDocumentTitle } from '@n8n/composables/useDocumentTitle';
6
+ import { useToast } from '@n8n/composables/useToast';
7
+ import { N8nHeading, N8nSpinner } from '@n8n/design-system';
8
+ import { componentRegistry, type SlotProjectSelection } from '@n8n/frontend-module-sdk';
9
+ import { useI18n } from '@n8n/i18n';
10
+ import { ResponseError } from '@n8n/rest-api-client/utils';
11
+ import { computed, defineAsyncComponent, onMounted, ref, shallowRef, watch } from 'vue';
12
+ import { useRoute } from 'vue-router';
13
+
14
+ import { useInsightsStore } from '../insights.store';
15
+ import InsightsSummary from './InsightsSummary.vue';
16
+ import { INSIGHT_TYPES } from '../insights.constants';
17
+ import { getAdjustedDateRange, getTimeRangeLabels, timeRangeMappings } from '../insights.utils';
18
+ import InsightsDataRangePicker from './InsightsDataRangePicker.vue';
19
+ import InsightsDateRangeAlert from './InsightsDateRangeAlert.vue';
20
+
21
+ const InsightsPaywall = defineAsyncComponent(async () => await import('./InsightsPaywall.vue'));
22
+ const InsightsChartTotal = defineAsyncComponent(
23
+ async () => await import('./charts/InsightsChartTotal.vue'),
24
+ );
25
+ const InsightsChartFailed = defineAsyncComponent(
26
+ async () => await import('./charts/InsightsChartFailed.vue'),
27
+ );
28
+ const InsightsChartFailureRate = defineAsyncComponent(
29
+ async () => await import('./charts/InsightsChartFailureRate.vue'),
30
+ );
31
+ const InsightsChartTimeSaved = defineAsyncComponent(
32
+ async () => await import('./charts/InsightsChartTimeSaved.vue'),
33
+ );
34
+ const InsightsChartAverageRuntime = defineAsyncComponent(
35
+ async () => await import('./charts/InsightsChartAverageRuntime.vue'),
36
+ );
37
+ const InsightsTableWorkflows = defineAsyncComponent(
38
+ async () => await import('./tables/InsightsTableWorkflows.vue'),
39
+ );
40
+
41
+ const props = defineProps<{
42
+ insightType: InsightsSummaryType;
43
+ }>();
44
+
45
+ const route = useRoute();
46
+ const i18n = useI18n();
47
+ const toast = useToast();
48
+
49
+ const insightsStore = useInsightsStore();
50
+
51
+ // The project picker is shell-hosted: its list, permissions and search live in
52
+ // `features/collaboration/projects`, above this module. Unregistered (a module's
53
+ // own test run) means no picker, and the dashboard reads as "all projects".
54
+ const projectFilter = computed(() => componentRegistry.get('project-filter'));
55
+ const isTimeSavedRoute = computed(() => route.params.insightType === INSIGHT_TYPES.TIME_SAVED);
56
+
57
+ const chartComponents = computed(() => ({
58
+ total: InsightsChartTotal,
59
+ failed: InsightsChartFailed,
60
+ failureRate: InsightsChartFailureRate,
61
+ timeSaved: InsightsChartTimeSaved,
62
+ averageRunTime: InsightsChartAverageRuntime,
63
+ }));
64
+
65
+ const transformFilter = ({ id, desc }: { id: string; desc: boolean }) => {
66
+ const key = id as InsightsSummaryType;
67
+ const order = desc ? 'desc' : 'asc';
68
+ return `${key}:${order}` as const;
69
+ };
70
+
71
+ const sortTableBy = ref([{ id: props.insightType, desc: true }]);
72
+
73
+ const granularity = computed(() => {
74
+ const { start, end } = range.value;
75
+ if (!start || !end) return 'day';
76
+
77
+ const comparison = end.compare(start);
78
+
79
+ if (comparison <= 0) return 'hour';
80
+ if (comparison <= 30) return 'day';
81
+ return 'week';
82
+ });
83
+ const selectedProject = ref<SlotProjectSelection>(null);
84
+
85
+ const maxDate = today(getLocalTimeZone());
86
+
87
+ const maxLicensedDate =
88
+ insightsStore.dateRanges.toReversed().find((dateRange) => dateRange.licensed)?.key ?? 'week';
89
+
90
+ const timeRangeLabels = getTimeRangeLabels();
91
+ const presets = computed(() =>
92
+ insightsStore.dateRanges.map((item) => {
93
+ return {
94
+ value: timeRangeMappings[item.key],
95
+ label: timeRangeLabels[item.key],
96
+ disabled: !item.licensed,
97
+ };
98
+ }),
99
+ );
100
+
101
+ const maximumValue = shallowRef(maxDate.copy());
102
+ const minimumValue = shallowRef(
103
+ maxDate.copy().subtract({ days: timeRangeMappings[maxLicensedDate] }),
104
+ );
105
+
106
+ function getDefaultRangeStart(): DateValue {
107
+ const sevenDaysAgo = maxDate.copy().subtract({ days: 7 });
108
+ if (insightsStore.earliestDataDate) {
109
+ const earliestDate = parseDate(insightsStore.earliestDataDate.substring(0, 10));
110
+ // If data only starts within the last 7 days, begin from first data point
111
+ return earliestDate.compare(sevenDaysAgo) > 0 ? earliestDate : sevenDaysAgo;
112
+ }
113
+ return sevenDaysAgo;
114
+ }
115
+
116
+ const range = shallowRef<{
117
+ start: DateValue;
118
+ end: DateValue;
119
+ }>({
120
+ start: getDefaultRangeStart(),
121
+ end: maxDate.copy(),
122
+ });
123
+
124
+ /**
125
+ * Converts the range to adjusted Date objects for API calls
126
+ */
127
+ const getFilteredRange = () => {
128
+ return getAdjustedDateRange(range.value);
129
+ };
130
+
131
+ const fetchPaginatedTableData = ({
132
+ page = 0,
133
+ itemsPerPage = 25,
134
+ sortBy,
135
+ projectId = selectedProject.value?.id,
136
+ }: {
137
+ page?: number;
138
+ itemsPerPage?: number;
139
+ sortBy: Array<{ id: string; desc: boolean }>;
140
+ projectId?: string;
141
+ }) => {
142
+ const skip = page * itemsPerPage;
143
+ const take = itemsPerPage;
144
+
145
+ const sortKey = sortBy.length ? transformFilter(sortBy[0]) : undefined;
146
+
147
+ const { startDate, endDate } = getFilteredRange();
148
+ void insightsStore.table.execute(0, {
149
+ skip,
150
+ take,
151
+ sortBy: sortKey,
152
+ startDate,
153
+ endDate,
154
+ projectId,
155
+ });
156
+ };
157
+
158
+ let latestFetchId = 0;
159
+
160
+ watch(
161
+ () => [props.insightType, selectedProject.value, range.value],
162
+ async () => {
163
+ const fetchId = ++latestFetchId;
164
+
165
+ sortTableBy.value = [{ id: props.insightType, desc: true }];
166
+
167
+ const { startDate, endDate } = getFilteredRange();
168
+ const projectId = selectedProject.value?.id;
169
+
170
+ if (insightsStore.isSummaryEnabled) {
171
+ void insightsStore.summary.execute(0, { startDate, endDate, projectId });
172
+ }
173
+
174
+ const chartsPromise = insightsStore.charts.execute(0, { startDate, endDate, projectId });
175
+
176
+ if (insightsStore.isDashboardEnabled) {
177
+ fetchPaginatedTableData({
178
+ sortBy: sortTableBy.value,
179
+ projectId,
180
+ });
181
+ }
182
+
183
+ await chartsPromise;
184
+
185
+ // A newer selection/range change has superseded this request.
186
+ if (fetchId !== latestFetchId) {
187
+ return;
188
+ }
189
+
190
+ // Callers may receive HTTP 403 if they have no `workflow:read` permission for a project.
191
+ // Revert to "All projects" instead of leaving the dashboard stuck on an all-zero, misleading state.
192
+ const chartsError = insightsStore.charts.error;
193
+ if (projectId && chartsError instanceof ResponseError && chartsError.httpStatusCode === 403) {
194
+ toast.showError(chartsError, i18n.baseText('insights.dashboard.error.forbidden.title'), {
195
+ message: i18n.baseText('insights.dashboard.error.forbidden.message'),
196
+ });
197
+ selectedProject.value = null;
198
+ }
199
+ },
200
+ {
201
+ immediate: true,
202
+ },
203
+ );
204
+
205
+ onMounted(() => {
206
+ useDocumentTitle().set(i18n.baseText('insights.heading'));
207
+ });
208
+ </script>
209
+
210
+ <template>
211
+ <div :class="$style.insightsView">
212
+ <div :class="$style.insightsContainer">
213
+ <N8nHeading bold tag="h2" size="xlarge">
214
+ {{ i18n.baseText('insights.dashboard.title') }}
215
+ </N8nHeading>
216
+
217
+ <div class="mt-s" style="display: flex; gap: 12px; align-items: center">
218
+ <component
219
+ :is="projectFilter"
220
+ v-if="projectFilter"
221
+ v-model="selectedProject"
222
+ :placeholder="i18n.baseText('insights.dashboard.search.placeholder')"
223
+ size="mini"
224
+ :class="$style.projectSelect"
225
+ />
226
+
227
+ <InsightsDataRangePicker
228
+ v-model="range"
229
+ :max-value="maximumValue"
230
+ :min-value="minimumValue"
231
+ :presets
232
+ />
233
+ </div>
234
+
235
+ <InsightsDateRangeAlert
236
+ :key="range.start.toString()"
237
+ class="mt-s"
238
+ :earliest-data-date="insightsStore.earliestDataDate"
239
+ :range-start="range.start"
240
+ :range-end="range.end"
241
+ />
242
+
243
+ <InsightsSummary
244
+ v-if="insightsStore.isSummaryEnabled"
245
+ :summary="insightsStore.summary.state"
246
+ :loading="insightsStore.summary.isLoading"
247
+ :start-date="range.start"
248
+ :end-date="range.end"
249
+ :class="$style.insightsBanner"
250
+ />
251
+ <div :class="$style.insightsContent">
252
+ <div
253
+ v-if="insightsStore.isDashboardEnabled || isTimeSavedRoute"
254
+ :class="$style.insightsContentWrapper"
255
+ >
256
+ <div
257
+ :class="[
258
+ $style.dataLoader,
259
+ {
260
+ [$style.isDataLoading]:
261
+ insightsStore.charts.isLoading || insightsStore.table.isLoading,
262
+ },
263
+ ]"
264
+ >
265
+ <N8nSpinner />
266
+ <span>{{ i18n.baseText('insights.chart.loading') }}</span>
267
+ </div>
268
+ <div :class="$style.insightsChartWrapper">
269
+ <N8nHeading bold tag="h3" size="medium" class="mb-s">{{
270
+ i18n.baseText('insights.dashboard.chart.title', { interpolate: { granularity } })
271
+ }}</N8nHeading>
272
+ <component
273
+ :is="chartComponents[props.insightType]"
274
+ :type="props.insightType"
275
+ :data="insightsStore.charts.state"
276
+ :granularity
277
+ :start-date="range.start.toString()"
278
+ :end-date="range.end.toString()"
279
+ />
280
+ </div>
281
+ <div :class="$style.insightsTableWrapper">
282
+ <InsightsTableWorkflows
283
+ v-model:sort-by="sortTableBy"
284
+ :data="insightsStore.table.state"
285
+ :loading="insightsStore.table.isLoading"
286
+ :is-dashboard-enabled="insightsStore.isDashboardEnabled"
287
+ @update:options="fetchPaginatedTableData"
288
+ />
289
+ </div>
290
+ </div>
291
+ <InsightsPaywall v-else />
292
+ </div>
293
+ </div>
294
+ </div>
295
+ </template>
296
+
297
+ <style lang="scss" module>
298
+ .insightsView {
299
+ flex: 1;
300
+ display: flex;
301
+ flex-direction: column;
302
+ gap: 30px;
303
+ overflow: auto;
304
+ }
305
+
306
+ .insightsContainer {
307
+ width: 100%;
308
+ max-width: var(--content-container--width);
309
+ padding: var(--spacing--lg) var(--spacing--2xl);
310
+ margin: 0 auto;
311
+ }
312
+
313
+ .insightsBanner {
314
+ margin-bottom: 0;
315
+
316
+ ul {
317
+ border-bottom-left-radius: 0;
318
+ border-bottom-right-radius: 0;
319
+ }
320
+ }
321
+
322
+ .insightsContent {
323
+ padding: var(--spacing--lg) 0;
324
+ border: var(--border-width) var(--border-style) var(--color--foreground);
325
+ border-top: 0;
326
+ border-bottom-left-radius: 6px;
327
+ border-bottom-right-radius: 6px;
328
+ background: var(--color--background--light-3);
329
+ }
330
+
331
+ .insightsContentWrapper {
332
+ position: relative;
333
+ overflow-x: hidden;
334
+ }
335
+
336
+ .insightsChartWrapper {
337
+ position: relative;
338
+ height: 292px;
339
+ padding: 0 var(--spacing--lg) var(--spacing--lg);
340
+ z-index: 1;
341
+ }
342
+
343
+ .insightsTableWrapper {
344
+ position: relative;
345
+ padding: var(--spacing--lg) var(--spacing--lg) 0;
346
+ z-index: 1;
347
+ }
348
+
349
+ .dataLoader {
350
+ position: absolute;
351
+ top: 0;
352
+ left: -100%;
353
+ height: 100%;
354
+ width: 100%;
355
+ display: flex;
356
+ flex-direction: column;
357
+ align-items: center;
358
+ justify-content: center;
359
+ gap: 9px;
360
+ z-index: 2;
361
+
362
+ &.isDataLoading {
363
+ transition: left 0s linear;
364
+ left: 0;
365
+ transition-delay: 0.5s;
366
+ }
367
+
368
+ > span {
369
+ position: relative;
370
+ z-index: 2;
371
+ }
372
+
373
+ &::before {
374
+ content: '';
375
+ position: absolute;
376
+ display: block;
377
+ top: 0;
378
+ left: 0;
379
+ width: 100%;
380
+ height: 100%;
381
+ background-color: var(--color--background--light-3);
382
+ opacity: 0.75;
383
+ z-index: 1;
384
+ }
385
+ }
386
+
387
+ .projectSelect {
388
+ min-width: 200px;
389
+ :global(.el-input--suffix .el-input__inner) {
390
+ padding-right: 26px;
391
+ }
392
+ }
393
+
394
+ .PresetButton {
395
+ background-color: transparent;
396
+ border: none;
397
+ transition-property:
398
+ color,
399
+ background-color,
400
+ border-color,
401
+ text-decoration-color,
402
+ fill,
403
+ stroke,
404
+ opacity,
405
+ box-shadow,
406
+ transform,
407
+ filter,
408
+ backdrop-filter,
409
+ -webkit-backdrop-filter;
410
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
411
+ transition-duration: 0.15s;
412
+ border-radius: 0.375rem;
413
+ padding-left: 0.75rem;
414
+ padding-right: 0.75rem;
415
+ padding-top: 0.5rem;
416
+ padding-bottom: 0.5rem;
417
+ text-align: left;
418
+ font-size: 13px;
419
+ cursor: pointer;
420
+ color: var(--color--text);
421
+ font-weight: 400;
422
+ &:hover {
423
+ background-color: var(--color--foreground--tint-1);
424
+ }
425
+ }
426
+ </style>
@@ -0,0 +1,195 @@
1
+ <script lang="ts" setup>
2
+ import { getLocalTimeZone, isToday } from '@internationalized/date';
3
+ import { useTelemetry } from '@n8n/composables/useTelemetry';
4
+ import type {
5
+ DateRange,
6
+ DateValue,
7
+ N8nDateRangePickerProps,
8
+ N8nDateRangePickerRootEmits,
9
+ } from '@n8n/design-system';
10
+ import { N8nButton, N8nDateRangePicker, N8nIcon } from '@n8n/design-system';
11
+ import { computed, ref, shallowRef, watch } from 'vue';
12
+
13
+ import { formatDateRange, getAdjustedDateRange } from '../insights.utils';
14
+ import InsightsUpgradeModal from './InsightsUpgradeModal.vue';
15
+
16
+ type Props = Pick<N8nDateRangePickerProps, 'maxValue' | 'minValue'>;
17
+ type Value = {
18
+ start: DateValue;
19
+ end: DateValue;
20
+ };
21
+
22
+ const props = defineProps<
23
+ Required<Props> & {
24
+ modelValue: Value;
25
+ presets: Array<{ value: number; label: string; disabled?: boolean }>;
26
+ }
27
+ >();
28
+ const emit = defineEmits<N8nDateRangePickerRootEmits>();
29
+ const telemetry = useTelemetry();
30
+
31
+ const upgradeModal = ref(false);
32
+ function showUpgradeModal() {
33
+ upgradeModal.value = true;
34
+ }
35
+
36
+ const actionType = ref<'preset' | 'custom'>('custom');
37
+
38
+ function getDaysDiff({ start, end }: DateRange) {
39
+ if (!start) return 0;
40
+ if (!end) return 0;
41
+
42
+ return end.compare(start);
43
+ }
44
+
45
+ function isBeforeOrSame(dateToCompare: DateValue, referenceDate: DateValue): boolean {
46
+ return dateToCompare.compare(referenceDate) <= 0;
47
+ }
48
+
49
+ function isAfterOrSame(dateToCompare: DateValue, referenceDate: DateValue) {
50
+ return dateToCompare.compare(referenceDate) >= 0;
51
+ }
52
+
53
+ function isEqual(dateToCompare?: DateValue, referenceDate?: DateValue) {
54
+ if (!dateToCompare || !referenceDate) return false;
55
+ return dateToCompare.compare(referenceDate) === 0;
56
+ }
57
+
58
+ function isValidDateRange({ start, end }: DateRange) {
59
+ if (!start) return false;
60
+ if (!end) return false;
61
+
62
+ return isBeforeOrSame(end, props.maxValue) && isAfterOrSame(start, props.minValue);
63
+ }
64
+
65
+ const range = shallowRef<N8nDateRangePickerProps['modelValue']>({
66
+ start: props.modelValue.start?.copy(),
67
+ end: props.modelValue.end?.copy(),
68
+ });
69
+
70
+ function syncWithParentValue() {
71
+ if (
72
+ !isEqual(range.value?.start, props.modelValue.start) ||
73
+ !isEqual(range.value?.end, props.modelValue.end)
74
+ ) {
75
+ range.value = {
76
+ start: props.modelValue.start?.copy(),
77
+ end: props.modelValue.end?.copy(),
78
+ };
79
+ }
80
+ }
81
+
82
+ let lastSyncedRange: DateRange | null = null;
83
+
84
+ function syncData(isOpen: boolean) {
85
+ if (isOpen) {
86
+ syncWithParentValue();
87
+ return;
88
+ }
89
+
90
+ const normalizedRange: DateRange = {
91
+ start: range.value?.start?.copy(),
92
+ end: range.value?.end?.copy() ?? range.value?.start?.copy(),
93
+ };
94
+
95
+ if (!isValidDateRange(normalizedRange)) {
96
+ console.error('Invalid date range selected', normalizedRange);
97
+ syncWithParentValue();
98
+ return;
99
+ }
100
+
101
+ // Check if this is the same range we just synced (prevent double sync)
102
+ if (
103
+ lastSyncedRange &&
104
+ isEqual(normalizedRange.start, lastSyncedRange.start) &&
105
+ isEqual(normalizedRange.end, lastSyncedRange.end)
106
+ ) {
107
+ return;
108
+ }
109
+
110
+ if (
111
+ isEqual(normalizedRange.start, props.modelValue.start) &&
112
+ isEqual(normalizedRange.end, props.modelValue.end)
113
+ ) {
114
+ return;
115
+ }
116
+
117
+ lastSyncedRange = normalizedRange;
118
+ emit('update:modelValue', normalizedRange);
119
+
120
+ const { startDate, endDate } = getAdjustedDateRange(normalizedRange);
121
+
122
+ const trackData = {
123
+ start_date: startDate.toISOString(),
124
+ end_date: endDate.toISOString(),
125
+ range_length_days: getDaysDiff(normalizedRange),
126
+ type: actionType.value,
127
+ };
128
+
129
+ telemetry.track('User updated insights time range', trackData);
130
+ }
131
+ const open = ref(false);
132
+ watch(open, (opened) => {
133
+ if (opened) {
134
+ actionType.value = 'custom';
135
+ }
136
+ syncData(opened);
137
+ });
138
+
139
+ function setPresetRange(days: number) {
140
+ range.value = {
141
+ start: props.maxValue.copy().subtract({ days }),
142
+ end: props.maxValue.copy(),
143
+ };
144
+ actionType.value = 'preset';
145
+ open.value = false;
146
+ }
147
+
148
+ const formattedRange = computed(() => {
149
+ const { start, end } = props.modelValue;
150
+
151
+ if (!start) return 'Select range';
152
+
153
+ return formatDateRange({ start, end });
154
+ });
155
+
156
+ function isActiveRange(presetValue: number) {
157
+ if (!isToday(props.modelValue.end, getLocalTimeZone())) return false;
158
+
159
+ return props.modelValue.end.compare(props.modelValue.start) === presetValue;
160
+ }
161
+ </script>
162
+
163
+ <template>
164
+ <!-- eslint-disable vue/no-multiple-template-root -->
165
+ <N8nDateRangePicker v-model="range" v-model:open="open" :max-value :min-value>
166
+ <template #trigger>
167
+ <N8nButton variant="subtle" icon="calendar">{{ formattedRange }}</N8nButton>
168
+ </template>
169
+ <template #presets>
170
+ <N8nButton
171
+ v-for="preset in presets"
172
+ :key="preset.value"
173
+ :class="$style.PresetButton"
174
+ :variant="isActiveRange(preset.value) ? 'solid' : 'outline'"
175
+ size="small"
176
+ @click="preset.disabled ? showUpgradeModal() : setPresetRange(preset.value)"
177
+ >
178
+ {{ preset.label }}
179
+ <N8nIcon v-if="preset.disabled" icon="lock" :class="$style.LockIcon" />
180
+ </N8nButton>
181
+ </template>
182
+ </N8nDateRangePicker>
183
+ <InsightsUpgradeModal v-model="upgradeModal" />
184
+ </template>
185
+
186
+ <style module>
187
+ .PresetButton {
188
+ --button--border-color: transparent;
189
+ text-align: left;
190
+ display: flex;
191
+ }
192
+ .LockIcon {
193
+ margin-left: auto;
194
+ }
195
+ </style>