Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(editor): Add workflow evaluation run views (no-changelog) #12258

Open
wants to merge 40 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
5e179b6
wip: test-runs controller
burivuhster Nov 25, 2024
dadd547
chore: rename test-run controller file
burivuhster Nov 27, 2024
70e0699
wip: add controller tests
burivuhster Nov 27, 2024
3f6cd79
wip: add pagination
burivuhster Nov 28, 2024
a4616e9
wip: fix import order
burivuhster Nov 28, 2024
dc27ff8
wip: Added tests for test run delete
burivuhster Nov 28, 2024
36bc011
wip: Addressing PR comments
burivuhster Nov 29, 2024
5e97cfb
wip: fix lint issue
burivuhster Nov 29, 2024
b247103
feat(editor-ui): Add test metrics management and simplify tags input
OlegIvaniv Dec 4, 2024
2c4c457
feat(editor): Allow to create new tags during evaluation edit
OlegIvaniv Dec 5, 2024
a2c2855
feat(editor-ui): add node controls slot to canvas components
OlegIvaniv Dec 6, 2024
5b87550
Merge remote-tracking branch 'origin/evaluation-tags' into ai-430-met…
OlegIvaniv Dec 6, 2024
a6b49f2
Fix state for tags
OlegIvaniv Dec 6, 2024
19d575c
Improve unit tests & fix tag adding
OlegIvaniv Dec 6, 2024
3ad64b2
Fix delete metric button
OlegIvaniv Dec 10, 2024
42926ba
Merge branch 'master' into ai-430-metrics
OlegIvaniv Dec 10, 2024
ee05fd9
Add tooltips and limit tag selection in test definitions
OlegIvaniv Dec 10, 2024
2e105d1
Revert Canvas slot changes
OlegIvaniv Dec 10, 2024
0fae057
Remove unnecessary comments
OlegIvaniv Dec 10, 2024
601cdc1
Fix linter complains
OlegIvaniv Dec 10, 2024
b8f1140
Add store metrics tests
OlegIvaniv Dec 10, 2024
4e8bf76
WIP: Test Run
OlegIvaniv Dec 11, 2024
9973452
WIP: ListRuns
OlegIvaniv Dec 12, 2024
499a4ed
refactor: Improve main header tab management
OlegIvaniv Dec 12, 2024
f174ae0
Add test run detail view and improve test execution tracking
OlegIvaniv Dec 12, 2024
7e37682
Add node pinning functionality to test definitions
OlegIvaniv Dec 13, 2024
68250db
Improve test definition UI and functionality
OlegIvaniv Dec 13, 2024
98b829b
Improve UX of the runs table & add select all + delete runs
OlegIvaniv Dec 16, 2024
6ab6dfb
Merge branch 'master' into ai-503-frontend-user-can-run-a-test-from-t…
OlegIvaniv Dec 16, 2024
552cc53
Add & fix unit tests
OlegIvaniv Dec 16, 2024
50b9c4e
refactor(editor): Enhance node pinning functionality and add localiza…
OlegIvaniv Dec 17, 2024
1c20c08
Merge branch 'master' into ai-503-frontend-user-can-run-a-test-from-t…
OlegIvaniv Dec 17, 2024
8a567ac
Get rid of compare view
OlegIvaniv Dec 17, 2024
46db970
Apply self-review changes
OlegIvaniv Dec 17, 2024
d907585
Use N8nSelect for metrics chart
OlegIvaniv Dec 17, 2024
93fc1a9
Refactor MetricsChart component and add useMetricsChart composable
OlegIvaniv Dec 17, 2024
c08bea9
change default sort order from descending to ascending
OlegIvaniv Dec 17, 2024
8f56169
simplify node class management in CanvasNode component
OlegIvaniv Dec 17, 2024
5bfa1dc
Remove unused import in useMetricsChart composable
OlegIvaniv Dec 17, 2024
f572564
change imports to type imports for TestRunRecord and IWorkflowDb
OlegIvaniv Dec 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export class TestRunnerService {
evaluationWorkflow: WorkflowEntity,
expectedData: IRunData,
actualData: IRunData,
testRunId?: string,
) {
// Prepare the evaluation wf input data.
// Provide both the expected data and the actual data
Expand All @@ -139,7 +140,13 @@ export class TestRunnerService {

// Prepare the data to run the evaluation workflow
const data = await getRunData(evaluationWorkflow, [evaluationInputData]);

// FIXME: This is a hack to add the testRunId to the evaluation workflow execution data
// So that we can fetch all execution runs for a test run
if (testRunId && data.executionData) {
data.executionData.resultData.metadata = {
testRunId,
};
}
data.executionMode = 'evaluation';

// Trigger the evaluation workflow
Expand Down Expand Up @@ -256,10 +263,9 @@ export class TestRunnerService {
evaluationWorkflow,
originalRunData,
testCaseRunData,
testRun.id,
);
assert(evalExecution);

// Extract the output of the last node executed in the evaluation workflow
metrics.addResults(this.extractEvaluationResult(evalExecution));
}

Expand Down
5 changes: 5 additions & 0 deletions packages/design-system/src/components/N8nSelect/Select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ const props = defineProps({
multiple: {
type: Boolean,
},
multipleLimit: {
type: Number,
default: 0,
},
filterMethod: {
type: Function,
},
Expand Down Expand Up @@ -120,6 +124,7 @@ defineExpose({
<ElSelect
v-bind="{ ...$props, ...listeners }"
ref="innerSelect"
:multiple-limit="props.multipleLimit"
:model-value="props.modelValue ?? undefined"
:size="computedSize"
:popper-class="props.popperClass"
Expand Down
172 changes: 164 additions & 8 deletions packages/editor-ui/src/api/testDefinition.ee.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { IRestApiContext } from '@/Interface';
import { makeRestApiRequest } from '@/utils/apiUtils';
import { makeRestApiRequest, request } from '@/utils/apiUtils';

export interface TestDefinitionRecord {
id: string;
name: string;
Expand All @@ -9,7 +10,10 @@ export interface TestDefinitionRecord {
description?: string | null;
updatedAt?: string;
createdAt?: string;
annotationTag?: string | null;
mockedNodes?: Array<{ name: string }>;
}

interface CreateTestDefinitionParams {
name: string;
workflowId: string;
Expand All @@ -21,31 +25,62 @@ export interface UpdateTestDefinitionParams {
evaluationWorkflowId?: string | null;
annotationTagId?: string | null;
description?: string | null;
mockedNodes?: Array<{ name: string }>;
}

export interface UpdateTestResponse {
createdAt: string;
updatedAt: string;
id: string;
name: string;
workflowId: string;
description: string | null;
annotationTag: string | null;
evaluationWorkflowId: string | null;
annotationTagId: string | null;
description?: string | null;
annotationTag?: string | null;
evaluationWorkflowId?: string | null;
annotationTagId?: string | null;
}

export interface TestRunRecord {
id: string;
testDefinitionId: string;
status: 'new' | 'running' | 'completed' | 'error';
metrics?: Record<string, number>;
createdAt: string;
updatedAt: string;
runAt: string;
completedAt: string;
}

interface GetTestRunParams {
testDefinitionId: string;
runId: string;
}

interface DeleteTestRunParams {
testDefinitionId: string;
runId: string;
}

const endpoint = '/evaluation/test-definitions';
const getMetricsEndpoint = (testDefinitionId: string) => `${endpoint}/${testDefinitionId}/metrics`;

export async function getTestDefinitions(context: IRestApiContext) {
export async function getTestDefinitions(
context: IRestApiContext,
params?: { workflowId?: string },
) {
let url = endpoint;
if (params?.workflowId) {
url += `?filter=${JSON.stringify({ workflowId: params.workflowId })}`;
}
return await makeRestApiRequest<{ count: number; testDefinitions: TestDefinitionRecord[] }>(
context,
'GET',
endpoint,
url,
);
}

export async function getTestDefinition(context: IRestApiContext, id: string) {
return await makeRestApiRequest<{ id: string }>(context, 'GET', `${endpoint}/${id}`);
return await makeRestApiRequest<TestDefinitionRecord>(context, 'GET', `${endpoint}/${id}`);
}

export async function createTestDefinition(
Expand All @@ -71,3 +106,124 @@ export async function updateTestDefinition(
export async function deleteTestDefinition(context: IRestApiContext, id: string) {
return await makeRestApiRequest<{ success: boolean }>(context, 'DELETE', `${endpoint}/${id}`);
}

// Metrics
export interface TestMetricRecord {
id: string;
name: string;
testDefinitionId: string;
createdAt?: string;
updatedAt?: string;
}

export interface CreateTestMetricParams {
testDefinitionId: string;
name: string;
}

export interface UpdateTestMetricParams {
name: string;
id: string;
testDefinitionId: string;
}

export interface DeleteTestMetricParams {
testDefinitionId: string;
id: string;
}

export const getTestMetrics = async (context: IRestApiContext, testDefinitionId: string) => {
return await makeRestApiRequest<TestMetricRecord[]>(
context,
'GET',
getMetricsEndpoint(testDefinitionId),
);
};

export const getTestMetric = async (
context: IRestApiContext,
testDefinitionId: string,
id: string,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'GET',
`${getMetricsEndpoint(testDefinitionId)}/${id}`,
);
};

export const createTestMetric = async (
context: IRestApiContext,
params: CreateTestMetricParams,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'POST',
getMetricsEndpoint(params.testDefinitionId),
{ name: params.name },
);
};

export const updateTestMetric = async (
context: IRestApiContext,
params: UpdateTestMetricParams,
) => {
return await makeRestApiRequest<TestMetricRecord>(
context,
'PATCH',
`${getMetricsEndpoint(params.testDefinitionId)}/${params.id}`,
{ name: params.name },
);
};

export const deleteTestMetric = async (
context: IRestApiContext,
params: DeleteTestMetricParams,
) => {
return await makeRestApiRequest(
context,
'DELETE',
`${getMetricsEndpoint(params.testDefinitionId)}/${params.id}`,
);
};

const getRunsEndpoint = (testDefinitionId: string) => `${endpoint}/${testDefinitionId}/runs`;

// Get all test runs for a test definition
export const getTestRuns = async (context: IRestApiContext, testDefinitionId: string) => {
return await makeRestApiRequest<TestRunRecord[]>(
context,
'GET',
getRunsEndpoint(testDefinitionId),
);
};

// Get specific test run
export const getTestRun = async (context: IRestApiContext, params: GetTestRunParams) => {
return await makeRestApiRequest<TestRunRecord>(
context,
'GET',
`${getRunsEndpoint(params.testDefinitionId)}/${params.runId}`,
);
};

// Start a new test run
export const startTestRun = async (context: IRestApiContext, testDefinitionId: string) => {
const response = await request({
method: 'POST',
baseURL: context.baseUrl,
endpoint: `${endpoint}/${testDefinitionId}/run`,
headers: { 'push-ref': context.pushRef },
});
// CLI is returning the response without wrapping it in `data` key
return response as { success: boolean };
};

// Delete a test run
export const deleteTestRun = async (context: IRestApiContext, params: DeleteTestRunParams) => {
return await makeRestApiRequest<{ success: boolean }>(
context,
'DELETE',
`${getRunsEndpoint(params.testDefinitionId)}/${params.runId}`,
);
};
51 changes: 36 additions & 15 deletions packages/editor-ui/src/components/MainHeader/MainHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,25 @@ const executionToReturnTo = ref('');
const dirtyState = ref(false);
const githubButtonHidden = useLocalStorage(LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON, false);

// Track the routes that are used for the tabs
// This is used to determine which tab to show when the route changes
// TODO: It might be easier to manage this in the router config, by passing meta information to the routes
// This would allow us to specify it just once on the root route, and then have the tabs be determined for children
const testDefinitionRoutes: VIEWS[] = [
VIEWS.TEST_DEFINITION,
VIEWS.TEST_DEFINITION_EDIT,
VIEWS.TEST_DEFINITION_RUNS,
VIEWS.TEST_DEFINITION_RUNS_DETAIL,
VIEWS.TEST_DEFINITION_RUNS_COMPARE,
];

const workflowRoutes: VIEWS[] = [VIEWS.WORKFLOW, VIEWS.NEW_WORKFLOW, VIEWS.EXECUTION_DEBUG];

const executionRoutes: VIEWS[] = [
VIEWS.EXECUTION_HOME,
VIEWS.WORKFLOW_EXECUTIONS,
VIEWS.EXECUTION_PREVIEW,
];
const tabBarItems = computed(() => {
const items = [
{ value: MAIN_HEADER_TABS.WORKFLOW, label: locale.baseText('generic.editor') },
Expand Down Expand Up @@ -92,22 +111,24 @@ onMounted(async () => {
syncTabsWithRoute(route);
});

function isViewRoute(name: unknown): name is VIEWS {
return (
typeof name === 'string' &&
[testDefinitionRoutes, workflowRoutes, executionRoutes].flat().includes(name as VIEWS)
);
}

function syncTabsWithRoute(to: RouteLocation, from?: RouteLocation): void {
if (to.matched.some((record) => record.name === VIEWS.TEST_DEFINITION)) {
activeHeaderTab.value = MAIN_HEADER_TABS.TEST_DEFINITION;
}
if (
to.name === VIEWS.EXECUTION_HOME ||
to.name === VIEWS.WORKFLOW_EXECUTIONS ||
to.name === VIEWS.EXECUTION_PREVIEW
) {
activeHeaderTab.value = MAIN_HEADER_TABS.EXECUTIONS;
} else if (
to.name === VIEWS.WORKFLOW ||
to.name === VIEWS.NEW_WORKFLOW ||
to.name === VIEWS.EXECUTION_DEBUG
) {
activeHeaderTab.value = MAIN_HEADER_TABS.WORKFLOW;
if (isViewRoute(to.name)) {
if (testDefinitionRoutes.includes(to.name)) {
activeHeaderTab.value = MAIN_HEADER_TABS.TEST_DEFINITION;
}
if (executionRoutes.includes(to.name)) {
activeHeaderTab.value = MAIN_HEADER_TABS.EXECUTIONS;
}
if (workflowRoutes.includes(to.name)) {
activeHeaderTab.value = MAIN_HEADER_TABS.WORKFLOW;
}
}

if (to.params.name !== 'new' && typeof to.params.name === 'string') {
Expand Down
Loading
Loading