@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +483 -0
  2. package/README.md +3 -1
  3. package/dist/module.json +1 -1
  4. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +6 -18
  5. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +13 -23
  6. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +7 -6
  7. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +15 -4
  8. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
  9. package/dist/runtime/components/Integrations/ExpressionInput.vue +45 -19
  10. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
  11. package/dist/runtime/components/Integrations/GroupFrameView.vue +5 -4
  12. package/dist/runtime/components/Integrations/LoadingAnnouncement.d.vue.ts +38 -0
  13. package/dist/runtime/components/Integrations/LoadingAnnouncement.vue +16 -0
  14. package/dist/runtime/components/Integrations/LoadingAnnouncement.vue.d.ts +38 -0
  15. package/dist/runtime/components/Integrations/NodeConfigField.vue +23 -0
  16. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +2 -2
  17. package/dist/runtime/components/Integrations/NodeInspector.vue +25 -32
  18. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +2 -2
  19. package/dist/runtime/components/Integrations/RunActionsMenu.vue +2 -1
  20. package/dist/runtime/components/Integrations/RunDetailPanel.vue +5 -17
  21. package/dist/runtime/components/Integrations/RunResponseModal.d.vue.ts +30 -8
  22. package/dist/runtime/components/Integrations/RunResponseModal.vue +92 -24
  23. package/dist/runtime/components/Integrations/RunResponseModal.vue.d.ts +30 -8
  24. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +61 -3
  25. package/dist/runtime/components/Integrations/RunsTable.vue +129 -18
  26. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +61 -3
  27. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +10 -1
  28. package/dist/runtime/components/Integrations/StickyNoteView.vue +1 -1
  29. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +18 -3
  30. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.d.vue.ts +18 -0
  31. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.vue +70 -0
  32. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.vue.d.ts +18 -0
  33. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +2 -1
  34. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +4 -4
  35. package/dist/runtime/components/Integrations/WorkflowEditor.vue +39 -28
  36. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +17 -6
  37. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +17 -21
  38. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +8 -11
  39. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +6 -7
  40. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +7 -7
  41. package/dist/runtime/components/Integrations/WorkflowsTable.vue +10 -48
  42. package/dist/runtime/components/Integrations/canvasSides.d.ts +48 -0
  43. package/dist/runtime/components/Integrations/canvasSides.js +22 -0
  44. package/dist/runtime/composables/useCredentialTypes.js +2 -6
  45. package/dist/runtime/composables/useIntegrationsAudit.js +2 -2
  46. package/dist/runtime/composables/useIntegrationsCredentials.js +4 -8
  47. package/dist/runtime/composables/useIntegrationsDeadLetters.d.ts +4 -0
  48. package/dist/runtime/composables/useIntegrationsDeadLetters.js +16 -1
  49. package/dist/runtime/composables/useIntegrationsNodes.js +2 -2
  50. package/dist/runtime/composables/useIntegrationsRevisions.js +2 -6
  51. package/dist/runtime/composables/useIntegrationsRuns.d.ts +4 -15
  52. package/dist/runtime/composables/useIntegrationsRuns.js +49 -34
  53. package/dist/runtime/composables/useIntegrationsSchedules.js +2 -2
  54. package/dist/runtime/composables/useIntegrationsSecrets.js +2 -6
  55. package/dist/runtime/composables/useIntegrationsTemplates.js +2 -5
  56. package/dist/runtime/composables/useIntegrationsTriggers.js +2 -2
  57. package/dist/runtime/composables/useIntegrationsWorkflows.js +4 -7
  58. package/dist/runtime/composables/useNodeConfigResolve.js +2 -2
  59. package/dist/runtime/composables/useOAuthConnect.d.ts +42 -0
  60. package/dist/runtime/composables/useOAuthConnect.js +33 -0
  61. package/dist/runtime/data/expressionEditor.d.ts +17 -5
  62. package/dist/runtime/data/expressionEditor.js +11 -5
  63. package/dist/runtime/data/handleId.d.ts +61 -1
  64. package/dist/runtime/data/handleId.js +8 -1
  65. package/dist/runtime/data/payloadForm.js +3 -2
  66. package/dist/runtime/i18n/messages/de.json +26 -8
  67. package/dist/runtime/i18n/messages/en.json +26 -8
  68. package/dist/runtime/pages/integrations/audit.vue +7 -6
  69. package/dist/runtime/pages/integrations/credentials.vue +9 -21
  70. package/dist/runtime/pages/integrations/index.vue +55 -8
  71. package/dist/runtime/pages/integrations/runs.vue +116 -7
  72. package/dist/runtime/pages/integrations/secrets.vue +2 -1
  73. package/dist/runtime/pages/integrations/templates/[slug].vue +2 -1
  74. package/dist/runtime/pages/integrations/templates/index.vue +18 -15
  75. package/dist/runtime/pages/integrations/workflows/[id].vue +155 -20
  76. package/dist/runtime/types/integrations.d.ts +5 -0
  77. package/dist/runtime/utils/apiError.d.ts +21 -1
  78. package/dist/runtime/utils/apiError.js +1 -1
  79. package/dist/runtime/utils/clipboard.d.ts +33 -0
  80. package/dist/runtime/utils/clipboard.js +11 -0
  81. package/dist/runtime/utils/deepClone.d.ts +41 -0
  82. package/dist/runtime/utils/deepClone.js +3 -0
  83. package/dist/runtime/utils/formatDuration.d.ts +44 -0
  84. package/dist/runtime/utils/formatDuration.js +13 -0
  85. package/dist/runtime/utils/nodeDisplayName.d.ts +22 -0
  86. package/dist/runtime/utils/nodeDisplayName.js +6 -0
  87. package/dist/runtime/utils/notify.d.ts +1 -1
  88. package/dist/runtime/utils/runEnvelope.d.ts +31 -0
  89. package/dist/runtime/utils/runEnvelope.js +12 -0
  90. package/dist/runtime/utils/runFeedQuery.d.ts +135 -0
  91. package/dist/runtime/utils/runFeedQuery.js +66 -0
  92. package/dist/runtime/utils/runOutputs.d.ts +33 -0
  93. package/dist/runtime/utils/runOutputs.js +10 -0
  94. package/dist/runtime/utils/validationFindings.d.ts +26 -3
  95. package/dist/runtime/utils/validationFindings.js +18 -3
  96. package/package.json +1 -1
