feat(editor): Evaluations frontend (no-changelog) (#15550)

Co-authored-by: Yiorgis Gozadinos <yiorgis@n8n.io>
Co-authored-by: JP van Oosten <jp@n8n.io>
Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
Eugene
2025-05-26 12:26:28 +02:00
committed by GitHub
parent 3ee15a8331
commit ca8f087a47
87 changed files with 3460 additions and 5103 deletions

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
import type { TestRunRecord } from '@/api/evaluation.ee';
import { computed, watchEffect } from 'vue';
import { Line } from 'vue-chartjs';
import { useMetricsChart } from '../composables/useMetricsChart';
const emit = defineEmits<{
'update:selectedMetric': [value: string];
}>();
const props = defineProps<{
selectedMetric: string;
runs: Array<TestRunRecord & { index: number }>;
}>();
const metricsChart = useMetricsChart();
const availableMetrics = computed(() => {
return props.runs.reduce((acc, run) => {
const metricKeys = Object.keys(run.metrics ?? {});
return [...new Set([...acc, ...metricKeys])];
}, [] as string[]);
});
const filteredRuns = computed(() =>
props.runs.filter((run) => run.metrics?.[props.selectedMetric] !== undefined),
);
const chartData = computed(() =>
metricsChart.generateChartData(filteredRuns.value, props.selectedMetric),
);
const chartOptions = computed(() =>
metricsChart.generateChartOptions({
metric: props.selectedMetric,
data: filteredRuns.value,
}),
);
watchEffect(() => {
if (props.runs.length > 0 && !props.selectedMetric) {
emit('update:selectedMetric', availableMetrics.value[0]);
}
});
</script>
<template>
<div :class="$style.metricsChartContainer">
<div :class="$style.chartHeader">
<N8nSelect
:model-value="selectedMetric"
:class="$style.metricSelect"
placeholder="Select metric"
size="small"
@update:model-value="emit('update:selectedMetric', $event)"
>
<N8nOption
v-for="metric in availableMetrics"
:key="metric"
:label="metric"
:value="metric"
/>
</N8nSelect>
</div>
<div :class="$style.chartWrapper">
<Line
:key="selectedMetric"
:data="chartData"
:options="chartOptions"
:class="$style.metricsChart"
/>
</div>
</div>
</template>
<style lang="scss" module>
.metricsChartContainer {
background: var(--color-background-xlight);
border-radius: var(--border-radius-large);
border: 1px solid var(--color-foreground-base);
.chartHeader {
padding: var(--spacing-xs) var(--spacing-s) 0;
}
.chartTitle {
font-size: var(--font-size-l);
font-weight: var(--font-weight-bold);
color: var(--color-text-base);
}
.metricSelect {
max-width: 15rem;
}
.chartWrapper {
position: relative;
height: var(--metrics-chart-height, 147px);
width: 100%;
padding: var(--spacing-s);
}
}
</style>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { TestRunRecord } from '@/api/evaluation.ee';
import MetricsChart from '@/components/Evaluations.ee/ListRuns/MetricsChart.vue';
import TestRunsTable from '@/components/Evaluations.ee/ListRuns/TestRunsTable.vue';
import { useI18n } from '@/composables/useI18n';
import { VIEWS } from '@/constants';
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
const props = defineProps<{
runs: Array<TestRunRecord & { index: number }>;
workflowId: string;
}>();
const locale = useI18n();
const router = useRouter();
const selectedMetric = defineModel<string>('selectedMetric', { required: true });
const metrics = computed(() => {
const metricKeys = props.runs.reduce((acc, run) => {
Object.keys(run.metrics ?? {}).forEach((metric) => acc.add(metric));
return acc;
}, new Set<string>());
return [...metricKeys];
});
const metricColumns = computed(() =>
metrics.value.map((metric) => ({
prop: `metrics.${metric}`,
label: metric,
sortable: true,
showHeaderTooltip: true,
sortMethod: (a: TestRunRecord, b: TestRunRecord) =>
(a.metrics?.[metric] ?? 0) - (b.metrics?.[metric] ?? 0),
formatter: (row: TestRunRecord) =>
row.metrics?.[metric] !== undefined ? (row.metrics?.[metric]).toFixed(2) : '',
})),
);
const columns = computed(() => [
{
prop: 'id',
label: locale.baseText('evaluation.listRuns.runNumber'),
showOverflowTooltip: true,
},
{
prop: 'runAt',
label: 'Run at',
sortable: true,
showOverflowTooltip: true,
formatter: (row: TestRunRecord) => {
const { date, time } = convertToDisplayDate(row.runAt);
return [date, time].join(', ');
},
sortMethod: (a: TestRunRecord, b: TestRunRecord) =>
new Date(a.runAt ?? a.createdAt).getTime() - new Date(b.runAt ?? b.createdAt).getTime(),
},
{
prop: 'status',
label: locale.baseText('evaluation.listRuns.status'),
sortable: true,
},
...metricColumns.value,
]);
const handleRowClick = (row: TestRunRecord) => {
void router.push({
name: VIEWS.EVALUATION_RUNS_DETAIL,
params: { runId: row.id },
});
};
</script>
<template>
<div :class="$style.runs">
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" />
<TestRunsTable
:class="$style.runsTable"
:runs
:columns
:selectable="true"
data-test-id="past-runs-table"
@row-click="handleRowClick"
/>
</div>
</template>
<style module lang="scss">
.runs {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
flex: 1;
overflow: auto;
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,177 @@
<script setup lang="ts">
import type { TestRunRecord } from '@/api/evaluation.ee';
import { useI18n } from '@/composables/useI18n';
import { N8nIcon, N8nText } from '@n8n/design-system';
import { computed } from 'vue';
import type { TestTableColumn } from '../shared/TestTableBase.vue';
import type { BaseTextKey } from '@/plugins/i18n';
import TestTableBase from '../shared/TestTableBase.vue';
import { statusDictionary } from '../shared/statusDictionary';
import { getErrorBaseKey } from '@/components/Evaluations.ee/shared/errorCodes';
const emit = defineEmits<{
rowClick: [run: TestRunRecord & { index: number }];
}>();
const props = defineProps<{
runs: Array<TestRunRecord & { index: number }>;
columns: Array<TestTableColumn<TestRunRecord & { index: number }>>;
}>();
const locale = useI18n();
const styledColumns = computed(() => {
return props.columns.map((column) => {
if (column.prop === 'id') {
return {
...column,
width: 100,
};
}
if (column.prop === 'runAt') {
return {
...column,
width: 150,
};
}
return column;
});
});
// Combine test run statuses and finalResult to get the final status
const runSummaries = computed(() => {
return props.runs.map(({ status, finalResult, errorDetails, ...run }) => {
if (status === 'completed' && finalResult && ['error', 'warning'].includes(finalResult)) {
status = 'warning';
}
return {
...run,
status,
finalResult,
errorDetails: errorDetails as Record<string, string | number> | undefined,
};
});
});
</script>
<template>
<div :class="$style.container">
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading" color="text-base">
{{ locale.baseText('evaluation.listRuns.pastRuns.total', { adjustToNumber: runs.length }) }}
({{ runs.length }})
</N8nHeading>
<TestTableBase
:data="runSummaries"
:columns="styledColumns"
:default-sort="{ prop: 'runAt', order: 'descending' }"
@row-click="(row) => (row.status !== 'error' ? emit('rowClick', row) : undefined)"
>
<template #id="{ row }">#{{ row.index }} </template>
<template #status="{ row }">
<div
style="display: inline-flex; gap: 12px; text-transform: capitalize; align-items: center"
>
<N8nText v-if="row.status === 'running'" color="secondary">
<AnimatedSpinner />
</N8nText>
<N8nIcon
v-else
:icon="statusDictionary[row.status].icon"
:color="statusDictionary[row.status].color"
/>
<template v-if="row.status === 'warning'">
<N8nText color="warning" :class="[$style.alertText, $style.warningText]">
{{ locale.baseText(`evaluation.runDetail.error.partialCasesFailed`) }}
</N8nText>
</template>
<template v-else-if="row.status === 'error'">
<N8nTooltip placement="top" :show-after="300">
<template #content>
<i18n-t :keypath="`${getErrorBaseKey(row.errorCode)}`">
<template
v-if="
locale.exists(`${getErrorBaseKey(row.errorCode)}.description` as BaseTextKey)
"
#description
>
{{
locale.baseText(
`${getErrorBaseKey(row.errorCode)}.description` as BaseTextKey,
) && '. '
}}
{{
locale.baseText(
`${getErrorBaseKey(row.errorCode)}.description` as BaseTextKey,
)
}}
</template>
</i18n-t>
</template>
<N8nText :class="[$style.alertText, $style.errorText]">
<i18n-t :keypath="`${getErrorBaseKey(row.errorCode)}`">
<template
v-if="
locale.exists(`${getErrorBaseKey(row.errorCode)}.description` as BaseTextKey)
"
#description
>
<p :class="$style.grayText">
{{
locale.baseText(
`${getErrorBaseKey(row.errorCode)}.description` as BaseTextKey,
)
}}
</p>
</template>
</i18n-t>
</N8nText>
</N8nTooltip>
</template>
<template v-else>
{{ row.status }}
</template>
</div>
</template>
</TestTableBase>
</div>
</template>
<style module lang="scss">
.container {
display: flex;
flex-direction: column;
gap: 8px;
}
.grayText {
color: var(--color-text-light);
}
.alertText {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
max-width: 100%;
text-overflow: ellipsis;
overflow: hidden;
white-space: normal;
word-break: break-word;
line-height: 1.25;
text-transform: none;
}
.alertText::first-letter {
text-transform: uppercase;
}
.warningText {
color: var(--color-warning);
}
.errorText {
color: var(--color-text-danger);
}
</style>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { COMMUNITY_PLUS_ENROLLMENT_MODAL } from '@/constants';
import { useUIStore } from '@/stores/ui.store';
const i18n = useI18n();
const uiStore = useUIStore();
const goToUpgrade = async () => {
uiStore.openModalWithData({
name: COMMUNITY_PLUS_ENROLLMENT_MODAL,
data: {
customHeading: undefined,
},
});
};
</script>
<template>
<n8n-action-box
data-test-id="evaluations-unlicensed"
:heading="i18n.baseText('evaluations.paywall.title')"
:description="i18n.baseText('evaluations.paywall.description')"
:button-text="i18n.baseText('evaluations.paywall.cta')"
@click="goToUpgrade"
></n8n-action-box>
</template>

View File

@@ -0,0 +1,325 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { N8nText, N8nButton, N8nCallout } from '@n8n/design-system';
import { ref, computed } from 'vue';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useEvaluationStore } from '@/stores/evaluation.store.ee';
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants';
import StepHeader from '../shared/StepHeader.vue';
import { useRouter } from 'vue-router';
import { useUsageStore } from '@/stores/usage.store';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
defineEmits<{
runTest: [];
}>();
const router = useRouter();
const locale = useI18n();
const workflowsStore = useWorkflowsStore();
const evaluationStore = useEvaluationStore();
const usageStore = useUsageStore();
const pageRedirectionHelper = usePageRedirectionHelper();
const hasRuns = computed(() => {
return evaluationStore.testRunsByWorkflowId[workflowsStore.workflow.id]?.length > 0;
});
const evaluationsAvailable = computed(() => {
return (
usageStore.workflowsWithEvaluationsLimit === -1 ||
usageStore.workflowsWithEvaluationsCount < usageStore.workflowsWithEvaluationsLimit
);
});
const evaluationsQuotaExceeded = computed(() => {
return (
usageStore.workflowsWithEvaluationsLimit !== -1 &&
usageStore.workflowsWithEvaluationsCount >= usageStore.workflowsWithEvaluationsLimit &&
!hasRuns.value
);
});
const activeStepIndex = ref(0);
// Calculate the initial active step based on the workflow state
const initializeActiveStep = () => {
if (evaluationsQuotaExceeded.value) {
activeStepIndex.value = 2;
return;
}
if (
evaluationStore.evaluationTriggerExists &&
evaluationStore.evaluationSetOutputsNodeExist &&
evaluationStore.evaluationSetMetricsNodeExist
) {
activeStepIndex.value = 3;
} else if (
evaluationStore.evaluationTriggerExists &&
evaluationStore.evaluationSetOutputsNodeExist
) {
activeStepIndex.value = 2;
} else if (evaluationStore.evaluationTriggerExists) {
activeStepIndex.value = 1;
} else {
activeStepIndex.value = 0;
}
};
// Run initialization on component mount
initializeActiveStep();
const toggleStep = (index: number) => {
activeStepIndex.value = index;
};
function navigateToWorkflow(
action?: 'addEvaluationTrigger' | 'addEvaluationNode' | 'executeEvaluation',
) {
const routeWorkflowId =
workflowsStore.workflow.id === PLACEHOLDER_EMPTY_WORKFLOW_ID
? 'new'
: workflowsStore.workflow.id;
void router.push({
name: VIEWS.WORKFLOW,
params: { name: routeWorkflowId },
query: action ? { action } : undefined,
});
}
function onSeePlans() {
void pageRedirectionHelper.goToUpgrade('evaluations', 'upgrade-evaluations');
}
</script>
<template>
<div :class="$style.container" data-test-id="evaluation-setup-wizard">
<div :class="$style.steps">
<!-- Step 1 -->
<div :class="[$style.step, $style.completed]">
<StepHeader
:step-number="1"
:title="locale.baseText('evaluations.setupWizard.step1.title')"
:is-completed="evaluationStore.evaluationTriggerExists"
:is-active="activeStepIndex === 0"
@click="toggleStep(0)"
/>
<div v-if="activeStepIndex === 0" :class="$style.stepContent">
<ul :class="$style.bulletPoints">
<li>
<N8nText size="small" color="text-base">
{{ locale.baseText('evaluations.setupWizard.step1.item1') }}
</N8nText>
</li>
<li>
<N8nText size="small" color="text-base">
{{ locale.baseText('evaluations.setupWizard.step1.item2') }}
</N8nText>
</li>
</ul>
<div :class="$style.actionButton">
<N8nButton
size="small"
type="secondary"
@click="navigateToWorkflow('addEvaluationTrigger')"
>
{{ locale.baseText('evaluations.setupWizard.step1.button') }}
</N8nButton>
</div>
</div>
</div>
<!-- Step 2 -->
<div :class="[$style.step, activeStepIndex === 1 ? $style.active : '']">
<StepHeader
:step-number="2"
:title="locale.baseText('evaluations.setupWizard.step2.title')"
:is-completed="evaluationStore.evaluationSetOutputsNodeExist"
:is-active="activeStepIndex === 1"
@click="toggleStep(1)"
/>
<div v-if="activeStepIndex === 1" :class="$style.stepContent">
<ul :class="$style.bulletPoints">
<li>
<N8nText size="small" color="text-base">
{{ locale.baseText('evaluations.setupWizard.step2.item1') }}
</N8nText>
</li>
</ul>
<div :class="$style.actionButton">
<N8nButton
size="small"
type="secondary"
@click="navigateToWorkflow('addEvaluationNode')"
>
{{ locale.baseText('evaluations.setupWizard.step2.button') }}
</N8nButton>
</div>
</div>
</div>
<!-- Step 3 -->
<div :class="$style.step">
<StepHeader
:step-number="3"
:title="locale.baseText('evaluations.setupWizard.step3.title')"
:is-completed="evaluationStore.evaluationSetMetricsNodeExist"
:is-active="activeStepIndex === 2"
:is-optional="true"
@click="toggleStep(2)"
/>
<div v-if="activeStepIndex === 2" :class="$style.stepContent">
<ul v-if="!evaluationsQuotaExceeded" :class="$style.bulletPoints">
<li>
<N8nText size="small" color="text-base">
{{ locale.baseText('evaluations.setupWizard.step3.item1') }}
</N8nText>
</li>
<li>
<N8nText size="small" color="text-base">
{{ locale.baseText('evaluations.setupWizard.step3.item2') }}
</N8nText>
</li>
</ul>
<N8nCallout v-else theme="warning" iconless>
{{ locale.baseText('evaluations.setupWizard.limitReached') }}
</N8nCallout>
<div :class="$style.actionButton">
<N8nButton
v-if="!evaluationsQuotaExceeded"
size="small"
type="secondary"
@click="navigateToWorkflow('addEvaluationNode')"
>
{{ locale.baseText('evaluations.setupWizard.step3.button') }}
</N8nButton>
<N8nButton v-else size="small" @click="onSeePlans">
{{ locale.baseText('generic.seePlans') }}
</N8nButton>
<N8nButton
size="small"
text
style="color: var(--color-text-light)"
@click="toggleStep(3)"
>
{{ locale.baseText('evaluations.setupWizard.step3.skip') }}
</N8nButton>
</div>
<div
v-if="usageStore.workflowsWithEvaluationsLimit !== -1 && evaluationsAvailable"
:class="$style.quotaNote"
>
<N8nText size="xsmall" color="text-base">
<i18n-t keypath="evaluations.setupWizard.step3.notice">
<template #link>
<a style="text-decoration: underline; color: inherit" @click="onSeePlans"
>{{ locale.baseText('evaluations.setupWizard.step3.notice.link') }}
</a>
</template>
</i18n-t>
</N8nText>
</div>
</div>
</div>
<!-- Step 4 -->
<div :class="$style.step">
<StepHeader
:step-number="4"
:title="locale.baseText('evaluations.setupWizard.step4.title')"
:is-completed="false"
:is-active="activeStepIndex === 3"
@click="toggleStep(3)"
>
<div :class="[$style.actionButton, $style.actionButtonInline]">
<N8nButton
v-if="evaluationStore.evaluationSetMetricsNodeExist && !evaluationsQuotaExceeded"
size="medium"
type="secondary"
:disabled="
!evaluationStore.evaluationTriggerExists ||
!evaluationStore.evaluationSetOutputsNodeExist
"
@click="$emit('runTest')"
>
{{ locale.baseText('evaluations.setupWizard.step4.button') }}
</N8nButton>
<N8nButton
v-else
size="medium"
type="secondary"
:disabled="
!evaluationStore.evaluationTriggerExists ||
!evaluationStore.evaluationSetOutputsNodeExist
"
@click="navigateToWorkflow('executeEvaluation')"
>
{{ locale.baseText('evaluations.setupWizard.step4.altButton') }}
</N8nButton>
</div>
</StepHeader>
</div>
</div>
</div>
</template>
<style module lang="scss">
.container {
background-color: var(--color-background-light);
}
.steps {
display: flex;
flex-direction: column;
gap: var(--spacing-m);
}
.step {
overflow: hidden;
}
.stepContent {
padding: 0 0 0 calc(var(--spacing-xs) + 28px);
animation: slideDown 0.2s ease;
}
.bulletPoints {
padding-left: var(--spacing-s);
li {
margin-bottom: var(--spacing-3xs);
}
}
.actionButton {
margin-top: var(--spacing-s);
display: flex;
gap: var(--spacing-s);
button {
font-weight: var(--font-weight-bold);
}
}
.actionButtonInline {
margin: 0;
}
.quotaNote {
margin-top: var(--spacing-2xs);
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,127 @@
import type { ChartData, ChartOptions } from 'chart.js';
import type { TestRunRecord } from '@/api/evaluation.ee';
import dateFormat from 'dateformat';
import { useCssVar } from '@vueuse/core';
export function useMetricsChart() {
const colors = {
primary: useCssVar('--color-primary', document.body).value,
textBase: useCssVar('--color-text-base', document.body).value,
backgroundXLight: useCssVar('--color-background-xlight', document.body).value,
foregroundLight: useCssVar('--color-foreground-light', document.body).value,
foregroundBase: useCssVar('--color-foreground-base', document.body).value,
foregroundDark: useCssVar('--color-foreground-dark', document.body).value,
};
function generateChartData(
runs: Array<TestRunRecord & { index: number }>,
metric: string,
): ChartData<'line'> {
/**
* @see https://www.chartjs.org/docs/latest/general/data-structures.html#object-using-custom-properties
*/
const data: ChartData<'line', TestRunRecord[]> = {
datasets: [
{
data: runs,
parsing: {
xAxisKey: 'id',
yAxisKey: `metrics.${metric}`,
},
borderColor: colors.primary,
backgroundColor: colors.backgroundXLight,
borderWidth: 1,
pointRadius: 2,
pointHoverRadius: 4,
pointBackgroundColor: colors.backgroundXLight,
pointHoverBackgroundColor: colors.backgroundXLight,
},
],
};
// casting to keep vue-chartjs happy!!
return data as unknown as ChartData<'line'>;
}
function generateChartOptions({
metric,
data,
}: { metric: string; data: Array<TestRunRecord & { index: number }> }): ChartOptions<'line'> {
return {
responsive: true,
maintainAspectRatio: false,
animation: false,
devicePixelRatio: 2,
interaction: {
mode: 'index' as const,
intersect: false,
},
scales: {
y: {
border: {
display: false,
},
grid: {
color: colors.foregroundBase,
},
ticks: {
padding: 8,
color: colors.textBase,
},
},
x: {
border: {
display: false,
},
grid: {
display: false,
},
ticks: {
color: colors.textBase,
// eslint-disable-next-line id-denylist
callback(_tickValue, index) {
return `#${data[index].index}`;
},
},
},
},
plugins: {
tooltip: {
backgroundColor: colors.backgroundXLight,
titleColor: colors.textBase,
titleFont: {
weight: '600',
},
bodyColor: colors.textBase,
bodySpacing: 4,
padding: 12,
borderColor: colors.foregroundBase,
borderWidth: 1,
displayColors: true,
callbacks: {
title: (tooltipItems) => {
return dateFormat((tooltipItems[0].raw as TestRunRecord).runAt, 'yyyy-mm-dd HH:MM');
},
label: (context) => `${metric}: ${context.parsed.y.toFixed(2)}`,
labelColor() {
return {
borderColor: 'rgba(29, 21, 21, 0)',
backgroundColor: colors.primary,
borderWidth: 0,
borderRadius: 5,
};
},
},
},
legend: {
display: false,
},
},
};
}
return {
generateChartData,
generateChartOptions,
};
}

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { N8nText, N8nBadge } from '@n8n/design-system';
import StepIndicator from './StepIndicator.vue';
import { useI18n } from '@/composables/useI18n';
defineProps<{
stepNumber: number;
title: string;
isCompleted: boolean;
isActive: boolean;
isOptional?: boolean;
}>();
const emit = defineEmits<{
click: [];
}>();
const locale = useI18n();
const handleClick = (event: Event) => {
// Only emit click event if the click isn't on a button or interactive element
if (
!(event.target as HTMLElement).closest('button') &&
!(event.target as HTMLElement).closest('a') &&
!(event.target as HTMLElement).closest('input') &&
!(event.target as HTMLElement).closest('select')
) {
emit('click');
}
};
</script>
<template>
<div :class="$style.stepHeader" @click="handleClick">
<StepIndicator :step-number="stepNumber" :is-completed="isCompleted" :is-active="isActive" />
<!-- Use slot if provided, otherwise use title prop -->
<div :class="$style.titleSlot">
<slot>
<N8nText
size="medium"
:color="isActive || isCompleted ? 'text-dark' : 'text-light'"
tag="span"
bold
>
{{ title }}
</N8nText>
</slot>
</div>
<N8nBadge
v-if="isOptional"
style="background-color: var(--color-background-base); border: none"
>
{{ locale.baseText('evaluations.setupWizard.stepHeader.optional') }}
</N8nBadge>
</div>
</template>
<style module lang="scss">
.stepHeader {
display: flex;
align-items: center;
gap: var(--spacing-xs);
cursor: pointer;
position: relative;
}
</style>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { N8nIcon } from '@n8n/design-system';
defineProps<{
stepNumber: number;
isCompleted: boolean;
isActive?: boolean;
}>();
</script>
<template>
<div
:class="[
$style.stepIndicator,
isCompleted && $style.completed,
isActive && $style.active,
!isActive && !isCompleted && $style.inactive,
]"
>
<template v-if="isCompleted">
<N8nIcon icon="check" size="xsmall" />
</template>
<template v-else>
{{ stepNumber }}
</template>
</div>
</template>
<style module lang="scss">
.stepIndicator {
display: flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid var(--color-text-light);
color: var(--color-text-light);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
flex-shrink: 0;
transition: all 0.2s ease;
&.active {
border-color: var(--color-primary);
color: var(--color-text-dark);
}
&.completed {
background-color: var(--color-success);
border-color: var(--color-success);
color: var(--prim-color-white);
}
&.inactive {
color: var(--color-text-light);
border-color: var(--color-text-base);
opacity: 0.7;
}
}
</style>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts" generic="T">
import type { TestTableColumn } from './TestTableBase.vue';
import { useRouter } from 'vue-router';
defineProps<{
column: TestTableColumn<T>;
row: T;
}>();
defineEmits<{
click: [];
}>();
const router = useRouter();
function hasProperty(row: unknown, prop: string): row is Record<string, unknown> {
return typeof row === 'object' && row !== null && prop in row;
}
const getCellContent = (column: TestTableColumn<T>, row: T) => {
if (column.formatter) {
return column.formatter(row);
}
return hasProperty(row, column.prop) ? row[column.prop] : undefined;
};
</script>
<template>
<div v-if="column.route?.(row)">
<a v-if="column.openInNewTab" :href="router.resolve(column.route(row)!).href" target="_blank">
{{ getCellContent(column, row) }}
</a>
<router-link v-else :to="column.route(row)!">
{{ getCellContent(column, row) }}
</router-link>
</div>
<div v-else>
{{ getCellContent(column, row) }}
</div>
</template>

View File

@@ -0,0 +1,106 @@
<script setup lang="ts" generic="T">
import N8nTooltip from '@n8n/design-system/components/N8nTooltip';
import type { BaseTextKey } from '@/plugins/i18n';
import type { TestTableColumn } from '@/components/Evaluations.ee/shared/TestTableBase.vue';
import { useI18n } from '@/composables/useI18n';
import { useRouter } from 'vue-router';
defineProps<{
column: TestTableColumn<T>;
row: T & { status: string };
}>();
const locale = useI18n();
const router = useRouter();
interface WithError {
errorCode: string;
}
function hasError(row: unknown): row is WithError {
return typeof row === 'object' && row !== null && 'errorCode' in row;
}
const errorTooltipMap: Record<string, BaseTextKey> = {
// Test case errors
MOCKED_NODE_NOT_FOUND: 'evaluation.runDetail.error.mockedNodeMissing',
FAILED_TO_EXECUTE_WORKFLOW: 'evaluation.runDetail.error.executionFailed',
INVALID_METRICS: 'evaluation.runDetail.error.invalidMetrics',
// Test run errors
TEST_CASES_NOT_FOUND: 'evaluation.listRuns.error.testCasesNotFound',
EVALUATION_TRIGGER_NOT_FOUND: 'evaluation.listRuns.error.evaluationTriggerNotFound',
EVALUATION_TRIGGER_NOT_CONFIGURED: 'evaluation.listRuns.error.evaluationTriggerNotConfigured',
SET_OUTPUTS_NODE_NOT_FOUND: 'evaluation.listRuns.error.setOutputsNodeNotFound',
SET_OUTPUTS_NODE_NOT_CONFIGURED: 'evaluation.listRuns.error.setOutputsNodeNotConfigured',
SET_METRICS_NODE_NOT_FOUND: 'evaluation.listRuns.error.setMetricsNodeNotFound',
SET_METRICS_NODE_NOT_CONFIGURED: 'evaluation.listRuns.error.setMetricsNodeNotConfigured',
CANT_FETCH_TEST_CASES: 'evaluation.listRuns.error.cantFetchTestCases',
};
// FIXME: move status logic to a parent component
const statusThemeMap: Record<string, string> = {
new: 'default',
running: 'warning',
evaluation_running: 'warning',
completed: 'success',
error: 'danger',
success: 'success',
warning: 'warning',
cancelled: 'default',
};
const statusLabelMap: Record<string, string> = {
new: locale.baseText('evaluation.listRuns.status.new'),
running: locale.baseText('evaluation.listRuns.status.running'),
evaluation_running: locale.baseText('evaluation.listRuns.status.evaluating'),
completed: locale.baseText('evaluation.listRuns.status.completed'),
error: locale.baseText('evaluation.listRuns.status.error'),
success: locale.baseText('evaluation.listRuns.status.success'),
warning: locale.baseText('evaluation.listRuns.status.warning'),
cancelled: locale.baseText('evaluation.listRuns.status.cancelled'),
};
function getErrorTooltip(column: TestTableColumn<T>, row: T): string | undefined {
if (hasError(row) && errorTooltipMap[row.errorCode]) {
const tooltipLinkUrl = getErrorTooltipUrl(column, row);
if (tooltipLinkUrl) {
return locale.baseText(errorTooltipMap[row.errorCode], {
interpolate: {
url: tooltipLinkUrl,
},
});
} else {
return locale.baseText(errorTooltipMap[row.errorCode]);
}
}
return undefined;
}
function getErrorTooltipUrl(column: TestTableColumn<T>, row: T): string | undefined {
if (hasError(row) && column.errorRoute?.(row)) {
return router.resolve(column.errorRoute(row)!).href;
}
return undefined;
}
</script>
<template>
<N8nTooltip
placement="right"
:show-after="300"
:disabled="getErrorTooltip(column, row) === undefined"
>
<template #content>
<div v-n8n-html="getErrorTooltip(column, row)" />
</template>
<N8nBadge :theme="statusThemeMap[row.status]" class="mr-4xs">
{{ statusLabelMap[row.status] }}
</N8nBadge>
</N8nTooltip>
</template>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,241 @@
<script setup lang="ts" generic="T extends object">
import { N8nIcon, N8nTooltip } from '@n8n/design-system';
import type { TableInstance } from 'element-plus';
import { ElTable, ElTableColumn } from 'element-plus';
import { isEqual } from 'lodash-es';
import { nextTick, ref, watch } from 'vue';
import type { RouteLocationRaw } from 'vue-router';
/**
* A reusable table component for displaying evaluation results data
* @template T - The type of data being displayed in the table rows
*/
/**
* Configuration for a table column
* @template TRow - The type of data in each table row
*/
export type TestTableColumn<TRow> = {
prop: string;
label: string;
showHeaderTooltip?: boolean;
showOverflowTooltip?: boolean;
width?: number;
sortable?: boolean;
filters?: Array<{ text: string; value: string }>;
filterMethod?: (value: string, row: TRow) => boolean;
route?: (row: TRow) => RouteLocationRaw | undefined;
errorRoute?: (row: TRow) => RouteLocationRaw | undefined;
sortMethod?: (a: TRow, b: TRow) => number;
openInNewTab?: boolean;
formatter?: (row: TRow) => string;
};
type TableRow = T & { id: string };
const props = withDefaults(
defineProps<{
data: TableRow[];
columns: Array<TestTableColumn<TableRow>>;
showControls?: boolean;
defaultSort?: { prop: string; order: 'ascending' | 'descending' };
selectable?: boolean;
selectableFilter?: (row: TableRow) => boolean;
}>(),
{
defaultSort: () => ({ prop: 'date', order: 'descending' }),
selectable: false,
selectableFilter: () => true,
},
);
const tableRef = ref<TableInstance>();
const selectedRows = ref<TableRow[]>([]);
const localData = ref<TableRow[]>([]);
const emit = defineEmits<{
rowClick: [row: TableRow];
selectionChange: [rows: TableRow[]];
}>();
// Watch for changes to the data prop and update the local data state
// This preserves selected rows when the data changes by:
// 1. Storing current selection IDs
// 2. Updating local data with new data
// 3. Re-applying default sort
// 4. Re-selecting previously selected rows that still exist in new data
watch(
() => props.data,
async (newData) => {
if (!isEqual(localData.value, newData)) {
const currentSelectionIds = selectedRows.value.map((row) => row.id);
localData.value = newData;
await nextTick();
tableRef.value?.sort(props.defaultSort.prop, props.defaultSort.order);
currentSelectionIds.forEach((id) => {
const row = localData.value.find((r) => r.id === id);
if (row) {
tableRef.value?.toggleRowSelection(row, true);
}
});
}
},
{ immediate: true, deep: true },
);
const handleSelectionChange = (rows: TableRow[]) => {
selectedRows.value = rows;
emit('selectionChange', rows);
};
const handleColumnResize = (
newWidth: number,
_oldWidth: number,
column: { minWidth: number; width: number },
// event: MouseEvent,
) => {
if (column.minWidth && newWidth < column.minWidth) {
column.width = column.minWidth;
}
};
defineSlots<{
id(props: { row: TableRow }): unknown;
status(props: { row: TableRow }): unknown;
}>();
</script>
<template>
<ElTable
ref="tableRef"
:class="$style.table"
:default-sort="defaultSort"
:data="localData"
:border="true"
:cell-class-name="$style.customCell"
:row-class-name="
({ row }) => (row?.status === 'error' ? $style.customDisabledRow : $style.customRow)
"
scrollbar-always-on
@selection-change="handleSelectionChange"
@header-dragend="handleColumnResize"
@row-click="(row) => $emit('rowClick', row)"
>
<ElTableColumn
v-if="selectable"
type="selection"
:selectable="selectableFilter"
data-test-id="table-column-select"
width="46"
fixed
align="center"
/>
<ElTableColumn
v-for="column in columns"
:key="column.prop"
v-bind="column"
:resizable="true"
data-test-id="table-column"
:min-width="125"
>
<template #header="headerProps">
<N8nTooltip
:content="headerProps.column.label"
placement="top"
:disabled="!column.showHeaderTooltip"
>
<div :class="$style.customHeaderCell">
<div :class="$style.customHeaderCellLabel">
{{ headerProps.column.label }}
</div>
<div
v-if="headerProps.column.sortable && headerProps.column.order"
:class="$style.customHeaderCellSort"
>
<N8nIcon
:icon="headerProps.column.order === 'descending' ? 'arrow-up' : 'arrow-down'"
size="small"
/>
</div>
</div>
</N8nTooltip>
</template>
<template #default="{ row }">
<slot v-if="column.prop === 'id'" name="id" v-bind="{ row }"></slot>
<slot v-if="column.prop === 'status'" name="status" v-bind="{ row }"></slot>
</template>
</ElTableColumn>
</ElTable>
</template>
<style module lang="scss">
.customCell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid var(--border-color-light) !important;
}
.cell {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.customRow {
cursor: pointer;
--color-table-row-hover-background: var(--color-background-light);
}
.customDisabledRow {
cursor: default;
--color-table-row-hover-background: var(--color-background-light);
}
.customHeaderCell {
display: flex;
gap: 4px;
}
.customHeaderCellLabel {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
font-weight: var(--font-weight-bold);
color: var(--color-text-base);
}
.customHeaderCellSort {
display: flex;
align-items: center;
}
.table {
border-radius: 12px;
:global(.el-scrollbar__wrap) {
overflow: hidden;
}
:global(.el-table__column-resize-proxy) {
background-color: var(--color-primary);
width: 3px;
}
:global(thead th) {
padding: 6px 0;
}
:global(.caret-wrapper) {
display: none;
}
:global(.el-scrollbar__thumb) {
background-color: var(--color-foreground-base);
}
:global(.el-scrollbar__bar) {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,59 @@
import type { BaseTextKey } from '@/plugins/i18n';
const TEST_CASE_EXECUTION_ERROR_CODE = {
MOCKED_NODE_NOT_FOUND: 'MOCKED_NODE_NOT_FOUND',
FAILED_TO_EXECUTE_WORKFLOW: 'FAILED_TO_EXECUTE_WORKFLOW',
INVALID_METRICS: 'INVALID_METRICS',
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
NO_METRICS_COLLECTED: 'NO_METRICS_COLLECTED',
} as const;
export type TestCaseExecutionErrorCodes =
(typeof TEST_CASE_EXECUTION_ERROR_CODE)[keyof typeof TEST_CASE_EXECUTION_ERROR_CODE];
const TEST_RUN_ERROR_CODES = {
TEST_CASES_NOT_FOUND: 'TEST_CASES_NOT_FOUND',
INTERRUPTED: 'INTERRUPTED',
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
EVALUATION_TRIGGER_NOT_FOUND: 'EVALUATION_TRIGGER_NOT_FOUND',
EVALUATION_TRIGGER_NOT_CONFIGURED: 'EVALUATION_TRIGGER_NOT_CONFIGURED',
EVALUATION_TRIGGER_DISABLED: 'EVALUATION_TRIGGER_DISABLED',
SET_OUTPUTS_NODE_NOT_FOUND: 'SET_OUTPUTS_NODE_NOT_FOUND',
SET_OUTPUTS_NODE_NOT_CONFIGURED: 'SET_OUTPUTS_NODE_NOT_CONFIGURED',
SET_METRICS_NODE_NOT_FOUND: 'SET_METRICS_NODE_NOT_FOUND',
SET_METRICS_NODE_NOT_CONFIGURED: 'SET_METRICS_NODE_NOT_CONFIGURED',
CANT_FETCH_TEST_CASES: 'CANT_FETCH_TEST_CASES',
PARTIAL_CASES_FAILED: 'PARTIAL_CASES_FAILED',
} as const;
export type TestRunErrorCode = (typeof TEST_RUN_ERROR_CODES)[keyof typeof TEST_RUN_ERROR_CODES];
const testCaseErrorDictionary: Partial<Record<TestCaseExecutionErrorCodes, BaseTextKey>> = {
MOCKED_NODE_NOT_FOUND: 'evaluation.runDetail.error.mockedNodeMissing',
FAILED_TO_EXECUTE_WORKFLOW: 'evaluation.runDetail.error.executionFailed',
INVALID_METRICS: 'evaluation.runDetail.error.invalidMetrics',
UNKNOWN_ERROR: 'evaluation.runDetail.error.unknownError',
NO_METRICS_COLLECTED: 'evaluation.runDetail.error.noMetricsCollected',
} as const;
const testRunErrorDictionary: Partial<Record<TestRunErrorCode, BaseTextKey>> = {
TEST_CASES_NOT_FOUND: 'evaluation.listRuns.error.testCasesNotFound',
INTERRUPTED: 'evaluation.listRuns.error.executionInterrupted',
UNKNOWN_ERROR: 'evaluation.listRuns.error.unknownError',
EVALUATION_TRIGGER_NOT_FOUND: 'evaluation.listRuns.error.evaluationTriggerNotFound',
EVALUATION_TRIGGER_NOT_CONFIGURED: 'evaluation.listRuns.error.evaluationTriggerNotConfigured',
EVALUATION_TRIGGER_DISABLED: 'evaluation.listRuns.error.evaluationTriggerDisabled',
SET_OUTPUTS_NODE_NOT_FOUND: 'evaluation.listRuns.error.setOutputsNodeNotFound',
SET_OUTPUTS_NODE_NOT_CONFIGURED: 'evaluation.listRuns.error.setOutputsNodeNotConfigured',
SET_METRICS_NODE_NOT_FOUND: 'evaluation.listRuns.error.setMetricsNodeNotFound',
SET_METRICS_NODE_NOT_CONFIGURED: 'evaluation.listRuns.error.setMetricsNodeNotConfigured',
CANT_FETCH_TEST_CASES: 'evaluation.listRuns.error.cantFetchTestCases',
PARTIAL_CASES_FAILED: 'evaluation.runDetail.error.partialCasesFailed',
} as const;
export const getErrorBaseKey = (errorCode?: string): string => {
return (
testCaseErrorDictionary[errorCode as TestCaseExecutionErrorCodes] ??
testRunErrorDictionary[errorCode as TestRunErrorCode] ??
''
);
};

View File

@@ -0,0 +1,34 @@
import type { TestRunRecord } from '@/api/evaluation.ee';
import type { IconColor } from '@n8n/design-system/types/icon';
export const statusDictionary: Record<TestRunRecord['status'], { icon: string; color: IconColor }> =
{
new: {
icon: 'status-new',
color: 'foreground-xdark',
},
running: {
icon: 'spinner',
color: 'secondary',
},
completed: {
icon: 'status-completed',
color: 'success',
},
error: {
icon: 'exclamation-triangle',
color: 'danger',
},
cancelled: {
icon: 'status-canceled',
color: 'foreground-xdark',
},
warning: {
icon: 'status-warning',
color: 'warning',
},
success: {
icon: 'status-completed',
color: 'success',
},
};

View File

@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest';
import { useMetricsChart } from '../composables/useMetricsChart';
import type { TestRunRecord as Record } from '@/api/evaluation.ee';
type TestRunRecord = Record & { index: number };
describe('useMetricsChart', () => {
const mockRuns: TestRunRecord[] = [
{
id: '1',
workflowId: 'workflow1',
status: 'completed',
createdAt: '2025-01-06T10:00:00Z',
updatedAt: '2025-01-06T10:00:00Z',
completedAt: '2025-01-06T10:00:00Z',
runAt: '2025-01-06T10:00:00Z',
metrics: { responseTime: 100, successRate: 95 },
index: 1,
},
{
id: '2',
workflowId: 'workflow1',
status: 'completed',
createdAt: '2025-01-06T10:00:00Z',
updatedAt: '2025-01-06T10:00:00Z',
completedAt: '2025-01-06T10:00:00Z',
runAt: '2025-01-06T10:00:00Z',
metrics: { responseTime: 150, successRate: 98 },
index: 2,
},
] as TestRunRecord[];
describe('generateChartData', () => {
it('should generate correct chart data structure', () => {
const { generateChartData } = useMetricsChart();
const {
datasets: [dataset],
} = generateChartData(mockRuns, 'responseTime');
//@ts-expect-error vue-chartjs types are wrong
expect(dataset.parsing?.yAxisKey).toBe('metrics.responseTime');
expect(dataset.data).toHaveLength(2);
});
});
describe('generateChartOptions', () => {
it('should generate correct chart options structure', () => {
const { generateChartOptions } = useMetricsChart();
const result = generateChartOptions({ metric: 'responseTime', data: mockRuns });
//@ts-expect-error vue-chartjs types are wrong
expect(result.scales?.x?.ticks?.callback?.(undefined, 0, [])).toBe('#1');
expect(result.responsive).toBe(true);
expect(result.maintainAspectRatio).toBe(false);
});
});
});

View File

@@ -0,0 +1,17 @@
import type { INodeParameterResourceLocator } from 'n8n-workflow';
export interface EditableField<T = string> {
value: T;
tempValue: T;
isEditing: boolean;
}
export interface EditableFormState {
name: EditableField<string>;
tags: EditableField<string[]>;
description: EditableField<string>;
}
export interface EvaluationFormState extends EditableFormState {
evaluationWorkflow: INodeParameterResourceLocator;
mockedNodes: Array<{ name: string; id: string }>;
}