@revenexx/studio-integrations 0.7.0 → 1.0.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +416 -0
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +2 -1
  4. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +4 -1
  5. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +8 -2
  6. package/dist/runtime/components/Integrations/ExpressionInput.vue +5 -0
  7. package/dist/runtime/components/Integrations/NodeConfigDynamicField.d.vue.ts +3 -0
  8. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +16 -3
  9. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue.d.ts +3 -0
  10. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +1 -0
  11. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +3 -0
  12. package/dist/runtime/components/Integrations/NodeConfigField.vue +2 -0
  13. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +3 -0
  14. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +2 -2
  15. package/dist/runtime/components/Integrations/NodeInspector.vue +95 -10
  16. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +2 -2
  17. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +2 -1
  18. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +34 -8
  19. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +2 -1
  20. package/dist/runtime/components/Integrations/NodePalette.vue +12 -8
  21. package/dist/runtime/components/Integrations/StickyNoteView.vue +45 -11
  22. package/dist/runtime/components/Integrations/SurfaceCard.d.vue.ts +50 -0
  23. package/dist/runtime/components/Integrations/SurfaceCard.vue +55 -0
  24. package/dist/runtime/components/Integrations/SurfaceCard.vue.d.ts +50 -0
  25. package/dist/runtime/components/Integrations/TemplateCard.vue +3 -3
  26. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +12 -3
  27. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +5 -4
  28. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +16 -0
  29. package/dist/runtime/components/Integrations/WorkflowEditor.vue +52 -11
  30. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +16 -0
  31. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.d.vue.ts +6 -0
  32. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +4 -2
  33. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue.d.ts +6 -0
  34. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +59 -28
  35. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +3 -4
  36. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.d.vue.ts +16 -17
  37. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +37 -26
  38. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue.d.ts +16 -17
  39. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +4 -5
  40. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +53 -14
  41. package/dist/runtime/composables/useIntegrationsCredentials.d.ts +11 -0
  42. package/dist/runtime/composables/useIntegrationsCredentials.js +2 -2
  43. package/dist/runtime/composables/useIntegrationsNodes.js +11 -1
  44. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +3 -2
  45. package/dist/runtime/composables/useIntegrationsTriggers.d.ts +23 -3
  46. package/dist/runtime/composables/useIntegrationsTriggers.js +18 -7
  47. package/dist/runtime/composables/useIntegrationsWorkflows.d.ts +10 -1
  48. package/dist/runtime/composables/useIntegrationsWorkflows.js +25 -4
  49. package/dist/runtime/composables/useNodeConfigResolve.js +2 -1
  50. package/dist/runtime/data/integrationNodeCatalog.d.ts +22 -1
  51. package/dist/runtime/data/integrationNodeCatalog.js +8 -4
  52. package/dist/runtime/data/nodeInputData.d.ts +58 -1
  53. package/dist/runtime/data/nodeInputData.js +30 -5
  54. package/dist/runtime/data/triggerGlyphs.d.ts +47 -0
  55. package/dist/runtime/data/triggerGlyphs.js +11 -0
  56. package/dist/runtime/i18n/messages/de.json +20 -3
  57. package/dist/runtime/i18n/messages/en.json +20 -3
  58. package/dist/runtime/pages/integrations/credentials.vue +4 -1
  59. package/dist/runtime/pages/integrations/index.vue +16 -17
  60. package/dist/runtime/pages/integrations/templates/[slug].vue +13 -5
  61. package/dist/runtime/pages/integrations/workflows/[id].vue +193 -104
  62. package/dist/runtime/types/integrations.d.ts +25 -1
  63. package/dist/runtime/utils/cockpitSources.d.ts +22 -3
  64. package/dist/runtime/utils/credentialState.d.ts +6 -1
  65. package/dist/runtime/utils/credentialState.js +1 -1
  66. package/dist/runtime/utils/localize.d.ts +34 -0
  67. package/dist/runtime/utils/localize.js +22 -7
  68. package/dist/runtime/utils/pollBudget.d.ts +1 -1
  69. package/dist/runtime/utils/servedPaths.d.ts +22 -0
  70. package/dist/runtime/utils/servedPaths.js +3 -0
  71. package/package.json +1 -1
@@ -1,7 +1,8 @@
1
1
  <script setup>
2
2
  import { computed, reactive, ref } from "vue";
3
3
  import { marked } from "marked";
