feat(core): Show alert when selected time range for insights lacks data (#33180)

This commit is contained in:
Konstantin Tieber 2026-06-29 10:24:20 +02:00 committed by GitHub
parent 6c85fdcd81
commit f4e7016c0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 361 additions and 57 deletions

View File

@ -272,6 +272,7 @@ export type FrontendModuleSettings = {
summary: boolean;
dashboard: boolean;
dateRanges: InsightsDateRange[];
earliestDataDate: string | null;
};
/**

View File

@ -6,6 +6,7 @@ import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import { InstanceSettings } from 'n8n-core';
import { InsightsByPeriodRepository } from '../database/repositories/insights-by-period.repository';
import { InsightsModule } from '../insights.module';
import { InsightsService } from '../insights.service';
@ -29,6 +30,7 @@ describe('InsightsModule', () => {
Container.set(InstanceSettings, mockInstanceSettings);
Container.set(Logger, mockLogger());
Container.set(LicenseState, mock<LicenseState>());
Container.set(InsightsByPeriodRepository, mock<InsightsByPeriodRepository>());
Container.set(
InsightsService,
new InsightsService(

View File

@ -1,33 +1,37 @@
import type { LicenseState } from '@n8n/backend-common';
import { mock } from 'jest-mock-extended';
import type { InsightsByPeriodRepository } from '../database/repositories/insights-by-period.repository';
import { InsightsSettings } from '../insights.settings';
describe('InsightsSettings', () => {
let licenseMock: jest.Mocked<LicenseState>;
let repositoryMock: jest.Mocked<InsightsByPeriodRepository>;
let insightsSettings: InsightsSettings;
beforeAll(() => {
licenseMock = mock<LicenseState>();
insightsSettings = new InsightsSettings(licenseMock);
repositoryMock = mock<InsightsByPeriodRepository>();
repositoryMock.getEarliestDataDate.mockResolvedValue(null);
insightsSettings = new InsightsSettings(licenseMock, repositoryMock);
});
test('returns correct summary and dashboard licenses', () => {
test('returns correct summary and dashboard licenses', async () => {
licenseMock.isInsightsSummaryLicensed.mockReturnValue(true);
licenseMock.isInsightsDashboardLicensed.mockReturnValue(true);
const result = insightsSettings.settings();
const result = await insightsSettings.settings();
expect(result.summary).toBe(true);
expect(result.dashboard).toBe(true);
});
describe('dateRanges', () => {
test('returns correct ranges when hourly data is enabled and max history is unlimited', () => {
test('returns correct ranges when hourly data is enabled and max history is unlimited', async () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(-1);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true);
const result = insightsSettings.settings();
const result = await insightsSettings.settings();
expect(result.dateRanges).toEqual([
{ key: 'day', licensed: true, granularity: 'hour' },
@ -40,11 +44,11 @@ describe('InsightsSettings', () => {
]);
});
test('returns correct ranges when hourly data is enabled and max history is 365 days', () => {
test('returns correct ranges when hourly data is enabled and max history is 365 days', async () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(365);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true);
const result = insightsSettings.settings();
const result = await insightsSettings.settings();
expect(result.dateRanges).toEqual([
{ key: 'day', licensed: true, granularity: 'hour' },
@ -57,11 +61,11 @@ describe('InsightsSettings', () => {
]);
});
test('returns correct ranges when hourly data is disabled and max history is 30 days', () => {
test('returns correct ranges when hourly data is disabled and max history is 30 days', async () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(30);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(false);
const result = insightsSettings.settings();
const result = await insightsSettings.settings();
expect(result.dateRanges).toEqual([
{ key: 'day', licensed: false, granularity: 'hour' },
@ -74,11 +78,11 @@ describe('InsightsSettings', () => {
]);
});
test('returns correct ranges when max history is less than 7 days', () => {
test('returns correct ranges when max history is less than 7 days', async () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(5);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(false);
const result = insightsSettings.settings();
const result = await insightsSettings.settings();
expect(result.dateRanges).toEqual([
{ key: 'day', licensed: false, granularity: 'hour' },
@ -91,11 +95,11 @@ describe('InsightsSettings', () => {
]);
});
test('returns correct ranges when max history is 90 days and hourly data is enabled', () => {
test('returns correct ranges when max history is 90 days and hourly data is enabled', async () => {
licenseMock.getInsightsMaxHistory.mockReturnValue(90);
licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true);
const result = insightsSettings.settings();
const result = await insightsSettings.settings();
expect(result.dateRanges).toEqual([
{ key: 'day', licensed: true, granularity: 'hour' },

View File

@ -436,4 +436,11 @@ export class InsightsByPeriodRepository extends Repository<InsightsByPeriod> {
return { affected: result.affected };
}
async getEarliestDataDate(): Promise<Date | null> {
const result = await this.createQueryBuilder('ibp')
.select('MIN(ibp.periodStart)', 'minDate')
.getRawOne<{ minDate: Date | string | null }>();
return result?.minDate ? new Date(result.minDate) : null;
}
}

View File

@ -26,7 +26,7 @@ export class InsightsModule implements ModuleInterface {
async settings() {
const { InsightsSettings } = await import('./insights.settings');
return Container.get(InsightsSettings).settings();
return await Container.get(InsightsSettings).settings();
}
@OnShutdown()

View File

@ -2,16 +2,22 @@ import { LicenseState } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { INSIGHTS_DATE_RANGE_KEYS, keyRangeToDays } from './insights.constants';
import { InsightsByPeriodRepository } from './database/repositories/insights-by-period.repository';
@Service()
export class InsightsSettings {
constructor(private readonly licenseState: LicenseState) {}
constructor(
private readonly licenseState: LicenseState,
private readonly insightsByPeriodRepository: InsightsByPeriodRepository,
) {}
settings() {
async settings() {
const earliest = await this.insightsByPeriodRepository.getEarliestDataDate();
return {
summary: this.licenseState.isInsightsSummaryLicensed(),
dashboard: this.licenseState.isInsightsDashboardLicensed(),
dateRanges: this.getAvailableDateRanges(),
earliestDataDate: earliest?.toISOString() ?? null,
};
}

View File

@ -5740,6 +5740,10 @@
"insights.dashboard.table.estimate": "Estimate",
"insights.dashboard.title": "Insights",
"insights.dashboard.search.placeholder": "All projects",
"insights.dashboard.dataRangeAlert.title": "Your selected range goes back further than your data",
"insights.dashboard.dataRangeAlert.description": "Insights started collecting data on {date}. The 1 earlier day in your range does not have any data available. | Insights started collecting data on {date}. The {days} earlier days in your range do not have any data available.",
"insights.dashboard.dataRangeAlert.descriptionNoData": "Insights started collecting data on {date}. Your selected range does not have any data available.",
"insights.dashboard.dataRangeAlert.dismiss": "Dismiss",
"insights.dashboard.paywall.title": "Upgrade to access more detailed insights",
"insights.dashboard.paywall.description": "Gain access to more granular, per-workflow insights and visual breakdown of production executions over different time periods.",
"insights.banner.title.timeSaved.tooltip": "Total time saved calculated from your estimated time savings per execution across all workflows",

View File

@ -81,6 +81,7 @@ const moduleSettings: FrontendModuleSettings = {
insights: {
summary: true,
dashboard: true,
earliestDataDate: null,
dateRanges: [
{
key: 'day',
@ -264,6 +265,46 @@ const selectProject = async (projectName: string | null) => {
await userEvent.click(teamProject as Element);
};
const setupStores = () => {
insightsStore = mockedStore(useInsightsStore);
projectsStore = mockedStore(useProjectsStore);
insightsStore.isSummaryEnabled = true;
insightsStore.isDashboardEnabled = true;
projectsStore.availableProjects = projects;
projectsStore.getAvailableProjects = vi.fn().mockResolvedValue(projects);
projectsStore.searchProjects.mockResolvedValue({ count: projects.length, data: projects });
projectsStore.globalProjectPermissions = { list: true };
insightsStore.summary = {
state: mockSummaryData,
isLoading: false,
execute: vi.fn(),
isReady: true,
error: null,
then: vi.fn(),
};
insightsStore.charts = {
state: mockChartsData,
isLoading: false,
execute: vi.fn(),
isReady: true,
error: null,
then: vi.fn(),
};
insightsStore.table = {
state: mockTableData,
isLoading: false,
execute: vi.fn(),
isReady: true,
error: null,
then: vi.fn(),
};
};
describe('InsightsDashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
@ -275,45 +316,7 @@ describe('InsightsDashboard', () => {
initialState: { settings: { settings: defaultSettings, moduleSettings } },
});
insightsStore = mockedStore(useInsightsStore);
projectsStore = mockedStore(useProjectsStore);
insightsStore.isSummaryEnabled = true;
insightsStore.isDashboardEnabled = true;
// Mock projects store
projectsStore.availableProjects = projects;
projectsStore.getAvailableProjects = vi.fn().mockResolvedValue(projects);
projectsStore.searchProjects.mockResolvedValue({ count: projects.length, data: projects });
projectsStore.globalProjectPermissions = { list: true };
// Mock async states
insightsStore.summary = {
state: mockSummaryData,
isLoading: false,
execute: vi.fn(),
isReady: true,
error: null,
then: vi.fn(),
};
insightsStore.charts = {
state: mockChartsData,
isLoading: false,
execute: vi.fn(),
isReady: true,
error: null,
then: vi.fn(),
};
insightsStore.table = {
state: mockTableData,
isLoading: false,
execute: vi.fn(),
isReady: true,
error: null,
then: vi.fn(),
};
setupStores();
});
describe('Component Rendering', () => {
@ -719,4 +722,33 @@ describe('InsightsDashboard', () => {
});
});
});
describe('Default date range initialization', () => {
it('should default to 7 days ago when earliestDataDate is null', () => {
const { getByRole } = renderComponent({ props: { insightType: INSIGHT_TYPES.TOTAL } });
// System date is 2000-12-19, so 7 days ago is 2000-12-12
expect(getByRole('button', { name: '12 Dec - 19 Dec, 2000' })).toBeInTheDocument();
});
it('should clamp default range start to the earliest date with data with a maximum of 7', () => {
// earliestDataDate is Dec 15, which is within the last 7 days (Dec 1219)
createTestingPinia({
initialState: {
settings: {
settings: defaultSettings,
moduleSettings: {
...moduleSettings,
insights: {
...moduleSettings.insights!,
earliestDataDate: '2000-12-15T00:00:00.000Z',
},
},
},
},
});
setupStores();
const { getByRole } = renderComponent({ props: { insightType: INSIGHT_TYPES.TOTAL } });
expect(getByRole('button', { name: '15 Dec - 19 Dec, 2000' })).toBeInTheDocument();
});
});
});

View File

@ -8,7 +8,7 @@ import { useAvailableProjectSearch } from '@/features/collaboration/projects/pro
import InsightsSummary from '@/features/execution/insights/components/InsightsSummary.vue';
import { useInsightsStore } from '@/features/execution/insights/insights.store';
import type { DateValue } from '@internationalized/date';
import { getLocalTimeZone, today } from '@internationalized/date';
import { getLocalTimeZone, parseDate, today } from '@internationalized/date';
import type { InsightsSummaryType } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import {
@ -24,6 +24,7 @@ import { useRoute } from 'vue-router';
import { INSIGHT_TYPES } from '../insights.constants';
import { getAdjustedDateRange, getTimeRangeLabels, timeRangeMappings } from '../insights.utils';
import InsightsDataRangePicker from './InsightsDataRangePicker.vue';
import InsightsDateRangeAlert from './InsightsDateRangeAlert.vue';
import { N8nHeading, N8nSpinner } from '@n8n/design-system';
const InsightsPaywall = defineAsyncComponent(
@ -114,11 +115,21 @@ const minimumValue = shallowRef(
maxDate.copy().subtract({ days: timeRangeMappings[maxLicensedDate] }),
);
function getDefaultRangeStart(): DateValue {
const sevenDaysAgo = maxDate.copy().subtract({ days: 7 });
if (insightsStore.earliestDataDate) {
const earliestDate = parseDate(insightsStore.earliestDataDate.substring(0, 10));
// If data only starts within the last 7 days, begin from first data point
return earliestDate.compare(sevenDaysAgo) > 0 ? earliestDate : sevenDaysAgo;
}
return sevenDaysAgo;
}
const range = shallowRef<{
start: DateValue;
end: DateValue;
}>({
start: maxDate.copy().subtract({ days: 7 }),
start: getDefaultRangeStart(),
end: maxDate.copy(),
});
@ -236,6 +247,14 @@ onBeforeMount(async () => {
/>
</div>
<InsightsDateRangeAlert
:key="range.start.toString()"
class="mt-s"
:earliest-data-date="insightsStore.earliestDataDate"
:range-start="range.start"
:range-end="range.end"
/>
<InsightsSummary
v-if="insightsStore.isSummaryEnabled"
:summary="insightsStore.summary.state"

View File

@ -0,0 +1,134 @@
import { CalendarDate } from '@internationalized/date';
import InsightsDateRangeAlert from '@/features/execution/insights/components/InsightsDateRangeAlert.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { defaultSettings } from '@/__tests__/defaults';
const renderComponent = createComponentRenderer(InsightsDateRangeAlert, {
global: {
stubs: { N8nIcon: true },
},
});
describe('InsightsDateRangeAlert', () => {
beforeEach(() => {
createTestingPinia({
initialState: { settings: { settings: defaultSettings } },
});
});
it('does not render when earliestDataDate is null', () => {
const { queryByTestId } = renderComponent({
props: {
earliestDataDate: null,
rangeStart: new CalendarDate(2026, 6, 1),
rangeEnd: new CalendarDate(2026, 6, 30),
},
});
expect(queryByTestId('insights-date-range-alert')).not.toBeInTheDocument();
});
it('does not render when rangeStart is on the earliest data date', () => {
const { queryByTestId } = renderComponent({
props: {
earliestDataDate: '2026-06-11T00:00:00.000Z',
rangeStart: new CalendarDate(2026, 6, 11),
rangeEnd: new CalendarDate(2026, 6, 30),
},
});
expect(queryByTestId('insights-date-range-alert')).not.toBeInTheDocument();
});
it('does not render when rangeStart is after the earliest data date', () => {
const { queryByTestId } = renderComponent({
props: {
earliestDataDate: '2026-06-11T00:00:00.000Z',
rangeStart: new CalendarDate(2026, 6, 15),
rangeEnd: new CalendarDate(2026, 6, 30),
},
});
expect(queryByTestId('insights-date-range-alert')).not.toBeInTheDocument();
});
it('renders the alert when rangeStart is before the earliest data date', () => {
const { getByTestId } = renderComponent({
props: {
earliestDataDate: '2026-06-11T00:00:00.000Z',
rangeStart: new CalendarDate(2026, 6, 9),
rangeEnd: new CalendarDate(2026, 6, 30),
},
});
expect(getByTestId('insights-date-range-alert')).toBeInTheDocument();
});
it('shows the correct title', () => {
const { getByText } = renderComponent({
props: {
earliestDataDate: '2026-06-11T00:00:00.000Z',
rangeStart: new CalendarDate(2026, 6, 9),
rangeEnd: new CalendarDate(2026, 6, 30),
},
});
expect(getByText('Your selected range goes back further than your data')).toBeInTheDocument();
});
it('uses the singular form when exactly 1 day has no data', () => {
const { getByTestId } = renderComponent({
props: {
earliestDataDate: '2026-06-11T00:00:00.000Z',
rangeStart: new CalendarDate(2026, 6, 10),
rangeEnd: new CalendarDate(2026, 6, 30),
},
});
expect(getByTestId('insights-date-range-alert').textContent).toContain('1 earlier day');
});
it('uses the plural form when multiple days have no data', () => {
const { getByTestId } = renderComponent({
props: {
earliestDataDate: '2026-06-11T00:00:00.000Z',
rangeStart: new CalendarDate(2026, 6, 9),
rangeEnd: new CalendarDate(2026, 6, 30),
},
});
expect(getByTestId('insights-date-range-alert').textContent).toContain('2 earlier days');
});
it('shows no-data description when the entire range is before the earliest data date', () => {
const { getByTestId } = renderComponent({
props: {
earliestDataDate: '2026-06-25T00:00:00.000Z',
rangeStart: new CalendarDate(2026, 6, 1),
rangeEnd: new CalendarDate(2026, 6, 10),
},
});
expect(getByTestId('insights-date-range-alert').textContent).toContain(
'Your selected range does not have any data available.',
);
});
it('hides the alert and emits dismiss when the dismiss button is clicked', async () => {
const { getByTestId, queryByTestId, emitted } = renderComponent({
props: {
earliestDataDate: '2026-06-11T00:00:00.000Z',
rangeStart: new CalendarDate(2026, 6, 9),
rangeEnd: new CalendarDate(2026, 6, 30),
},
});
expect(getByTestId('insights-date-range-alert')).toBeInTheDocument();
await getByTestId('insights-date-range-alert-dismiss').click();
expect(queryByTestId('insights-date-range-alert')).not.toBeInTheDocument();
expect(emitted('dismiss')).toHaveLength(1);
});
});

View File

@ -0,0 +1,90 @@
<script setup lang="ts">
import type { DateValue } from '@internationalized/date';
import { getLocalTimeZone, parseDate } from '@internationalized/date';
import { N8nAlert, N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { computed, ref } from 'vue';
const props = defineProps<{
earliestDataDate: string | null;
rangeStart: DateValue;
rangeEnd: DateValue;
}>();
const emit = defineEmits<{ dismiss: [] }>();
const i18n = useI18n();
const isDismissed = ref(false);
const alertInfo = computed(() => {
if (!props.earliestDataDate) return null;
const earliestCalendarDate = parseDate(props.earliestDataDate.substring(0, 10));
if (props.rangeStart.compare(earliestCalendarDate) >= 0) return null;
if (props.rangeEnd.compare(earliestCalendarDate) < 0) {
return { earliestCalendarDate, daysWithoutData: 0, noData: true as const };
}
const daysWithoutData = earliestCalendarDate.compare(props.rangeStart);
return { earliestCalendarDate, daysWithoutData, noData: false as const };
});
const formattedDate = computed(() => {
if (!alertInfo.value) return '';
const d = alertInfo.value.earliestCalendarDate.toDate(getLocalTimeZone());
return new Intl.DateTimeFormat(undefined, {
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(d);
});
function dismiss() {
isDismissed.value = true;
emit('dismiss');
}
</script>
<template>
<N8nAlert
v-if="alertInfo && !isDismissed"
type="info"
:show-icon="false"
data-test-id="insights-date-range-alert"
>
<template #icon>
<N8nText color="text-dark">
<N8nIcon icon="info" />
</N8nText>
</template>
<template #title>
<N8nText color="text-dark" bold>
{{ i18n.baseText('insights.dashboard.dataRangeAlert.title') }}
</N8nText>
</template>
<N8nText color="text-dark">
{{
alertInfo.noData
? i18n.baseText('insights.dashboard.dataRangeAlert.descriptionNoData', {
interpolate: { date: formattedDate },
})
: i18n.baseText('insights.dashboard.dataRangeAlert.description', {
interpolate: { date: formattedDate, days: alertInfo.daysWithoutData },
adjustToNumber: alertInfo.daysWithoutData,
})
}}
</N8nText>
<template #aside>
<N8nButton
type="secondary"
size="small"
variant="subtle"
:label="i18n.baseText('insights.dashboard.dataRangeAlert.dismiss')"
data-test-id="insights-date-range-alert-dismiss"
@click="dismiss"
/>
</template>
</N8nAlert>
</template>

View File

@ -68,6 +68,10 @@ export const useInsightsStore = defineStore('insights', () => {
const dateRanges = computed(() => settingsStore.moduleSettings.insights?.dateRanges ?? []);
const earliestDataDate = computed(
() => settingsStore.moduleSettings.insights?.earliestDataDate ?? null,
);
return {
globalInsightsPermissions,
isInsightsEnabled,
@ -78,5 +82,6 @@ export const useInsightsStore = defineStore('insights', () => {
charts,
table,
dateRanges,
earliestDataDate,
};
});