mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
fix(editor): Ai 674 update runs list UI graph (no-changelog) (#14012)
Co-authored-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
committed by
GitHub
parent
b91be496c3
commit
02d11b5e7a
@@ -4,28 +4,20 @@ import MetricsChart from '@/components/TestDefinition/ListRuns/MetricsChart.vue'
|
||||
import TestRunsTable from '@/components/TestDefinition/ListRuns/TestRunsTable.vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { VIEWS } from '@/constants';
|
||||
import type { AppliedThemeOption } from '@/Interface';
|
||||
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const props = defineProps<{
|
||||
runs: TestRunRecord[];
|
||||
runs: Array<TestRunRecord & { index: number }>;
|
||||
testId: string;
|
||||
appliedTheme: AppliedThemeOption;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
deleteRuns: [runs: TestRunRecord[]];
|
||||
}>();
|
||||
const locale = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const selectedMetric = defineModel<string>('selectedMetric', { required: true });
|
||||
|
||||
function onDeleteRuns(toDelete: TestRunRecord[]) {
|
||||
emit('deleteRuns', toDelete);
|
||||
}
|
||||
|
||||
const metrics = computed(() => {
|
||||
const metricKeys = props.runs.reduce((acc, run) => {
|
||||
Object.keys(run.metrics ?? {}).forEach((metric) => acc.add(metric));
|
||||
@@ -42,15 +34,15 @@ const metricColumns = computed(() =>
|
||||
showHeaderTooltip: true,
|
||||
sortMethod: (a: TestRunRecord, b: TestRunRecord) =>
|
||||
(a.metrics?.[metric] ?? 0) - (b.metrics?.[metric] ?? 0),
|
||||
formatter: (row: TestRunRecord) => (row.metrics?.[metric] ?? 0).toFixed(2),
|
||||
formatter: (row: TestRunRecord) =>
|
||||
row.metrics?.[metric] !== undefined ? (row.metrics?.[metric]).toFixed(2) : '',
|
||||
})),
|
||||
);
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
prop: 'runNumber',
|
||||
prop: 'id',
|
||||
label: locale.baseText('testDefinition.listRuns.runNumber'),
|
||||
formatter: (row: TestRunRecord) => `${row.id}`,
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{
|
||||
@@ -58,6 +50,10 @@ const columns = computed(() => [
|
||||
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(),
|
||||
},
|
||||
@@ -79,7 +75,7 @@ const handleRowClick = (row: TestRunRecord) => {
|
||||
|
||||
<template>
|
||||
<div :class="$style.runs">
|
||||
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" :theme="appliedTheme" />
|
||||
<MetricsChart v-model:selectedMetric="selectedMetric" :runs="runs" />
|
||||
|
||||
<TestRunsTable
|
||||
:class="$style.runsTable"
|
||||
@@ -87,7 +83,6 @@ const handleRowClick = (row: TestRunRecord) => {
|
||||
:columns
|
||||
:selectable="true"
|
||||
data-test-id="past-runs-table"
|
||||
@delete-runs="onDeleteRuns"
|
||||
@row-click="handleRowClick"
|
||||
/>
|
||||
</div>
|
||||
@@ -97,7 +92,7 @@ const handleRowClick = (row: TestRunRecord) => {
|
||||
.runs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-m);
|
||||
gap: var(--spacing-s);
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
margin-bottom: 20px;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { AppliedThemeOption } from '@/Interface';
|
||||
import { computed, watchEffect } from 'vue';
|
||||
import { Line } from 'vue-chartjs';
|
||||
import { useMetricsChart } from '../composables/useMetricsChart';
|
||||
@@ -12,12 +10,10 @@ const emit = defineEmits<{
|
||||
|
||||
const props = defineProps<{
|
||||
selectedMetric: string;
|
||||
runs: TestRunRecord[];
|
||||
theme?: AppliedThemeOption;
|
||||
runs: Array<TestRunRecord & { index: number }>;
|
||||
}>();
|
||||
|
||||
const locale = useI18n();
|
||||
const metricsChart = useMetricsChart(props.theme);
|
||||
const metricsChart = useMetricsChart();
|
||||
|
||||
const availableMetrics = computed(() => {
|
||||
return props.runs.reduce((acc, run) => {
|
||||
@@ -26,12 +22,18 @@ const availableMetrics = computed(() => {
|
||||
}, [] as string[]);
|
||||
});
|
||||
|
||||
const chartData = computed(() => metricsChart.generateChartData(props.runs, props.selectedMetric));
|
||||
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,
|
||||
xTitle: locale.baseText('testDefinition.listRuns.runDate'),
|
||||
data: filteredRuns.value,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -43,7 +45,7 @@ watchEffect(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="availableMetrics.length > 0" :class="$style.metricsChartContainer">
|
||||
<div :class="$style.metricsChartContainer">
|
||||
<div :class="$style.chartHeader">
|
||||
<N8nSelect
|
||||
:model-value="selectedMetric"
|
||||
@@ -62,7 +64,6 @@ watchEffect(() => {
|
||||
</div>
|
||||
<div :class="$style.chartWrapper">
|
||||
<Line
|
||||
v-if="availableMetrics.length > 0"
|
||||
:key="selectedMetric"
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
@@ -76,15 +77,10 @@ watchEffect(() => {
|
||||
.metricsChartContainer {
|
||||
background: var(--color-background-xlight);
|
||||
border-radius: var(--border-radius-large);
|
||||
box-shadow: var(--box-shadow-base);
|
||||
border: 1px solid var(--color-foreground-base);
|
||||
|
||||
.chartHeader {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
padding: var(--spacing-xs) var(--spacing-s);
|
||||
border-bottom: 1px solid var(--color-foreground-base);
|
||||
padding: var(--spacing-xs) var(--spacing-s) 0;
|
||||
}
|
||||
|
||||
.chartTitle {
|
||||
@@ -99,7 +95,7 @@ watchEffect(() => {
|
||||
|
||||
.chartWrapper {
|
||||
position: relative;
|
||||
height: var(--metrics-chart-height, 200px);
|
||||
height: var(--metrics-chart-height, 147px);
|
||||
width: 100%;
|
||||
padding: var(--spacing-s);
|
||||
}
|
||||
|
||||
@@ -2,36 +2,53 @@
|
||||
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { IconColor } from '@n8n/design-system/types/icon';
|
||||
import { computed } from 'vue';
|
||||
import type { TestTableColumn } from '../shared/TestTableBase.vue';
|
||||
import TestTableBase from '../shared/TestTableBase.vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
rowClick: [run: TestRunRecord];
|
||||
selectionChange: [runs: TestRunRecord[]];
|
||||
deleteRuns: [runs: TestRunRecord[]];
|
||||
rowClick: [run: TestRunRecord & { index: number }];
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
runs: TestRunRecord[];
|
||||
columns: Array<TestTableColumn<TestRunRecord>>;
|
||||
selectable?: boolean;
|
||||
runs: Array<TestRunRecord & { index: number }>;
|
||||
columns: Array<TestTableColumn<TestRunRecord & { index: number }>>;
|
||||
}>();
|
||||
|
||||
const statusesColorDictionary: Record<TestRunRecord['status'], string> = {
|
||||
new: 'var(--color-primary)',
|
||||
running: 'var(--color-secondary)',
|
||||
completed: 'var(--color-success)',
|
||||
error: 'var(--color-danger)',
|
||||
cancelled: 'var(--color-foreground-dark)',
|
||||
warning: 'var(--color-warning)',
|
||||
success: 'var(--color-success)',
|
||||
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: 'status-error',
|
||||
color: 'danger',
|
||||
},
|
||||
cancelled: {
|
||||
icon: 'status-canceled',
|
||||
color: 'foreground-xdark',
|
||||
},
|
||||
warning: {
|
||||
icon: 'status-warning',
|
||||
color: 'warning',
|
||||
},
|
||||
success: {
|
||||
icon: 'status-completed',
|
||||
color: 'success',
|
||||
},
|
||||
};
|
||||
|
||||
const locale = useI18n();
|
||||
|
||||
const selectedRows = ref<TestRunRecord[]>([]);
|
||||
|
||||
// Combine test run statuses and finalResult to get the final status
|
||||
const runSummaries = computed(() => {
|
||||
return props.runs.map(({ status, finalResult, ...run }) => {
|
||||
@@ -42,63 +59,41 @@ const runSummaries = computed(() => {
|
||||
return { ...run, status };
|
||||
});
|
||||
});
|
||||
|
||||
function onSelectionChange(runs: TestRunRecord[]) {
|
||||
selectedRows.value = runs;
|
||||
emit('selectionChange', runs);
|
||||
}
|
||||
|
||||
async function deleteRuns() {
|
||||
emit('deleteRuns', selectedRows.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container">
|
||||
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading">{{
|
||||
locale.baseText('testDefinition.edit.pastRuns.total', { adjustToNumber: runs.length })
|
||||
}}</N8nHeading>
|
||||
<div :class="$style.header">
|
||||
<n8n-button
|
||||
v-show="selectedRows.length > 0"
|
||||
type="danger"
|
||||
:class="$style.activator"
|
||||
size="medium"
|
||||
icon="trash"
|
||||
data-test-id="delete-runs-button"
|
||||
@click="deleteRuns"
|
||||
>
|
||||
{{
|
||||
locale.baseText('testDefinition.listRuns.deleteRuns', {
|
||||
adjustToNumber: selectedRows.length,
|
||||
})
|
||||
}}
|
||||
</n8n-button>
|
||||
</div>
|
||||
<N8nHeading size="large" :bold="true" :class="$style.runsTableHeading" color="text-base">
|
||||
{{ locale.baseText('testDefinition.edit.pastRuns.total', { adjustToNumber: runs.length }) }}
|
||||
<N8nText> ({{ runs.length }}) </N8nText>
|
||||
</N8nHeading>
|
||||
|
||||
<TestTableBase
|
||||
:data="runSummaries"
|
||||
:columns="columns"
|
||||
selectable
|
||||
:default-sort="{ prop: 'runAt', order: 'descending' }"
|
||||
@row-click="(row) => emit('rowClick', row)"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<template #id="{ row }">#{{ row.index }} </template>
|
||||
<template #status="{ row }">
|
||||
<div
|
||||
style="display: inline-flex; gap: 8px; text-transform: capitalize; align-items: center"
|
||||
>
|
||||
<N8nText v-if="row.status === 'running'" color="secondary" class="mr-2xs">
|
||||
<AnimatedSpinner />
|
||||
</N8nText>
|
||||
<N8nIcon
|
||||
icon="circle"
|
||||
size="xsmall"
|
||||
:style="{ color: statusesColorDictionary[row.status] }"
|
||||
></N8nIcon>
|
||||
<N8nText v-if="row.status === 'error'" size="small" bold color="text-base">
|
||||
{{ row.failedCases }} / {{ row.totalCases }} {{ row.status }}
|
||||
</N8nText>
|
||||
<N8nText v-else size="small" bold color="text-base">
|
||||
v-else
|
||||
:icon="statusDictionary[row.status].icon"
|
||||
:color="statusDictionary[row.status].color"
|
||||
class="mr-2xs"
|
||||
/>
|
||||
<template v-if="row.status === 'error'">
|
||||
{{ row.failedCases }} {{ row.status }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ row.status }}
|
||||
</N8nText>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</TestTableBase>
|
||||
@@ -109,7 +104,6 @@ async function deleteRuns() {
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,68 +1,56 @@
|
||||
import type { ChartData, ChartOptions } from 'chart.js';
|
||||
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import dateFormat from 'dateformat';
|
||||
import type { AppliedThemeOption } from '@/Interface';
|
||||
import { useCssVar } from '@vueuse/core';
|
||||
|
||||
const THEME_COLORS = {
|
||||
light: {
|
||||
primary: 'rgb(255, 110, 92)',
|
||||
text: {
|
||||
primary: 'rgb(68, 68, 68)',
|
||||
secondary: 'rgb(102, 102, 102)',
|
||||
},
|
||||
background: 'rgb(255, 255, 255)',
|
||||
grid: 'rgba(68, 68, 68, 0.1)',
|
||||
},
|
||||
dark: {
|
||||
primary: 'rgb(255, 110, 92)',
|
||||
text: {
|
||||
primary: 'rgb(255, 255, 255)',
|
||||
secondary: 'rgba(255, 255, 255, 0.7)',
|
||||
},
|
||||
background: 'rgb(32, 32, 32)',
|
||||
grid: 'rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
};
|
||||
|
||||
export function useMetricsChart(mode: AppliedThemeOption = 'light') {
|
||||
const colors = THEME_COLORS[mode];
|
||||
const toRGBA = (color: string, alpha: number) => {
|
||||
if (color.includes('rgba')) return color;
|
||||
return color.replace('rgb', 'rgba').replace(')', `, ${alpha})`);
|
||||
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: TestRunRecord[], metric: string): ChartData<'line'> {
|
||||
const sortedRuns = [...runs]
|
||||
.sort((a, b) => new Date(a.runAt).getTime() - new Date(b.runAt).getTime())
|
||||
.filter((run) => run.metrics?.[metric]);
|
||||
|
||||
return {
|
||||
labels: sortedRuns.map((run) => {
|
||||
return dateFormat(run.runAt, 'yyyy-mm-dd HH:MM');
|
||||
}),
|
||||
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: [
|
||||
{
|
||||
label: metric,
|
||||
data: sortedRuns.map((run) => run.metrics?.[metric] ?? 0),
|
||||
data: runs,
|
||||
parsing: {
|
||||
xAxisKey: 'id',
|
||||
yAxisKey: `metrics.${metric}`,
|
||||
},
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: toRGBA(colors.primary, 0.1),
|
||||
borderWidth: 2,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
pointBackgroundColor: colors.primary,
|
||||
pointBorderColor: colors.primary,
|
||||
pointHoverBackgroundColor: colors.background,
|
||||
pointHoverBorderColor: colors.primary,
|
||||
tension: 0.4,
|
||||
fill: true,
|
||||
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(params: { metric: string; xTitle: string }): ChartOptions<'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,
|
||||
@@ -70,68 +58,65 @@ export function useMetricsChart(mode: AppliedThemeOption = 'light') {
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
color: colors.grid,
|
||||
color: colors.foregroundBase,
|
||||
},
|
||||
ticks: {
|
||||
padding: 8,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
title: {
|
||||
display: false,
|
||||
text: params.metric,
|
||||
padding: 16,
|
||||
color: colors.text.primary,
|
||||
color: colors.textBase,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
border: {
|
||||
display: false,
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
ticks: {
|
||||
display: false,
|
||||
color: colors.textBase,
|
||||
// eslint-disable-next-line id-denylist
|
||||
callback(_tickValue, index) {
|
||||
return `#${data[index].index}`;
|
||||
},
|
||||
title: {
|
||||
text: params.xTitle,
|
||||
padding: 1,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
tooltip: {
|
||||
backgroundColor: colors.background,
|
||||
titleColor: colors.text.primary,
|
||||
backgroundColor: colors.backgroundXLight,
|
||||
titleColor: colors.textBase,
|
||||
titleFont: {
|
||||
weight: '600',
|
||||
},
|
||||
bodyColor: colors.text.secondary,
|
||||
bodyColor: colors.textBase,
|
||||
bodySpacing: 4,
|
||||
padding: 12,
|
||||
borderColor: toRGBA(colors.primary, 0.2),
|
||||
borderColor: colors.foregroundBase,
|
||||
borderWidth: 1,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
title: (tooltipItems) => tooltipItems[0].label,
|
||||
label: (context) => `${params.metric}: ${context.parsed.y.toFixed(2)}`,
|
||||
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,
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
duration: 750,
|
||||
easing: 'easeInOutQuart',
|
||||
},
|
||||
transitions: {
|
||||
active: {
|
||||
animation: {
|
||||
duration: 300,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<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 { N8nIcon, N8nTooltip } from '@n8n/design-system';
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
/**
|
||||
@@ -181,11 +181,7 @@ defineSlots<{
|
||||
|
||||
.customRow {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
& > .customCell {
|
||||
background-color: var(--color-background-light);
|
||||
}
|
||||
}
|
||||
--color-table-row-hover-background: var(--color-background-light);
|
||||
}
|
||||
|
||||
.customHeaderCell {
|
||||
@@ -210,6 +206,10 @@ defineSlots<{
|
||||
.table {
|
||||
border-radius: 12px;
|
||||
|
||||
:global(.el-scrollbar__wrap) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.el-table__column-resize-proxy) {
|
||||
background-color: var(--color-primary);
|
||||
width: 3px;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useMetricsChart } from '../composables/useMetricsChart';
|
||||
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import type { TestRunRecord as Record } from '@/api/testDefinition.ee';
|
||||
|
||||
type TestRunRecord = Record & { index: number };
|
||||
describe('useMetricsChart', () => {
|
||||
const mockRuns: TestRunRecord[] = [
|
||||
{
|
||||
@@ -13,6 +14,7 @@ describe('useMetricsChart', () => {
|
||||
completedAt: '2025-01-06T10:00:00Z',
|
||||
runAt: '2025-01-06T10:00:00Z',
|
||||
metrics: { responseTime: 100, successRate: 95 },
|
||||
index: 1,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
@@ -23,105 +25,32 @@ describe('useMetricsChart', () => {
|
||||
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('light');
|
||||
const result = generateChartData(mockRuns, 'responseTime');
|
||||
const { generateChartData } = useMetricsChart();
|
||||
const {
|
||||
datasets: [dataset],
|
||||
} = generateChartData(mockRuns, 'responseTime');
|
||||
|
||||
expect(result.labels).toHaveLength(2);
|
||||
expect(result.datasets).toHaveLength(1);
|
||||
expect(result.datasets[0].data).toEqual([100, 150]);
|
||||
});
|
||||
|
||||
it('should sort runs by date', () => {
|
||||
const unsortedRuns = [
|
||||
{
|
||||
id: '1',
|
||||
testDefinitionId: 'test1',
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-06T10:05:00Z',
|
||||
updatedAt: '2025-01-06T10:05:00Z',
|
||||
completedAt: '2025-01-06T10:05:00Z',
|
||||
runAt: '2025-01-06T10:05:00Z',
|
||||
metrics: { responseTime: 150 },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
testDefinitionId: 'test1',
|
||||
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 },
|
||||
},
|
||||
] as TestRunRecord[];
|
||||
|
||||
const { generateChartData } = useMetricsChart('light');
|
||||
const result = generateChartData(unsortedRuns, 'responseTime');
|
||||
|
||||
expect(result.datasets[0].data).toEqual([100, 150]);
|
||||
});
|
||||
|
||||
it('should filter out runs without specified metric', () => {
|
||||
const runsWithMissingMetrics = [
|
||||
{
|
||||
id: '1',
|
||||
testDefinitionId: 'test1',
|
||||
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 },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
testDefinitionId: 'test1',
|
||||
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: {},
|
||||
},
|
||||
] as TestRunRecord[];
|
||||
|
||||
const { generateChartData } = useMetricsChart('light');
|
||||
const result = generateChartData(runsWithMissingMetrics, 'responseTime');
|
||||
|
||||
expect(result.labels).toHaveLength(1);
|
||||
expect(result.datasets[0].data).toEqual([100]);
|
||||
});
|
||||
|
||||
it('should handle dark theme colors', () => {
|
||||
const { generateChartData } = useMetricsChart('dark');
|
||||
const result = generateChartData(mockRuns, 'responseTime');
|
||||
|
||||
expect(result.datasets[0].pointHoverBackgroundColor).toBe('rgb(32, 32, 32)');
|
||||
//@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('light');
|
||||
const result = generateChartOptions({ metric: 'responseTime', xTitle: 'Time' });
|
||||
const { generateChartOptions } = useMetricsChart();
|
||||
const result = generateChartOptions({ metric: 'responseTime', data: mockRuns });
|
||||
|
||||
expect(result.scales?.y?.title?.text).toBe('responseTime');
|
||||
expect(result.scales?.x?.title?.text).toBe('Time');
|
||||
//@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);
|
||||
});
|
||||
|
||||
it('should apply correct theme colors', () => {
|
||||
const { generateChartOptions } = useMetricsChart('dark');
|
||||
const result = generateChartOptions({ metric: 'responseTime', xTitle: 'Time' });
|
||||
|
||||
expect(result.scales?.y?.ticks?.color).toBe('rgb(255, 255, 255)');
|
||||
expect(result.plugins?.tooltip?.backgroundColor).toBe('rgb(32, 32, 32)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,9 +12,6 @@ import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import type { FrontendSettings } from '@n8n/api-types';
|
||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
import type { TestDefinitionRecord } from '@/api/testDefinition.ee';
|
||||
|
||||
const showMessage = vi.fn();
|
||||
const showError = vi.fn();
|
||||
@@ -71,16 +68,6 @@ const executionDataFactory = (
|
||||
annotation: { tags, vote: 'up' },
|
||||
});
|
||||
|
||||
const testCaseFactory = (workflowId: string, annotationTagId?: string): TestDefinitionRecord => ({
|
||||
id: faker.string.uuid(),
|
||||
createdAt: faker.date.past().toString(),
|
||||
updatedAt: faker.date.past().toString(),
|
||||
evaluationWorkflowId: null,
|
||||
annotationTagId,
|
||||
workflowId,
|
||||
name: `My test ${faker.number.int()}`,
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(WorkflowExecutionsPreview, {
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -134,119 +121,4 @@ describe('WorkflowExecutionsPreview.vue', () => {
|
||||
|
||||
expect(getByTestId('stop-execution')).toBeDisabled();
|
||||
});
|
||||
|
||||
describe('test execution crud', () => {
|
||||
it('should add an execution to a testcase', async () => {
|
||||
const tag = { id: 'tag_id', name: 'tag_name' };
|
||||
const execution = executionDataFactory([]);
|
||||
const testCase = testCaseFactory(execution.workflowId, tag.id);
|
||||
|
||||
const testDefinitionStore = mockedStore(useTestDefinitionStore);
|
||||
const executionsStore = mockedStore(useExecutionsStore);
|
||||
const settingsStore = mockedStore(useSettingsStore);
|
||||
|
||||
testDefinitionStore.allTestDefinitionsByWorkflowId[execution.workflowId] = [testCase];
|
||||
|
||||
settingsStore.isEnterpriseFeatureEnabled = {
|
||||
advancedExecutionFilters: true,
|
||||
} as FrontendSettings['enterprise'];
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: { execution: { ...execution, status: 'success' } },
|
||||
});
|
||||
|
||||
await router.push({ params: { name: execution.workflowId }, query: { testId: testCase.id } });
|
||||
|
||||
expect(getByTestId('test-execution-crud')).toBeInTheDocument();
|
||||
expect(getByTestId('test-execution-add')).toBeVisible();
|
||||
|
||||
await userEvent.click(getByTestId('test-execution-add'));
|
||||
|
||||
expect(executionsStore.annotateExecution).toHaveBeenCalledWith(execution.id, {
|
||||
tags: [testCase.annotationTagId],
|
||||
});
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
|
||||
});
|
||||
|
||||
it('should remove an execution from a testcase', async () => {
|
||||
const tag = { id: 'tag_id', name: 'tag_name' };
|
||||
const execution = executionDataFactory([tag]);
|
||||
const testCase = testCaseFactory(execution.workflowId, tag.id);
|
||||
|
||||
const testDefinitionStore = mockedStore(useTestDefinitionStore);
|
||||
const executionsStore = mockedStore(useExecutionsStore);
|
||||
const settingsStore = mockedStore(useSettingsStore);
|
||||
|
||||
testDefinitionStore.allTestDefinitionsByWorkflowId[execution.workflowId] = [testCase];
|
||||
|
||||
settingsStore.isEnterpriseFeatureEnabled = {
|
||||
advancedExecutionFilters: true,
|
||||
} as FrontendSettings['enterprise'];
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: { execution: { ...execution, status: 'success' } },
|
||||
});
|
||||
|
||||
await router.push({ params: { name: execution.workflowId }, query: { testId: testCase.id } });
|
||||
|
||||
expect(getByTestId('test-execution-crud')).toBeInTheDocument();
|
||||
expect(getByTestId('test-execution-remove')).toBeVisible();
|
||||
|
||||
await userEvent.click(getByTestId('test-execution-remove'));
|
||||
|
||||
expect(executionsStore.annotateExecution).toHaveBeenCalledWith(execution.id, {
|
||||
tags: [],
|
||||
});
|
||||
|
||||
expect(showMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' }));
|
||||
});
|
||||
|
||||
it('should toggle an execution', async () => {
|
||||
const tag1 = { id: 'tag_id', name: 'tag_name' };
|
||||
const tag2 = { id: 'tag_id_2', name: 'tag_name_2' };
|
||||
const execution = executionDataFactory([tag1]);
|
||||
const testCase1 = testCaseFactory(execution.workflowId, tag1.id);
|
||||
const testCase2 = testCaseFactory(execution.workflowId, tag2.id);
|
||||
|
||||
const testDefinitionStore = mockedStore(useTestDefinitionStore);
|
||||
const executionsStore = mockedStore(useExecutionsStore);
|
||||
const settingsStore = mockedStore(useSettingsStore);
|
||||
|
||||
testDefinitionStore.allTestDefinitionsByWorkflowId[execution.workflowId] = [
|
||||
testCase1,
|
||||
testCase2,
|
||||
];
|
||||
|
||||
settingsStore.isEnterpriseFeatureEnabled = {
|
||||
advancedExecutionFilters: true,
|
||||
} as FrontendSettings['enterprise'];
|
||||
|
||||
const { getByTestId, queryAllByTestId, rerender } = renderComponent({
|
||||
props: { execution: { ...execution, status: 'success' } },
|
||||
});
|
||||
|
||||
await router.push({ params: { name: execution.workflowId } });
|
||||
|
||||
expect(getByTestId('test-execution-crud')).toBeInTheDocument();
|
||||
expect(getByTestId('test-execution-toggle')).toBeVisible();
|
||||
|
||||
// add
|
||||
await userEvent.click(getByTestId('test-execution-toggle'));
|
||||
await userEvent.click(queryAllByTestId('test-execution-add-to')[1]);
|
||||
expect(executionsStore.annotateExecution).toHaveBeenCalledWith(execution.id, {
|
||||
tags: [tag1.id, tag2.id],
|
||||
});
|
||||
|
||||
const executionWithBothTags = executionDataFactory([tag1, tag2]);
|
||||
await rerender({ execution: { ...executionWithBothTags, status: 'success' } });
|
||||
|
||||
// remove
|
||||
await userEvent.click(getByTestId('test-execution-toggle'));
|
||||
await userEvent.click(queryAllByTestId('test-execution-add-to')[1]);
|
||||
expect(executionsStore.annotateExecution).toHaveBeenLastCalledWith(executionWithBothTags.id, {
|
||||
tags: [tag1.id],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,18 +6,15 @@ import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
|
||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { EnterpriseEditionFeature, MODAL_CONFIRM, VIEWS } from '@/constants';
|
||||
import { getResourcePermissions } from '@/permissions';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus';
|
||||
import { N8nButton, N8nIcon, N8nIconButton, N8nText, N8nTooltip } from '@n8n/design-system';
|
||||
import { N8nButton, N8nIconButton, N8nText } from '@n8n/design-system';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { computed, h, onMounted, ref } from 'vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import { computed, ref } from 'vue';
|
||||
import { RouterLink, useRoute } from 'vue-router';
|
||||
|
||||
type RetryDropdownRef = InstanceType<typeof ElDropdown>;
|
||||
|
||||
@@ -32,17 +29,13 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const locale = useI18n();
|
||||
|
||||
const executionHelpers = useExecutionHelpers();
|
||||
const message = useMessage();
|
||||
const toast = useToast();
|
||||
const executionDebugging = useExecutionDebugging();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const testDefinitionStore = useTestDefinitionStore();
|
||||
const executionsStore = useExecutionsStore();
|
||||
const retryDropdownRef = ref<RetryDropdownRef | null>(null);
|
||||
const workflowId = computed(() => route.params.name as string);
|
||||
const workflowPermissions = computed(
|
||||
@@ -77,117 +70,6 @@ const hasAnnotation = computed(
|
||||
(props.execution?.annotation.vote || props.execution?.annotation.tags.length > 0),
|
||||
);
|
||||
|
||||
const testDefinitions = computed(
|
||||
() => testDefinitionStore.allTestDefinitionsByWorkflowId[workflowId.value] ?? [],
|
||||
);
|
||||
|
||||
const testDefinition = computed(() =>
|
||||
testDefinitions.value.find((test) => test.id === route.query.testId),
|
||||
);
|
||||
|
||||
const disableAddToTestTooltip = computed(() => {
|
||||
if (props.execution.mode === 'evaluation') {
|
||||
return locale.baseText('testDefinition.executions.tooltip.noExecutions');
|
||||
}
|
||||
|
||||
if (props.execution.status !== 'success') {
|
||||
return locale.baseText('testDefinition.executions.tooltip.onlySuccess');
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
type Command = {
|
||||
type: 'addTag' | 'removeTag' | 'createTest';
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const getTagIds = (tags?: Array<{ id: string; name: string }>) => (tags ?? []).map((t) => t.id);
|
||||
|
||||
const addExecutionTag = async (annotationTagId: string) => {
|
||||
const newTags = [...getTagIds(props.execution?.annotation?.tags), annotationTagId];
|
||||
await executionsStore.annotateExecution(props.execution.id, { tags: newTags });
|
||||
toast.showToast({
|
||||
title: locale.baseText('testDefinition.executions.toast.addedTo.title'),
|
||||
message: h(
|
||||
N8nText,
|
||||
{
|
||||
color: 'primary',
|
||||
style: { cursor: 'pointer ' },
|
||||
},
|
||||
() => locale.baseText('testDefinition.executions.toast.closeTab'),
|
||||
),
|
||||
closeOnClick: false,
|
||||
onClick() {
|
||||
window.close();
|
||||
},
|
||||
type: 'success',
|
||||
});
|
||||
};
|
||||
|
||||
const removeExecutionTag = async (annotationTagId: string) => {
|
||||
const newTags = getTagIds(props.execution?.annotation?.tags).filter(
|
||||
(id) => id !== annotationTagId,
|
||||
);
|
||||
await executionsStore.annotateExecution(props.execution.id, { tags: newTags });
|
||||
toast.showMessage({
|
||||
title: locale.baseText('testDefinition.executions.toast.removedFrom.title'),
|
||||
type: 'success',
|
||||
});
|
||||
};
|
||||
|
||||
const createTestForExecution = async (id: string) => {
|
||||
await router.push({
|
||||
name: VIEWS.NEW_TEST_DEFINITION,
|
||||
params: {
|
||||
name: workflowId.value,
|
||||
},
|
||||
query: {
|
||||
executionId: id,
|
||||
annotationTags: getTagIds(props.execution?.annotation?.tags),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const commandCallbacks = {
|
||||
addTag: addExecutionTag,
|
||||
removeTag: removeExecutionTag,
|
||||
createTest: createTestForExecution,
|
||||
} as const;
|
||||
|
||||
const handleCommand = async (command: Command) => {
|
||||
const action = commandCallbacks[command.type];
|
||||
return await action(command.id);
|
||||
};
|
||||
|
||||
const testList = computed(() => {
|
||||
return testDefinitions.value.reduce<
|
||||
Array<{ label: string; value: string; added: boolean; command: Command }>
|
||||
>((acc, test) => {
|
||||
if (!test.annotationTagId) return acc;
|
||||
|
||||
const added = isTagAlreadyAdded(test.annotationTagId);
|
||||
|
||||
acc.push({
|
||||
label: test.name,
|
||||
value: test.annotationTagId,
|
||||
added,
|
||||
command: { type: added ? 'removeTag' : 'addTag', id: test.annotationTagId, name: test.name },
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
});
|
||||
|
||||
function isTagAlreadyAdded(tagId?: string | null) {
|
||||
return Boolean(tagId && props.execution?.annotation?.tags.some((tag) => tag.id === tagId));
|
||||
}
|
||||
|
||||
const executionHasTestTag = computed(() =>
|
||||
isTagAlreadyAdded(testDefinition.value?.annotationTagId),
|
||||
);
|
||||
|
||||
async function onDeleteExecution(): Promise<void> {
|
||||
// Prepend the message with a note about annotations if they exist
|
||||
const confirmationText = [
|
||||
@@ -226,10 +108,6 @@ function onRetryButtonBlur(event: FocusEvent) {
|
||||
retryDropdownRef.value.handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await testDefinitionStore.fetchTestDefinitionsByWorkflowId(workflowId.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -324,97 +202,6 @@ onMounted(async () => {
|
||||
</N8nText>
|
||||
</div>
|
||||
<div :class="$style.actions">
|
||||
<N8nTooltip
|
||||
placement="top"
|
||||
:content="disableAddToTestTooltip"
|
||||
:disabled="!disableAddToTestTooltip"
|
||||
>
|
||||
<ElDropdown
|
||||
trigger="click"
|
||||
placement="bottom-end"
|
||||
data-test-id="test-execution-crud"
|
||||
@command="handleCommand"
|
||||
>
|
||||
<div v-if="testDefinition" :class="$style.buttonGroup">
|
||||
<N8nButton
|
||||
v-if="executionHasTestTag"
|
||||
:disabled="!!disableAddToTestTooltip"
|
||||
type="secondary"
|
||||
data-test-id="test-execution-remove"
|
||||
@click.stop="removeExecutionTag(testDefinition.annotationTagId!)"
|
||||
>
|
||||
{{
|
||||
locale.baseText('testDefinition.executions.removeFrom', {
|
||||
interpolate: { name: testDefinition.name },
|
||||
})
|
||||
}}
|
||||
</N8nButton>
|
||||
|
||||
<N8nButton
|
||||
v-else
|
||||
:disabled="!!disableAddToTestTooltip"
|
||||
type="primary"
|
||||
data-test-id="test-execution-add"
|
||||
@click.stop="addExecutionTag(testDefinition.annotationTagId!)"
|
||||
>
|
||||
{{
|
||||
locale.baseText('testDefinition.executions.addTo.existing', {
|
||||
interpolate: { name: testDefinition.name },
|
||||
})
|
||||
}}
|
||||
</N8nButton>
|
||||
<N8nIconButton
|
||||
:disabled="!!disableAddToTestTooltip"
|
||||
icon="angle-down"
|
||||
:type="executionHasTestTag ? 'secondary' : 'primary'"
|
||||
data-test-id="test-execution-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<N8nButton
|
||||
v-else
|
||||
:disabled="!!disableAddToTestTooltip"
|
||||
type="secondary"
|
||||
data-test-id="test-execution-toggle"
|
||||
>
|
||||
{{ locale.baseText('testDefinition.executions.addTo.new') }}
|
||||
<N8nIcon icon="angle-down" size="small" class="ml-2xs" />
|
||||
</N8nButton>
|
||||
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu :class="$style.testDropdownMenu">
|
||||
<div :class="$style.testDropdownMenuScroll">
|
||||
<ElDropdownItem
|
||||
v-for="test in testList"
|
||||
:key="test.value"
|
||||
:command="test.command"
|
||||
data-test-id="test-execution-add-to"
|
||||
>
|
||||
<N8nText
|
||||
:color="test.added ? 'primary' : 'text-dark'"
|
||||
:class="$style.fontMedium"
|
||||
>
|
||||
<N8nIcon v-if="test.added" icon="check" color="primary" />
|
||||
{{ test.label }}
|
||||
</N8nText>
|
||||
</ElDropdownItem>
|
||||
</div>
|
||||
<ElDropdownItem
|
||||
:class="$style.createTestButton"
|
||||
:command="{ type: 'createTest', id: execution.id }"
|
||||
:disabled="!workflowPermissions.update"
|
||||
data-test-id="test-execution-create"
|
||||
>
|
||||
<N8nText :class="$style.fontMedium">
|
||||
<N8nIcon icon="plus" />
|
||||
{{ locale.baseText('testDefinition.executions.tooltip.addTo') }}
|
||||
</N8nText>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</N8nTooltip>
|
||||
|
||||
<RouterLink
|
||||
:to="{
|
||||
name: VIEWS.EXECUTION_DEBUG,
|
||||
@@ -565,41 +352,4 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.testDropdownMenu {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.testDropdownMenuScroll {
|
||||
max-height: 274px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.createTestButton {
|
||||
border-top: 1px solid var(--color-foreground-base);
|
||||
background-color: var(--color-background-light-base);
|
||||
border-bottom-left-radius: var(--border-radius-base);
|
||||
border-bottom-right-radius: var(--border-radius-base);
|
||||
&:not(.is-disabled):hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.fontMedium {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.buttonGroup {
|
||||
display: inline-flex;
|
||||
:global(.button:first-child) {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
:global(.button:last-child) {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2964,7 +2964,7 @@
|
||||
"testDefinition.edit.modal.description": "Choose which past data to keep when re-running the execution(s). Any mocked node will be replayed rather than re-executed. The trigger is always mocked.",
|
||||
"testDefinition.edit.runExecution": "Run execution",
|
||||
"testDefinition.edit.pastRuns": "Past runs",
|
||||
"testDefinition.edit.pastRuns.total": "No runs | Past run ({count}) | Past runs ({count})",
|
||||
"testDefinition.edit.pastRuns.total": "No runs | Past run | Past runs",
|
||||
"testDefinition.edit.nodesPinning.pinButtonTooltip": "Use benchmark data for this node during evaluation execution",
|
||||
"testDefinition.edit.nodesPinning.pinButtonTooltip.pinned": "This node will not be re-executed",
|
||||
"testDefinition.edit.nodesPinning.triggerTooltip": "Trigger nodes are mocked by default",
|
||||
|
||||
@@ -133,6 +133,18 @@ export const statusUnknown: IconDefinition = {
|
||||
],
|
||||
};
|
||||
|
||||
export const statusWarning: IconDefinition = {
|
||||
prefix: 'fas',
|
||||
iconName: 'status-warning' as IconName,
|
||||
icon: [
|
||||
14,
|
||||
14,
|
||||
[],
|
||||
'',
|
||||
'M 14 7 C 14 10.866 10.866 14 7 14 C 3.134 14 0 10.866 0 7 C 0 3.134 3.134 0 7 0 C 10.866 0 14 3.134 14 7 Z M 6.5 9 C 6.224 9 6 9.224 6 9.5 L 6 10.5 C 6 10.776 6.224 11 6.5 11 L 7.5 11 C 7.776 11 8 10.776 8 10.5 L 8 9.5 C 8 9.224 7.776 9 7.5 9 L 6.5 9 Z M 6.5 3 C 6.224 3 6 3.224 6 3.5 L 6 7.5 C 6 7.776 6.224 8 6.5 8 L 7.5 8 C 7.776 8 8 7.776 8 7.5 L 8 3.5 C 8 3.224 7.776 3 7.5 3 L 6.5 3 Z',
|
||||
],
|
||||
};
|
||||
|
||||
export const faPopOut: IconDefinition = {
|
||||
prefix: 'fas',
|
||||
iconName: 'pop-out' as IconName,
|
||||
|
||||
@@ -188,6 +188,7 @@ import {
|
||||
statusCanceled,
|
||||
statusNew,
|
||||
statusUnknown,
|
||||
statusWarning,
|
||||
faPopOut,
|
||||
} from './custom';
|
||||
import { faStickyNote } from '@fortawesome/free-regular-svg-icons';
|
||||
@@ -385,6 +386,7 @@ export const FontAwesomePlugin: Plugin = {
|
||||
addIcon(statusCanceled);
|
||||
addIcon(statusNew);
|
||||
addIcon(statusUnknown);
|
||||
addIcon(statusWarning);
|
||||
|
||||
addIcon(faPopOut);
|
||||
|
||||
|
||||
@@ -9,15 +9,15 @@ import { useAnnotationTagsStore } from '@/stores/tags.store';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import type { TestRunRecord } from '@/api/testDefinition.ee';
|
||||
import InlineNameEdit from '@/components/InlineNameEdit.vue';
|
||||
import ConfigSection from '@/components/TestDefinition/EditDefinition/sections/ConfigSection.vue';
|
||||
import RunsSection from '@/components/TestDefinition/EditDefinition/sections/RunsSection.vue';
|
||||
import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useDocumentVisibility } from '@vueuse/core';
|
||||
import { N8nButton, N8nIconButton, N8nText } from '@n8n/design-system';
|
||||
import { useDocumentVisibility } from '@vueuse/core';
|
||||
import { orderBy } from 'lodash-es';
|
||||
import type { IDataObject, IPinData } from 'n8n-workflow';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -50,7 +50,6 @@ const { state, isSaving, cancelEditing, loadTestData, updateTest, startEditing,
|
||||
const isLoading = computed(() => tagsStore.isLoading);
|
||||
const tagsById = computed(() => tagsStore.tagsById);
|
||||
const currentWorkflowId = computed(() => props.name);
|
||||
const appliedTheme = computed(() => uiStore.appliedTheme);
|
||||
const workflowName = computed(() => workflowStore.workflow.name);
|
||||
const hasRuns = computed(() => runs.value.length > 0);
|
||||
const fieldsIssues = computed(() => testDefinitionStore.getFieldIssues(props.testId) ?? []);
|
||||
@@ -94,23 +93,19 @@ async function openExecutionsViewForTag() {
|
||||
window.open(executionsRoute.href, '_blank');
|
||||
}
|
||||
|
||||
const runs = computed(() =>
|
||||
Object.values(testDefinitionStore.testRunsById ?? {}).filter(
|
||||
(run) => run.testDefinitionId === props.testId,
|
||||
),
|
||||
);
|
||||
const runs = computed(() => {
|
||||
const testRuns = Object.values(testDefinitionStore.testRunsById ?? {}).filter(
|
||||
({ testDefinitionId }) => testDefinitionId === props.testId,
|
||||
);
|
||||
|
||||
return orderBy(testRuns, (record) => new Date(record.runAt), ['asc']).map((record, index) =>
|
||||
Object.assign(record, { index: index + 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
const isRunning = computed(() => runs.value.some((run) => run.status === 'running'));
|
||||
const isRunTestEnabled = computed(() => fieldsIssues.value.length === 0 && !isRunning.value);
|
||||
|
||||
async function onDeleteRuns(toDelete: TestRunRecord[]) {
|
||||
await Promise.all(
|
||||
toDelete.map(async (run) => {
|
||||
await testDefinitionStore.deleteTestRun({ testDefinitionId: props.testId, runId: run.id });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function renameTag(newName: string) {
|
||||
await tagsStore.rename({ id: state.value.tags.value[0], name: newName });
|
||||
}
|
||||
@@ -225,8 +220,6 @@ function onEvaluationWorkflowCreated(workflowId: string) {
|
||||
:class="$style.runs"
|
||||
:runs="runs"
|
||||
:test-id="testId"
|
||||
:applied-theme="appliedTheme"
|
||||
@delete-runs="onDeleteRuns"
|
||||
/>
|
||||
|
||||
<ConfigSection
|
||||
|
||||
Reference in New Issue
Block a user