@revenexx/studio-integrations 1.1.0 → 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 +520 -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 +77 -22
- 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 +5 -3
- 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 +143 -20
- 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 +30 -11
- package/dist/runtime/i18n/messages/en.json +27 -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 +70 -12
- 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 +177 -28
- 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/statusStyle.d.ts +24 -0
- package/dist/runtime/utils/statusStyle.js +1 -0
- package/dist/runtime/utils/validationFindings.d.ts +26 -3
- package/dist/runtime/utils/validationFindings.js +18 -3
- package/package.json +1 -1
|
@@ -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;
|
|
@@ -397,6 +447,9 @@ const runsError = ref(null);
|
|
|
397
447
|
const runsPage = ref(1);
|
|
398
448
|
const runsTotal = ref(0);
|
|
399
449
|
const runsLastPage = ref(1);
|
|
450
|
+
const runsWanted = ref(1);
|
|
451
|
+
let runsSeq = 0;
|
|
452
|
+
let runsLoadingSeq = 0;
|
|
400
453
|
const RUNS_POLL_MS = 3e3;
|
|
401
454
|
const { delivering: realtimeDelivering, runTick, lastRunEvent, buildTick, lastBuildEvent } = useIntegrationsRealtime();
|
|
402
455
|
const runsBudget = createPollBudget({ intervalMs: RUNS_POLL_MS, liveIntervalMs: SAFETY_POLL_MS });
|
|
@@ -410,12 +463,69 @@ function stopRunsPoll() {
|
|
|
410
463
|
function startRunsPoll() {
|
|
411
464
|
stopRunsPoll();
|
|
412
465
|
runsPoll = setInterval(() => {
|
|
413
|
-
if (
|
|
466
|
+
if (!runsLoading.value && runsWanted.value === 1 && runsBudget.due(realtimeDelivering.value)) {
|
|
467
|
+
void loadRuns(1, { silent: true });
|
|
468
|
+
}
|
|
414
469
|
}, RUNS_POLL_MS);
|
|
415
470
|
}
|
|
416
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
|
+
}
|
|
417
517
|
watch(runTick, () => {
|
|
418
|
-
if (
|
|
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);
|
|
527
|
+
watch(runTick, () => {
|
|
528
|
+
if (tab.value !== "runs" || runsWanted.value !== 1 || runsLoading.value) return;
|
|
419
529
|
const eventWorkflow = workflowIdOf(lastRunEvent.value);
|
|
420
530
|
if (eventWorkflow && eventWorkflow !== String(workflow.value?.id ?? "")) return;
|
|
421
531
|
void loadRuns(1, { silent: true });
|
|
@@ -426,10 +536,15 @@ watch(buildTick, () => {
|
|
|
426
536
|
if (eventWorkflow && eventWorkflow !== String(workflow.value.id)) return;
|
|
427
537
|
void get(workflow.value.id);
|
|
428
538
|
});
|
|
429
|
-
async function loadRuns(page =
|
|
539
|
+
async function loadRuns(page = runsWanted.value, opts = {}) {
|
|
430
540
|
if (!workflow.value) return;
|
|
541
|
+
runsWanted.value = page;
|
|
542
|
+
const seq = ++runsSeq;
|
|
431
543
|
runsBudget.spend();
|
|
432
|
-
if (!opts.silent)
|
|
544
|
+
if (!opts.silent) {
|
|
545
|
+
runsLoading.value = true;
|
|
546
|
+
runsLoadingSeq = seq;
|
|
547
|
+
}
|
|
433
548
|
runsError.value = null;
|
|
434
549
|
try {
|
|
435
550
|
const res = await loadForWorkflow(workflow.value.id, workflow.value.name, {
|
|
@@ -437,19 +552,23 @@ async function loadRuns(page = runsPage.value, opts = {}) {
|
|
|
437
552
|
page,
|
|
438
553
|
perPage: RUNS_PER_PAGE
|
|
439
554
|
});
|
|
555
|
+
if (seq !== runsSeq) return;
|
|
440
556
|
wfRuns.value = res.runs;
|
|
441
557
|
runsTotal.value = res.total;
|
|
442
558
|
runsLastPage.value = res.lastPage;
|
|
443
559
|
runsPage.value = res.page;
|
|
560
|
+
runsWanted.value = res.page;
|
|
444
561
|
} catch (e) {
|
|
445
|
-
|
|
562
|
+
if (seq !== runsSeq) return;
|
|
563
|
+
runsError.value = errorReason(e, t("integration.editor.runs.error.load"));
|
|
564
|
+
runsWanted.value = runsPage.value;
|
|
446
565
|
} finally {
|
|
447
|
-
if (!opts.silent) runsLoading.value = false;
|
|
566
|
+
if (!opts.silent && seq === runsLoadingSeq) runsLoading.value = false;
|
|
448
567
|
}
|
|
449
568
|
}
|
|
450
569
|
function goRunsPage(p) {
|
|
451
570
|
const next = Math.min(Math.max(1, p), runsLastPage.value);
|
|
452
|
-
if (next !==
|
|
571
|
+
if (next !== runsWanted.value) void loadRuns(next);
|
|
453
572
|
}
|
|
454
573
|
watch(tab, (t2) => {
|
|
455
574
|
if (t2 === "runs") {
|
|
@@ -496,7 +615,7 @@ async function loadOpenRun() {
|
|
|
496
615
|
try {
|
|
497
616
|
openRun.value = await fetchRun(workflow.value.id, openRunId.value, workflow.value.name);
|
|
498
617
|
} catch (e) {
|
|
499
|
-
openRunError.value = e
|
|
618
|
+
openRunError.value = errorReason(e, t("integration.editor.run.error.loadOpenRun"));
|
|
500
619
|
}
|
|
501
620
|
}
|
|
502
621
|
watch(openRunId, () => {
|
|
@@ -522,7 +641,7 @@ async function retryOpenRun() {
|
|
|
522
641
|
toast.success(newId && newId !== openRun.value.id ? t("integration.editor.retry.toast.started", { id: newId }) : t("integration.editor.retry.toast.requested"));
|
|
523
642
|
void loadRuns(1, { silent: true });
|
|
524
643
|
} catch (e) {
|
|
525
|
-
toast.error(e
|
|
644
|
+
toast.error(errorReason(e, t("integration.editor.retry.error.generic")));
|
|
526
645
|
} finally {
|
|
527
646
|
retryBusy.value = false;
|
|
528
647
|
}
|
|
@@ -575,7 +694,7 @@ async function onToggleActive() {
|
|
|
575
694
|
toast.error(t("integration.editor.activate.error.validationIssues", issues.length));
|
|
576
695
|
return;
|
|
577
696
|
}
|
|
578
|
-
toast.error(e
|
|
697
|
+
toast.error(errorReason(e, t("integration.editor.activate.error.generic")));
|
|
579
698
|
}
|
|
580
699
|
}
|
|
581
700
|
async function save() {
|
|
@@ -592,6 +711,7 @@ async function save() {
|
|
|
592
711
|
executionMode: d.executionMode
|
|
593
712
|
});
|
|
594
713
|
draft.value = null;
|
|
714
|
+
void settleBuildStatus();
|
|
595
715
|
} catch (e) {
|
|
596
716
|
const issues = apiValidationIssues(e);
|
|
597
717
|
if (issues.length && workflow.value.active) {
|
|
@@ -603,7 +723,7 @@ async function save() {
|
|
|
603
723
|
});
|
|
604
724
|
deactivationOpen.value = true;
|
|
605
725
|
} else {
|
|
606
|
-
saveError.value = e
|
|
726
|
+
saveError.value = errorReason(e, t("integration.editor.save.error.generic"));
|
|
607
727
|
}
|
|
608
728
|
} finally {
|
|
609
729
|
saving.value = false;
|
|
@@ -628,9 +748,10 @@ async function confirmDeactivate() {
|
|
|
628
748
|
draft.value = null;
|
|
629
749
|
deactivationOpen.value = false;
|
|
630
750
|
toast.success(t("integration.editor.deactivate.toast.savedPaused"));
|
|
751
|
+
void settleBuildStatus();
|
|
631
752
|
} catch (e) {
|
|
632
753
|
deactivationOpen.value = false;
|
|
633
|
-
saveError.value = e
|
|
754
|
+
saveError.value = errorReason(e, t("integration.editor.save.error.generic"));
|
|
634
755
|
} finally {
|
|
635
756
|
saving.value = false;
|
|
636
757
|
}
|
|
@@ -733,10 +854,33 @@ function formatDateTime(iso) {
|
|
|
733
854
|
{{ workflow.buildStatus === "building" ? t("integration.editor.build.chip.building") : t("integration.editor.build.chip.queued") }}
|
|
734
855
|
</span>
|
|
735
856
|
<span v-if="isDirty" class="text-sm text-amber-600">{{ t("integration.editor.status.unsavedChanges") }}</span>
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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>
|
|
740
884
|
<Button variant="outline" class="h-7 text-sm" :disabled="!isDirty || saving" :title="t('integration.editor.action.discard.title')" @click="discardChanges">
|
|
741
885
|
{{ t("integration.editor.action.discard.label") }}
|
|
742
886
|
</Button>
|
|
@@ -884,7 +1028,7 @@ function formatDateTime(iso) {
|
|
|
884
1028
|
:draft="draft?.blob ?? null"
|
|
885
1029
|
:run-busy="running || syncLoading || waitingForBuild"
|
|
886
1030
|
:run-waiting="waitingForBuild"
|
|
887
|
-
:overlay-open="settingsOpen || duplicateOpen || syncOpen || runPayloadOpen || deactivationOpen || leaveOpen"
|
|
1031
|
+
:overlay-open="settingsOpen || duplicateOpen || deleteOpen || syncOpen || runPayloadOpen || deactivationOpen || leaveOpen"
|
|
888
1032
|
@change="onChange"
|
|
889
1033
|
@run="runFromEditor"
|
|
890
1034
|
@toggle-active="onToggleActive"
|
|
@@ -912,10 +1056,10 @@ function formatDateTime(iso) {
|
|
|
912
1056
|
<div v-if="runsLastPage > 1 || runsTotal > wfRuns.length" class="flex items-center justify-between text-sm text-muted-foreground">
|
|
913
1057
|
<span class="tabular-nums">{{ t("integration.editor.runs.pagination.page", { page: runsPage, lastPage: runsLastPage }) }} · {{ t("integration.editor.runs.pagination.count", runsTotal) }}</span>
|
|
914
1058
|
<div class="flex gap-2">
|
|
915
|
-
<Button variant="outline" size="sm" class="h-7 text-sm" :disabled="
|
|
1059
|
+
<Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsWanted <= 1 || runsLoading" @click="goRunsPage(runsWanted - 1)">
|
|
916
1060
|
{{ t("integration.editor.runs.pagination.newer") }}
|
|
917
1061
|
</Button>
|
|
918
|
-
<Button variant="outline" size="sm" class="h-7 text-sm" :disabled="
|
|
1062
|
+
<Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsWanted >= runsLastPage || runsLoading" @click="goRunsPage(runsWanted + 1)">
|
|
919
1063
|
{{ t("integration.editor.runs.pagination.older") }}
|
|
920
1064
|
</Button>
|
|
921
1065
|
</div>
|
|
@@ -1018,7 +1162,10 @@ function formatDateTime(iso) {
|
|
|
1018
1162
|
:open="syncOpen"
|
|
1019
1163
|
:loading="syncLoading"
|
|
1020
1164
|
:mode="runModalMode"
|
|
1021
|
-
:
|
|
1165
|
+
:run="runObject"
|
|
1166
|
+
:outputs="runOutputs"
|
|
1167
|
+
:result="runResult"
|
|
1168
|
+
:following="followingRun"
|
|
1022
1169
|
:error="syncError"
|
|
1023
1170
|
:run-id="runModalRunId"
|
|
1024
1171
|
@update:open="(v) => syncOpen = v"
|
|
@@ -1035,6 +1182,8 @@ function formatDateTime(iso) {
|
|
|
1035
1182
|
@apply="applySettings"
|
|
1036
1183
|
/>
|
|
1037
1184
|
|
|
1185
|
+
<WorkflowDeleteDialog v-model:open="deleteOpen" :workflow="deleteTarget" @deleted="onDeleted" />
|
|
1186
|
+
|
|
1038
1187
|
<WorkflowDuplicateDialog v-model:open="duplicateOpen" :workflow="duplicateTarget" @duplicated="onDuplicated" />
|
|
1039
1188
|
|
|
1040
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). */
|
|
@@ -60,5 +60,25 @@ export declare function apiFieldErrors(err: unknown): Record<string, string>;
|
|
|
60
60
|
* one — and it counts things no form draws. A surface that shows every field-keyed
|
|
61
61
|
* message at its field does not need the number at all; what it needs is the reason
|
|
62
62
|
* (PO-275, reported from use).
|
|
63
|
+
*
|
|
64
|
+
* **It answers for a load as well as a refusal, and that is why it is not called
|
|
65
|
+
* `refusalReason` any more.** Six composables carried a byte-identical private
|
|
66
|
+
* `captureError` for the load case, and forty-odd call sites spelled the cast inline —
|
|
67
|
+
* one contract, three implementations, and only this one knew about the suffix. The
|
|
68
|
+
* strip is a no-op on a load failure, because nothing but a `422` carries a count; what
|
|
69
|
+
* the load case gains is the two lines above it, which it never had: a whitespace-only
|
|
70
|
+
* message now falls back instead of drawing blank.
|
|
71
|
+
*
|
|
72
|
+
* The `instanceof IntegrationsApiError` branch every `captureError` opened with is gone
|
|
73
|
+
* rather than moved. It read `if (e instanceof IntegrationsApiError) return e.message`
|
|
74
|
+
* above a line that reaches `e.message` anyway — the class extends `Error`, and `??`
|
|
75
|
+
* does not fire on a non-empty string — so it never changed an answer for any input.
|
|
76
|
+
*
|
|
77
|
+
* Three other sites kept that shape with this function in the `else`, and there it stopped
|
|
78
|
+
* being harmless the moment the two branches differed: `integrationsApi` wraps **every**
|
|
79
|
+
* failure in `IntegrationsApiError`, so the guard always won and the count was never
|
|
80
|
+
* stripped on a node-config resolve, a schedules load or a run's detail panel. They are
|
|
81
|
+
* gone too. The shape is worth recognising: an `instanceof` guard in front of this
|
|
82
|
+
* function reintroduces exactly the split it exists to remove.
|
|
63
83
|
*/
|
|
64
|
-
export declare function
|
|
84
|
+
export declare function errorReason(err: unknown, fallback: string): string;
|
|
@@ -33,7 +33,7 @@ export function apiFieldErrors(err) {
|
|
|
33
33
|
}
|
|
34
34
|
return out;
|
|
35
35
|
}
|
|
36
|
-
export function
|
|
36
|
+
export function errorReason(err, fallback) {
|
|
37
37
|
const raw = err?.message?.trim();
|
|
38
38
|
if (!raw) return fallback;
|
|
39
39
|
return raw.replace(/\s*\(and \d+ more errors?\)\s*$/, "").trim() || fallback;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Put something on the clipboard, and tell the operator what actually happened.
|
|
3
|
+
*
|
|
4
|
+
* **The waiting is the point.** `writeText` returns a promise, and a browser can refuse
|
|
5
|
+
* it after the fact — a page that is not focused, a context that is not secure, a
|
|
6
|
+
* permission the operator declined. A caller that fires its "Copied" message beside the
|
|
7
|
+
* call rather than after it announces a success it has not been told about, and the
|
|
8
|
+
* operator pastes whatever was on the clipboard before. That shipped on the audit log,
|
|
9
|
+
* where the promise was neither awaited nor caught: the toast had already gone up by the
|
|
10
|
+
* time the rejection arrived, and the rejection went nowhere.
|
|
11
|
+
*
|
|
12
|
+
* **A missing `navigator.clipboard` is a failure, not a reason to do nothing.** The audit
|
|
13
|
+
* log guarded on it and skipped the whole block, so on a browser without the API the
|
|
14
|
+
* button did nothing at all — no copy, no message — which reads as broken rather than as
|
|
15
|
+
* refused. Reading through it and letting the `TypeError` land in the same `catch` is
|
|
16
|
+
* what makes "it did not work" one path instead of two.
|
|
17
|
+
*
|
|
18
|
+
* `vue-sonner` directly, not `notify.ts`: that wrapper exists so a *save* is never lost
|
|
19
|
+
* to a notification that cannot be drawn, and says in its own docblock that everywhere
|
|
20
|
+
* else imports the library. A copy has no such underlying action — the message IS the
|
|
21
|
+
* outcome — so routing six more surfaces through it would spend the indirection on the
|
|
22
|
+
* case it was explicitly not written for.
|
|
23
|
+
*
|
|
24
|
+
* The two messages stay with the caller because they are the only part that differs:
|
|
25
|
+
* copying a node's config, a webhook URL and an audit entry are three different things to
|
|
26
|
+
* have failed, and one message covering all of them would name none of them.
|
|
27
|
+
*
|
|
28
|
+
* Returns whether it worked, for a caller that has more to do than say so.
|
|
29
|
+
*/
|
|
30
|
+
export declare function copyToClipboard(text: string, said: {
|
|
31
|
+
copied: string;
|
|
32
|
+
failed: string;
|
|
33
|
+
}): Promise<boolean>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A deep copy of a value the studio is about to let someone edit.
|
|
3
|
+
*
|
|
4
|
+
* Every surface that edits a workflow takes one first, so that an unsaved change can
|
|
5
|
+
* be thrown away by dropping the copy rather than by undoing it: the editor's canvas,
|
|
6
|
+
* the node inspector's draft, the variables dialog's working set, a duplicate on its
|
|
7
|
+
* way to a new name, and the payload form's expert extras.
|
|
8
|
+
*
|
|
9
|
+
* **It is a JSON round trip, and that is a claim about the input rather than a
|
|
10
|
+
* shortcut.** A workflow blob is JSON — it is what the API stores and what the wire
|
|
11
|
+
* carries — so serialising and parsing it loses nothing. That is the whole guarantee,
|
|
12
|
+
* and it was the guarantee that made eight hand-written copies of this expression safe.
|
|
13
|
+
* What it cost is that the guarantee was never written down, so nothing said which
|
|
14
|
+
* inputs it holds for.
|
|
15
|
+
*
|
|
16
|
+
* **What it does not survive**, for whoever reaches for this with something that is not
|
|
17
|
+
* a blob: `undefined` becomes a missing key rather than a present one, a `Date` comes
|
|
18
|
+
* back as a string, a `Map`, a `Set` and a function are lost entirely, and a cycle
|
|
19
|
+
* throws. None of those can appear in a blob. All of them can appear in editor state
|
|
20
|
+
* that has not been through the API yet — a resolved sample, a cached schema — so a
|
|
21
|
+
* new caller is the moment to check the claim rather than to assume it.
|
|
22
|
+
*
|
|
23
|
+
* **The signature cannot enforce any of that, and says so here rather than pretending.**
|
|
24
|
+
* `<T>(value: T): T` claims the type it was handed comes back, which is false for exactly
|
|
25
|
+
* the five: `deepClone({ at: new Date() }).at` is typed `Date` and is a string, so
|
|
26
|
+
* `.getTime()` type-checks and throws. Constraining the parameter to a JSON-shaped type
|
|
27
|
+
* was tried and does not work here — `WorkflowBlob`, `WorkflowNode` and `WorkflowTrigger`
|
|
28
|
+
* are interfaces, which have no implicit index signature and so satisfy no recursive JSON
|
|
29
|
+
* type; it turned 13 type errors into 73, and collapsed the inference that gives callers
|
|
30
|
+
* their concrete type back. So the guarantee above is advisory, and the tests beside this
|
|
31
|
+
* file are where it is actually held.
|
|
32
|
+
*
|
|
33
|
+
* **Not `structuredClone`, deliberately.** It is available everywhere the module runs
|
|
34
|
+
* and it preserves all five of the things above, which sounds strictly better and is
|
|
35
|
+
* not: it would *change* what the callers get. A field that is dropped today because
|
|
36
|
+
* JSON cannot carry it would start surviving the copy, and something downstream may be
|
|
37
|
+
* relying on that loss without saying so. Swapping the mechanism is a behaviour change
|
|
38
|
+
* that wants its own proof; this module exists so that it becomes one edit when
|
|
39
|
+
* somebody makes it — which is PO-395's other half, and is not done here.
|
|
40
|
+
*/
|
|
41
|
+
export declare function deepClone<T>(value: T): T;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long something took, in the shortest unit that still reads.
|
|
3
|
+
*
|
|
4
|
+
* One function for every surface that prints a duration: the runs table, the run
|
|
5
|
+
* record's step list, the result of a run started by hand, the replay panel's step
|
|
6
|
+
* and run figures, and the dashboard's latest executions. Private copies are how two
|
|
7
|
+
* surfaces come to spell the same milliseconds differently, which is what PO-383
|
|
8
|
+
* closed — three implementations across those five, two of them disagreeing. The
|
|
9
|
+
* dashboard's was not in the ticket's enumeration: it divided by a thousand inside
|
|
10
|
+
* the template, so it was invisible to a search for a formatter.
|
|
11
|
+
*
|
|
12
|
+
* The form is `900ms`, `1.5s`, `1m 3s`, and it was chosen rather than inherited.
|
|
13
|
+
* Three candidates stood in PO-383: this one, the two-decimal `1.50s`/`1.1m` these
|
|
14
|
+
* surfaces used to print, and the same shape as this one with a space before the
|
|
15
|
+
* unit. The unit sits against the number because that is how the product already
|
|
16
|
+
* writes a duration everywhere it is copy rather than a computed figure — `{n}s
|
|
17
|
+
* ago`, `{n}m ago`, `{n}h ago` in the message catalogue, read out beside these very
|
|
18
|
+
* values in the runs list. A space would also have had to stop applying above a
|
|
19
|
+
* minute, where `1 m 3 s` is unreadable, so it was one form under two rules.
|
|
20
|
+
*
|
|
21
|
+
* One decimal under a minute, and minutes-and-seconds above: a duration is read to
|
|
22
|
+
* answer "was that quick", so `1m 3s` beats `1.1m` for a step that took a minute,
|
|
23
|
+
* and the second decimal of `1.50s` is precision nobody acts on.
|
|
24
|
+
*
|
|
25
|
+
* **A trailing zero is dropped, because it is not always a measurement.** Two kinds of
|
|
26
|
+
* value arrive here. A *step's* duration is measured in milliseconds and a tenth of a
|
|
27
|
+
* second is a real reading — `3.4s`. A *run's* is the difference between two
|
|
28
|
+
* timestamps the platform stores to the second (`timestamp(0)`) and serialises without
|
|
29
|
+
* a fraction, so it is always a whole number of seconds: there is no run-level
|
|
30
|
+
* millisecond value anywhere in the API to be had. Printing `4.0s` there claimed a
|
|
31
|
+
* precision that cannot exist. Nothing has to be told which kind it is — a value that
|
|
32
|
+
* measured nothing below the unit has nothing after the point, so `4s` and `1m` fall
|
|
33
|
+
* out of the arithmetic while `3.4s` and `1m 3s` keep what they measured.
|
|
34
|
+
*
|
|
35
|
+
* Nothing promises the form. `specs/run-row.md` AC-3 promises that a row says how
|
|
36
|
+
* long a run has been going and deliberately not in what shape, and that stays the
|
|
37
|
+
* right altitude: a criterion pinning the spelling would freeze a decision the
|
|
38
|
+
* product may want to revisit — the next one being a locale, which this function
|
|
39
|
+
* does not take (German wants `1,5 s`).
|
|
40
|
+
*
|
|
41
|
+
* `null` is not zero: a run that has not started has no duration, and an em dash
|
|
42
|
+
* says so where `0ms` would claim it finished instantly.
|
|
43
|
+
*/
|
|
44
|
+
export declare function formatDuration(ms: number | null | undefined): string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function formatDuration(ms) {
|
|
2
|
+
if (ms == null) return "\u2014";
|
|
3
|
+
if (ms < 1e3) return `${ms}ms`;
|
|
4
|
+
const tenths = Math.round(ms / 100);
|
|
5
|
+
if (tenths < 600) {
|
|
6
|
+
const seconds2 = tenths / 10;
|
|
7
|
+
return tenths % 10 === 0 ? `${seconds2}s` : `${seconds2.toFixed(1)}s`;
|
|
8
|
+
}
|
|
9
|
+
const seconds = Math.round(ms / 1e3);
|
|
10
|
+
const rest = seconds % 60;
|
|
11
|
+
const minutes = Math.floor(seconds / 60);
|
|
12
|
+
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** What a node display name can be derived from — the blob's own fields. */
|
|
2
|
+
export interface NameableNode {
|
|
3
|
+
name?: unknown;
|
|
4
|
+
nodeSlug: string;
|
|
5
|
+
nodeVersion?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* What a node is called, wherever it is named.
|
|
9
|
+
*
|
|
10
|
+
* The three sources in order: the name the author typed, the manifest's name for
|
|
11
|
+
* that kind of node, and — where the catalogue does not know the slug — the slug
|
|
12
|
+
* itself. Never the node's id, which is a key the studio generated and nobody
|
|
13
|
+
* chose.
|
|
14
|
+
*
|
|
15
|
+
* The two resolvers are not interchangeable and the difference has already cost
|
|
16
|
+
* once (PO-204): the author's own name is passed through untouched, while a
|
|
17
|
+
* manifest string is trimmed and may carry its languages as an object. That is
|
|
18
|
+
* why this is one function rather than the rule restated wherever a node is
|
|
19
|
+
* named — the canvas card and the result of a run started by hand have to agree
|
|
20
|
+
* on what a node is called, or the dialog names something the graph does not.
|
|
21
|
+
*/
|
|
22
|
+
export declare function nodeDisplayName(node: NameableNode): string;
|