4
- import { AltArrowRight as Chevron, Bolt as Zap, CheckCircle as CheckCircle2, ClockCircle as Clock, Global as Webhook, Key, Play, RefreshCircle as Loader2, Shield as ShieldAlert, User } from "#solar-icons";
4
+ import { AltArrowRight as Chevron, CheckCircle as CheckCircle2, Key, RefreshCircle as Loader2, Shield as ShieldAlert, User } from "#solar-icons";
5
+ import { triggerGlyph as glyphForTrigger } from "../../../data/triggerGlyphs";
5
6
  import {
6
7
  Badge,
7
8
  Button,
@@ -90,7 +91,6 @@ function firstSentence(text) {
90
91
  const idx = text.indexOf(". ");
91
92
  return idx === -1 ? text : text.slice(0, idx + 1);
92
93
  }
93
- const TRIGGER_ICON = { schedule: Clock, webhook: Webhook, manual: Play, event: Zap };
94
94
  const triggerChips = computed(() => (tpl.value?.blob.triggers ?? []).map((trig) => {
95
95
  let label = trig.type.charAt(0).toUpperCase() + trig.type.slice(1);
96
96
  if (trig.type === "schedule" && trig.config?.cron) {
@@ -99,7 +99,7 @@ const triggerChips = computed(() => (tpl.value?.blob.triggers ?? []).map((trig)
99
99
  }
100
100
  if (trig.type === "webhook" && trig.config?.path) label = `${t("integration.templates.trigger.webhook")} \xB7 ${trig.config.path}`;
101
101
  if (trig.type === "manual") label = t("integration.templates.trigger.manual");
102
- return { key: trig.id, icon: TRIGGER_ICON[trig.type] ?? Zap, label };
102
+ return { key: trig.id, icon: glyphForTrigger(trig.type), label };
103
103
  }));
104
104
  const variables = computed(() => Object.entries(tpl.value?.blob.variables ?? {}).map(([key, v]) => ({
105
105
  key,
@@ -206,8 +206,16 @@ function levelDot(level) {
206
206
  </PageHeader>
207
207
 
208
208
  <div class="p-4 space-y-4">
209
- <!-- Hero: the live workflow preview on the editor's dotted canvas -->
210
- <div class="relative overflow-hidden rounded-lg border border-black/[0.06] bg-slate-50/70 [background-image:radial-gradient(circle,rgba(15,23,42,0.07)_1px,transparent_1px)] [background-size:14px_14px]">
209
+ <!-- Hero: the live workflow preview on the editor's dotted canvas.
210
+ Named like the two panels below it, with `aria-label` because it
211
+ carries no heading to point at: it was the largest thing on the page
212
+ and the only one with no name at all, which also left it the one
213
+ surface drawing the miniature that nothing could address (PO-247). -->
214
+ <div
215
+ role="region"
216
+ :aria-label="t('integration.templates.detail.flow.ariaLabel')"
217
+ class="relative overflow-hidden rounded-lg border border-black/[0.06] bg-slate-50/70 [background-image:radial-gradient(circle,rgba(15,23,42,0.07)_1px,transparent_1px)] [background-size:14px_14px]"
218
+ >
211
219
  <TemplateFlowPreview v-if="tpl.detailLoaded" :blob="tpl.blob" :height="400" class="px-4" />
212
220
  <div v-else class="flex h-[400px] items-center justify-center">
213
221
  <Loader2 :size="18" class="animate-spin text-muted-foreground/50" />
@@ -1,8 +1,9 @@
1
1
  <script setup>
2
2
  import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
3
- import { AltArrowLeft as ArrowLeft, AltArrowRight as Chevron, Copy as CopyIcon, Pause as PauseIcon, RefreshCircle as Loader2, Play, Refresh as RetryIcon, Diskette as Save, SidebarMinimalistic as RailIcon } from "#solar-icons";
3
+ import { AltArrowLeft as ArrowLeft, AltArrowRight as Chevron, Copy as CopyIcon, RefreshCircle as Loader2, Play, Refresh as RetryIcon, Diskette as Save, SidebarMinimalistic as RailIcon } from "#solar-icons";
4
4
  import {
5
5
  AlertDialog,
6
+ AlertDialogCancel,
6
7
  AlertDialogContent,
7
8
  AlertDialogDescription,
8
9
  AlertDialogFooter,
@@ -20,6 +21,7 @@ import {
20
21
  SelectItem,
21
22
  SelectTrigger,
22
23
  SelectValue,
24
+ Switch,
23
25
  Textarea
24
26
  } from "@revenexx/studio";
25
27
  import WorkflowEditor from "../../../components/Integrations/WorkflowEditor.vue";
@@ -41,7 +43,6 @@ const router = useRouter();
41
43
  const { workflows, get, update, toggleActive } = useIntegrationsWorkflows();
42
44
  const id = computed(() => Number(route.params.id));
43
45
  const workflow = computed(() => workflows.value.find((w) => w.id === id.value) ?? null);
44
- useHead({ title: () => t("integration.editor.head.title", { name: workflow.value?.name ?? t("integration.editor.head.untitled") }) });
45
46
  const tab = ref("editor");
46
47
  const tabs = computed(() => [
47
48
  { key: "editor", label: t("integration.editor.tab.editor") },
@@ -49,8 +50,39 @@ const tabs = computed(() => [
49
50
  { key: "runs", label: t("integration.editor.tab.runs") },
50
51
  { key: "dead-letters", label: t("integration.editor.tab.deadLetters") }
51
52
  ]);
52
- const dirtyBlob = ref(null);
53
- const isDirty = computed(() => dirtyBlob.value !== null);
53
+ const draft = ref(null);
54
+ const isDirty = computed(() => draft.value !== null);
55
+ function draftFrom() {
56
+ if (draft.value) return draft.value;
57
+ const w = workflow.value;
58
+ if (!w) return null;
59
+ return {
60
+ name: w.name,
61
+ description: w.description ?? null,
62
+ executionMode: w.execution_mode ?? "async_only",
63
+ tags: [...w.blob.tags ?? []],
64
+ icon: w.blob.icon ?? null,
65
+ blob: JSON.parse(JSON.stringify(w.blob))
66
+ };
67
+ }
68
+ function payloadBlob(d) {
69
+ const blob = JSON.parse(JSON.stringify(d.blob));
70
+ blob.name = d.name;
71
+ if (d.description) blob.description = d.description;
72
+ else delete blob.description;
73
+ if (d.tags.length) blob.tags = [...d.tags];
74
+ else delete blob.tags;
75
+ if (d.icon) blob.icon = d.icon;
76
+ else delete blob.icon;
77
+ return blob;
78
+ }
79
+ const shownName = computed(() => draft.value?.name ?? workflow.value?.name ?? "");
80
+ const draftedMode = computed(() => draft.value?.executionMode ?? workflow.value?.execution_mode ?? "async_only");
81
+ const shownIcon = computed(() => draft.value ? draft.value.icon : workflow.value?.blob.icon ?? null);
82
+ const shownTags = computed(() => draft.value ? draft.value.tags : [...workflow.value?.blob.tags ?? []]);
83
+ const shownDescription = computed(() => (draft.value ? draft.value.description : workflow.value?.description) ?? "");
84
+ const shownBlob = computed(() => draft.value?.blob ?? workflow.value?.blob ?? null);
85
+ useHead({ title: () => t("integration.editor.head.title", { name: shownName.value || t("integration.editor.head.untitled") }) });
54
86
  const saving = ref(false);
55
87
  const saveError = ref(null);
56
88
  const validationWarnings = computed(() => workflow.value?.warnings);
@@ -76,7 +108,7 @@ onMounted(() => {
76
108
  void loadCredentials();
77
109
  });
78
110
  const reauthCredentials = computed(() => {
79
- const blob = JSON.stringify(dirtyBlob.value ?? workflow.value?.blob ?? {});
111
+ const blob = JSON.stringify(shownBlob.value ?? {});
80
112
  return needsReauth.value.filter((c) => blob.includes(c.id));
81
113
  });
82
114
  const buildWorking = computed(() => workflow.value?.buildStatus === "building" || workflow.value?.buildStatus === "pending");
@@ -123,29 +155,67 @@ const buildNotice = computed(() => {
123
155
  });
124
156
  watch(id, async () => {
125
157
  const w = await get(id.value);
126
- if (!w) await router.push("/integrations/workflows");
127
- dirtyBlob.value = null;
158
+ if (!w) await leaveTo("/integrations/workflows");
159
+ draft.value = null;
128
160
  warningsExpanded.value = false;
129
161
  if (tab.value === "runs") void loadRuns(1);
130
162
  }, { immediate: true });
131
163
  function onChange(blob) {
132
- dirtyBlob.value = blob;
164
+ const d = draftFrom();
165
+ if (d) draft.value = { ...d, blob };
133
166
  }
134
167
  const editorResetKey = ref(0);
135
168
  function discardChanges() {
136
- dirtyBlob.value = null;
169
+ draft.value = null;
137
170
  editorResetKey.value++;
138
171
  }
172
+ const leaveOpen = ref(false);
173
+ let leaveDecision = null;
174
+ function askBeforeLeaving() {
175
+ leaveOpen.value = true;
176
+ return new Promise((resolve) => {
177
+ leaveDecision = resolve;
178
+ });
179
+ }
180
+ function answerLeave(leave) {
181
+ leaveOpen.value = false;
182
+ leaveDecision?.(leave);
183
+ leaveDecision = null;
184
+ }
185
+ let leavingOnPurpose = false;
186
+ async function leaveTo(path) {
187
+ leavingOnPurpose = true;
188
+ try {
189
+ await router.push(path);
190
+ } finally {
191
+ leavingOnPurpose = false;
192
+ }
193
+ }
194
+ async function guardTheDraft(to, from) {
195
+ if (to.path === from.path) return true;
196
+ if (leavingOnPurpose) return true;
197
+ if (!isDirty.value) return true;
198
+ return await askBeforeLeaving();
199
+ }
200
+ onBeforeRouteLeave(guardTheDraft);
201
+ onBeforeRouteUpdate(guardTheDraft);
202
+ function warnBeforeUnload(e) {
203
+ if (!isDirty.value) return;
204
+ e.preventDefault();
205
+ e.returnValue = "unsaved";
206
+ }
207
+ onMounted(() => window.addEventListener("beforeunload", warnBeforeUnload));
208
+ onUnmounted(() => window.removeEventListener("beforeunload", warnBeforeUnload));
139
209
  const duplicateOpen = ref(false);
140
210
  const duplicateTarget = computed(() => workflow.value ? { id: workflow.value.id, name: workflow.value.name } : null);
141
211
  async function onDuplicated(copy) {
142
212
  toast.success(t("integration.editor.toast.duplicated", { name: copy.name }));
143
- await router.push(`/integrations/workflows/${copy.id}`);
213
+ await leaveTo(`/integrations/workflows/${copy.id}`);
144
214
  }
145
215
  async function onRevisionRestored() {
146
216
  if (!workflow.value) return;
147
217
  await get(workflow.value.id);
148
- dirtyBlob.value = null;
218
+ draft.value = null;
149
219
  }
150
220
  const { trigger: triggerRun, loadForWorkflow, fetchRun, retryRun } = useIntegrationsRuns();
151
221
  const running = ref(false);
@@ -250,7 +320,7 @@ async function run(mode) {
250
320
  void loadRuns(1);
251
321
  }
252
322
  }
253
- const payloadDriven = computed(() => payloadFieldsFromBlob(dirtyBlob.value ?? workflow.value?.blob ?? { nodes: [] }).length > 0);
323
+ const payloadDriven = computed(() => payloadFieldsFromBlob(shownBlob.value ?? { nodes: [] }).length > 0);
254
324
  async function runFromEditor() {
255
325
  if (!workflow.value) return;
256
326
  if (payloadDriven.value) {
@@ -462,72 +532,32 @@ const EXECUTION_MODES = computed(() => [
462
532
  { value: "sync_only", label: t("integration.editor.executionMode.sync.label"), hint: t("integration.editor.executionMode.sync.hint") },
463
533
  { value: "caller_decides", label: t("integration.editor.executionMode.callerDecides.label"), hint: t("integration.editor.executionMode.callerDecides.hint") }
464
534
  ]);
465
- const executionModeDraft = ref("async_only");
466
- watch(workflow, (w) => {
467
- if (w) executionModeDraft.value = w.execution_mode ?? "async_only";
468
- }, { immediate: true });
469
- const savingMode = ref(false);
470
535
  function modeLabel(m) {
471
536
  return m === "sync_only" ? t("integration.editor.executionMode.sync.short") : m === "caller_decides" ? t("integration.editor.executionMode.callerDecides.label") : t("integration.editor.executionMode.async.short");
472
537
  }
473
- async function saveExecutionMode(mode) {
474
- if (!workflow.value || mode === workflow.value.execution_mode) return;
475
- const prev = workflow.value.execution_mode ?? "async_only";
476
- executionModeDraft.value = mode;
477
- savingMode.value = true;
478
- saveError.value = null;
479
- try {
480
- await update(workflow.value.id, {
481
- name: workflow.value.name,
482
- description: workflow.value.description,
483
- blob: workflow.value.blob,
484
- active: workflow.value.active,
485
- executionMode: mode
486
- });
487
- await get(workflow.value.id);
488
- } catch (e) {
489
- executionModeDraft.value = prev;
490
- saveError.value = e.message || t("integration.editor.executionMode.error.generic");
491
- } finally {
492
- savingMode.value = false;
493
- }
494
- }
495
- const savingMeta = ref(false);
496
538
  const tagSuggestions = computed(() => {
497
539
  const all = /* @__PURE__ */ new Set();
498
540
  for (const wf of workflows.value) for (const t2 of wf.blob.tags ?? []) all.add(t2);
499
541
  return [...all].sort();
500
542
  });
501
- async function saveWorkflowMeta(meta) {
502
- if (!workflow.value) return;
503
- savingMeta.value = true;
504
- saveError.value = null;
505
- try {
506
- const blob = JSON.parse(JSON.stringify(dirtyBlob.value ?? workflow.value.blob));
507
- blob.name = meta.name;
508
- if (meta.description) blob.description = meta.description;
509
- else delete blob.description;
510
- if (meta.tags.length) blob.tags = [...meta.tags];
511
- else delete blob.tags;
512
- if (meta.icon) blob.icon = meta.icon;
513
- else delete blob.icon;
514
- await update(workflow.value.id, {
515
- name: meta.name,
516
- description: meta.description || null,
517
- blob,
518
- active: workflow.value.active,
519
- executionMode: workflow.value.execution_mode
520
- });
521
- await get(workflow.value.id);
522
- dirtyBlob.value = null;
523
- toast.success(t("integration.editor.appearance.toast.saved"));
524
- } catch (e) {
525
- const msg = e.message || t("integration.editor.appearance.error.generic");
526
- saveError.value = msg;
527
- toast.error(msg);
528
- } finally {
529
- savingMeta.value = false;
530
- }
543
+ const settingsValues = computed(() => ({
544
+ name: shownName.value,
545
+ description: shownDescription.value,
546
+ tags: [...shownTags.value],
547
+ icon: shownIcon.value,
548
+ executionMode: draftedMode.value
549
+ }));
550
+ function applySettings(values) {
551
+ const d = draftFrom();
552
+ if (!d) return;
553
+ draft.value = {
554
+ ...d,
555
+ name: values.name,
556
+ description: values.description || null,
557
+ executionMode: values.executionMode,
558
+ tags: [...values.tags],
559
+ icon: values.icon
560
+ };
531
561
  }
532
562
  async function onToggleActive() {
533
563
  const wf = workflow.value;
@@ -549,18 +579,19 @@ async function onToggleActive() {
549
579
  }
550
580
  }
551
581
  async function save() {
552
- if (!workflow.value || !dirtyBlob.value) return;
582
+ const d = draft.value;
583
+ if (!workflow.value || !d) return;
553
584
  saving.value = true;
554
585
  saveError.value = null;
555
586
  try {
556
587
  await update(workflow.value.id, {
557
- name: workflow.value.name,
558
- description: workflow.value.description,
559
- blob: dirtyBlob.value,
588
+ name: d.name,
589
+ description: d.description,
590
+ blob: payloadBlob(d),
560
591
  active: workflow.value.active,
561
- executionMode: workflow.value.execution_mode
592
+ executionMode: d.executionMode
562
593
  });
563
- dirtyBlob.value = null;
594
+ draft.value = null;
564
595
  } catch (e) {
565
596
  const issues = apiValidationIssues(e);
566
597
  if (issues.length && workflow.value.active) {
@@ -579,21 +610,22 @@ async function save() {
579
610
  }
580
611
  }
581
612
  async function confirmDeactivate() {
582
- if (!workflow.value || !dirtyBlob.value) {
613
+ const d = draft.value;
614
+ if (!workflow.value || !d) {
583
615
  deactivationOpen.value = false;
584
616
  return;
585
617
  }
586
618
  saving.value = true;
587
619
  try {
588
620
  await update(workflow.value.id, {
589
- name: workflow.value.name,
590
- description: workflow.value.description,
591
- blob: dirtyBlob.value,
621
+ name: d.name,
622
+ description: d.description,
623
+ blob: payloadBlob(d),
592
624
  active: false,
593
- executionMode: workflow.value.execution_mode
625
+ executionMode: d.executionMode
594
626
  });
595
627
  await get(workflow.value.id);
596
- dirtyBlob.value = null;
628
+ draft.value = null;
597
629
  deactivationOpen.value = false;
598
630
  toast.success(t("integration.editor.deactivate.toast.savedPaused"));
599
631
  } catch (e) {
@@ -619,7 +651,7 @@ function formatDateTime(iso) {
619
651
  remaining height below the page header. -->
620
652
  <div class="flex h-full flex-col">
621
653
  <template v-if="workflow">
622
- <PageHeader :title="workflow.name">
654
+ <PageHeader :title="shownName">
623
655
  <template #title>
624
656
  <span class="inline-flex min-w-0 items-center gap-1.5">
625
657
  <NuxtLink to="/integrations/workflows" class="font-normal text-muted-foreground/80 transition-colors hover:text-primary">{{ t("integration.editor.breadcrumb.workflows") }}</NuxtLink>
@@ -627,7 +659,7 @@ function formatDateTime(iso) {
627
659
  <!-- Run open: the breadcrumb extends — name and Runs become crumbs
628
660
  back into the workflow surface. -->
629
661
  <template v-if="openRunId">
630
- <button type="button" class="truncate font-normal text-muted-foreground/80 transition-colors hover:text-primary" @click="closeRun('editor')">{{ workflow.name }}</button>
662
+ <button type="button" class="truncate font-normal text-muted-foreground/80 transition-colors hover:text-primary" @click="closeRun('editor')">{{ shownName }}</button>
631
663
  <Chevron :size="15" weight="Outline" class="text-muted-foreground/50" />
632
664
  <button type="button" class="font-normal text-muted-foreground/80 transition-colors hover:text-primary" @click="closeRun('runs')">{{ t("integration.editor.breadcrumb.runs") }}</button>
633
665
  <Chevron :size="15" weight="Outline" class="text-muted-foreground/50" />
@@ -637,9 +669,15 @@ function formatDateTime(iso) {
637
669
  </template>
638
670
  <template v-else>
639
671
  <span class="flex size-7 shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-500">
640
- <WorkflowIconView :icon="workflow.blob.icon" :size="16" />
672
+ <WorkflowIconView :icon="shownIcon" :size="16" />
641
673
  </span>
642
- <span class="truncate">{{ workflow.name }}</span>
674
+ <span class="truncate">{{ shownName }}</span>
675
+ <!-- The SAVED mode, beside a drafted name, and the difference is not an
676
+ oversight. A name changes nothing about what happens when Run is
677
+ pressed, so the header may show what the operator just entered. The
678
+ dispatch mode decides what a run does — and a run goes to the
679
+ workflow the platform holds, not to the draft. A badge following the
680
+ draft would read SYNC over a button that fires an async run. -->
643
681
  <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) })">
644
682
  {{ modeLabel(workflow.execution_mode) }}
645
683
  </Badge>
@@ -707,17 +745,37 @@ function formatDateTime(iso) {
707
745
  <Save v-else :size="12" />
708
746
  {{ saving ? t("integration.editor.action.save.saving") : t("integration.editor.action.save.label") }}
709
747
  </Button>
710
- <Button
711
- :variant="workflow.active ? 'outline' : 'default'"
712
- class="h-7 text-sm"
713
- :class="workflow.active ? 'text-amber-700 hover:text-amber-800' : 'bg-emerald-600 text-white hover:bg-emerald-700'"
714
- :disabled="!workflow.active && isInvalid"
748
+ <!-- Activation is a standing state, not a transport control. The play
749
+ symbol belongs to Run, 600 px away in the canvas's control pane,
750
+ where pressing it makes one thing happen now; arming a workflow's
751
+ triggers looks like nothing happening at all. So this states the
752
+ state in the words the workflows list uses — Active, Paused — and
753
+ the switch beside it is the flip (PO-254).
754
+
755
+ The blocked reason has two homes because a disabled control shows
756
+ no native tooltip in Chrome: the title sits on the wrapper, which
757
+ is not disabled, and `aria-describedby` puts the same sentence on
758
+ the switch for anyone who never hovers. Sighted users also have
759
+ the amber banner directly below, which says it at length. -->
760
+ <div
761
+ class="flex h-7 items-center gap-2 rounded-md border border-black/[0.08] px-2"
715
762
  :title="workflow.active ? t('integration.editor.action.pause.title') : isInvalid ? t('integration.editor.action.activate.title.invalid', validationWarnings?.length ?? 0) : t('integration.editor.action.activate.title')"
716
- @click="onToggleActive"
717
763
  >
718
- <component :is="workflow.active ? PauseIcon : Play" :size="12" />
719
- {{ workflow.active ? t("integration.editor.action.pause.label") : t("integration.editor.action.activate.label") }}
720
- </Button>
764
+ <Switch
765
+ :aria-label="t('integration.editor.action.state.label')"
766
+ :aria-describedby="!workflow.active && isInvalid ? 'wf-activation-blocked' : void 0"
767
+ class="data-[state=checked]:bg-emerald-600 data-[state=unchecked]:bg-slate-300"
768
+ :model-value="workflow.active"
769
+ :disabled="!workflow.active && isInvalid"
770
+ @update:model-value="onToggleActive"
771
+ />
772
+ <span class="text-sm" :class="workflow.active ? 'font-medium text-emerald-700' : 'text-muted-foreground'">
773
+ {{ workflow.active ? t("integration.workflows.table.status.lifecycle.active") : t("integration.workflows.table.status.lifecycle.paused") }}
774
+ </span>
775
+ <span v-if="!workflow.active && isInvalid" id="wf-activation-blocked" class="sr-only">
776
+ {{ t("integration.editor.action.activate.title.invalid", validationWarnings?.length ?? 0) }}
777
+ </span>
778
+ </div>
721
779
  </template>
722
780
  </template>
723
781
 
@@ -739,8 +797,8 @@ function formatDateTime(iso) {
739
797
  </div>
740
798
 
741
799
  <!-- Manifest validation (PO-186): an inactive workflow with warnings can't
742
- be activated. The banner names what's incomplete; the Activate button
743
- stays disabled until the next save recomputes an empty warnings set.
800
+ be activated. The banner names what's incomplete; the header's state
801
+ switch stays blocked until the next save recomputes an empty warnings set.
744
802
  Amber (not red) — the work is saved fine, it just isn't runnable yet.
745
803
  One line per NODE, listing its fields (PO-209): the messages behind
746
804
  them ride along as the line's title, and the line leads to the node. -->
@@ -823,9 +881,10 @@ function formatDateTime(iso) {
823
881
  :key="editorResetKey"
824
882
  ref="editorRef"
825
883
  :workflow="workflow"
884
+ :draft="draft?.blob ?? null"
826
885
  :run-busy="running || syncLoading || waitingForBuild"
827
886
  :run-waiting="waitingForBuild"
828
- :overlay-open="settingsOpen || duplicateOpen || syncOpen || runPayloadOpen || deactivationOpen"
887
+ :overlay-open="settingsOpen || duplicateOpen || syncOpen || runPayloadOpen || deactivationOpen || leaveOpen"
829
888
  @change="onChange"
830
889
  @run="runFromEditor"
831
890
  @toggle-active="onToggleActive"
@@ -870,7 +929,7 @@ function formatDateTime(iso) {
870
929
 
871
930
  <!-- History — n8n-style: read-only canvas preview + revision list. -->
872
931
  <div v-else-if="tab === 'history'" class="flex-1 min-h-0">
873
- <WorkflowHistoryPanel :workflow="workflow" @restored="onRevisionRestored" />
932
+ <WorkflowHistoryPanel :workflow="workflow" :draft-pending="isDirty" @restored="onRevisionRestored" />
874
933
  </div>
875
934
  </template>
876
935
 
@@ -971,16 +1030,46 @@ function formatDateTime(iso) {
971
1030
  v-model:open="settingsOpen"
972
1031
  :workflow="workflow"
973
1032
  :modes="EXECUTION_MODES"
974
- :mode-draft="executionModeDraft"
975
- :saving-mode="savingMode"
976
- :saving-meta="savingMeta"
1033
+ :current="settingsValues"
977
1034
  :tag-suggestions="tagSuggestions"
978
- @change-mode="(m) => saveExecutionMode(m)"
979
- @save-meta="saveWorkflowMeta"
1035
+ @apply="applySettings"
980
1036
  />
981
1037
 
982
1038
  <WorkflowDuplicateDialog v-model:open="duplicateOpen" :workflow="duplicateTarget" @duplicated="onDuplicated" />
983
1039
 
1040
+ <!-- Leaving the workflow with unsaved work.
1041
+ Staying is the default in the only sense that decides anything: it holds the
1042
+ focus when the dialog opens, so Enter or Space keeps the work. That is what
1043
+ `AlertDialogCancel` is for — the dialog focuses its cancel element, and with
1044
+ none declared it focuses the first tabbable button instead, which was
1045
+ "Discard and leave". A breadcrumb click followed by Enter then threw away the
1046
+ afternoon this dialog exists to protect.
1047
+ `as-child` so the studio's own Button is that element rather than a second one
1048
+ beside it. Escape means the same thing, through `update:open`; a click beside
1049
+ the dialog does nothing at all, which an alert dialog is entitled to. -->
1050
+ <AlertDialog :open="leaveOpen" @update:open="(v) => {
1051
+ if (!v) answerLeave(false);
1052
+ }">
1053
+ <AlertDialogContent>
1054
+ <AlertDialogHeader>
1055
+ <AlertDialogTitle>{{ t("integration.editor.leave.dialog.title") }}</AlertDialogTitle>
1056
+ <AlertDialogDescription>
1057
+ {{ t("integration.editor.leave.dialog.description") }}
1058
+ </AlertDialogDescription>
1059
+ </AlertDialogHeader>
1060
+ <AlertDialogFooter>
1061
+ <Button variant="outline" @click="answerLeave(true)">
1062
+ {{ t("integration.editor.leave.dialog.discard") }}
1063
+ </Button>
1064
+ <AlertDialogCancel as-child>
1065
+ <Button @click="answerLeave(false)">
1066
+ {{ t("integration.editor.leave.dialog.stay") }}
1067
+ </Button>
1068
+ </AlertDialogCancel>
1069
+ </AlertDialogFooter>
1070
+ </AlertDialogContent>
1071
+ </AlertDialog>
1072
+
984
1073
  <!-- Active-save blocked by manifest validation: keep the work by saving it
985
1074
  as paused. Cancelling persists nothing (the 422 already rolled back). -->
986
1075
  <AlertDialog :open="deactivationOpen" @update:open="(v) => {
@@ -318,7 +318,24 @@ export interface NodeManifest {
318
318
  title?: LocalizedString;
319
319
  category?: string;
320
320
  }>;
321
- input: NodeManifestInput;
321
+ /**
322
+ * The named ports a node accepts data on, keyed by port name — the mirror of
323
+ * `outputs`.
324
+ *
325
+ * **A node that accepts nothing arrives as `[]`, not `{}`**, which is why the
326
+ * empty array is in the type: the service encodes an empty PHP array as a JSON
327
+ * array, so the empty case is a different JSON *type* from the populated one. It
328
+ * is `never[]` rather than `NodeManifestInput[]` because a *populated* array is
329
+ * not a shape the service produces and not one anything here would read — only
330
+ * `[]` is real. Read the key through `inputPortsForManifest`, which handles both
331
+ * forms once.
332
+ *
333
+ * **Optional here, required there.** Every published manifest carries it — the
334
+ * service's schema lists it among the required keys — but the studio also builds
335
+ * manifest objects in tests and has to tolerate one that predates a field or
336
+ * omits it, so the type does not claim more than the studio can rely on.
337
+ */
338
+ inputs?: Record<string, NodeManifestInput> | never[];
322
339
  /** Empty marks a terminal node (PO-201): the run ends there, so the canvas
323
340
  * draws no output handle and no edge may leave it. */
324
341
  outputs: NodeManifestOutput[];
@@ -328,6 +345,13 @@ export interface NodeManifest {
328
345
  name: string;
329
346
  label: string | null;
330
347
  };
348
+ /**
349
+ * Whether this version's code was built and can therefore be executed
350
+ * (editor-side enrichment from `GET /nodes`; not manifest data). Absent means
351
+ * an API that does not report it — treated as executable, so an older host
352
+ * does not make every node disappear.
353
+ */
354
+ __hasBundle?: boolean;
331
355
  }
332
356
  export interface SecretRecord {
333
357
  key: string;
@@ -40,9 +40,18 @@
40
40
  * That is measured, not read off the regex: unimport's own `injectImports`, run
41
41
  * over this file, injects all five — and over the same file with the declarations
42
42
  * rewritten as `declare function`, injects nothing. Repeat it that way if the
43
- * spelling ever has to change. Nothing stands guard over it automatically, because
44
- * a test would mean this repository taking a dependency on a build tool it does
45
- * not otherwise use, to assert that tool's behaviour rather than its own.
43
+ * spelling ever has to change.
44
+ *
45
+ * **Since PO-271 something does stand guard over it, incidentally rather than by
46
+ * design.** The end-to-end suite runs against a host that auto-imports the same five
47
+ * names (`playground/app/composables/cockpitFakes.ts`), so the injection has to work
48
+ * *there* for `specs/credential-managed-source.md` AC-2 to pass at all: rewrite these
49
+ * as `declare function`, and unimport skips the injection in the playground exactly
50
+ * as it would in the cockpit, all five names are undefined, the panel disables
51
+ * itself, and AC-2 goes red. AC-1 would stay green, which is why this is worth
52
+ * writing down — the half of the spec that is about a host *without* the sources
53
+ * cannot see the difference. There is still no test asserting the build tool's
54
+ * behaviour directly, and there deliberately is not one.
46
55
  */
47
56
  /**
48
57
  * The shapes this module uses.
@@ -148,6 +157,16 @@ export interface CockpitMailCredential {
148
157
  * says nothing about why. `null` is what the caller turns into "the managed
149
158
  * sources are unavailable here", with the manual fields left in place.
150
159
  *
160
+ * **This is not a test for being inside a cockpit, and PO-223 learned that the hard
161
+ * way.** The dashboard read `!== null` as "am I in a cockpit" for its four shortcuts
162
+ * into host-owned surfaces, and the standalone dev studio answered yes: it provides
163
+ * all five names as inert stand-ins on purpose
164
+ * (`integrations/studio-ui/app/composables/consoleStubs.ts`) so that the managed
165
+ * credential panels render their empty states rather than nothing. What this answers
166
+ * is whether the five names are in scope — exactly what the managed sources need to
167
+ * know, and nothing beyond it. A caller asking anything else has to ask its own
168
+ * question; that dashboard now asks the router.
169
+ *
151
170
  * Must be called from a component's setup, like the composables it calls.
152
171
  */
153
172
  export declare function cockpitSources(): CockpitSources | null;
@@ -16,7 +16,12 @@ export interface CredentialStateInfo {
16
16
  label: string;
17
17
  /** Badge/pill classes for this state. */
18
18
  toneClass: string;
19
- /** Failure detail, when the state is `failed`. */
19
+ /**
20
+ * What the last test said, whether it passed or failed. A credential type that
21
+ * reached something can name it — which host, which environment, which plan —
22
+ * and that is exactly what an operator cannot see anywhere else once a node
23
+ * stops asking them to configure it.
24
+ */
20
25
  message?: string | null;
21
26
  /** When the underlying result was recorded (ISO). */
22
27
  at?: string | null;
@@ -23,7 +23,7 @@ export function credentialState(c) {
23
23
  return { key: "needs_reauth", label: t(MESSAGE_KEY.needs_reauth), toneClass: TONE.needs_reauth, at: c.last_test_at };
24
24
  }
25
25
  if (c.last_test_ok === true) {
26
- return { key: "connected", label: t(MESSAGE_KEY.connected), toneClass: TONE.connected, at: c.last_test_at };
26
+ return { key: "connected", label: t(MESSAGE_KEY.connected), toneClass: TONE.connected, message: c.last_test_message, at: c.last_test_at };
27
27
  }
28
28
  if (c.last_test_ok === false) {
29
29
  return { key: "failed", label: t(MESSAGE_KEY.failed), toneClass: TONE.failed, message: c.last_test_message, at: c.last_test_at };