@revenexx/studio-integrations 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +254 -0
- package/dist/module.json +1 -1
- package/dist/runtime/components/Integrations/DeadLettersPanel.vue +2 -6
- package/dist/runtime/components/Integrations/ExpressionInput.vue +33 -4
- package/dist/runtime/components/Integrations/NodeInspector.vue +40 -0
- package/dist/runtime/components/Integrations/RunActionsMenu.vue +3 -2
- package/dist/runtime/components/Integrations/RunDetailPanel.vue +9 -6
- package/dist/runtime/components/Integrations/RunsTable.vue +15 -3
- package/dist/runtime/components/Integrations/WorkflowEditor.vue +27 -1
- package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +9 -1
- package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +13 -0
- package/dist/runtime/components/Integrations/WorkflowNodeView.vue +38 -3
- package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +13 -0
- package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +8 -6
- package/dist/runtime/components/Integrations/WorkflowsTable.vue +9 -4
- package/dist/runtime/composables/useIntegrationsDeadLetters.d.ts +9 -2
- package/dist/runtime/composables/useIntegrationsDeadLetters.js +27 -18
- package/dist/runtime/composables/useIntegrationsRuns.d.ts +30 -1
- package/dist/runtime/composables/useIntegrationsRuns.js +70 -29
- package/dist/runtime/composables/useIntegrationsSchedules.d.ts +7 -15
- package/dist/runtime/composables/useIntegrationsSchedules.js +80 -35
- package/dist/runtime/composables/useIntegrationsTriggers.d.ts +8 -1
- package/dist/runtime/data/loopBody.d.ts +129 -0
- package/dist/runtime/data/loopBody.js +76 -0
- package/dist/runtime/i18n/messages/de.json +46 -33
- package/dist/runtime/i18n/messages/en.json +13 -0
- package/dist/runtime/pages/integrations/index.vue +29 -19
- package/dist/runtime/pages/integrations/runs.vue +22 -10
- package/dist/runtime/pages/integrations/schedules.vue +64 -7
- package/dist/runtime/pages/integrations/workflows/[id].vue +22 -8
- package/dist/runtime/types/integrations.d.ts +39 -0
- package/dist/runtime/utils/statusStyle.d.ts +76 -0
- package/dist/runtime/utils/statusStyle.js +27 -0
- package/package.json +1 -1
|
@@ -71,6 +71,8 @@
|
|
|
71
71
|
"integration.canvas.node.action.duplicateAria": "Duplicate node",
|
|
72
72
|
"integration.canvas.node.badge.invalidConfig": "Config incomplete — {n} setting still needs fixing: {fields} | Config incomplete — {n} settings still need fixing: {fields}",
|
|
73
73
|
"integration.canvas.node.badge.noBuiltCode": "Version {version} is not ready to use — this step cannot be tested",
|
|
74
|
+
"integration.canvas.node.badge.repeats": "Repeats — runs once per element of {iterators} | Repeats — runs once per element of each of the {n} loops it sits inside: {iterators}",
|
|
75
|
+
"integration.canvas.node.badge.loopBody": "Loop — nothing is wired to Item yet, so nothing repeats | Loop — repeats the one step wired to Item, once per element | Loop — repeats the {n} steps wired to Item, once per element",
|
|
74
76
|
"integration.canvas.node.handle.input": "Input",
|
|
75
77
|
"integration.canvas.node.meta.versionTitle": "Version {version}",
|
|
76
78
|
"integration.canvas.node.note.heading": "Note",
|
|
@@ -298,12 +300,17 @@
|
|
|
298
300
|
"integration.deadLetters.panel.title": "Missed runs",
|
|
299
301
|
"integration.deadLetters.reason.fallback": "No reason recorded.",
|
|
300
302
|
"integration.deadLetters.state.empty": "No missed runs. Every trigger firing made it into a run.",
|
|
303
|
+
"integration.deadLetters.status.discarded": "discarded",
|
|
304
|
+
"integration.deadLetters.status.pending": "waiting",
|
|
305
|
+
"integration.deadLetters.status.replayed": "replayed",
|
|
306
|
+
"integration.deadLetters.status.unknown": "unknown ({status})",
|
|
301
307
|
"integration.deadLetters.tabs.missed.label": "Missed runs",
|
|
302
308
|
"integration.deadLetters.toast.discard.done": "Discarded",
|
|
303
309
|
"integration.deadLetters.toast.discard.error": "Could not discard.",
|
|
304
310
|
"integration.deadLetters.toast.replay.done": "Replayed",
|
|
305
311
|
"integration.deadLetters.toast.replay.error": "Could not replay.",
|
|
306
312
|
"integration.deadLetters.toast.replay.started": "Replayed — started run #{id}",
|
|
313
|
+
"integration.deadLetters.workflow.fallback": "Workflow #{id}",
|
|
307
314
|
"integration.editor.action.activate.title": "Activate this workflow",
|
|
308
315
|
"integration.editor.action.activate.title.invalid": "Fix {n} node setting before activating | Fix {n} node settings before activating",
|
|
309
316
|
"integration.editor.action.backToWorkflow": "Back to Workflow",
|
|
@@ -513,6 +520,7 @@
|
|
|
513
520
|
"integration.nodeConfig.dynamicSchema.resolving": "Resolving fields…",
|
|
514
521
|
"integration.nodeConfig.expression.badge.title": "Accepts ${{ … }} expressions",
|
|
515
522
|
"integration.nodeConfig.expression.insertTitle": "Insert expression",
|
|
523
|
+
"integration.nodeConfig.expression.insertAria": "Insert expression — available data for this setting",
|
|
516
524
|
"integration.nodeConfig.expression.picker.aria": "Available data for this setting",
|
|
517
525
|
"integration.nodeConfig.expression.picker.empty": "Nothing matches “{query}”.",
|
|
518
526
|
"integration.nodeConfig.expression.picker.filterAria": "Filter available data",
|
|
@@ -622,6 +630,10 @@
|
|
|
622
630
|
"integration.nodeInspector.notice.versionNotReady.none": "Version {version} of this node is no longer usable, and no version of it is. This step cannot be tested, and the choice lists in its settings stay empty.",
|
|
623
631
|
"integration.nodeInspector.notice.versionNotReady.upgrade": "Version {version} of this node is no longer usable. To keep using this step, it has to be upgraded to version {ready}.",
|
|
624
632
|
"integration.nodeInspector.output.description": "What downstream nodes receive from this one. Declared by the node type; filled with real values once the step has run.",
|
|
633
|
+
"integration.nodeInspector.output.loopBody.heading": "The loop body",
|
|
634
|
+
"integration.nodeInspector.output.loopBody.size": "Nothing is wired to Item yet, so this loop repeats nothing. | One step is wired to Item and runs once per element. | {n} steps are wired to Item and each runs once per element.",
|
|
635
|
+
"integration.nodeInspector.output.loopBody.names": "These repeat: {names}",
|
|
636
|
+
"integration.nodeInspector.output.loopBody.joined": "One of them is wired after the loop as well, on the Done side — so it repeats with the body rather than running once: {names} | {n} of them are wired after the loop as well, on the Done side — so they repeat with the body rather than running once: {names}",
|
|
625
637
|
"integration.nodeInspector.output.pin.editAction": "Edit pinned sample",
|
|
626
638
|
"integration.nodeInspector.output.pin.editorAria": "Pinned sample output",
|
|
627
639
|
"integration.nodeInspector.output.pin.hint": "A pinned sample stands in for run data while you map downstream fields.",
|
|
@@ -948,6 +960,7 @@
|
|
|
948
960
|
"integration.schedules.cron.weekday.tuesday": "Tuesdays",
|
|
949
961
|
"integration.schedules.cron.weekday.wednesday": "Wednesdays",
|
|
950
962
|
"integration.schedules.cron.weeklyAt": "{days} at {time}",
|
|
963
|
+
"integration.schedules.error.loadFailed": "Schedules could not be loaded.",
|
|
951
964
|
"integration.schedules.head.title": "Schedules · Integration Studio",
|
|
952
965
|
"integration.schedules.header.description": "Every scheduled trigger across your workflows. Click a row to open the workflow.",
|
|
953
966
|
"integration.schedules.header.title": "Schedules",
|
|
@@ -6,38 +6,38 @@ import WorkflowCreateDialog from "../../components/Integrations/WorkflowCreateDi
|
|
|
6
6
|
import { Pulse as Activity, DangerTriangle as AlertTriangle, ArrowRight, Box as Boxes, ClockCircle as Clock, Database, Layers, AddCircle as Plus, Radio, MagicStick as Sparkles, LinkRound as Webhook, Programming as Workflow } from "#solar-icons";
|
|
7
7
|
import { Badge, Button } from "@revenexx/studio";
|
|
8
8
|
import { servedPaths } from "../../utils/servedPaths";
|
|
9
|
-
import {
|
|
9
|
+
import { RUN_STATUS_COLUMN_WIDTH, runStatusClass, runStatusLabel } from "../../utils/statusStyle";
|
|
10
|
+
const LATEST_RUN_COLUMNS = `${RUN_STATUS_COLUMN_WIDTH} minmax(0,1fr) 100px 70px`;
|
|
10
11
|
const t = useIntegrationsT();
|
|
11
12
|
useHead({ title: () => t("integration.dashboard.head.title") });
|
|
12
13
|
const { workflows, activeCount: activeWorkflows, loading: workflowsLoading, load: loadWorkflows } = useIntegrationsWorkflows();
|
|
13
|
-
const {
|
|
14
|
+
const {
|
|
15
|
+
latestPerWorkflow: latestRunPerWorkflow,
|
|
16
|
+
runningCount,
|
|
17
|
+
latestLoading: runsLoading,
|
|
18
|
+
loadSummary: loadRunSummary,
|
|
19
|
+
loadLatestPerWorkflow
|
|
20
|
+
} = useIntegrationsRuns();
|
|
14
21
|
const { startingPoints, loading: templatesLoading, load: loadTemplates, ensureDetails } = useIntegrationsTemplates();
|
|
15
22
|
watch(() => startingPoints.value.map((t2) => t2.slug).join(","), () => {
|
|
16
23
|
if (startingPoints.value.length) void ensureDetails(startingPoints.value.map((t2) => t2.slug));
|
|
17
24
|
}, { immediate: true });
|
|
18
25
|
const { activeCount: schedulesActive, load: loadSchedules } = useIntegrationsSchedules();
|
|
19
26
|
const { pendingCount: missedRuns, globalLoading: missedLoading, loadGlobal: loadMissedRuns } = useIntegrationsDeadLetters();
|
|
20
|
-
const kpisLoading = computed(() => workflowsLoading.value && !workflows.value.length || runsLoading.value && !
|
|
21
|
-
const latestLoading = computed(() => runsLoading.value && !
|
|
27
|
+
const kpisLoading = computed(() => workflowsLoading.value && !workflows.value.length || runsLoading.value && !latestRunPerWorkflow.value.length);
|
|
28
|
+
const latestLoading = computed(() => runsLoading.value && !latestRunPerWorkflow.value.length);
|
|
22
29
|
const createOpen = ref(false);
|
|
23
30
|
const NuxtLinkComp = resolveComponent("NuxtLink");
|
|
24
31
|
onMounted(() => {
|
|
25
32
|
void loadWorkflows();
|
|
26
|
-
void
|
|
33
|
+
void loadRunSummary();
|
|
34
|
+
void loadLatestPerWorkflow();
|
|
27
35
|
void loadTemplates();
|
|
28
36
|
void loadSchedules();
|
|
29
|
-
void loadMissedRuns()
|
|
30
|
-
});
|
|
31
|
-
const latestByWorkflow = computed(() => {
|
|
32
|
-
const seen = /* @__PURE__ */ new Set();
|
|
33
|
-
const out = [];
|
|
34
|
-
for (const r of runs.value) {
|
|
35
|
-
if (seen.has(r.workflowId)) continue;
|
|
36
|
-
seen.add(r.workflowId);
|
|
37
|
-
out.push(r);
|
|
38
|
-
}
|
|
39
|
-
return out;
|
|
37
|
+
void loadMissedRuns().catch(() => {
|
|
38
|
+
});
|
|
40
39
|
});
|
|
40
|
+
const latestByWorkflow = computed(() => latestRunPerWorkflow.value);
|
|
41
41
|
const latestPerWorkflow = computed(() => latestByWorkflow.value.slice(0, 10));
|
|
42
42
|
const failedRecent = computed(() => latestByWorkflow.value.filter((r) => r.status === "failed").slice(0, 3));
|
|
43
43
|
function formatRelative(iso) {
|
|
@@ -178,9 +178,18 @@ function surfaceCards() {
|
|
|
178
178
|
v-for="r in latestPerWorkflow"
|
|
179
179
|
:key="r.id"
|
|
180
180
|
:to="{ path: `/integrations/workflows/${r.workflowId}`, query: { run: r.id } }"
|
|
181
|
-
class="grid
|
|
181
|
+
class="grid items-center gap-2 border-b border-black/[0.04] px-3 py-2 text-sm last:border-b-0 hover:bg-muted/30"
|
|
182
|
+
:style="{ gridTemplateColumns: LATEST_RUN_COLUMNS }"
|
|
182
183
|
>
|
|
183
|
-
|
|
184
|
+
<!-- The runs list's own track, not a second guess: this panel was the
|
|
185
|
+
narrower of the two at 90px, so three German words ran out of it,
|
|
186
|
+
"erfolgreich" included (PO-362). `max-w-full` is the net behind it,
|
|
187
|
+
and it clips rather than ellipsing — see the runs list. -->
|
|
188
|
+
<Badge
|
|
189
|
+
variant="outline"
|
|
190
|
+
:title="runStatusLabel(r.status)"
|
|
191
|
+
:class="['border text-xs font-medium uppercase max-w-full', runStatusClass(r.status)]"
|
|
192
|
+
>
|
|
184
193
|
{{ runStatusLabel(r.status) }}
|
|
185
194
|
</Badge>
|
|
186
195
|
<div class="truncate">
|
|
@@ -198,7 +207,8 @@ function surfaceCards() {
|
|
|
198
207
|
<div
|
|
199
208
|
v-for="n in 6"
|
|
200
209
|
:key="`sk-run-${n}`"
|
|
201
|
-
class="grid
|
|
210
|
+
class="grid items-center gap-2 border-b border-black/[0.04] px-3 py-2.5 last:border-b-0"
|
|
211
|
+
:style="{ gridTemplateColumns: LATEST_RUN_COLUMNS }"
|
|
202
212
|
>
|
|
203
213
|
<div class="h-5 w-16 rounded bg-black/[0.07] animate-pulse" />
|
|
204
214
|
<div class="space-y-1.5">
|
|
@@ -4,7 +4,7 @@ import { Badge, Button } from "@revenexx/studio";
|
|
|
4
4
|
import { toast } from "vue-sonner";
|
|
5
5
|
import RunsTable from "../../components/Integrations/RunsTable.vue";
|
|
6
6
|
import { createPollBudget, SAFETY_POLL_MS } from "../../utils/pollBudget";
|
|
7
|
-
import {
|
|
7
|
+
import { deadLetterStatusClass, deadLetterStatusLabel } from "../../utils/statusStyle";
|
|
8
8
|
const t = useIntegrationsT();
|
|
9
9
|
useHead({ title: () => t("integration.runs.list.head.title") });
|
|
10
10
|
const route = useRoute();
|
|
@@ -37,7 +37,7 @@ watch([runTick, workflowTick], () => {
|
|
|
37
37
|
let pollTimer = null;
|
|
38
38
|
onMounted(() => {
|
|
39
39
|
void load(true);
|
|
40
|
-
void
|
|
40
|
+
void reloadMissedRuns();
|
|
41
41
|
pollTimer = setInterval(() => {
|
|
42
42
|
if (hasActiveRuns.value && !loading.value && budget.due(delivering.value)) void load(true);
|
|
43
43
|
}, POLL_MS);
|
|
@@ -49,31 +49,43 @@ onUnmounted(() => {
|
|
|
49
49
|
}
|
|
50
50
|
});
|
|
51
51
|
const busyId = ref(null);
|
|
52
|
+
async function reloadMissedRuns() {
|
|
53
|
+
try {
|
|
54
|
+
await loadGlobal(true);
|
|
55
|
+
} catch {
|
|
56
|
+
}
|
|
57
|
+
}
|
|
52
58
|
async function onReplay(dl) {
|
|
53
59
|
busyId.value = dl.id;
|
|
54
60
|
try {
|
|
55
61
|
const res = await replay(dl.id);
|
|
56
62
|
const newId = res && typeof res === "object" && "id" in res ? String(res.id) : null;
|
|
57
63
|
toast.success(newId ? t("integration.deadLetters.toast.replay.started", { id: newId }) : t("integration.deadLetters.toast.replay.done"));
|
|
58
|
-
await loadGlobal(true);
|
|
59
|
-
reload();
|
|
60
64
|
} catch (e) {
|
|
61
65
|
toast.error(e?.message ?? t("integration.deadLetters.toast.replay.error"));
|
|
62
|
-
} finally {
|
|
63
66
|
busyId.value = null;
|
|
67
|
+
return;
|
|
64
68
|
}
|
|
69
|
+
await reloadMissedRuns();
|
|
70
|
+
reload();
|
|
71
|
+
busyId.value = null;
|
|
65
72
|
}
|
|
66
73
|
async function onDiscard(dl) {
|
|
67
74
|
busyId.value = dl.id;
|
|
68
75
|
try {
|
|
69
76
|
await discard(dl.id);
|
|
70
77
|
toast.success(t("integration.deadLetters.toast.discard.done"));
|
|
71
|
-
await loadGlobal(true);
|
|
72
78
|
} catch (e) {
|
|
73
79
|
toast.error(e?.message ?? t("integration.deadLetters.toast.discard.error"));
|
|
74
|
-
} finally {
|
|
75
80
|
busyId.value = null;
|
|
81
|
+
return;
|
|
76
82
|
}
|
|
83
|
+
await reloadMissedRuns();
|
|
84
|
+
busyId.value = null;
|
|
85
|
+
}
|
|
86
|
+
function workflowNameOf(dl) {
|
|
87
|
+
const name = dl.workflow_name;
|
|
88
|
+
return typeof name === "string" && name.trim() !== "" ? name : t("integration.deadLetters.workflow.fallback", { id: dl.workflow_id });
|
|
77
89
|
}
|
|
78
90
|
function fmt(iso) {
|
|
79
91
|
if (!iso) return "\u2014";
|
|
@@ -104,7 +116,7 @@ function fmt(iso) {
|
|
|
104
116
|
<p class="text-sm text-muted-foreground">
|
|
105
117
|
{{ t("integration.deadLetters.list.description") }}
|
|
106
118
|
</p>
|
|
107
|
-
<Button variant="outline" size="sm" class="h-7 text-sm" :disabled="missedLoading" @click="() =>
|
|
119
|
+
<Button variant="outline" size="sm" class="h-7 text-sm" :disabled="missedLoading" @click="() => reloadMissedRuns()">
|
|
108
120
|
<Loader2 v-if="missedLoading" :size="12" class="animate-spin" />
|
|
109
121
|
{{ t("integration.common.action.refresh") }}
|
|
110
122
|
</Button>
|
|
@@ -130,10 +142,10 @@ function fmt(iso) {
|
|
|
130
142
|
:key="dl.id"
|
|
131
143
|
class="flex items-start gap-3 px-3 py-2.5"
|
|
132
144
|
>
|
|
133
|
-
<Badge variant="outline" :class="['border text-xs font-medium uppercase shrink-0 mt-0.5',
|
|
145
|
+
<Badge variant="outline" :class="['border text-xs font-medium uppercase shrink-0 mt-0.5', deadLetterStatusClass(dl.status)]">{{ deadLetterStatusLabel(dl.status) }}</Badge>
|
|
134
146
|
<div class="min-w-0 flex-1">
|
|
135
147
|
<NuxtLink :to="`/integrations/workflows/${dl.workflow_id}`" class="text-sm font-medium truncate transition-colors hover:text-primary">
|
|
136
|
-
{{ dl
|
|
148
|
+
{{ workflowNameOf(dl) }}
|
|
137
149
|
</NuxtLink>
|
|
138
150
|
<div class="text-sm break-words">{{ dl.reason || t("integration.deadLetters.reason.fallback") }}</div>
|
|
139
151
|
<div class="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground font-mono">
|
|
@@ -2,14 +2,23 @@
|
|
|
2
2
|
import { ClockCircle as Clock, Globus as Globe } from "#solar-icons";
|
|
3
3
|
import { Badge } from "@revenexx/studio";
|
|
4
4
|
import { cadenceRank, describeCron } from "../../utils/schedule";
|
|
5
|
+
import { runStatusLabel } from "../../utils/statusStyle";
|
|
5
6
|
const t = useIntegrationsT();
|
|
6
7
|
useHead({ title: () => t("integration.schedules.head.title") });
|
|
7
8
|
const router = useRouter();
|
|
8
|
-
const { entries, activeCount, loading, load } = useIntegrationsSchedules();
|
|
9
|
+
const { entries, activeCount, timezonesInUse, loading, error, load } = useIntegrationsSchedules();
|
|
9
10
|
onMounted(() => {
|
|
10
11
|
void load(true);
|
|
11
12
|
});
|
|
12
|
-
function
|
|
13
|
+
function inScheduleZone(iso, timezone) {
|
|
14
|
+
const parts = { month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" };
|
|
15
|
+
try {
|
|
16
|
+
return new Date(iso).toLocaleString(void 0, { ...parts, timeZone: timezone });
|
|
17
|
+
} catch {
|
|
18
|
+
return new Date(iso).toLocaleString(void 0, parts);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function formatRelative(iso, timezone) {
|
|
13
22
|
if (!iso) return "\u2014";
|
|
14
23
|
const ms = new Date(iso).getTime() - Date.now();
|
|
15
24
|
if (Math.abs(ms) < 6e4) return ms > 0 ? t("integration.schedules.time.inLessThanMinute") : t("integration.common.time.justNow");
|
|
@@ -21,7 +30,11 @@ function formatRelative(iso) {
|
|
|
21
30
|
const hrs = Math.round(Math.abs(ms) / 36e5);
|
|
22
31
|
return ms > 0 ? t("integration.schedules.time.inHours", hrs) : t("integration.common.time.hoursAgo", hrs);
|
|
23
32
|
}
|
|
24
|
-
return
|
|
33
|
+
return inScheduleZone(iso, timezone);
|
|
34
|
+
}
|
|
35
|
+
function lastRunText(e) {
|
|
36
|
+
if (e.lastRunAt) return formatRelative(e.lastRunAt, e.timezone);
|
|
37
|
+
return e.lastRunStatus ? runStatusLabel(e.lastRunStatus) : "\u2014";
|
|
25
38
|
}
|
|
26
39
|
const filterTabs = computed(() => [
|
|
27
40
|
{
|
|
@@ -40,7 +53,10 @@ const filterTabs = computed(() => [
|
|
|
40
53
|
key: "timezone",
|
|
41
54
|
label: t("integration.schedules.table.column.timezone"),
|
|
42
55
|
getter: (e) => e.timezone,
|
|
43
|
-
|
|
56
|
+
// The zones the platform reports as in use across the tenant, not the ones
|
|
57
|
+
// visible in the rows: with the list narrowed, deriving them from the rows
|
|
58
|
+
// would shrink the menu to what the previous choice left standing.
|
|
59
|
+
options: timezonesInUse.value.map((tz) => ({ value: tz, label: tz }))
|
|
44
60
|
}
|
|
45
61
|
]);
|
|
46
62
|
const columns = computed(() => [
|
|
@@ -76,7 +92,25 @@ const columns = computed(() => [
|
|
|
76
92
|
</template>
|
|
77
93
|
</PageHeader>
|
|
78
94
|
|
|
95
|
+
<!--
|
|
96
|
+
A refused read says so. Without this the page fell through to the table's
|
|
97
|
+
own empty text, so a 503 and a tenant with no schedules were one screen —
|
|
98
|
+
and the message it needed was already written and translated
|
|
99
|
+
(`specs/schedules.md` AC-13). An alert rather than a bare div, so it is
|
|
100
|
+
announced and not only visible.
|
|
101
|
+
-->
|
|
102
|
+
<div v-if="error" role="alert" class="m-4 rounded-md border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-sm text-rose-700">
|
|
103
|
+
{{ error }}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
79
106
|
<div class="p-4">
|
|
107
|
+
<!--
|
|
108
|
+
The empty text answers the error too, so the table cannot go on saying
|
|
109
|
+
"No schedules yet" underneath a banner explaining that there was no
|
|
110
|
+
answer to count. Two renderings of one fact rather than two facts: the
|
|
111
|
+
banner carries the platform's own words, which may be specific, and this
|
|
112
|
+
is why the list is blank.
|
|
113
|
+
-->
|
|
80
114
|
<DataTable
|
|
81
115
|
:loading="loading"
|
|
82
116
|
:data="entries"
|
|
@@ -85,7 +119,7 @@ const columns = computed(() => [
|
|
|
85
119
|
:default-sort="{ column: 'nextRun', direction: 'asc' }"
|
|
86
120
|
:search="{ placeholder: t('integration.schedules.search.placeholder') }"
|
|
87
121
|
:filter-tabs="filterTabs"
|
|
88
|
-
:empty-text="t('integration.schedules.state.empty')"
|
|
122
|
+
:empty-text="error ? t('integration.schedules.error.loadFailed') : t('integration.schedules.state.empty')"
|
|
89
123
|
@row-click="(e2) => router.push(`/integrations/workflows/${e2.workflowId}`)"
|
|
90
124
|
>
|
|
91
125
|
<template #cell-workflow="{ row: e }">
|
|
@@ -143,12 +177,35 @@ const columns = computed(() => [
|
|
|
143
177
|
semantics in the shared table (PO-233, and a gap in
|
|
144
178
|
`specs/accessibility.md`).
|
|
145
179
|
-->
|
|
180
|
+
<!--
|
|
181
|
+
The instant the platform holds, shown as a distance from now, with the
|
|
182
|
+
moment in the schedule's own zone as its title: "in 6d" answers when,
|
|
183
|
+
and "2026-08-24T08:30:00+02:00" answers which side of a clock change it
|
|
184
|
+
falls on. Neither is worked out here — a browser doing summer-time
|
|
185
|
+
arithmetic that a wrong answer makes worse than no answer is exactly
|
|
186
|
+
what this replaced.
|
|
187
|
+
-->
|
|
146
188
|
<template #cell-nextRun="{ row: e }">
|
|
147
|
-
<span
|
|
189
|
+
<span
|
|
190
|
+
:aria-label="t('integration.schedules.table.column.nextRun')"
|
|
191
|
+
:title="e.nextRunLocal ?? void 0"
|
|
192
|
+
class="text-sm tabular-nums"
|
|
193
|
+
>{{ formatRelative(e.nextRunAt, e.timezone) }}</span>
|
|
148
194
|
</template>
|
|
149
195
|
|
|
196
|
+
<!--
|
|
197
|
+
The last run carries a title too, and it is the instant rather than a
|
|
198
|
+
zone reading: the platform reports no local form for a run the way it
|
|
199
|
+
does for a firing, and inventing one here is what this page stopped
|
|
200
|
+
doing. Without it a run further back than a day was readable to the
|
|
201
|
+
minute and to no greater precision than the cell's own text.
|
|
202
|
+
-->
|
|
150
203
|
<template #cell-lastRun="{ row: e }">
|
|
151
|
-
<span
|
|
204
|
+
<span
|
|
205
|
+
:aria-label="t('integration.schedules.table.column.lastRun')"
|
|
206
|
+
:title="e.lastRunAt ?? void 0"
|
|
207
|
+
class="text-sm tabular-nums text-muted-foreground"
|
|
208
|
+
>{{ lastRunText(e) }}</span>
|
|
152
209
|
</template>
|
|
153
210
|
|
|
154
211
|
<template #cell-status="{ row: e }">
|
|
@@ -397,6 +397,9 @@ const runsError = ref(null);
|
|
|
397
397
|
const runsPage = ref(1);
|
|
398
398
|
const runsTotal = ref(0);
|
|
399
399
|
const runsLastPage = ref(1);
|
|
400
|
+
const runsWanted = ref(1);
|
|
401
|
+
let runsSeq = 0;
|
|
402
|
+
let runsLoadingSeq = 0;
|
|
400
403
|
const RUNS_POLL_MS = 3e3;
|
|
401
404
|
const { delivering: realtimeDelivering, runTick, lastRunEvent, buildTick, lastBuildEvent } = useIntegrationsRealtime();
|
|
402
405
|
const runsBudget = createPollBudget({ intervalMs: RUNS_POLL_MS, liveIntervalMs: SAFETY_POLL_MS });
|
|
@@ -410,12 +413,14 @@ function stopRunsPoll() {
|
|
|
410
413
|
function startRunsPoll() {
|
|
411
414
|
stopRunsPoll();
|
|
412
415
|
runsPoll = setInterval(() => {
|
|
413
|
-
if (
|
|
416
|
+
if (!runsLoading.value && runsWanted.value === 1 && runsBudget.due(realtimeDelivering.value)) {
|
|
417
|
+
void loadRuns(1, { silent: true });
|
|
418
|
+
}
|
|
414
419
|
}, RUNS_POLL_MS);
|
|
415
420
|
}
|
|
416
421
|
onUnmounted(stopRunsPoll);
|
|
417
422
|
watch(runTick, () => {
|
|
418
|
-
if (tab.value !== "runs" ||
|
|
423
|
+
if (tab.value !== "runs" || runsWanted.value !== 1 || runsLoading.value) return;
|
|
419
424
|
const eventWorkflow = workflowIdOf(lastRunEvent.value);
|
|
420
425
|
if (eventWorkflow && eventWorkflow !== String(workflow.value?.id ?? "")) return;
|
|
421
426
|
void loadRuns(1, { silent: true });
|
|
@@ -426,10 +431,15 @@ watch(buildTick, () => {
|
|
|
426
431
|
if (eventWorkflow && eventWorkflow !== String(workflow.value.id)) return;
|
|
427
432
|
void get(workflow.value.id);
|
|
428
433
|
});
|
|
429
|
-
async function loadRuns(page =
|
|
434
|
+
async function loadRuns(page = runsWanted.value, opts = {}) {
|
|
430
435
|
if (!workflow.value) return;
|
|
436
|
+
runsWanted.value = page;
|
|
437
|
+
const seq = ++runsSeq;
|
|
431
438
|
runsBudget.spend();
|
|
432
|
-
if (!opts.silent)
|
|
439
|
+
if (!opts.silent) {
|
|
440
|
+
runsLoading.value = true;
|
|
441
|
+
runsLoadingSeq = seq;
|
|
442
|
+
}
|
|
433
443
|
runsError.value = null;
|
|
434
444
|
try {
|
|
435
445
|
const res = await loadForWorkflow(workflow.value.id, workflow.value.name, {
|
|
@@ -437,19 +447,23 @@ async function loadRuns(page = runsPage.value, opts = {}) {
|
|
|
437
447
|
page,
|
|
438
448
|
perPage: RUNS_PER_PAGE
|
|
439
449
|
});
|
|
450
|
+
if (seq !== runsSeq) return;
|
|
440
451
|
wfRuns.value = res.runs;
|
|
441
452
|
runsTotal.value = res.total;
|
|
442
453
|
runsLastPage.value = res.lastPage;
|
|
443
454
|
runsPage.value = res.page;
|
|
455
|
+
runsWanted.value = res.page;
|
|
444
456
|
} catch (e) {
|
|
457
|
+
if (seq !== runsSeq) return;
|
|
445
458
|
runsError.value = e.message || t("integration.editor.runs.error.load");
|
|
459
|
+
runsWanted.value = runsPage.value;
|
|
446
460
|
} finally {
|
|
447
|
-
if (!opts.silent) runsLoading.value = false;
|
|
461
|
+
if (!opts.silent && seq === runsLoadingSeq) runsLoading.value = false;
|
|
448
462
|
}
|
|
449
463
|
}
|
|
450
464
|
function goRunsPage(p) {
|
|
451
465
|
const next = Math.min(Math.max(1, p), runsLastPage.value);
|
|
452
|
-
if (next !==
|
|
466
|
+
if (next !== runsWanted.value) void loadRuns(next);
|
|
453
467
|
}
|
|
454
468
|
watch(tab, (t2) => {
|
|
455
469
|
if (t2 === "runs") {
|
|
@@ -912,10 +926,10 @@ function formatDateTime(iso) {
|
|
|
912
926
|
<div v-if="runsLastPage > 1 || runsTotal > wfRuns.length" class="flex items-center justify-between text-sm text-muted-foreground">
|
|
913
927
|
<span class="tabular-nums">{{ t("integration.editor.runs.pagination.page", { page: runsPage, lastPage: runsLastPage }) }} · {{ t("integration.editor.runs.pagination.count", runsTotal) }}</span>
|
|
914
928
|
<div class="flex gap-2">
|
|
915
|
-
<Button variant="outline" size="sm" class="h-7 text-sm" :disabled="
|
|
929
|
+
<Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsWanted <= 1 || runsLoading" @click="goRunsPage(runsWanted - 1)">
|
|
916
930
|
{{ t("integration.editor.runs.pagination.newer") }}
|
|
917
931
|
</Button>
|
|
918
|
-
<Button variant="outline" size="sm" class="h-7 text-sm" :disabled="
|
|
932
|
+
<Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsWanted >= runsLastPage || runsLoading" @click="goRunsPage(runsWanted + 1)">
|
|
919
933
|
{{ t("integration.editor.runs.pagination.older") }}
|
|
920
934
|
</Button>
|
|
921
935
|
</div>
|
|
@@ -339,6 +339,18 @@ export interface NodeManifest {
|
|
|
339
339
|
/** Empty marks a terminal node (PO-201): the run ends there, so the canvas
|
|
340
340
|
* draws no output handle and no edge may leave it. */
|
|
341
341
|
outputs: NodeManifestOutput[];
|
|
342
|
+
/**
|
|
343
|
+
* When true, the node runs whatever is wired to its `item` output once per
|
|
344
|
+
* element, and its `done` output once afterwards — so the canvas marks that
|
|
345
|
+
* part as repeating (PO-277, `specs/editor-loop-body.md`).
|
|
346
|
+
*
|
|
347
|
+
* **Nothing publishes it yet** (later phase; harmless now, like
|
|
348
|
+
* `resolveOutputs` above). The worker derives the same fact at bundle-build
|
|
349
|
+
* time by duck-typing the implementation, and no field of `GET /nodes` carries
|
|
350
|
+
* it, so until one does the editor falls back to the published iterator's slug
|
|
351
|
+
* — `ITERATOR_SLUGS` in `data/loopBody.ts`, which this field is here to retire.
|
|
352
|
+
*/
|
|
353
|
+
iteration?: boolean;
|
|
342
354
|
config?: ConfigField[];
|
|
343
355
|
/** Source package (editor-side enrichment from GET /nodes; not manifest data). */
|
|
344
356
|
__package?: {
|
|
@@ -453,6 +465,12 @@ export interface WebhookEntry {
|
|
|
453
465
|
lastFiredAt: string | null;
|
|
454
466
|
callCount24h: number;
|
|
455
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* Why a schedule holds no next firing. Mirrors the conditions under which the
|
|
470
|
+
* platform's scheduler would never select it, so a paused schedule and one whose
|
|
471
|
+
* expression nobody can read stop reading the same way.
|
|
472
|
+
*/
|
|
473
|
+
export type ScheduleNotFiringReason = 'trigger_paused' | 'workflow_inactive' | 'workflow_not_ready' | 'no_cron' | 'invalid_cron' | 'unknown_timezone' | 'unsatisfiable';
|
|
456
474
|
export interface ScheduleEntry {
|
|
457
475
|
id: string;
|
|
458
476
|
workflowId: number;
|
|
@@ -462,8 +480,29 @@ export interface ScheduleEntry {
|
|
|
462
480
|
cron: string | null;
|
|
463
481
|
timezone: string;
|
|
464
482
|
active: boolean;
|
|
483
|
+
/** Whether the workflow the schedule belongs to is active. */
|
|
484
|
+
workflowActive: boolean;
|
|
485
|
+
/**
|
|
486
|
+
* The next firing the platform holds, as an instant, or `null` when it holds
|
|
487
|
+
* none. Not computed here: a browser working it out from the expression has to
|
|
488
|
+
* do summer-time arithmetic that a wrong answer makes worse than no answer.
|
|
489
|
+
*/
|
|
465
490
|
nextRunAt: string | null;
|
|
491
|
+
/** The same moment in the schedule's own zone, offset included. */
|
|
492
|
+
nextRunLocal: string | null;
|
|
493
|
+
/** When the schedule last actually ran — `null` when it never has. */
|
|
466
494
|
lastRunAt: string | null;
|
|
495
|
+
/** How that run ended. */
|
|
496
|
+
lastRunStatus: string | null;
|
|
497
|
+
/**
|
|
498
|
+
* When the scheduler last DECIDED to fire this schedule. Stamped before the
|
|
499
|
+
* run exists, so it can differ from `lastRunAt` — a firing that was dispatched
|
|
500
|
+
* and then lost.
|
|
501
|
+
*/
|
|
502
|
+
lastDispatchAt: string | null;
|
|
503
|
+
/** False whenever the platform would not fire this schedule. */
|
|
504
|
+
holdsFirings: boolean;
|
|
505
|
+
notFiringReason: ScheduleNotFiringReason | null;
|
|
467
506
|
}
|
|
468
507
|
export type TemplateCategory = 'sync' | 'notification' | 'enrichment' | 'fulfilment' | 'reporting' | 'finance' | 'commerce' | 'sales';
|
|
469
508
|
export interface WorkflowTemplate {
|
|
@@ -23,6 +23,30 @@ export declare function toneClass(tone: StatusTone): string;
|
|
|
23
23
|
* order. It used to keep its own copy of the list, so a sixth word added here
|
|
24
24
|
* would have been labelled everywhere and silently missing from that menu. */
|
|
25
25
|
export declare const RUN_STATUS_WORDS: readonly ["running", "succeeded", "failed", "cancelled", "queued"];
|
|
26
|
+
/**
|
|
27
|
+
* How wide a list draws its Status column, as a grid track.
|
|
28
|
+
*
|
|
29
|
+
* A fixed width, and it has to be one: the shared table gives the header row, every
|
|
30
|
+
* body row and every skeleton row a grid of their own, so a content-sized track
|
|
31
|
+
* (`max-content`, `auto`, `fit-content`) is resolved per row and a row holding a
|
|
32
|
+
* longer word would step out of line with the header and with the rows around it.
|
|
33
|
+
* The `id` column below says the same thing, and this constant was `minmax(136px,
|
|
34
|
+
* max-content)` for one commit because that warning was read too late.
|
|
35
|
+
*
|
|
36
|
+
* So the number is the promise, and it is a floor rather than a measurement. The
|
|
37
|
+
* column stood at 100px, which fitted every English word with room to spare and let
|
|
38
|
+
* the German "fehlgeschlagen" — 126px as a rendered badge on the machine this was
|
|
39
|
+
* measured on — run out of its cell and under the workflow name beside it (PO-362).
|
|
40
|
+
* 160px is that word plus a quarter, because the rendered width is not ours to know:
|
|
41
|
+
* a font that failed to load, a browser zoom or a reader's own minimum font size all
|
|
42
|
+
* move it, and one of those is why a first attempt at 136 was still reported cut.
|
|
43
|
+
*
|
|
44
|
+
* What happens when it is still not enough, and why that is survivable: the badge
|
|
45
|
+
* carries `max-w-full`, so it is clipped inside its own cell rather than drawn over
|
|
46
|
+
* the column beside it, and the whole word stays readable in its `title`. Raising
|
|
47
|
+
* this number is the fix at that point — not making the track elastic.
|
|
48
|
+
*/
|
|
49
|
+
export declare const RUN_STATUS_COLUMN_WIDTH = "160px";
|
|
26
50
|
/**
|
|
27
51
|
* Raw API/Temporal run vocabulary → the cockpit's run-status labels, so a run
|
|
28
52
|
* never reads "completed" in one surface and "succeeded" in another. This IS
|
|
@@ -42,4 +66,56 @@ export declare const RUN_STATUS_WORDS: readonly ["running", "succeeded", "failed
|
|
|
42
66
|
* never disagree about which status they mean.
|
|
43
67
|
*/
|
|
44
68
|
export declare function normalisedRunStatus(status: string | null | undefined): string;
|
|
69
|
+
/**
|
|
70
|
+
* The classes a run's status badge is drawn with — the colour half of
|
|
71
|
+
* {@link runStatusLabel}, and it reads the same word.
|
|
72
|
+
*
|
|
73
|
+
* `statusClass` on its own is handed whatever the platform wrote, and the map it
|
|
74
|
+
* looks in knows the studio's vocabulary rather than Temporal's: `terminated` and
|
|
75
|
+
* `timed_out` are in no branch of it, so a run the operator terminated was drawn
|
|
76
|
+
* neutral — the colour a *cancelled* run wears — while its badge said "failed".
|
|
77
|
+
* Two runs reading the same, in two colours, because the word went through
|
|
78
|
+
* {@link normalisedRunStatus} and the colour did not.
|
|
79
|
+
*
|
|
80
|
+
* So this normalises first. Every surface drawing a run's state calls it rather
|
|
81
|
+
* than `statusClass`, which stays what it is: one map from a status *word* to a
|
|
82
|
+
* tone, for the many vocabularies across the cockpit that already speak it.
|
|
83
|
+
*/
|
|
84
|
+
export declare function runStatusClass(status: string | null | undefined): string;
|
|
45
85
|
export declare function runStatusLabel(status: string | null | undefined): string;
|
|
86
|
+
/**
|
|
87
|
+
* A missed run's state, as a word written for the operator.
|
|
88
|
+
*
|
|
89
|
+
* The counterpart to {@link runStatusLabel} for the entry a trigger firing leaves
|
|
90
|
+
* when its workflow could not run. A state the catalogue does not know is named as
|
|
91
|
+
* unknown and carries the platform's own value inside that frame, so an operator
|
|
92
|
+
* can still quote what arrived without the studio claiming the word as one of its
|
|
93
|
+
* own. Nothing in, nothing out — the same answer `runStatusLabel` gives, for a
|
|
94
|
+
* column that is never actually empty.
|
|
95
|
+
*
|
|
96
|
+
* **The match is exact, unlike `runStatusLabel`'s**, and that is the point rather
|
|
97
|
+
* than an omission. Everything else that reads this field compares it strictly —
|
|
98
|
+
* whether an entry offers replay and discard, and whether it counts towards the
|
|
99
|
+
* firings still waiting — so a value differing by case or a stray space is one the
|
|
100
|
+
* studio does not act on. Saying "waiting" for it would promise an action that is
|
|
101
|
+
* not offered; saying it is unknown is what the rest of the studio already
|
|
102
|
+
* believes.
|
|
103
|
+
*
|
|
104
|
+
* The frame carries the value with its surrounding whitespace off and nothing else
|
|
105
|
+
* touched. Not verbatim, twice over, and neither is worth chasing: the badge is
|
|
106
|
+
* HTML, which collapses whitespace, and it draws its text uppercased — so an
|
|
107
|
+
* operator reads the letters that arrived rather than the casing, exactly as they
|
|
108
|
+
* do for the three states the studio does have words for.
|
|
109
|
+
*/
|
|
110
|
+
export declare function deadLetterStatusLabel(status: string | null | undefined): string;
|
|
111
|
+
/**
|
|
112
|
+
* The classes a missed run's state badge is drawn with, on every surface that lists
|
|
113
|
+
* one.
|
|
114
|
+
*
|
|
115
|
+
* The colour half of {@link deadLetterStatusLabel}, reading the same map by the same
|
|
116
|
+
* exact match, so the two cannot disagree about which states the studio knows: a
|
|
117
|
+
* state named as unknown is drawn as unknown. Tones come from the shared palette,
|
|
118
|
+
* so this is one more vocabulary mapped into the studio's colours rather than a
|
|
119
|
+
* second set of colours.
|
|
120
|
+
*/
|
|
121
|
+
export declare function deadLetterStatusClass(status: string | null | undefined): string;
|
|
@@ -77,6 +77,7 @@ export function toneClass(tone) {
|
|
|
77
77
|
return TONE_CLASS[tone];
|
|
78
78
|
}
|
|
79
79
|
export const RUN_STATUS_WORDS = ["running", "succeeded", "failed", "cancelled", "queued"];
|
|
80
|
+
export const RUN_STATUS_COLUMN_WIDTH = "160px";
|
|
80
81
|
const RUN_STATUS_WORD_SET = new Set(RUN_STATUS_WORDS);
|
|
81
82
|
export function normalisedRunStatus(status) {
|
|
82
83
|
if (!status) return "";
|
|
@@ -89,13 +90,39 @@ export function normalisedRunStatus(status) {
|
|
|
89
90
|
case "terminated":
|
|
90
91
|
case "timed_out":
|
|
91
92
|
return "failed";
|
|
93
|
+
// Temporal spells a cancellation the American way and the studio does not, so
|
|
94
|
+
// without this the *word* half of the promise fails on the surfaces that read
|
|
95
|
+
// the platform's answer directly: the row said `cancelled`, the record
|
|
96
|
+
// `canceled`, and in German the record said it in English.
|
|
97
|
+
case "canceled":
|
|
98
|
+
return "cancelled";
|
|
92
99
|
default:
|
|
93
100
|
return s;
|
|
94
101
|
}
|
|
95
102
|
}
|
|
103
|
+
export function runStatusClass(status) {
|
|
104
|
+
return statusClass(normalisedRunStatus(status));
|
|
105
|
+
}
|
|
96
106
|
export function runStatusLabel(status) {
|
|
97
107
|
const normalized = normalisedRunStatus(status);
|
|
98
108
|
if (!normalized) return "";
|
|
99
109
|
if (!RUN_STATUS_WORD_SET.has(normalized)) return normalized;
|
|
100
110
|
return useIntegrationsT()(`integration.runs.status.${normalized}`);
|
|
101
111
|
}
|
|
112
|
+
const MISSED_RUN_STATUS_TONE = /* @__PURE__ */ new Map([
|
|
113
|
+
["pending", "warning"],
|
|
114
|
+
["replayed", "success"],
|
|
115
|
+
["discarded", "neutral"]
|
|
116
|
+
]);
|
|
117
|
+
export function deadLetterStatusLabel(status) {
|
|
118
|
+
if (!status) return "";
|
|
119
|
+
const raw = String(status);
|
|
120
|
+
if (!MISSED_RUN_STATUS_TONE.has(raw)) {
|
|
121
|
+
return useIntegrationsT()("integration.deadLetters.status.unknown", { status: raw.trim() || raw });
|
|
122
|
+
}
|
|
123
|
+
return useIntegrationsT()(`integration.deadLetters.status.${raw}`);
|
|
124
|
+
}
|
|
125
|
+
export function deadLetterStatusClass(status) {
|
|
126
|
+
const tone = status ? MISSED_RUN_STATUS_TONE.get(String(status)) : void 0;
|
|
127
|
+
return toneClass(tone ?? "neutral");
|
|
128
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@revenexx/studio-integrations",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Integration Studio (core: workflows, node editor, runs, schedules, credentials, secrets, audit) as a standalone, publishable Nuxt module.",
|
|
6
6
|
"repository": {
|