mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 10:31:15 +00:00
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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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] ??
|
||||
''
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 }>;
|
||||
}
|
||||
Reference in New Issue
Block a user