feat(editor): Insights summary banner (#13424)

Co-authored-by: Guillaume Jacquart <jacquart.guillaume@gmail.com>
This commit is contained in:
Csaba Tuncsik
2025-03-21 20:22:28 +01:00
committed by GitHub
parent 6992c36ebb
commit df474f3ccb
22 changed files with 559 additions and 34 deletions

View File

@@ -23,4 +23,5 @@ export const RESOURCES = {
workersView: ['manage'] as const, workersView: ['manage'] as const,
workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const, workflow: ['share', 'execute', 'move', ...DEFAULT_OPERATIONS] as const,
folder: [...DEFAULT_OPERATIONS] as const, folder: [...DEFAULT_OPERATIONS] as const,
insights: ['list'] as const,
} as const; } as const;

View File

@@ -19,6 +19,7 @@ interface LoadingProps {
animated?: boolean; animated?: boolean;
loading?: boolean; loading?: boolean;
rows?: number; rows?: number;
cols?: number;
shrinkLast?: boolean; shrinkLast?: boolean;
variant?: (typeof VARIANT)[number]; variant?: (typeof VARIANT)[number];
} }
@@ -27,6 +28,7 @@ withDefaults(defineProps<LoadingProps>(), {
animated: true, animated: true,
loading: true, loading: true,
rows: 1, rows: 1,
cols: 0,
shrinkLast: true, shrinkLast: true,
variant: 'p', variant: 'p',
}); });
@@ -38,7 +40,10 @@ withDefaults(defineProps<LoadingProps>(), {
:animated="animated" :animated="animated"
:class="['n8n-loading', `n8n-loading-${variant}`]" :class="['n8n-loading', `n8n-loading-${variant}`]"
> >
<template #template> <template v-if="cols" #template>
<ElSkeletonItem v-for="i in cols" :key="i" />
</template>
<template v-else #template>
<div v-if="variant === 'h1'"> <div v-if="variant === 'h1'">
<div <div
v-for="(item, index) in rows" v-for="(item, index) in rows"

View File

@@ -61,21 +61,6 @@ describe('ProjectHeader', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('should render the correct icon', async () => {
const { container, rerender } = renderComponent();
expect(container.querySelector('.fa-home')).toBeVisible();
projectsStore.currentProject = { type: ProjectTypes.Personal } as Project;
await rerender({});
expect(container.querySelector('.fa-user')).toBeVisible();
const projectName = 'My Project';
projectsStore.currentProject = { name: projectName } as Project;
await rerender({});
expect(container.querySelector('.fa-layer-group')).toBeVisible();
});
it('should render the correct title and subtitle', async () => { it('should render the correct title and subtitle', async () => {
const { getByText, queryByText, rerender } = renderComponent(); const { getByText, queryByText, rerender } = renderComponent();
const subtitle = 'All the workflows, credentials and executions you have access to'; const subtitle = 'All the workflows, credentials and executions you have access to';

View File

@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
import type { UserAction } from '@n8n/design-system'; import type { UserAction } from '@n8n/design-system';
import { N8nButton, N8nTooltip } from '@n8n/design-system'; import { N8nButton, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { type ProjectIcon, ProjectTypes } from '@/types/projects.types'; import { ProjectTypes } from '@/types/projects.types';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import ProjectTabs from '@/components/Projects/ProjectTabs.vue'; import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
import { getResourcePermissions } from '@/permissions'; import { getResourcePermissions } from '@/permissions';
@@ -24,16 +24,6 @@ const emit = defineEmits<{
createFolder: []; createFolder: [];
}>(); }>();
const headerIcon = computed((): ProjectIcon => {
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
return { type: 'icon', value: 'user' };
} else if (projectsStore.currentProject?.name) {
return projectsStore.currentProject.icon ?? { type: 'icon', value: 'layer-group' };
} else {
return { type: 'icon', value: 'home' };
}
});
const projectName = computed(() => { const projectName = computed(() => {
if (!projectsStore.currentProject) { if (!projectsStore.currentProject) {
return i18n.baseText('projects.menu.overview'); return i18n.baseText('projects.menu.overview');
@@ -136,7 +126,6 @@ const onSelect = (action: string) => {
<div> <div>
<div :class="$style.projectHeader"> <div :class="$style.projectHeader">
<div :class="$style.projectDetails"> <div :class="$style.projectDetails">
<ProjectIcon :icon="headerIcon" :border-less="true" size="medium" />
<div :class="$style.headerActions"> <div :class="$style.headerActions">
<N8nHeading bold tag="h2" size="xlarge">{{ projectName }}</N8nHeading> <N8nHeading bold tag="h2" size="xlarge">{{ projectName }}</N8nHeading>
<N8nText color="text-light"> <N8nText color="text-light">
@@ -168,6 +157,7 @@ const onSelect = (action: string) => {
</N8nTooltip> </N8nTooltip>
</div> </div>
</div> </div>
<slot></slot>
<div :class="$style.actions"> <div :class="$style.actions">
<ProjectTabs :show-settings="showSettings" /> <ProjectTabs :show-settings="showSettings" />
</div> </div>

View File

@@ -2,7 +2,6 @@
import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue'; import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue';
import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue'; import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue';
import GlobalExecutionsListItem from '@/components/executions/global/GlobalExecutionsListItem.vue'; import GlobalExecutionsListItem from '@/components/executions/global/GlobalExecutionsListItem.vue';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useMessage } from '@/composables/useMessage'; import { useMessage } from '@/composables/useMessage';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
@@ -334,7 +333,7 @@ const goToUpgrade = () => {
<template> <template>
<div :class="$style.execListWrapper"> <div :class="$style.execListWrapper">
<ProjectHeader /> <slot />
<div :class="$style.execListHeaderControls"> <div :class="$style.execListHeaderControls">
<ExecutionsFilter <ExecutionsFilter
:workflows="workflows" :workflows="workflows"

View File

@@ -0,0 +1,20 @@
import { computed, reactive } from 'vue';
import { useRoute } from 'vue-router';
import { VIEWS } from '@/constants';
export const useOverview = () => {
const route = useRoute();
const isOverviewSubPage = computed(
() =>
route.name === VIEWS.WORKFLOWS ||
route.name === VIEWS.HOMEPAGE ||
route.name === VIEWS.CREDENTIALS ||
route.name === VIEWS.EXECUTIONS ||
route.name === VIEWS.FOLDERS,
);
return reactive({
isOverviewSubPage,
});
};

View File

@@ -0,0 +1,56 @@
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import { createComponentRenderer } from '@/__tests__/render';
import type { InsightsSummaryDisplay } from '@/features/insights/insights.types';
const renderComponent = createComponentRenderer(InsightsSummary);
describe('InsightsSummary', () => {
it('should render without error', () => {
expect(() =>
renderComponent({
props: {
summary: [],
},
}),
).not.toThrow();
});
test.each<InsightsSummaryDisplay[]>([
[[]],
[
[
{ id: 'total', value: 525, deviation: 85, unit: '' },
{ id: 'failed', value: 14, deviation: 3, unit: '' },
{ id: 'failureRate', value: 1.9, deviation: -0.8, unit: '%' },
{ id: 'timeSaved', value: 55.55555555555556, deviation: -5.164722222222222, unit: 'h' },
{ id: 'averageRunTime', value: 2.5, deviation: -0.5, unit: 's' },
],
],
[
[
{ id: 'total', value: 525, deviation: 85, unit: '' },
{ id: 'failed', value: 14, deviation: 3, unit: '' },
{ id: 'failureRate', value: 1.9, deviation: -0.8, unit: '%' },
{ id: 'timeSaved', value: 0, deviation: 0, unit: 'h' },
{ id: 'averageRunTime', value: 2.5, deviation: -0.5, unit: 's' },
],
],
[
[
{ id: 'total', value: 525, deviation: -2, unit: '' },
{ id: 'failed', value: 14, deviation: -3, unit: '' },
{ id: 'failureRate', value: 1.9, deviation: 0.8, unit: '%' },
{ id: 'timeSaved', value: 55.55555555555556, deviation: 0, unit: 'h' },
{ id: 'averageRunTime', value: 2.5, deviation: 0.5, unit: 's' },
],
],
])('should render the summary correctly', (summary) => {
const { html } = renderComponent({
props: {
summary,
},
});
expect(html()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,224 @@
<script setup lang="ts">
import { computed, useCssModule } from 'vue';
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
import { useI18n } from '@/composables/useI18n';
import type { InsightsSummary } from '@n8n/api-types';
import type { InsightsSummaryDisplay } from '@/features/insights/insights.types';
import {
INSIGHT_IMPACT_TYPES,
INSIGHTS_UNIT_IMPACT_MAPPING,
} from '@/features/insights/insights.constants';
defineProps<{
summary: InsightsSummaryDisplay;
loading?: boolean;
}>();
const i18n = useI18n();
const $style = useCssModule();
const summaryTitles = computed<Record<keyof InsightsSummary, string>>(() => ({
total: i18n.baseText('insights.banner.title.total'),
failed: i18n.baseText('insights.banner.title.failed'),
failureRate: i18n.baseText('insights.banner.title.failureRate'),
timeSaved: i18n.baseText('insights.banner.title.timeSaved'),
averageRunTime: i18n.baseText('insights.banner.title.averageRunTime'),
}));
const getSign = (n: number) => (n > 0 ? '+' : undefined);
const getImpactStyle = (id: keyof InsightsSummary, value: number) => {
const impact = INSIGHTS_UNIT_IMPACT_MAPPING[id];
if (value === 0 || impact === INSIGHT_IMPACT_TYPES.NEUTRAL) {
return $style.neutral;
}
if (impact === INSIGHT_IMPACT_TYPES.POSITIVE) {
return value > 0 ? $style.positive : $style.negative;
}
if (impact === INSIGHT_IMPACT_TYPES.NEGATIVE) {
return value < 0 ? $style.positive : $style.negative;
}
return $style.neutral;
};
</script>
<template>
<div v-if="summary.length" :class="$style.insights">
<N8nHeading bold tag="h3" size="small" color="text-light" class="mb-xs">{{
i18n.baseText('insights.banner.title', { interpolate: { count: 7 } })
}}</N8nHeading>
<N8nLoading v-if="loading" :class="$style.loading" :cols="5" />
<ul v-else data-test-id="insights-summary-tabs">
<li
v-for="{ id, value, deviation, unit } in summary"
:key="id"
:data-test-id="`insights-summary-tab-${id}`"
>
<p>
<strong>{{ summaryTitles[id] }}</strong>
<span v-if="value === 0 && id === 'timeSaved'" :class="$style.empty">
<em>--</em>
<small>
<N8nTooltip placement="bottom">
<template #content>
<i18n-t keypath="insights.banner.timeSaved.tooltip">
<template #link>
<a href="#">{{
i18n.baseText('insights.banner.timeSaved.tooltip.link.text')
}}</a>
</template>
</i18n-t>
</template>
<N8nIcon :class="$style.icon" icon="info-circle" />
</N8nTooltip>
</small>
</span>
<span v-else>
<em
>{{ smartDecimal(value) }} <i>{{ unit }}</i></em
>
<small :class="getImpactStyle(id, deviation)">
<N8nIcon
:class="[$style.icon, getImpactStyle(id, deviation)]"
:icon="deviation === 0 ? 'caret-right' : deviation > 0 ? 'caret-up' : 'caret-down'"
/>
{{ getSign(deviation) }}{{ smartDecimal(deviation) }}
</small>
</span>
</p>
</li>
</ul>
</div>
</template>
<style lang="scss" module>
.insights {
display: grid;
grid-template-rows: auto 1fr;
padding: var(--spacing-xs) 0 var(--spacing-2xl);
ul {
display: flex;
height: 91px;
align-items: stretch;
justify-content: flex-start;
border: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
border-radius: 6px;
list-style: none;
background-color: var(--color-background-xlight);
overflow-x: auto;
li {
display: flex;
justify-content: flex-start;
align-items: center;
flex: 1;
border-left: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
padding: 0 var(--spacing-xl) 0 var(--spacing-l);
&:first-child {
border-left: 0;
}
}
p {
display: grid;
strong {
color: var(--color-text-dark);
font-size: var(--font-size-s);
font-weight: 400;
white-space: nowrap;
margin-bottom: var(--spacing-2xs);
}
span {
display: flex;
align-items: baseline;
gap: var(--spacing-xs);
&.empty {
em {
color: var(--color-text-lighter);
}
small {
padding: 0;
height: 21px;
font-weight: var(--font-weight-bold);
.icon {
height: 20px;
width: 8px;
top: -3px;
transform: translateY(0);
color: var(--color-text-light);
}
}
}
}
em {
display: flex;
align-items: baseline;
justify-content: flex-start;
color: var(--color-text-dark);
font-size: var(--font-size-2xl);
line-height: 100%;
font-weight: 600;
font-style: normal;
gap: var(--spacing-5xs);
i {
color: var(--color-text-light);
font-size: 22px;
font-style: normal;
}
}
small {
position: relative;
display: flex;
align-items: center;
padding: 0 0 0 18px;
font-size: 14px;
font-weight: 400;
white-space: nowrap;
}
}
}
}
.positive {
color: var(--color-success);
}
.negative {
color: var(--color-danger);
}
.neutral {
color: var(--color-text-light);
.icon {
font-size: 23px;
}
}
.icon {
position: absolute;
font-size: 32px;
left: 0;
top: 50%;
transform: translateY(-50%);
}
.loading {
display: flex;
align-self: stretch;
align-items: stretch;
> div {
margin: 0;
height: auto;
}
}
</style>

View File

@@ -0,0 +1,75 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`InsightsSummary > should render the summary correctly 1`] = `"<!--v-if-->"`;
exports[`InsightsSummary > should render the summary correctly 2`] = `
"<div class="insights">
<h3 class="n8n-heading text-light size-small bold mb-xs mb-xs">Production executions for the last 7 days</h3>
<ul data-test-id="insights-summary-tabs">
<li data-test-id="insights-summary-tab-total">
<p><strong>Total</strong><span><em>525 <i></i></em><small class="positive"><span class="n8n-text compact size-medium regular n8n-icon icon positive icon positive n8n-icon icon positive icon positive"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +85</small></span></p>
</li>
<li data-test-id="insights-summary-tab-failed">
<p><strong>Failed</strong><span><em>14 <i></i></em><small class="negative"><span class="n8n-text compact size-medium regular n8n-icon icon negative icon negative n8n-icon icon negative icon negative"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +3</small></span></p>
</li>
<li data-test-id="insights-summary-tab-failureRate">
<p><strong>Failure rate</strong><span><em>1.9 <i>%</i></em><small class="positive"><span class="n8n-text compact size-medium regular n8n-icon icon positive icon positive n8n-icon icon positive icon positive"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -0.8</small></span></p>
</li>
<li data-test-id="insights-summary-tab-timeSaved">
<p><strong>Time saved</strong><span><em>55.56 <i>h</i></em><small class="negative"><span class="n8n-text compact size-medium regular n8n-icon icon negative icon negative n8n-icon icon negative icon negative"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -5.16</small></span></p>
</li>
<li data-test-id="insights-summary-tab-averageRunTime">
<p><strong>Avg. run time</strong><span><em>2.5 <i>s</i></em><small class="neutral"><span class="n8n-text compact size-medium regular n8n-icon icon neutral icon neutral n8n-icon icon neutral icon neutral"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -0.5</small></span></p>
</li>
</ul>
</div>"
`;
exports[`InsightsSummary > should render the summary correctly 3`] = `
"<div class="insights">
<h3 class="n8n-heading text-light size-small bold mb-xs mb-xs">Production executions for the last 7 days</h3>
<ul data-test-id="insights-summary-tabs">
<li data-test-id="insights-summary-tab-total">
<p><strong>Total</strong><span><em>525 <i></i></em><small class="positive"><span class="n8n-text compact size-medium regular n8n-icon icon positive icon positive n8n-icon icon positive icon positive"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +85</small></span></p>
</li>
<li data-test-id="insights-summary-tab-failed">
<p><strong>Failed</strong><span><em>14 <i></i></em><small class="negative"><span class="n8n-text compact size-medium regular n8n-icon icon negative icon negative n8n-icon icon negative icon negative"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +3</small></span></p>
</li>
<li data-test-id="insights-summary-tab-failureRate">
<p><strong>Failure rate</strong><span><em>1.9 <i>%</i></em><small class="positive"><span class="n8n-text compact size-medium regular n8n-icon icon positive icon positive n8n-icon icon positive icon positive"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -0.8</small></span></p>
</li>
<li data-test-id="insights-summary-tab-timeSaved">
<p><strong>Time saved</strong><span class="empty"><em>--</em><small><span class="n8n-text compact size-medium regular n8n-icon icon el-tooltip__trigger el-tooltip__trigger icon el-tooltip__trigger el-tooltip__trigger n8n-icon icon el-tooltip__trigger el-tooltip__trigger icon el-tooltip__trigger el-tooltip__trigger"><svg class="svg-inline--fa fa-info-circle fa-w-16 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="info-circle" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path class="" fill="currentColor" d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z"></path></svg></span>
<!--teleport start-->
<!--teleport end--></small></span>
</p>
</li>
<li data-test-id="insights-summary-tab-averageRunTime">
<p><strong>Avg. run time</strong><span><em>2.5 <i>s</i></em><small class="neutral"><span class="n8n-text compact size-medium regular n8n-icon icon neutral icon neutral n8n-icon icon neutral icon neutral"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -0.5</small></span></p>
</li>
</ul>
</div>"
`;
exports[`InsightsSummary > should render the summary correctly 4`] = `
"<div class="insights">
<h3 class="n8n-heading text-light size-small bold mb-xs mb-xs">Production executions for the last 7 days</h3>
<ul data-test-id="insights-summary-tabs">
<li data-test-id="insights-summary-tab-total">
<p><strong>Total</strong><span><em>525 <i></i></em><small class="negative"><span class="n8n-text compact size-medium regular n8n-icon icon negative icon negative n8n-icon icon negative icon negative"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -2</small></span></p>
</li>
<li data-test-id="insights-summary-tab-failed">
<p><strong>Failed</strong><span><em>14 <i></i></em><small class="positive"><span class="n8n-text compact size-medium regular n8n-icon icon positive icon positive n8n-icon icon positive icon positive"><svg class="svg-inline--fa fa-caret-down fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-down" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M31.3 192h257.3c17.8 0 26.7 21.5 14.1 34.1L174.1 354.8c-7.8 7.8-20.5 7.8-28.3 0L17.2 226.1C4.6 213.5 13.5 192 31.3 192z"></path></svg></span> -3</small></span></p>
</li>
<li data-test-id="insights-summary-tab-failureRate">
<p><strong>Failure rate</strong><span><em>1.9 <i>%</i></em><small class="negative"><span class="n8n-text compact size-medium regular n8n-icon icon negative icon negative n8n-icon icon negative icon negative"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +0.8</small></span></p>
</li>
<li data-test-id="insights-summary-tab-timeSaved">
<p><strong>Time saved</strong><span><em>55.56 <i>h</i></em><small class="neutral"><span class="n8n-text compact size-medium regular n8n-icon icon neutral icon neutral n8n-icon icon neutral icon neutral"><svg class="svg-inline--fa fa-caret-right fa-w-6 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-right" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 512"><path class="" fill="currentColor" d="M0 384.662V127.338c0-17.818 21.543-26.741 34.142-14.142l128.662 128.662c7.81 7.81 7.81 20.474 0 28.284L34.142 398.804C21.543 411.404 0 402.48 0 384.662z"></path></svg></span> 0</small></span></p>
</li>
<li data-test-id="insights-summary-tab-averageRunTime">
<p><strong>Avg. run time</strong><span><em>2.5 <i>s</i></em><small class="neutral"><span class="n8n-text compact size-medium regular n8n-icon icon neutral icon neutral n8n-icon icon neutral icon neutral"><svg class="svg-inline--fa fa-caret-up fa-w-10 medium" aria-hidden="true" focusable="false" data-prefix="fas" data-icon="caret-up" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path class="" fill="currentColor" d="M288.662 352H31.338c-17.818 0-26.741-21.543-14.142-34.142l128.662-128.662c7.81-7.81 20.474-7.81 28.284 0l128.662 128.662c12.6 12.599 3.676 34.142-14.142 34.142z"></path></svg></span> +0.5</small></span></p>
</li>
</ul>
</div>"
`;

View File

@@ -0,0 +1,6 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import type { InsightsSummary } from '@n8n/api-types';
export const fetchInsightsSummary = async (context: IRestApiContext): Promise<InsightsSummary> =>
await makeRestApiRequest<InsightsSummary>(context, 'GET', '/insights/summary');

View File

@@ -0,0 +1,34 @@
import type { InsightsSummaryType } from '@n8n/api-types';
export const INSIGHTS_SUMMARY_ORDER: InsightsSummaryType[] = [
'total',
'failed',
'failureRate',
'timeSaved',
'averageRunTime',
];
export const INSIGHTS_UNIT_MAPPING: Record<InsightsSummaryType, string> = {
total: '',
failed: '',
failureRate: '%',
timeSaved: 'h',
averageRunTime: 's',
} as const;
export const INSIGHT_IMPACT_TYPES = {
POSITIVE: 'positive',
NEGATIVE: 'negative',
NEUTRAL: 'neutral',
} as const;
export const INSIGHTS_UNIT_IMPACT_MAPPING: Record<
InsightsSummaryType,
(typeof INSIGHT_IMPACT_TYPES)[keyof typeof INSIGHT_IMPACT_TYPES]
> = {
total: INSIGHT_IMPACT_TYPES.POSITIVE,
failed: INSIGHT_IMPACT_TYPES.NEGATIVE,
failureRate: INSIGHT_IMPACT_TYPES.NEGATIVE, // Higher failureRate is bad → negative (red)
timeSaved: INSIGHT_IMPACT_TYPES.POSITIVE, // More time saved is good → positive (green)
averageRunTime: INSIGHT_IMPACT_TYPES.NEUTRAL, // Not good or bad → neutral (grey)
} as const;

View File

@@ -0,0 +1,35 @@
import { computed } from 'vue';
import { defineStore } from 'pinia';
import { useAsyncState } from '@vueuse/core';
import * as insightsApi from '@/features/insights/insights.api';
import { useRootStore } from '@/stores/root.store';
import { useUsersStore } from '@/stores/users.store';
import { transformInsightsSummary } from '@/features/insights/insights.utils';
import { getResourcePermissions } from '@/permissions';
export const useInsightsStore = defineStore('insights', () => {
const rootStore = useRootStore();
const usersStore = useUsersStore();
const globalInsightsPermissions = computed(
() => getResourcePermissions(usersStore.currentUser?.globalScopes).insights,
);
const summary = useAsyncState(
async () => {
if (!globalInsightsPermissions.value.list) {
return [];
}
const raw = await insightsApi.fetchInsightsSummary(rootStore.restApiContext);
return transformInsightsSummary(raw);
},
[],
{ immediate: false },
);
return {
summary,
globalInsightsPermissions,
};
});

View File

@@ -0,0 +1,14 @@
import type { INSIGHTS_UNIT_MAPPING } from '@/features/insights/insights.constants';
type InsightsDisplayUnits = typeof INSIGHTS_UNIT_MAPPING;
export type InsightsSummaryDisplay = Array<
{
[K in keyof InsightsDisplayUnits]: {
id: K;
value: number;
deviation: number;
unit: InsightsDisplayUnits[K];
};
}[keyof InsightsDisplayUnits]
>;

View File

@@ -0,0 +1,22 @@
import type { InsightsSummary, InsightsSummaryType } from '@n8n/api-types';
import type { InsightsSummaryDisplay } from '@/features/insights/insights.types';
import {
INSIGHTS_SUMMARY_ORDER,
INSIGHTS_UNIT_MAPPING,
} from '@/features/insights/insights.constants';
const transformInsightsValues: Partial<Record<InsightsSummaryType, (value: number) => number>> = {
timeSaved: (value: number): number => value / 3600, // we want to show saved time in hours
averageRunTime: (value: number): number => Math.round((value / 1000) * 100) / 100, // we want to show average run time in seconds with 2 decimal places
failureRate: (value: number): number => value * 100, // we want to show failure rate in percentage
};
export const transformInsightsSummary = (data: InsightsSummary | null): InsightsSummaryDisplay =>
data
? INSIGHTS_SUMMARY_ORDER.map((key) => ({
id: key,
value: transformInsightsValues[key]?.(data[key].value) ?? data[key].value,
deviation: transformInsightsValues[key]?.(data[key].deviation) ?? data[key].deviation,
unit: INSIGHTS_UNIT_MAPPING[key],
}))
: [];

View File

@@ -9,6 +9,7 @@ import { useExternalHooks } from '@/composables/useExternalHooks';
import { useVersionsStore } from '@/stores/versions.store'; import { useVersionsStore } from '@/stores/versions.store';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { useRolesStore } from './stores/roles.store'; import { useRolesStore } from './stores/roles.store';
import { useInsightsStore } from '@/features/insights/insights.store';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import SourceControlInitializationErrorMessage from '@/components/SourceControlInitializationErrorMessage.vue'; import SourceControlInitializationErrorMessage from '@/components/SourceControlInitializationErrorMessage.vue';
@@ -66,6 +67,7 @@ export async function initializeAuthenticatedFeatures(
const cloudPlanStore = useCloudPlanStore(); const cloudPlanStore = useCloudPlanStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const rolesStore = useRolesStore(); const rolesStore = useRolesStore();
const insightsStore = useInsightsStore();
if (sourceControlStore.isEnterpriseSourceControlEnabled) { if (sourceControlStore.isEnterpriseSourceControlEnabled) {
try { try {
@@ -103,6 +105,7 @@ export async function initializeAuthenticatedFeatures(
projectsStore.getPersonalProject(), projectsStore.getPersonalProject(),
projectsStore.getProjectsCount(), projectsStore.getProjectsCount(),
rolesStore.fetchRoles(), rolesStore.fetchRoles(),
insightsStore.summary.execute(),
]); ]);
authenticatedFeaturesInitialized = true; authenticatedFeaturesInitialized = true;

View File

@@ -28,6 +28,7 @@ describe('permissions', () => {
workersView: {}, workersView: {},
workflow: {}, workflow: {},
folder: {}, folder: {},
insights: {},
}); });
}); });
it('getResourcePermissions', () => { it('getResourcePermissions', () => {
@@ -59,6 +60,7 @@ describe('permissions', () => {
'workflow:share', 'workflow:share',
'workflow:update', 'workflow:update',
'folder:create', 'folder:create',
'insights:list',
]; ];
const permissionRecord: PermissionsRecord = { const permissionRecord: PermissionsRecord = {
@@ -120,6 +122,9 @@ describe('permissions', () => {
folder: { folder: {
create: true, create: true,
}, },
insights: {
list: true,
},
}; };
expect(getResourcePermissions(scopes)).toEqual(permissionRecord); expect(getResourcePermissions(scopes)).toEqual(permissionRecord);

View File

@@ -3067,5 +3067,13 @@
"freeAi.credits.showError.claim.title": "Free AI credits", "freeAi.credits.showError.claim.title": "Free AI credits",
"freeAi.credits.showError.claim.message": "Enable to claim credits", "freeAi.credits.showError.claim.message": "Enable to claim credits",
"freeAi.credits.showWarning.workflow.activation.title": "You're using free OpenAI API credits", "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." "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.banner.title": "Production executions for the last {count} days",
"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.title.total": "Total",
"insights.banner.title.failed": "Failed",
"insights.banner.title.failureRate": "Failure rate",
"insights.banner.title.timeSaved": "Time saved",
"insights.banner.title.averageRunTime": "Avg. run time"
} }

View File

@@ -23,6 +23,9 @@ import {
faBrain, faBrain,
faCalculator, faCalculator,
faCalendar, faCalendar,
faCaretDown,
faCaretRight,
faCaretUp,
faChartBar, faChartBar,
faCheck, faCheck,
faCheckCircle, faCheckCircle,
@@ -216,6 +219,9 @@ export const FontAwesomePlugin: Plugin = {
addIcon(faBrain); addIcon(faBrain);
addIcon(faCalculator); addIcon(faCalculator);
addIcon(faCalendar); addIcon(faCalendar);
addIcon(faCaretDown);
addIcon(faCaretRight);
addIcon(faCaretUp);
addIcon(faChartBar); addIcon(faChartBar);
addIcon(faCheck); addIcon(faCheck);
addIcon(faCheckCircle); addIcon(faCheckCircle);

View File

@@ -35,6 +35,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => {
saml: {}, saml: {},
securityAudit: {}, securityAudit: {},
folder: {}, folder: {},
insights: {},
}); });
function addGlobalRole(role: IRole) { function addGlobalRole(role: IRole) {

View File

@@ -32,6 +32,9 @@ import { N8nCheckbox } from '@n8n/design-system';
import { pickBy } from 'lodash-es'; import { pickBy } from 'lodash-es';
import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow'; import { CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
import { isCredentialsResource } from '@/utils/typeGuards'; 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';
const props = defineProps<{ const props = defineProps<{
credentialId?: string; credentialId?: string;
@@ -44,12 +47,14 @@ const sourceControlStore = useSourceControlStore();
const externalSecretsStore = useExternalSecretsStore(); const externalSecretsStore = useExternalSecretsStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const usersStore = useUsersStore(); const usersStore = useUsersStore();
const insightsStore = useInsightsStore();
const documentTitle = useDocumentTitle(); const documentTitle = useDocumentTitle();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const i18n = useI18n(); const i18n = useI18n();
const overview = useOverview();
type Filters = BaseFilters & { type?: string[]; setupNeeded?: boolean }; type Filters = BaseFilters & { type?: string[]; setupNeeded?: boolean };
const updateFilter = (state: Filters) => { const updateFilter = (state: Filters) => {
@@ -235,7 +240,13 @@ onMounted(() => {
@update:search="onSearchUpdated" @update:search="onSearchUpdated"
> >
<template #header> <template #header>
<ProjectHeader /> <ProjectHeader>
<InsightsSummary
v-if="overview.isOverviewSubPage"
:loading="insightsStore.summary.isLoading"
:summary="insightsStore.summary.state"
/>
</ProjectHeader>
</template> </template>
<template #default="{ data }"> <template #default="{ data }">
<CredentialCard <CredentialCard

View File

@@ -2,15 +2,19 @@
import { onBeforeMount, onBeforeUnmount, onMounted } from 'vue'; import { onBeforeMount, onBeforeUnmount, onMounted } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import GlobalExecutionsList from '@/components/executions/global/GlobalExecutionsList.vue'; 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 { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useExternalHooks } from '@/composables/useExternalHooks'; import { useExternalHooks } from '@/composables/useExternalHooks';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useExecutionsStore } from '@/stores/executions.store'; import { useExecutionsStore } from '@/stores/executions.store';
import { useInsightsStore } from '@/features/insights/insights.store';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { useDocumentTitle } from '@/composables/useDocumentTitle'; import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import type { ExecutionFilterType } from '@/Interface'; import type { ExecutionFilterType } from '@/Interface';
import { useOverview } from '@/composables/useOverview';
const route = useRoute(); const route = useRoute();
const i18n = useI18n(); const i18n = useI18n();
@@ -18,8 +22,10 @@ const telemetry = useTelemetry();
const externalHooks = useExternalHooks(); const externalHooks = useExternalHooks();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const executionsStore = useExecutionsStore(); const executionsStore = useExecutionsStore();
const insightsStore = useInsightsStore();
const documentTitle = useDocumentTitle(); const documentTitle = useDocumentTitle();
const toast = useToast(); const toast = useToast();
const overview = useOverview();
const { executionsCount, executionsCountEstimated, filters, allExecutions } = const { executionsCount, executionsCountEstimated, filters, allExecutions } =
storeToRefs(executionsStore); storeToRefs(executionsStore);
@@ -87,5 +93,13 @@ async function onExecutionStop() {
:estimated-total="executionsCountEstimated" :estimated-total="executionsCountEstimated"
@execution:stop="onExecutionStop" @execution:stop="onExecutionStop"
@update:filters="onUpdateFilters" @update:filters="onUpdateFilters"
/> >
<ProjectHeader>
<InsightsSummary
v-if="overview.isOverviewSubPage"
:loading="insightsStore.summary.isLoading"
:summary="insightsStore.summary.state"
/>
</ProjectHeader>
</GlobalExecutionsList>
</template> </template>

View File

@@ -61,6 +61,9 @@ import { useToast } from '@/composables/useToast';
import { useFoldersStore } from '@/stores/folders.store'; import { useFoldersStore } from '@/stores/folders.store';
import { useFolders } from '@/composables/useFolders'; import { useFolders } from '@/composables/useFolders';
import { useUsageStore } from '@/stores/usage.store'; 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';
const SEARCH_DEBOUNCE_TIME = 300; const SEARCH_DEBOUNCE_TIME = 300;
const FILTERS_DEBOUNCE_TIME = 100; const FILTERS_DEBOUNCE_TIME = 100;
@@ -102,9 +105,11 @@ const uiStore = useUIStore();
const tagsStore = useTagsStore(); const tagsStore = useTagsStore();
const foldersStore = useFoldersStore(); const foldersStore = useFoldersStore();
const usageStore = useUsageStore(); const usageStore = useUsageStore();
const insightsStore = useInsightsStore();
const documentTitle = useDocumentTitle(); const documentTitle = useDocumentTitle();
const { callDebounced } = useDebounce(); const { callDebounced } = useDebounce();
const overview = useOverview();
const loading = ref(false); const loading = ref(false);
const breadcrumbsLoading = ref(false); const breadcrumbsLoading = ref(false);
@@ -1213,7 +1218,13 @@ const onCreateWorkflowClick = () => {
@sort="onSortUpdated" @sort="onSortUpdated"
> >
<template #header> <template #header>
<ProjectHeader @create-folder="createFolderInCurrent" /> <ProjectHeader>
<InsightsSummary
v-if="overview.isOverviewSubPage"
:loading="insightsStore.summary.isLoading"
:summary="insightsStore.summary.state"
/>
</ProjectHeader>
</template> </template>
<template v-if="foldersEnabled" #add-button> <template v-if="foldersEnabled" #add-button>
<N8nTooltip <N8nTooltip