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