@n8n/frontend-module-inbox 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -2
- package/package.json +12 -12
- package/src/components/InboxList.test.ts +53 -1
- package/src/components/InboxList.vue +7 -12
- package/src/components/InboxListItem.vue +54 -22
- package/src/inbox.constants.ts +3 -1
- package/src/inbox.module.ts +6 -1
- package/src/inbox.routes.test.ts +26 -0
- package/src/inbox.routes.ts +15 -2
- package/src/inbox.store.ts +8 -2
- package/src/promotions/PromotionReviewDetail.test.ts +244 -0
- package/src/promotions/PromotionReviewDetail.vue +395 -0
- package/src/promotions/promotionReviews.api.ts +15 -0
- package/src/self-healing/SelfHealingResultActivity.vue +160 -0
- package/src/self-healing/SelfHealingResultContent.test.ts +458 -0
- package/src/self-healing/SelfHealingResultContent.vue +430 -0
- package/src/self-healing/SelfHealingResultDetail.test.ts +804 -0
- package/src/self-healing/SelfHealingResultDetail.vue +340 -8
- package/src/self-healing/SelfHealingResultMetadata.vue +282 -0
- package/src/self-healing/selfHealingResult.store.test.ts +86 -0
- package/src/self-healing/selfHealingResult.store.ts +153 -0
- package/src/self-healing/selfHealingResults.api.test.ts +24 -0
- package/src/self-healing/selfHealingResults.api.ts +38 -0
- package/src/self-healing/selfHealingResults.test.utils.ts +106 -0
- package/src/views/InboxView.test.ts +126 -4
- package/src/views/InboxView.vue +26 -10
|
@@ -1,19 +1,351 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import type { SelfHealingResultActionResponse, SelfHealingResultDetail } from '@n8n/api-types';
|
|
3
|
+
import { useToast } from '@n8n/composables/useToast';
|
|
4
|
+
import { N8nButton, N8nCallout, N8nEmptyState, N8nLoading } from '@n8n/design-system';
|
|
5
|
+
import { VIEWS } from '@n8n/frontend-constants/views';
|
|
6
|
+
import {
|
|
7
|
+
capabilities,
|
|
8
|
+
capabilityRegistry,
|
|
9
|
+
type SelfHealingChatStartOutcome,
|
|
10
|
+
} from '@n8n/frontend-module-sdk';
|
|
3
11
|
import { useI18n } from '@n8n/i18n';
|
|
12
|
+
import { ResponseError } from '@n8n/rest-api-client';
|
|
13
|
+
import { useRootStore } from '@n8n/stores/useRootStore';
|
|
14
|
+
import { storeToRefs } from 'pinia';
|
|
15
|
+
import { computed, ref, watch } from 'vue';
|
|
16
|
+
import { useRouter } from 'vue-router';
|
|
4
17
|
|
|
5
|
-
import type {
|
|
18
|
+
import type { InboxItemChange } from '../inbox.constants';
|
|
19
|
+
import SelfHealingResultContent from './SelfHealingResultContent.vue';
|
|
20
|
+
import { useSelfHealingResultStore } from './selfHealingResult.store';
|
|
21
|
+
import {
|
|
22
|
+
fetchSelfHealingResult,
|
|
23
|
+
reviewSelfHealingResult,
|
|
24
|
+
type SelfHealingPublishAccess,
|
|
25
|
+
type SelfHealingResultAction,
|
|
26
|
+
type SelfHealingReviewAction,
|
|
27
|
+
type SelfHealingSelection,
|
|
28
|
+
} from './selfHealingResults.api';
|
|
6
29
|
|
|
7
|
-
|
|
8
|
-
|
|
30
|
+
type ChatRetry = {
|
|
31
|
+
target: SelfHealingSelection;
|
|
32
|
+
projectId: string;
|
|
33
|
+
result: SelfHealingResultDetail;
|
|
34
|
+
reason: Exclude<SelfHealingChatStartOutcome, 'started'> | 'unconfirmed';
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const props = defineProps<{
|
|
38
|
+
selection: SelfHealingSelection;
|
|
39
|
+
tab: 'activity' | 'changes';
|
|
40
|
+
onItemChange: (change: InboxItemChange) => void;
|
|
41
|
+
isSelected: (id: string) => boolean;
|
|
42
|
+
}>();
|
|
43
|
+
const emit = defineEmits<{ 'update:tab': [tab: 'activity' | 'changes'] }>();
|
|
44
|
+
|
|
45
|
+
const store = useSelfHealingResultStore();
|
|
46
|
+
const { detail, workflow, loading, error, workflowError, pendingAction } = storeToRefs(store);
|
|
47
|
+
const rootStore = useRootStore();
|
|
48
|
+
const router = useRouter();
|
|
9
49
|
const i18n = useI18n();
|
|
50
|
+
const { showError, showMessage } = useToast();
|
|
51
|
+
const chat = capabilityRegistry.tryUse(capabilities.createSelfHealingChatHandoff)?.();
|
|
52
|
+
const chatRetry = ref<ChatRetry | null>(null);
|
|
53
|
+
let reconcileOnRetry: ((current: SelfHealingResultDetail) => Promise<boolean>) | null = null;
|
|
54
|
+
|
|
55
|
+
const workflowName = computed(
|
|
56
|
+
() =>
|
|
57
|
+
workflow.value?.name ??
|
|
58
|
+
detail.value?.suggestion?.payload.original.name ??
|
|
59
|
+
props.selection.workflowId,
|
|
60
|
+
);
|
|
61
|
+
const publishAccess = computed<SelfHealingPublishAccess>(() => {
|
|
62
|
+
if (!workflow.value) return 'unknown';
|
|
63
|
+
return workflow.value.scopes.includes('workflow:publish') ? 'allowed' : 'denied';
|
|
64
|
+
});
|
|
65
|
+
const errorKind = computed(() => {
|
|
66
|
+
if (error.value instanceof ResponseError) {
|
|
67
|
+
if (error.value.httpStatusCode === 404) return 'notFound';
|
|
68
|
+
if (error.value.httpStatusCode === 403) return 'forbidden';
|
|
69
|
+
}
|
|
70
|
+
return 'loadError';
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
watch(
|
|
74
|
+
[() => props.selection.id, () => props.selection.projectId, () => props.selection.workflowId],
|
|
75
|
+
() => {
|
|
76
|
+
reconcileOnRetry = null;
|
|
77
|
+
chatRetry.value = null;
|
|
78
|
+
void store.select(props.selection);
|
|
79
|
+
},
|
|
80
|
+
{ immediate: true },
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
/** A client error status means the server rejected the action, so nothing was saved. */
|
|
84
|
+
function isRejected(cause: unknown) {
|
|
85
|
+
return (
|
|
86
|
+
cause instanceof ResponseError &&
|
|
87
|
+
cause.httpStatusCode !== undefined &&
|
|
88
|
+
cause.httpStatusCode < 500
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function notifyResultClosed(
|
|
93
|
+
before: SelfHealingResultDetail,
|
|
94
|
+
after: SelfHealingResultDetail,
|
|
95
|
+
onItemChange: (change: InboxItemChange) => void,
|
|
96
|
+
) {
|
|
97
|
+
if (before.reviewState === 'open' && after.reviewState !== 'open') {
|
|
98
|
+
onItemChange({
|
|
99
|
+
type: 'self_healing_result',
|
|
100
|
+
id: after.resultId,
|
|
101
|
+
state: 'closed',
|
|
102
|
+
updatedAt: after.updatedAt,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function openEditor(workflowId: string) {
|
|
108
|
+
try {
|
|
109
|
+
const failure = await router.push({ name: VIEWS.WORKFLOW, params: { workflowId } });
|
|
110
|
+
if (failure) throw failure;
|
|
111
|
+
} catch (cause) {
|
|
112
|
+
showError(cause, i18n.baseText('inbox.selfHealing.action.openEditorError'));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function startChat(request: Omit<ChatRetry, 'reason'>) {
|
|
117
|
+
let outcome: SelfHealingChatStartOutcome;
|
|
118
|
+
try {
|
|
119
|
+
outcome = (await chat?.start(request.projectId, request.result)) ?? 'unavailable';
|
|
120
|
+
} catch (cause) {
|
|
121
|
+
outcome = 'failed';
|
|
122
|
+
if (store.isSelected(request.target)) {
|
|
123
|
+
showError(cause, i18n.baseText('inbox.selfHealing.action.chatError'));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!store.isSelected(request.target)) return;
|
|
128
|
+
chatRetry.value = outcome === 'started' ? null : { ...request, reason: outcome };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function retryChat() {
|
|
132
|
+
const retry = chatRetry.value;
|
|
133
|
+
if (!retry || !chat?.available.value || !store.isSelected(retry.target)) return;
|
|
134
|
+
if (!store.beginAction(retry.target, 'chat')) return;
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
await startChat(retry);
|
|
138
|
+
} finally {
|
|
139
|
+
store.endAction(retry.target);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function retryDetail() {
|
|
144
|
+
if (pendingAction.value) return;
|
|
145
|
+
|
|
146
|
+
const reconcile = reconcileOnRetry;
|
|
147
|
+
const result = await store.refreshDetail();
|
|
148
|
+
if (!reconcile || !result || reconcileOnRetry !== reconcile) return;
|
|
149
|
+
|
|
150
|
+
reconcileOnRetry = null;
|
|
151
|
+
await reconcile(result);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function onAction(action: SelfHealingResultAction) {
|
|
155
|
+
const before = detail.value;
|
|
156
|
+
if (before?.reviewState !== 'open' || pendingAction.value || !store.isSelected(props.selection))
|
|
157
|
+
return;
|
|
158
|
+
|
|
159
|
+
if (
|
|
160
|
+
action === 'approve-and-publish' &&
|
|
161
|
+
(publishAccess.value !== 'allowed' || before.outcome !== 'fix_ready')
|
|
162
|
+
)
|
|
163
|
+
return;
|
|
164
|
+
if (action === 'chat' && !chat?.available.value) return;
|
|
165
|
+
|
|
166
|
+
const target = { ...props.selection };
|
|
167
|
+
if (!store.beginAction(target, action)) return;
|
|
168
|
+
|
|
169
|
+
const isSelected = props.isSelected;
|
|
170
|
+
const onItemChange = props.onItemChange;
|
|
171
|
+
const isContinuation = action === 'chat' || action === 'editor';
|
|
172
|
+
|
|
173
|
+
let reviewAction: SelfHealingReviewAction;
|
|
174
|
+
if (isContinuation) {
|
|
175
|
+
reviewAction = before.suggestion ? 'apply' : 'dismiss';
|
|
176
|
+
} else {
|
|
177
|
+
reviewAction = action;
|
|
178
|
+
}
|
|
179
|
+
const closedState = reviewAction === 'dismiss' ? 'dismissed' : 'applied';
|
|
180
|
+
let chatProjectId: string | null = null;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Without a response, the saved state does not show which request closed the result.
|
|
184
|
+
* So the chat waits for an explicit start, and publication stays unconfirmed.
|
|
185
|
+
*/
|
|
186
|
+
async function continueAfterClose(
|
|
187
|
+
result: SelfHealingResultActionResponse,
|
|
188
|
+
responseLost: boolean,
|
|
189
|
+
) {
|
|
190
|
+
// An outdated or concurrently discarded suggestion leaves nothing to continue with.
|
|
191
|
+
const closed =
|
|
192
|
+
result.reviewState === closedState ||
|
|
193
|
+
(action === 'dismiss' && result.reviewState === 'discarded');
|
|
194
|
+
if (!isSelected(target.id) || !closed) return false;
|
|
195
|
+
|
|
196
|
+
if (chatProjectId) {
|
|
197
|
+
const request = { target, projectId: chatProjectId, result };
|
|
198
|
+
if (responseLost) chatRetry.value = { ...request, reason: 'unconfirmed' };
|
|
199
|
+
else await startChat(request);
|
|
200
|
+
} else if (action !== 'dismiss') {
|
|
201
|
+
if (responseLost && action === 'approve-and-publish') {
|
|
202
|
+
showMessage({
|
|
203
|
+
type: 'warning',
|
|
204
|
+
duration: 0,
|
|
205
|
+
title: i18n.baseText('inbox.selfHealing.action.publishUnconfirmed.title'),
|
|
206
|
+
message: i18n.baseText('inbox.selfHealing.action.publishUnconfirmed.message'),
|
|
207
|
+
});
|
|
208
|
+
} else if (result.publishError !== undefined) {
|
|
209
|
+
showMessage({
|
|
210
|
+
type: 'warning',
|
|
211
|
+
duration: 0,
|
|
212
|
+
title: i18n.baseText('inbox.selfHealing.action.publishError'),
|
|
213
|
+
message: result.publishError,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await openEditor(result.workflowId);
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
if (action === 'chat') {
|
|
224
|
+
// Load the chat project before closing, so a failed lookup leaves the result open.
|
|
225
|
+
chatProjectId = (await chat?.resolveProjectId().catch(() => null)) ?? null;
|
|
226
|
+
if (!isSelected(target.id)) return;
|
|
227
|
+
if (!chatProjectId) {
|
|
228
|
+
showMessage({
|
|
229
|
+
type: 'error',
|
|
230
|
+
title: i18n.baseText('inbox.selfHealing.action.chatError'),
|
|
231
|
+
message: i18n.baseText('inbox.selfHealing.action.chatProjectError'),
|
|
232
|
+
});
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const result = await reviewSelfHealingResult(rootStore.restApiContext, target, reviewAction);
|
|
238
|
+
|
|
239
|
+
store.acceptResult(result);
|
|
240
|
+
await continueAfterClose(result, false);
|
|
241
|
+
notifyResultClosed(before, result, onItemChange);
|
|
242
|
+
} catch (cause) {
|
|
243
|
+
// Refresh saved state before the user retries a request with a lost response.
|
|
244
|
+
const reconcile = async (current: SelfHealingResultDetail) => {
|
|
245
|
+
const continued = !isRejected(cause) && (await continueAfterClose(current, true));
|
|
246
|
+
notifyResultClosed(before, current, onItemChange);
|
|
247
|
+
return continued;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const current = await fetchSelfHealingResult(rootStore.restApiContext, target);
|
|
252
|
+
|
|
253
|
+
store.acceptResult(current);
|
|
254
|
+
if (isSelected(target.id)) void store.refreshWorkflow();
|
|
255
|
+
if (await reconcile(current)) return;
|
|
256
|
+
} catch (readError) {
|
|
257
|
+
store.setError(target, readError);
|
|
258
|
+
if (isSelected(target.id)) reconcileOnRetry = reconcile;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (isSelected(target.id)) showError(cause, i18n.baseText('inbox.selfHealing.action.error'));
|
|
262
|
+
} finally {
|
|
263
|
+
store.endAction(target);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
10
266
|
</script>
|
|
11
267
|
|
|
12
268
|
<template>
|
|
13
|
-
<
|
|
14
|
-
:
|
|
15
|
-
:description="i18n.baseText('inbox.resultUnavailable.body')"
|
|
269
|
+
<section
|
|
270
|
+
:class="$style.detail"
|
|
16
271
|
data-test-id="inbox-self-healing-detail"
|
|
17
272
|
:data-result-id="selection.id"
|
|
18
|
-
|
|
273
|
+
>
|
|
274
|
+
<template v-if="loading || error || !detail">
|
|
275
|
+
<div :class="$style.columnTitle" />
|
|
276
|
+
<N8nLoading v-if="loading" :loading="true" :rows="3" />
|
|
277
|
+
<div v-else-if="error" :class="$style.error" data-test-id="self-healing-detail-error">
|
|
278
|
+
<N8nEmptyState
|
|
279
|
+
:heading="i18n.baseText(`inbox.selfHealing.detail.${errorKind}.title`)"
|
|
280
|
+
:description="i18n.baseText(`inbox.selfHealing.detail.${errorKind}.body`)"
|
|
281
|
+
/>
|
|
282
|
+
<N8nButton :loading="pendingAction !== null" @click="retryDetail()">{{
|
|
283
|
+
i18n.baseText('generic.retry')
|
|
284
|
+
}}</N8nButton>
|
|
285
|
+
</div>
|
|
286
|
+
</template>
|
|
287
|
+
<template v-else>
|
|
288
|
+
<SelfHealingResultContent
|
|
289
|
+
:detail="detail"
|
|
290
|
+
:workflow-name="workflowName"
|
|
291
|
+
:tab="tab"
|
|
292
|
+
:pending-action="pendingAction"
|
|
293
|
+
:publish-access="publishAccess"
|
|
294
|
+
:can-chat="chat?.available.value ?? false"
|
|
295
|
+
@update:tab="emit('update:tab', $event)"
|
|
296
|
+
@action="onAction"
|
|
297
|
+
>
|
|
298
|
+
<template v-if="workflowError || chatRetry" #notice>
|
|
299
|
+
<N8nCallout v-if="workflowError" theme="warning">
|
|
300
|
+
{{ i18n.baseText('inbox.selfHealing.detail.metadataError') }}
|
|
301
|
+
<template #trailingContent>
|
|
302
|
+
<N8nButton variant="ghost" @click="store.refreshWorkflow()">{{
|
|
303
|
+
i18n.baseText('generic.retry')
|
|
304
|
+
}}</N8nButton>
|
|
305
|
+
</template>
|
|
306
|
+
</N8nCallout>
|
|
307
|
+
<N8nCallout
|
|
308
|
+
v-if="chatRetry"
|
|
309
|
+
:theme="chatRetry.reason === 'unconfirmed' ? 'info' : 'warning'"
|
|
310
|
+
data-test-id="self-healing-chat-retry"
|
|
311
|
+
>
|
|
312
|
+
{{ i18n.baseText(`inbox.selfHealing.chatRetry.${chatRetry.reason}`) }}
|
|
313
|
+
<template #trailingContent>
|
|
314
|
+
<N8nButton
|
|
315
|
+
variant="ghost"
|
|
316
|
+
:disabled="!chat?.available.value"
|
|
317
|
+
:loading="pendingAction === 'chat'"
|
|
318
|
+
data-test-id="self-healing-chat-retry-button"
|
|
319
|
+
@click="retryChat()"
|
|
320
|
+
>{{ i18n.baseText('inbox.selfHealing.action.continueWithAssistant') }}</N8nButton
|
|
321
|
+
>
|
|
322
|
+
</template>
|
|
323
|
+
</N8nCallout>
|
|
324
|
+
</template>
|
|
325
|
+
</SelfHealingResultContent>
|
|
326
|
+
</template>
|
|
327
|
+
</section>
|
|
19
328
|
</template>
|
|
329
|
+
|
|
330
|
+
<style lang="scss" module>
|
|
331
|
+
.detail {
|
|
332
|
+
display: flex;
|
|
333
|
+
flex: 1;
|
|
334
|
+
flex-direction: column;
|
|
335
|
+
min-height: 0;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.columnTitle {
|
|
339
|
+
min-height: var(--spacing--2xl);
|
|
340
|
+
padding-bottom: var(--spacing--sm);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.error {
|
|
344
|
+
display: flex;
|
|
345
|
+
flex-direction: column;
|
|
346
|
+
align-items: center;
|
|
347
|
+
justify-content: center;
|
|
348
|
+
gap: var(--spacing--sm);
|
|
349
|
+
height: 100%;
|
|
350
|
+
}
|
|
351
|
+
</style>
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SelfHealingResultDetail } from '@n8n/api-types';
|
|
3
|
+
import {
|
|
4
|
+
N8nAssistantAvatar,
|
|
5
|
+
N8nCard,
|
|
6
|
+
N8nIcon,
|
|
7
|
+
N8nLink,
|
|
8
|
+
N8nText,
|
|
9
|
+
N8nTimeAgo,
|
|
10
|
+
} from '@n8n/design-system';
|
|
11
|
+
import { VIEWS } from '@n8n/frontend-constants/views';
|
|
12
|
+
import { useI18n } from '@n8n/i18n';
|
|
13
|
+
import { useRootStore } from '@n8n/stores/useRootStore';
|
|
14
|
+
import { computed } from 'vue';
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{
|
|
17
|
+
detail: SelfHealingResultDetail;
|
|
18
|
+
workflowName: string;
|
|
19
|
+
statusLabel: string;
|
|
20
|
+
}>();
|
|
21
|
+
const i18n = useI18n();
|
|
22
|
+
const rootStore = useRootStore();
|
|
23
|
+
|
|
24
|
+
const usageRows = computed(() => {
|
|
25
|
+
const usage = props.detail.usage;
|
|
26
|
+
const measurements =
|
|
27
|
+
'credits' in usage
|
|
28
|
+
? [
|
|
29
|
+
{
|
|
30
|
+
key: 'credits',
|
|
31
|
+
label: i18n.baseText('inbox.selfHealing.usage.credits'),
|
|
32
|
+
value: usage.credits,
|
|
33
|
+
},
|
|
34
|
+
]
|
|
35
|
+
: [
|
|
36
|
+
{
|
|
37
|
+
key: 'promptTokens',
|
|
38
|
+
label: i18n.baseText('inbox.selfHealing.usage.promptTokens'),
|
|
39
|
+
value: usage.promptTokens,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
key: 'completionTokens',
|
|
43
|
+
label: i18n.baseText('inbox.selfHealing.usage.completionTokens'),
|
|
44
|
+
value: usage.completionTokens,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
key: 'totalTokens',
|
|
48
|
+
label: i18n.baseText('inbox.selfHealing.usage.totalTokens'),
|
|
49
|
+
value: usage.totalTokens,
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const rows = measurements.flatMap((row) => {
|
|
54
|
+
if (row.value === null) return [];
|
|
55
|
+
|
|
56
|
+
return [{ ...row, value: row.value.toLocaleString(rootStore.defaultLocale) }];
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
if (usage.durationSeconds !== null) {
|
|
60
|
+
rows.push({
|
|
61
|
+
key: 'duration',
|
|
62
|
+
label: i18n.baseText('inbox.selfHealing.usage.duration'),
|
|
63
|
+
value: i18n.baseText('inbox.selfHealing.usage.durationValue', {
|
|
64
|
+
interpolate: {
|
|
65
|
+
minutes: String(Math.floor(usage.durationSeconds / 60)),
|
|
66
|
+
seconds: String(Math.floor(usage.durationSeconds % 60)),
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return rows;
|
|
73
|
+
});
|
|
74
|
+
</script>
|
|
75
|
+
|
|
76
|
+
<template>
|
|
77
|
+
<aside :class="$style.metadata" data-test-id="self-healing-metadata">
|
|
78
|
+
<N8nCard :class="$style.card" data-test-id="self-healing-status-card">
|
|
79
|
+
<template #header>
|
|
80
|
+
<N8nText bold color="text-light" size="medium">{{
|
|
81
|
+
i18n.baseText('inbox.selfHealing.metadata.status')
|
|
82
|
+
}}</N8nText>
|
|
83
|
+
</template>
|
|
84
|
+
<N8nText size="medium">{{ statusLabel }}</N8nText>
|
|
85
|
+
</N8nCard>
|
|
86
|
+
<N8nCard :class="[$style.card, $style.peopleCard]">
|
|
87
|
+
<div :class="$style.section">
|
|
88
|
+
<N8nText bold color="text-light" size="medium">{{
|
|
89
|
+
i18n.baseText('inbox.selfHealing.metadata.createdBy')
|
|
90
|
+
}}</N8nText>
|
|
91
|
+
<div :class="$style.person">
|
|
92
|
+
<N8nAssistantAvatar size="small" />
|
|
93
|
+
<N8nText size="medium">{{
|
|
94
|
+
i18n.baseText('inbox.selfHealing.metadata.assistant')
|
|
95
|
+
}}</N8nText>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
<div :class="$style.section">
|
|
99
|
+
<N8nText bold color="text-light" size="medium">{{
|
|
100
|
+
i18n.baseText('inbox.selfHealing.metadata.completed')
|
|
101
|
+
}}</N8nText>
|
|
102
|
+
<N8nText size="medium">
|
|
103
|
+
<time :datetime="detail.completedAt"
|
|
104
|
+
><N8nTimeAgo :date="detail.completedAt" :locale="rootStore.defaultLocale"
|
|
105
|
+
/></time>
|
|
106
|
+
</N8nText>
|
|
107
|
+
</div>
|
|
108
|
+
</N8nCard>
|
|
109
|
+
<N8nCard :class="$style.card">
|
|
110
|
+
<template #header>
|
|
111
|
+
<N8nText bold color="text-light" size="medium">{{
|
|
112
|
+
i18n.baseText('inbox.selfHealing.metadata.workflow')
|
|
113
|
+
}}</N8nText>
|
|
114
|
+
</template>
|
|
115
|
+
<div :class="$style.section">
|
|
116
|
+
<N8nLink
|
|
117
|
+
:to="{ name: VIEWS.WORKFLOW, params: { workflowId: detail.workflowId } }"
|
|
118
|
+
theme="text"
|
|
119
|
+
size="medium"
|
|
120
|
+
:underline="false"
|
|
121
|
+
:class="$style.plainLink"
|
|
122
|
+
data-test-id="self-healing-workflow-link"
|
|
123
|
+
>
|
|
124
|
+
<span :class="$style.link">
|
|
125
|
+
<N8nIcon icon="workflow" size="medium" :class="$style.workflowIcon" />
|
|
126
|
+
<span :class="$style.workflowName">{{ workflowName }}</span>
|
|
127
|
+
</span>
|
|
128
|
+
</N8nLink>
|
|
129
|
+
</div>
|
|
130
|
+
<div :class="$style.section">
|
|
131
|
+
<N8nText bold color="text-light" size="medium">{{
|
|
132
|
+
i18n.baseText('inbox.selfHealing.metadata.execution')
|
|
133
|
+
}}</N8nText>
|
|
134
|
+
<N8nLink
|
|
135
|
+
v-if="detail.execution.status === 'available'"
|
|
136
|
+
:to="{
|
|
137
|
+
name: VIEWS.EXECUTION_PREVIEW,
|
|
138
|
+
params: { workflowId: detail.workflowId, executionId: detail.execution.id },
|
|
139
|
+
}"
|
|
140
|
+
theme="text"
|
|
141
|
+
size="medium"
|
|
142
|
+
:underline="false"
|
|
143
|
+
:class="$style.plainLink"
|
|
144
|
+
data-test-id="self-healing-execution-link"
|
|
145
|
+
>
|
|
146
|
+
{{
|
|
147
|
+
i18n.baseText('inbox.selfHealing.metadata.executionLink', {
|
|
148
|
+
interpolate: { id: detail.execution.id },
|
|
149
|
+
})
|
|
150
|
+
}}
|
|
151
|
+
</N8nLink>
|
|
152
|
+
<N8nText
|
|
153
|
+
v-else
|
|
154
|
+
size="small"
|
|
155
|
+
color="text-light"
|
|
156
|
+
data-test-id="self-healing-execution-unavailable"
|
|
157
|
+
>
|
|
158
|
+
{{ i18n.baseText('inbox.selfHealing.metadata.executionUnavailable') }}
|
|
159
|
+
</N8nText>
|
|
160
|
+
</div>
|
|
161
|
+
</N8nCard>
|
|
162
|
+
<N8nCard v-if="usageRows.length" :class="$style.card" data-test-id="self-healing-usage">
|
|
163
|
+
<template #header>
|
|
164
|
+
<N8nText bold color="text-light" size="medium">{{
|
|
165
|
+
i18n.baseText('inbox.selfHealing.usage.title')
|
|
166
|
+
}}</N8nText>
|
|
167
|
+
</template>
|
|
168
|
+
<dl :class="$style.usage">
|
|
169
|
+
<div
|
|
170
|
+
v-for="row in usageRows"
|
|
171
|
+
:key="row.key"
|
|
172
|
+
:class="$style.measurement"
|
|
173
|
+
:data-test-id="`self-healing-usage-${row.key}`"
|
|
174
|
+
>
|
|
175
|
+
<N8nText tag="dt" size="small" color="text-light">{{ row.label }}</N8nText>
|
|
176
|
+
<N8nText tag="dd" size="small">{{ row.value }}</N8nText>
|
|
177
|
+
</div>
|
|
178
|
+
</dl>
|
|
179
|
+
</N8nCard>
|
|
180
|
+
</aside>
|
|
181
|
+
</template>
|
|
182
|
+
|
|
183
|
+
<style module lang="scss">
|
|
184
|
+
.metadata {
|
|
185
|
+
display: flex;
|
|
186
|
+
/* Match the prototype's metadata rail. The design system has no column-width token. */
|
|
187
|
+
flex: 0 0 min(18rem, 30%);
|
|
188
|
+
min-width: 14rem;
|
|
189
|
+
flex-direction: column;
|
|
190
|
+
gap: var(--spacing--2xs);
|
|
191
|
+
padding-top: var(--spacing--5xs);
|
|
192
|
+
overflow: auto;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.card {
|
|
196
|
+
--card--padding: var(--spacing--xs);
|
|
197
|
+
--n8n--card-body--gap: var(--spacing--2xs);
|
|
198
|
+
align-items: stretch;
|
|
199
|
+
border-color: var(--border-color);
|
|
200
|
+
background-color: transparent;
|
|
201
|
+
|
|
202
|
+
> div {
|
|
203
|
+
gap: var(--spacing--4xs);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.peopleCard {
|
|
208
|
+
--n8n--card-body--gap: var(--spacing--sm);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.section {
|
|
212
|
+
display: flex;
|
|
213
|
+
flex-direction: column;
|
|
214
|
+
gap: var(--spacing--2xs);
|
|
215
|
+
min-width: 0;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.person,
|
|
219
|
+
.link {
|
|
220
|
+
display: flex;
|
|
221
|
+
align-items: center;
|
|
222
|
+
gap: var(--spacing--2xs);
|
|
223
|
+
min-width: 0;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.link {
|
|
227
|
+
align-items: flex-start;
|
|
228
|
+
gap: var(--spacing--4xs);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.plainLink:hover,
|
|
232
|
+
.plainLink:focus-visible {
|
|
233
|
+
text-decoration: underline;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
.workflowIcon {
|
|
237
|
+
flex-shrink: 0;
|
|
238
|
+
/* Align the icon with the first line, as in the prototype. */
|
|
239
|
+
margin-top: calc((1lh - 1em) / 2);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.workflowName {
|
|
243
|
+
white-space: normal;
|
|
244
|
+
overflow-wrap: anywhere;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.usage {
|
|
248
|
+
display: flex;
|
|
249
|
+
flex-direction: column;
|
|
250
|
+
gap: var(--spacing--4xs);
|
|
251
|
+
margin: 0;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.measurement {
|
|
255
|
+
display: flex;
|
|
256
|
+
justify-content: space-between;
|
|
257
|
+
gap: var(--spacing--2xs);
|
|
258
|
+
|
|
259
|
+
dd {
|
|
260
|
+
margin: 0;
|
|
261
|
+
text-align: right;
|
|
262
|
+
font-variant-numeric: tabular-nums;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* Match the prototype's sidebar widths. There are no column-width tokens. */
|
|
267
|
+
@media (max-width: 75rem) {
|
|
268
|
+
.metadata {
|
|
269
|
+
flex-basis: 15rem;
|
|
270
|
+
min-width: 12rem;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/* Match the prototype's detail breakpoint. Container queries cannot use CSS variables. */
|
|
275
|
+
@container assistant-detail (max-width: 44rem) {
|
|
276
|
+
.metadata {
|
|
277
|
+
flex: 0 0 auto;
|
|
278
|
+
min-width: 0;
|
|
279
|
+
overflow: visible;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
</style>
|