mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): Log view improvements (#16489)
This commit is contained in:
@@ -194,6 +194,7 @@ describe('Logs', () => {
|
||||
it('should show logs for a workflow with a node that waits for webhook', () => {
|
||||
workflow.navigateToNewWorkflowPage();
|
||||
workflow.pasteWorkflow(Workflow_wait_for_webhook);
|
||||
workflow.getCanvas().click('topLeft'); // click canvas to deselect nodes
|
||||
workflow.clickZoomToFit();
|
||||
logs.openLogsPanel();
|
||||
|
||||
@@ -202,7 +203,6 @@ describe('Logs', () => {
|
||||
workflow.getNodesWithSpinner().should('contain.text', 'Wait');
|
||||
workflow.getWaitingNodes().should('contain.text', 'Wait');
|
||||
logs.getLogEntries().should('have.length', 2);
|
||||
logs.getLogEntries().eq(0).click(); // click selected row to deselect
|
||||
logs.getLogEntries().eq(1).should('contain.text', 'Wait node');
|
||||
logs.getLogEntries().eq(1).should('contain.text', 'Waiting');
|
||||
|
||||
@@ -224,6 +224,7 @@ describe('Logs', () => {
|
||||
.getOverviewStatus()
|
||||
.contains(/Success in [\d\.]+m?s/)
|
||||
.should('exist');
|
||||
logs.getLogEntries().eq(1).click(); // click selected row to deselect
|
||||
logs.getLogEntries().should('have.length', 2);
|
||||
logs.getLogEntries().eq(1).should('contain.text', 'Wait node');
|
||||
logs.getLogEntries().eq(1).should('contain.text', 'Success');
|
||||
|
||||
@@ -215,7 +215,7 @@
|
||||
"chat.window.logsFromNode": "from {nodeName} node",
|
||||
"chat.window.noChatNode": "No Chat Node",
|
||||
"chat.window.noExecution": "Nothing got executed yet",
|
||||
"chat.window.chat.placeholder": "Type a message, or press ‘up’ arrow for previous one",
|
||||
"chat.window.chat.placeholder": "Type message, or press ‘up’ for prev one",
|
||||
"chat.window.chat.placeholderPristine": "Type a message",
|
||||
"chat.window.chat.sendButtonText": "Send",
|
||||
"chat.window.chat.provideMessage": "Please provide a message",
|
||||
|
||||
@@ -1601,10 +1601,12 @@ defineExpose({ enterEditMode });
|
||||
<div ref="dataContainerRef" :class="$style.dataContainer" data-test-id="ndv-data-container">
|
||||
<div
|
||||
v-if="isExecuting && !isWaitNodeWaiting"
|
||||
:class="$style.center"
|
||||
:class="[$style.center, $style.executingMessage]"
|
||||
data-test-id="ndv-executing"
|
||||
>
|
||||
<div :class="$style.spinner"><N8nSpinner type="ring" /></div>
|
||||
<div v-if="!props.compact" :class="$style.spinner">
|
||||
<N8nSpinner type="ring" />
|
||||
</div>
|
||||
<N8nText>{{ executingMessage }}</N8nText>
|
||||
</div>
|
||||
|
||||
@@ -2302,6 +2304,12 @@ defineExpose({ enterEditMode });
|
||||
}
|
||||
}
|
||||
|
||||
.executingMessage {
|
||||
.compact & {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 240px) {
|
||||
/* Hide title when the panel is too narrow */
|
||||
.compact:hover .title {
|
||||
|
||||
@@ -175,6 +175,12 @@ function onCopyToClipboard(object: IDataObject | IDataObject[]) {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
.compact & {
|
||||
line-height: var(--font-line-height-xloose);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +208,7 @@ function onCopyToClipboard(object: IDataObject | IDataObject[]) {
|
||||
.compact & {
|
||||
padding-top: 0;
|
||||
padding-inline: var(--spacing-2xs);
|
||||
font-size: var(--font-size-xs);
|
||||
font-size: var(--font-size-2xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useActiveElement, useEventListener } from '@vueuse/core';
|
||||
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
|
||||
import type { MaybeRef, Ref } from 'vue';
|
||||
import { computed, inject, unref } from 'vue';
|
||||
import { computed, inject, ref, unref } from 'vue';
|
||||
import { PiPWindowSymbol } from '@/constants';
|
||||
|
||||
type KeyboardEventHandler =
|
||||
@@ -30,7 +30,7 @@ export const useKeybindings = (
|
||||
disabled: MaybeRef<boolean>;
|
||||
},
|
||||
) => {
|
||||
const pipWindow = inject(PiPWindowSymbol);
|
||||
const pipWindow = inject(PiPWindowSymbol, ref<Window | undefined>());
|
||||
const activeElement = useActiveElement({ window: pipWindow?.value });
|
||||
const { isCtrlKeyPressed } = useDeviceSupport();
|
||||
|
||||
|
||||
@@ -482,7 +482,7 @@ export const LOCAL_STORAGE_HIDE_GITHUB_STAR_BUTTON = 'N8N_HIDE_HIDE_GITHUB_STAR_
|
||||
export const LOCAL_STORAGE_NDV_INPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_INPUT_PANEL_DISPLAY_MODE';
|
||||
export const LOCAL_STORAGE_NDV_OUTPUT_PANEL_DISPLAY_MODE = 'N8N_NDV_OUTPUT_PANEL_DISPLAY_MODE';
|
||||
export const LOCAL_STORAGE_LOGS_PANEL_OPEN = 'N8N_LOGS_PANEL_OPEN';
|
||||
export const LOCAL_STORAGE_LOGS_SYNC_SELECTION = 'N8N_LOGS_SYNC_SELECTION';
|
||||
export const LOCAL_STORAGE_LOGS_SYNC_SELECTION = 'N8N_LOGS_SYNC_SELECTION_ENABLED';
|
||||
export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL = 'N8N_LOGS_DETAILS_PANEL';
|
||||
export const LOCAL_STORAGE_LOGS_PANEL_DETAILS_PANEL_SUB_NODE = 'N8N_LOGS_DETAILS_PANEL_SUB_NODE';
|
||||
export const LOCAL_STORAGE_WORKFLOW_LIST_PREFERENCES_KEY = 'N8N_WORKFLOWS_LIST_PREFERENCES';
|
||||
|
||||
@@ -20,7 +20,7 @@ export function createTestLogTreeCreationContext(
|
||||
workflows: {},
|
||||
subWorkflowData: {},
|
||||
executionId: 'test-execution-id',
|
||||
depth: 0,
|
||||
ancestorRunIndexes: [],
|
||||
data: {
|
||||
resultData: {
|
||||
runData,
|
||||
|
||||
@@ -17,7 +17,6 @@ export function createTestLogEntry(data: Partial<LogEntry> = {}): LogEntry {
|
||||
id: uuid(),
|
||||
children: [],
|
||||
consumedTokens: { completionTokens: 0, totalTokens: 0, promptTokens: 0, isEstimate: false },
|
||||
depth: 0,
|
||||
workflow: createTestWorkflowObject(),
|
||||
executionId,
|
||||
execution: createTestWorkflowExecutionResponse({ id: executionId }).data!,
|
||||
|
||||
@@ -255,7 +255,7 @@ async function copySessionId() {
|
||||
.chat {
|
||||
--chat--spacing: var(--spacing-xs);
|
||||
--chat--message--padding: var(--spacing-2xs);
|
||||
--chat--message--font-size: var(--font-size-xs);
|
||||
--chat--message--font-size: var(--font-size-2xs);
|
||||
--chat--input--font-size: var(--font-size-s);
|
||||
--chat--input--placeholder--font-size: var(--font-size-xs);
|
||||
--chat--message--bot--background: transparent;
|
||||
@@ -269,7 +269,10 @@ async function copySessionId() {
|
||||
--chat--color-typing: var(--color-text-light);
|
||||
--chat--textarea--max-height: calc(var(--panel-height) * 0.3);
|
||||
--chat--message--pre--background: var(--color-foreground-light);
|
||||
--chat--textarea--height: 2.5rem;
|
||||
--chat--textarea--height: calc(
|
||||
var(--chat--input--padding) * 2 + var(--chat--input--font-size) *
|
||||
var(--chat--input--line-height)
|
||||
);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -381,4 +384,12 @@ async function copySessionId() {
|
||||
--input-border-color: #4538a3;
|
||||
}
|
||||
}
|
||||
|
||||
.messagesHistory {
|
||||
height: var(--chat--textarea--height);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -93,7 +93,8 @@ describe('LogsOverviewPanel', () => {
|
||||
const row1 = within(tree.queryAllByRole('treeitem')[0]);
|
||||
|
||||
expect(row1.queryByText('AI Agent')).toBeInTheDocument();
|
||||
expect(row1.queryByText('Success in 1.778s')).toBeInTheDocument();
|
||||
expect(row1.queryByText('Success')).toBeInTheDocument();
|
||||
expect(row1.queryByText('in 1.778s')).toBeInTheDocument();
|
||||
expect(row1.queryByText('Started 00:00:00.002, 26 Mar')).toBeInTheDocument();
|
||||
|
||||
const row2 = within(tree.queryAllByRole('treeitem')[1]);
|
||||
|
||||
@@ -8,14 +8,11 @@ import LogsOverviewRow from '@/features/logs/components/LogsOverviewRow.vue';
|
||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||
import { useRouter } from 'vue-router';
|
||||
import LogsViewExecutionSummary from '@/features/logs/components/LogsViewExecutionSummary.vue';
|
||||
import {
|
||||
getSubtreeTotalConsumedTokens,
|
||||
getTotalConsumedTokens,
|
||||
hasSubExecution,
|
||||
} from '@/features/logs/logs.utils';
|
||||
import { getSubtreeTotalConsumedTokens, getTotalConsumedTokens } from '@/features/logs/logs.utils';
|
||||
import { useVirtualList } from '@vueuse/core';
|
||||
import { type IExecutionResponse } from '@/Interface';
|
||||
import type { LatestNodeInfo, LogEntry } from '@/features/logs/logs.types';
|
||||
import { getScrollbarWidth } from '@/utils/htmlUtils';
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
@@ -43,7 +40,6 @@ const emit = defineEmits<{
|
||||
clearExecutionData: [];
|
||||
openNdv: [LogEntry];
|
||||
toggleExpanded: [LogEntry];
|
||||
loadSubExecution: [LogEntry];
|
||||
}>();
|
||||
|
||||
defineSlots<{ actions: {} }>();
|
||||
@@ -57,6 +53,7 @@ const switchViewOptions = computed(() => [
|
||||
{ label: locale.baseText('logs.overview.header.switch.overview'), value: 'overview' as const },
|
||||
{ label: locale.baseText('logs.overview.header.switch.details'), value: 'details' as const },
|
||||
]);
|
||||
const hasStaticScrollbar = getScrollbarWidth() > 0;
|
||||
const consumedTokens = computed(() =>
|
||||
getTotalConsumedTokens(
|
||||
...entries.map((entry) =>
|
||||
@@ -73,6 +70,12 @@ const shouldShowTokenCountColumn = computed(
|
||||
consumedTokens.value.totalTokens > 0 ||
|
||||
entries.some((entry) => getSubtreeTotalConsumedTokens(entry, true).totalTokens > 0),
|
||||
);
|
||||
const isExpanded = computed(() =>
|
||||
flatLogEntries.reduce<Record<string, boolean>>((acc, entry, index, arr) => {
|
||||
acc[entry.id] = arr[index + 1]?.parent?.id === entry.id;
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
const virtualList = useVirtualList(
|
||||
toRef(() => flatLogEntries),
|
||||
{ itemHeight: 32 },
|
||||
@@ -82,14 +85,6 @@ function handleSwitchView(value: 'overview' | 'details') {
|
||||
emit('select', value === 'overview' ? undefined : flatLogEntries[0]);
|
||||
}
|
||||
|
||||
function handleToggleExpanded(treeNode: LogEntry) {
|
||||
if (hasSubExecution(treeNode) && treeNode.children.length === 0) {
|
||||
emit('loadSubExecution', treeNode);
|
||||
return;
|
||||
}
|
||||
emit('toggleExpanded', treeNode);
|
||||
}
|
||||
|
||||
async function handleTriggerPartialExecution(treeNode: LogEntry) {
|
||||
const latestName = latestNodeInfo[treeNode.node.id]?.name ?? treeNode.node.name;
|
||||
|
||||
@@ -98,25 +93,46 @@ async function handleTriggerPartialExecution(treeNode: LogEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
// While executing, scroll to the bottom if there's no selection
|
||||
watch(
|
||||
[() => execution?.status === 'running', () => flatLogEntries.length],
|
||||
async ([isRunning, flatEntryCount], [wasRunning]) => {
|
||||
await nextTick(() => {
|
||||
if (selected === undefined && (isRunning || wasRunning)) {
|
||||
virtualList.scrollTo(flatEntryCount - 1);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Scroll selected row into view
|
||||
watch(
|
||||
() => selected,
|
||||
async (selection) => {
|
||||
if (selection && virtualList.list.value.every((e) => e.data.id !== selection.id)) {
|
||||
const index = flatLogEntries.findIndex((e) => e.id === selection?.id);
|
||||
() => selected?.id,
|
||||
async (selectedId) => {
|
||||
await nextTick(() => {
|
||||
if (selectedId === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = virtualList.list.value.some((e) => e.data.id === selectedId)
|
||||
? -1
|
||||
: flatLogEntries.findIndex((e) => e.id === selectedId);
|
||||
|
||||
if (index >= 0) {
|
||||
// Wait for the node to be added to the list, and then scroll
|
||||
await nextTick(() => virtualList.scrollTo(index));
|
||||
virtualList.scrollTo(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container" data-test-id="logs-overview">
|
||||
<div
|
||||
:class="[$style.container, hasStaticScrollbar ? $style.staticScrollBar : '']"
|
||||
data-test-id="logs-overview"
|
||||
>
|
||||
<LogsPanelHeader
|
||||
:title="locale.baseText('logs.overview.header.title')"
|
||||
data-test-id="logs-overview-header"
|
||||
@@ -180,9 +196,9 @@ watch(
|
||||
:is-compact="isCompact"
|
||||
:should-show-token-count-column="shouldShowTokenCountColumn"
|
||||
:latest-info="latestNodeInfo[data.node.id]"
|
||||
:expanded="virtualList.list.value[index + 1]?.data.parent?.id === data.id"
|
||||
:expanded="isExpanded[data.id]"
|
||||
:can-open-ndv="data.executionId === execution?.id"
|
||||
@toggle-expanded="handleToggleExpanded(data)"
|
||||
@toggle-expanded="emit('toggleExpanded', data)"
|
||||
@open-ndv="emit('openNdv', data)"
|
||||
@trigger-partial-execution="handleTriggerPartialExecution(data)"
|
||||
@toggle-selected="emit('select', selected?.id === data.id ? undefined : data)"
|
||||
@@ -228,6 +244,7 @@ watch(
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
padding-right: var(--spacing-5xs);
|
||||
|
||||
&.empty {
|
||||
align-items: center;
|
||||
@@ -247,7 +264,30 @@ watch(
|
||||
.tree {
|
||||
padding: 0 var(--spacing-2xs) var(--spacing-2xs) var(--spacing-2xs);
|
||||
|
||||
scroll-padding-block: var(--spacing-3xs);
|
||||
.container:not(.staticScrollBar) & {
|
||||
scroll-padding-block: var(--spacing-3xs);
|
||||
|
||||
@supports not (selector(::-webkit-scrollbar)) {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
padding-right: var(--spacing-5xs);
|
||||
scrollbar-gutter: stable;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: var(--spacing-4xs);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
border-radius: var(--spacing-4xs);
|
||||
background: var(--color-foreground-dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* For programmatically triggered scroll in useVirtualList to animate, make it scroll smoothly */
|
||||
scroll-behavior: smooth;
|
||||
|
||||
& :global(.el-icon) {
|
||||
display: none;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { N8nButton, N8nIcon, N8nIconButton, N8nText } from '@n8n/design-system';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import LogsViewConsumedTokenCountText from '@/features/logs/components/LogsViewConsumedTokenCountText.vue';
|
||||
import upperFirst from 'lodash/upperFirst';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { type BaseTextKey, useI18n } from '@n8n/i18n';
|
||||
import { I18nT } from 'vue-i18n';
|
||||
import { toDayMonth, toTime } from '@/utils/formatters/dateFormatter';
|
||||
import LogsViewNodeName from '@/features/logs/components/LogsViewNodeName.vue';
|
||||
@@ -35,12 +35,13 @@ const locale = useI18n();
|
||||
const now = useTimestamp({ interval: 1000 });
|
||||
const nodeTypeStore = useNodeTypesStore();
|
||||
const type = computed(() => nodeTypeStore.getNodeType(props.data.node.type));
|
||||
const isSettled = computed(
|
||||
() =>
|
||||
props.data.runData?.executionStatus &&
|
||||
!['running', 'waiting'].includes(props.data.runData.executionStatus),
|
||||
);
|
||||
const isRunning = computed(() => props.data.runData?.executionStatus === 'running');
|
||||
const isWaiting = computed(() => props.data.runData?.executionStatus === 'waiting');
|
||||
const isSettled = computed(() => !isRunning.value && !isWaiting.value);
|
||||
const isError = computed(() => !!props.data.runData?.error);
|
||||
const statusTextKeyPath = computed<BaseTextKey>(() =>
|
||||
isSettled.value ? 'logs.overview.body.summaryText.in' : 'logs.overview.body.summaryText.for',
|
||||
);
|
||||
const startedAtText = computed(() => {
|
||||
if (props.data.runData === undefined) {
|
||||
return '—';
|
||||
@@ -72,23 +73,21 @@ const subtreeConsumedTokens = computed(() =>
|
||||
|
||||
const hasChildren = computed(() => props.data.children.length > 0 || hasSubExecution(props.data));
|
||||
|
||||
function isLastChild(level: number) {
|
||||
let parent = props.data.parent;
|
||||
let data: LogEntry | undefined = props.data;
|
||||
const indents = computed(() => {
|
||||
const ret: Array<{ straight: boolean; curved: boolean }> = [];
|
||||
|
||||
for (let i = 0; i < props.data.depth - level; i++) {
|
||||
data = parent;
|
||||
parent = parent?.parent;
|
||||
let data: LogEntry = props.data;
|
||||
|
||||
while (data.parent !== undefined) {
|
||||
const siblings = data.parent?.children ?? [];
|
||||
const lastSibling = siblings[siblings.length - 1];
|
||||
|
||||
ret.unshift({ straight: lastSibling?.id !== data.id, curved: data === props.data });
|
||||
data = data.parent;
|
||||
}
|
||||
|
||||
const siblings = parent?.children ?? [];
|
||||
const lastSibling = siblings[siblings.length - 1];
|
||||
|
||||
return (
|
||||
(data === undefined && lastSibling === undefined) ||
|
||||
(data?.node === lastSibling?.node && data?.runIndex === lastSibling?.runIndex)
|
||||
);
|
||||
}
|
||||
return ret;
|
||||
});
|
||||
|
||||
// Focus when selected: For scrolling into view and for keyboard navigation to work
|
||||
watch(
|
||||
@@ -119,16 +118,16 @@ watch(
|
||||
}"
|
||||
@click.stop="emit('toggleSelected')"
|
||||
>
|
||||
<template v-for="level in props.data.depth" :key="level">
|
||||
<div
|
||||
:class="{
|
||||
[$style.indent]: true,
|
||||
[$style.connectorCurved]: level === props.data.depth,
|
||||
[$style.connectorStraight]: !isLastChild(level),
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
<div :class="$style.background" :style="{ '--indent-depth': props.data.depth }" />
|
||||
<div
|
||||
v-for="(indent, level) in indents"
|
||||
:key="level"
|
||||
:class="{
|
||||
[$style.indent]: true,
|
||||
[$style.connectorCurved]: indent.curved,
|
||||
[$style.connectorStraight]: indent.straight,
|
||||
}"
|
||||
/>
|
||||
<div :class="$style.background" :style="{ '--indent-depth': indents.length }" />
|
||||
<NodeIcon :node-type="type" :size="16" :class="$style.icon" />
|
||||
<LogsViewNodeName
|
||||
:class="$style.name"
|
||||
@@ -138,23 +137,17 @@ watch(
|
||||
:is-deleted="latestInfo?.deleted ?? false"
|
||||
/>
|
||||
<N8nText v-if="!isCompact" tag="div" color="text-light" size="small" :class="$style.timeTook">
|
||||
<I18nT v-if="isSettled" keypath="logs.overview.body.summaryText.in">
|
||||
<I18nT v-if="timeText !== undefined" :keypath="statusTextKeyPath">
|
||||
<template #status>
|
||||
<N8nText v-if="isError" color="danger" :bold="true" size="small">
|
||||
<N8nIcon icon="triangle-alert" :class="$style.errorIcon" />
|
||||
<N8nText :color="isError ? 'danger' : undefined" :bold="isError" size="small">
|
||||
<AnimatedSpinner v-if="isRunning" :class="$style.statusTextIcon" />
|
||||
<N8nIcon v-else-if="isWaiting" icon="status-waiting" :class="$style.statusTextIcon" />
|
||||
<N8nIcon v-else-if="isError" icon="triangle-alert" :class="$style.statusTextIcon" />
|
||||
{{ statusText }}
|
||||
</N8nText>
|
||||
<template v-else>{{ statusText }}</template>
|
||||
</template>
|
||||
<template #time>{{ timeText }}</template>
|
||||
</I18nT>
|
||||
<template v-else-if="timeText !== undefined">
|
||||
{{
|
||||
locale.baseText('logs.overview.body.summaryText.for', {
|
||||
interpolate: { status: statusText, time: timeText },
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</N8nText>
|
||||
<N8nText
|
||||
@@ -193,10 +186,8 @@ watch(
|
||||
size="small"
|
||||
icon="square-pen"
|
||||
icon-size="medium"
|
||||
style="color: var(--color-text-base)"
|
||||
:style="{
|
||||
visibility: props.canOpenNdv ? '' : 'hidden',
|
||||
color: 'var(--color-text-base)',
|
||||
}"
|
||||
:disabled="props.latestInfo?.deleted"
|
||||
:class="$style.openNdvButton"
|
||||
@@ -211,12 +202,15 @@ watch(
|
||||
type="secondary"
|
||||
size="small"
|
||||
icon="play"
|
||||
style="color: var(--color-text-base)"
|
||||
:aria-label="locale.baseText('logs.overview.body.run')"
|
||||
:class="[$style.partialExecutionButton, props.data.depth > 0 ? $style.unavailable : '']"
|
||||
:class="[$style.partialExecutionButton, indents.length > 0 ? $style.unavailable : '']"
|
||||
:disabled="props.latestInfo?.deleted || props.latestInfo?.disabled"
|
||||
@click.stop="emit('triggerPartialExecution')"
|
||||
/>
|
||||
<template v-if="isCompact && !hasChildren">
|
||||
<AnimatedSpinner v-if="isRunning" :class="$style.statusIcon" />
|
||||
<N8nIcon v-else-if="isWaiting" icon="status-waiting" :class="$style.statusIcon" />
|
||||
</template>
|
||||
<N8nButton
|
||||
v-if="!isCompact || hasChildren"
|
||||
type="secondary"
|
||||
@@ -226,7 +220,6 @@ watch(
|
||||
:square="true"
|
||||
:style="{
|
||||
visibility: hasChildren ? '' : 'hidden',
|
||||
color: 'var(--color-text-base)', // give higher specificity than the style from the component itself
|
||||
}"
|
||||
:class="$style.toggleButton"
|
||||
:aria-label="locale.baseText('logs.overview.body.toggleRow')"
|
||||
@@ -244,6 +237,7 @@ watch(
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding-inline-end: var(--spacing-5xs);
|
||||
cursor: pointer;
|
||||
|
||||
& > * {
|
||||
overflow: hidden;
|
||||
@@ -322,8 +316,8 @@ watch(
|
||||
flex-shrink: 0;
|
||||
width: 20%;
|
||||
|
||||
.errorIcon {
|
||||
margin-right: var(--spacing-4xs);
|
||||
.statusTextIcon {
|
||||
margin-right: var(--spacing-5xs);
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
}
|
||||
@@ -395,4 +389,17 @@ watch(
|
||||
.toggleButton {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.statusIcon {
|
||||
color: var(--color-text-light);
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: var(--spacing-3xs);
|
||||
|
||||
&.placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -322,7 +322,8 @@ describe('LogsPanel', () => {
|
||||
},
|
||||
});
|
||||
expect(await lastTreeItem.findByText('AI Agent')).toBeInTheDocument();
|
||||
expect(lastTreeItem.getByText('Success in 33ms')).toBeInTheDocument();
|
||||
expect(await lastTreeItem.findByText('Success')).toBeInTheDocument();
|
||||
expect(lastTreeItem.getByText('in 33ms')).toBeInTheDocument();
|
||||
|
||||
workflowsStore.setWorkflowExecutionData({
|
||||
...workflowsStore.workflowExecutionData!,
|
||||
|
||||
@@ -58,7 +58,7 @@ const {
|
||||
|
||||
const { entries, execution, hasChat, latestNodeNameById, resetExecutionData, loadSubExecution } =
|
||||
useLogsExecutionData();
|
||||
const { flatLogEntries, toggleExpanded } = useLogsTreeExpand(entries);
|
||||
const { flatLogEntries, toggleExpanded } = useLogsTreeExpand(entries, loadSubExecution);
|
||||
const { selected, select, selectNext, selectPrev } = useLogsSelection(
|
||||
execution,
|
||||
entries,
|
||||
@@ -171,7 +171,6 @@ function handleOpenNdv(treeNode: LogEntry) {
|
||||
@resizeend="handleResizeOverviewPanelEnd"
|
||||
>
|
||||
<LogsOverviewPanel
|
||||
:key="execution?.id ?? ''"
|
||||
:class="$style.logsOverview"
|
||||
:is-open="isOpen"
|
||||
:is-read-only="isReadOnly"
|
||||
@@ -186,7 +185,6 @@ function handleOpenNdv(treeNode: LogEntry) {
|
||||
@clear-execution-data="resetExecutionData"
|
||||
@toggle-expanded="toggleExpanded"
|
||||
@open-ndv="handleOpenNdv"
|
||||
@load-sub-execution="loadSubExecution"
|
||||
>
|
||||
<template #actions>
|
||||
<LogsPanelActions
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
import { type KeyMap, useKeybindings } from '@/composables/useKeybindings';
|
||||
import { PiPWindowSymbol } from '@/constants';
|
||||
import { useActiveElement } from '@vueuse/core';
|
||||
import { computed, toRef, inject } from 'vue';
|
||||
import { ref, computed, toRef, inject } from 'vue';
|
||||
|
||||
const { container, keyMap } = defineProps<{ keyMap: KeyMap; container: HTMLElement | null }>();
|
||||
const pipWindow = inject(PiPWindowSymbol);
|
||||
const pipWindow = inject(PiPWindowSymbol, ref<Window | undefined>());
|
||||
|
||||
const activeElement = useActiveElement({ window: pipWindow?.value });
|
||||
const isBlurred = computed(() => {
|
||||
@@ -25,3 +25,7 @@ useKeybindings(
|
||||
{ disabled: isBlurred },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { N8nLink, N8nText } from '@n8n/design-system';
|
||||
import { computed, inject, ref } from 'vue';
|
||||
import { I18nT } from 'vue-i18n';
|
||||
import { PiPWindowSymbol } from '@/constants';
|
||||
import { isSubNodeLog } from '../logs.utils';
|
||||
|
||||
const { title, logEntry, paneType } = defineProps<{
|
||||
title: string;
|
||||
@@ -28,7 +29,7 @@ const isMultipleInput = computed(
|
||||
const runDataProps = computed<
|
||||
Pick<InstanceType<typeof RunData>['$props'], 'node' | 'runIndex' | 'overrideOutputs'> | undefined
|
||||
>(() => {
|
||||
if (logEntry.depth > 0 || paneType === 'output') {
|
||||
if (isSubNodeLog(logEntry) || paneType === 'output') {
|
||||
return { node: logEntry.node, runIndex: logEntry.runIndex };
|
||||
}
|
||||
|
||||
@@ -81,7 +82,7 @@ function handleChangeDisplayMode(value: IRunDataDisplayMode) {
|
||||
:disable-edit="true"
|
||||
:disable-hover-highlight="true"
|
||||
:display-mode="displayMode"
|
||||
:disable-ai-content="logEntry.depth === 0"
|
||||
:disable-ai-content="!isSubNodeLog(logEntry)"
|
||||
:is-executing="isExecuting"
|
||||
table-header-bg-color="light"
|
||||
@display-mode-change="handleChangeDisplayMode"
|
||||
|
||||
@@ -12,8 +12,8 @@ import type { IExecutionResponse } from '@/Interface';
|
||||
import { useCanvasStore } from '@/stores/canvas.store';
|
||||
import { useLogsStore } from '@/stores/logs.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { watch } from 'vue';
|
||||
import { computed, ref, type ComputedRef } from 'vue';
|
||||
import { shallowRef, watch } from 'vue';
|
||||
import { computed, type ComputedRef } from 'vue';
|
||||
|
||||
export function useLogsSelection(
|
||||
execution: ComputedRef<IExecutionResponse | undefined>,
|
||||
@@ -22,8 +22,12 @@ export function useLogsSelection(
|
||||
toggleExpand: (entry: LogEntry, expand?: boolean) => void,
|
||||
) {
|
||||
const telemetry = useTelemetry();
|
||||
const manualLogEntrySelection = ref<LogEntrySelection>({ type: 'initial' });
|
||||
const selected = computed(() => findSelectedLogEntry(manualLogEntrySelection.value, tree.value));
|
||||
const manualLogEntrySelection = shallowRef<LogEntrySelection>({ type: 'initial' });
|
||||
const nodeNameToSelect = shallowRef<string>();
|
||||
const isExecutionStopped = computed(() => execution.value?.stoppedAt !== undefined);
|
||||
const selected = computed(() =>
|
||||
findSelectedLogEntry(manualLogEntrySelection.value, tree.value, !isExecutionStopped.value),
|
||||
);
|
||||
const logsStore = useLogsStore();
|
||||
const uiStore = useUIStore();
|
||||
const canvasStore = useCanvasStore();
|
||||
@@ -38,7 +42,7 @@ export function useLogsSelection(
|
||||
|
||||
function select(value: LogEntry | undefined) {
|
||||
manualLogEntrySelection.value =
|
||||
value === undefined ? { type: 'none' } : { type: 'selected', id: value.id };
|
||||
value === undefined ? { type: 'none' } : { type: 'selected', entry: value };
|
||||
|
||||
if (value) {
|
||||
syncSelectionToCanvasIfEnabled(value);
|
||||
@@ -55,21 +59,31 @@ export function useLogsSelection(
|
||||
|
||||
function selectPrev() {
|
||||
const entries = flatLogEntries.value;
|
||||
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevEntry = selected.value
|
||||
? (getEntryAtRelativeIndex(entries, selected.value.id, -1) ?? entries[0])
|
||||
: entries[entries.length - 1];
|
||||
|
||||
manualLogEntrySelection.value = { type: 'selected', id: prevEntry.id };
|
||||
manualLogEntrySelection.value = { type: 'selected', entry: prevEntry };
|
||||
syncSelectionToCanvasIfEnabled(prevEntry);
|
||||
}
|
||||
|
||||
function selectNext() {
|
||||
const entries = flatLogEntries.value;
|
||||
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextEntry = selected.value
|
||||
? (getEntryAtRelativeIndex(entries, selected.value.id, 1) ?? entries[entries.length - 1])
|
||||
: entries[0];
|
||||
|
||||
manualLogEntrySelection.value = { type: 'selected', id: nextEntry.id };
|
||||
manualLogEntrySelection.value = { type: 'selected', entry: nextEntry };
|
||||
syncSelectionToCanvasIfEnabled(nextEntry);
|
||||
}
|
||||
|
||||
@@ -93,16 +107,19 @@ export function useLogsSelection(
|
||||
canvasStore.hasRangeSelection ||
|
||||
selected.value?.node.name === selectedOnCanvas
|
||||
) {
|
||||
nodeNameToSelect.value = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = findLogEntryRec((e) => e.node.name === selectedOnCanvas, tree.value);
|
||||
|
||||
if (!entry) {
|
||||
nodeNameToSelect.value = selectedOnCanvas;
|
||||
return;
|
||||
}
|
||||
|
||||
manualLogEntrySelection.value = { type: 'selected', id: entry.id };
|
||||
nodeNameToSelect.value = undefined;
|
||||
manualLogEntrySelection.value = { type: 'selected', entry };
|
||||
|
||||
let parent = entry.parent;
|
||||
|
||||
@@ -114,5 +131,22 @@ export function useLogsSelection(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
tree,
|
||||
(t) => {
|
||||
if (nodeNameToSelect.value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = findLogEntryRec((e) => e.node.name === nodeNameToSelect.value, t);
|
||||
|
||||
if (entry) {
|
||||
nodeNameToSelect.value = undefined;
|
||||
manualLogEntrySelection.value = { type: 'selected', entry };
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return { selected, select, selectPrev, selectNext };
|
||||
}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { flattenLogEntries } from '@/features/logs/logs.utils';
|
||||
import { computed, ref, type ComputedRef } from 'vue';
|
||||
import { flattenLogEntries, hasSubExecution } from '@/features/logs/logs.utils';
|
||||
import { computed, shallowRef, type ComputedRef } from 'vue';
|
||||
import type { LogEntry } from '../logs.types';
|
||||
|
||||
export function useLogsTreeExpand(entries: ComputedRef<LogEntry[]>) {
|
||||
const collapsedEntries = ref<Record<string, boolean>>({});
|
||||
export function useLogsTreeExpand(
|
||||
entries: ComputedRef<LogEntry[]>,
|
||||
loadSubExecution: (logEntry: LogEntry) => Promise<void>,
|
||||
) {
|
||||
const collapsedEntries = shallowRef<Record<string, boolean>>({});
|
||||
const flatLogEntries = computed(() => flattenLogEntries(entries.value, collapsedEntries.value));
|
||||
|
||||
function toggleExpanded(treeNode: LogEntry, expand?: boolean) {
|
||||
collapsedEntries.value[treeNode.id] =
|
||||
expand === undefined ? !collapsedEntries.value[treeNode.id] : !expand;
|
||||
if (hasSubExecution(treeNode) && treeNode.children.length === 0) {
|
||||
void loadSubExecution(treeNode);
|
||||
return;
|
||||
}
|
||||
|
||||
collapsedEntries.value = {
|
||||
...collapsedEntries.value,
|
||||
[treeNode.id]: expand === undefined ? !collapsedEntries.value[treeNode.id] : !expand,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,7 +7,6 @@ export interface LogEntry {
|
||||
node: INodeUi;
|
||||
id: string;
|
||||
children: LogEntry[];
|
||||
depth: number;
|
||||
runIndex: number;
|
||||
runData: ITaskData | undefined;
|
||||
consumedTokens: LlmTokenUsageData;
|
||||
@@ -18,7 +17,7 @@ export interface LogEntry {
|
||||
|
||||
export interface LogTreeCreationContext {
|
||||
parent: LogEntry | undefined;
|
||||
depth: number;
|
||||
ancestorRunIndexes: number[];
|
||||
workflow: Workflow;
|
||||
executionId: string;
|
||||
data: IRunExecutionData;
|
||||
@@ -34,7 +33,7 @@ export interface LatestNodeInfo {
|
||||
|
||||
export type LogEntrySelection =
|
||||
| { type: 'initial' }
|
||||
| { type: 'selected'; id: string }
|
||||
| { type: 'selected'; entry: LogEntry }
|
||||
| { type: 'none' };
|
||||
|
||||
export type LogsPanelState = (typeof LOGS_PANEL_STATE)[keyof typeof LOGS_PANEL_STATE];
|
||||
|
||||
@@ -79,15 +79,13 @@ describe(getTreeNodeData, () => {
|
||||
|
||||
expect(logTree.length).toBe(1);
|
||||
|
||||
expect(logTree[0].id).toBe('test-wf-id:A:test-execution-id:0');
|
||||
expect(logTree[0].depth).toBe(0);
|
||||
expect(logTree[0].id).toBe('test-wf-id:test-node-id-a:0');
|
||||
expect(logTree[0].runIndex).toBe(0);
|
||||
expect(logTree[0].parent).toBe(undefined);
|
||||
expect(logTree[0].runData?.startTime).toBe(1740528000000);
|
||||
expect(logTree[0].children.length).toBe(2);
|
||||
|
||||
expect(logTree[0].children[0].id).toBe('test-wf-id:B:test-execution-id:0');
|
||||
expect(logTree[0].children[0].depth).toBe(1);
|
||||
expect(logTree[0].children[0].id).toBe('test-wf-id:test-node-id-b:0:0');
|
||||
expect(logTree[0].children[0].runIndex).toBe(0);
|
||||
expect(logTree[0].children[0].parent?.node.name).toBe('A');
|
||||
expect(logTree[0].children[0].runData?.startTime).toBe(1740528000001);
|
||||
@@ -95,23 +93,20 @@ describe(getTreeNodeData, () => {
|
||||
expect(logTree[0].children[0].consumedTokens.completionTokens).toBe(1);
|
||||
expect(logTree[0].children[0].children.length).toBe(1);
|
||||
|
||||
expect(logTree[0].children[0].children[0].id).toBe('test-wf-id:C:test-execution-id:0');
|
||||
expect(logTree[0].children[0].children[0].depth).toBe(2);
|
||||
expect(logTree[0].children[0].children[0].id).toBe('test-wf-id:test-node-id-c:0:0:0');
|
||||
expect(logTree[0].children[0].children[0].runIndex).toBe(0);
|
||||
expect(logTree[0].children[0].children[0].parent?.node.name).toBe('B');
|
||||
expect(logTree[0].children[0].children[0].consumedTokens.isEstimate).toBe(true);
|
||||
expect(logTree[0].children[0].children[0].consumedTokens.completionTokens).toBe(7);
|
||||
|
||||
expect(logTree[0].children[1].id).toBe('test-wf-id:B:test-execution-id:1');
|
||||
expect(logTree[0].children[1].depth).toBe(1);
|
||||
expect(logTree[0].children[1].id).toBe('test-wf-id:test-node-id-b:0:1');
|
||||
expect(logTree[0].children[1].runIndex).toBe(1);
|
||||
expect(logTree[0].children[1].parent?.node.name).toBe('A');
|
||||
expect(logTree[0].children[1].consumedTokens.isEstimate).toBe(false);
|
||||
expect(logTree[0].children[1].consumedTokens.completionTokens).toBe(4);
|
||||
expect(logTree[0].children[1].children.length).toBe(1);
|
||||
|
||||
expect(logTree[0].children[1].children[0].id).toBe('test-wf-id:C:test-execution-id:1');
|
||||
expect(logTree[0].children[1].children[0].depth).toBe(2);
|
||||
expect(logTree[0].children[1].children[0].id).toBe('test-wf-id:test-node-id-c:0:1:1');
|
||||
expect(logTree[0].children[1].children[0].runIndex).toBe(1);
|
||||
expect(logTree[0].children[1].children[0].parent?.node.name).toBe('B');
|
||||
expect(logTree[0].children[1].children[0].consumedTokens.completionTokens).toBe(0);
|
||||
@@ -554,14 +549,15 @@ describe(getTreeNodeData, () => {
|
||||
});
|
||||
|
||||
describe(findSelectedLogEntry, () => {
|
||||
function find(state: LogEntrySelection, response: IExecutionResponse) {
|
||||
function find(state: LogEntrySelection, response: IExecutionResponse, isExecuting: boolean) {
|
||||
return findSelectedLogEntry(
|
||||
state,
|
||||
createLogTree(createTestWorkflowObject(response.workflowData), response),
|
||||
isExecuting,
|
||||
);
|
||||
}
|
||||
|
||||
describe('when log is not manually selected', () => {
|
||||
describe('when execution is finished and log is not manually selected', () => {
|
||||
it('should return undefined if no execution data exists', () => {
|
||||
const response = createTestWorkflowExecutionResponse({
|
||||
workflowData: createTestWorkflow({
|
||||
@@ -574,7 +570,7 @@ describe(findSelectedLogEntry, () => {
|
||||
data: { resultData: { runData: {} } },
|
||||
});
|
||||
|
||||
expect(find({ type: 'initial' }, response)).toBe(undefined);
|
||||
expect(find({ type: 'initial' }, response, false)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should return first log entry with error', () => {
|
||||
@@ -589,19 +585,27 @@ describe(findSelectedLogEntry, () => {
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {
|
||||
A: [createTestTaskData({ executionStatus: 'success' })],
|
||||
B: [createTestTaskData({ executionStatus: 'success' })],
|
||||
A: [createTestTaskData({ executionStatus: 'success', startTime: 0 })],
|
||||
B: [createTestTaskData({ executionStatus: 'success', startTime: 1 })],
|
||||
C: [
|
||||
createTestTaskData({ executionStatus: 'success' }),
|
||||
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
|
||||
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
|
||||
createTestTaskData({ executionStatus: 'success', startTime: 2 }),
|
||||
createTestTaskData({
|
||||
error: {} as ExecutionError,
|
||||
executionStatus: 'error',
|
||||
startTime: 3,
|
||||
}),
|
||||
createTestTaskData({
|
||||
error: {} as ExecutionError,
|
||||
executionStatus: 'error',
|
||||
startTime: 4,
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(find({ type: 'initial' }, response)).toEqual(
|
||||
expect(find({ type: 'initial' }, response, false)).toEqual(
|
||||
expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 1 }),
|
||||
);
|
||||
});
|
||||
@@ -625,19 +629,27 @@ describe(findSelectedLogEntry, () => {
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {
|
||||
A: [createTestTaskData({ executionStatus: 'success' })],
|
||||
B: [createTestTaskData({ executionStatus: 'success' })],
|
||||
A: [createTestTaskData({ executionStatus: 'success', startTime: 0 })],
|
||||
B: [createTestTaskData({ executionStatus: 'success', startTime: 1 })],
|
||||
C: [
|
||||
createTestTaskData({ executionStatus: 'success' }),
|
||||
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
|
||||
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
|
||||
createTestTaskData({ executionStatus: 'success', startTime: 2 }),
|
||||
createTestTaskData({
|
||||
error: {} as ExecutionError,
|
||||
executionStatus: 'error',
|
||||
startTime: 3,
|
||||
}),
|
||||
createTestTaskData({
|
||||
error: {} as ExecutionError,
|
||||
executionStatus: 'error',
|
||||
startTime: 4,
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(find({ type: 'initial' }, response)).toEqual(
|
||||
expect(find({ type: 'initial' }, response, false)).toEqual(
|
||||
expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 1 }),
|
||||
);
|
||||
});
|
||||
@@ -654,24 +666,55 @@ describe(findSelectedLogEntry, () => {
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {
|
||||
A: [createTestTaskData({ executionStatus: 'success' })],
|
||||
B: [createTestTaskData({ executionStatus: 'success' })],
|
||||
A: [createTestTaskData({ executionStatus: 'success', startTime: 0 })],
|
||||
B: [createTestTaskData({ executionStatus: 'success', startTime: 1 })],
|
||||
C: [
|
||||
createTestTaskData({ executionStatus: 'success' }),
|
||||
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
|
||||
createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' }),
|
||||
createTestTaskData({ executionStatus: 'success', startTime: 2 }),
|
||||
createTestTaskData({ executionStatus: 'success', startTime: 3 }),
|
||||
createTestTaskData({ executionStatus: 'success', startTime: 4 }),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(find({ type: 'initial' }, response)).toEqual(
|
||||
expect(find({ type: 'initial' }, response, false)).toEqual(
|
||||
expect.objectContaining({ node: expect.objectContaining({ name: 'B' }), runIndex: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return first log entry if there is no log entry with error nor executed AI agent node', () => {
|
||||
it('should return first log entry with error when it appears after a log entry for AI agent', () => {
|
||||
const response = createTestWorkflowExecutionResponse({
|
||||
workflowData: createTestWorkflow({
|
||||
nodes: [
|
||||
createTestNode({ name: 'A' }),
|
||||
createTestNode({ name: 'B', type: AGENT_LANGCHAIN_NODE_TYPE }),
|
||||
createTestNode({ name: 'C' }),
|
||||
],
|
||||
}),
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {
|
||||
A: [createTestTaskData({ executionStatus: 'success', startTime: 0 })],
|
||||
B: [createTestTaskData({ executionStatus: 'success', startTime: 1 })],
|
||||
C: [
|
||||
createTestTaskData({
|
||||
executionStatus: 'success',
|
||||
error: {} as ExecutionError,
|
||||
startTime: 2,
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(find({ type: 'initial' }, response, false)).toEqual(
|
||||
expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return last log entry if there is no log entry with error nor executed AI agent node', () => {
|
||||
const response = createTestWorkflowExecutionResponse({
|
||||
workflowData: createTestWorkflow({
|
||||
nodes: [
|
||||
@@ -683,48 +726,98 @@ describe(findSelectedLogEntry, () => {
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {
|
||||
A: [createTestTaskData({ executionStatus: 'success' })],
|
||||
B: [createTestTaskData({ executionStatus: 'success' })],
|
||||
A: [createTestTaskData({ executionStatus: 'success', startTime: 0 })],
|
||||
B: [createTestTaskData({ executionStatus: 'success', startTime: 1 })],
|
||||
C: [
|
||||
createTestTaskData({ executionStatus: 'success' }),
|
||||
createTestTaskData({ executionStatus: 'success' }),
|
||||
createTestTaskData({ executionStatus: 'success' }),
|
||||
createTestTaskData({ executionStatus: 'success', startTime: 2 }),
|
||||
createTestTaskData({ executionStatus: 'success', startTime: 3 }),
|
||||
createTestTaskData({ executionStatus: 'success', startTime: 4 }),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(find({ type: 'initial' }, response)).toEqual(
|
||||
expect.objectContaining({ node: expect.objectContaining({ name: 'A' }), runIndex: 0 }),
|
||||
expect(find({ type: 'initial' }, response, false)).toEqual(
|
||||
expect.objectContaining({ node: expect.objectContaining({ name: 'C' }), runIndex: 2 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when log is manually selected', () => {
|
||||
it('should return manually selected log', () => {
|
||||
const response = createTestWorkflowExecutionResponse({
|
||||
id: 'my-exec-id',
|
||||
workflowData: createTestWorkflow({
|
||||
id: 'test-wf-id',
|
||||
nodes: [createTestNode({ name: 'A' }), createTestNode({ name: 'B' })],
|
||||
}),
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {
|
||||
A: [createTestTaskData({ executionStatus: 'success' })],
|
||||
B: [createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' })],
|
||||
},
|
||||
const nodeA = createTestNode({ name: 'A', id: 'a' });
|
||||
const nodeB = createTestNode({ name: 'B', id: 'b' });
|
||||
const workflowData = createTestWorkflow({
|
||||
id: 'test-wf-id',
|
||||
nodes: [nodeA, nodeB],
|
||||
});
|
||||
const response = createTestWorkflowExecutionResponse({
|
||||
workflowData,
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {
|
||||
A: [
|
||||
createTestTaskData({ executionStatus: 'success' }),
|
||||
createTestTaskData({ executionStatus: 'success' }),
|
||||
createTestTaskData({ executionStatus: 'success' }),
|
||||
],
|
||||
B: [createTestTaskData({ error: {} as ExecutionError, executionStatus: 'error' })],
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const result = find({ type: 'selected', id: 'test-wf-id:A:my-exec-id:0' }, response);
|
||||
it('should return manually selected log', () => {
|
||||
const result = find(
|
||||
{ type: 'selected', entry: createTestLogEntry({ id: 'test-wf-id:a:0' }) },
|
||||
response,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ node: expect.objectContaining({ name: 'A' }), runIndex: 0 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the log with same node and closest run index as selected if the exact run index is not found in logs', () => {
|
||||
const result = find(
|
||||
{
|
||||
type: 'selected',
|
||||
entry: createTestLogEntry({
|
||||
id: 'test-wf-id:a:4',
|
||||
executionId: response.id,
|
||||
node: nodeA,
|
||||
runIndex: 4,
|
||||
workflow: createTestWorkflowObject(workflowData),
|
||||
}),
|
||||
},
|
||||
response,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({ node: expect.objectContaining({ name: 'A' }), runIndex: 2 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not fallback to the closest run index while executing', () => {
|
||||
const result = find(
|
||||
{
|
||||
type: 'selected',
|
||||
entry: createTestLogEntry({
|
||||
id: 'test-wf-id:a:4',
|
||||
executionId: response.id,
|
||||
node: nodeA,
|
||||
runIndex: 4,
|
||||
workflow: createTestWorkflowObject(workflowData),
|
||||
}),
|
||||
},
|
||||
response,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -909,28 +1002,24 @@ describe(createLogTree, () => {
|
||||
expect(logs).toHaveLength(2);
|
||||
|
||||
expect(logs[0].node.name).toBe('A');
|
||||
expect(logs[0].depth).toBe(0);
|
||||
expect(logs[0].workflow).toBe(workflow);
|
||||
expect(logs[0].execution).toBe(rootExecutionData.data);
|
||||
expect(logs[0].executionId).toBe('root-exec-id');
|
||||
expect(logs[0].children).toHaveLength(0);
|
||||
|
||||
expect(logs[1].node.name).toBe('B');
|
||||
expect(logs[1].depth).toBe(0);
|
||||
expect(logs[1].workflow).toBe(workflow);
|
||||
expect(logs[1].execution).toBe(rootExecutionData.data);
|
||||
expect(logs[1].executionId).toBe('root-exec-id');
|
||||
expect(logs[1].children).toHaveLength(2);
|
||||
|
||||
expect(logs[1].children[0].node.name).toBe('C');
|
||||
expect(logs[1].children[0].depth).toBe(1);
|
||||
expect(logs[1].children[0].workflow).toBe(subWorkflow);
|
||||
expect(logs[1].children[0].execution).toBe(subExecutionData);
|
||||
expect(logs[1].children[0].executionId).toBe('sub-exec-id');
|
||||
expect(logs[1].children[0].children).toHaveLength(0);
|
||||
|
||||
expect(logs[1].children[1].node.name).toBe('C');
|
||||
expect(logs[1].children[1].depth).toBe(1);
|
||||
expect(logs[1].children[1].workflow).toBe(subWorkflow);
|
||||
expect(logs[1].children[1].execution).toBe(subExecutionData);
|
||||
expect(logs[1].children[1].executionId).toBe('sub-exec-id');
|
||||
|
||||
@@ -17,8 +17,8 @@ import type { LogEntry, LogEntrySelection, LogTreeCreationContext } from './logs
|
||||
import { isProxy, isReactive, isRef, toRaw } from 'vue';
|
||||
import { CHAT_TRIGGER_NODE_TYPE, MANUAL_CHAT_TRIGGER_NODE_TYPE } from '@/constants';
|
||||
import { type ChatMessage } from '@n8n/chat/types';
|
||||
import get from 'lodash-es/get';
|
||||
import isEmpty from 'lodash-es/isEmpty';
|
||||
import get from 'lodash/get';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { TOOL_EXECUTOR_NODE_NAME } from '@n8n/constants';
|
||||
|
||||
@@ -54,8 +54,9 @@ function createNode(
|
||||
return {
|
||||
parent: context.parent,
|
||||
node,
|
||||
id: `${context.workflow.id}:${node.name}:${context.executionId}:${runIndex}`,
|
||||
depth: context.depth,
|
||||
// The ID consists of workflow ID, node ID and run index (including ancestor's), which
|
||||
// makes it possible to identify the same log across different executions
|
||||
id: `${context.workflow.id}:${node.id}:${[...context.ancestorRunIndexes, runIndex].join(':')}`,
|
||||
runIndex,
|
||||
runData,
|
||||
children,
|
||||
@@ -85,7 +86,7 @@ function getChildNodes(
|
||||
return createLogTreeRec({
|
||||
...context,
|
||||
parent: treeNode,
|
||||
depth: context.depth + 1,
|
||||
ancestorRunIndexes: [...context.ancestorRunIndexes, runIndex ?? 0],
|
||||
workflow,
|
||||
executionId: subExecutionLocator.executionId,
|
||||
data: subWorkflowRunData,
|
||||
@@ -121,7 +122,7 @@ function getChildNodes(
|
||||
return subNode
|
||||
? getTreeNodeData(subNode, t, index, {
|
||||
...context,
|
||||
depth: context.depth + 1,
|
||||
ancestorRunIndexes: [...context.ancestorRunIndexes, runIndex ?? 0],
|
||||
parent: treeNode,
|
||||
})
|
||||
: [];
|
||||
@@ -171,28 +172,25 @@ export function getSubtreeTotalConsumedTokens(
|
||||
return calculate(treeNode);
|
||||
}
|
||||
|
||||
function findLogEntryToAutoSelectRec(subTree: LogEntry[], depth: number): LogEntry | undefined {
|
||||
for (const entry of subTree) {
|
||||
if (entry.runData?.error) {
|
||||
return entry;
|
||||
}
|
||||
function findLogEntryToAutoSelect(subTree: LogEntry[]): LogEntry | undefined {
|
||||
const entryWithError = findLogEntryRec((e) => !!e.runData?.error, subTree);
|
||||
|
||||
const childAutoSelect = findLogEntryToAutoSelectRec(entry.children, depth + 1);
|
||||
|
||||
if (childAutoSelect) {
|
||||
return childAutoSelect;
|
||||
}
|
||||
|
||||
if (entry.node.type === AGENT_LANGCHAIN_NODE_TYPE) {
|
||||
if (isPlaceholderLog(entry) && entry.children.length > 0) {
|
||||
return entry.children[0];
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
if (entryWithError) {
|
||||
return entryWithError;
|
||||
}
|
||||
|
||||
return depth === 0 ? subTree[0] : undefined;
|
||||
const entryForAiAgent = findLogEntryRec(
|
||||
(entry) =>
|
||||
entry.node.type === AGENT_LANGCHAIN_NODE_TYPE ||
|
||||
(entry.parent?.node.type === AGENT_LANGCHAIN_NODE_TYPE && isPlaceholderLog(entry.parent)),
|
||||
subTree,
|
||||
);
|
||||
|
||||
if (entryForAiAgent) {
|
||||
return entryForAiAgent;
|
||||
}
|
||||
|
||||
return subTree[subTree.length - 1];
|
||||
}
|
||||
|
||||
export function createLogTree(
|
||||
@@ -203,7 +201,7 @@ export function createLogTree(
|
||||
) {
|
||||
return createLogTreeRec({
|
||||
parent: undefined,
|
||||
depth: 0,
|
||||
ancestorRunIndexes: [],
|
||||
executionId: response.id,
|
||||
workflow,
|
||||
workflows,
|
||||
@@ -283,20 +281,33 @@ export function findLogEntryRec(
|
||||
export function findSelectedLogEntry(
|
||||
selection: LogEntrySelection,
|
||||
entries: LogEntry[],
|
||||
isExecuting: boolean,
|
||||
): LogEntry | undefined {
|
||||
switch (selection.type) {
|
||||
case 'initial':
|
||||
return findLogEntryToAutoSelectRec(entries, 0);
|
||||
return isExecuting ? undefined : findLogEntryToAutoSelect(entries);
|
||||
case 'none':
|
||||
return undefined;
|
||||
case 'selected': {
|
||||
const entry = findLogEntryRec((e) => e.id === selection.id, entries);
|
||||
const found = findLogEntryRec((e) => e.id === selection.entry.id, entries);
|
||||
|
||||
if (entry) {
|
||||
return entry;
|
||||
if (found === undefined && !isExecuting) {
|
||||
for (let runIndex = selection.entry.runIndex - 1; runIndex >= 0; runIndex--) {
|
||||
const fallback = findLogEntryRec(
|
||||
(e) =>
|
||||
e.workflow.id === selection.entry.workflow.id &&
|
||||
e.node.id === selection.entry.node.id &&
|
||||
e.runIndex === runIndex,
|
||||
entries,
|
||||
);
|
||||
|
||||
if (fallback !== undefined) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findLogEntryToAutoSelectRec(entries, 0);
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ export const useLogsStore = defineStore('logs', () => {
|
||||
LOG_DETAILS_PANEL_STATE.BOTH,
|
||||
{ writeDefaults: false },
|
||||
);
|
||||
const isLogSelectionSyncedWithCanvas = useLocalStorage(LOCAL_STORAGE_LOGS_SYNC_SELECTION, false);
|
||||
const isLogSelectionSyncedWithCanvas = useLocalStorage(LOCAL_STORAGE_LOGS_SYNC_SELECTION, true, {
|
||||
writeDefaults: false,
|
||||
});
|
||||
const isSubNodeSelected = ref(false);
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
@@ -73,3 +73,26 @@ export function isOutsideSelected(el: HTMLElement | null) {
|
||||
selection.anchorOffset !== selection.focusOffset)
|
||||
);
|
||||
}
|
||||
|
||||
let scrollbarWidth: number | undefined;
|
||||
|
||||
export function getScrollbarWidth() {
|
||||
if (scrollbarWidth !== undefined) {
|
||||
return scrollbarWidth;
|
||||
}
|
||||
|
||||
const outer = document.createElement('div');
|
||||
const inner = document.createElement('div');
|
||||
|
||||
outer.style.visibility = 'hidden';
|
||||
outer.style.overflow = 'scroll';
|
||||
document.body.appendChild(outer);
|
||||
|
||||
outer.appendChild(inner);
|
||||
|
||||
scrollbarWidth = outer.offsetWidth - inner.offsetWidth;
|
||||
|
||||
outer.parentElement?.removeChild(outer);
|
||||
|
||||
return scrollbarWidth;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user