feat(editor): Improve errors in output panel (#8644)

Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
Giulio Andreini
2024-03-07 17:08:01 +01:00
committed by GitHub
parent 6e2aa405fc
commit 5301323906
38 changed files with 772 additions and 287 deletions

View File

@@ -1,126 +1,216 @@
<template>
<div>
<div class="error-header">
<div class="error-message" v-text="getErrorMessage()" />
<div v-if="error.description" class="error-description" v-html="getErrorDescription()"></div>
<div class="node-error-view">
<div class="node-error-view__header">
<div class="node-error-view__header-message" v-text="getErrorMessage()" />
<div
class="node-error-view__header-description"
v-if="error.description"
v-html="getErrorDescription()"
></div>
</div>
<details>
<summary class="error-details__summary">
<font-awesome-icon class="error-details__icon" icon="angle-right" />
{{ $locale.baseText('nodeErrorView.details') }}
</summary>
<div class="error-details__content">
<div v-if="error.context && error.context.causeDetailed">
<el-card class="box-card" shadow="never">
<div>
{{ error.context.causeDetailed }}
</div>
</el-card>
</div>
<div v-if="error.timestamp">
<el-card class="box-card" shadow="never">
<template #header>
<div class="clearfix box-card__title">
<span>{{ $locale.baseText('nodeErrorView.time') }}</span>
</div>
</template>
<div>
{{ new Date(error.timestamp).toLocaleString() }}
</div>
</el-card>
</div>
<div
v-if="error.context && error.context.itemIndex !== undefined"
class="el-card box-card is-never-shadow el-card__body"
<div class="node-error-view__info">
<div class="node-error-view__info-header">
<p class="node-error-view__info-title">
{{ $locale.baseText('nodeErrorView.details.title') }}
</p>
<n8n-tooltip
class="item"
:content="$locale.baseText('nodeErrorView.copyToClipboard.tooltip')"
placement="left"
>
<span class="error-details__summary"
>{{ $locale.baseText('nodeErrorView.itemIndex') }}:</span
>
{{ error.context.itemIndex }}
<span v-if="error.context.runIndex">
|
<span class="error-details__summary"
>{{ $locale.baseText('nodeErrorView.itemIndex') }}:</span
>
{{ error.context.runIndex }}
</span>
<span v-if="error.context.parameter">
|
<span class="error-details__summary"
>{{ $locale.baseText('nodeErrorView.inParameter') }}:</span
>
{{ parameterDisplayName(error.context.parameter) }}
</span>
</div>
<div v-if="error.httpCode">
<el-card class="box-card" shadow="never">
<template #header>
<div class="clearfix box-card__title">
<span>{{ $locale.baseText('nodeErrorView.httpCode') }}</span>
</div>
</template>
<div>
{{ error.httpCode }}
</div>
</el-card>
</div>
<div v-if="error.cause">
<el-card class="box-card" shadow="never">
<template #header>
<div class="clearfix box-card__title">
<span>{{ $locale.baseText('nodeErrorView.cause') }}</span>
<br />
<span class="box-card__subtitle">{{
$locale.baseText('nodeErrorView.dataBelowMayContain')
}}</span>
</div>
</template>
<div>
<div v-if="displayCause" class="copy-button">
<n8n-icon-button
:title="$locale.baseText('nodeErrorView.copyToClipboard')"
icon="copy"
@click="copyCause"
/>
</div>
<VueJsonPretty
v-if="displayCause"
:data="error.cause"
:deep="3"
:show-length="true"
selectable-type="single"
path="error"
class="json-data"
/>
<span v-else>
<font-awesome-icon icon="info-circle" />{{
$locale.baseText('nodeErrorView.theErrorCauseIsTooLargeToBeDisplayed')
}}
</span>
</div>
</el-card>
</div>
<div v-if="error.stack">
<el-card class="box-card" shadow="never">
<template #header>
<div class="clearfix box-card__title">
<span>{{ $locale.baseText('nodeErrorView.stack') }}</span>
</div>
</template>
<div>
<pre><code>{{error.stack}}</code></pre>
</div>
</el-card>
</div>
<div class="copy-button">
<n8n-icon-button
icon="copy"
type="secondary"
size="mini"
text="true"
transparent-background="transparent"
@click="copyErrorDetails"
/>
</div>
</n8n-tooltip>
</div>
</details>
<div class="node-error-view__info-content">
<details
class="node-error-view__details"
v-if="error.httpCode || prepareRawMessages.length || error?.context?.data || error.extra"
>
<summary class="node-error-view__details-summary">
<font-awesome-icon class="node-error-view__details-icon" icon="angle-right" />
{{
$locale.baseText('nodeErrorView.details.from', {
interpolate: { node: getNodeDefaultName(error?.node) as string },
})
}}
</summary>
<div class="node-error-view__details-content">
<div class="node-error-view__details-row" v-if="error.httpCode">
<p class="node-error-view__details-label">
{{ $locale.baseText('nodeErrorView.errorCode') }}
</p>
<p class="node-error-view__details-value">
<code>{{ error.httpCode }}</code>
</p>
</div>
<div class="node-error-view__details-row" v-if="prepareRawMessages.length">
<p class="node-error-view__details-label">
{{ $locale.baseText('nodeErrorView.details.rawMessages') }}
</p>
<div class="node-error-view__details-value">
<div v-for="(msg, index) in prepareRawMessages" :key="index">
<pre><code>{{ msg }}</code></pre>
</div>
</div>
</div>
<div class="node-error-view__details-row" v-if="error?.context?.data">
<p class="node-error-view__details-label">
{{ $locale.baseText('nodeErrorView.details.errorData') }}
</p>
<div class="node-error-view__details-value">
<pre><code>{{ error.context.data }}</code></pre>
</div>
</div>
<div class="node-error-view__details-row" v-if="error.extra">
<p class="node-error-view__details-label">
{{ $locale.baseText('nodeErrorView.details.errorExtra') }}
</p>
<div class="node-error-view__details-value">
<pre><code>{{ error.extra }}</code></pre>
</div>
</div>
<div class="node-error-view__details-row" v-if="error.context && error.context.request">
<p class="node-error-view__details-label">
{{ $locale.baseText('nodeErrorView.details.request') }}
</p>
<div class="node-error-view__details-value">
<pre><code>{{ error.context.request }}</code></pre>
</div>
</div>
</div>
</details>
<details class="node-error-view__details">
<summary class="node-error-view__details-summary">
<font-awesome-icon class="node-error-view__details-icon" icon="angle-right" />
{{ $locale.baseText('nodeErrorView.details.info') }}
</summary>
<div class="node-error-view__details-content">
<div
class="node-error-view__details-row"
v-if="error.context && error.context.itemIndex !== undefined"
>
<p class="node-error-view__details-label">
{{ $locale.baseText('nodeErrorView.itemIndex') }}
</p>
<p class="node-error-view__details-value">
<code>{{ error.context.itemIndex }}</code>
</p>
</div>
<div
class="node-error-view__details-row"
v-if="error.context && error.context.runIndex !== undefined"
>
<p class="node-error-view__details-label">
{{ $locale.baseText('nodeErrorView.runIndex') }}
</p>
<p class="node-error-view__details-value">
<code>{{ error.context.runIndex }}</code>
</p>
</div>
<div
class="node-error-view__details-row"
v-if="error.context && error.context.parameter !== undefined"
>
<p class="node-error-view__details-label">
{{ $locale.baseText('nodeErrorView.inParameter') }}
</p>
<p class="node-error-view__details-value">
<code>{{ parameterDisplayName(error.context.parameter) }}</code>
</p>
</div>
<div class="node-error-view__details-row" v-if="error.node && error.node.type">
<p class="node-error-view__details-label">
{{ $locale.baseText('nodeErrorView.details.nodeType') }}
</p>
<p class="node-error-view__details-value">
<code>{{ error.node.type }}</code>
</p>
</div>
<div class="node-error-view__details-row" v-if="error.node && error.node.typeVersion">
<p class="node-error-view__details-label">
{{ $locale.baseText('nodeErrorView.details.nodeVersion') }}
</p>
<p class="node-error-view__details-value">
<code>
<span>{{ error.node.typeVersion + ' ' }}</span>
<span>({{ nodeVersionTag(error.node) }})</span>
</code>
</p>
</div>
<div class="node-error-view__details-row">
<p class="node-error-view__details-label">
{{ $locale.baseText('nodeErrorView.details.n8nVersion') }}
</p>
<p class="node-error-view__details-value">
<code>{{ n8nVersion }}</code>
</p>
</div>
<div class="node-error-view__details-row" v-if="error.timestamp">
<p class="node-error-view__details-label">
{{ $locale.baseText('nodeErrorView.time') }}
</p>
<p class="node-error-view__details-value">
<code>{{ new Date(error.timestamp).toLocaleString() }}</code>
</p>
</div>
<div class="node-error-view__details-row" v-if="error.cause && displayCause">
<p class="node-error-view__details-label">
{{ $locale.baseText('nodeErrorView.details.errorCause') }}
</p>
<pre class="node-error-view__details-value"><code>{{ error.cause }}</code></pre>
</div>
<div
class="node-error-view__details-row"
v-if="error.context && error.context.causeDetailed"
>
<p class="node-error-view__details-label">
{{ $locale.baseText('nodeErrorView.details.causeDetailed') }}
</p>
<pre
class="node-error-view__details-value"
><code>{{ error.context.causeDetailed }}</code></pre>
</div>
<div class="node-error-view__details-row" v-if="error.stack">
<p class="node-error-view__details-label">
{{ $locale.baseText('nodeErrorView.details.stackTrace') }}
</p>
<pre class="node-error-view__details-value"><code>{{ error.stack }}</code></pre>
</div>
</div>
</details>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { mapStores } from 'pinia';
import VueJsonPretty from 'vue-json-pretty';
import { useToast } from '@/composables/useToast';
import { MAX_DISPLAY_DATA_SIZE } from '@/constants';
@@ -133,13 +223,13 @@ import type {
import { sanitizeHtml } from '@/utils/htmlUtils';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useRootStore } from '@/stores/n8nRoot.store';
import { useClipboard } from '@/composables/useClipboard';
import type { IDataObject } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
export default defineComponent({
name: 'NodeErrorView',
components: {
VueJsonPretty,
},
props: ['error'],
setup() {
const clipboard = useClipboard();
@@ -150,7 +240,7 @@ export default defineComponent({
};
},
computed: {
...mapStores(useNodeTypesStore, useNDVStore),
...mapStores(useNodeTypesStore, useNDVStore, useRootStore),
displayCause(): boolean {
return JSON.stringify(this.error.cause).length < MAX_DISPLAY_DATA_SIZE;
},
@@ -167,8 +257,67 @@ export default defineComponent({
return nodeType.properties;
},
n8nVersion() {
const baseUrl = this.rootStore.urlBaseEditor;
let instanceType = 'Self Hosted';
if (baseUrl.includes('n8n.cloud')) {
instanceType = 'Cloud';
}
return this.rootStore.versionCli + ` (${instanceType})`;
},
prepareRawMessages() {
const returnData: Array<string | IDataObject> = [];
if (!this.error.messages || !this.error.messages.length) {
return [];
}
const errorMessage = this.getErrorMessage();
(Array.from(new Set(this.error.messages)) as string[]).forEach((message) => {
const isParsable = /^\d{3} - \{/.test(message);
const parts = isParsable ? message.split(' - ').map((part) => part.trim()) : [];
//try to parse the message as JSON
for (const part of parts) {
try {
const parsed = JSON.parse(part);
if (typeof parsed === 'object') {
returnData.push(parsed);
return;
}
} catch (error) {}
}
//if message is the same as error message, do not include it
if (message === errorMessage) return;
returnData.push(message);
});
return returnData;
},
},
methods: {
getNodeDefaultName(node: INodeUi) {
if (!node) return 'Node';
const nodeType = this.nodeTypesStore.getNodeType(node.type, node.typeVersion);
return nodeType?.defaults?.name || node.name;
},
nodeVersionTag(nodeType: IDataObject): string {
if (!nodeType || nodeType.hidden) {
return this.$locale.baseText('nodeSettings.deprecated');
}
const latestNodeVersion = Math.max(
...this.nodeTypesStore.getNodeVersions(nodeType.type as string),
);
if (latestNodeVersion === nodeType.typeVersion) {
return this.$locale.baseText('nodeSettings.latest');
}
return this.$locale.baseText('nodeSettings.latestVersion', {
interpolate: { version: latestNodeVersion.toString() },
});
},
replacePlaceholders(parameter: string, message: string): string {
const parameterName = this.parameterDisplayName(parameter, false);
const parameterFullName = this.parameterDisplayName(parameter, true);
@@ -199,7 +348,7 @@ export default defineComponent({
);
},
getErrorMessage(): string {
const baseErrorMessage = this.$locale.baseText('nodeErrorView.error') + ': ';
const baseErrorMessage = '';
const isSubNodeError =
this.error.name === 'NodeOperationError' &&
@@ -284,8 +433,89 @@ export default defineComponent({
// We can not resolve any deeper so lets stop here and at least return hopefully something useful
return [currentParameter];
},
copyCause() {
void this.clipboard.copy(JSON.stringify(this.error.cause));
copyErrorDetails() {
const error = this.error;
const errorInfo: IDataObject = {
errorMessage: this.getErrorMessage(),
};
if (error.description) {
errorInfo.errorDescription = error.description;
}
//add error details
const errorDetails: IDataObject = {};
if (error?.messages?.length) {
errorDetails.rawErrorMessage = error.messages;
}
if (error.httpCode) {
errorDetails.httpCode = error.httpCode;
}
if (error.context && error.context.data) {
errorDetails.errorData = error.context.data;
}
if (error.extra) {
errorDetails.errorExtra = error.extra;
}
errorInfo.errorDetails = errorDetails;
//add n8n details
const n8nDetails: IDataObject = {};
if (error.node) {
n8nDetails.nodeName = error.node.name;
n8nDetails.nodeType = error.node.type;
n8nDetails.nodeVersion = error.node.typeVersion;
if (error.node?.parameters?.resource) {
n8nDetails.resource = error.node.parameters.resource;
}
if (error?.node?.parameters?.operation) {
n8nDetails.operation = error.node.parameters.operation;
}
}
if (error.context) {
if (error.context.itemIndex !== undefined) {
n8nDetails.itemIndex = error.context.itemIndex;
}
if (error.context.runIndex !== undefined) {
n8nDetails.runIndex = error.context.runIndex;
}
if (error.context.parameter !== undefined) {
n8nDetails.parameter = error.context.parameter;
}
if (error.context.causeDetailed) {
n8nDetails.causeDetailed = error.context.causeDetailed;
}
}
if (error.timestamp) {
n8nDetails.time = new Date(error.timestamp).toLocaleString();
}
n8nDetails.n8nVersion = this.n8nVersion;
n8nDetails.binaryDataMode = this.rootStore.binaryDataMode;
if (error.cause) {
n8nDetails.cause = error.cause;
}
n8nDetails.stackTrace = error.stack && error.stack.split('\n');
errorInfo.n8nDetails = n8nDetails;
void this.clipboard.copy(JSON.stringify(errorInfo, null, 2));
this.copySuccess();
},
copySuccess() {
@@ -299,72 +529,124 @@ export default defineComponent({
</script>
<style lang="scss">
.error-header {
margin-bottom: 10px;
}
.node-error-view {
&__header {
max-width: 960px;
margin: 0 auto var(--spacing-s) auto;
padding-bottom: var(--spacing-3xs);
background-color: var(--color-background-xlight);
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-large);
}
.error-message {
color: var(--color-ndv-ouptut-error-font);
font-weight: bold;
font-size: 1.1rem;
}
&__header-title {
padding: var(--spacing-2xs) var(--spacing-s);
border-bottom: 1px solid var(--color-danger-tint-1);
font-size: var(--font-size-3xs);
font-weight: var(--font-weight-bold);
background-color: var(--color-danger-tint-2);
border-radius: var(--border-radius-large) var(--border-radius-large) 0 0;
color: var(--color-danger);
}
.error-description {
margin-top: 10px;
font-size: 1rem;
}
&__header-message {
padding: var(--spacing-xs) var(--spacing-s) var(--spacing-3xs) var(--spacing-s);
color: var(--color-danger);
color: var(--color-danger);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-s);
}
.error-details__summary {
font-weight: 600;
font-size: 16px;
cursor: pointer;
outline: none;
}
&__header-description {
padding: 0 var(--spacing-s) var(--spacing-3xs) var(--spacing-s);
font-size: var(--font-size-xs);
}
.error-details__icon {
margin-right: 4px;
}
&__info {
max-width: 960px;
margin: 0 auto;
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-large);
}
details > summary {
list-style-type: none;
}
&__info-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-3xs) var(--spacing-3xs) var(--spacing-3xs) var(--spacing-s);
border-bottom: 1px solid var(--color-foreground-base);
}
details > summary::-webkit-details-marker {
display: none;
}
&__info-title {
font-size: var(--font-size-2xs);
font-weight: var(--font-weight-bold);
color: var(--color-text-dark);
}
details[open] {
.error-details__icon {
transform: rotate(90deg);
&__info-content {
padding: var(--spacing-2xs) var(--spacing-s);
}
&__details:not(:last-child) {
margin-bottom: var(--spacing-2xs);
}
&__details[open] {
.node-error-view__details {
&-icon {
transform: rotate(90deg);
transition: transform 0.3s ease;
}
}
}
&__details-summary {
padding: var(--spacing-5xs) 0;
font-size: var(--font-size-2xs);
color: var(--color-text-dark);
cursor: pointer;
list-style-type: none;
outline: none;
}
&__details-content {
padding: var(--spacing-2xs) var(--spacing-s);
}
&__details-row {
display: flex;
padding: var(--spacing-4xs) 0;
}
&__details-row:not(:first-child) {
border-top: 1px solid var(--color-foreground-base);
}
&__details-icon {
margin-right: var(--spacing-xs);
}
&__details-label {
flex-grow: 0;
flex-shrink: 0;
width: 120px;
color: var(--color-text);
font-size: var(--font-size-2xs);
}
&__details-value {
flex: 1;
overflow: hidden;
margin-right: auto;
color: var(--color-text);
font-size: var(--font-size-2xs);
word-wrap: break-word;
code {
color: var(--color-json-string);
text-wrap: wrap;
word-wrap: break-word;
}
}
}
.error-details__content {
margin-top: 15px;
}
.el-divider__text {
background-color: var(--color-background-light);
}
.box-card {
margin-top: 1em;
overflow: auto;
}
.box-card__title {
font-weight: 400;
}
.box-card__subtitle {
font-weight: 200;
font-style: italic;
font-size: 0.7rem;
}
.copy-button {
position: absolute;
right: 50px;
z-index: 1000;
}
</style>