feat(editor): Insights dashboard (#13739)

Co-authored-by: Guillaume Jacquart <jacquart.guillaume@gmail.com>
Co-authored-by: Raúl Gómez Morales <raul00gm@gmail.com>
This commit is contained in:
Csaba Tuncsik
2025-04-03 16:24:44 +02:00
committed by GitHub
parent 7379f44896
commit 90ba680631
30 changed files with 1872 additions and 102 deletions

View File

@@ -104,6 +104,15 @@ const mainMenuItems = computed(() => [
position: 'bottom',
route: { to: { name: VIEWS.VARIABLES } },
},
{
id: 'insights',
icon: 'chart-bar',
label: 'Insights',
customIconSize: 'medium',
position: 'bottom',
route: { to: { name: VIEWS.INSIGHTS } },
available: hasPermission(['rbac'], { rbac: { scope: 'insights:list' } }),
},
{
id: 'help',
icon: 'question',

View File

@@ -550,6 +550,7 @@ export const enum VIEWS {
PROJECTS_EXECUTIONS = 'ProjectsExecutions',
FOLDERS = 'Folders',
PROJECTS_FOLDERS = 'ProjectsFolders',
INSIGHTS = 'Insights',
}
export const EDITABLE_CANVAS_VIEWS = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];

View File

@@ -0,0 +1,205 @@
import { merge } from 'lodash-es';
import { type ChartOptions, type ScriptableContext } from 'chart.js';
import { useCssVar } from '@vueuse/core';
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
/**
*
* Chart js configuration
*/
export const generateLinearGradient = (ctx: CanvasRenderingContext2D, height: number) => {
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, 'rgba(255, 111,92, 1)');
gradient.addColorStop(0.8, 'rgba(255, 111, 92, 0.25)');
gradient.addColorStop(1, 'rgba(255, 111, 92, 0)');
return gradient;
};
export const generateLineChartOptions = (
overrides: ChartOptions<'line'> = {},
): ChartOptions<'line'> => {
const colorTextDark = useCssVar('--color-text-dark', document.body);
const colorBackgroundLight = useCssVar('--color-background-xlight', document.body);
const colorForeGroundBase = useCssVar('--color-foreground-base', document.body);
const colorTextLight = useCssVar('--color-text-light', document.body);
return merge(
{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
caretSize: 0,
xAlign: 'center',
yAlign: 'bottom',
padding: 16,
titleFont: {
size: 14,
},
bodyFont: {
size: 14,
},
backgroundColor: colorBackgroundLight.value,
titleColor: colorTextDark.value,
bodyColor: colorTextDark.value,
borderWidth: 1,
borderColor: colorForeGroundBase.value,
callbacks: {
label(context: ScriptableContext<'line'>) {
const label = context.dataset.label ?? '';
return `${label} ${smartDecimal(context.parsed.y)}`;
},
labelColor(context: ScriptableContext<'line'>) {
return {
borderColor: 'rgba(0, 0, 0, 0)',
backgroundColor: context.dataset.backgroundColor as string,
borderWidth: 0,
borderRadius: 2,
};
},
},
},
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false,
},
scales: {
x: {
grid: {
display: false,
},
stacked: true,
beginAtZero: true,
border: {
display: false,
},
ticks: {
color: colorTextLight.value,
},
},
y: {
grid: {
color: colorForeGroundBase.value,
},
stacked: true,
border: {
display: false,
},
ticks: {
maxTicksLimit: 3,
color: colorTextLight.value,
},
},
},
},
overrides,
);
};
export const generateBarChartOptions = (
overrides: ChartOptions<'bar'> = {},
): ChartOptions<'bar'> => {
const colorTextLight = useCssVar('--color-text-light', document.body);
const colorTextDark = useCssVar('--color-text-dark', document.body);
const colorBackgroundLight = useCssVar('--color-background-xlight', document.body);
const colorForeGroundBase = useCssVar('--color-foreground-base', document.body);
return merge(
{
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
align: 'end',
reverse: true,
position: 'top',
labels: {
boxWidth: 8,
boxHeight: 8,
borderRadius: 2,
useBorderRadius: true,
},
},
tooltip: {
caretSize: 0,
xAlign: 'center',
yAlign: 'bottom',
padding: 16,
titleFont: {
size: 14,
},
bodyFont: {
size: 14,
},
backgroundColor: colorBackgroundLight.value,
titleColor: colorTextDark.value,
bodyColor: colorTextDark.value,
borderWidth: 1,
borderColor: colorForeGroundBase.value,
callbacks: {
label(context: ScriptableContext<'bar'>) {
const label = context.dataset.label ?? '';
return `${label} ${context.parsed.y}`;
},
labelColor(context: ScriptableContext<'bar'>) {
return {
borderColor: 'rgba(0, 0, 0, 0)',
backgroundColor: context.dataset.backgroundColor as string,
borderWidth: 0,
borderRadius: 2,
};
},
},
},
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false,
},
datasets: {
bar: {
maxBarThickness: 32,
borderRadius: 4,
},
},
scales: {
x: {
grid: {
display: false,
},
stacked: true,
beginAtZero: true,
border: {
display: false,
},
ticks: {
color: colorTextLight.value,
},
},
y: {
grid: {
color: colorForeGroundBase.value,
},
stacked: true,
border: {
display: false,
},
ticks: {
maxTicksLimit: 3,
color: colorTextLight.value,
},
},
},
},
overrides,
);
};

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import { useInsightsStore } from '@/features/insights/insights.store';
import type { InsightsSummaryType } from '@n8n/api-types';
import { computed, defineAsyncComponent, watch } from 'vue';
import { useRoute, type LocationQuery } from 'vue-router';
const InsightsPaywall = defineAsyncComponent(
async () => await import('@/features/insights/components/InsightsPaywall.vue'),
);
const InsightsChartTotal = defineAsyncComponent(
async () => await import('@/features/insights/components/charts/InsightsChartTotal.vue'),
);
const InsightsChartFailed = defineAsyncComponent(
async () => await import('@/features/insights/components/charts/InsightsChartFailed.vue'),
);
const InsightsChartFailureRate = defineAsyncComponent(
async () => await import('@/features/insights/components/charts/InsightsChartFailureRate.vue'),
);
const InsightsChartTimeSaved = defineAsyncComponent(
async () => await import('@/features/insights/components/charts/InsightsChartTimeSaved.vue'),
);
const InsightsChartAverageRuntime = defineAsyncComponent(
async () => await import('@/features/insights/components/charts/InsightsChartAverageRuntime.vue'),
);
const InsightsTableWorkflows = defineAsyncComponent(
async () => await import('@/features/insights/components/tables/InsightsTableWorkflows.vue'),
);
const props = defineProps<{
insightType: InsightsSummaryType;
}>();
const route = useRoute();
const i18n = useI18n();
const insightsStore = useInsightsStore();
const chartComponents = computed(() => ({
total: InsightsChartTotal,
failed: InsightsChartFailed,
failureRate: InsightsChartFailureRate,
timeSaved: InsightsChartTimeSaved,
averageRunTime: InsightsChartAverageRuntime,
}));
type Filter = { time_span: string };
const getDefaultFilter = (query: LocationQuery): Filter => {
const { time_span } = query as Filter;
return {
time_span: time_span ?? '7',
};
};
const filters = computed(() => getDefaultFilter(route.query));
const transformFilter = ({ id, desc }: { id: string; desc: boolean }) => {
// TODO: remove exclude once failureRate is added to the BE
const key = id as Exclude<InsightsSummaryType, 'failureRate'>;
const order = desc ? 'desc' : 'asc';
return `${key}:${order}` as const;
};
const fetchPaginatedTableData = ({
page,
itemsPerPage,
sortBy,
}: {
page: number;
itemsPerPage: number;
sortBy: Array<{ id: string; desc: boolean }>;
}) => {
const skip = page * itemsPerPage;
const take = itemsPerPage;
const sortKey = sortBy.length ? transformFilter(sortBy[0]) : undefined;
void insightsStore.table.execute(0, {
skip,
take,
sortBy: sortKey,
});
};
watch(
() => filters.value.time_span,
() => {
if (insightsStore.isSummaryEnabled) {
void insightsStore.summary.execute();
}
void insightsStore.charts.execute();
void insightsStore.table.execute();
},
{
immediate: true,
},
);
</script>
<template>
<div :class="$style.insightsView">
<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 v-if="insightsStore.isInsightsEnabled" :class="$style.insightsContent">
<div :class="$style.insightsChartWrapper">
<template v-if="insightsStore.charts.isLoading"> loading </template>
<component
:is="chartComponents[props.insightType]"
v-else
:type="props.insightType"
:data="insightsStore.charts.state"
/>
</div>
<div :class="$style.insightsTableWrapper">
<InsightsTableWorkflows
:data="insightsStore.table.state"
:loading="insightsStore.table.isLoading"
@update:options="fetchPaginatedTableData"
/>
</div>
</div>
<InsightsPaywall v-else data-test-id="insights-dashboard-unlicensed" />
</div>
</div>
</template>
<style lang="scss" module>
.insightsView {
padding: var(--spacing-l) var(--spacing-2xl);
flex: 1;
display: flex;
flex-direction: column;
gap: 30px;
overflow: auto;
max-width: var(--content-container-width);
}
.insightsBanner {
padding-bottom: 0;
ul {
border-radius: 0;
}
}
.insightsContent {
padding: var(--spacing-l) 0;
border: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
border-top: 0;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
background: var(--color-background-xlight);
}
.insightsChartWrapper {
height: 292px;
padding: 0 var(--spacing-l);
}
.insightsTableWrapper {
padding: var(--spacing-l) var(--spacing-l) 0;
}
</style>

