feat(editor): "Executing" state in the output panel (#15470)

This commit is contained in:
Suguru Inoue
2025-05-19 17:21:39 +02:00
committed by GitHub
parent 8fd0738191
commit 7e3bcd3895
12 changed files with 99 additions and 30 deletions

View File

@@ -230,7 +230,7 @@ describe('LogsPanel', () => {
await fireEvent.click(rendered.getByText('Overview')); await fireEvent.click(rendered.getByText('Overview'));
expect(rendered.getByText('Running')).toBeInTheDocument(); expect(rendered.getByText(/Running/)).toBeInTheDocument();
expect(rendered.queryByText('AI Agent')).not.toBeInTheDocument(); expect(rendered.queryByText('AI Agent')).not.toBeInTheDocument();
workflowsStore.addNodeExecutionStartedData({ workflowsStore.addNodeExecutionStartedData({
@@ -247,7 +247,7 @@ describe('LogsPanel', () => {
}); });
expect(lastTreeItem.getByText('AI Agent')).toBeInTheDocument(); expect(lastTreeItem.getByText('AI Agent')).toBeInTheDocument();
expect(lastTreeItem.getByText('Running')).toBeInTheDocument(); expect(lastTreeItem.getByText(/Running/)).toBeInTheDocument();
workflowsStore.updateNodeExecutionData({ workflowsStore.updateNodeExecutionData({
nodeName: 'AI Agent', nodeName: 'AI Agent',

View File

@@ -108,6 +108,24 @@ describe('LogDetailsPanel', () => {
expect(await outputPanel.findByText('Hello!')).toBeInTheDocument(); expect(await outputPanel.findByText('Hello!')).toBeInTheDocument();
}); });
it('should show a message in the output panel and data in the input panel when node is running', async () => {
const rendered = render({
isOpen: true,
logEntry: createLogEntry({
node: aiNode,
runIndex: 0,
runData: { ...aiNodeRunData, executionStatus: 'running' },
}),
panels: LOG_DETAILS_PANEL_STATE.BOTH,
});
const inputPanel = within(rendered.getByTestId('log-details-input'));
const outputPanel = within(rendered.getByTestId('log-details-output'));
expect(await inputPanel.findByText('hey')).toBeInTheDocument();
expect(await outputPanel.findByText('Executing node...')).toBeInTheDocument();
});
it('should close input panel by dragging the divider to the left end', async () => { it('should close input panel by dragging the divider to the left end', async () => {
const rendered = render({ const rendered = render({
isOpen: true, isOpen: true,

View File

@@ -88,6 +88,7 @@ function handleResizeEnd() {
:class="$style.executionSummary" :class="$style.executionSummary"
:status="logEntry.runData.executionStatus ?? 'unknown'" :status="logEntry.runData.executionStatus ?? 'unknown'"
:consumed-tokens="consumedTokens" :consumed-tokens="consumedTokens"
:start-time="logEntry.runData.startTime"
:time-took="logEntry.runData.executionTime" :time-took="logEntry.runData.executionTime"
/> />
</div> </div>

View File

@@ -163,6 +163,7 @@ watch(
:class="$style.summary" :class="$style.summary"
:status="execution.status" :status="execution.status"
:consumed-tokens="consumedTokens" :consumed-tokens="consumedTokens"
:start-time="+new Date(execution.startedAt)"
:time-took=" :time-took="
execution.startedAt && execution.stoppedAt execution.startedAt && execution.stoppedAt
? +new Date(execution.stoppedAt) - +new Date(execution.startedAt) ? +new Date(execution.stoppedAt) - +new Date(execution.startedAt)
@@ -247,6 +248,8 @@ watch(
.tree { .tree {
padding: 0 var(--spacing-2xs) var(--spacing-2xs) var(--spacing-2xs); padding: 0 var(--spacing-2xs) var(--spacing-2xs) var(--spacing-2xs);
scroll-padding-block: var(--spacing-3xs);
& :global(.el-icon) { & :global(.el-icon) {
display: none; display: none;
} }

View File

@@ -13,6 +13,7 @@ import {
type LatestNodeInfo, type LatestNodeInfo,
type LogEntry, type LogEntry,
} from '@/components/RunDataAi/utils'; } from '@/components/RunDataAi/utils';
import { useTimestamp } from '@vueuse/core';
const props = defineProps<{ const props = defineProps<{
data: LogEntry; data: LogEntry;
@@ -34,12 +35,13 @@ const emit = defineEmits<{
const container = useTemplateRef('containerRef'); const container = useTemplateRef('containerRef');
const locale = useI18n(); const locale = useI18n();
const now = useTimestamp({ interval: 1000 });
const nodeTypeStore = useNodeTypesStore(); const nodeTypeStore = useNodeTypesStore();
const type = computed(() => nodeTypeStore.getNodeType(props.data.node.type)); const type = computed(() => nodeTypeStore.getNodeType(props.data.node.type));
const isSettled = computed( const isSettled = computed(
() => () =>
props.data.runData.executionStatus && props.data.runData.executionStatus &&
['crashed', 'error', 'success'].includes(props.data.runData.executionStatus), !['running', 'waiting'].includes(props.data.runData.executionStatus),
); );
const isError = computed(() => !!props.data.runData.error); const isError = computed(() => !!props.data.runData.error);
const startedAtText = computed(() => { const startedAtText = computed(() => {
@@ -51,6 +53,15 @@ const startedAtText = computed(() => {
}, },
}); });
}); });
const statusText = computed(() => upperFirst(props.data.runData.executionStatus));
const timeText = computed(() =>
locale.displayTimer(
isSettled.value
? props.data.runData.executionTime
: Math.floor((now.value - props.data.runData.startTime) / 1000) * 1000,
true,
),
);
const subtreeConsumedTokens = computed(() => const subtreeConsumedTokens = computed(() =>
props.shouldShowTokenCountColumn ? getSubtreeTotalConsumedTokens(props.data, false) : undefined, props.shouldShowTokenCountColumn ? getSubtreeTotalConsumedTokens(props.data, false) : undefined,
@@ -126,19 +137,24 @@ watch(
:is-deleted="latestInfo?.deleted ?? false" :is-deleted="latestInfo?.deleted ?? false"
/> />
<N8nText v-if="!isCompact" tag="div" color="text-light" size="small" :class="$style.timeTook"> <N8nText v-if="!isCompact" tag="div" color="text-light" size="small" :class="$style.timeTook">
<I18nT v-if="isSettled" keypath="logs.overview.body.summaryText"> <I18nT v-if="isSettled" keypath="logs.overview.body.summaryText.in">
<template #status> <template #status>
<N8nText v-if="isError" color="danger" :bold="true" size="small"> <N8nText v-if="isError" color="danger" :bold="true" size="small">
<N8nIcon icon="exclamation-triangle" :class="$style.errorIcon" />{{ <N8nIcon icon="exclamation-triangle" :class="$style.errorIcon" />
upperFirst(props.data.runData.executionStatus) {{ statusText }}
}}
</N8nText> </N8nText>
<template v-else>{{ upperFirst(props.data.runData.executionStatus) }}</template> <template v-else>{{ statusText }}</template>
</template> </template>
<template #time>{{ locale.displayTimer(props.data.runData.executionTime, true) }}</template> <template #time>{{ timeText }}</template>
</I18nT> </I18nT>
<template v-else>{{ upperFirst(props.data.runData.executionStatus) }}</template></N8nText <template v-else>
> {{
locale.baseText('logs.overview.body.summaryText.for', {
interpolate: { status: statusText, time: timeText },
})
}}
</template>
</N8nText>
<N8nText <N8nText
v-if="!isCompact" v-if="!isCompact"
tag="div" tag="div"

View File

@@ -3,26 +3,36 @@ import LogsViewConsumedTokenCountText from '@/components/CanvasChat/future/compo
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { type LlmTokenUsageData } from '@/Interface'; import { type LlmTokenUsageData } from '@/Interface';
import { N8nText } from '@n8n/design-system'; import { N8nText } from '@n8n/design-system';
import { useTimestamp } from '@vueuse/core';
import { upperFirst } from 'lodash-es'; import { upperFirst } from 'lodash-es';
import { type ExecutionStatus } from 'n8n-workflow'; import { type ExecutionStatus } from 'n8n-workflow';
import { computed } from 'vue'; import { computed } from 'vue';
const { status, consumedTokens, timeTook } = defineProps<{ const { status, consumedTokens, startTime, timeTook } = defineProps<{
status: ExecutionStatus; status: ExecutionStatus;
consumedTokens: LlmTokenUsageData; consumedTokens: LlmTokenUsageData;
startTime: number;
timeTook?: number; timeTook?: number;
}>(); }>();
const locale = useI18n(); const locale = useI18n();
const now = useTimestamp({ interval: 1000 });
const executionStatusText = computed(() => const executionStatusText = computed(() =>
timeTook === undefined status === 'running' || status === 'waiting'
? upperFirst(status) ? locale.baseText('logs.overview.body.summaryText.for', {
: locale.baseText('logs.overview.body.summaryText', {
interpolate: { interpolate: {
status: upperFirst(status), status: upperFirst(status),
time: locale.displayTimer(timeTook, true), time: locale.displayTimer(Math.floor((now.value - startTime) / 1000) * 1000, true),
}, },
}), })
: timeTook === undefined
? upperFirst(status)
: locale.baseText('logs.overview.body.summaryText.in', {
interpolate: {
status: upperFirst(status),
time: locale.displayTimer(timeTook, true),
},
}),
); );
</script> </script>

View File

@@ -4,6 +4,7 @@ import { type LogEntry } from '@/components/RunDataAi/utils';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { type IRunDataDisplayMode, type NodePanelType } from '@/Interface'; import { type IRunDataDisplayMode, type NodePanelType } from '@/Interface';
import { useNDVStore } from '@/stores/ndv.store'; import { useNDVStore } from '@/stores/ndv.store';
import { waitingNodeTooltip } from '@/utils/executionUtils';
import { N8nLink, N8nText } from '@n8n/design-system'; import { N8nLink, N8nText } from '@n8n/design-system';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { I18nT } from 'vue-i18n'; import { I18nT } from 'vue-i18n';
@@ -42,6 +43,12 @@ const runDataProps = computed<
overrideOutputs: [source.previousNodeOutput ?? 0], overrideOutputs: [source.previousNodeOutput ?? 0],
}; };
}); });
const isExecuting = computed(
() =>
paneType === 'output' &&
(logEntry.runData.executionStatus === 'running' ||
logEntry.runData.executionStatus === 'waiting'),
);
function handleClickOpenNdv() { function handleClickOpenNdv() {
ndvStore.setActiveNodeName(logEntry.node.name); ndvStore.setActiveNodeName(logEntry.node.name);
@@ -69,6 +76,7 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) {
:disable-hover-highlight="true" :disable-hover-highlight="true"
:display-mode="displayMode" :display-mode="displayMode"
:disable-ai-content="logEntry.depth === 0" :disable-ai-content="logEntry.depth === 0"
:is-executing="isExecuting"
table-header-bg-color="light" table-header-bg-color="light"
@display-mode-change="handleChangeDisplayMode" @display-mode-change="handleChangeDisplayMode"
> >
@@ -84,6 +92,13 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) {
</N8nText> </N8nText>
</template> </template>
<template #node-waiting>
<N8nText :bold="true" color="text-dark" size="large">
{{ locale.baseText('ndv.output.waitNodeWaiting.title') }}
</N8nText>
<N8nText v-n8n-html="waitingNodeTooltip(logEntry.node)"></N8nText>
</template>
<template v-if="isMultipleInput" #content> <template v-if="isMultipleInput" #content>
<!-- leave empty --> <!-- leave empty -->
</template> </template>

View File

@@ -507,7 +507,9 @@ function activatePane() {
</template> </template>
<template #node-waiting> <template #node-waiting>
<N8nText :bold="true" color="text-dark" size="large">Waiting for input</N8nText> <N8nText :bold="true" color="text-dark" size="large">
{{ i18n.baseText('ndv.output.waitNodeWaiting.title') }}
</N8nText>
<N8nText v-n8n-html="waitingMessage"></N8nText> <N8nText v-n8n-html="waitingMessage"></N8nText>
</template> </template>

View File

@@ -402,7 +402,9 @@ const activatePane = () => {
</template> </template>
<template #node-waiting> <template #node-waiting>
<N8nText :bold="true" color="text-dark" size="large">Waiting for input</N8nText> <N8nText :bold="true" color="text-dark" size="large">
{{ i18n.baseText('ndv.output.waitNodeWaiting.title') }}
</N8nText>
<N8nText v-n8n-html="waitingNodeTooltip(node)"></N8nText> <N8nText v-n8n-html="waitingNodeTooltip(node)"></N8nText>
</template> </template>

View File

@@ -1005,7 +1005,8 @@
"logs.overview.header.switch.overview": "Overview", "logs.overview.header.switch.overview": "Overview",
"logs.overview.body.empty.message": "Nothing to display yet. Execute the workflow to see execution logs.", "logs.overview.body.empty.message": "Nothing to display yet. Execute the workflow to see execution logs.",
"logs.overview.body.empty.action": "Execute the workflow", "logs.overview.body.empty.action": "Execute the workflow",
"logs.overview.body.summaryText": "{status} in {time}", "logs.overview.body.summaryText.for": "{status} for {time}",
"logs.overview.body.summaryText.in": "{status} in {time}",
"logs.overview.body.started": "Started {time}", "logs.overview.body.started": "Started {time}",
"logs.overview.body.run": "Execute step", "logs.overview.body.run": "Execute step",
"logs.overview.body.open": "Open...", "logs.overview.body.open": "Open...",
@@ -1136,11 +1137,12 @@
"ndv.output.run": "Run", "ndv.output.run": "Run",
"ndv.output.runNodeHint": "Execute this node to view data", "ndv.output.runNodeHint": "Execute this node to view data",
"ndv.output.runNodeHintSubNode": "Output will appear here once the parent node is run", "ndv.output.runNodeHintSubNode": "Output will appear here once the parent node is run",
"ndv.output.waitNodeWaitingForWebhook": "Execution will continue when webhook is received on ",
"ndv.output.githubNodeWaitingForWebhook": "Execution will continue when the following webhook URL is called: ", "ndv.output.githubNodeWaitingForWebhook": "Execution will continue when the following webhook URL is called: ",
"ndv.output.sendAndWaitWaitingApproval": "Execution will continue after the user's response", "ndv.output.sendAndWaitWaitingApproval": "Execution will continue after the user's response",
"ndv.output.waitNodeWaitingForFormSubmission": "Execution will continue when form is submitted on ", "ndv.output.waitNodeWaiting.title": "Waiting for input",
"ndv.output.waitNodeWaiting": "Execution will continue when wait time is over", "ndv.output.waitNodeWaiting.description.webhook": "Execution will continue when webhook is received on ",
"ndv.output.waitNodeWaiting.description.form": "Execution will continue when form is submitted on ",
"ndv.output.waitNodeWaiting.description.timer": "Execution will continue when wait time is over",
"ndv.output.insertTestData": "set mock data", "ndv.output.insertTestData": "set mock data",
"ndv.output.staleDataWarning.regular": "Node parameters have changed.<br>Test node again to refresh output.", "ndv.output.staleDataWarning.regular": "Node parameters have changed.<br>Test node again to refresh output.",
"ndv.output.staleDataWarning.pinData": "Node parameter changes will not affect pinned output data.", "ndv.output.staleDataWarning.pinData": "Node parameter changes will not affect pinned output data.",

View File

@@ -33,9 +33,9 @@ vi.mock('@/plugins/i18n', () => ({
i18n: { i18n: {
baseText: (key: string, options?: { interpolate?: { error?: string; details?: string } }) => { baseText: (key: string, options?: { interpolate?: { error?: string; details?: string } }) => {
const texts: { [key: string]: string } = { const texts: { [key: string]: string } = {
'ndv.output.waitNodeWaiting': 'Waiting for execution to resume...', 'ndv.output.waitNodeWaiting.description.timer': 'Waiting for execution to resume...',
'ndv.output.waitNodeWaitingForFormSubmission': 'Waiting for form submission: ', 'ndv.output.waitNodeWaiting.description.form': 'Waiting for form submission: ',
'ndv.output.waitNodeWaitingForWebhook': 'Waiting for webhook call: ', 'ndv.output.waitNodeWaiting.description.webhook': 'Waiting for webhook call: ',
'ndv.output.githubNodeWaitingForWebhook': 'Waiting for webhook call: ', 'ndv.output.githubNodeWaitingForWebhook': 'Waiting for webhook call: ',
'ndv.output.sendAndWaitWaitingApproval': 'Waiting for approval...', 'ndv.output.sendAndWaitWaitingApproval': 'Waiting for approval...',
'pushConnection.executionError': `Execution error${options?.interpolate?.error}`, 'pushConnection.executionError': `Execution error${options?.interpolate?.error}`,

View File

@@ -157,7 +157,7 @@ export const waitingNodeTooltip = (node: INodeUi | null | undefined) => {
} }
if (resume) { if (resume) {
if (!['webhook', 'form'].includes(resume as string)) { if (!['webhook', 'form'].includes(resume as string)) {
return i18n.baseText('ndv.output.waitNodeWaiting'); return i18n.baseText('ndv.output.waitNodeWaiting.description.timer');
} }
const { webhookSuffix } = (node.parameters.options ?? {}) as { webhookSuffix: string }; const { webhookSuffix } = (node.parameters.options ?? {}) as { webhookSuffix: string };
@@ -168,12 +168,12 @@ export const waitingNodeTooltip = (node: INodeUi | null | undefined) => {
if (resume === 'form') { if (resume === 'form') {
resumeUrl = `${useRootStore().formWaitingUrl}/${useWorkflowsStore().activeExecutionId}${suffix}`; resumeUrl = `${useRootStore().formWaitingUrl}/${useWorkflowsStore().activeExecutionId}${suffix}`;
message = i18n.baseText('ndv.output.waitNodeWaitingForFormSubmission'); message = i18n.baseText('ndv.output.waitNodeWaiting.description.form');
} }
if (resume === 'webhook') { if (resume === 'webhook') {
resumeUrl = `${useRootStore().webhookWaitingUrl}/${useWorkflowsStore().activeExecutionId}${suffix}`; resumeUrl = `${useRootStore().webhookWaitingUrl}/${useWorkflowsStore().activeExecutionId}${suffix}`;
message = i18n.baseText('ndv.output.waitNodeWaitingForWebhook'); message = i18n.baseText('ndv.output.waitNodeWaiting.description.webhook');
} }
if (message && resumeUrl) { if (message && resumeUrl) {
@@ -182,7 +182,7 @@ export const waitingNodeTooltip = (node: INodeUi | null | undefined) => {
} }
if (node?.type === FORM_NODE_TYPE) { if (node?.type === FORM_NODE_TYPE) {
const message = i18n.baseText('ndv.output.waitNodeWaitingForFormSubmission'); const message = i18n.baseText('ndv.output.waitNodeWaiting.description.form');
const resumeUrl = `${useRootStore().formWaitingUrl}/${useWorkflowsStore().activeExecutionId}`; const resumeUrl = `${useRootStore().formWaitingUrl}/${useWorkflowsStore().activeExecutionId}`;
return `${message}<a href="${resumeUrl}" target="_blank">${resumeUrl}</a>`; return `${message}<a href="${resumeUrl}" target="_blank">${resumeUrl}</a>`;
} }