@revenexx/studio-integrations 1.3.0 → 1.4.1
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/CHANGELOG.md +457 -0
- package/dist/module.json +1 -1
- package/dist/runtime/components/Integrations/CanvasShortcutsDialog.d.vue.ts +9 -0
- package/dist/runtime/components/Integrations/CanvasShortcutsDialog.vue +53 -0
- package/dist/runtime/components/Integrations/CanvasShortcutsDialog.vue.d.ts +9 -0
- package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +61 -29
- package/dist/runtime/components/Integrations/EditorTip.vue +2 -15
- package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +10 -0
- package/dist/runtime/components/Integrations/NodeConfigField.vue +94 -49
- package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +10 -0
- package/dist/runtime/components/Integrations/NodeInspector.vue +37 -60
- package/dist/runtime/components/Integrations/RunDetailPanel.vue +80 -33
- package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +0 -4
- package/dist/runtime/components/Integrations/TemplateCard.vue +3 -6
- package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +0 -4
- package/dist/runtime/components/Integrations/TemplateInstallStep.d.vue.ts +25 -0
- package/dist/runtime/components/Integrations/TemplateInstallStep.vue +198 -0
- package/dist/runtime/components/Integrations/TemplateInstallStep.vue.d.ts +25 -0
- package/dist/runtime/components/Integrations/WorkflowContextMenu.d.vue.ts +2 -2
- package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +22 -40
- package/dist/runtime/components/Integrations/WorkflowContextMenu.vue.d.ts +2 -2
- package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +94 -65
- package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +36 -2
- package/dist/runtime/components/Integrations/WorkflowEditor.vue +165 -56
- package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +36 -2
- package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.d.vue.ts +6 -2
- package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +4 -106
- package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue.d.ts +6 -2
- package/dist/runtime/components/Integrations/WorkflowsTable.vue +22 -11
- package/dist/runtime/composables/useCanvasShortcuts.d.ts +34 -0
- package/dist/runtime/composables/useCanvasShortcuts.js +11 -8
- package/dist/runtime/composables/useCredentialTypes.js +3 -9
- package/dist/runtime/composables/useIntegrationsCredentials.js +6 -14
- package/dist/runtime/composables/useIntegrationsNodes.js +2 -2
- package/dist/runtime/composables/useIntegrationsRuns.js +8 -13
- package/dist/runtime/composables/useIntegrationsSecrets.js +5 -5
- package/dist/runtime/composables/useIntegrationsTemplates.js +5 -8
- package/dist/runtime/composables/useIntegrationsTriggers.js +1 -4
- package/dist/runtime/composables/useIntegrationsWorkflows.js +5 -7
- package/dist/runtime/data/canvasShortcuts.d.ts +208 -0
- package/dist/runtime/data/canvasShortcuts.js +68 -0
- package/dist/runtime/i18n/messages/de.json +30 -16
- package/dist/runtime/i18n/messages/en.json +30 -16
- package/dist/runtime/pages/integrations/credentials.vue +40 -18
- package/dist/runtime/pages/integrations/templates/[slug].vue +22 -132
- package/dist/runtime/pages/integrations/workflows/[id].vue +128 -33
- package/dist/runtime/utils/apiEnvelope.d.ts +27 -0
- package/dist/runtime/utils/apiEnvelope.js +20 -0
- package/dist/runtime/utils/apiError.d.ts +35 -8
- package/dist/runtime/utils/apiError.js +12 -12
- package/dist/runtime/utils/configDefaults.d.ts +18 -0
- package/dist/runtime/utils/configDefaults.js +7 -0
- package/dist/runtime/utils/errorPathAdvice.d.ts +60 -0
- package/dist/runtime/utils/errorPathAdvice.js +19 -0
- package/dist/runtime/utils/notify.d.ts +2 -2
- package/dist/runtime/utils/runEnvelope.d.ts +12 -0
- package/dist/runtime/utils/runEnvelope.js +2 -1
- package/dist/runtime/utils/selectOptions.d.ts +48 -0
- package/dist/runtime/utils/selectOptions.js +19 -0
- package/dist/runtime/utils/shortcutLabel.d.ts +40 -0
- package/dist/runtime/utils/shortcutLabel.js +48 -0
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed, onMounted, reactive, ref } from "vue";
|
|
2
|
+
import { computed, nextTick, onMounted, reactive, ref } from "vue";
|
|
3
3
|
import {
|
|
4
4
|
RefreshCircle as Loader2,
|
|
5
5
|
AddCircle as Plus,
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
SelectValue
|
|
32
32
|
} from "@revenexx/studio";
|
|
33
33
|
import { toast } from "../../utils/notify";
|
|
34
|
-
import {
|
|
34
|
+
import { apiRefusal, errorReason } from "../../utils/apiError";
|
|
35
35
|
import { CREDENTIAL_STATES, credentialState, credentialStateLabel } from "../../utils/credentialState";
|
|
36
36
|
import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
|
|
37
37
|
import { resolveLocalized } from "../../utils/localize";
|
|
@@ -92,19 +92,36 @@ const form = reactive({ name: "", config: {} });
|
|
|
92
92
|
const saving = ref(false);
|
|
93
93
|
const testing = ref(false);
|
|
94
94
|
const testResult = ref(null);
|
|
95
|
-
const
|
|
96
|
-
const fieldErrors = ref({});
|
|
95
|
+
const saveRefusal = ref(null);
|
|
97
96
|
const sourceActive = ref(false);
|
|
98
97
|
const isEdit = computed(() => editing.value !== null);
|
|
99
98
|
function fieldsOf(t2) {
|
|
100
99
|
return t2?.fields ?? [];
|
|
101
100
|
}
|
|
101
|
+
const drawnKeys = computed(() => [
|
|
102
|
+
"name",
|
|
103
|
+
...sourceActive.value ? [] : fieldsOf(pickedType.value).map((f) => `config.${f.key}`)
|
|
104
|
+
]);
|
|
105
|
+
const refusal = computed(() => apiRefusal(saveRefusal.value, drawnKeys.value));
|
|
106
|
+
const fieldErrors = computed(() => refusal.value.atFields);
|
|
107
|
+
const summaryReasons = computed(() => {
|
|
108
|
+
if (!saveRefusal.value) return [];
|
|
109
|
+
if (refusal.value.unshown.length) return refusal.value.unshown.map((u) => u.message);
|
|
110
|
+
if (Object.keys(refusal.value.atFields).length) return [];
|
|
111
|
+
return [errorReason(saveRefusal.value, t("integration.credentials.error.save"))];
|
|
112
|
+
});
|
|
102
113
|
function configFieldAria(key) {
|
|
103
114
|
return fieldErrors.value[`config.${key}`] ? { "aria-invalid": "true", "aria-describedby": `cred-field-${key}-error` } : {};
|
|
104
115
|
}
|
|
116
|
+
async function focusFirstRefusedField() {
|
|
117
|
+
await nextTick();
|
|
118
|
+
const key = drawnKeys.value.find((k) => k in fieldErrors.value);
|
|
119
|
+
if (!key) return;
|
|
120
|
+
const id = key === "name" ? "cred-name" : `cred-field-${key.slice("config.".length)}`;
|
|
121
|
+
document.getElementById(id)?.focus();
|
|
122
|
+
}
|
|
105
123
|
function clearRefusal() {
|
|
106
|
-
|
|
107
|
-
fieldErrors.value = {};
|
|
124
|
+
saveRefusal.value = null;
|
|
108
125
|
}
|
|
109
126
|
function openCreate(t2) {
|
|
110
127
|
editing.value = null;
|
|
@@ -176,9 +193,9 @@ async function submit() {
|
|
|
176
193
|
}
|
|
177
194
|
}
|
|
178
195
|
} catch (e) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
toast.error(
|
|
196
|
+
saveRefusal.value = e;
|
|
197
|
+
void focusFirstRefusedField();
|
|
198
|
+
toast.error(errorReason(e, t("integration.credentials.error.save")));
|
|
182
199
|
} finally {
|
|
183
200
|
saving.value = false;
|
|
184
201
|
}
|
|
@@ -351,7 +368,10 @@ async function confirmDelete() {
|
|
|
351
368
|
<IntegrationsCredentialRevenexxSource
|
|
352
369
|
v-if="hasRevenexxSource(pickedType?.slug)"
|
|
353
370
|
:type="pickedType"
|
|
354
|
-
@update:active="(v) =>
|
|
371
|
+
@update:active="(v) => {
|
|
372
|
+
sourceActive = v;
|
|
373
|
+
clearRefusal();
|
|
374
|
+
}"
|
|
355
375
|
@apply="(c2) => Object.assign(form.config, c2)"
|
|
356
376
|
/>
|
|
357
377
|
|
|
@@ -438,14 +458,16 @@ async function confirmDelete() {
|
|
|
438
458
|
|
|
439
459
|
</div>
|
|
440
460
|
|
|
441
|
-
<!-- The save was refused
|
|
442
|
-
button sits in the footer below, so a summary at
|
|
443
|
-
scrolls — five fields already do — would be a
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
gateway error —
|
|
447
|
-
<div v-if="
|
|
448
|
-
|
|
461
|
+
<!-- The save was refused for something no field shows. Outside the scrolling
|
|
462
|
+
form on purpose: the Save button sits in the footer below, so a summary at
|
|
463
|
+
the end of a form that scrolls — five fields already do — would be a
|
|
464
|
+
refusal rendered under the fold. What is marked at a field is not repeated
|
|
465
|
+
here; what has no field to be marked at — a service-side field, a conflict,
|
|
466
|
+
a gateway error — would otherwise be silent. -->
|
|
467
|
+
<div v-if="summaryReasons.length && pickedType" role="alert" class="space-y-1 rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
|
|
468
|
+
<!-- Keyed by position: two undrawn keys can be refused with the same
|
|
469
|
+
sentence, and a duplicate key is how one of them goes unrendered. -->
|
|
470
|
+
<p v-for="(reason, i) in summaryReasons" :key="i">{{ reason }}</p>
|
|
449
471
|
</div>
|
|
450
472
|
|
|
451
473
|
<DialogFooter v-if="pickedType" class="sm:justify-between">
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
-
import { computed,
|
|
2
|
+
import { computed, ref } from "vue";
|
|
3
3
|
import { marked } from "marked";
|
|
4
4
|
import { AltArrowRight as Chevron, CheckCircle as CheckCircle2, Key, RefreshCircle as Loader2, Shield as ShieldAlert, User } from "#solar-icons";
|
|
5
5
|
import { triggerGlyph as glyphForTrigger } from "../../../data/triggerGlyphs";
|
|
@@ -9,27 +9,19 @@ import {
|
|
|
9
9
|
Dialog,
|
|
10
10
|
DialogContent,
|
|
11
11
|
DialogDescription,
|
|
12
|
-
DialogFooter,
|
|
13
12
|
DialogHeader,
|
|
14
|
-
DialogTitle
|
|
15
|
-
Input,
|
|
16
|
-
Label,
|
|
17
|
-
Select,
|
|
18
|
-
SelectContent,
|
|
19
|
-
SelectItem,
|
|
20
|
-
SelectTrigger,
|
|
21
|
-
SelectValue
|
|
13
|
+
DialogTitle
|
|
22
14
|
} from "@revenexx/studio";
|
|
23
15
|
import TemplateFlowPreview from "../../../components/Integrations/TemplateFlowPreview.vue";
|
|
16
|
+
import TemplateInstallStep from "../../../components/Integrations/TemplateInstallStep.vue";
|
|
17
|
+
import LoadingAnnouncement from "../../../components/Integrations/LoadingAnnouncement.vue";
|
|
24
18
|
import NodeMark from "../../../components/Integrations/NodeMark.vue";
|
|
25
19
|
import { templateCategoryLabel, templatePackageLabel } from "../../../data/integrationTemplates";
|
|
26
20
|
import { describeCron } from "../../../utils/schedule";
|
|
27
|
-
import { errorReason } from "../../../utils/apiError";
|
|
28
|
-
const SELECT_LATER = "__later__";
|
|
29
21
|
const t = useIntegrationsT();
|
|
30
22
|
const route = useRoute();
|
|
31
23
|
const router = useRouter();
|
|
32
|
-
const { get, load, loaded, fetchDetail, requirements
|
|
24
|
+
const { get, load, loaded, fetchDetail, requirements } = useIntegrationsTemplates();
|
|
33
25
|
const { load: loadNodeCatalog, findManifest, resolveLocalized } = useIntegrationsNodes();
|
|
34
26
|
const { keys: secretKeys, load: loadSecrets } = useIntegrationsSecrets();
|
|
35
27
|
const { ofType, load: loadCredentials } = useIntegrationsCredentials();
|
|
@@ -137,39 +129,13 @@ const setupReady = computed(() => missingSecrets.value === 0 && missingCredentia
|
|
|
137
129
|
const packageLabel = computed(() => tpl.value ? templatePackageLabel(tpl.value) : null);
|
|
138
130
|
const categoryLabel = computed(() => templateCategoryLabel(tpl.value?.category ?? ""));
|
|
139
131
|
const installOpen = ref(false);
|
|
140
|
-
const
|
|
141
|
-
const installError = ref(null);
|
|
142
|
-
const name = ref("");
|
|
143
|
-
const bindings = reactive({});
|
|
144
|
-
function nodesFor(usedBy) {
|
|
145
|
-
return [...new Set(usedBy.map((u) => u.nodeId))];
|
|
146
|
-
}
|
|
132
|
+
const stepLoading = ref(false);
|
|
147
133
|
function openInstall() {
|
|
148
134
|
if (!tpl.value) return;
|
|
149
|
-
name.value = tpl.value.name;
|
|
150
|
-
for (const k of Object.keys(bindings)) delete bindings[k];
|
|
151
|
-
installError.value = null;
|
|
152
135
|
installOpen.value = true;
|
|
153
136
|
}
|
|
154
|
-
async function
|
|
155
|
-
|
|
156
|
-
installing.value = true;
|
|
157
|
-
installError.value = null;
|
|
158
|
-
try {
|
|
159
|
-
const credentialBindings = {};
|
|
160
|
-
for (const [nodeId, credId] of Object.entries(bindings)) {
|
|
161
|
-
if (credId && credId !== SELECT_LATER) credentialBindings[nodeId] = credId;
|
|
162
|
-
}
|
|
163
|
-
const id = await instantiate(tpl.value.slug, {
|
|
164
|
-
name: name.value.trim() || void 0,
|
|
165
|
-
credentialBindings
|
|
166
|
-
});
|
|
167
|
-
if (id) await router.push(`/integrations/workflows/${id}`);
|
|
168
|
-
} catch (e) {
|
|
169
|
-
installError.value = errorReason(e, t("integration.templates.detail.install.error.fallback"));
|
|
170
|
-
} finally {
|
|
171
|
-
installing.value = false;
|
|
172
|
-
}
|
|
137
|
+
async function installed(id) {
|
|
138
|
+
await router.push(`/integrations/workflows/${id}`);
|
|
173
139
|
}
|
|
174
140
|
const PUBLISHER_KIND_LABEL_KEY = {
|
|
175
141
|
revenexx: "integration.templates.detail.publisher.kind.revenexx",
|
|
@@ -471,7 +437,13 @@ function levelDot(level) {
|
|
|
471
437
|
{{ t("integration.templates.detail.state.notFound") }}
|
|
472
438
|
</div>
|
|
473
439
|
|
|
474
|
-
|
|
440
|
+
<LoadingAnnouncement
|
|
441
|
+
:label="t('integration.templates.detail.action.useTemplate')"
|
|
442
|
+
:loading="stepLoading"
|
|
443
|
+
:text="t('integration.templates.detail.install.state.loading')"
|
|
444
|
+
/>
|
|
445
|
+
|
|
446
|
+
<!-- Install: the shared step, in this page's dialog ------------------- -->
|
|
475
447
|
<Dialog v-model:open="installOpen">
|
|
476
448
|
<DialogContent class="sm:max-w-lg">
|
|
477
449
|
<DialogHeader>
|
|
@@ -481,95 +453,13 @@ function levelDot(level) {
|
|
|
481
453
|
</DialogDescription>
|
|
482
454
|
</DialogHeader>
|
|
483
455
|
|
|
484
|
-
<
|
|
485
|
-
<
|
|
486
|
-
<
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
<div class="space-y-2">
|
|
492
|
-
<div class="text-mono text-muted-foreground border-b border-black/[0.06] pb-1.5">
|
|
493
|
-
{{ t("integration.templates.detail.credentials.title") }}
|
|
494
|
-
</div>
|
|
495
|
-
<p v-if="!credentialReqs.length" class="text-sm text-muted-foreground italic">
|
|
496
|
-
{{ t("integration.templates.detail.install.noCredentials") }}
|
|
497
|
-
</p>
|
|
498
|
-
<div v-for="req in credentialReqs" :key="req.slug" class="space-y-2">
|
|
499
|
-
<div class="flex items-center gap-2">
|
|
500
|
-
<span class="text-sm font-medium">{{ req.name ?? req.slug }}</span>
|
|
501
|
-
<Badge v-if="!req.registered" variant="outline" class="bg-rose-500/10 border-rose-500/20 text-rose-700 text-sm">
|
|
502
|
-
{{ t("integration.templates.detail.install.notRegistered") }}
|
|
503
|
-
</Badge>
|
|
504
|
-
<Badge v-else-if="!ofType(req.slug).length" variant="outline" class="bg-amber-500/10 border-amber-500/20 text-amber-700 text-sm">
|
|
505
|
-
{{ t("integration.templates.detail.install.noneYet") }}
|
|
506
|
-
</Badge>
|
|
507
|
-
</div>
|
|
508
|
-
|
|
509
|
-
<p v-if="!ofType(req.slug).length" class="rounded-md border-l-2 border-black/[0.08] bg-muted/30 px-2.5 py-1.5 text-sm text-muted-foreground">
|
|
510
|
-
{{ t("integration.templates.detail.install.noCredentialsOfType") }}
|
|
511
|
-
<NuxtLink to="/integrations/credentials" class="text-primary hover:underline">{{ t("integration.templates.detail.install.createOneLink") }}</NuxtLink>
|
|
512
|
-
{{ t("integration.templates.detail.install.orInstallLater") }}
|
|
513
|
-
</p>
|
|
514
|
-
<div v-for="nodeId in nodesFor(req.usedBy)" v-else :key="nodeId" class="space-y-1">
|
|
515
|
-
<Label :for="`tpl-cred-${nodeId}`" class="text-xs text-muted-foreground font-mono">{{ t("integration.templates.detail.install.nodeLabel", { id: nodeId }) }}</Label>
|
|
516
|
-
<Select
|
|
517
|
-
:model-value="bindings[nodeId] ?? SELECT_LATER"
|
|
518
|
-
@update:model-value="(v2) => {
|
|
519
|
-
bindings[nodeId] = v2 ?? SELECT_LATER;
|
|
520
|
-
}"
|
|
521
|
-
>
|
|
522
|
-
<SelectTrigger :id="`tpl-cred-${nodeId}`" class="bg-white">
|
|
523
|
-
<SelectValue :placeholder="t('integration.templates.detail.install.selectLater')" />
|
|
524
|
-
</SelectTrigger>
|
|
525
|
-
<SelectContent>
|
|
526
|
-
<SelectItem :value="SELECT_LATER">{{ t("integration.templates.detail.install.selectLater") }}</SelectItem>
|
|
527
|
-
<SelectItem v-for="c in ofType(req.slug)" :key="c.id" :value="c.id">
|
|
528
|
-
{{ c.name }}
|
|
529
|
-
</SelectItem>
|
|
530
|
-
</SelectContent>
|
|
531
|
-
</Select>
|
|
532
|
-
</div>
|
|
533
|
-
</div>
|
|
534
|
-
</div>
|
|
535
|
-
|
|
536
|
-
<!-- Secrets -->
|
|
537
|
-
<div v-if="secretChecks.length" class="space-y-2">
|
|
538
|
-
<div class="text-mono text-muted-foreground border-b border-black/[0.06] pb-1.5">
|
|
539
|
-
{{ t("integration.templates.detail.install.secretsHeader") }}
|
|
540
|
-
</div>
|
|
541
|
-
<p class="text-sm text-muted-foreground">
|
|
542
|
-
{{ t("integration.templates.detail.install.secretsDescription") }}
|
|
543
|
-
<NuxtLink to="/integrations/secrets" class="text-primary hover:underline">{{ t("integration.nav.item.secrets") }}</NuxtLink> {{ t("integration.templates.detail.install.secretsDescriptionSuffix") }}
|
|
544
|
-
</p>
|
|
545
|
-
<ul class="space-y-1">
|
|
546
|
-
<li
|
|
547
|
-
v-for="(s, i) in secretChecks"
|
|
548
|
-
:key="s.key ?? `dlg-secret-${i}`"
|
|
549
|
-
class="flex items-center justify-between rounded-md border border-black/[0.06] bg-muted/20 px-2 py-1 text-sm"
|
|
550
|
-
>
|
|
551
|
-
<code class="font-mono">{{ s.label }}</code>
|
|
552
|
-
<Badge variant="outline" :class="s.present ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-700 text-sm' : 'bg-amber-500/10 border-amber-500/20 text-amber-700 text-sm'">
|
|
553
|
-
{{ s.present ? t("integration.templates.detail.install.secretStored") : t("integration.templates.detail.secrets.missing") }}
|
|
554
|
-
</Badge>
|
|
555
|
-
</li>
|
|
556
|
-
</ul>
|
|
557
|
-
</div>
|
|
558
|
-
|
|
559
|
-
<div v-if="installError" class="rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
|
|
560
|
-
{{ installError }}
|
|
561
|
-
</div>
|
|
562
|
-
</div>
|
|
563
|
-
|
|
564
|
-
<DialogFooter>
|
|
565
|
-
<Button variant="outline" :disabled="installing" @click="installOpen = false">
|
|
566
|
-
{{ t("integration.common.action.cancel") }}
|
|
567
|
-
</Button>
|
|
568
|
-
<Button :disabled="installing || !name.trim()" @click="confirmInstall">
|
|
569
|
-
<Loader2 v-if="installing" :size="14" class="mr-1 animate-spin" />
|
|
570
|
-
{{ installing ? t("integration.templates.detail.install.submitting") : t("integration.templates.detail.install.submit") }}
|
|
571
|
-
</Button>
|
|
572
|
-
</DialogFooter>
|
|
456
|
+
<TemplateInstallStep :slug="slug" @installed="installed" @update:loading="stepLoading = $event">
|
|
457
|
+
<template #secondary="{ installing }">
|
|
458
|
+
<Button variant="outline" :disabled="installing" @click="installOpen = false">
|
|
459
|
+
{{ t("integration.common.action.cancel") }}
|
|
460
|
+
</Button>
|
|
461
|
+
</template>
|
|
462
|
+
</TemplateInstallStep>
|
|
573
463
|
</DialogContent>
|
|
574
464
|
</Dialog>
|
|
575
465
|
</IntegrationsLayout>
|
|
@@ -46,6 +46,8 @@ import { BUILD_SETTLE_POLL_MS, BUILD_SETTLE_TIMEOUT_MS, createBuildSettle, isBui
|
|
|
46
46
|
import { splitRunOutputs } from "../../../utils/runOutputs";
|
|
47
47
|
import { isTerminalRun, unwrapRun } from "../../../utils/runEnvelope";
|
|
48
48
|
import { findingsSummary, groupFindingsByNode, nodeDisplayLabel } from "../../../utils/validationFindings";
|
|
49
|
+
import { unwiredErrorPorts } from "../../../utils/errorPathAdvice";
|
|
50
|
+
import { newestExecutableVersion } from "../../../data/integrationNodeCatalog";
|
|
49
51
|
import { payloadFieldsFromBlob } from "../../../data/payloadForm";
|
|
50
52
|
import WorkflowIconView from "../../../components/Integrations/WorkflowIconView.vue";
|
|
51
53
|
const t = useIntegrationsT();
|
|
@@ -108,6 +110,27 @@ async function revealNode(nodeId) {
|
|
|
108
110
|
await nextTick();
|
|
109
111
|
editorRef.value?.revealNode(nodeId);
|
|
110
112
|
}
|
|
113
|
+
const errorPathAdvice = computed(() => {
|
|
114
|
+
const blob = shownBlob.value;
|
|
115
|
+
if (!blob) return [];
|
|
116
|
+
const byId = new Map(blob.nodes.map((n) => [n.id, n]));
|
|
117
|
+
return unwiredErrorPorts(blob).flatMap((a) => {
|
|
118
|
+
const node = byId.get(a.nodeId);
|
|
119
|
+
return node ? [{ ...a, label: nodeDisplayLabel(node) }] : [];
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
const adviceExpanded = ref(false);
|
|
123
|
+
const canAttachErrorStop = computed(() => newestExecutableVersion("revenexx:stop-and-error") !== null);
|
|
124
|
+
async function showNodeOnCanvas(nodeId) {
|
|
125
|
+
tab.value = "editor";
|
|
126
|
+
await nextTick();
|
|
127
|
+
editorRef.value?.showNodeOnCanvas(nodeId);
|
|
128
|
+
}
|
|
129
|
+
async function attachErrorStop(nodeId, port) {
|
|
130
|
+
tab.value = "editor";
|
|
131
|
+
await nextTick();
|
|
132
|
+
editorRef.value?.attachErrorStop(nodeId, port);
|
|
133
|
+
}
|
|
111
134
|
const { load: loadNodeCatalog } = useIntegrationsNodes();
|
|
112
135
|
onMounted(() => {
|
|
113
136
|
void loadNodeCatalog();
|
|
@@ -177,6 +200,7 @@ watch(id, async () => {
|
|
|
177
200
|
if (!w) await leaveTo("/integrations/workflows");
|
|
178
201
|
draft.value = null;
|
|
179
202
|
warningsExpanded.value = false;
|
|
203
|
+
adviceExpanded.value = false;
|
|
180
204
|
if (tab.value === "runs") void loadRuns(1);
|
|
181
205
|
settleBuildStatus();
|
|
182
206
|
}, { immediate: true });
|
|
@@ -369,7 +393,7 @@ async function run(mode) {
|
|
|
369
393
|
const payloadDriven = computed(() => payloadFieldsFromBlob(shownBlob.value ?? { nodes: [] }).length > 0);
|
|
370
394
|
async function runFromEditor() {
|
|
371
395
|
if (!workflow.value) return;
|
|
372
|
-
if (payloadDriven.value) {
|
|
396
|
+
if (payloadDriven.value || workflow.value.execution_mode === "caller_decides") {
|
|
373
397
|
runPayloadOpen.value = true;
|
|
374
398
|
return;
|
|
375
399
|
}
|
|
@@ -626,16 +650,20 @@ function closeRun(target) {
|
|
|
626
650
|
tab.value = target;
|
|
627
651
|
void router.replace({ query: { ...route.query, run: void 0, view: void 0 } });
|
|
628
652
|
}
|
|
629
|
-
const canRetryOpenRun = computed(() =>
|
|
653
|
+
const canRetryOpenRun = computed(() => openRun.value?.status === "failed");
|
|
630
654
|
const retryBusy = ref(false);
|
|
631
655
|
async function retryOpenRun() {
|
|
632
|
-
|
|
656
|
+
const source = openRun.value;
|
|
657
|
+
if (!source || retryBusy.value) return;
|
|
633
658
|
retryBusy.value = true;
|
|
634
659
|
try {
|
|
635
|
-
const res = await retryRun(
|
|
660
|
+
const res = await retryRun(source.workflowId, source.id);
|
|
636
661
|
const newId = res && typeof res === "object" && "id" in res ? String(res.id) : null;
|
|
637
|
-
toast.success(newId && newId !==
|
|
662
|
+
toast.success(newId && newId !== source.id ? t("integration.editor.retry.toast.started", { id: newId }) : t("integration.editor.retry.toast.requested"));
|
|
638
663
|
void loadRuns(1, { silent: true });
|
|
664
|
+
if (newId && newId !== source.id) {
|
|
665
|
+
await router.push({ query: { ...route.query, run: newId, view: void 0 } });
|
|
666
|
+
}
|
|
639
667
|
} catch (e) {
|
|
640
668
|
toast.error(errorReason(e, t("integration.editor.retry.error.generic")));
|
|
641
669
|
} finally {
|
|
@@ -770,32 +798,32 @@ function formatDateTime(iso) {
|
|
|
770
798
|
<template v-if="workflow">
|
|
771
799
|
<PageHeader :title="shownName">
|
|
772
800
|
<template #title>
|
|
773
|
-
<span class="
|
|
774
|
-
<NuxtLink to="/integrations/workflows" class="font-normal text-muted-foreground/80 transition-colors hover:text-primary">{{ t("integration.editor.breadcrumb.workflows") }}</NuxtLink>
|
|
775
|
-
<Chevron :size="15" weight="Outline" class="text-muted-foreground/50" />
|
|
801
|
+
<span class="flex min-w-0 items-center gap-1.5">
|
|
802
|
+
<NuxtLink to="/integrations/workflows" class="shrink-0 font-normal text-muted-foreground/80 transition-colors hover:text-primary">{{ t("integration.editor.breadcrumb.workflows") }}</NuxtLink>
|
|
803
|
+
<Chevron :size="15" weight="Outline" class="shrink-0 text-muted-foreground/50" />
|
|
776
804
|
<!-- Run open: the breadcrumb extends — name and Runs become crumbs
|
|
777
805
|
back into the workflow surface. -->
|
|
778
806
|
<template v-if="openRunId">
|
|
779
|
-
<button type="button" class="truncate font-normal text-muted-foreground/80 transition-colors hover:text-primary" @click="closeRun('editor')">{{ shownName }}</button>
|
|
780
|
-
<Chevron :size="15" weight="Outline" class="text-muted-foreground/50" />
|
|
781
|
-
<button type="button" class="font-normal text-muted-foreground/80 transition-colors hover:text-primary" @click="closeRun('runs')">{{ t("integration.editor.breadcrumb.runs") }}</button>
|
|
782
|
-
<Chevron :size="15" weight="Outline" class="text-muted-foreground/50" />
|
|
807
|
+
<button type="button" class="truncate font-normal text-muted-foreground/80 transition-colors hover:text-primary" :title="shownName" @click="closeRun('editor')">{{ shownName }}</button>
|
|
808
|
+
<Chevron :size="15" weight="Outline" class="shrink-0 text-muted-foreground/50" />
|
|
809
|
+
<button type="button" class="shrink-0 font-normal text-muted-foreground/80 transition-colors hover:text-primary" @click="closeRun('runs')">{{ t("integration.editor.breadcrumb.runs") }}</button>
|
|
810
|
+
<Chevron :size="15" weight="Outline" class="shrink-0 text-muted-foreground/50" />
|
|
783
811
|
<!-- The Temporal run id IS the run's identity; the internal
|
|
784
812
|
number only fills in until the run is handed off. -->
|
|
785
|
-
<span class="
|
|
813
|
+
<span class="shrink-0 font-mono">{{ openRun?.temporalRunId ?? t("integration.editor.breadcrumb.runNumber", { id: openRunId }) }}</span>
|
|
786
814
|
</template>
|
|
787
815
|
<template v-else>
|
|
788
816
|
<span class="flex size-7 shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-500">
|
|
789
817
|
<WorkflowIconView :icon="shownIcon" :size="16" />
|
|
790
818
|
</span>
|
|
791
|
-
<span class="truncate">{{ shownName }}</span>
|
|
819
|
+
<span class="truncate" :title="shownName">{{ shownName }}</span>
|
|
792
820
|
<!-- The SAVED mode, beside a drafted name, and the difference is not an
|
|
793
821
|
oversight. A name changes nothing about what happens when Run is
|
|
794
822
|
pressed, so the header may show what the operator just entered. The
|
|
795
823
|
dispatch mode decides what a run does — and a run goes to the
|
|
796
824
|
workflow the platform holds, not to the draft. A badge following the
|
|
797
825
|
draft would read SYNC over a button that fires an async run. -->
|
|
798
|
-
<Badge variant="outline" class="ml-1.5 border text-xs font-medium uppercase text-muted-foreground" :title="t('integration.editor.header.executionMode.title', { mode: modeLabel(workflow.execution_mode) })">
|
|
826
|
+
<Badge variant="outline" class="ml-1.5 shrink-0 border text-xs font-medium uppercase text-muted-foreground" :title="t('integration.editor.header.executionMode.title', { mode: modeLabel(workflow.execution_mode) })">
|
|
799
827
|
{{ modeLabel(workflow.execution_mode) }}
|
|
800
828
|
</Badge>
|
|
801
829
|
</template>
|
|
@@ -812,25 +840,33 @@ function formatDateTime(iso) {
|
|
|
812
840
|
outside it. Leftmost there is as near the rail as the shared header
|
|
813
841
|
allows without a slot of its own.
|
|
814
842
|
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
843
|
+
The way back out carries its words; the way in does not. An
|
|
844
|
+
icon-only control is one nobody finds, and a folded rail is the one
|
|
845
|
+
state where that is unaffordable — nothing else on the page offers
|
|
846
|
+
the navigation back. Folding it away needs no such help: the rail
|
|
847
|
+
is standing right beside the pictogram, and the words cost 159 px
|
|
848
|
+
of a header row that a long workflow name already fills, where they
|
|
849
|
+
were the widest thing in it for the least important thing in it.
|
|
850
|
+
Icon-only, so it carries the same name as before to a screen reader
|
|
851
|
+
and to a test. Rendered only where the canvas fills the page, the
|
|
852
|
+
one surface it acts on. -->
|
|
819
853
|
<Button
|
|
820
854
|
v-if="canvasFillsPage"
|
|
821
855
|
variant="ghost"
|
|
822
856
|
size="sm"
|
|
823
|
-
class="h-7 shrink-0 gap-1.5
|
|
857
|
+
class="h-7 shrink-0 gap-1.5 text-sm font-normal text-muted-foreground hover:text-primary"
|
|
858
|
+
:class="railFolded ? 'px-2' : 'w-7 px-0'"
|
|
859
|
+
:aria-label="railFolded ? void 0 : t('integration.nav.action.hide')"
|
|
824
860
|
:title="railFolded ? t('integration.nav.action.show.title') : t('integration.nav.action.hide.title')"
|
|
825
861
|
@click="toggleRail"
|
|
826
862
|
>
|
|
827
863
|
<RailIcon :size="15" />
|
|
828
|
-
{{
|
|
864
|
+
<template v-if="railFolded">{{ t("integration.nav.action.show") }}</template>
|
|
829
865
|
</Button>
|
|
830
866
|
|
|
831
867
|
<!-- Run open: run-scoped actions replace the workflow Save. -->
|
|
832
868
|
<template v-if="openRunId">
|
|
833
|
-
<Button v-if="canRetryOpenRun" variant="outline" class="h-7 text-sm" :disabled="retryBusy" @click="retryOpenRun">
|
|
869
|
+
<Button v-if="canRetryOpenRun" variant="outline" class="h-7 text-sm" :disabled="retryBusy" :title="t('integration.editor.action.retry.title')" @click="retryOpenRun">
|
|
834
870
|
<Loader2 v-if="retryBusy" :size="12" class="animate-spin" />
|
|
835
871
|
<RetryIcon v-else :size="12" />
|
|
836
872
|
{{ t("integration.editor.action.retry") }}
|
|
@@ -843,13 +879,13 @@ function formatDateTime(iso) {
|
|
|
843
879
|
<template v-else>
|
|
844
880
|
<span
|
|
845
881
|
v-if="buildWorking"
|
|
846
|
-
class="inline-flex h-7 items-center gap-1.5 rounded-md border border-sky-500/25 bg-sky-500/10 px-2.5 text-sm text-sky-700"
|
|
882
|
+
class="inline-flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md border border-sky-500/25 bg-sky-500/10 px-2.5 text-sm text-sky-700"
|
|
847
883
|
:title="buildNotice?.text"
|
|
848
884
|
>
|
|
849
885
|
<Loader2 :size="12" class="animate-spin" />
|
|
850
886
|
{{ workflow.buildStatus === "building" ? t("integration.editor.build.chip.building") : t("integration.editor.build.chip.queued") }}
|
|
851
887
|
</span>
|
|
852
|
-
<span v-if="isDirty" class="text-sm text-amber-600">{{ t("integration.editor.status.unsavedChanges") }}</span>
|
|
888
|
+
<span v-if="isDirty" class="shrink-0 whitespace-nowrap text-sm text-amber-600">{{ t("integration.editor.status.unsavedChanges") }}</span>
|
|
853
889
|
<!-- What is done to the WORKFLOW, as opposed to the draft in front
|
|
854
890
|
of it: the buttons beside this menu discard and save an
|
|
855
891
|
afternoon's work, and these two make and unmake the thing that
|
|
@@ -981,6 +1017,62 @@ function formatDateTime(iso) {
|
|
|
981
1017
|
</ul>
|
|
982
1018
|
</div>
|
|
983
1019
|
|
|
1020
|
+
<!-- Error-path advice (PO-443, specs/workflow-advice.md).
|
|
1021
|
+
Slate, NOT amber: amber already means "fix this before it runs" twice
|
|
1022
|
+
over in this editor (the reauth strip, the validation banner) and on
|
|
1023
|
+
the node cards, so a third would leave the colour meaning nothing.
|
|
1024
|
+
This blocks nothing — the activation control stays operable while it
|
|
1025
|
+
shows, which is the point of it not being a finding.
|
|
1026
|
+
|
|
1027
|
+
Editor tab only, unlike the validation banner above: every line here
|
|
1028
|
+
acts on the canvas — reveal a card, attach a step to it — so over the
|
|
1029
|
+
run list or the history preview it offers moves to a canvas that is not
|
|
1030
|
+
on screen, and its node names read as part of whatever is. That is not
|
|
1031
|
+
hypothetical: it put a current node's name over a preview of an
|
|
1032
|
+
archived revision (workflow-history.md AC-3). -->
|
|
1033
|
+
<div
|
|
1034
|
+
v-if="tab === 'editor' && !openRunId && errorPathAdvice.length"
|
|
1035
|
+
class="border-b border-slate-500/20 bg-slate-500/5 px-4 py-2 text-sm text-slate-700"
|
|
1036
|
+
>
|
|
1037
|
+
<p class="font-medium">
|
|
1038
|
+
{{ t("integration.editor.errorPath.advice", errorPathAdvice.length) }}
|
|
1039
|
+
</p>
|
|
1040
|
+
<ul
|
|
1041
|
+
class="mt-1 space-y-0.5"
|
|
1042
|
+
:class="adviceExpanded ? 'max-h-36 overflow-y-auto pr-1' : ''"
|
|
1043
|
+
>
|
|
1044
|
+
<li
|
|
1045
|
+
v-for="a in adviceExpanded ? errorPathAdvice : errorPathAdvice.slice(0, WARN_COLLAPSED)"
|
|
1046
|
+
:key="`${a.nodeId}-${a.port}`"
|
|
1047
|
+
class="flex items-baseline gap-1.5"
|
|
1048
|
+
>
|
|
1049
|
+
<span class="select-none text-slate-400">•</span>
|
|
1050
|
+
<button
|
|
1051
|
+
type="button"
|
|
1052
|
+
class="text-left font-medium underline decoration-transparent underline-offset-2 hover:decoration-slate-700"
|
|
1053
|
+
:aria-label="t('integration.editor.errorPath.ariaReveal', { label: a.label, port: a.port })"
|
|
1054
|
+
@click="showNodeOnCanvas(a.nodeId)"
|
|
1055
|
+
>
|
|
1056
|
+
{{ a.label }}
|
|
1057
|
+
</button>
|
|
1058
|
+
<button
|
|
1059
|
+
v-if="canAttachErrorStop"
|
|
1060
|
+
type="button"
|
|
1061
|
+
class="text-slate-600 underline underline-offset-2 hover:text-slate-900"
|
|
1062
|
+
:aria-label="t('integration.editor.errorPath.ariaFix', { label: a.label, port: a.port })"
|
|
1063
|
+
@click="attachErrorStop(a.nodeId, a.port)"
|
|
1064
|
+
>
|
|
1065
|
+
{{ t("integration.editor.errorPath.action.stop") }}
|
|
1066
|
+
</button>
|
|
1067
|
+
</li>
|
|
1068
|
+
<li v-if="!adviceExpanded && errorPathAdvice.length > WARN_COLLAPSED" class="pl-3">
|
|
1069
|
+
<button type="button" class="text-slate-600 underline underline-offset-2 hover:text-slate-900" @click="adviceExpanded = true">
|
|
1070
|
+
{{ t("integration.editor.errorPath.more", { n: errorPathAdvice.length - WARN_COLLAPSED }) }}
|
|
1071
|
+
</button>
|
|
1072
|
+
</li>
|
|
1073
|
+
</ul>
|
|
1074
|
+
</div>
|
|
1075
|
+
|
|
984
1076
|
<!-- Only the FAILED build state banners (its message matters); the
|
|
985
1077
|
working states live as the header chip — and inside a run the
|
|
986
1078
|
workflow's build state is noise, so nothing shows at all. -->
|
|
@@ -1005,7 +1097,7 @@ function formatDateTime(iso) {
|
|
|
1005
1097
|
</div>
|
|
1006
1098
|
</div>
|
|
1007
1099
|
<div v-else class="min-h-0 flex-1">
|
|
1008
|
-
<WorkflowRunReplayPanel :workflow="workflow" :initial-run-id="openRunId"
|
|
1100
|
+
<WorkflowRunReplayPanel :workflow="workflow" :initial-run-id="openRunId" />
|
|
1009
1101
|
</div>
|
|
1010
1102
|
</template>
|
|
1011
1103
|
|
|
@@ -1033,7 +1125,7 @@ function formatDateTime(iso) {
|
|
|
1033
1125
|
</div>
|
|
1034
1126
|
|
|
1035
1127
|
<!-- Runs — this workflow's full execution history. Manual runs start
|
|
1036
|
-
from the editor's Run
|
|
1128
|
+
from the editor's Run button, through the run dialog. -->
|
|
1037
1129
|
<div v-else-if="tab === 'runs'" class="p-4 space-y-4 overflow-y-auto">
|
|
1038
1130
|
<div v-if="runsError" class="rounded-md border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-sm text-rose-700">
|
|
1039
1131
|
{{ runsError }}
|
|
@@ -1078,7 +1170,7 @@ function formatDateTime(iso) {
|
|
|
1078
1170
|
</div>
|
|
1079
1171
|
</div>
|
|
1080
1172
|
|
|
1081
|
-
<!--
|
|
1173
|
+
<!-- The run dialog — opened by the editor's Run button. -->
|
|
1082
1174
|
<Dialog v-model:open="runPayloadOpen">
|
|
1083
1175
|
<DialogContent class="sm:max-w-lg">
|
|
1084
1176
|
<DialogHeader>
|
|
@@ -1116,27 +1208,30 @@ function formatDateTime(iso) {
|
|
|
1116
1208
|
</p>
|
|
1117
1209
|
</div>
|
|
1118
1210
|
<div v-if="workflow" class="flex items-center justify-end gap-2">
|
|
1211
|
+
<!-- Both modes, and the waiting one is the offer: the author pressed Run
|
|
1212
|
+
to see what comes back, so sync is the primary button — which puts it
|
|
1213
|
+
on the right, where every primary button in the studio sits. -->
|
|
1119
1214
|
<template v-if="workflow.execution_mode === 'caller_decides'">
|
|
1120
1215
|
<Button
|
|
1121
1216
|
variant="outline"
|
|
1122
1217
|
:disabled="running || syncLoading || waitingForBuild || buildFailed"
|
|
1123
1218
|
:title="buildFailed ? t('integration.editor.build.tooltip.failed') : void 0"
|
|
1124
1219
|
class="h-8 text-sm"
|
|
1125
|
-
@click="runFromPayloadDialog('
|
|
1220
|
+
@click="runFromPayloadDialog('async')"
|
|
1126
1221
|
>
|
|
1127
|
-
<Loader2 v-if="
|
|
1222
|
+
<Loader2 v-if="running || waitingMode === 'async'" :size="14" class="animate-spin" />
|
|
1128
1223
|
<Play v-else :size="14" />
|
|
1129
|
-
{{ waitingMode === "
|
|
1224
|
+
{{ waitingMode === "async" ? t("integration.editor.run.waitingForBuild") : t("integration.editor.runPayload.action.run", { mode: t("integration.editor.mode.word.async") }) }}
|
|
1130
1225
|
</Button>
|
|
1131
1226
|
<Button
|
|
1132
1227
|
:disabled="running || syncLoading || waitingForBuild || buildFailed"
|
|
1133
1228
|
:title="buildFailed ? t('integration.editor.build.tooltip.failed') : void 0"
|
|
1134
1229
|
class="h-8 text-sm"
|
|
1135
|
-
@click="runFromPayloadDialog('
|
|
1230
|
+
@click="runFromPayloadDialog('sync')"
|
|
1136
1231
|
>
|
|
1137
|
-
<Loader2 v-if="
|
|
1232
|
+
<Loader2 v-if="syncLoading || waitingMode === 'sync'" :size="14" class="animate-spin" />
|
|
1138
1233
|
<Play v-else :size="14" />
|
|
1139
|
-
{{ waitingMode === "
|
|
1234
|
+
{{ waitingMode === "sync" ? t("integration.editor.run.waitingForBuild") : t("integration.editor.runPayload.action.run", { mode: t("integration.editor.mode.word.sync") }) }}
|
|
1140
1235
|
</Button>
|
|
1141
1236
|
</template>
|
|
1142
1237
|
<Button
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One record, from `{ data: record }` or from a bare record.
|
|
3
|
+
*
|
|
4
|
+
* Throws when the response carries an envelope with nothing in it, or no body at
|
|
5
|
+
* all — the case the old expression turned into an object with none of the
|
|
6
|
+
* caller's fields, which every page downstream drew as empty.
|
|
7
|
+
*
|
|
8
|
+
* **The thrown message is deliberately empty.** `errorReason(err, fallback)` in
|
|
9
|
+
* `apiError.ts` falls back to the caller's own translated sentence when the
|
|
10
|
+
* message is blank, so a shape violation surfaces as the page's ordinary "could
|
|
11
|
+
* not load this" copy rather than as an untranslated technical string in the UI.
|
|
12
|
+
* The response that caused it rides along in `body`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function unwrapRecord<T>(res: unknown, ownFields?: readonly string[]): T;
|
|
15
|
+
/**
|
|
16
|
+
* A list, from `{ data: [ … ] }` or from a bare array.
|
|
17
|
+
*
|
|
18
|
+
* An empty collection is `[]` or `{ "data": [] }` and answers empty. A body that is
|
|
19
|
+
* neither throws, for the same reason the record above does — and it was drafted the
|
|
20
|
+
* other way round, returning empty for anything unreadable, until a review pointed
|
|
21
|
+
* out what that costs: the workflows listing used to raise (`ingestList` mapped over
|
|
22
|
+
* the body and threw inside its own `try`), so an unreadable answer reached the
|
|
23
|
+
* reader as an error. Empty would have turned that into "no workflows yet" for a
|
|
24
|
+
* tenant whose workflows are all still there — a silent wrong answer in place of a
|
|
25
|
+
* loud one, in the change that exists to remove exactly that.
|
|
26
|
+
*/
|
|
27
|
+
export declare function unwrapList<T>(res: unknown): T[];
|