@revenexx/studio-integrations 1.1.1 → 1.2.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/CHANGELOG.md +483 -0
- package/README.md +3 -1
- package/dist/module.json +1 -1
- package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +6 -18
- package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +13 -23
- package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +7 -6
- package/dist/runtime/components/Integrations/DeadLettersPanel.vue +15 -4
- package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
- package/dist/runtime/components/Integrations/ExpressionInput.vue +45 -19
- package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
- package/dist/runtime/components/Integrations/GroupFrameView.vue +5 -4
- package/dist/runtime/components/Integrations/LoadingAnnouncement.d.vue.ts +38 -0
- package/dist/runtime/components/Integrations/LoadingAnnouncement.vue +16 -0
- package/dist/runtime/components/Integrations/LoadingAnnouncement.vue.d.ts +38 -0
- package/dist/runtime/components/Integrations/NodeConfigField.vue +23 -0
- package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +2 -2
- package/dist/runtime/components/Integrations/NodeInspector.vue +25 -32
- package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +2 -2
- package/dist/runtime/components/Integrations/RunActionsMenu.vue +2 -1
- package/dist/runtime/components/Integrations/RunDetailPanel.vue +5 -17
- package/dist/runtime/components/Integrations/RunResponseModal.d.vue.ts +30 -8
- package/dist/runtime/components/Integrations/RunResponseModal.vue +92 -24
- package/dist/runtime/components/Integrations/RunResponseModal.vue.d.ts +30 -8
- package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +61 -3
- package/dist/runtime/components/Integrations/RunsTable.vue +129 -18
- package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +61 -3
- package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +10 -1
- package/dist/runtime/components/Integrations/StickyNoteView.vue +1 -1
- package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +18 -3
- package/dist/runtime/components/Integrations/WorkflowDeleteDialog.d.vue.ts +18 -0
- package/dist/runtime/components/Integrations/WorkflowDeleteDialog.vue +70 -0
- package/dist/runtime/components/Integrations/WorkflowDeleteDialog.vue.d.ts +18 -0
- package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +2 -1
- package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +4 -4
- package/dist/runtime/components/Integrations/WorkflowEditor.vue +39 -28
- package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +17 -6
- package/dist/runtime/components/Integrations/WorkflowNodeView.vue +17 -21
- package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +8 -11
- package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +6 -7
- package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +7 -7
- package/dist/runtime/components/Integrations/WorkflowsTable.vue +10 -48
- package/dist/runtime/components/Integrations/canvasSides.d.ts +48 -0
- package/dist/runtime/components/Integrations/canvasSides.js +22 -0
- package/dist/runtime/composables/useCredentialTypes.js +2 -6
- package/dist/runtime/composables/useIntegrationsAudit.js +2 -2
- package/dist/runtime/composables/useIntegrationsCredentials.js +4 -8
- package/dist/runtime/composables/useIntegrationsDeadLetters.d.ts +4 -0
- package/dist/runtime/composables/useIntegrationsDeadLetters.js +16 -1
- package/dist/runtime/composables/useIntegrationsNodes.js +2 -2
- package/dist/runtime/composables/useIntegrationsRevisions.js +2 -6
- package/dist/runtime/composables/useIntegrationsRuns.d.ts +4 -15
- package/dist/runtime/composables/useIntegrationsRuns.js +49 -34
- package/dist/runtime/composables/useIntegrationsSchedules.js +2 -2
- package/dist/runtime/composables/useIntegrationsSecrets.js +2 -6
- package/dist/runtime/composables/useIntegrationsTemplates.js +2 -5
- package/dist/runtime/composables/useIntegrationsTriggers.js +2 -2
- package/dist/runtime/composables/useIntegrationsWorkflows.js +4 -7
- package/dist/runtime/composables/useNodeConfigResolve.js +2 -2
- package/dist/runtime/composables/useOAuthConnect.d.ts +42 -0
- package/dist/runtime/composables/useOAuthConnect.js +33 -0
- package/dist/runtime/data/expressionEditor.d.ts +17 -5
- package/dist/runtime/data/expressionEditor.js +11 -5
- package/dist/runtime/data/handleId.d.ts +61 -1
- package/dist/runtime/data/handleId.js +8 -1
- package/dist/runtime/data/payloadForm.js +3 -2
- package/dist/runtime/i18n/messages/de.json +26 -8
- package/dist/runtime/i18n/messages/en.json +26 -8
- package/dist/runtime/pages/integrations/audit.vue +7 -6
- package/dist/runtime/pages/integrations/credentials.vue +9 -21
- package/dist/runtime/pages/integrations/index.vue +55 -8
- package/dist/runtime/pages/integrations/runs.vue +116 -7
- package/dist/runtime/pages/integrations/secrets.vue +2 -1
- package/dist/runtime/pages/integrations/templates/[slug].vue +2 -1
- package/dist/runtime/pages/integrations/templates/index.vue +18 -15
- package/dist/runtime/pages/integrations/workflows/[id].vue +155 -20
- package/dist/runtime/types/integrations.d.ts +5 -0
- package/dist/runtime/utils/apiError.d.ts +21 -1
- package/dist/runtime/utils/apiError.js +1 -1
- package/dist/runtime/utils/clipboard.d.ts +33 -0
- package/dist/runtime/utils/clipboard.js +11 -0
- package/dist/runtime/utils/deepClone.d.ts +41 -0
- package/dist/runtime/utils/deepClone.js +3 -0
- package/dist/runtime/utils/formatDuration.d.ts +44 -0
- package/dist/runtime/utils/formatDuration.js +13 -0
- package/dist/runtime/utils/nodeDisplayName.d.ts +22 -0
- package/dist/runtime/utils/nodeDisplayName.js +6 -0
- package/dist/runtime/utils/notify.d.ts +1 -1
- package/dist/runtime/utils/runEnvelope.d.ts +31 -0
- package/dist/runtime/utils/runEnvelope.js +12 -0
- package/dist/runtime/utils/runFeedQuery.d.ts +135 -0
- package/dist/runtime/utils/runFeedQuery.js +66 -0
- package/dist/runtime/utils/runOutputs.d.ts +33 -0
- package/dist/runtime/utils/runOutputs.js +10 -0
- package/dist/runtime/utils/validationFindings.d.ts +26 -3
- package/dist/runtime/utils/validationFindings.js +18 -3
- package/package.json +1 -1
|
@@ -2,21 +2,52 @@
|
|
|
2
2
|
import { RefreshCircle as Loader2, Refresh as ReplayIcon, TrashBinMinimalistic as TrashBin } from "#solar-icons";
|
|
3
3
|
import { Badge, Button } from "@revenexx/studio";
|
|
4
4
|
import { toast } from "vue-sonner";
|
|
5
|
+
import LoadingAnnouncement from "../../components/Integrations/LoadingAnnouncement.vue";
|
|
5
6
|
import RunsTable from "../../components/Integrations/RunsTable.vue";
|
|
6
7
|
import { createPollBudget, SAFETY_POLL_MS } from "../../utils/pollBudget";
|
|
7
8
|
import { deadLetterStatusClass, deadLetterStatusLabel } from "../../utils/statusStyle";
|
|
9
|
+
import { errorReason } from "../../utils/apiError";
|
|
8
10
|
const t = useIntegrationsT();
|
|
9
11
|
useHead({ title: () => t("integration.runs.list.head.title") });
|
|
10
12
|
const route = useRoute();
|
|
11
|
-
const { runs, loading, load } = useIntegrationsRuns();
|
|
13
|
+
const { runs, loading, error, load, query, setQuery, narrowedTotal } = useIntegrationsRuns();
|
|
12
14
|
const {
|
|
13
15
|
globalItems: missed,
|
|
14
16
|
globalLoading: missedLoading,
|
|
15
17
|
pendingCount,
|
|
18
|
+
globalPage: missedPage,
|
|
19
|
+
globalLastPage: missedLastPage,
|
|
20
|
+
goGlobalPage,
|
|
21
|
+
resetGlobalPage,
|
|
16
22
|
loadGlobal,
|
|
17
23
|
replay,
|
|
18
|
-
discard
|
|
24
|
+
discard,
|
|
25
|
+
summary: missedSummary
|
|
19
26
|
} = useIntegrationsDeadLetters();
|
|
27
|
+
const page = computed({
|
|
28
|
+
get: () => query.value.page,
|
|
29
|
+
// Only when it actually moved. The shared table returns to its first page
|
|
30
|
+
// whenever the search or the page size changes, and reports that as a page
|
|
31
|
+
// change too — so an unguarded setter would send a second request for the page
|
|
32
|
+
// the list is already on, beside the one the search itself asks for.
|
|
33
|
+
set: (next) => {
|
|
34
|
+
if (next !== query.value.page) void setQuery({ page: next });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
function onNarrow(patch) {
|
|
38
|
+
void setQuery(patch);
|
|
39
|
+
}
|
|
40
|
+
function onReorder(order) {
|
|
41
|
+
void setQuery(order);
|
|
42
|
+
}
|
|
43
|
+
let searchTimer;
|
|
44
|
+
function onSearch(text) {
|
|
45
|
+
clearTimeout(searchTimer);
|
|
46
|
+
if (text === query.value.search) return;
|
|
47
|
+
searchTimer = setTimeout(() => {
|
|
48
|
+
void setQuery({ search: text });
|
|
49
|
+
}, 250);
|
|
50
|
+
}
|
|
20
51
|
const tab = ref(route.query.tab === "missed" ? "missed" : "runs");
|
|
21
52
|
const tabs = computed(() => [
|
|
22
53
|
{ key: "runs", label: t("integration.runs.tabs.runs.label") },
|
|
@@ -36,7 +67,8 @@ watch([runTick, workflowTick], () => {
|
|
|
36
67
|
});
|
|
37
68
|
let pollTimer = null;
|
|
38
69
|
onMounted(() => {
|
|
39
|
-
void
|
|
70
|
+
void setQuery({ search: "", page: 1 });
|
|
71
|
+
resetGlobalPage();
|
|
40
72
|
void reloadMissedRuns();
|
|
41
73
|
pollTimer = setInterval(() => {
|
|
42
74
|
if (hasActiveRuns.value && !loading.value && budget.due(delivering.value)) void load(true);
|
|
@@ -47,8 +79,15 @@ onUnmounted(() => {
|
|
|
47
79
|
clearInterval(pollTimer);
|
|
48
80
|
pollTimer = null;
|
|
49
81
|
}
|
|
82
|
+
clearTimeout(searchTimer);
|
|
50
83
|
});
|
|
51
84
|
const busyId = ref(null);
|
|
85
|
+
async function goMissedPage(page2) {
|
|
86
|
+
try {
|
|
87
|
+
await goGlobalPage(page2);
|
|
88
|
+
} catch {
|
|
89
|
+
}
|
|
90
|
+
}
|
|
52
91
|
async function reloadMissedRuns() {
|
|
53
92
|
try {
|
|
54
93
|
await loadGlobal(true);
|
|
@@ -62,7 +101,7 @@ async function onReplay(dl) {
|
|
|
62
101
|
const newId = res && typeof res === "object" && "id" in res ? String(res.id) : null;
|
|
63
102
|
toast.success(newId ? t("integration.deadLetters.toast.replay.started", { id: newId }) : t("integration.deadLetters.toast.replay.done"));
|
|
64
103
|
} catch (e) {
|
|
65
|
-
toast.error(e
|
|
104
|
+
toast.error(errorReason(e, t("integration.deadLetters.toast.replay.error")));
|
|
66
105
|
busyId.value = null;
|
|
67
106
|
return;
|
|
68
107
|
}
|
|
@@ -76,7 +115,7 @@ async function onDiscard(dl) {
|
|
|
76
115
|
await discard(dl.id);
|
|
77
116
|
toast.success(t("integration.deadLetters.toast.discard.done"));
|
|
78
117
|
} catch (e) {
|
|
79
|
-
toast.error(e
|
|
118
|
+
toast.error(errorReason(e, t("integration.deadLetters.toast.discard.error")));
|
|
80
119
|
busyId.value = null;
|
|
81
120
|
return;
|
|
82
121
|
}
|
|
@@ -107,7 +146,49 @@ function fmt(iso) {
|
|
|
107
146
|
<TabBar v-model="tab" :tabs="tabs" class="px-4" />
|
|
108
147
|
|
|
109
148
|
<div class="p-4 space-y-4">
|
|
110
|
-
|
|
149
|
+
<!-- Above the tab chain on purpose: the missed-runs half is drawn by a
|
|
150
|
+
`v-else`, so a region placed inside it would mount together with the
|
|
151
|
+
placeholders it speaks for, and a live region that arrives with its own
|
|
152
|
+
content is one nothing was listening to. Out here it is on the page from
|
|
153
|
+
the first render and only its text moves (PO-251). -->
|
|
154
|
+
<LoadingAnnouncement
|
|
155
|
+
:label="t('integration.deadLetters.tabs.missed.label')"
|
|
156
|
+
:loading="tab === 'missed' && missedLoading && !missed.length"
|
|
157
|
+
:text="t('integration.deadLetters.state.loading')"
|
|
158
|
+
/>
|
|
159
|
+
|
|
160
|
+
<!--
|
|
161
|
+
A refused read, said plainly. Without this the page kept the rows it had
|
|
162
|
+
while `query` had already moved on, so a failed page turn drew rows 1–25
|
|
163
|
+
under a footer reading "Showing 26–50", and a refused narrowing left the
|
|
164
|
+
old rows under the new selection — both reading as an answer.
|
|
165
|
+
-->
|
|
166
|
+
<div
|
|
167
|
+
v-if="tab === 'runs' && error"
|
|
168
|
+
class="rounded-md border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-sm text-rose-700"
|
|
169
|
+
role="alert"
|
|
170
|
+
>
|
|
171
|
+
{{ error }}
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<RunsTable
|
|
175
|
+
v-if="tab === 'runs'"
|
|
176
|
+
v-model:page="page"
|
|
177
|
+
:runs="runs"
|
|
178
|
+
:loading="loading"
|
|
179
|
+
server-mode
|
|
180
|
+
:total="narrowedTotal"
|
|
181
|
+
:per-page="query.perPage"
|
|
182
|
+
:status="query.status"
|
|
183
|
+
:trigger-type="query.triggerType"
|
|
184
|
+
:sort="query.sort"
|
|
185
|
+
:direction="query.direction"
|
|
186
|
+
@narrow="onNarrow"
|
|
187
|
+
@search="onSearch"
|
|
188
|
+
@page-size="(size) => setQuery({ perPage: size })"
|
|
189
|
+
@reorder="onReorder"
|
|
190
|
+
@changed="reload"
|
|
191
|
+
/>
|
|
111
192
|
|
|
112
193
|
<!-- Missed runs — trigger firings that never produced a run (the
|
|
113
194
|
workflow wasn't runnable when its schedule/event fired). -->
|
|
@@ -122,7 +203,7 @@ function fmt(iso) {
|
|
|
122
203
|
</Button>
|
|
123
204
|
</div>
|
|
124
205
|
|
|
125
|
-
<div v-if="missedLoading && !missed.length"><div class="rounded-lg border border-black/[0.06] bg-white divide-y divide-black/[0.04]">
|
|
206
|
+
<div v-if="missedLoading && !missed.length" aria-hidden="true"><div class="rounded-lg border border-black/[0.06] bg-white divide-y divide-black/[0.04]">
|
|
126
207
|
<div v-for="n in 4" :key="`sk-${n}`" class="flex items-start gap-3 px-3 py-3">
|
|
127
208
|
<div class="h-5 w-16 shrink-0 rounded bg-black/[0.07] animate-pulse" />
|
|
128
209
|
<div class="min-w-0 flex-1 space-y-2">
|
|
@@ -168,6 +249,34 @@ function fmt(iso) {
|
|
|
168
249
|
</div>
|
|
169
250
|
</div>
|
|
170
251
|
</div>
|
|
252
|
+
|
|
253
|
+
<!--
|
|
254
|
+
Server pagination — the endpoint pages, so the whole backlog is
|
|
255
|
+
reachable. It used to read one page of a hundred and offer no way past
|
|
256
|
+
it, while `has_more` came back on every read and was shown to nobody
|
|
257
|
+
(PO-369).
|
|
258
|
+
-->
|
|
259
|
+
<div v-if="missedLastPage > 1" class="flex items-center justify-between text-sm text-muted-foreground">
|
|
260
|
+
<span class="tabular-nums">
|
|
261
|
+
{{ t("integration.deadLetters.pagination.summary", { page: missedPage, lastPage: missedLastPage, total: missedSummary.total }) }}
|
|
262
|
+
</span>
|
|
263
|
+
<div class="flex gap-2">
|
|
264
|
+
<Button
|
|
265
|
+
variant="outline" size="sm" class="h-7 text-sm"
|
|
266
|
+
:disabled="missedPage <= 1 || missedLoading"
|
|
267
|
+
@click="goMissedPage(missedPage - 1)"
|
|
268
|
+
>
|
|
269
|
+
{{ t("integration.deadLetters.pagination.newer") }}
|
|
270
|
+
</Button>
|
|
271
|
+
<Button
|
|
272
|
+
variant="outline" size="sm" class="h-7 text-sm"
|
|
273
|
+
:disabled="missedPage >= missedLastPage || missedLoading"
|
|
274
|
+
@click="goMissedPage(missedPage + 1)"
|
|
275
|
+
>
|
|
276
|
+
{{ t("integration.deadLetters.pagination.older") }}
|
|
277
|
+
</Button>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
171
280
|
</template>
|
|
172
281
|
</div>
|
|
173
282
|
</IntegrationsLayout>
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
Input,
|
|
25
25
|
Label
|
|
26
26
|
} from "@revenexx/studio";
|
|
27
|
+
import { errorReason } from "../../utils/apiError";
|
|
27
28
|
const t = useIntegrationsT();
|
|
28
29
|
useHead({ title: () => t("integration.secrets.head.title") });
|
|
29
30
|
const { secrets, loading, error, isValidKey, upsert, remove, load } = useIntegrationsSecrets();
|
|
@@ -58,7 +59,7 @@ async function submitCreate() {
|
|
|
58
59
|
await upsert(key, newValue.value);
|
|
59
60
|
createOpen.value = false;
|
|
60
61
|
} catch (e) {
|
|
61
|
-
formError.value = e.
|
|
62
|
+
formError.value = errorReason(e, t("integration.secrets.form.error.saveFailed"));
|
|
62
63
|
} finally {
|
|
63
64
|
saving.value = false;
|
|
64
65
|
}
|
|
@@ -24,6 +24,7 @@ import TemplateFlowPreview from "../../../components/Integrations/TemplateFlowPr
|
|
|
24
24
|
import NodeMark from "../../../components/Integrations/NodeMark.vue";
|
|
25
25
|
import { templateCategoryLabel, templatePackageLabel } from "../../../data/integrationTemplates";
|
|
26
26
|
import { describeCron } from "../../../utils/schedule";
|
|
27
|
+
import { errorReason } from "../../../utils/apiError";
|
|
27
28
|
const SELECT_LATER = "__later__";
|
|
28
29
|
const t = useIntegrationsT();
|
|
29
30
|
const route = useRoute();
|
|
@@ -165,7 +166,7 @@ async function confirmInstall() {
|
|
|
165
166
|
});
|
|
166
167
|
if (id) await router.push(`/integrations/workflows/${id}`);
|
|
167
168
|
} catch (e) {
|
|
168
|
-
installError.value = e
|
|
169
|
+
installError.value = errorReason(e, t("integration.templates.detail.install.error.fallback"));
|
|
169
170
|
} finally {
|
|
170
171
|
installing.value = false;
|
|
171
172
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { computed, ref, watch } from "vue";
|
|
3
3
|
import { Magnifier as Search, Layers, DangerTriangle as AlertTriangle } from "#solar-icons";
|
|
4
4
|
import { Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@revenexx/studio";
|
|
5
|
+
import LoadingAnnouncement from "../../../components/Integrations/LoadingAnnouncement.vue";
|
|
5
6
|
import TemplateCard from "../../../components/Integrations/TemplateCard.vue";
|
|
6
7
|
import { templateCategoryLabel, templatePackageLabel } from "../../../data/integrationTemplates";
|
|
7
8
|
const t = useIntegrationsT();
|
|
@@ -55,7 +56,6 @@ watch(() => templates.value.length, () => {
|
|
|
55
56
|
if (templates.value.length) void ensureDetails(templates.value.map((t2) => t2.slug));
|
|
56
57
|
});
|
|
57
58
|
const awaitingCatalogue = computed(() => loading.value && !templates.value.length);
|
|
58
|
-
const statusText = computed(() => awaitingCatalogue.value ? t("integration.templates.gallery.state.loading") : "");
|
|
59
59
|
</script>
|
|
60
60
|
|
|
61
61
|
<template>
|
|
@@ -115,22 +115,25 @@ const statusText = computed(() => awaitingCatalogue.value ? t("integration.templ
|
|
|
115
115
|
</PageHeader>
|
|
116
116
|
|
|
117
117
|
<div class="p-4">
|
|
118
|
-
<!-- What the page is doing, in words (specs/templates.md AC-9).
|
|
119
|
-
|
|
120
|
-
six blank tiles carry no text, no name and no state — so waiting and
|
|
121
|
-
"nothing is registered" were the same silence, which are the two states
|
|
122
|
-
that ask for opposite reactions.
|
|
118
|
+
<!-- What the page is doing, in words (specs/templates.md AC-9). The mechanics are
|
|
119
|
+
in LoadingAnnouncement; three things about this page in particular are not.
|
|
123
120
|
|
|
124
|
-
The region is
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
what is announced is a change inside an existing region.
|
|
121
|
+
The region is NAMED because every card draws a status region of its own while
|
|
122
|
+
its flow miniature loads, so an unnamed one here would be indistinguishable
|
|
123
|
+
from six of those.
|
|
128
124
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
125
|
+
Only the WAIT gets words. The empty registry and a filter without a match say
|
|
126
|
+
which they are in copy the reader arrives at or already has focus in, so
|
|
127
|
+
announcing them too would tell each of them twice.
|
|
128
|
+
|
|
129
|
+
A refused catalogue IS announced, but not from here: it is said by the block
|
|
130
|
+
that shows it, which carries `role="alert"`. Saying it here as well would put
|
|
131
|
+
the same sentence twice in the DOM, heard twice by anybody walking the page. -->
|
|
132
|
+
<LoadingAnnouncement
|
|
133
|
+
:label="t('integration.templates.gallery.state.regionLabel')"
|
|
134
|
+
:loading="awaitingCatalogue"
|
|
135
|
+
:text="t('integration.templates.gallery.state.loading')"
|
|
136
|
+
/>
|
|
134
137
|
|
|
135
138
|
<!-- The placeholders are taken out of the accessibility tree rather than
|
|
136
139
|
marked busy. `aria-busy` only asks assistive technology to defer
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
|
3
|
-
import { AltArrowLeft as ArrowLeft, AltArrowRight as Chevron,
|
|
3
|
+
import { AltArrowLeft as ArrowLeft, AltArrowRight as Chevron, RefreshCircle as Loader2, MenuDots as MoreHorizontal, Play, Refresh as RetryIcon, Diskette as Save, SidebarMinimalistic as RailIcon } from "#solar-icons";
|
|
4
4
|
import {
|
|
5
5
|
AlertDialog,
|
|
6
6
|
AlertDialogCancel,
|
|
@@ -16,6 +16,12 @@ import {
|
|
|
16
16
|
DialogDescription,
|
|
17
17
|
DialogHeader,
|
|
18
18
|
DialogTitle,
|
|
19
|
+
DropdownMenu,
|
|
20
|
+
DropdownMenuContent,
|
|
21
|
+
DropdownMenuItem,
|
|
22
|
+
DropdownMenuLabel,
|
|
23
|
+
DropdownMenuSeparator,
|
|
24
|
+
DropdownMenuTrigger,
|
|
19
25
|
Select,
|
|
20
26
|
SelectContent,
|
|
21
27
|
SelectItem,
|
|
@@ -24,16 +30,20 @@ import {
|
|
|
24
30
|
Switch,
|
|
25
31
|
Textarea
|
|
26
32
|
} from "@revenexx/studio";
|
|
33
|
+
import { deepClone } from "../../../utils/deepClone";
|
|
27
34
|
import WorkflowEditor from "../../../components/Integrations/WorkflowEditor.vue";
|
|
28
35
|
import WorkflowHistoryPanel from "../../../components/Integrations/WorkflowHistoryPanel.vue";
|
|
29
36
|
import WorkflowRunReplayPanel from "../../../components/Integrations/WorkflowRunReplayPanel.vue";
|
|
30
37
|
import WorkflowSettingsDialog from "../../../components/Integrations/WorkflowSettingsDialog.vue";
|
|
38
|
+
import WorkflowDeleteDialog from "../../../components/Integrations/WorkflowDeleteDialog.vue";
|
|
31
39
|
import WorkflowDuplicateDialog from "../../../components/Integrations/WorkflowDuplicateDialog.vue";
|
|
32
40
|
import RunsTable from "../../../components/Integrations/RunsTable.vue";
|
|
33
41
|
import RunDetailPanel from "../../../components/Integrations/RunDetailPanel.vue";
|
|
34
42
|
import { toast } from "vue-sonner";
|
|
35
|
-
import { IntegrationsApiError, apiValidationIssues } from "../../../utils/apiError";
|
|
43
|
+
import { IntegrationsApiError, apiValidationIssues, errorReason } from "../../../utils/apiError";
|
|
36
44
|
import { createPollBudget, SAFETY_POLL_MS } from "../../../utils/pollBudget";
|
|
45
|
+
import { splitRunOutputs } from "../../../utils/runOutputs";
|
|
46
|
+
import { isTerminalRun, unwrapRun } from "../../../utils/runEnvelope";
|
|
37
47
|
import { findingsSummary, groupFindingsByNode, nodeDisplayLabel } from "../../../utils/validationFindings";
|
|
38
48
|
import { payloadFieldsFromBlob } from "../../../data/payloadForm";
|
|
39
49
|
import WorkflowIconView from "../../../components/Integrations/WorkflowIconView.vue";
|
|
@@ -62,11 +72,11 @@ function draftFrom() {
|
|
|
62
72
|
executionMode: w.execution_mode ?? "async_only",
|
|
63
73
|
tags: [...w.blob.tags ?? []],
|
|
64
74
|
icon: w.blob.icon ?? null,
|
|
65
|
-
blob:
|
|
75
|
+
blob: deepClone(w.blob)
|
|
66
76
|
};
|
|
67
77
|
}
|
|
68
78
|
function payloadBlob(d) {
|
|
69
|
-
const blob =
|
|
79
|
+
const blob = deepClone(d.blob);
|
|
70
80
|
blob.name = d.name;
|
|
71
81
|
if (d.description) blob.description = d.description;
|
|
72
82
|
else delete blob.description;
|
|
@@ -138,6 +148,19 @@ async function waitForBuildReady() {
|
|
|
138
148
|
waitingForBuild.value = false;
|
|
139
149
|
}
|
|
140
150
|
}
|
|
151
|
+
let buildWatch = 0;
|
|
152
|
+
async function settleBuildStatus() {
|
|
153
|
+
const mine = ++buildWatch;
|
|
154
|
+
const started = Date.now();
|
|
155
|
+
while (Date.now() - started < BUILD_WAIT_TIMEOUT_MS) {
|
|
156
|
+
if (mine !== buildWatch || !workflow.value) return;
|
|
157
|
+
const status = workflow.value.buildStatus;
|
|
158
|
+
if (status !== "pending" && status !== "building") return;
|
|
159
|
+
await new Promise((resolve) => setTimeout(resolve, BUILD_WAIT_POLL_MS));
|
|
160
|
+
if (mine !== buildWatch || !workflow.value) return;
|
|
161
|
+
await get(workflow.value.id);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
141
164
|
const buildNotice = computed(() => {
|
|
142
165
|
switch (workflow.value?.buildStatus) {
|
|
143
166
|
case "building":
|
|
@@ -159,6 +182,7 @@ watch(id, async () => {
|
|
|
159
182
|
draft.value = null;
|
|
160
183
|
warningsExpanded.value = false;
|
|
161
184
|
if (tab.value === "runs") void loadRuns(1);
|
|
185
|
+
void settleBuildStatus();
|
|
162
186
|
}, { immediate: true });
|
|
163
187
|
function onChange(blob) {
|
|
164
188
|
const d = draftFrom();
|
|
@@ -212,12 +236,25 @@ async function onDuplicated(copy) {
|
|
|
212
236
|
toast.success(t("integration.editor.toast.duplicated", { name: copy.name }));
|
|
213
237
|
await leaveTo(`/integrations/workflows/${copy.id}`);
|
|
214
238
|
}
|
|
239
|
+
const deleteOpen = ref(false);
|
|
240
|
+
const deleteTarget = ref(null);
|
|
241
|
+
function askDelete() {
|
|
242
|
+
if (!workflow.value) return;
|
|
243
|
+
deleteTarget.value = { id: workflow.value.id, name: workflow.value.name };
|
|
244
|
+
deleteOpen.value = true;
|
|
245
|
+
}
|
|
246
|
+
async function onDeleted(wf) {
|
|
247
|
+
draft.value = null;
|
|
248
|
+
toast.success(t("integration.editor.toast.deleted", { name: wf.name }));
|
|
249
|
+
await leaveTo("/integrations/workflows");
|
|
250
|
+
}
|
|
215
251
|
async function onRevisionRestored() {
|
|
216
252
|
if (!workflow.value) return;
|
|
217
253
|
await get(workflow.value.id);
|
|
218
254
|
draft.value = null;
|
|
255
|
+
void settleBuildStatus();
|
|
219
256
|
}
|
|
220
|
-
const { trigger: triggerRun, loadForWorkflow, fetchRun, retryRun } = useIntegrationsRuns();
|
|
257
|
+
const { trigger: triggerRun, loadForWorkflow, fetchRun, getRun, retryRun } = useIntegrationsRuns();
|
|
221
258
|
const running = ref(false);
|
|
222
259
|
const waitingMode = ref(null);
|
|
223
260
|
const payloadText = ref("{}");
|
|
@@ -227,6 +264,8 @@ const syncOpen = ref(false);
|
|
|
227
264
|
const syncLoading = ref(false);
|
|
228
265
|
const syncResponse = ref(null);
|
|
229
266
|
const syncError = ref(null);
|
|
267
|
+
const runObject = ref(null);
|
|
268
|
+
const followingRun = ref(false);
|
|
230
269
|
const runModalMode = ref("sync");
|
|
231
270
|
const runModalRunId = ref(null);
|
|
232
271
|
function extractRunId(res) {
|
|
@@ -236,6 +275,13 @@ function extractRunId(res) {
|
|
|
236
275
|
}
|
|
237
276
|
return null;
|
|
238
277
|
}
|
|
278
|
+
function asRunObject(res) {
|
|
279
|
+
if (!res || typeof res !== "object" || Array.isArray(res)) return null;
|
|
280
|
+
const o = res;
|
|
281
|
+
return "id" in o && "status" in o ? o : null;
|
|
282
|
+
}
|
|
283
|
+
const runOutputs = computed(() => splitRunOutputs(runObject.value?.result, shownBlob.value?.nodes ?? []));
|
|
284
|
+
const runResult = computed(() => runObject.value ? runObject.value.result : syncResponse.value);
|
|
239
285
|
function viewRunFromModal(id2) {
|
|
240
286
|
syncOpen.value = false;
|
|
241
287
|
void router.push({ query: { ...route.query, run: id2 } });
|
|
@@ -244,9 +290,8 @@ function syncErrorMessage(e) {
|
|
|
244
290
|
if (e instanceof IntegrationsApiError) {
|
|
245
291
|
if (e.status === 504) return t("integration.editor.run.error.syncTimeout");
|
|
246
292
|
if (e.status === 422 && e.body?.status === "failed") return t("integration.editor.run.error.syncFailed");
|
|
247
|
-
return e.message || t("integration.editor.run.error.syncFailed");
|
|
248
293
|
}
|
|
249
|
-
return e
|
|
294
|
+
return errorReason(e, t("integration.editor.run.error.syncFailed"));
|
|
250
295
|
}
|
|
251
296
|
function refusedForSettings(e) {
|
|
252
297
|
const issues = apiValidationIssues(e);
|
|
@@ -289,6 +334,8 @@ async function run(mode) {
|
|
|
289
334
|
}
|
|
290
335
|
runModalMode.value = mode;
|
|
291
336
|
runModalRunId.value = null;
|
|
337
|
+
stopFollowingRun();
|
|
338
|
+
runObject.value = null;
|
|
292
339
|
syncOpen.value = true;
|
|
293
340
|
syncLoading.value = true;
|
|
294
341
|
syncResponse.value = null;
|
|
@@ -297,6 +344,7 @@ async function run(mode) {
|
|
|
297
344
|
try {
|
|
298
345
|
syncResponse.value = await triggerRun(workflow.value.id, payload, "sync");
|
|
299
346
|
runModalRunId.value = extractRunId(syncResponse.value);
|
|
347
|
+
runObject.value = asRunObject(syncResponse.value);
|
|
300
348
|
} catch (e) {
|
|
301
349
|
await reportRunFailure(e, syncErrorMessage);
|
|
302
350
|
if (e instanceof IntegrationsApiError) {
|
|
@@ -312,8 +360,10 @@ async function run(mode) {
|
|
|
312
360
|
try {
|
|
313
361
|
const res = await triggerRun(workflow.value.id, payload, "async");
|
|
314
362
|
runModalRunId.value = extractRunId(res);
|
|
363
|
+
runObject.value = asRunObject(res);
|
|
364
|
+
startFollowingRun();
|
|
315
365
|
} catch (e) {
|
|
316
|
-
await reportRunFailure(e, (err) => err
|
|
366
|
+
await reportRunFailure(e, (err) => errorReason(err, t("integration.editor.run.error.couldNotStart")));
|
|
317
367
|
} finally {
|
|
318
368
|
running.value = false;
|
|
319
369
|
syncLoading.value = false;
|
|
@@ -419,6 +469,61 @@ function startRunsPoll() {
|
|
|
419
469
|
}, RUNS_POLL_MS);
|
|
420
470
|
}
|
|
421
471
|
onUnmounted(stopRunsPoll);
|
|
472
|
+
const FOLLOW_POLL_MS = 3e3;
|
|
473
|
+
const followBudget = createPollBudget({ intervalMs: FOLLOW_POLL_MS, liveIntervalMs: SAFETY_POLL_MS });
|
|
474
|
+
const FOLLOW_MAX_MISSES = 3;
|
|
475
|
+
let followTimer = null;
|
|
476
|
+
let followMisses = 0;
|
|
477
|
+
let followSeq = 0;
|
|
478
|
+
function stopFollowingRun() {
|
|
479
|
+
if (followTimer) {
|
|
480
|
+
clearInterval(followTimer);
|
|
481
|
+
followTimer = null;
|
|
482
|
+
}
|
|
483
|
+
followSeq++;
|
|
484
|
+
followingRun.value = false;
|
|
485
|
+
}
|
|
486
|
+
function countFollowMiss() {
|
|
487
|
+
if (++followMisses >= FOLLOW_MAX_MISSES) stopFollowingRun();
|
|
488
|
+
}
|
|
489
|
+
async function readFollowedRun() {
|
|
490
|
+
const id2 = runModalRunId.value;
|
|
491
|
+
if (!workflow.value || !id2) return stopFollowingRun();
|
|
492
|
+
const seq = ++followSeq;
|
|
493
|
+
try {
|
|
494
|
+
const raw = await getRun(workflow.value.id, id2);
|
|
495
|
+
followBudget.spend();
|
|
496
|
+
if (seq !== followSeq) return;
|
|
497
|
+
const run2 = asRunObject(unwrapRun(raw));
|
|
498
|
+
if (!run2) return countFollowMiss();
|
|
499
|
+
followMisses = 0;
|
|
500
|
+
runObject.value = run2;
|
|
501
|
+
if (isTerminalRun(run2)) stopFollowingRun();
|
|
502
|
+
} catch {
|
|
503
|
+
if (seq !== followSeq) return;
|
|
504
|
+
countFollowMiss();
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function startFollowingRun() {
|
|
508
|
+
stopFollowingRun();
|
|
509
|
+
if (!runModalRunId.value || isTerminalRun(runObject.value)) return;
|
|
510
|
+
followMisses = 0;
|
|
511
|
+
followingRun.value = true;
|
|
512
|
+
void readFollowedRun();
|
|
513
|
+
followTimer = setInterval(() => {
|
|
514
|
+
if (followBudget.due(realtimeDelivering.value)) void readFollowedRun();
|
|
515
|
+
}, FOLLOW_POLL_MS);
|
|
516
|
+
}
|
|
517
|
+
watch(runTick, () => {
|
|
518
|
+
if (!followingRun.value) return;
|
|
519
|
+
const eventRun = lastRunEvent.value?.topicId;
|
|
520
|
+
if (eventRun && eventRun !== String(runModalRunId.value ?? "")) return;
|
|
521
|
+
void readFollowedRun();
|
|
522
|
+
});
|
|
523
|
+
watch(syncOpen, (open) => {
|
|
524
|
+
if (!open) stopFollowingRun();
|
|
525
|
+
});
|
|
526
|
+
onUnmounted(stopFollowingRun);
|
|
422
527
|
watch(runTick, () => {
|
|
423
528
|
if (tab.value !== "runs" || runsWanted.value !== 1 || runsLoading.value) return;
|
|
424
529
|
const eventWorkflow = workflowIdOf(lastRunEvent.value);
|
|
@@ -455,7 +560,7 @@ async function loadRuns(page = runsWanted.value, opts = {}) {
|
|
|
455
560
|
runsWanted.value = res.page;
|
|
456
561
|
} catch (e) {
|
|
457
562
|
if (seq !== runsSeq) return;
|
|
458
|
-
runsError.value = e
|
|
563
|
+
runsError.value = errorReason(e, t("integration.editor.runs.error.load"));
|
|
459
564
|
runsWanted.value = runsPage.value;
|
|
460
565
|
} finally {
|
|
461
566
|
if (!opts.silent && seq === runsLoadingSeq) runsLoading.value = false;
|
|
@@ -510,7 +615,7 @@ async function loadOpenRun() {
|
|
|
510
615
|
try {
|
|
511
616
|
openRun.value = await fetchRun(workflow.value.id, openRunId.value, workflow.value.name);
|
|
512
617
|
} catch (e) {
|
|
513
|
-
openRunError.value = e
|
|
618
|
+
openRunError.value = errorReason(e, t("integration.editor.run.error.loadOpenRun"));
|
|
514
619
|
}
|
|
515
620
|
}
|
|
516
621
|
watch(openRunId, () => {
|
|
@@ -536,7 +641,7 @@ async function retryOpenRun() {
|
|
|
536
641
|
toast.success(newId && newId !== openRun.value.id ? t("integration.editor.retry.toast.started", { id: newId }) : t("integration.editor.retry.toast.requested"));
|
|
537
642
|
void loadRuns(1, { silent: true });
|
|
538
643
|
} catch (e) {
|
|
539
|
-
toast.error(e
|
|
644
|
+
toast.error(errorReason(e, t("integration.editor.retry.error.generic")));
|
|
540
645
|
} finally {
|
|
541
646
|
retryBusy.value = false;
|
|
542
647
|
}
|
|
@@ -589,7 +694,7 @@ async function onToggleActive() {
|
|
|
589
694
|
toast.error(t("integration.editor.activate.error.validationIssues", issues.length));
|
|
590
695
|
return;
|
|
591
696
|
}
|
|
592
|
-
toast.error(e
|
|
697
|
+
toast.error(errorReason(e, t("integration.editor.activate.error.generic")));
|
|
593
698
|
}
|
|
594
699
|
}
|
|
595
700
|
async function save() {
|
|
@@ -606,6 +711,7 @@ async function save() {
|
|
|
606
711
|
executionMode: d.executionMode
|
|
607
712
|
});
|
|
608
713
|
draft.value = null;
|
|
714
|
+
void settleBuildStatus();
|
|
609
715
|
} catch (e) {
|
|
610
716
|
const issues = apiValidationIssues(e);
|
|
611
717
|
if (issues.length && workflow.value.active) {
|
|
@@ -617,7 +723,7 @@ async function save() {
|
|
|
617
723
|
});
|
|
618
724
|
deactivationOpen.value = true;
|
|
619
725
|
} else {
|
|
620
|
-
saveError.value = e
|
|
726
|
+
saveError.value = errorReason(e, t("integration.editor.save.error.generic"));
|
|
621
727
|
}
|
|
622
728
|
} finally {
|
|
623
729
|
saving.value = false;
|
|
@@ -642,9 +748,10 @@ async function confirmDeactivate() {
|
|
|
642
748
|
draft.value = null;
|
|
643
749
|
deactivationOpen.value = false;
|
|
644
750
|
toast.success(t("integration.editor.deactivate.toast.savedPaused"));
|
|
751
|
+
void settleBuildStatus();
|
|
645
752
|
} catch (e) {
|
|
646
753
|
deactivationOpen.value = false;
|
|
647
|
-
saveError.value = e
|
|
754
|
+
saveError.value = errorReason(e, t("integration.editor.save.error.generic"));
|
|
648
755
|
} finally {
|
|
649
756
|
saving.value = false;
|
|
650
757
|
}
|
|
@@ -747,10 +854,33 @@ function formatDateTime(iso) {
|
|
|
747
854
|
{{ workflow.buildStatus === "building" ? t("integration.editor.build.chip.building") : t("integration.editor.build.chip.queued") }}
|
|
748
855
|
</span>
|
|
749
856
|
<span v-if="isDirty" class="text-sm text-amber-600">{{ t("integration.editor.status.unsavedChanges") }}</span>
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
857
|
+
<!-- What is done to the WORKFLOW, as opposed to the draft in front
|
|
858
|
+
of it: the buttons beside this menu discard and save an
|
|
859
|
+
afternoon's work, and these two make and unmake the thing that
|
|
860
|
+
work belongs to. Two reasons they are a menu rather than two more
|
|
861
|
+
buttons in a row of five. Deleting cannot be undone and has no
|
|
862
|
+
business a mis-click away from Save; and a lone Delete under a
|
|
863
|
+
Duplicate that stayed a button reads as an oversight rather than
|
|
864
|
+
as a grouping — the menu only holds together while it holds both.
|
|
865
|
+
Icon-only, so it carries a name of its own. -->
|
|
866
|
+
<DropdownMenu>
|
|
867
|
+
<DropdownMenuTrigger as-child>
|
|
868
|
+
<Button variant="outline" size="icon" class="size-7" :disabled="saving" :aria-label="t('integration.editor.action.more.aria')">
|
|
869
|
+
<MoreHorizontal :size="12" />
|
|
870
|
+
</Button>
|
|
871
|
+
</DropdownMenuTrigger>
|
|
872
|
+
<DropdownMenuContent align="end">
|
|
873
|
+
<DropdownMenuLabel>{{ t("integration.editor.action.more.label") }}</DropdownMenuLabel>
|
|
874
|
+
<DropdownMenuSeparator />
|
|
875
|
+
<DropdownMenuItem @select="duplicateOpen = true">
|
|
876
|
+
{{ t("integration.editor.action.duplicate.label") }}
|
|
877
|
+
</DropdownMenuItem>
|
|
878
|
+
<DropdownMenuSeparator />
|
|
879
|
+
<DropdownMenuItem class="text-destructive focus:text-destructive" @select="askDelete">
|
|
880
|
+
{{ t("integration.common.action.delete") }}
|
|
881
|
+
</DropdownMenuItem>
|
|
882
|
+
</DropdownMenuContent>
|
|
883
|
+
</DropdownMenu>
|
|
754
884
|
<Button variant="outline" class="h-7 text-sm" :disabled="!isDirty || saving" :title="t('integration.editor.action.discard.title')" @click="discardChanges">
|
|
755
885
|
{{ t("integration.editor.action.discard.label") }}
|
|
756
886
|
</Button>
|
|
@@ -898,7 +1028,7 @@ function formatDateTime(iso) {
|
|
|
898
1028
|
:draft="draft?.blob ?? null"
|
|
899
1029
|
:run-busy="running || syncLoading || waitingForBuild"
|
|
900
1030
|
:run-waiting="waitingForBuild"
|
|
901
|
-
:overlay-open="settingsOpen || duplicateOpen || syncOpen || runPayloadOpen || deactivationOpen || leaveOpen"
|
|
1031
|
+
:overlay-open="settingsOpen || duplicateOpen || deleteOpen || syncOpen || runPayloadOpen || deactivationOpen || leaveOpen"
|
|
902
1032
|
@change="onChange"
|
|
903
1033
|
@run="runFromEditor"
|
|
904
1034
|
@toggle-active="onToggleActive"
|
|
@@ -1032,7 +1162,10 @@ function formatDateTime(iso) {
|
|
|
1032
1162
|
:open="syncOpen"
|
|
1033
1163
|
:loading="syncLoading"
|
|
1034
1164
|
:mode="runModalMode"
|
|
1035
|
-
:
|
|
1165
|
+
:run="runObject"
|
|
1166
|
+
:outputs="runOutputs"
|
|
1167
|
+
:result="runResult"
|
|
1168
|
+
:following="followingRun"
|
|
1036
1169
|
:error="syncError"
|
|
1037
1170
|
:run-id="runModalRunId"
|
|
1038
1171
|
@update:open="(v) => syncOpen = v"
|
|
@@ -1049,6 +1182,8 @@ function formatDateTime(iso) {
|
|
|
1049
1182
|
@apply="applySettings"
|
|
1050
1183
|
/>
|
|
1051
1184
|
|
|
1185
|
+
<WorkflowDeleteDialog v-model:open="deleteOpen" :workflow="deleteTarget" @deleted="onDeleted" />
|
|
1186
|
+
|
|
1052
1187
|
<WorkflowDuplicateDialog v-model:open="duplicateOpen" :workflow="duplicateTarget" @duplicated="onDuplicated" />
|
|
1053
1188
|
|
|
1054
1189
|
<!-- Leaving the workflow with unsaved work.
|
|
@@ -261,6 +261,11 @@ export interface ConfigField {
|
|
|
261
261
|
default?: unknown;
|
|
262
262
|
placeholder?: LocalizedString;
|
|
263
263
|
expressionAllowed?: boolean;
|
|
264
|
+
/** The setting takes several lines — a mail body, a list of instructions one
|
|
265
|
+
* per line — so it is drawn with room for them. Read for `string` and
|
|
266
|
+
* `expression` fields; every other type ignores it (a dropdown has no
|
|
267
|
+
* multi-line meaning, and the JSON fallback is already a text area). */
|
|
268
|
+
multiline?: boolean;
|
|
264
269
|
options?: ConfigFieldOption[];
|
|
265
270
|
/** When true, this field's `options` are resolved at author-time via
|
|
266
271
|
* config:resolve with target === this field's `key` (see useNodeConfigResolve). */
|