@revenexx/studio-integrations 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +201 -0
- package/README.md +17 -4
- package/dist/module.json +1 -1
- package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +15 -14
- package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +91 -19
- package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +58 -21
- package/dist/runtime/components/Integrations/CredentialTypePicker.vue +4 -8
- package/dist/runtime/components/Integrations/EventTypeCombobox.d.vue.ts +1 -0
- package/dist/runtime/components/Integrations/EventTypeCombobox.vue +4 -2
- package/dist/runtime/components/Integrations/EventTypeCombobox.vue.d.ts +1 -0
- package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
- package/dist/runtime/components/Integrations/ExpressionInput.vue +54 -25
- package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
- package/dist/runtime/components/Integrations/GroupFrameView.vue +12 -12
- package/dist/runtime/components/Integrations/Layout.vue +21 -2
- package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +10 -11
- package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +2 -5
- package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +2 -0
- package/dist/runtime/components/Integrations/NodeConfigField.vue +160 -67
- package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +2 -0
- package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +6 -0
- package/dist/runtime/components/Integrations/NodeInspector.vue +1075 -303
- package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +6 -0
- package/dist/runtime/components/Integrations/NodeLogo.d.vue.ts +12 -0
- package/dist/runtime/components/Integrations/NodeLogo.vue +2 -30
- package/dist/runtime/components/Integrations/NodeLogo.vue.d.ts +12 -0
- package/dist/runtime/components/Integrations/NodeMark.d.vue.ts +15 -0
- package/dist/runtime/components/Integrations/NodeMark.vue +79 -0
- package/dist/runtime/components/Integrations/NodeMark.vue.d.ts +15 -0
- package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +21 -0
- package/dist/runtime/components/Integrations/NodeOutputSchema.vue +42 -0
- package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +21 -0
- package/dist/runtime/components/Integrations/NodePalette.vue +100 -34
- package/dist/runtime/components/Integrations/QueryBuilder.vue +8 -1
- package/dist/runtime/components/Integrations/RunActionsMenu.vue +3 -1
- package/dist/runtime/components/Integrations/RunPayloadInput.vue +17 -4
- package/dist/runtime/components/Integrations/StickyNoteView.vue +2 -1
- package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +18 -0
- package/dist/runtime/components/Integrations/TemplateCard.vue +109 -0
- package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +18 -0
- package/dist/runtime/components/Integrations/TemplateFlowPreview.d.vue.ts +2 -2
- package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +7 -10
- package/dist/runtime/components/Integrations/TemplateFlowPreview.vue.d.ts +2 -2
- package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +21 -37
- package/dist/runtime/components/Integrations/WorkflowEdgeView.d.vue.ts +18 -4
- package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +23 -7
- package/dist/runtime/components/Integrations/WorkflowEdgeView.vue.d.ts +18 -4
- package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +20 -1
- package/dist/runtime/components/Integrations/WorkflowEditor.vue +167 -46
- package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +20 -1
- package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +4 -20
- package/dist/runtime/components/Integrations/WorkflowIconPicker.d.vue.ts +19 -1
- package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +11 -2
- package/dist/runtime/components/Integrations/WorkflowIconPicker.vue.d.ts +19 -1
- package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +4 -0
- package/dist/runtime/components/Integrations/WorkflowNodeView.vue +189 -46
- package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +4 -0
- package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +4 -20
- package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +15 -9
- package/dist/runtime/components/Integrations/WorkflowTagsInput.d.vue.ts +1 -0
- package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +7 -2
- package/dist/runtime/components/Integrations/WorkflowTagsInput.vue.d.ts +1 -0
- package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +4 -2
- package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +30 -17
- package/dist/runtime/components/Integrations/WorkflowsTable.vue +47 -7
- package/dist/runtime/composables/useCredentialTypes.js +4 -4
- package/dist/runtime/composables/useIntegrationsAudit.js +4 -3
- package/dist/runtime/composables/useIntegrationsCredentials.js +4 -4
- package/dist/runtime/composables/useIntegrationsDeadLetters.js +7 -6
- package/dist/runtime/composables/useIntegrationsNodes.js +4 -4
- package/dist/runtime/composables/useIntegrationsRuns.js +11 -6
- package/dist/runtime/composables/useIntegrationsSchedules.d.ts +4 -0
- package/dist/runtime/composables/useIntegrationsSchedules.js +10 -3
- package/dist/runtime/composables/useIntegrationsSecrets.js +10 -7
- package/dist/runtime/composables/useIntegrationsTemplates.d.ts +1 -1
- package/dist/runtime/composables/useIntegrationsTemplates.js +12 -8
- package/dist/runtime/composables/useIntegrationsTriggers.js +6 -6
- package/dist/runtime/composables/useIntegrationsWorkflows.js +8 -4
- package/dist/runtime/composables/useLoadGate.d.ts +17 -0
- package/dist/runtime/composables/useLoadGate.js +8 -0
- package/dist/runtime/data/branchReorder.d.ts +37 -0
- package/dist/runtime/data/branchReorder.js +71 -0
- package/dist/runtime/data/handleId.d.ts +23 -0
- package/dist/runtime/data/handleId.js +6 -0
- package/dist/runtime/data/integrationTemplates.d.ts +39 -0
- package/dist/runtime/data/integrationTemplates.js +15 -0
- package/dist/runtime/data/integrationWorkflowSamples.js +1 -1
- package/dist/runtime/data/nodeIcons.d.ts +2 -0
- package/dist/runtime/data/nodeIcons.js +73 -0
- package/dist/runtime/data/nodeInputData.d.ts +16 -4
- package/dist/runtime/data/nodeInputData.js +4 -0
- package/dist/runtime/data/payloadForm.d.ts +9 -2
- package/dist/runtime/data/payloadForm.js +27 -3
- package/dist/runtime/data/portTone.d.ts +17 -0
- package/dist/runtime/data/portTone.js +7 -0
- package/dist/runtime/data/webhookAuth.d.ts +18 -0
- package/dist/runtime/data/webhookAuth.js +19 -0
- package/dist/runtime/data/workflowIconCatalog.js +2 -5
- package/dist/runtime/pages/integrations/audit.vue +91 -6
- package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +6 -4
- package/dist/runtime/pages/integrations/credentials.vue +119 -21
- package/dist/runtime/pages/integrations/index.vue +49 -44
- package/dist/runtime/pages/integrations/runs.vue +1 -1
- package/dist/runtime/pages/integrations/schedules.vue +51 -7
- package/dist/runtime/pages/integrations/secrets.vue +29 -7
- package/dist/runtime/pages/integrations/templates/[slug].vue +52 -30
- package/dist/runtime/pages/integrations/templates/index.vue +57 -86
- package/dist/runtime/pages/integrations/workflows/[id].vue +196 -8
- package/dist/runtime/pages/integrations/workflows/index.vue +15 -2
- package/dist/runtime/types/integrations.d.ts +65 -6
- package/dist/runtime/utils/apiError.d.ts +47 -0
- package/dist/runtime/utils/apiError.js +32 -0
- package/dist/runtime/utils/cockpitSources.d.ts +153 -0
- package/dist/runtime/utils/cockpitSources.js +14 -0
- package/dist/runtime/utils/loadGate.d.ts +61 -0
- package/dist/runtime/utils/loadGate.js +22 -0
- package/dist/runtime/utils/localize.d.ts +29 -0
- package/dist/runtime/utils/localize.js +4 -0
- package/dist/runtime/utils/notify.d.ts +59 -0
- package/dist/runtime/utils/notify.js +21 -0
- package/dist/runtime/utils/revenexxCredentialSource.d.ts +26 -0
- package/dist/runtime/utils/revenexxCredentialSource.js +8 -0
- package/dist/runtime/utils/schedule.d.ts +141 -0
- package/dist/runtime/utils/schedule.js +207 -0
- package/dist/runtime/utils/validationFindings.d.ts +101 -0
- package/dist/runtime/utils/validationFindings.js +53 -0
- package/dist/runtime/utils/whenIdle.d.ts +42 -0
- package/dist/runtime/utils/whenIdle.js +12 -0
- package/package.json +8 -2
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A schedule, in both directions: the structured rhythm the studio lets a user
|
|
3
|
+
* pick, the five-field cron expression the platform runs on, and the sentence
|
|
4
|
+
* that says it in words.
|
|
5
|
+
*
|
|
6
|
+
* The studio no longer asks anyone to write cron. It asks for a rhythm — every
|
|
7
|
+
* few minutes, daily at a time, on these weekdays — and `toCron` turns that into
|
|
8
|
+
* the expression the API stores. `parseCron` is the way back, so a schedule that
|
|
9
|
+
* arrives from anywhere (a template, the MCP tools, a hand-written expression from
|
|
10
|
+
* before this existed) opens in the same controls if it can, and stays an
|
|
11
|
+
* expression if it cannot.
|
|
12
|
+
*
|
|
13
|
+
* **The sentence is total over what the picker can build.** `describeRecurrence`
|
|
14
|
+
* always answers; the refusal to guess lives in `parseCron`, which returns `null`
|
|
15
|
+
* for everything outside the shapes below — several stepped fields, minute and
|
|
16
|
+
* hour lists, `L`/`#` syntax, month restrictions, seconds precision. That is what
|
|
17
|
+
* lets the studio show the sentence instead of the expression: for anything it
|
|
18
|
+
* authored there is always a sentence, and where there is none the expression is
|
|
19
|
+
* shown, because nothing else would be true.
|
|
20
|
+
*
|
|
21
|
+
* **Nothing here converts a time.** The platform evaluates the expression in the
|
|
22
|
+
* trigger's own timezone (`CronExpression->isDue($now->setTimezone($tz))`), so the
|
|
23
|
+
* numbers in it are wall-clock time in that zone. The zone is therefore half of
|
|
24
|
+
* the schedule and the caller's to show — it is not a formatting detail.
|
|
25
|
+
*/
|
|
26
|
+
/** A rhythm the picker can build, and `describeRecurrence` can always say. */
|
|
27
|
+
export type Recurrence =
|
|
28
|
+
/** `interval` minutes apart, all day, every day. `1` means every minute. */
|
|
29
|
+
{
|
|
30
|
+
kind: 'minutes';
|
|
31
|
+
interval: number;
|
|
32
|
+
}
|
|
33
|
+
/** Once an hour, at `minute` past. */
|
|
34
|
+
| {
|
|
35
|
+
kind: 'hourly';
|
|
36
|
+
minute: number;
|
|
37
|
+
}
|
|
38
|
+
/** Every `interval` hours (2 or more), at `minute` past. */
|
|
39
|
+
| {
|
|
40
|
+
kind: 'hours';
|
|
41
|
+
interval: number;
|
|
42
|
+
minute: number;
|
|
43
|
+
}
|
|
44
|
+
/** Once a day, at a fixed time. */
|
|
45
|
+
| {
|
|
46
|
+
kind: 'daily';
|
|
47
|
+
hour: number;
|
|
48
|
+
minute: number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Several times a day, at the same minute past each of `hours` — two or more —
|
|
52
|
+
* and on `days`, or every day when that is `null`.
|
|
53
|
+
*
|
|
54
|
+
* A list of hours and a run of them are the same rhythm written two ways
|
|
55
|
+
* (`9,10,11` fires exactly when `9-11` does), so both read back into this one
|
|
56
|
+
* form and `toCron` picks the canonical spelling. Times with *different*
|
|
57
|
+
* minutes are not one rhythm and cron cannot express them in one expression:
|
|
58
|
+
* 07:30 and 09:15 is two schedules.
|
|
59
|
+
*/
|
|
60
|
+
| {
|
|
61
|
+
kind: 'times';
|
|
62
|
+
minute: number;
|
|
63
|
+
hours: number[];
|
|
64
|
+
days: number[] | null;
|
|
65
|
+
}
|
|
66
|
+
/** On the given weekdays (0 = Sunday), at a fixed time. Never empty. */
|
|
67
|
+
| {
|
|
68
|
+
kind: 'weekly';
|
|
69
|
+
days: number[];
|
|
70
|
+
hour: number;
|
|
71
|
+
minute: number;
|
|
72
|
+
}
|
|
73
|
+
/** On one day of the month, at a fixed time. */
|
|
74
|
+
| {
|
|
75
|
+
kind: 'monthly';
|
|
76
|
+
day: number;
|
|
77
|
+
hour: number;
|
|
78
|
+
minute: number;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* The zone a schedule without one of its own runs in: the platform's default,
|
|
82
|
+
* which is UTC (`config('app.timezone')`, which nothing overrides).
|
|
83
|
+
*
|
|
84
|
+
* Not the zone the studio *writes*. A schedule set up here carries
|
|
85
|
+
* `Europe/Berlin` from the trigger's own default config, and this is only what a
|
|
86
|
+
* schedule that arrived without a zone actually runs in.
|
|
87
|
+
*/
|
|
88
|
+
export declare const PLATFORM_TIMEZONE = "UTC";
|
|
89
|
+
/**
|
|
90
|
+
* The zone a schedule's numbers are read in — its own where it has one, the
|
|
91
|
+
* platform's default where it does not.
|
|
92
|
+
*
|
|
93
|
+
* One function because two surfaces show it: the trigger settings under the
|
|
94
|
+
* controls, and the Schedules page in its own column. A surface that answered this
|
|
95
|
+
* for itself is how one schedule ends up with two zones, and a zone is half of
|
|
96
|
+
* what a time means.
|
|
97
|
+
*/
|
|
98
|
+
export declare function scheduleTimezone(timezone: unknown): string;
|
|
99
|
+
/**
|
|
100
|
+
* The rhythm an expression describes, or `null` when it is not one of them.
|
|
101
|
+
*
|
|
102
|
+
* @param cron A five-field expression: minute hour day-of-month month day-of-week.
|
|
103
|
+
*/
|
|
104
|
+
export declare function parseCron(cron: string | null | undefined): Recurrence | null;
|
|
105
|
+
/**
|
|
106
|
+
* The five-field expression for a rhythm. Always valid, always five fields, and
|
|
107
|
+
* **never both a day of the month and a weekday** — cron reads that pair as
|
|
108
|
+
* "either", so a picker that produced it would mean something other than what it
|
|
109
|
+
* showed.
|
|
110
|
+
*
|
|
111
|
+
* Three rhythms are written in their canonical form rather than literally, so one
|
|
112
|
+
* rhythm has one expression: an hourly interval of 1 is `hourly`, a weekly
|
|
113
|
+
* schedule on all seven days is `daily`, and every day of the week under `times`
|
|
114
|
+
* is written as an open field. Reading any of them back therefore gives the
|
|
115
|
+
* canonical form, not the one passed in.
|
|
116
|
+
*/
|
|
117
|
+
export declare function toCron(r: Recurrence): string;
|
|
118
|
+
/**
|
|
119
|
+
* A sentence for a rhythm. Total: every `Recurrence` has one, which is what lets
|
|
120
|
+
* a surface show the sentence in place of the expression.
|
|
121
|
+
*/
|
|
122
|
+
export declare function describeRecurrence(r: Recurrence): string;
|
|
123
|
+
/**
|
|
124
|
+
* A sentence for the expression, or `null` when it cannot be said plainly.
|
|
125
|
+
*
|
|
126
|
+
* Nothing at all is the point where it happens. A wrong aid is worse than none,
|
|
127
|
+
* because a schedule that reads "Daily at 03:00" and fires hourly is a fault
|
|
128
|
+
* nobody goes looking for — so the shapes this cannot read produce no sentence,
|
|
129
|
+
* and the surface shows the expression instead.
|
|
130
|
+
*
|
|
131
|
+
* Times are read as written: the expression's own numbers, in the schedule's own
|
|
132
|
+
* timezone. Nothing here converts anything, which is why the timezone is the
|
|
133
|
+
* caller's to show.
|
|
134
|
+
*/
|
|
135
|
+
export declare function describeCron(cron: string | null | undefined): string | null;
|
|
136
|
+
/**
|
|
137
|
+
* How long a rhythm waits between firings, in minutes — the order a "when does
|
|
138
|
+
* this run" column sorts in. An expression that cannot be read sorts last, since
|
|
139
|
+
* the alternative is ordering rows by a string the reader cannot see.
|
|
140
|
+
*/
|
|
141
|
+
export declare function cadenceRank(cron: string | null | undefined): number;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
const DAY_NAMES = ["Sundays", "Mondays", "Tuesdays", "Wednesdays", "Thursdays", "Fridays", "Saturdays"];
|
|
2
|
+
export const PLATFORM_TIMEZONE = "UTC";
|
|
3
|
+
export function scheduleTimezone(timezone) {
|
|
4
|
+
return typeof timezone === "string" && timezone.trim() !== "" ? timezone.trim() : PLATFORM_TIMEZONE;
|
|
5
|
+
}
|
|
6
|
+
const WEEKDAYS = [1, 2, 3, 4, 5];
|
|
7
|
+
const WEEKEND = [0, 6];
|
|
8
|
+
function isEvery(field) {
|
|
9
|
+
return field === "*";
|
|
10
|
+
}
|
|
11
|
+
function fixed(field, min, max) {
|
|
12
|
+
if (!/^\d{1,2}$/.test(field)) return null;
|
|
13
|
+
const n = Number(field);
|
|
14
|
+
return n >= min && n <= max ? n : null;
|
|
15
|
+
}
|
|
16
|
+
function step(field, max, range) {
|
|
17
|
+
const m = /^\*\/(\d{1,2})$/.exec(field);
|
|
18
|
+
if (!m) return null;
|
|
19
|
+
const n = Number(m[1]);
|
|
20
|
+
return n >= 2 && n <= max && range % n === 0 ? n : null;
|
|
21
|
+
}
|
|
22
|
+
function numberSet(field, min, max) {
|
|
23
|
+
if (isEvery(field)) return null;
|
|
24
|
+
const out = /* @__PURE__ */ new Set();
|
|
25
|
+
for (const part of field.split(",")) {
|
|
26
|
+
const run = /^(\d{1,2})-(\d{1,2})$/.exec(part);
|
|
27
|
+
if (run) {
|
|
28
|
+
const from = Number(run[1]);
|
|
29
|
+
const to = Number(run[2]);
|
|
30
|
+
if (from < min || to > max || from >= to) return null;
|
|
31
|
+
for (let n = from; n <= to; n++) out.add(n);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const one = fixed(part, min, max);
|
|
35
|
+
if (one == null) return null;
|
|
36
|
+
out.add(one);
|
|
37
|
+
}
|
|
38
|
+
return out.size ? [...out].sort((a, b) => a - b) : null;
|
|
39
|
+
}
|
|
40
|
+
function daySet(field) {
|
|
41
|
+
const raw = numberSet(field, 0, 7);
|
|
42
|
+
if (!raw) return null;
|
|
43
|
+
return [...new Set(raw.map((d) => d === 7 ? 0 : d))].sort((a, b) => a - b);
|
|
44
|
+
}
|
|
45
|
+
function isRun(values) {
|
|
46
|
+
return values.length > 0 && values[values.length - 1] - values[0] === values.length - 1;
|
|
47
|
+
}
|
|
48
|
+
function readsAsSpan(hours) {
|
|
49
|
+
return hours.length >= 3 && isRun(hours);
|
|
50
|
+
}
|
|
51
|
+
function saysAsSpan(hours) {
|
|
52
|
+
return hours.length >= 4 && isRun(hours);
|
|
53
|
+
}
|
|
54
|
+
function writeSet(values) {
|
|
55
|
+
return readsAsSpan(values) ? `${values[0]}-${values[values.length - 1]}` : values.join(",");
|
|
56
|
+
}
|
|
57
|
+
export function parseCron(cron) {
|
|
58
|
+
if (!cron) return null;
|
|
59
|
+
const fields = cron.trim().split(/\s+/);
|
|
60
|
+
if (fields.length !== 5) return null;
|
|
61
|
+
const [minute, hour, dom, month, dow] = fields;
|
|
62
|
+
if (!isEvery(month)) return null;
|
|
63
|
+
const everyDay = isEvery(dom) && isEvery(dow);
|
|
64
|
+
if (isEvery(hour) && everyDay) {
|
|
65
|
+
if (isEvery(minute)) return { kind: "minutes", interval: 1 };
|
|
66
|
+
const perMinute = step(minute, 59, 60);
|
|
67
|
+
if (perMinute) return { kind: "minutes", interval: perMinute };
|
|
68
|
+
}
|
|
69
|
+
const min = fixed(minute, 0, 59);
|
|
70
|
+
if (min == null) return null;
|
|
71
|
+
if (everyDay) {
|
|
72
|
+
if (isEvery(hour)) return { kind: "hourly", minute: min };
|
|
73
|
+
const perHour = step(hour, 23, 24);
|
|
74
|
+
if (perHour) return { kind: "hours", interval: perHour, minute: min };
|
|
75
|
+
}
|
|
76
|
+
const hr = fixed(hour, 0, 23);
|
|
77
|
+
if (hr == null && isEvery(dom)) {
|
|
78
|
+
const hours = numberSet(hour, 0, 23);
|
|
79
|
+
if (hours && hours.length >= 2) {
|
|
80
|
+
const days = isEvery(dow) ? null : daySet(dow);
|
|
81
|
+
if (!isEvery(dow) && !days) return null;
|
|
82
|
+
return { kind: "times", minute: min, hours, days };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (hr == null) return null;
|
|
86
|
+
if (everyDay) return { kind: "daily", hour: hr, minute: min };
|
|
87
|
+
if (isEvery(dom)) {
|
|
88
|
+
const days = daySet(dow);
|
|
89
|
+
return days ? { kind: "weekly", days, hour: hr, minute: min } : null;
|
|
90
|
+
}
|
|
91
|
+
if (isEvery(dow)) {
|
|
92
|
+
const day = fixed(dom, 1, 31);
|
|
93
|
+
if (day) return { kind: "monthly", day, hour: hr, minute: min };
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
export function toCron(r) {
|
|
98
|
+
switch (r.kind) {
|
|
99
|
+
case "minutes":
|
|
100
|
+
return r.interval <= 1 ? "* * * * *" : `*/${r.interval} * * * *`;
|
|
101
|
+
case "hourly":
|
|
102
|
+
return `${r.minute} * * * *`;
|
|
103
|
+
case "hours":
|
|
104
|
+
return r.interval <= 1 ? `${r.minute} * * * *` : `${r.minute} */${r.interval} * * *`;
|
|
105
|
+
case "daily":
|
|
106
|
+
return `${r.minute} ${r.hour} * * *`;
|
|
107
|
+
case "times": {
|
|
108
|
+
const hours = [...new Set(r.hours)].sort((a, b) => a - b);
|
|
109
|
+
if (hours.length < 2) {
|
|
110
|
+
const hour = hours[0] ?? 0;
|
|
111
|
+
return r.days ? toCron({ kind: "weekly", days: r.days, hour, minute: r.minute }) : toCron({ kind: "daily", hour, minute: r.minute });
|
|
112
|
+
}
|
|
113
|
+
const days = r.days ? [...new Set(r.days.map((d) => d === 7 ? 0 : d))].sort((a, b) => a - b) : [];
|
|
114
|
+
const dow = !days.length || days.length === 7 ? "*" : writeSet(days);
|
|
115
|
+
return `${r.minute} ${writeSet(hours)} * * ${dow}`;
|
|
116
|
+
}
|
|
117
|
+
case "weekly": {
|
|
118
|
+
const days = [...new Set(r.days.map((d) => d === 7 ? 0 : d))].sort((a, b) => a - b);
|
|
119
|
+
if (!days.length || days.length === 7) return `${r.minute} ${r.hour} * * *`;
|
|
120
|
+
return `${r.minute} ${r.hour} * * ${days.join(",")}`;
|
|
121
|
+
}
|
|
122
|
+
case "monthly":
|
|
123
|
+
return `${r.minute} ${r.hour} ${r.day} * *`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function at(hour, minute) {
|
|
127
|
+
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
|
128
|
+
}
|
|
129
|
+
function plural(n, unit) {
|
|
130
|
+
return `${n} ${unit}${n === 1 ? "" : "s"}`;
|
|
131
|
+
}
|
|
132
|
+
function ordinal(n) {
|
|
133
|
+
const suffix = n % 100 >= 11 && n % 100 <= 13 ? "th" : ["th", "st", "nd", "rd"][n % 10] ?? "th";
|
|
134
|
+
return `${n}${suffix}`;
|
|
135
|
+
}
|
|
136
|
+
function sameDays(days, other) {
|
|
137
|
+
return days.length === other.length && days.every((d) => other.includes(d));
|
|
138
|
+
}
|
|
139
|
+
function listWords(words) {
|
|
140
|
+
if (words.length <= 1) return words[0] ?? "";
|
|
141
|
+
return `${words.slice(0, -1).join(", ")} and ${words[words.length - 1]}`;
|
|
142
|
+
}
|
|
143
|
+
function dayPhrase(days) {
|
|
144
|
+
if (!days) return "Daily";
|
|
145
|
+
const set = [...new Set(days.map((d) => d === 7 ? 0 : d))].sort((a, b) => a - b);
|
|
146
|
+
if (!set.length || set.length === 7) return "Daily";
|
|
147
|
+
if (sameDays(set, WEEKDAYS)) return "Weekdays";
|
|
148
|
+
if (sameDays(set, WEEKEND)) return "Weekends";
|
|
149
|
+
return listWords(set.map((d) => DAY_NAMES[d] ?? "").filter(Boolean));
|
|
150
|
+
}
|
|
151
|
+
export function describeRecurrence(r) {
|
|
152
|
+
switch (r.kind) {
|
|
153
|
+
case "minutes":
|
|
154
|
+
return r.interval <= 1 ? "Every minute" : `Every ${plural(r.interval, "minute")}`;
|
|
155
|
+
case "hourly":
|
|
156
|
+
return r.minute === 0 ? "Hourly, on the hour" : `Hourly at :${String(r.minute).padStart(2, "0")}`;
|
|
157
|
+
case "hours":
|
|
158
|
+
if (r.interval <= 1) return describeRecurrence({ kind: "hourly", minute: r.minute });
|
|
159
|
+
return r.minute === 0 ? `Every ${plural(r.interval, "hour")}, on the hour` : `Every ${plural(r.interval, "hour")} at :${String(r.minute).padStart(2, "0")}`;
|
|
160
|
+
case "daily":
|
|
161
|
+
return `Daily at ${at(r.hour, r.minute)}`;
|
|
162
|
+
case "times": {
|
|
163
|
+
const hours = [...new Set(r.hours)].sort((a, b) => a - b);
|
|
164
|
+
if (hours.length < 2) return describeRecurrence({ kind: "weekly", days: r.days ?? [0, 1, 2, 3, 4, 5, 6], hour: hours[0] ?? 0, minute: r.minute });
|
|
165
|
+
const phrase = dayPhrase(r.days);
|
|
166
|
+
if (saysAsSpan(hours)) {
|
|
167
|
+
const span = `hourly from ${at(hours[0], r.minute)} to ${at(hours[hours.length - 1], r.minute)}`;
|
|
168
|
+
return phrase === "Daily" ? `Hourly from ${at(hours[0], r.minute)} to ${at(hours[hours.length - 1], r.minute)}` : `${phrase}, ${span}`;
|
|
169
|
+
}
|
|
170
|
+
return `${phrase} at ${listWords(hours.map((h) => at(h, r.minute)))}`;
|
|
171
|
+
}
|
|
172
|
+
case "weekly": {
|
|
173
|
+
return `${dayPhrase(r.days)} at ${at(r.hour, r.minute)}`;
|
|
174
|
+
}
|
|
175
|
+
case "monthly":
|
|
176
|
+
return `Monthly on the ${ordinal(r.day)}, at ${at(r.hour, r.minute)}`;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
export function describeCron(cron) {
|
|
180
|
+
const r = parseCron(cron);
|
|
181
|
+
return r ? describeRecurrence(r) : null;
|
|
182
|
+
}
|
|
183
|
+
export function cadenceRank(cron) {
|
|
184
|
+
const r = parseCron(cron);
|
|
185
|
+
if (!r) return Number.MAX_SAFE_INTEGER;
|
|
186
|
+
switch (r.kind) {
|
|
187
|
+
case "minutes":
|
|
188
|
+
return Math.max(1, r.interval);
|
|
189
|
+
case "hourly":
|
|
190
|
+
return 60;
|
|
191
|
+
case "hours":
|
|
192
|
+
return Math.max(1, r.interval) * 60;
|
|
193
|
+
case "daily":
|
|
194
|
+
return 1440;
|
|
195
|
+
// Several times a day, thinned out by the days it skips.
|
|
196
|
+
case "times":
|
|
197
|
+
return Math.round(
|
|
198
|
+
1440 / Math.max(1, r.hours.length) * (7 / Math.min(7, Math.max(1, r.days?.length ?? 7)))
|
|
199
|
+
);
|
|
200
|
+
// A schedule on five weekdays fires roughly every 1.4 days, which belongs
|
|
201
|
+
// between daily and weekly rather than lumped in with a Monday-only one.
|
|
202
|
+
case "weekly":
|
|
203
|
+
return Math.round(10080 / Math.min(7, Math.max(1, r.days.length)));
|
|
204
|
+
case "monthly":
|
|
205
|
+
return 43200;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { WorkflowNode } from '../types/integrations.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reading the backend's validation findings — for the editor's validation
|
|
4
|
+
* banner (behavior specs/workflow-validation.md, how docs/workflow-validation.md)
|
|
5
|
+
* and for the node configuration dialog (specs/node-inspector.md AC-8).
|
|
6
|
+
*
|
|
7
|
+
* The backend reports one finding per incomplete setting, each keyed by its
|
|
8
|
+
* blob path (`blob.nodes.4.config.companyId`). The two readers want different
|
|
9
|
+
* cuts of that list:
|
|
10
|
+
*
|
|
11
|
+
* - `groupFindingsByNode` — for the banner. Rendered one-per-line that repeats
|
|
12
|
+
* the same node name three times over for a node missing three fields, so the
|
|
13
|
+
* banner groups them by node and names the fields instead of the messages.
|
|
14
|
+
* The full messages ride along for the hover title.
|
|
15
|
+
* - `findingsByNodeField` — for the dialog. Above the canvas the message alone
|
|
16
|
+
* is enough; inside a node's configuration it is not, because the dialog has
|
|
17
|
+
* to know WHICH FIELD the sentence belongs to or the user is left comparing a
|
|
18
|
+
* message against a form.
|
|
19
|
+
*
|
|
20
|
+
* Both live here rather than in their components because the parsing is the
|
|
21
|
+
* part worth pinning in a unit test: a finding whose path names no node must
|
|
22
|
+
* still reach the user (nothing may be silently dropped on the way to the
|
|
23
|
+
* banner), and a path shape that changes must not quietly mark the wrong field.
|
|
24
|
+
*/
|
|
25
|
+
export interface ValidationFinding {
|
|
26
|
+
path: string;
|
|
27
|
+
message: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Either a node with its incomplete settings, or a single finding that belongs
|
|
31
|
+
* to no node. `nodeId` discriminates the two, and `label` / `fields` are only
|
|
32
|
+
* populated in the first case — an unattributed finding has no node to name and
|
|
33
|
+
* no config key to point at, so it is rendered from `messages` alone.
|
|
34
|
+
*/
|
|
35
|
+
export interface FindingNodeGroup {
|
|
36
|
+
/** Blob node id, or `null` when the path names no node in the blob. */
|
|
37
|
+
nodeId: string | null;
|
|
38
|
+
/** The node's human label; `null` when `nodeId` is. */
|
|
39
|
+
label: string | null;
|
|
40
|
+
/**
|
|
41
|
+
* One compact entry per finding, in order and de-duplicated: the config key
|
|
42
|
+
* the path ends in, falling back to the message when the path carries none.
|
|
43
|
+
* **Empty when `nodeId` is `null`** — read `messages` instead.
|
|
44
|
+
*/
|
|
45
|
+
fields: string[];
|
|
46
|
+
/** The full messages behind this group, in order. Never empty. */
|
|
47
|
+
messages: string[];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* How a node is named wherever it is talked ABOUT rather than rendered: the
|
|
51
|
+
* instance name the user typed, else the manifest's name, else the raw slug.
|
|
52
|
+
* The same three steps the node card itself uses — a banner that stops after
|
|
53
|
+
* two of them prints `revenexx:business-central-create-customer` at the user.
|
|
54
|
+
*/
|
|
55
|
+
export declare function nodeDisplayLabel(node: WorkflowNode): string;
|
|
56
|
+
/**
|
|
57
|
+
* The setting a finding is about: the config key its path ends in.
|
|
58
|
+
*
|
|
59
|
+
* Only the FIRST segment after `config.` — `config.mapping[0].source` reads as
|
|
60
|
+
* `mapping`, so two findings on different sub-paths of one field collapse to a
|
|
61
|
+
* single `fields` entry while both still count towards the banner's total. That
|
|
62
|
+
* is deliberate: the line names the setting to open, not every leaf inside it,
|
|
63
|
+
* and the full messages survive in `messages` for the hover. It does mean a node
|
|
64
|
+
* can show two field names under a header that counted three findings.
|
|
65
|
+
*/
|
|
66
|
+
export declare function findingFieldKey(path: string): string | null;
|
|
67
|
+
/**
|
|
68
|
+
* Group findings by the node they belong to, preserving first-appearance order.
|
|
69
|
+
*
|
|
70
|
+
* A finding whose path resolves to no node becomes a group of its own (with a
|
|
71
|
+
* `null` nodeId), rendered after the node groups — it has no node to hide
|
|
72
|
+
* behind, and dropping it would lose an issue the user has to fix.
|
|
73
|
+
*/
|
|
74
|
+
export declare function groupFindingsByNode(findings: readonly ValidationFinding[], nodes: readonly WorkflowNode[], labelOf?: (node: WorkflowNode) => string): FindingNodeGroup[];
|
|
75
|
+
/**
|
|
76
|
+
* Node id → config key → the message to show AT that setting
|
|
77
|
+
* (specs/node-inspector.md AC-8).
|
|
78
|
+
*
|
|
79
|
+
* First finding wins: the backend reports the most specific reason first, and
|
|
80
|
+
* two sentences stacked under one input would be noise.
|
|
81
|
+
*
|
|
82
|
+
* Findings whose path names no node, or no config key, are absent here on
|
|
83
|
+
* purpose — they have no field to sit next to. They still reach the user in the
|
|
84
|
+
* warnings above the canvas via `groupFindingsByNode`, so nothing is dropped.
|
|
85
|
+
*/
|
|
86
|
+
export declare function findingsByNodeField(findings: readonly ValidationFinding[], nodes: readonly WorkflowNode[]): Record<string, Record<string, string>>;
|
|
87
|
+
/**
|
|
88
|
+
* "6 settings in 4 nodes still need fixing" — the count of findings and the count
|
|
89
|
+
* of nodes, but only naming nodes while there IS a node to attribute them to.
|
|
90
|
+
*
|
|
91
|
+
* Extracted because two surfaces have to give the same reason: the banner above
|
|
92
|
+
* the canvas, and the refusal a run comes back with (specs/workflow-validation.md
|
|
93
|
+
* AC-1 and AC-11). AC-11 exists precisely because those two disagreed — the gate
|
|
94
|
+
* named the settings and the run reported a generic failure — so the phrase they
|
|
95
|
+
* share is the wrong thing to write twice.
|
|
96
|
+
*
|
|
97
|
+
* NOT the wording of the activation toast, which is deliberately shorter ("N
|
|
98
|
+
* settings still need fixing", no node count) and pinned by AC-4. Three surfaces,
|
|
99
|
+
* two phrases; this is the one used where the node names are shown alongside.
|
|
100
|
+
*/
|
|
101
|
+
export declare function findingsSummary(findingCount: number, nodeCount: number): string;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { findManifest } from "../data/integrationNodeCatalog.js";
|
|
2
|
+
import { resolveLocalized } from "./localize.js";
|
|
3
|
+
export function nodeDisplayLabel(node) {
|
|
4
|
+
const manifest = findManifest(node.nodeSlug, node.nodeVersion);
|
|
5
|
+
return resolveLocalized(node.name) || resolveLocalized(manifest?.name) || node.nodeSlug;
|
|
6
|
+
}
|
|
7
|
+
export function findingFieldKey(path) {
|
|
8
|
+
const m = /^blob\.nodes\.\d+\.config\.([^.[]+)/.exec(path);
|
|
9
|
+
return m ? m[1] : null;
|
|
10
|
+
}
|
|
11
|
+
function findingNode(path, nodes) {
|
|
12
|
+
const m = /^blob\.nodes\.(\d+)\b/.exec(path);
|
|
13
|
+
return m ? nodes[Number(m[1])] : void 0;
|
|
14
|
+
}
|
|
15
|
+
export function groupFindingsByNode(findings, nodes, labelOf = nodeDisplayLabel) {
|
|
16
|
+
const nodeGroups = [];
|
|
17
|
+
const orphans = [];
|
|
18
|
+
const byNodeId = /* @__PURE__ */ new Map();
|
|
19
|
+
for (const finding of findings) {
|
|
20
|
+
const node = findingNode(finding.path, nodes);
|
|
21
|
+
if (!node) {
|
|
22
|
+
orphans.push({ nodeId: null, label: null, fields: [], messages: [finding.message] });
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
let group = byNodeId.get(node.id);
|
|
26
|
+
if (!group) {
|
|
27
|
+
group = { nodeId: node.id, label: labelOf(node), fields: [], messages: [] };
|
|
28
|
+
byNodeId.set(node.id, group);
|
|
29
|
+
nodeGroups.push(group);
|
|
30
|
+
}
|
|
31
|
+
const field = findingFieldKey(finding.path) ?? finding.message;
|
|
32
|
+
if (!group.fields.includes(field)) group.fields.push(field);
|
|
33
|
+
group.messages.push(finding.message);
|
|
34
|
+
}
|
|
35
|
+
return [...nodeGroups, ...orphans];
|
|
36
|
+
}
|
|
37
|
+
export function findingsByNodeField(findings, nodes) {
|
|
38
|
+
const out = {};
|
|
39
|
+
for (const finding of findings) {
|
|
40
|
+
const node = findingNode(finding.path, nodes);
|
|
41
|
+
if (!node) continue;
|
|
42
|
+
const field = findingFieldKey(finding.path);
|
|
43
|
+
if (!field) continue;
|
|
44
|
+
const forNode = out[node.id] ??= {};
|
|
45
|
+
forNode[field] ??= finding.message;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
export function findingsSummary(findingCount, nodeCount) {
|
|
50
|
+
const settings = `${findingCount} setting${findingCount === 1 ? "" : "s"}`;
|
|
51
|
+
const inNodes = nodeCount ? ` in ${nodeCount} node${nodeCount === 1 ? "" : "s"}` : "";
|
|
52
|
+
return `${settings}${inNodes} still need${findingCount === 1 ? "s" : ""} fixing`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
/**
|
|
3
|
+
* Resolves once the load a `loading` flag stands for has finished.
|
|
4
|
+
*
|
|
5
|
+
* Every composable in the studio guards its `load()` against a second caller
|
|
6
|
+
* arriving while one is in flight. Nine of them did it by handing that caller the
|
|
7
|
+
* state **as it stands** — empty, on a cold visit — so whatever it fed then fanned
|
|
8
|
+
* out over nothing and cached the nothing as an answer. On the Runs page the
|
|
9
|
+
* missed-runs tab showed neither a count nor an entry on a first visit, and the
|
|
10
|
+
* dead-letter endpoint was never called at all; the dashboard was empty for the
|
|
11
|
+
* same reason. Waiting is the honest answer, and it is what this does.
|
|
12
|
+
*
|
|
13
|
+
* The watcher lives in a **detached** scope on purpose. The caller waiting here is
|
|
14
|
+
* usually a page, and the operator can leave it while the request is still out; a
|
|
15
|
+
* watcher owned by that page would be disposed with it, this promise would never
|
|
16
|
+
* settle, and the caller's own loading flag would stay up for the rest of the
|
|
17
|
+
* session — the same tab empty again, and this time permanently. That was found by
|
|
18
|
+
* PO-219 and is the single most important line in this file.
|
|
19
|
+
*
|
|
20
|
+
* Three things it cannot do, so nobody has to find them out:
|
|
21
|
+
*
|
|
22
|
+
* - **It must not be reached during server-side setup.** `watch(source, cb)`
|
|
23
|
+
* without `immediate` is a no-op there, so the promise would never settle and the
|
|
24
|
+
* render would hang. Every call site is `onMounted` or an event handler today; a
|
|
25
|
+
* `useAsyncData` / `callOnce` / `onServerPrefetch` caller has to await the load
|
|
26
|
+
* outside setup instead.
|
|
27
|
+
* - **A hung request parks every waiting caller.** `$fetch` carries no timeout
|
|
28
|
+
* here, so a socket that never answers keeps them waiting rather than letting
|
|
29
|
+
* them proceed with an empty list. Deliberate — the empty list was the bug — but
|
|
30
|
+
* it is the one input that turns this into a permanent stall.
|
|
31
|
+
* - **It does not decide what happens next.** Whether a caller that waited then
|
|
32
|
+
* sends its own request is the difference between a forced re-read and an
|
|
33
|
+
* ordinary one, and every guard that answered it by hand got it wrong the same
|
|
34
|
+
* way — two forced callers each sent a request, so the flag went down with one
|
|
35
|
+
* still out. That rule now lives in `loadGate.ts`, which is what the composables
|
|
36
|
+
* use; call this directly only where there is nothing to decide.
|
|
37
|
+
*
|
|
38
|
+
* An already-idle flag resolves immediately rather than waiting for a load that
|
|
39
|
+
* will never start — so this is safe to call without checking first, which is what
|
|
40
|
+
* stops it becoming a hang the next time somebody reuses it.
|
|
41
|
+
*/
|
|
42
|
+
export declare function whenIdle(loading: Ref<boolean>): Promise<void>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { effectScope, watch } from "vue";
|
|
2
|
+
export function whenIdle(loading) {
|
|
3
|
+
if (!loading.value) return Promise.resolve();
|
|
4
|
+
const scope = effectScope(true);
|
|
5
|
+
return new Promise((resolve) => {
|
|
6
|
+
scope.run(() => watch(loading, (busy) => {
|
|
7
|
+
if (busy) return;
|
|
8
|
+
scope.stop();
|
|
9
|
+
resolve();
|
|
10
|
+
}));
|
|
11
|
+
});
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@revenexx/studio-integrations",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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": {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"dist",
|
|
20
|
+
"CHANGELOG.md",
|
|
20
21
|
"LICENSE"
|
|
21
22
|
],
|
|
22
23
|
"publishConfig": {
|
|
@@ -50,6 +51,7 @@
|
|
|
50
51
|
"vue-sonner": "^2.0.0"
|
|
51
52
|
},
|
|
52
53
|
"devDependencies": {
|
|
54
|
+
"@changesets/cli": "^2.31.0",
|
|
53
55
|
"@nuxt/module-builder": "^1.0.2",
|
|
54
56
|
"@playwright/test": "^1.57.0",
|
|
55
57
|
"@nuxt/kit": "^4.0.0",
|
|
@@ -63,6 +65,10 @@
|
|
|
63
65
|
"scripts": {
|
|
64
66
|
"build": "nuxt-module-build build",
|
|
65
67
|
"test": "vitest run",
|
|
66
|
-
"test:e2e": "playwright test"
|
|
68
|
+
"test:e2e": "playwright test",
|
|
69
|
+
"typecheck": "node scripts/typecheck.mjs",
|
|
70
|
+
"spec:check": "node scripts/spec-check.mjs",
|
|
71
|
+
"changeset": "changeset",
|
|
72
|
+
"version-packages": "changeset version"
|
|
67
73
|
}
|
|
68
74
|
}
|