feat(editor): Add time range selector to Insights (#14877)

Co-authored-by: Guillaume Jacquart <jacquart.guillaume@gmail.com>
This commit is contained in:
Raúl Gómez Morales
2025-04-28 11:11:36 +02:00
committed by GitHub
parent 2d60e469f3
commit bfd85dd3c9
23 changed files with 481 additions and 189 deletions

View File

@@ -1,5 +1,7 @@
import type { ExpressionEvaluatorType, LogLevel, WorkflowSettings } from 'n8n-workflow';
import { type InsightsDateRange } from './schemas/insights.schema';
export interface IVersionNotificationSettings {
enabled: boolean;
endpoint: string;
@@ -193,5 +195,6 @@ export interface FrontendSettings {
enabled: boolean;
summary: boolean;
dashboard: boolean;
dateRanges: InsightsDateRange[];
};
}

View File

@@ -251,6 +251,7 @@ export class FrontendService {
enabled: this.modulesConfig.modules.includes('insights'),
summary: true,
dashboard: false,
dateRanges: [],
},
logsView: {
enabled: false,

View File

@@ -149,6 +149,15 @@ export const defaultSettings: FrontendSettings = {
enabled: false,
summary: true,
dashboard: false,
dateRanges: [
{ key: 'day', licensed: true, granularity: 'hour' },
{ key: 'week', licensed: true, granularity: 'day' },
{ key: '2weeks', licensed: true, granularity: 'day' },
{ key: 'month', licensed: false, granularity: 'day' },
{ key: 'quarter', licensed: false, granularity: 'week' },
{ key: '6months', licensed: false, granularity: 'week' },
{ key: 'year', licensed: false, granularity: 'week' },
],
},
logsView: {
enabled: false,

View File

@@ -0,0 +1,44 @@
import { createComponentRenderer } from '@/__tests__/render';
import InsightsDashboard from './InsightsDashboard.vue';
import { createTestingPinia } from '@pinia/testing';
import { defaultSettings } from '@/__tests__/defaults';
import { useInsightsStore } from '@/features/insights/insights.store';
import { mockedStore } from '@/__tests__/utils';
import { within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
const renderComponent = createComponentRenderer(InsightsDashboard, {
props: {
insightType: 'total',
},
});
describe('InsightsDashboard', () => {
beforeEach(() => {
createTestingPinia({ initialState: { settings: { settings: defaultSettings } } });
});
it('should render without error', () => {
mockedStore(useInsightsStore);
expect(() => renderComponent()).not.toThrow();
});
it('should update the selected time range', async () => {
mockedStore(useInsightsStore);
const { getByTestId } = renderComponent();
expect(getByTestId('range-select')).toBeVisible();
const select = within(getByTestId('range-select')).getByRole('combobox');
await userEvent.click(select);
const controllingId = select.getAttribute('aria-controls');
const actions = document.querySelector(`#${controllingId}`);
if (!actions) {
throw new Error('Actions menu not found');
}
await userEvent.click(actions.querySelectorAll('li')[0]);
expect((select as HTMLInputElement).value).toBe('Last 24 hours');
});
});

View File

@@ -1,9 +1,13 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import { useInsightsStore } from '@/features/insights/insights.store';
import type { InsightsSummaryType } from '@n8n/api-types';
import type { InsightsDateRange, InsightsSummaryType } from '@n8n/api-types';
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import { TELEMETRY_TIME_RANGE, UNLICENSED_TIME_RANGE } from '../insights.constants';
import InsightsDateRangeSelect from './InsightsDateRangeSelect.vue';
import InsightsUpgradeModal from './InsightsUpgradeModal.vue';
const InsightsPaywall = defineAsyncComponent(
async () => await import('@/features/insights/components/InsightsPaywall.vue'),
@@ -32,6 +36,7 @@ const props = defineProps<{
}>();
const i18n = useI18n();
const telemetry = useTelemetry();
const insightsStore = useInsightsStore();
@@ -53,10 +58,12 @@ const fetchPaginatedTableData = ({
page = 0,
itemsPerPage = 20,
sortBy,
dateRange = selectedDateRange.value,
}: {
page?: number;
itemsPerPage?: number;
sortBy: Array<{ id: string; desc: boolean }>;
dateRange?: InsightsDateRange['key'];
}) => {
const skip = page * itemsPerPage;
const take = itemsPerPage;
@@ -67,22 +74,42 @@ const fetchPaginatedTableData = ({
skip,
take,
sortBy: sortKey,
dateRange,
});
};
const sortTableBy = ref([{ id: props.insightType, desc: true }]);
const upgradeModalVisible = ref(false);
const selectedDateRange = ref<InsightsDateRange['key']>('week');
const granularity = computed(
() =>
insightsStore.dateRanges.find((item) => item.key === selectedDateRange.value)?.granularity ??
'day',
);
function handleTimeChange(value: InsightsDateRange['key'] | typeof UNLICENSED_TIME_RANGE) {
if (value === UNLICENSED_TIME_RANGE) {
upgradeModalVisible.value = true;
return;
}
selectedDateRange.value = value;
telemetry.track('User updated insights time range', { range: TELEMETRY_TIME_RANGE[value] });
}
watch(
() => props.insightType,
() => [props.insightType, selectedDateRange.value],
() => {
sortTableBy.value = [{ id: props.insightType, desc: true }];
if (insightsStore.isSummaryEnabled) {
void insightsStore.summary.execute();
void insightsStore.summary.execute(0, { dateRange: selectedDateRange.value });
}
if (insightsStore.isDashboardEnabled) {
void insightsStore.charts.execute();
fetchPaginatedTableData({ sortBy: sortTableBy.value });
void insightsStore.charts.execute(0, { dateRange: selectedDateRange.value });
fetchPaginatedTableData({ sortBy: sortTableBy.value, dateRange: selectedDateRange.value });
}
},
{
@@ -97,51 +124,63 @@ watch(
<N8nHeading bold tag="h2" size="xlarge">
{{ i18n.baseText('insights.dashboard.title') }}
</N8nHeading>
<div>
<InsightsSummary
v-if="insightsStore.isSummaryEnabled"
:summary="insightsStore.summary.state"
:loading="insightsStore.summary.isLoading"
:class="$style.insightsBanner"
<div class="mt-s">
<InsightsDateRangeSelect
:model-value="selectedDateRange"
style="width: 173px"
data-test-id="range-select"
@update:model-value="handleTimeChange"
/>
<div :class="$style.insightsContent">
<InsightsPaywall
v-if="!insightsStore.isDashboardEnabled"
data-test-id="insights-dashboard-unlicensed"
/>
<div v-else>
<div :class="$style.insightsChartWrapper">
<div v-if="insightsStore.charts.isLoading" :class="$style.chartLoader">
<svg
width="22"
height="22"
viewBox="0 0 22 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 11C21 16.5228 16.5228 21 11 21C5.47715 21 1 16.5228 1 11C1 5.47715 5.47715 1 11 1C11.6293 1 12.245 1.05813 12.8421 1.16931"
stroke="currentColor"
stroke-width="2"
/>
</svg>
{{ i18n.baseText('insights.chart.loading') }}
</div>
<component
:is="chartComponents[props.insightType]"
v-else
:type="props.insightType"
:data="insightsStore.charts.state"
/>
</div>
<div :class="$style.insightsTableWrapper">
<InsightsTableWorkflows
v-model:sort-by="sortTableBy"
:data="insightsStore.table.state"
:loading="insightsStore.table.isLoading"
@update:options="fetchPaginatedTableData"
/>
<InsightsUpgradeModal v-model="upgradeModalVisible" />
</div>
<InsightsSummary
v-if="insightsStore.isSummaryEnabled"
:summary="insightsStore.summary.state"
:loading="insightsStore.summary.isLoading"
:time-range="selectedDateRange"
:class="$style.insightsBanner"
/>
<div :class="$style.insightsContent">
<InsightsPaywall
v-if="!insightsStore.isDashboardEnabled"
data-test-id="insights-dashboard-unlicensed"
/>
<div v-else>
<div :class="$style.insightsChartWrapper">
<div v-if="insightsStore.charts.isLoading" :class="$style.chartLoader">
<svg
width="22"
height="22"
viewBox="0 0 22 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M21 11C21 16.5228 16.5228 21 11 21C5.47715 21 1 16.5228 1 11C1 5.47715 5.47715 1 11 1C11.6293 1 12.245 1.05813 12.8421 1.16931"
stroke="currentColor"
stroke-width="2"
/>
</svg>
{{ i18n.baseText('insights.chart.loading') }}
</div>
<component
:is="chartComponents[props.insightType]"
v-else
:type="props.insightType"
:data="insightsStore.charts.state"
:granularity
/>
</div>
<div :class="$style.insightsTableWrapper">
<InsightsTableWorkflows
v-model:sort-by="sortTableBy"
:data="insightsStore.table.state"
:loading="insightsStore.table.isLoading"
@update:options="fetchPaginatedTableData"
/>
</div>
</div>
</div>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { useInsightsStore } from '@/features/insights/insights.store';
import type { InsightsDateRange } from '@n8n/api-types';
import { N8nOption, N8nSelect } from '@n8n/design-system';
import { ref } from 'vue';
import { TIME_RANGE_LABELS, UNLICENSED_TIME_RANGE } from '../insights.constants';
const model = defineModel<InsightsDateRange['key'] | typeof UNLICENSED_TIME_RANGE>({
required: true,
});
const insightsStore = useInsightsStore();
const timeOptions = ref(
insightsStore.dateRanges.map((option) => {
return {
key: option.key,
label: TIME_RANGE_LABELS[option.key],
value: option.licensed ? option.key : UNLICENSED_TIME_RANGE,
licensed: option.licensed,
};
}),
);
</script>
<template>
<N8nSelect v-model="model" size="small">
<N8nOption v-for="item in timeOptions" :key="item.key" :value="item.value" :label="item.label">
{{ item.label }}
<svg
v-if="!item.licensed"
width="16"
height="17"
viewBox="0 0 16 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style="margin-left: auto"
>
<path
d="M12.6667 7.83337H3.33333C2.59695 7.83337 2 8.43033 2 9.16671V13.8334C2 14.5698 2.59695 15.1667 3.33333 15.1667H12.6667C13.403 15.1667 14 14.5698 14 13.8334V9.16671C14 8.43033 13.403 7.83337 12.6667 7.83337Z"
stroke="#9A9A9A"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4.66681 7.83337V5.16671C4.66681 4.28265 5.018 3.43481 5.64312 2.80968C6.26824 2.18456 7.11609 1.83337 8.00014 1.83337C8.8842 1.83337 9.73204 2.18456 10.3572 2.80968C10.9823 3.43481 11.3335 4.28265 11.3335 5.16671V7.83337"
stroke="#9A9A9A"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</N8nOption>
</N8nSelect>
</template>

View File

@@ -27,6 +27,7 @@ describe('InsightsSummary', () => {
renderComponent({
props: {
summary: [],
timeRange: 'week',
},
}),
).not.toThrow();
@@ -95,6 +96,7 @@ describe('InsightsSummary', () => {
const { html } = renderComponent({
props: {
summary,
timeRange: 'week',
},
});

View File

@@ -5,15 +5,17 @@ import { VIEWS } from '@/constants';
import {
INSIGHT_IMPACT_TYPES,
INSIGHTS_UNIT_IMPACT_MAPPING,
TIME_RANGE_LABELS,
} from '@/features/insights/insights.constants';
import type { InsightsSummaryDisplay } from '@/features/insights/insights.types';
import type { InsightsSummary } from '@n8n/api-types';
import type { InsightsDateRange, InsightsSummary } from '@n8n/api-types';
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
import { computed, ref, useCssModule } from 'vue';
import { computed, useCssModule } from 'vue';
import { useRoute } from 'vue-router';
const props = defineProps<{
summary: InsightsSummaryDisplay;
timeRange: InsightsDateRange['key'];
loading?: boolean;
}>();
@@ -22,8 +24,6 @@ const route = useRoute();
const $style = useCssModule();
const telemetry = useTelemetry();
const lastNDays = ref(7);
const summaryTitles = computed<Record<keyof InsightsSummary, string>>(() => ({
total: i18n.baseText('insights.banner.title.total'),
failed: i18n.baseText('insights.banner.title.failed'),
@@ -84,9 +84,9 @@ const trackTabClick = (insightType: keyof InsightsSummary) => {
{{ summaryTitles[id] }}
</N8nTooltip>
</strong>
<small :class="$style.days">{{
i18n.baseText('insights.lastNDays', { interpolate: { count: lastNDays } })
}}</small>
<small :class="$style.days">
{{ TIME_RANGE_LABELS[timeRange] }}
</small>
<span v-if="summaryHasNoData" :class="$style.noData">
<N8nTooltip placement="bottom">
<template #content>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import type { BaseTextKey } from '@/plugins/i18n';
import { N8nButton, N8nText } from '@n8n/design-system';
import { ElDialog } from 'element-plus';
import { computed } from 'vue';
const model = defineModel<boolean>();
const i18n = useI18n();
function goToUpgrade() {
model.value = false;
void usePageRedirectionHelper().goToUpgrade('insights', 'upgrade-insights');
}
const perks = computed(() =>
[...Array(3).keys()].map((index) =>
i18n.baseText(`insights.upgradeModal.perks.${index}` as BaseTextKey),
),
);
</script>
<template>
<ElDialog v-model="model" :title="i18n.baseText('insights.upgradeModal.title')" width="500">
<div>
<N8nText tag="p" class="mb-s">
{{ i18n.baseText('insights.upgradeModal.content') }}
</N8nText>
<ul class="perks-list">
<N8nText v-for="perk in perks" :key="perk" color="text-dark" tag="li">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16px" height="16px">
<path
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"
fill="currentColor"
/>
</svg>
{{ perk }}
</N8nText>
</ul>
</div>
<template #footer>
<div>
<N8nButton type="secondary" @click="model = false">
{{ i18n.baseText('insights.upgradeModal.button.dismiss') }}
</N8nButton>
<N8nButton type="primary" @click="goToUpgrade">
{{ i18n.baseText('insights.upgradeModal.button.upgrade') }}
</N8nButton>
</div>
</template>
</ElDialog>
</template>
<style scoped>
.perks-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: var(--spacing-s);
> li {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
}
}
</style>

View File

@@ -4,20 +4,18 @@ import {
generateLinearGradient,
generateLineChartOptions,
} from '@/features/insights/chartjs.utils';
import { DATE_FORMAT_MASK, INSIGHTS_UNIT_MAPPING } from '@/features/insights/insights.constants';
import {
GRANULARITY_DATE_FORMAT_MASK,
INSIGHTS_UNIT_MAPPING,
} from '@/features/insights/insights.constants';
import { transformInsightsAverageRunTime } from '@/features/insights/insights.utils';
import type { InsightsByTime, InsightsSummaryType } from '@n8n/api-types';
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
import { type ChartData, Filler, type ScriptableContext } from 'chart.js';
import dateformat from 'dateformat';
import { computed } from 'vue';
import { Line } from 'vue-chartjs';
import type { ChartProps } from './insightChartProps';
const props = defineProps<{
data: InsightsByTime[];
type: InsightsSummaryType;
}>();
const props = defineProps<ChartProps>();
const i18n = useI18n();
const chartOptions = computed(() =>
@@ -40,7 +38,7 @@ const chartData = computed<ChartData<'line'>>(() => {
const data: number[] = [];
for (const entry of props.data) {
labels.push(dateformat(entry.date, DATE_FORMAT_MASK));
labels.push(GRANULARITY_DATE_FORMAT_MASK[props.granularity](entry.date));
const value = transformInsightsAverageRunTime(entry.values.averageRunTime);

View File

@@ -1,19 +1,16 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
import { generateBarChartOptions } from '@/features/insights/chartjs.utils';
import { DATE_FORMAT_MASK } from '@/features/insights/insights.constants';
import type { InsightsByTime, InsightsSummaryType } from '@n8n/api-types';
import { GRANULARITY_DATE_FORMAT_MASK } from '@/features/insights/insights.constants';
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
import { useCssVar } from '@vueuse/core';
import type { ChartData } from 'chart.js';
import dateformat from 'dateformat';
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
const props = defineProps<{
data: InsightsByTime[];
type: InsightsSummaryType;
}>();
import type { ChartProps } from './insightChartProps';
const props = defineProps<ChartProps>();
const i18n = useI18n();
@@ -38,7 +35,7 @@ const chartData = computed<ChartData<'bar'>>(() => {
const data: number[] = [];
for (const entry of props.data) {
labels.push(dateformat(entry.date, DATE_FORMAT_MASK));
labels.push(GRANULARITY_DATE_FORMAT_MASK[props.granularity](entry.date));
data.push(entry.values.failed);
}

View File

@@ -1,20 +1,19 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
import { generateBarChartOptions } from '@/features/insights/chartjs.utils';
import { DATE_FORMAT_MASK, INSIGHTS_UNIT_MAPPING } from '@/features/insights/insights.constants';
import {
GRANULARITY_DATE_FORMAT_MASK,
INSIGHTS_UNIT_MAPPING,
} from '@/features/insights/insights.constants';
import { transformInsightsFailureRate } from '@/features/insights/insights.utils';
import type { InsightsByTime, InsightsSummaryType } from '@n8n/api-types';
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
import { useCssVar } from '@vueuse/core';
import type { ChartData } from 'chart.js';
import dateformat from 'dateformat';
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import type { ChartProps } from './insightChartProps';
const props = defineProps<{
data: InsightsByTime[];
type: InsightsSummaryType;
}>();
const props = defineProps<ChartProps>();
const i18n = useI18n();
@@ -39,7 +38,7 @@ const chartData = computed<ChartData<'bar'>>(() => {
const data: number[] = [];
for (const entry of props.data) {
labels.push(dateformat(entry.date, DATE_FORMAT_MASK));
labels.push(GRANULARITY_DATE_FORMAT_MASK[props.granularity](entry.date));
data.push(transformInsightsFailureRate(entry.values.failureRate));
}

View File

@@ -6,18 +6,17 @@ import {
} from '@/features/insights/chartjs.utils';
import { transformInsightsTimeSaved } from '@/features/insights/insights.utils';
import { DATE_FORMAT_MASK, INSIGHTS_UNIT_MAPPING } from '@/features/insights/insights.constants';
import type { InsightsByTime, InsightsSummaryType } from '@n8n/api-types';
import {
GRANULARITY_DATE_FORMAT_MASK,
INSIGHTS_UNIT_MAPPING,
} from '@/features/insights/insights.constants';
import { type ChartData, Filler, type ScriptableContext } from 'chart.js';
import dateformat from 'dateformat';
import { computed } from 'vue';
import { Line } from 'vue-chartjs';
const props = defineProps<{
data: InsightsByTime[];
type: InsightsSummaryType;
}>();
import type { ChartProps } from './insightChartProps';
const props = defineProps<ChartProps>();
const i18n = useI18n();
const chartOptions = computed(() =>
@@ -51,7 +50,7 @@ const chartData = computed<ChartData<'line'>>(() => {
const data: number[] = [];
for (const entry of props.data) {
labels.push(dateformat(entry.date, DATE_FORMAT_MASK));
labels.push(GRANULARITY_DATE_FORMAT_MASK[props.granularity](entry.date));
data.push(entry.values.timeSaved);
}

View File

@@ -1,18 +1,14 @@
<script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
import { generateBarChartOptions } from '@/features/insights/chartjs.utils';
import { DATE_FORMAT_MASK } from '@/features/insights/insights.constants';
import type { InsightsByTime, InsightsSummaryType } from '@n8n/api-types';
import { GRANULARITY_DATE_FORMAT_MASK } from '@/features/insights/insights.constants';
import { useCssVar } from '@vueuse/core';
import type { ChartData } from 'chart.js';
import dateformat from 'dateformat';
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import type { ChartProps } from './insightChartProps';
const props = defineProps<{
data: InsightsByTime[];
type: InsightsSummaryType;
}>();
const props = defineProps<ChartProps>();
const i18n = useI18n();
@@ -33,7 +29,7 @@ const chartData = computed<ChartData<'bar'>>(() => {
const failedData: number[] = [];
for (const entry of props.data) {
labels.push(dateformat(entry.date, DATE_FORMAT_MASK));
labels.push(GRANULARITY_DATE_FORMAT_MASK[props.granularity](entry.date));
succeededData.push(entry.values.succeeded);
failedData.push(entry.values.failed);
}

View File

@@ -0,0 +1,7 @@
import type { InsightsByTime, InsightsSummaryType, InsightsDateRange } from '@n8n/api-types';
export type ChartProps = {
data: InsightsByTime[];
type: InsightsSummaryType;
granularity: InsightsDateRange['granularity'];
};

View File

@@ -5,13 +5,20 @@ import type {
InsightsByTime,
InsightsByWorkflow,
ListInsightsWorkflowQueryDto,
InsightsDateRange,
} from '@n8n/api-types';
export const fetchInsightsSummary = async (context: IRestApiContext): Promise<InsightsSummary> =>
await makeRestApiRequest(context, 'GET', '/insights/summary');
export const fetchInsightsSummary = async (
context: IRestApiContext,
filter?: { dateRange: InsightsDateRange['key'] },
): Promise<InsightsSummary> =>
await makeRestApiRequest(context, 'GET', '/insights/summary', filter);
export const fetchInsightsByTime = async (context: IRestApiContext): Promise<InsightsByTime[]> =>
await makeRestApiRequest(context, 'GET', '/insights/by-time');
export const fetchInsightsByTime = async (
context: IRestApiContext,
filter?: { dateRange: InsightsDateRange['key'] },
): Promise<InsightsByTime[]> =>
await makeRestApiRequest(context, 'GET', '/insights/by-time', filter);
export const fetchInsightsByWorkflow = async (
context: IRestApiContext,

View File

@@ -1,4 +1,6 @@
import type { InsightsSummaryType } from '@n8n/api-types';
import { useI18n } from '@/composables/useI18n';
import dateformat from 'dateformat';
export const INSIGHTS_SUMMARY_ORDER: InsightsSummaryType[] = [
'total',
@@ -44,4 +46,39 @@ export const INSIGHTS_UNIT_IMPACT_MAPPING: Record<
averageRunTime: INSIGHT_IMPACT_TYPES.NEUTRAL, // Not good or bad → neutral (grey)
} as const;
export const DATE_FORMAT_MASK = 'mmm d';
export const GRANULARITY_DATE_FORMAT_MASK = {
hour: (date: string) => dateformat(date, 'HH:MM'),
day: (date: string) => dateformat(date, 'mmm d'),
week: (date: string) => {
const startDate = new Date(date);
const endDate = new Date(startDate);
endDate.setDate(startDate.getDate() + 7);
const spansTwoMonths = startDate.getMonth() !== endDate.getMonth();
const endDateFormat = spansTwoMonths ? 'mmm d' : 'd';
return [dateformat(startDate, 'mmm d'), dateformat(endDate, endDateFormat)].join('-');
},
};
export const TELEMETRY_TIME_RANGE = {
day: 1,
week: 7,
'2weeks': 14,
month: 30,
quarter: 90,
'6months': 180,
year: 365,
};
export const TIME_RANGE_LABELS = {
day: useI18n().baseText('insights.lastNHours', { interpolate: { count: 24 } }),
week: useI18n().baseText('insights.lastNDays', { interpolate: { count: 7 } }),
'2weeks': useI18n().baseText('insights.lastNDays', { interpolate: { count: 14 } }),
month: useI18n().baseText('insights.lastNDays', { interpolate: { count: 30 } }),
quarter: useI18n().baseText('insights.lastNDays', { interpolate: { count: 90 } }),
'6months': useI18n().baseText('insights.months', { interpolate: { count: 6 } }),
year: useI18n().baseText('insights.oneYear'),
};
export const UNLICENSED_TIME_RANGE = 'UNLICENSED_TIME_RANGE' as const;

View File

@@ -1,7 +1,7 @@
import { computed } from 'vue';
import { defineStore } from 'pinia';
import { useAsyncState } from '@vueuse/core';
import type { ListInsightsWorkflowQueryDto } from '@n8n/api-types';
import type { ListInsightsWorkflowQueryDto, InsightsDateRange } from '@n8n/api-types';
import * as insightsApi from '@/features/insights/insights.api';
import { useRootStore } from '@/stores/root.store';
import { useUsersStore } from '@/stores/users.store';
@@ -25,9 +25,20 @@ export const useInsightsStore = defineStore('insights', () => {
() => globalInsightsPermissions.value.list && isInsightsEnabled.value,
);
const summary = useAsyncState(
const weeklySummary = useAsyncState(
async () => {
const raw = await insightsApi.fetchInsightsSummary(rootStore.restApiContext);
const raw = await insightsApi.fetchInsightsSummary(rootStore.restApiContext, {
dateRange: 'week',
});
return transformInsightsSummary(raw);
},
[],
{ immediate: false },
);
const summary = useAsyncState(
async (filter?: { dateRange: InsightsDateRange['key'] }) => {
const raw = await insightsApi.fetchInsightsSummary(rootStore.restApiContext, filter);
return transformInsightsSummary(raw);
},
[],
@@ -35,8 +46,8 @@ export const useInsightsStore = defineStore('insights', () => {
);
const charts = useAsyncState(
async () => {
return await insightsApi.fetchInsightsByTime(rootStore.restApiContext);
async (filter?: { dateRange: InsightsDateRange['key'] }) => {
return await insightsApi.fetchInsightsByTime(rootStore.restApiContext, filter);
},
[],
{ immediate: false },
@@ -53,13 +64,17 @@ export const useInsightsStore = defineStore('insights', () => {
{ resetOnExecute: false, immediate: false },
);
const dateRanges = computed(() => settingsStore.settings.insights.dateRanges);
return {
globalInsightsPermissions,
isInsightsEnabled,
isSummaryEnabled,
isDashboardEnabled,
weeklySummary,
summary,
charts,
table,
dateRanges,
};
});

View File

@@ -102,7 +102,7 @@ export async function initializeAuthenticatedFeatures(
}
if (insightsStore.isSummaryEnabled) {
void insightsStore.summary.execute();
void insightsStore.weeklySummary.execute();
}
await Promise.all([

View File

@@ -3091,6 +3091,9 @@
"freeAi.credits.showWarning.workflow.activation.title": "You're using free OpenAI API credits",
"freeAi.credits.showWarning.workflow.activation.description": "To make sure your workflow runs smoothly in the future, replace the free OpenAI API credits with your own API key.",
"insights.lastNDays": "Last {count} days",
"insights.lastNHours": "Last {count} hours",
"insights.months": "{count} months",
"insights.oneYear": "One year",
"insights.banner.timeSaved.tooltip": "No estimate available yet. To see potential time savings, {link} to each workflow from workflow settings.",
"insights.banner.timeSaved.tooltip.link.text": "add time estimates",
"insights.banner.noData": "Collecting...",
@@ -3111,5 +3114,12 @@
"insights.banner.failureRate.deviation.tooltip": "Percentage point change from previous period",
"insights.chart.failed": "Failed",
"insights.chart.succeeded": "Successful",
"insights.chart.loading": "Loading data..."
"insights.chart.loading": "Loading data...",
"insights.upgradeModal.button.dismiss": "Dismiss",
"insights.upgradeModal.button.upgrade": "Upgrade",
"insights.upgradeModal.content": "Viewing this time period requires an enterprise plan. Upgrade to Enterprise to unlock advanced features.",
"insights.upgradeModal.perks.0": "View up to one year of insights history",
"insights.upgradeModal.perks.1": "Zoom into last 24 hours with hourly granularity",
"insights.upgradeModal.perks.2": "Gain deeper visibility into workflow trends over time",
"insights.upgradeModal.title": "Upgrade to Enterprise"
}

View File

@@ -1,40 +1,40 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router';
import type { ICredentialTypeMap } from '@/Interface';
import type { ICredentialType, ICredentialsDecrypted } from 'n8n-workflow';
import ResourcesListLayout, {
type Resource,
type BaseFilters,
} from '@/components/layouts/ResourcesListLayout.vue';
import CredentialCard from '@/components/CredentialCard.vue';
import ResourcesListLayout, {
type BaseFilters,
type Resource,
} from '@/components/layouts/ResourcesListLayout.vue';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useI18n } from '@/composables/useI18n';
import { useOverview } from '@/composables/useOverview';
import { useTelemetry } from '@/composables/useTelemetry';
import {
CREDENTIAL_SELECT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
EnterpriseEditionFeature,
VIEWS,
} from '@/constants';
import { useUIStore, listenForModalChanges } from '@/stores/ui.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useProjectsStore } from '@/stores/projects.store';
import useEnvironmentsStore from '@/stores/environments.ee.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import { useInsightsStore } from '@/features/insights/insights.store';
import type { ICredentialTypeMap } from '@/Interface';
import { getResourcePermissions } from '@/permissions';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useTelemetry } from '@/composables/useTelemetry';
import { useI18n } from '@/composables/useI18n';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import { useCredentialsStore } from '@/stores/credentials.store';
import useEnvironmentsStore from '@/stores/environments.ee.store';
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { listenForModalChanges, useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import { isCredentialsResource } from '@/utils/typeGuards';
import { N8nCheckbox } from '@n8n/design-system';
import { pickBy } from 'lodash-es';
import type { ICredentialType, ICredentialsDecrypted } from 'n8n-workflow';
import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
import { isCredentialsResource } from '@/utils/typeGuards';
import { useInsightsStore } from '@/features/insights/insights.store';
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import { useOverview } from '@/composables/useOverview';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter, type LocationQueryRaw } from 'vue-router';
const props = defineProps<{
credentialId?: string;
@@ -243,8 +243,9 @@ onMounted(() => {
<ProjectHeader>
<InsightsSummary
v-if="overview.isOverviewSubPage && insightsStore.isSummaryEnabled"
:loading="insightsStore.summary.isLoading"
:summary="insightsStore.summary.state"
:loading="insightsStore.weeklySummary.isLoading"
:summary="insightsStore.weeklySummary.state"
time-range="week"
/>
</ProjectHeader>
</template>

View File

@@ -1,20 +1,20 @@
<script lang="ts" setup>
import type { ExecutionFilterType } from '@/Interface';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import GlobalExecutionsList from '@/components/executions/global/GlobalExecutionsList.vue';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useI18n } from '@/composables/useI18n';
import { useOverview } from '@/composables/useOverview';
import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import { useInsightsStore } from '@/features/insights/insights.store';
import { useExecutionsStore } from '@/stores/executions.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { storeToRefs } from 'pinia';
import { onBeforeMount, onBeforeUnmount, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import GlobalExecutionsList from '@/components/executions/global/GlobalExecutionsList.vue';
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { useExternalHooks } from '@/composables/useExternalHooks';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useExecutionsStore } from '@/stores/executions.store';
import { useInsightsStore } from '@/features/insights/insights.store';
import { useToast } from '@/composables/useToast';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { storeToRefs } from 'pinia';
import type { ExecutionFilterType } from '@/Interface';
import { useOverview } from '@/composables/useOverview';
const route = useRoute();
const i18n = useI18n();
@@ -97,8 +97,9 @@ async function onExecutionStop() {
<ProjectHeader>
<InsightsSummary
v-if="overview.isOverviewSubPage && insightsStore.isSummaryEnabled"
:loading="insightsStore.summary.isLoading"
:summary="insightsStore.summary.state"
:loading="insightsStore.weeklySummary.isLoading"
:summary="insightsStore.weeklySummary.state"
time-range="week"
/>
</ProjectHeader>
</GlobalExecutionsList>

View File

@@ -1,44 +1,56 @@
<script lang="ts" setup>
import { computed, onMounted, watch, ref, onBeforeUnmount } from 'vue';
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
import Draggable from '@/components/Draggable.vue';
import { FOLDER_LIST_ITEM_ACTIONS } from '@/components/Folders/constants';
import type {
Resource,
BaseFilters,
FolderResource,
Resource,
WorkflowResource,
} from '@/components/layouts/ResourcesListLayout.vue';
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import WorkflowCard from '@/components/WorkflowCard.vue';
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
import Draggable from '@/components/Draggable.vue';
import { useDebounce } from '@/composables/useDebounce';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import type { DragTarget, DropTarget } from '@/composables/useFolders';
import { useFolders } from '@/composables/useFolders';
import { useI18n } from '@/composables/useI18n';
import { useMessage } from '@/composables/useMessage';
import { useOverview } from '@/composables/useOverview';
import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
import {
COMMUNITY_PLUS_ENROLLMENT_MODAL,
DEFAULT_WORKFLOW_PAGE_SIZE,
EASY_AI_WORKFLOW_EXPERIMENT,
EnterpriseEditionFeature,
VIEWS,
DEFAULT_WORKFLOW_PAGE_SIZE,
MODAL_CONFIRM,
COMMUNITY_PLUS_ENROLLMENT_MODAL,
VIEWS,
} from '@/constants';
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import { useInsightsStore } from '@/features/insights/insights.store';
import type {
FolderListItem,
FolderPathItem,
IUser,
UserAction,
WorkflowListResource,
WorkflowListItem,
FolderPathItem,
FolderListItem,
WorkflowListResource,
} from '@/Interface';
import { useUIStore } from '@/stores/ui.store';
import { getResourcePermissions } from '@/permissions';
import { useFoldersStore } from '@/stores/folders.store';
import { usePostHog } from '@/stores/posthog.store';
import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useTagsStore } from '@/stores/tags.store';
import { useProjectsStore } from '@/stores/projects.store';
import { getResourcePermissions } from '@/permissions';
import { usePostHog } from '@/stores/posthog.store';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useI18n } from '@/composables/useI18n';
import { type LocationQueryRaw, useRoute, useRouter } from 'vue-router';
import { useTelemetry } from '@/composables/useTelemetry';
import { useUIStore } from '@/stores/ui.store';
import { useUsageStore } from '@/stores/usage.store';
import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { type ProjectSharingData, ProjectTypes } from '@/types/projects.types';
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
import {
N8nCard,
N8nHeading,
@@ -48,24 +60,12 @@ import {
N8nSelect,
N8nText,
} from '@n8n/design-system';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
import { useDebounce } from '@/composables/useDebounce';
import { createEventBus } from '@n8n/utils/event-bus';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { type ProjectSharingData, ProjectTypes } from '@/types/projects.types';
import { FOLDER_LIST_ITEM_ACTIONS } from '@/components/Folders/constants';
import { createEventBus } from '@n8n/utils/event-bus';
import { debounce } from 'lodash-es';
import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import { useFoldersStore } from '@/stores/folders.store';
import type { DragTarget, DropTarget } from '@/composables/useFolders';
import { useFolders } from '@/composables/useFolders';
import { useUsageStore } from '@/stores/usage.store';
import { useInsightsStore } from '@/features/insights/insights.store';
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import { useOverview } from '@/composables/useOverview';
import { PROJECT_ROOT } from 'n8n-workflow';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { type LocationQueryRaw, useRoute, useRouter } from 'vue-router';
const SEARCH_DEBOUNCE_TIME = 300;
const FILTERS_DEBOUNCE_TIME = 100;
@@ -1337,8 +1337,9 @@ const onCreateWorkflowClick = () => {
<ProjectHeader @create-folder="createFolderInCurrent">
<InsightsSummary
v-if="overview.isOverviewSubPage && insightsStore.isSummaryEnabled"
:loading="insightsStore.summary.isLoading"
:summary="insightsStore.summary.state"
:loading="insightsStore.weeklySummary.isLoading"
:summary="insightsStore.weeklySummary.state"
time-range="week"
/>
</ProjectHeader>
</template>