@@ -3,8 +3,10 @@ import { computed, onUnmounted, ref, watch } from "vue";
3
3
  import { Download as DownloadIcon, RefreshCircle as RefreshIcon } from "#solar-icons";
4
4
  import { Badge, Button, Dialog, DialogContent, DialogHeader, DialogTitle } from "@revenexx/studio";
5
5
  import { runStatusClass, runStatusLabel } from "../../utils/statusStyle";
6
- import { IntegrationsApiError } from "../../utils/apiError";
6
+ import { IntegrationsApiError, errorReason } from "../../utils/apiError";
7
7
  import { createPollBudget, SAFETY_POLL_MS } from "../../utils/pollBudget";
8
+ import { formatDuration } from "../../utils/formatDuration";
9
+ import { unwrapRun } from "../../utils/runEnvelope";
8
10
  const props = defineProps({
9
11
  run: { type: Object, required: true },
10
12
  view: { type: String, required: false, default: "details" }
@@ -59,14 +61,6 @@ function pickStr(obj, keys) {
59
61
  const v = pick(obj, keys);
60
62
  return v == null ? null : String(v);
61
63
  }
62
- function unwrapRun(res) {
63
- if (!res || typeof res !== "object") return null;
64
- const o = res;
65
- if ("data" in o && o.data && typeof o.data === "object" && !("status" in o) && !("temporal_run_id" in o)) {
66
- return o.data;
67
- }
68
- return o;
69
- }
70
64
  function isNonEmptyObject(v) {
71
65
  return !!v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length > 0;
72
66
  }
@@ -141,8 +135,7 @@ function statusOf(e) {
141
135
  return e instanceof IntegrationsApiError ? e.status : null;
142
136
  }
143
137
  function genericMsg(e, fallback) {
144
- if (e instanceof IntegrationsApiError) return e.message || fallback;
145
- return e?.message ?? fallback;
138
+ return errorReason(e, fallback);
146
139
  }
147
140
  function detailsErrMsg(e) {
148
141
  const s = statusOf(e);
@@ -170,12 +163,7 @@ function stepsErrMsg(e) {
170
163
  if (s === 503) return t("integration.runs.detail.error.details.unavailable");
171
164
  return genericMsg(e, t("integration.runs.detail.error.steps.generic"));
172
165
  }
173
- function fmtMs(ms) {
174
- if (ms == null) return "\u2014";
175
- if (ms < 1e3) return `${ms}ms`;
176
- if (ms < 6e4) return `${(ms / 1e3).toFixed(2)}s`;
177
- return `${(ms / 6e4).toFixed(1)}m`;
178
- }
166
+ const fmtMs = formatDuration;
179
167
  function activityName(a) {
180
168
  const at = a ? a.activity_type ?? a.activityType : void 0;
181
169
  if (typeof at === "string") return at;
@@ -1,18 +1,40 @@
1
+ import type { RunOutput } from '../../utils/runOutputs.js';
1
2
  /**
2
- * Result surface for a manual run, sync and async alike. The parent opens it
3
- * in a loading state, awaits the trigger, then feeds through the outcome:
3
+ * Result surface for a manual run, sync and async alike.
4
4
  *
5
- * - sync: the run's response body (JSON) or a mapped error (timeout /
6
- * failed run). A failed 422 ships the run object, so `runId` lets
7
- * the operator jump straight into the run detail.
8
- * - async: a started confirmation with the fresh run's number — the visible
9
- * counterpart to the fire-and-forget POST.
5
+ * BOTH MODES END HERE, and that is the point of the component (PO-380). The
6
+ * parent opens it on the press, awaits the trigger, and then feeds through
7
+ * whatever it learns about the run — the body a sync request waited for, or the
8
+ * successive states of an async run the parent follows on the author's behalf.
9
+ * What differs between the two is only who does the waiting; the async branch
10
+ * used to stop at "run #99 started", which said that the press did something and
11
+ * nothing about what it did.
12
+ *
13
+ * WHAT IT LEADS WITH is the other half. A run's record carries its Temporal ids,
14
+ * its two re-run references and four timestamps, and the author pressed Run to
15
+ * see none of them: `outputs` is the returned value split per node and named the
16
+ * way the canvas names it, `result` is that value whole for a shape that cannot
17
+ * be split, and the record itself is a click away rather than printed here.
10
18
  */
19
+ interface RunSummary {
20
+ id?: unknown;
21
+ status?: unknown;
22
+ started_at?: unknown;
23
+ completed_at?: unknown;
24
+ }
11
25
  type __VLS_Props = {
12
26
  open: boolean;
27
+ /** The trigger request is still out. */
13
28
  loading?: boolean;
14
29
  mode?: 'sync' | 'async';
15
- response?: unknown;
30
+ /** The run as the platform last reported it — the identity line above the result. */
31
+ run?: RunSummary | null;
32
+ /** What the run returned, per node. `null` where the value has no such shape. */
33
+ outputs?: RunOutput[] | null;
34
+ /** …and the value whole, for when it has not. */
35
+ result?: unknown;
36
+ /** The studio is still following a run that has not ended. */
37
+ following?: boolean;
16
38
  error?: string | null;
17
39
  /** The run behind this result (sync failure / async start) for deep-linking. */
18
40
  runId?: string | null;
@@ -1,4 +1,5 @@
1
1
  <script setup>
2
+ import { computed } from "vue";
2
3
  import { CheckCircle as CheckCircle2, RefreshCircle as Loader2 } from "#solar-icons";
3
4
  import {
4
5
  Button,
@@ -8,22 +9,50 @@ import {
8
9
  DialogHeader,
9
10
  DialogTitle
10
11
  } from "@revenexx/studio";
11
- defineProps({
12
+ import { formatDuration } from "../../utils/formatDuration";
13
+ import { runStatusClass, runStatusLabel } from "../../utils/statusStyle";
14
+ import { isTerminalRun } from "../../utils/runEnvelope";
15
+ const props = defineProps({
12
16
  open: { type: Boolean, required: true },
13
17
  loading: { type: Boolean, required: false },
14
18
  mode: { type: String, required: false },
15
- response: { type: null, required: false },
19
+ run: { type: [Object, null], required: false },
20
+ outputs: { type: [Array, null], required: false },
21
+ result: { type: null, required: false },
22
+ following: { type: Boolean, required: false },
16
23
  error: { type: [String, null], required: false },
17
24
  runId: { type: [String, null], required: false }
18
25
  });
19
26
  const emit = defineEmits(["update:open", "view-run"]);
20
27
  const t = useIntegrationsT();
28
+ const runNumber = computed(() => props.run?.id != null ? String(props.run.id) : props.runId);
29
+ const status = computed(() => props.run?.status != null ? String(props.run.status) : "");
30
+ const duration = computed(() => {
31
+ const from = props.run?.started_at;
32
+ const to = props.run?.completed_at;
33
+ if (typeof from !== "string" || typeof to !== "string") return null;
34
+ const ms = Date.parse(to) - Date.parse(from);
35
+ return Number.isFinite(ms) && ms >= 0 ? ms : null;
36
+ });
37
+ const ended = computed(() => {
38
+ if (props.following) return false;
39
+ if (props.mode !== "async") return true;
40
+ return isTerminalRun(props.run);
41
+ });
42
+ const returnedNothing = computed(() => props.result == null || typeof props.result === "object" && !Array.isArray(props.result) && !Object.keys(props.result).length);
43
+ function pretty(value) {
44
+ return typeof value === "string" ? value : JSON.stringify(value, null, 2);
45
+ }
21
46
  </script>
22
47
 
23
48
  <template>
24
49
  <Dialog :open="open" @update:open="(v) => emit('update:open', v)">
25
50
  <!-- Wide result surface — but hard-capped: min-w-0 down the tree keeps the
26
- JSON <pre> scrolling INSIDE instead of blowing the dialog open. -->
51
+ JSON <pre> scrolling INSIDE instead of blowing the dialog open. The same
52
+ had to be said about the height, and was not: the dialog grew as tall as
53
+ whatever came back, `overflow-hidden` clipped what stuck out, and nothing
54
+ scrolled — so a result of two node outputs ran off the top and the bottom
55
+ of the screen at once and took the buttons below it out of reach (PO-402). -->
27
56
  <DialogContent class="max-w-[94vw] overflow-hidden sm:max-w-4xl">
28
57
  <DialogHeader>
29
58
  <DialogTitle>{{ mode === "async" ? t("integration.runs.response.title.async") : t("integration.runs.response.title.sync") }}</DialogTitle>
@@ -32,7 +61,10 @@ const t = useIntegrationsT();
32
61
  </DialogDescription>
33
62
  </DialogHeader>
34
63
 
35
- <div class="min-w-0 max-w-full text-sm">
64
+ <!-- The body is what scrolls, which is how the create-workflow, node-config
65
+ and SFTP-import dialogs solve the same thing. Capped below the viewport so
66
+ the dialog's own header stays put above it. -->
67
+ <div class="min-w-0 max-h-[70vh] max-w-full overflow-y-auto text-sm">
36
68
  <div v-if="loading" class="flex items-center gap-2 py-6 text-muted-foreground">
37
69
  <Loader2 :size="16" class="animate-spin" />
38
70
  {{ mode === "async" ? t("integration.runs.response.loading.async") : t("integration.runs.response.loading.sync") }}
@@ -49,36 +81,72 @@ const t = useIntegrationsT();
49
81
  </div>
50
82
  </template>
51
83
 
52
- <template v-else-if="mode === 'async'">
53
- <div class="flex items-center gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-emerald-700">
84
+ <template v-else>
85
+ <!-- The confirmation an async press earns: the run exists and is named,
86
+ whatever the studio manages to read back about it afterwards. -->
87
+ <div
88
+ v-if="mode === 'async'"
89
+ class="flex items-center gap-2 rounded-md border border-emerald-500/20 bg-emerald-500/5 px-3 py-2 text-emerald-700"
90
+ >
54
91
  <CheckCircle2 :size="15" class="shrink-0" />
55
- <span v-if="runId">{{ t("integration.runs.response.async.startedWithId", { id: runId }) }}</span>
92
+ <span v-if="runNumber">{{ t("integration.runs.response.async.startedWithId", { id: runNumber }) }}</span>
56
93
  <span v-else>{{ t("integration.runs.response.async.started") }}</span>
57
94
  </div>
58
- <div class="mt-3 flex justify-end gap-2">
95
+
96
+ <!-- Where the run has got to: three facts, and the rest of its record
97
+ stays behind the button below. -->
98
+ <div v-if="runNumber || status" class="mt-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground">
99
+ <span v-if="runNumber" class="font-mono">{{ t("integration.runs.response.runNumber", { id: runNumber }) }}</span>
100
+ <span
101
+ v-if="status"
102
+ class="rounded px-1.5 py-0.5 text-xs"
103
+ :class="runStatusClass(status)"
104
+ >{{ runStatusLabel(status) }}</span>
105
+ <span v-if="duration != null" class="tabular-nums text-xs">{{ formatDuration(duration) }}</span>
106
+ </div>
107
+
108
+ <div v-if="following" class="mt-3 flex items-center gap-2 py-3 text-muted-foreground">
109
+ <Loader2 :size="16" class="animate-spin" />
110
+ {{ t("integration.runs.response.following") }}
111
+ </div>
112
+
113
+ <!-- What it returned, one block per node that produced something. -->
114
+ <div v-else-if="outputs?.length" class="mt-3 space-y-3">
115
+ <div v-for="out in outputs" :key="out.nodeId" class="min-w-0">
116
+ <p class="mb-1 font-medium text-foreground">
117
+ {{ out.name }}
118
+ </p>
119
+ <pre
120
+ class="max-h-[45vh] w-full min-w-0 max-w-full overflow-auto rounded-md border border-black/[0.06] bg-slate-50 p-3 text-xs"
121
+ >{{ pretty(out.value) }}</pre>
122
+ </div>
123
+ </div>
124
+
125
+ <!-- Nothing at all where the run has not ended: the studio has stopped
126
+ looking, which is not the same as the run having finished, and the
127
+ confirmation and the status line above already say where it stands. -->
128
+ <template v-else-if="ended">
129
+ <p v-if="returnedNothing" class="mt-3 py-3 text-muted-foreground">
130
+ {{ t("integration.runs.response.nothingReturned") }}
131
+ </p>
132
+
133
+ <!-- A returned value this workflow's nodes cannot account for: shown
134
+ whole rather than filed under a name it may not belong to. -->
135
+ <pre
136
+ v-else
137
+ class="mt-3 max-h-[60vh] w-full min-w-0 max-w-full overflow-auto rounded-md border border-black/[0.06] bg-slate-50 p-3 text-xs"
138
+ >{{ pretty(result) }}</pre>
139
+ </template>
140
+
141
+ <div class="mt-4 flex justify-end gap-2">
59
142
  <Button variant="outline" class="h-8 text-sm" @click="emit('update:open', false)">
60
143
  {{ t("integration.common.action.close") }}
61
144
  </Button>
62
- <Button v-if="runId" class="h-8 text-sm" @click="emit('view-run', runId)">
145
+ <Button v-if="runNumber" class="h-8 text-sm" @click="emit('view-run', String(runNumber))">
63
146
  {{ t("integration.runs.response.action.viewRun") }}
64
147
  </Button>
65
148
  </div>
66
149
  </template>
67
-
68
- <template v-else-if="response !== void 0 && response !== null">
69
- <pre
70
- class="max-h-[66vh] w-full min-w-0 max-w-full overflow-auto rounded-md border border-black/[0.06] bg-slate-50 p-3 text-xs"
71
- >{{ JSON.stringify(response, null, 2) }}</pre>
72
- <div v-if="runId" class="mt-3 flex justify-end">
73
- <Button variant="outline" class="h-8 text-sm" @click="emit('view-run', runId)">
74
- {{ t("integration.runs.response.action.viewRun") }}
75
- </Button>
76
- </div>
77
- </template>
78
-
79
- <p v-else class="py-6 text-muted-foreground">
80
- {{ t("integration.runs.response.empty") }}
81
- </p>
82
150
  </div>
83
151
  </DialogContent>
84
152
  </Dialog>
@@ -1,18 +1,40 @@
1
+ import type { RunOutput } from '../../utils/runOutputs.js';
1
2
  /**
2
- * Result surface for a manual run, sync and async alike. The parent opens it
3
- * in a loading state, awaits the trigger, then feeds through the outcome:
3
+ * Result surface for a manual run, sync and async alike.
4
4
  *
5
- * - sync: the run's response body (JSON) or a mapped error (timeout /
6
- * failed run). A failed 422 ships the run object, so `runId` lets
7
- * the operator jump straight into the run detail.
8
- * - async: a started confirmation with the fresh run's number — the visible
9
- * counterpart to the fire-and-forget POST.
5
+ * BOTH MODES END HERE, and that is the point of the component (PO-380). The
6
+ * parent opens it on the press, awaits the trigger, and then feeds through
7
+ * whatever it learns about the run — the body a sync request waited for, or the
8
+ * successive states of an async run the parent follows on the author's behalf.
9
+ * What differs between the two is only who does the waiting; the async branch
10
+ * used to stop at "run #99 started", which said that the press did something and
11
+ * nothing about what it did.
12
+ *
13
+ * WHAT IT LEADS WITH is the other half. A run's record carries its Temporal ids,
14
+ * its two re-run references and four timestamps, and the author pressed Run to
15
+ * see none of them: `outputs` is the returned value split per node and named the
16
+ * way the canvas names it, `result` is that value whole for a shape that cannot
17
+ * be split, and the record itself is a click away rather than printed here.
10
18
  */
19
+ interface RunSummary {
20
+ id?: unknown;
21
+ status?: unknown;
22
+ started_at?: unknown;
23
+ completed_at?: unknown;
24
+ }
11
25
  type __VLS_Props = {
12
26
  open: boolean;
27
+ /** The trigger request is still out. */
13
28
  loading?: boolean;
14
29
  mode?: 'sync' | 'async';
15
- response?: unknown;
30
+ /** The run as the platform last reported it — the identity line above the result. */
31
+ run?: RunSummary | null;
32
+ /** What the run returned, per node. `null` where the value has no such shape. */
33
+ outputs?: RunOutput[] | null;
34
+ /** …and the value whole, for when it has not. */
35
+ result?: unknown;
36
+ /** The studio is still following a run that has not ended. */
37
+ following?: boolean;
16
38
  error?: string | null;
17
39
  /** The run behind this result (sync failure / async start) for deep-linking. */
18
40
  runId?: string | null;
@@ -1,4 +1,5 @@
1
- import type { WorkflowRun } from '../../types/integrations.js';
1
+ import type { RunStatus, TriggerType, WorkflowRun } from '../../types/integrations.js';
2
+ import type { RunSort, RunSortDirection } from '../../utils/runFeedQuery.js';
2
3
  type __VLS_Props = {
3
4
  runs: WorkflowRun[];
4
5
  loading?: boolean;
@@ -7,14 +8,71 @@ type __VLS_Props = {
7
8
  pagination?: boolean;
8
9
  /** false inside a workflow — the context makes the column redundant. */
9
10
  showWorkflow?: boolean;
11
+ /**
12
+ * The owning list reads its page and its narrowing from the platform.
13
+ *
14
+ * The table then renders the rows it is handed and reports what the operator
15
+ * asked for, instead of narrowing and slicing an array. That is what lets the
16
+ * list reach past the newest hundred runs at all (PO-369).
17
+ */
18
+ serverMode?: boolean;
19
+ /** How many runs match the narrowing. The pager's "of 7"; server mode only. */
20
+ total?: number;
21
+ /**
22
+ * How many rows a page holds — the SAME number the page asks the platform for.
23
+ *
24
+ * The shared table works its footer's "showing 1–25 of 140" out from its own
25
+ * page size, so a table left on its default while the request asks for another
26
+ * number states a range that is simply wrong: it counted twenty where
27
+ * twenty-five had arrived.
28
+ */
29
+ perPage?: number;
30
+ /** The narrowing in force. Owned by the page, drawn here. */
31
+ status?: RunStatus[];
32
+ triggerType?: TriggerType | '';
33
+ /** The order the platform is reading the list in. Server mode only. */
34
+ sort?: RunSort;
35
+ direction?: RunSortDirection;
10
36
  };
11
- declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
37
+ type __VLS_PublicProps = __VLS_Props & {
38
+ /** 1-based, as the platform pages. The shared table counts from zero. */
39
+ 'page'?: number;
40
+ };
41
+ declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
42
+ search: (text: string) => any;
12
43
  changed: () => any;
13
- }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
44
+ narrow: (patch: {
45
+ status?: RunStatus[];
46
+ triggerType?: TriggerType | "";
47
+ }) => any;
48
+ "page-size": (size: number) => any;
49
+ reorder: (order: {
50
+ sort: RunSort;
51
+ direction: RunSortDirection;
52
+ }) => any;
53
+ "update:page": (value: number) => any;
54
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
55
+ onSearch?: ((text: string) => any) | undefined;
14
56
  onChanged?: (() => any) | undefined;
57
+ onNarrow?: ((patch: {
58
+ status?: RunStatus[];
59
+ triggerType?: TriggerType | "";
60
+ }) => any) | undefined;
61
+ "onPage-size"?: ((size: number) => any) | undefined;
62
+ onReorder?: ((order: {
63
+ sort: RunSort;
64
+ direction: RunSortDirection;
65
+ }) => any) | undefined;
66
+ "onUpdate:page"?: ((value: number) => any) | undefined;
15
67
  }>, {
68
+ sort: RunSort;
16
69
  loading: boolean;
70
+ total: number;
71
+ direction: RunSortDirection;
72
+ triggerType: TriggerType | "";
73
+ perPage: number;
17
74
  pagination: boolean;
18
75
  showWorkflow: boolean;
76
+ serverMode: boolean;
19
77
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
20
78
  export default _default;
@@ -1,19 +1,36 @@
1
1
  <script setup>
2
2
  import { computed, onMounted, onUnmounted, ref } from "vue";
3
- import { Badge } from "@revenexx/studio";
3
+ import {
4
+ Badge,
5
+ DropdownMenuItem,
6
+ DropdownMenuSeparator,
7
+ DropdownMenuSub,
8
+ DropdownMenuSubContent,
9
+ DropdownMenuSubTrigger
10
+ } from "@revenexx/studio";
11
+ import { DEFAULT_RUN_DIRECTION, DEFAULT_RUN_SORT } from "../../utils/runFeedQuery";
4
12
  import { RUN_STATUS_COLUMN_WIDTH, RUN_STATUS_WORDS, normalisedRunStatus, runStatusClass, runStatusLabel } from "../../utils/statusStyle";
13
+ import { formatDuration } from "../../utils/formatDuration";
5
14
  import { TRIGGER_TYPE_WORDS, triggerTypeLabel } from "../../data/integrationNodeCatalog";
6
15
  const props = defineProps({
7
16
  runs: { type: Array, required: true },
8
17
  loading: { type: Boolean, required: false, default: false },
9
18
  emptyText: { type: String, required: false },
10
19
  pagination: { type: Boolean, required: false, default: true },
11
- showWorkflow: { type: Boolean, required: false, default: true }
20
+ showWorkflow: { type: Boolean, required: false, default: true },
21
+ serverMode: { type: Boolean, required: false, default: false },
22
+ total: { type: Number, required: false, default: 0 },
23
+ perPage: { type: Number, required: false, default: 25 },
24
+ status: { type: Array, required: false },
25
+ triggerType: { type: String, required: false, default: "" },
26
+ sort: { type: null, required: false, default: DEFAULT_RUN_SORT },
27
+ direction: { type: String, required: false, default: DEFAULT_RUN_DIRECTION }
12
28
  });
13
- const emit = defineEmits(["changed"]);
29
+ const emit = defineEmits(["changed", "narrow", "search", "page-size", "reorder"]);
30
+ const page = defineModel("page", { type: Number, ...{ default: 1 } });
14
31
  const t = useIntegrationsT();
15
32
  const router = useRouter();
16
- const emptyText = computed(() => props.emptyText ?? t("integration.runs.table.empty"));
33
+ const emptyText = computed(() => props.emptyText ?? (anyNarrowing.value || searchText.value ? t("integration.runs.table.empty.narrowed") : t("integration.runs.table.empty")));
17
34
  const now = ref(Date.now());
18
35
  let tick = null;
19
36
  onMounted(() => {
@@ -39,12 +56,6 @@ function formatRelative(iso) {
39
56
  if (ms < 864e5) return t("integration.common.time.hoursAgo", Math.floor(ms / 36e5));
40
57
  return t("integration.common.time.daysAgo", Math.floor(ms / 864e5));
41
58
  }
42
- function formatDuration(ms) {
43
- if (ms == null) return "\u2014";
44
- if (ms < 1e3) return `${ms}ms`;
45
- if (ms < 6e4) return `${(ms / 1e3).toFixed(2)}s`;
46
- return `${(ms / 6e4).toFixed(1)}m`;
47
- }
48
59
  function liveDuration(run) {
49
60
  if (!run.startedAt) return null;
50
61
  const start = Date.parse(run.startedAt);
@@ -52,7 +63,7 @@ function liveDuration(run) {
52
63
  const end = run.finishedAt ? Date.parse(run.finishedAt) : now.value;
53
64
  return end - start;
54
65
  }
55
- const filterTabs = computed(() => props.pagination ? [
66
+ const filterTabs = computed(() => props.pagination && !props.serverMode ? [
56
67
  {
57
68
  key: "status",
58
69
  label: t("integration.runs.table.column.status"),
@@ -66,6 +77,54 @@ const filterTabs = computed(() => props.pagination ? [
66
77
  options: TRIGGER_TYPE_WORDS.map((v) => ({ value: v, label: triggerTypeLabel(v) }))
67
78
  }
68
79
  ] : void 0);
80
+ const chosenStatus = computed(() => props.status ?? []);
81
+ const anyNarrowing = computed(() => chosenStatus.value.length > 0 || props.triggerType !== "");
82
+ function setStatus(word) {
83
+ emit("narrow", { status: word ? [word] : [] });
84
+ }
85
+ function setTrigger(kind) {
86
+ emit("narrow", { triggerType: kind ?? "" });
87
+ }
88
+ const statusApplied = computed(() => chosenStatus.value[0] ? runStatusLabel(chosenStatus.value[0]) : "");
89
+ const triggerApplied = computed(() => props.triggerType ? triggerTypeLabel(props.triggerType) : "");
90
+ const searchText = ref("");
91
+ function onTableState(state) {
92
+ searchText.value = state.search;
93
+ emit("search", state.search);
94
+ if (state.pageSize !== props.perPage) emit("page-size", state.pageSize);
95
+ }
96
+ const tableSort = computed(() => ({
97
+ column: sortedColumn.value,
98
+ direction: sortedColumn.value ? props.direction : null
99
+ }));
100
+ function onTableSort(next) {
101
+ const sort = next.column ? SERVER_SORTS[next.column] : void 0;
102
+ if (!sort || !next.direction) {
103
+ emit("reorder", { sort: DEFAULT_RUN_SORT, direction: DEFAULT_RUN_DIRECTION });
104
+ return;
105
+ }
106
+ emit("reorder", { sort, direction: next.direction });
107
+ }
108
+ const SERVER_SORTS = {
109
+ started: "started_at",
110
+ duration: "duration"
111
+ };
112
+ function sortableColumn(key) {
113
+ return props.serverMode && key in SERVER_SORTS;
114
+ }
115
+ const sortedColumn = computed(() => {
116
+ const entry = Object.entries(SERVER_SORTS).find(([, value]) => value === props.sort);
117
+ return entry ? entry[0] : null;
118
+ });
119
+ const serverModels = computed(() => props.serverMode ? {
120
+ "sort": tableSort.value,
121
+ "onUpdate:sort": onTableSort,
122
+ // 1-based here, as the platform pages; the shared table counts from zero.
123
+ "page": page.value - 1,
124
+ "onUpdate:page": (p) => {
125
+ page.value = p + 1;
126
+ }
127
+ } : {});
69
128
  const columns = computed(() => [
70
129
  // Sorted and searched by the word on screen, not by the raw field: a column
71
130
  // with no `searchGetter` is searched through its `sortGetter`, so a German
@@ -74,17 +133,17 @@ const columns = computed(() => [
74
133
  // number guessed twice — see `RUN_STATUS_COLUMN_WIDTH` for how it was chosen and
75
134
  // why the track cannot be elastic. At the 100px it used to be, the German
76
135
  // "fehlgeschlagen" ran out of its cell and under the workflow name (PO-362).
77
- { key: "status", label: t("integration.runs.table.column.status"), width: RUN_STATUS_COLUMN_WIDTH, sortable: true, sortGetter: (r) => runStatusLabel(r.status) },
78
- ...props.showWorkflow ? [{ key: "workflow", label: t("integration.runs.table.column.workflow"), sortable: true, sortGetter: (r) => r.workflowName.toLowerCase(), searchGetter: (r) => r.workflowName }] : [],
136
+ { key: "status", label: t("integration.runs.table.column.status"), width: RUN_STATUS_COLUMN_WIDTH, sortable: sortableColumn("status"), sortGetter: (r) => runStatusLabel(r.status) },
137
+ ...props.showWorkflow ? [{ key: "workflow", label: t("integration.runs.table.column.workflow"), sortable: sortableColumn("workflow"), sortGetter: (r) => r.workflowName.toLowerCase(), searchGetter: (r) => r.workflowName }] : [],
79
138
  // Fixed floor wide enough for a full UUID, flexible above it — header and
80
139
  // body rows are separate grids, so a content-sized track (max-content)
81
140
  // would misalign them. The flex share also lets the table fill the width.
82
141
  { key: "id", label: t("integration.runs.table.column.id"), width: "minmax(280px,1fr)", searchGetter: (r) => `${r.temporalRunId ?? ""} ${r.id}` },
83
142
  // Same rule as the status column above: the word on screen is what sorts and
84
143
  // what a search has to match.
85
- { key: "trigger", label: t("integration.runs.table.column.trigger"), width: "120px", sortable: true, sortGetter: (r) => triggerTypeLabel(r.triggerType) },
86
- { key: "started", label: t("integration.runs.table.column.started"), width: "120px", sortable: true, sortGetter: (r) => r.startedAt },
87
- { key: "duration", label: t("integration.runs.table.column.duration"), width: "100px", sortable: true, sortGetter: (r) => r.durationMs ?? -1 },
144
+ { key: "trigger", label: t("integration.runs.table.column.trigger"), width: "120px", sortable: sortableColumn("trigger"), sortGetter: (r) => triggerTypeLabel(r.triggerType) },
145
+ { key: "started", label: t("integration.runs.table.column.started"), width: "120px", sortable: sortableColumn("started"), sortGetter: (r) => r.startedAt },
146
+ { key: "duration", label: t("integration.runs.table.column.duration"), width: "100px", sortable: sortableColumn("duration"), sortGetter: (r) => r.durationMs ?? -1 },
88
147
  { key: "actions", label: "", width: "52px" }
89
148
  ]);
90
149
  </script>
@@ -94,14 +153,66 @@ const columns = computed(() => [
94
153
  :data="runs"
95
154
  :columns="columns"
96
155
  :row-key="(r) => r.id"
97
- :default-sort="{ column: 'started', direction: 'desc' }"
98
156
  :search="{ placeholder: t('integration.runs.table.search.placeholder') }"
99
157
  :filter-tabs="filterTabs"
158
+ :filter="serverMode ? { width: 'w-72', active: anyNarrowing } : false"
159
+ :server-mode="serverMode"
160
+ :total="total"
100
161
  :loading="loading"
101
- :pagination="pagination"
162
+ :pagination="serverMode ? { pageSize: perPage } : pagination"
102
163
  :empty-text="emptyText"
164
+ v-bind="serverModels"
165
+ @state-change="onTableState"
103
166
  @row-click="(r) => openRun(r)"
104
167
  >
168
+ <!--
169
+ The narrowing, where a server-mode surface has to put it: the shared table
170
+ owns the popover, this page owns the selection. Its own in-memory control
171
+ (`filterTabs`) cannot be used here, because it compares the rows in hand and
172
+ those are one page.
173
+
174
+ Built to the same shape as that control on purpose — a dimension per
175
+ sub-menu, "All" first, one value at a time — so this list is narrowed by the
176
+ same gesture as the five that narrow what they hold, and one test helper
177
+ still addresses all six (`e2e/support/studio-table.ts`). What is NOT copied
178
+ is the count beside each option: the built-in counts the rows in hand, and
179
+ counting a page here would put a number next to "failed" that describes this
180
+ page rather than the history the option narrows.
181
+ -->
182
+ <template v-if="serverMode" #filter-menu>
183
+ <DropdownMenuSub>
184
+ <DropdownMenuSubTrigger class="gap-2">
185
+ <span class="flex-1">{{ t("integration.runs.table.column.status") }}</span>
186
+ <span v-if="statusApplied" class="text-xs text-primary">{{ statusApplied }}</span>
187
+ </DropdownMenuSubTrigger>
188
+ <DropdownMenuSubContent class="w-48">
189
+ <DropdownMenuItem @select="setStatus(null)">
190
+ {{ t("integration.runs.table.filter.all") }}
191
+ </DropdownMenuItem>
192
+ <DropdownMenuSeparator />
193
+ <DropdownMenuItem v-for="word in RUN_STATUS_WORDS" :key="word" @select="setStatus(word)">
194
+ {{ runStatusLabel(word) }}
195
+ </DropdownMenuItem>
196
+ </DropdownMenuSubContent>
197
+ </DropdownMenuSub>
198
+
199
+ <DropdownMenuSub>
200
+ <DropdownMenuSubTrigger class="gap-2">
201
+ <span class="flex-1">{{ t("integration.runs.table.column.trigger") }}</span>
202
+ <span v-if="triggerApplied" class="text-xs text-primary">{{ triggerApplied }}</span>
203
+ </DropdownMenuSubTrigger>
204
+ <DropdownMenuSubContent class="w-48">
205
+ <DropdownMenuItem @select="setTrigger(null)">
206
+ {{ t("integration.runs.table.filter.all") }}
207
+ </DropdownMenuItem>
208
+ <DropdownMenuSeparator />
209
+ <DropdownMenuItem v-for="kind in TRIGGER_TYPE_WORDS" :key="kind" @select="setTrigger(kind)">
210
+ {{ triggerTypeLabel(kind) }}
211
+ </DropdownMenuItem>
212
+ </DropdownMenuSubContent>
213
+ </DropdownMenuSub>
214
+ </template>
215
+
105
216
  <template #cell-status="{ row: run }">
106
217
  <!-- `max-w-full` is the net, not the fix: a word wider than the column above is
107
218
  then clipped inside its own cell instead of being drawn over the one beside