View File

@@ -0,0 +1,27 @@
<script lang="ts" setup></script>
<template>
<div :class="$style.callout">
<N8nIcon icon="lock" size="size"></N8nIcon>
<N8nText bold tag="h3" size="large">Upgrade to Pro or Enterprise to see full data</N8nText>
<N8nText
>Gain access to detailed execution data with one year data retention.
<N8nLink to="/">Learn more</N8nLink>
</N8nText>
<N8nButton type="primary" size="large">Upgrade</N8nButton>
</div>
</template>
<style lang="scss" module>
.callout {
display: flex;
flex-direction: column;
height: 100%;
align-items: center;
max-width: 360px;
margin: 0 auto;
text-align: center;
gap: 10px;
justify-content: center;
}
</style>

View File

@@ -1,8 +1,25 @@
import { reactive } from 'vue';
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);
vi.mock('vue-router', () => ({
useRouter: () => ({}),
useRoute: () => reactive({}),
RouterLink: {
template: '<a><slot /></a>',
},
}));
const renderComponent = createComponentRenderer(InsightsSummary, {
global: {
stubs: {
'router-link': {
template: '<a><slot /></a>',
},
},
},
});
describe('InsightsSummary', () => {
it('should render without error', () => {

View File

@@ -1,20 +1,23 @@
<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 { VIEWS } from '@/constants';
import {
INSIGHT_IMPACT_TYPES,
INSIGHTS_UNIT_IMPACT_MAPPING,
} from '@/features/insights/insights.constants';
import type { InsightsSummaryDisplay } from '@/features/insights/insights.types';
import type { InsightsSummary } from '@n8n/api-types';
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
import { computed, useCssModule } from 'vue';
import { useRoute } from 'vue-router';
defineProps<{
const props = defineProps<{
summary: InsightsSummaryDisplay;
loading?: boolean;
}>();
const i18n = useI18n();
const route = useRoute();
const $style = useCssModule();
const summaryTitles = computed<Record<keyof InsightsSummary, string>>(() => ({
@@ -25,6 +28,13 @@ const summaryTitles = computed<Record<keyof InsightsSummary, string>>(() => ({
averageRunTime: i18n.baseText('insights.banner.title.averageRunTime'),
}));
const summaryWithRouteLocations = computed(() =>
props.summary.map((s) => ({
...s,
to: { name: VIEWS.INSIGHTS, params: { insightType: s.id }, query: route.query },
})),
);
const getSign = (n: number) => (n > 0 ? '+' : undefined);
const getImpactStyle = (id: keyof InsightsSummary, value: number) => {
const impact = INSIGHTS_UNIT_IMPACT_MAPPING[id];
@@ -42,18 +52,18 @@ const getImpactStyle = (id: keyof InsightsSummary, value: number) => {
</script>
<template>
<div v-if="summary.length" :class="$style.insights">
<div :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"
v-for="{ id, value, deviation, unit, to } in summaryWithRouteLocations"
:key="id"
:data-test-id="`insights-summary-tab-${id}`"
>
<p>
<router-link :to="to" :exact-active-class="$style.activeTab">
<strong>{{ summaryTitles[id] }}</strong>
<span v-if="value === 0 && id === 'timeSaved'" :class="$style.empty">
<em>--</em>
@@ -84,7 +94,7 @@ const getImpactStyle = (id: keyof InsightsSummary, value: number) => {
{{ getSign(deviation) }}{{ smartDecimal(deviation) }}
</small>
</span>
</p>
</router-link>
</li>
</ul>
</div>
@@ -100,28 +110,42 @@ const getImpactStyle = (id: keyof InsightsSummary, value: number) => {
display: flex;
height: 91px;
align-items: stretch;
justify-content: flex-start;
justify-content: space-evenly;
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;
justify-content: stretch;
align-items: stretch;
flex: 1 0;
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 {
a {
display: grid;
align-items: center;
width: 100%;
height: 100%;
padding: var(--spacing-m) var(--spacing-l);
border-bottom: 3px solid transparent;
&:hover {
background-color: var(--color-background-xlight);
transition: background-color 0.3s;
}
&.activeTab {
background-color: var(--color-background-xlight);
border-color: var(--color-primary);
transition: background-color 0.3s ease-in-out;
}
strong {
color: var(--color-text-dark);
@@ -213,6 +237,7 @@ const getImpactStyle = (id: keyof InsightsSummary, value: number) => {
.loading {
display: flex;
min-height: 91px;
align-self: stretch;
align-items: stretch;

View File

@@ -1,26 +1,21 @@
// 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 1`] = `
"<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"></ul>
</div>"
`;
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>
<li data-test-id="insights-summary-tab-total"><a to="[object Object]" exact-active-class="activeTab"><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></a></li>
<li data-test-id="insights-summary-tab-failed"><a to="[object Object]" exact-active-class="activeTab"><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></a></li>
<li data-test-id="insights-summary-tab-failureRate"><a to="[object Object]" exact-active-class="activeTab"><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></a></li>
<li data-test-id="insights-summary-tab-timeSaved"><a to="[object Object]" exact-active-class="activeTab"><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></a></li>
<li data-test-id="insights-summary-tab-averageRunTime"><a to="[object Object]" exact-active-class="activeTab"><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></a></li>
</ul>
</div>"
`;
@@ -29,24 +24,14 @@ 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>
<li data-test-id="insights-summary-tab-total"><a to="[object Object]" exact-active-class="activeTab"><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></a></li>
<li data-test-id="insights-summary-tab-failed"><a to="[object Object]" exact-active-class="activeTab"><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></a></li>
<li data-test-id="insights-summary-tab-failureRate"><a to="[object Object]" exact-active-class="activeTab"><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></a></li>
<li data-test-id="insights-summary-tab-timeSaved"><a to="[object Object]" exact-active-class="activeTab"><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>
</a></li>
<li data-test-id="insights-summary-tab-averageRunTime"><a to="[object Object]" exact-active-class="activeTab"><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></a></li>
</ul>
</div>"
`;
@@ -55,21 +40,11 @@ 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>
<li data-test-id="insights-summary-tab-total"><a to="[object Object]" exact-active-class="activeTab"><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></a></li>
<li data-test-id="insights-summary-tab-failed"><a to="[object Object]" exact-active-class="activeTab"><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></a></li>
<li data-test-id="insights-summary-tab-failureRate"><a to="[object Object]" exact-active-class="activeTab"><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></a></li>
<li data-test-id="insights-summary-tab-timeSaved"><a to="[object Object]" exact-active-class="activeTab"><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></a></li>
<li data-test-id="insights-summary-tab-averageRunTime"><a to="[object Object]" exact-active-class="activeTab"><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></a></li>
</ul>
</div>"
`;
@@ -78,21 +53,11 @@ exports[`InsightsSummary > should render the summary correctly 5`] = `
"<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><!--v-if--></span></p>
</li>
<li data-test-id="insights-summary-tab-failed">
<p><strong>Failed</strong><span><em>14 <i></i></em><!--v-if--></span></p>
</li>
<li data-test-id="insights-summary-tab-failureRate">
<p><strong>Failure rate</strong><span><em>1.9 <i>%</i></em><!--v-if--></span></p>
</li>
<li data-test-id="insights-summary-tab-timeSaved">
<p><strong>Time saved</strong><span><em>55.56 <i>h</i></em><!--v-if--></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><!--v-if--></span></p>
</li>
<li data-test-id="insights-summary-tab-total"><a to="[object Object]" exact-active-class="activeTab"><strong>Total</strong><span><em>525 <i></i></em><!--v-if--></span></a></li>
<li data-test-id="insights-summary-tab-failed"><a to="[object Object]" exact-active-class="activeTab"><strong>Failed</strong><span><em>14 <i></i></em><!--v-if--></span></a></li>
<li data-test-id="insights-summary-tab-failureRate"><a to="[object Object]" exact-active-class="activeTab"><strong>Failure rate</strong><span><em>1.9 <i>%</i></em><!--v-if--></span></a></li>
<li data-test-id="insights-summary-tab-timeSaved"><a to="[object Object]" exact-active-class="activeTab"><strong>Time saved</strong><span><em>55.56 <i>h</i></em><!--v-if--></span></a></li>
<li data-test-id="insights-summary-tab-averageRunTime"><a to="[object Object]" exact-active-class="activeTab"><strong>Avg. run time</strong><span><em>2.5 <i>s</i></em><!--v-if--></span></a></li>
</ul>
</div>"
`;

View File

@@ -0,0 +1,71 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { Line } from 'vue-chartjs';
import { type ScriptableContext, type ChartData, Filler } from 'chart.js';
import dateformat from 'dateformat';
import type { InsightsByTime, InsightsSummaryType } from '@n8n/api-types';
import {
generateLinearGradient,
generateLineChartOptions,
} from '@/features/insights/chartjs.utils';
import { useI18n } from '@/composables/useI18n';
import { transformInsightsAverageRunTime } from '@/features/insights/insights.utils';
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
import { INSIGHTS_UNIT_MAPPING } from '@/features/insights/insights.constants';
const props = defineProps<{
data: InsightsByTime[];
type: InsightsSummaryType;
}>();
const i18n = useI18n();
const chartOptions = computed(() =>
generateLineChartOptions({
plugins: {
tooltip: {
callbacks: {
label: (context) => {
const label = context.dataset.label ?? '';
return `${label} ${smartDecimal(context.parsed.y)}${INSIGHTS_UNIT_MAPPING[props.type]}`;
},
},
},
},
}),
);
const chartData = computed<ChartData<'line'>>(() => {
const labels: string[] = [];
const data: number[] = [];
for (const entry of props.data) {
labels.push(dateformat(entry.date, 'd. mmm'));
const value = transformInsightsAverageRunTime(entry.values.averageRunTime);
data.push(value);
}
return {
labels,
datasets: [
{
label: i18n.baseText('insights.banner.title.averageRunTime'),
data,
cubicInterpolationMode: 'monotone' as const,
fill: 'origin',
backgroundColor: (ctx: ScriptableContext<'line'>) =>
generateLinearGradient(ctx.chart.ctx, 292),
borderColor: 'rgba(255, 64, 39, 1)',
},
],
};
});
</script>
<template>
<Line :data="chartData" :options="chartOptions" :plugins="[Filler]" />
</template>
<style lang="scss" module></style>

View File

@@ -0,0 +1,61 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import type { ChartData } from 'chart.js';
import { useCssVar } from '@vueuse/core';
import dateformat from 'dateformat';
import type { InsightsByTime, InsightsSummaryType } from '@n8n/api-types';
import { generateBarChartOptions } from '@/features/insights/chartjs.utils';
import { useI18n } from '@/composables/useI18n';
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
const props = defineProps<{
data: InsightsByTime[];
type: InsightsSummaryType;
}>();
const i18n = useI18n();
const colorPrimary = useCssVar('--color-primary', document.body);
const chartOptions = computed(() =>
generateBarChartOptions({
plugins: {
tooltip: {
callbacks: {
label: (context) => {
const label = context.dataset.label ?? '';
return `${label} ${smartDecimal(context.parsed.y)}`;
},
},
},
},
}),
);
const chartData = computed<ChartData<'bar'>>(() => {
const labels: string[] = [];
const data: number[] = [];
for (const entry of props.data) {
labels.push(dateformat(entry.date, 'd. mmm'));
data.push(entry.values.failed);
}
return {
labels,
datasets: [
{
label: i18n.baseText('insights.banner.title.failed'),
data,
backgroundColor: colorPrimary.value,
},
],
};
});
</script>
<template>
<Bar :data="chartData" :options="chartOptions" />
</template>
<style lang="scss" module></style>

View File

@@ -0,0 +1,63 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import type { ChartData } from 'chart.js';
import { useCssVar } from '@vueuse/core';
import dateformat from 'dateformat';
import type { InsightsByTime, InsightsSummaryType } from '@n8n/api-types';
import { generateBarChartOptions } from '@/features/insights/chartjs.utils';
import { useI18n } from '@/composables/useI18n';
import { transformInsightsFailureRate } from '@/features/insights/insights.utils';
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
import { INSIGHTS_UNIT_MAPPING } from '@/features/insights/insights.constants';
const props = defineProps<{
data: InsightsByTime[];
type: InsightsSummaryType;
}>();
const i18n = useI18n();
const colorPrimary = useCssVar('--color-primary', document.body);
const chartOptions = computed(() =>
generateBarChartOptions({
plugins: {
tooltip: {
callbacks: {
label: (context) => {
const label = context.dataset.label ?? '';
return `${label} ${smartDecimal(context.parsed.y)}${INSIGHTS_UNIT_MAPPING[props.type]}`;
},
},
},
},
}),
);
const chartData = computed<ChartData<'bar'>>(() => {
const labels: string[] = [];
const data: number[] = [];
for (const entry of props.data) {
labels.push(dateformat(entry.date, 'd. mmm'));
data.push(transformInsightsFailureRate(entry.values.failureRate));
}
return {
labels,
datasets: [
{
label: i18n.baseText('insights.banner.title.failureRate'),
data,
backgroundColor: colorPrimary.value,
},
],
};
});
</script>
<template>
<Bar :data="chartData" :options="chartOptions" />
</template>
<style lang="scss" module></style>

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { Line } from 'vue-chartjs';
import { type ScriptableContext, type ChartData, Filler } from 'chart.js';
import dateformat from 'dateformat';
import type { InsightsByTime, InsightsSummaryType } from '@n8n/api-types';
import {
generateLinearGradient,
generateLineChartOptions,
} from '@/features/insights/chartjs.utils';
import { useI18n } from '@/composables/useI18n';
import { transformInsightsTimeSaved } from '@/features/insights/insights.utils';
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
import { INSIGHTS_UNIT_MAPPING } from '@/features/insights/insights.constants';
const props = defineProps<{
data: InsightsByTime[];
type: InsightsSummaryType;
}>();
const i18n = useI18n();
const chartOptions = computed(() =>
generateLineChartOptions({
plugins: {
tooltip: {
callbacks: {
label: (context) => {
const label = context.dataset.label ?? '';
return `${label} ${smartDecimal(context.parsed.y)}${INSIGHTS_UNIT_MAPPING[props.type]}`;
},
},
},
},
}),
);
const chartData = computed<ChartData<'line'>>(() => {
const labels: string[] = [];
const data: number[] = [];
for (const entry of props.data) {
labels.push(dateformat(entry.date, 'd. mmm'));
const timeSaved = transformInsightsTimeSaved(entry.values.timeSaved);
data.push(timeSaved);
}
return {
labels,
datasets: [
{
label: i18n.baseText('insights.banner.title.timeSaved'),
data,
fill: 'origin',
cubicInterpolationMode: 'monotone' as const,
backgroundColor: (ctx: ScriptableContext<'line'>) =>
generateLinearGradient(ctx.chart.ctx, 292),
borderColor: 'rgba(255, 64, 39, 1)',
},
],
};
});
</script>
<template>
<Line :data="chartData" :options="chartOptions" :plugins="[Filler]" />
</template>
<style lang="scss" module></style>

View File

@@ -0,0 +1,63 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { Bar } from 'vue-chartjs';
import type { ChartData } from 'chart.js';
import { useCssVar } from '@vueuse/core';
import dateformat from 'dateformat';
import type { InsightsByTime, InsightsSummaryType } from '@n8n/api-types';
import { generateBarChartOptions } from '@/features/insights/chartjs.utils';
import { useI18n } from '@/composables/useI18n';
const props = defineProps<{
data: InsightsByTime[];
type: InsightsSummaryType;
}>();
const i18n = useI18n();
const colorPrimary = useCssVar('--color-primary', document.body);
const chartOptions = computed(() =>
generateBarChartOptions({
plugins: {
tooltip: {
itemSort: (a) =>
a.dataset.label === i18n.baseText('insights.banner.title.succeeded') ? -1 : 1,
},
},
}),
);
const chartData = computed<ChartData<'bar'>>(() => {
const labels: string[] = [];
const succeededData: number[] = [];
const failedData: number[] = [];
for (const entry of props.data) {
labels.push(dateformat(entry.date, 'd. mmm'));
succeededData.push(entry.values.succeeded);
failedData.push(entry.values.failed);
}
return {
labels,
datasets: [
{
label: i18n.baseText('insights.banner.title.failed'),
data: failedData,
backgroundColor: colorPrimary.value,
},
{
label: i18n.baseText('insights.banner.title.succeeded'),
data: succeededData,
backgroundColor: '#3E999F',
},
],
};
});
</script>
<template>
<Bar :data="chartData" :options="chartOptions" />
</template>
<style lang="scss" module></style>

View File

@@ -0,0 +1,130 @@
<script lang="ts" setup="">
import { INSIGHTS_UNIT_MAPPING } from '@/features/insights/insights.constants';
import {
transformInsightsAverageRunTime,
transformInsightsFailureRate,
transformInsightsTimeSaved,
} from '@/features/insights/insights.utils';
import type { InsightsByWorkflow } from '@n8n/api-types';
import { N8nTooltip } from '@n8n/design-system';
import N8nDataTableServer, {
type TableHeader,
} from '@n8n/design-system/components/N8nDataTableServer/N8nDataTableServer.vue';
import { smartDecimal } from '@n8n/utils/number/smartDecimal';
import { computed, ref } from 'vue';
const props = defineProps<{
data: InsightsByWorkflow;
loading?: boolean;
}>();
type Item = InsightsByWorkflow['data'][number];
const rows = computed(() => props.data.data);
const headers = ref<Array<TableHeader<Item>>>([
{
title: 'Name',
key: 'workflowName',
width: 400,
disableSort: true,
},
{
title: 'Total executions',
key: 'total',
},
{
title: 'Total failed executions',
key: 'failed',
},
{
title: 'Average run time',
key: 'failureRate',
value(row) {
return (
smartDecimal(transformInsightsFailureRate(row.failureRate)) +
INSIGHTS_UNIT_MAPPING.failureRate
);
},
},
{
title: 'Time saved',
key: 'timeSaved',
value(row) {
return (
smartDecimal(transformInsightsTimeSaved(row.timeSaved)) + INSIGHTS_UNIT_MAPPING.timeSaved
);
},
},
{
title: 'Run time',
key: 'averageRunTime',
value(row) {
return (
smartDecimal(transformInsightsAverageRunTime(row.averageRunTime)) +
INSIGHTS_UNIT_MAPPING.averageRunTime
);
},
},
{
title: 'Project name',
key: 'projectName',
disableSort: true,
},
]);
const sortTableBy = ref([{ id: 'total', desc: true }]);
const currentPage = ref(0);
const itemsPerPage = ref(20);
const emit = defineEmits<{
'update:options': [
payload: {
page: number;
itemsPerPage: number;
sortBy: Array<{ id: string; desc: boolean }>;
},
];
}>();
</script>
<template>
<div>
<N8nHeading bold tag="h3" size="medium" class="mb-s">Workflow insights</N8nHeading>
<N8nDataTableServer
v-model:sort-by="sortTableBy"
v-model:page="currentPage"
v-model:items-per-page="itemsPerPage"
:items="rows"
:headers="headers"
:items-length="data.count"
:loading="loading"
@update:options="emit('update:options', $event)"
>
<template #[`item.workflowName`]="{ item }">
<N8nTooltip :content="item.workflowName" placement="top">
<div class="ellipsis">
{{ item.workflowName }}
</div>
</N8nTooltip>
</template>
<template #[`item.projectName`]="{ item }">
<N8nTooltip v-if="item.projectName" :content="item.projectName" placement="top">
<div class="ellipsis">
{{ item.projectName }}
</div>
</N8nTooltip>
<template v-else> - </template>
</template>
</N8nDataTableServer>
</div>
</template>
<style lang="scss" scoped>
.ellipsis {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
</style>

View File

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

View File

@@ -0,0 +1,36 @@
import { RouterView, type RouteRecordRaw } from 'vue-router';
import { VIEWS } from '@/constants';
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
const InsightsDashboard = async () =>
await import('@/features/insights/components/InsightsDashboard.vue');
export const insightsRoutes: RouteRecordRaw[] = [
{
path: '/insights',
components: {
default: RouterView,
sidebar: MainSidebar,
},
meta: {
middleware: ['authenticated', 'rbac'],
middlewareOptions: {
rbac: {
scope: ['insights:list'],
},
},
},
children: [
{
path: ':insightType?',
name: VIEWS.INSIGHTS,
beforeEnter(to) {
if (to.params.insightType) return true;
return Object.assign(to, { params: { ...to.params, insightType: 'total' } });
},
component: InsightsDashboard,
props: true,
},
],
},
];

View File

@@ -1,26 +1,31 @@
import { computed } from 'vue';
import { defineStore } from 'pinia';
import { useAsyncState } from '@vueuse/core';
import type { ListInsightsWorkflowQueryDto } from '@n8n/api-types';
import * as insightsApi from '@/features/insights/insights.api';
import { useRootStore } from '@/stores/root.store';
import { useUsersStore } from '@/stores/users.store';
import { useSettingsStore } from '@/stores/settings.store';
import { transformInsightsSummary } from '@/features/insights/insights.utils';
import { getResourcePermissions } from '@/permissions';
export const useInsightsStore = defineStore('insights', () => {
const rootStore = useRootStore();
const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const globalInsightsPermissions = computed(
() => getResourcePermissions(usersStore.currentUser?.globalScopes).insights,
);
const isInsightsEnabled = computed(() => settingsStore.settings.insights.enabled);
const isSummaryEnabled = computed(
() => globalInsightsPermissions.value.list && isInsightsEnabled.value,
);
const summary = useAsyncState(
async () => {
if (!globalInsightsPermissions.value.list) {
return [];
}
const raw = await insightsApi.fetchInsightsSummary(rootStore.restApiContext);
return transformInsightsSummary(raw);
},
@@ -28,8 +33,31 @@ export const useInsightsStore = defineStore('insights', () => {
{ immediate: false },
);
const charts = useAsyncState(
async () => {
return await insightsApi.fetchInsightsByTime(rootStore.restApiContext);
},
[],
{ immediate: false },
);
const table = useAsyncState(
async (filter?: ListInsightsWorkflowQueryDto) => {
return await insightsApi.fetchInsightsByWorkflow(rootStore.restApiContext, filter);
},
{
count: 0,
data: [],
},
{ immediate: false },
);
return {
summary,
globalInsightsPermissions,
isInsightsEnabled,
isSummaryEnabled,
summary,
charts,
table,
};
});

View File

@@ -5,10 +5,16 @@ import {
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 transformInsightsTimeSaved = (value: number): number => value / 3600; // we want to show saved time in hours
export const transformInsightsAverageRunTime = (value: number): number => value / 1000; // we want to show average run time in seconds
export const transformInsightsFailureRate = (value: number): number => value * 100; // we want to show failure rate in percentage
export const transformInsightsValues: Partial<
Record<InsightsSummaryType, (value: number) => number>
> = {
timeSaved: transformInsightsTimeSaved,
averageRunTime: transformInsightsAverageRunTime,
failureRate: transformInsightsFailureRate,
};
export const transformInsightsSummary = (data: InsightsSummary | null): InsightsSummaryDisplay =>

View File

@@ -100,12 +100,16 @@ export async function initializeAuthenticatedFeatures(
console.error('Failed to initialize cloud plan store:', e);
}
}
if (insightsStore.isSummaryEnabled) {
void insightsStore.summary.execute();
}
await Promise.all([
projectsStore.getMyProjects(),
projectsStore.getPersonalProject(),
projectsStore.getProjectsCount(),
rolesStore.fetchRoles(),
insightsStore.summary.execute(),
]);
authenticatedFeaturesInitialized = true;

View File

@@ -3074,7 +3074,10 @@
"insights.banner.timeSaved.tooltip.link.text": "add time estimates",
"insights.banner.title.total": "Total",
"insights.banner.title.failed": "Failed",
"insights.banner.title.succeeded": "Succeeded",
"insights.banner.title.failureRate": "Failure rate",
"insights.banner.title.timeSaved": "Time saved",
"insights.banner.title.averageRunTime": "Avg. run time"
"insights.banner.title.timeSavedDailyAverage": "Time saved daily avg.",
"insights.banner.title.averageRunTime": "Avg. run time",
"insights.dashboard.title": "Insights"
}

View File

@@ -19,6 +19,7 @@ import type { RouterMiddleware } from '@/types/router';
import { initializeAuthenticatedFeatures, initializeCore } from '@/init';
import { tryToParseNumber } from '@/utils/typesUtils';
import { projectsRoutes } from '@/routes/projects.routes';
import { insightsRoutes } from '@/features/insights/insights.router';
import TestDefinitionRunDetailView from './views/TestDefinition/TestDefinitionRunDetailView.vue';
const ChangePasswordView = async () => await import('./views/ChangePasswordView.vue');
@@ -734,6 +735,7 @@ export const routes: RouteRecordRaw[] = [
},
},
...projectsRoutes,
...insightsRoutes,
{
path: '/:pathMatch(.*)*',
name: VIEWS.NOT_FOUND,

View File

@@ -242,7 +242,7 @@ onMounted(() => {
<template #header>
<ProjectHeader>
<InsightsSummary
v-if="overview.isOverviewSubPage"
v-if="overview.isOverviewSubPage && insightsStore.isSummaryEnabled"
:loading="insightsStore.summary.isLoading"
:summary="insightsStore.summary.state"
/>

View File

@@ -96,7 +96,7 @@ async function onExecutionStop() {
>
<ProjectHeader>
<InsightsSummary
v-if="overview.isOverviewSubPage"
v-if="overview.isOverviewSubPage && insightsStore.isSummaryEnabled"
:loading="insightsStore.summary.isLoading"
:summary="insightsStore.summary.state"
/>

View File

@@ -1225,7 +1225,7 @@ const onCreateWorkflowClick = () => {
<template #header>
<ProjectHeader @create-folder="createFolderInCurrent">
<InsightsSummary
v-if="overview.isOverviewSubPage"
v-if="overview.isOverviewSubPage && insightsStore.isSummaryEnabled"
:loading="insightsStore.summary.isLoading"
:summary="insightsStore.summary.state"
/>