@revenexx/studio-integrations 1.0.0 → 1.1.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 (31) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +2 -6
  4. package/dist/runtime/components/Integrations/NodeInspector.vue +40 -0
  5. package/dist/runtime/components/Integrations/RunDetailPanel.vue +9 -6
  6. package/dist/runtime/components/Integrations/RunsTable.vue +2 -2
  7. package/dist/runtime/components/Integrations/WorkflowEditor.vue +27 -1
  8. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +9 -1
  9. package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +13 -0
  10. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +38 -3
  11. package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +13 -0
  12. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +8 -6
  13. package/dist/runtime/components/Integrations/WorkflowsTable.vue +9 -4
  14. package/dist/runtime/composables/useIntegrationsDeadLetters.d.ts +9 -2
  15. package/dist/runtime/composables/useIntegrationsDeadLetters.js +27 -18
  16. package/dist/runtime/composables/useIntegrationsRuns.d.ts +30 -1
  17. package/dist/runtime/composables/useIntegrationsRuns.js +70 -29
  18. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +7 -15
  19. package/dist/runtime/composables/useIntegrationsSchedules.js +80 -35
  20. package/dist/runtime/composables/useIntegrationsTriggers.d.ts +8 -1
  21. package/dist/runtime/data/loopBody.d.ts +129 -0
  22. package/dist/runtime/data/loopBody.js +76 -0
  23. package/dist/runtime/i18n/messages/de.json +42 -30
  24. package/dist/runtime/i18n/messages/en.json +12 -0
  25. package/dist/runtime/pages/integrations/index.vue +16 -17
  26. package/dist/runtime/pages/integrations/runs.vue +22 -10
  27. package/dist/runtime/pages/integrations/schedules.vue +64 -7
  28. package/dist/runtime/types/integrations.d.ts +39 -0
  29. package/dist/runtime/utils/statusStyle.d.ts +52 -0
  30. package/dist/runtime/utils/statusStyle.js +26 -0
  31. package/package.json +1 -1
@@ -24,9 +24,10 @@ import StickyNoteView from "./StickyNoteView.vue";
24
24
  import GroupFrameView from "./GroupFrameView.vue";
25
25
  import WorkflowEdgeView from "./WorkflowEdgeView.vue";
26
26
  import { findManifest } from "../../data/integrationNodeCatalog";
27
+ import { nodeLoopData } from "../../data/loopBody";
27
28
  import { triggerGlyph } from "../../data/triggerGlyphs";
28
29
  import { SAFETY_POLL_MS } from "../../utils/pollBudget";
29
- import { runStatusLabel, statusClass } from "../../utils/statusStyle";
30
+ import { runStatusClass, runStatusLabel } from "../../utils/statusStyle";
30
31
  const props = defineProps({
31
32
  workflow: { type: Object, required: true },
32
33
  initialRunId: { type: [String, null], required: false },
@@ -236,6 +237,7 @@ function replayNodeClass(id, type) {
236
237
  const active = activeStep.value?.node_id === id ? " replay-node-active" : "";
237
238
  return `${tone}${reached ? "" : " replay-unreached"}${active}`;
238
239
  }
240
+ const loops = computed(() => nodeLoopData(props.workflow.blob.nodes, props.workflow.blob.edges));
239
241
  const vfNodes = computed(() => {
240
242
  const blob = props.workflow.blob;
241
243
  const triggerPos = (id, i) => blob.triggerPositions?.[id] ?? { x: -40, y: 60 + i * 140 };
@@ -274,7 +276,7 @@ const vfNodes = computed(() => {
274
276
  position: n.position ?? { x: 240, y: 80 },
275
277
  draggable: false,
276
278
  class: replayNodeClass(n.id, "workflow"),
277
- data: { ...n, __resolvedOutputs: dynamicOutputs.value[n.id] }
279
+ data: { ...n, __resolvedOutputs: dynamicOutputs.value[n.id], __loop: loops.value.get(n.id) }
278
280
  }))
279
281
  ];
280
282
  });
@@ -471,7 +473,7 @@ watch(() => props.initialRunId, (id) => {
471
473
  <div class="absolute top-3 left-3 z-10 flex items-center gap-2 rounded-lg border border-black/[0.08] bg-white/95 px-2.5 py-1.5 text-sm shadow-sm backdrop-blur-sm">
472
474
  <Play :size="13" class="text-muted-foreground" />
473
475
  <span class="font-medium">{{ selectedRunId ? t("integration.replay.canvas.runLabel", { id: selectedRunId }) : t("integration.replay.canvas.pickRun") }}</span>
474
- <Badge v-if="runStatus" variant="outline" class="border text-xs font-medium uppercase" :class="statusClass(runStatus)">
476
+ <Badge v-if="runStatus" variant="outline" class="border text-xs font-medium uppercase" :class="runStatusClass(runStatus)">
475
477
  {{ runStatusLabel(runStatus) }}
476
478
  </Badge>
477
479
  <Badge v-if="stepsSource" variant="outline" class="border text-xs font-medium uppercase text-muted-foreground">
@@ -535,7 +537,7 @@ watch(() => props.initialRunId, (id) => {
535
537
  <template v-else>{{ t("integration.replay.debug.noStepSelected") }}</template>
536
538
  </div>
537
539
  </div>
538
- <Badge v-if="activeStep" variant="outline" class="shrink-0 border text-xs font-medium uppercase" :class="statusClass(activeStep.status)">
540
+ <Badge v-if="activeStep" variant="outline" class="shrink-0 border text-xs font-medium uppercase" :class="runStatusClass(activeStep.status)">
539
541
  {{ runStatusLabel(activeStep.status) }}
540
542
  </Badge>
541
543
  </div>
@@ -613,8 +615,8 @@ watch(() => props.initialRunId, (id) => {
613
615
  <component :is="triggerIcon(r.triggerType)" :size="12" class="shrink-0 text-muted-foreground" />
614
616
  <span class="truncate">#{{ r.id }}</span>
615
617
  </span>
616
- <Badge variant="outline" class="shrink-0 border text-xs font-medium uppercase" :class="statusClass(r.status)">
617
- {{ r.status }}
618
+ <Badge variant="outline" class="shrink-0 border text-xs font-medium uppercase" :class="runStatusClass(r.status)">
619
+ {{ runStatusLabel(r.status) }}
618
620
  </Badge>
619
621
  </div>
620
622
  <div class="mt-0.5 flex items-center justify-between gap-2 text-sm text-muted-foreground">
@@ -50,13 +50,18 @@ watch(groupByTags, (on) => {
50
50
  } catch {
51
51
  }
52
52
  });
53
- const { runs, loaded: runsLoaded, load: loadRuns, list: listRuns } = useIntegrationsRuns();
53
+ const {
54
+ latestPerWorkflow,
55
+ latestLoaded: runsLoaded,
56
+ loadLatestPerWorkflow,
57
+ lastRunFor: latestRunFor
58
+ } = useIntegrationsRuns();
54
59
  onMounted(() => {
55
- void loadRuns();
60
+ void loadLatestPerWorkflow();
56
61
  });
57
62
  function lastRunFor(id) {
58
- void runs.value.length;
59
- return listRuns({ workflowId: id })[0] ?? null;
63
+ void latestPerWorkflow.value.length;
64
+ return latestRunFor(id);
60
65
  }
61
66
  function runBadge(status) {
62
67
  switch (status) {
@@ -1,7 +1,11 @@
1
1
  import type { DeadLetterRow } from '../types/integrations.js';
2
- /** A dead letter enriched with its workflow's name (global "Missed runs" view). */
2
+ /**
3
+ * A dead letter as the tenant-wide list serves it: the platform now reports the
4
+ * workflow's name on the row, where the fan-out had to inject it from the loop
5
+ * variable.
6
+ */
3
7
  export interface GlobalDeadLetterRow extends DeadLetterRow {
4
- workflowName: string;
8
+ workflow_name?: string | null;
5
9
  }
6
10
  export declare function useIntegrationsDeadLetters(): {
7
11
  list: (workflowId: number | string, opts?: {
@@ -18,5 +22,8 @@ export declare function useIntegrationsDeadLetters(): {
18
22
  loadGlobal: (force?: boolean) => Promise<GlobalDeadLetterRow[]>;
19
23
  globalItems: any;
20
24
  globalLoading: any;
25
+ globalLoaded: any;
26
+ globalHasMore: any;
27
+ summary: any;
21
28
  pendingCount: import("vue").ComputedRef<any>;
22
29
  };
@@ -1,6 +1,7 @@
1
1
  import { computed } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
3
  import { useLoadGate } from "./useLoadGate.js";
4
+ const EMPTY_SUMMARY = { total: 0, by_status: {} };
4
5
  export function useIntegrationsDeadLetters() {
5
6
  const api = useIntegrationsApi();
6
7
  async function list(workflowId, opts = {}) {
@@ -18,34 +19,42 @@ export function useIntegrationsDeadLetters() {
18
19
  }
19
20
  const replay = (id) => api.post(`/dead-letters/${id}/replay`, {});
20
21
  const discard = (id) => api.post(`/dead-letters/${id}/discard`, {});
21
- const { workflows, load: loadWorkflows, loaded: workflowsLoaded } = useIntegrationsWorkflows();
22
22
  const globalItems = useState("integrations.deadletters.global", () => []);
23
23
  const globalLoading = useState("integrations.deadletters.global.loading", () => false);
24
24
  const globalLoaded = useState("integrations.deadletters.global.loaded", () => false);
25
25
  const gate = useLoadGate("integrations.deadletters.global", { loading: globalLoading, loaded: globalLoaded });
26
- const pendingCount = computed(() => globalItems.value.filter((i) => i.status === "pending").length);
26
+ const summary = useState("integrations.deadletters.summary", () => ({ ...EMPTY_SUMMARY }));
27
+ const globalHasMore = useState("integrations.deadletters.global.hasMore", () => false);
28
+ const pendingCount = computed(() => summary.value.by_status.pending ?? 0);
27
29
  async function loadGlobal(force = false) {
28
30
  if (!await gate.claim(force)) return globalItems.value;
29
31
  try {
30
- await loadWorkflows();
31
- const MAX_PAGES = 20;
32
- const per = await Promise.allSettled(workflows.value.map(async (wf) => {
33
- const out = [];
34
- let page = 1;
35
- while (page <= MAX_PAGES) {
36
- const res = await list(wf.id, { page, perPage: 100 });
37
- out.push(...res.items.map((i) => ({ ...i, workflowName: wf.name })));
38
- if (page >= res.lastPage) break;
39
- page++;
40
- }
41
- return out;
42
- }));
43
- globalItems.value = per.flatMap((p) => p.status === "fulfilled" ? p.value : []).sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
44
- globalLoaded.value = workflowsLoaded.value && per.every((p) => p.status === "fulfilled");
32
+ const [page, counts] = await Promise.all([
33
+ api.get("/dead-letters?page=1&per_page=100"),
34
+ api.get("/dead-letters/summary")
35
+ ]);
36
+ globalItems.value = page?.data ?? [];
37
+ globalHasMore.value = page?.meta?.has_more ?? false;
38
+ summary.value = counts?.data ?? { ...EMPTY_SUMMARY };
39
+ globalLoaded.value = true;
40
+ } catch (e) {
41
+ globalLoaded.value = false;
42
+ throw e;
45
43
  } finally {
46
44
  gate.release();
47
45
  }
48
46
  return globalItems.value;
49
47
  }
50
- return { list, replay, discard, loadGlobal, globalItems, globalLoading, pendingCount };
48
+ return {
49
+ list,
50
+ replay,
51
+ discard,
52
+ loadGlobal,
53
+ globalItems,
54
+ globalLoading,
55
+ globalLoaded,
56
+ globalHasMore,
57
+ summary,
58
+ pendingCount
59
+ };
51
60
  }
@@ -1,13 +1,36 @@
1
1
  import type { RunStatus, RunStepRow, WorkflowRun } from '../types/integrations.js';
2
+ interface RunSummary {
3
+ total: number;
4
+ in_flight: number;
5
+ by_status: Record<string, number>;
6
+ }
7
+ /**
8
+ * Which page of the tenant's list to read.
9
+ *
10
+ * Only the page: `/runs` also narrows by status, trigger kind, workflow, window
11
+ * and text, and none of that is driven from here yet. The shared DataTable
12
+ * narrows and searches what it holds, and it cannot report a filter selection
13
+ * back to the page under `serverMode` — so a narrowing sent to the platform
14
+ * would have nothing to set it. That is the remaining half of PO-231, and the
15
+ * parameters are waiting on the endpoint for it.
16
+ */
17
+ export interface RunFeedQuery {
18
+ page: number;
19
+ perPage: number;
20
+ }
2
21
  export declare function useIntegrationsRuns(): {
3
22
  runs: any;
4
23
  loading: any;
5
24
  error: any;
6
25
  loaded: any;
7
26
  total: import("vue").ComputedRef<any>;
8
- failedCount: import("vue").ComputedRef<any>;
27
+ failedCount: import("vue").ComputedRef<number>;
9
28
  runningCount: import("vue").ComputedRef<any>;
29
+ summary: any;
30
+ hasMore: any;
31
+ query: any;
10
32
  load: (force?: boolean) => Promise<WorkflowRun[]>;
33
+ loadSummary: () => Promise<RunSummary>;
11
34
  loadForWorkflow: (workflowId: number, workflowName: string, opts?: {
12
35
  reconcile?: boolean;
13
36
  page?: number;
@@ -24,6 +47,11 @@ export declare function useIntegrationsRuns(): {
24
47
  }) => WorkflowRun[];
25
48
  get: (id: string) => WorkflowRun | null;
26
49
  fetchRun: (workflowId: number | string, runId: number | string, workflowName: string) => Promise<WorkflowRun>;
50
+ latestPerWorkflow: any;
51
+ latestLoaded: any;
52
+ latestLoading: any;
53
+ loadLatestPerWorkflow: (force?: boolean) => Promise<WorkflowRun[]>;
54
+ lastRunFor: (workflowId: number) => WorkflowRun | null;
27
55
  trigger: (workflowId: number | string, payload?: Record<string, unknown>, mode?: "async" | "sync") => Promise<unknown>;
28
56
  cancelRun: (workflowId: number | string, runId: number | string, reason?: string) => Promise<unknown>;
29
57
  terminateRun: (workflowId: number | string, runId: number | string, reason?: string) => Promise<unknown>;
@@ -49,3 +77,4 @@ export declare function useIntegrationsRuns(): {
49
77
  }>;
50
78
  getStackTrace: (workflowId: number | string, runId: number | string) => Promise<string>;
51
79
  };
80
+ export {};
@@ -3,6 +3,7 @@ import { useIntegrationsApi } from "./integrationsApi.js";
3
3
  import { useIntegrationsT } from "./useIntegrationsT.js";
4
4
  import { IntegrationsApiError } from "../utils/apiError.js";
5
5
  import { useLoadGate } from "./useLoadGate.js";
6
+ const EMPTY_SUMMARY = { total: 0, in_flight: 0, by_status: {} };
6
7
  function mapStatus(status) {
7
8
  switch (status) {
8
9
  case "completed":
@@ -31,22 +32,30 @@ export function useIntegrationsRuns() {
31
32
  const error = useState("integrations.runs.error", () => null);
32
33
  const loaded = useState("integrations.runs.loaded", () => false);
33
34
  const reconciling = useState("integrations.runs.reconciling", () => false);
35
+ const summary = useState("integrations.runs.summary", () => ({ ...EMPTY_SUMMARY }));
36
+ const hasMore = useState("integrations.runs.hasMore", () => false);
37
+ const query = useState("integrations.runs.query", () => ({
38
+ page: 1,
39
+ perPage: 100
40
+ }));
34
41
  const gate = useLoadGate("integrations.runs", { loading, loaded });
35
42
  const SYNC_LIMIT = 5;
36
43
  const api = useIntegrationsApi();
37
44
  const t = useIntegrationsT();
38
- const { workflows, load: loadWorkflows, loaded: workflowsLoaded } = useIntegrationsWorkflows();
39
- function mapRun(r, workflowName) {
45
+ function mapRun(r, workflowName = "") {
40
46
  const startedMs = r.started_at ? new Date(r.started_at).getTime() : null;
41
47
  const finishedMs = r.completed_at ? new Date(r.completed_at).getTime() : null;
42
48
  return {
43
49
  id: String(r.id),
44
50
  workflowId: r.workflow_id,
45
- workflowName,
51
+ workflowName: r.workflow_name ?? workflowName,
46
52
  status: mapStatus(r.status),
47
53
  temporalRunId: r.temporal_run_id ?? null,
48
- triggerId: "",
49
- triggerType: "manual",
54
+ triggerId: r.trigger_handle ?? "",
55
+ // The platform reports what started the run. This was hardcoded to
56
+ // `manual` because the payload carried nothing, which left the trigger
57
+ // column and its filter with one populated value in production.
58
+ triggerType: r.trigger_type ?? "manual",
50
59
  startedAt: r.started_at ?? "",
51
60
  finishedAt: r.completed_at ?? null,
52
61
  // The list carries the reason a run failed; dropping it here was why every
@@ -63,25 +72,14 @@ export function useIntegrationsRuns() {
63
72
  if (!await gate.claim(force)) return runs.value;
64
73
  error.value = null;
65
74
  try {
66
- await loadWorkflows();
67
- const MAX_PAGES = 100;
68
- const perWorkflow = await Promise.allSettled(
69
- workflows.value.map(async (wf) => {
70
- const out = [];
71
- let page = 1;
72
- while (page <= MAX_PAGES) {
73
- const res = await api.get(
74
- `/workflows/${wf.id}/runs?page=${page}&per_page=100`
75
- );
76
- out.push(...(res?.data ?? []).map((r) => mapRun(r, wf.name)));
77
- const last = res?.meta?.last_page ?? 1;
78
- if (page >= last) break;
79
- page++;
80
- }
81
- return out;
82
- })
83
- );
84
- runs.value = perWorkflow.flatMap((p) => p.status === "fulfilled" ? p.value : []).sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
75
+ const { page: pageNumber, perPage } = query.value;
76
+ const [page, counts] = await Promise.all([
77
+ api.get(`/runs?page=${pageNumber}&per_page=${perPage}`),
78
+ api.get("/runs/summary")
79
+ ]);
80
+ runs.value = (page?.data ?? []).map((r) => mapRun(r));
81
+ hasMore.value = page?.meta?.has_more ?? false;
82
+ summary.value = counts?.data ?? { ...EMPTY_SUMMARY };
85
83
  if (!reconciling.value) {
86
84
  reconciling.value = true;
87
85
  try {
@@ -99,9 +97,10 @@ export function useIntegrationsRuns() {
99
97
  reconciling.value = false;
100
98
  }
101
99
  }
102
- loaded.value = workflowsLoaded.value && perWorkflow.every((p) => p.status === "fulfilled");
100
+ loaded.value = true;
103
101
  } catch (e) {
104
102
  error.value = e instanceof IntegrationsApiError ? e.message : e?.message ?? t("integration.runs.error.loadFailed");
103
+ loaded.value = false;
105
104
  } finally {
106
105
  gate.release();
107
106
  }
@@ -137,7 +136,40 @@ export function useIntegrationsRuns() {
137
136
  let result = [...runs.value];
138
137
  if (filter?.workflowId !== void 0) result = result.filter((r) => r.workflowId === filter.workflowId);
139
138
  if (filter?.status) result = result.filter((r) => r.status === filter.status);
140
- return result.sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
139
+ return result;
140
+ }
141
+ async function loadSummary() {
142
+ try {
143
+ const res = await api.get("/runs/summary");
144
+ summary.value = res?.data ?? { ...EMPTY_SUMMARY };
145
+ } catch (e) {
146
+ error.value = e instanceof IntegrationsApiError ? e.message : e?.message ?? t("integration.runs.error.loadFailed");
147
+ }
148
+ return summary.value;
149
+ }
150
+ const latestPerWorkflow = useState("integrations.runs.latest", () => []);
151
+ const latestLoaded = useState("integrations.runs.latest.loaded", () => false);
152
+ const latestLoading = useState("integrations.runs.latest.loading", () => false);
153
+ const latestGate = useLoadGate("integrations.runs.latest", {
154
+ loading: latestLoading,
155
+ loaded: latestLoaded
156
+ });
157
+ async function loadLatestPerWorkflow(force = false) {
158
+ if (!await latestGate.claim(force)) return latestPerWorkflow.value;
159
+ try {
160
+ const res = await api.get("/runs/latest-per-workflow");
161
+ latestPerWorkflow.value = (res?.data ?? []).map((r) => mapRun(r));
162
+ latestLoaded.value = true;
163
+ } catch (e) {
164
+ error.value = e instanceof IntegrationsApiError ? e.message : e?.message ?? t("integration.runs.error.loadFailed");
165
+ latestLoaded.value = false;
166
+ } finally {
167
+ latestGate.release();
168
+ }
169
+ return latestPerWorkflow.value;
170
+ }
171
+ function lastRunFor(workflowId) {
172
+ return latestPerWorkflow.value.find((r) => r.workflowId === workflowId) ?? null;
141
173
  }
142
174
  function get(id) {
143
175
  return runs.value.find((r) => r.id === id) ?? null;
@@ -175,9 +207,9 @@ export function useIntegrationsRuns() {
175
207
  return api.get(`/workflows/${workflowId}/runs/${runId}/history${suffix}`);
176
208
  };
177
209
  const getStackTrace = (workflowId, runId) => api.get(`/workflows/${workflowId}/runs/${runId}/stack-trace`).then((r) => r.stack_trace);
178
- const total = computed(() => runs.value.length);
179
- const failedCount = computed(() => runs.value.filter((r) => r.status === "failed").length);
180
- const runningCount = computed(() => runs.value.filter((r) => r.status === "running").length);
210
+ const total = computed(() => summary.value.total);
211
+ const failedCount = computed(() => Object.entries(summary.value.by_status).filter(([status]) => mapStatus(status) === "failed").reduce((sum, [, count]) => sum + count, 0));
212
+ const runningCount = computed(() => summary.value.in_flight);
181
213
  return {
182
214
  runs,
183
215
  loading,
@@ -186,11 +218,20 @@ export function useIntegrationsRuns() {
186
218
  total,
187
219
  failedCount,
188
220
  runningCount,
221
+ summary,
222
+ hasMore,
223
+ query,
189
224
  load,
225
+ loadSummary,
190
226
  loadForWorkflow,
191
227
  list,
192
228
  get,
193
229
  fetchRun,
230
+ latestPerWorkflow,
231
+ latestLoaded,
232
+ latestLoading,
233
+ loadLatestPerWorkflow,
234
+ lastRunFor,
194
235
  trigger,
195
236
  cancelRun,
196
237
  terminateRun,
@@ -1,19 +1,11 @@
1
1
  import type { ScheduleEntry } from '../types/integrations.js';
2
- /**
3
- * Derived view — every trigger of type `schedule` across all workflows, as
4
- * `useIntegrationsTriggers` holds them: read out of the workflow list, which carries
5
- * each workflow's triggers, rather than fetched per workflow (PO-236).
6
- * Next/last-run timestamps are mocked deterministically (no telemetry
7
- * endpoint yet).
8
- *
9
- * Unordered on purpose: the page's table sorts, by its own default and by
10
- * whichever column the operator clicks, so an order established here would be
11
- * replaced before anyone saw it.
12
- */
13
2
  export declare function useIntegrationsSchedules(): {
14
- entries: import("vue").ComputedRef<ScheduleEntry[]>;
15
- total: import("vue").ComputedRef<number>;
16
- activeCount: import("vue").ComputedRef<number>;
3
+ entries: any;
4
+ total: import("vue").ComputedRef<any>;
5
+ activeCount: import("vue").ComputedRef<any>;
6
+ timezonesInUse: import("vue").ComputedRef<any>;
17
7
  loading: any;
18
- load: any;
8
+ loaded: any;
9
+ error: any;
10
+ load: (force?: boolean) => Promise<ScheduleEntry[]>;
19
11
  };
@@ -1,39 +1,84 @@
1
1
  import { computed } from "vue";
2
+ import { useIntegrationsApi } from "./integrationsApi.js";
3
+ import { useIntegrationsT } from "./useIntegrationsT.js";
4
+ import { useLoadGate } from "./useLoadGate.js";
5
+ import { IntegrationsApiError } from "../utils/apiError.js";
2
6
  import { scheduleTimezone } from "../utils/schedule.js";
3
7
  export function useIntegrationsSchedules() {
4
- const { all, loading, loadAll } = useIntegrationsTriggers();
5
- const entries = computed(
6
- () => all.value.filter((t) => t.type === "schedule").map((t) => {
7
- const cfg = t.config ?? {};
8
- const triggerId = String(t.id);
9
- return {
10
- id: `${t.workflow_id}:${t.id}`,
11
- workflowId: t.workflow_id,
12
- workflowName: t.workflowName,
13
- triggerId,
14
- // Never invented: a trigger without an expression says so on the page.
15
- // Standing in `* * * * *` for it would read as "every minute", and with
16
- // the expression no longer on screen that guess would be invisible.
17
- cron: typeof cfg.cron === "string" && cfg.cron.trim() !== "" ? cfg.cron : null,
18
- // The schedule's own zone, or the platform's default where it has none —
19
- // from the same helper the trigger settings read, so one schedule cannot
20
- // be shown in two zones on two screens.
21
- timezone: scheduleTimezone(cfg.timezone),
22
- active: t.active,
23
- nextRunAt: t.active ? mockNextRun(t.workflow_id, triggerId) : null,
24
- lastRunAt: t.active ? mockLastRun(t.workflow_id, triggerId) : null
25
- };
26
- })
27
- );
28
- const total = computed(() => entries.value.length);
29
- const activeCount = computed(() => entries.value.filter((e) => e.active).length);
30
- return { entries, total, activeCount, loading, load: loadAll };
31
- }
32
- function mockNextRun(workflowId, triggerId) {
33
- const offsetMinutes = (workflowId * 7 + triggerId.length * 3) % 720 + 5;
34
- return new Date(Date.now() + offsetMinutes * 6e4).toISOString();
35
- }
36
- function mockLastRun(workflowId, triggerId) {
37
- const offsetMinutes = (workflowId * 13 + triggerId.length * 11) % 720 + 30;
38
- return new Date(Date.now() - offsetMinutes * 6e4).toISOString();
8
+ const api = useIntegrationsApi();
9
+ const t = useIntegrationsT();
10
+ const entries = useState("integrations.schedules", () => []);
11
+ const loading = useState("integrations.schedules.loading", () => false);
12
+ const loaded = useState("integrations.schedules.loaded", () => false);
13
+ const error = useState("integrations.schedules.error", () => null);
14
+ const meta = useState("integrations.schedules.meta", () => ({
15
+ total: 0,
16
+ live_count: 0,
17
+ timezones: []
18
+ }));
19
+ const gate = useLoadGate("integrations.schedules", { loading, loaded });
20
+ function mapEntry(row) {
21
+ const block = row.schedule;
22
+ const firstFiring = block?.next_fire_times?.[0] ?? null;
23
+ return {
24
+ id: `${row.workflow_id}:${row.id}`,
25
+ workflowId: row.workflow_id,
26
+ workflowName: row.workflow_name ?? "",
27
+ triggerId: String(row.id),
28
+ // Never invented: a trigger without an expression says so on the page.
29
+ // Standing in `* * * * *` for it would read as "every minute", and with the
30
+ // expression no longer on screen that guess would be invisible.
31
+ cron: block?.cron ?? null,
32
+ // The zone the platform resolved, rather than one guessed here — but the
33
+ // platform's default put back where it reports none. `timezone` is null on
34
+ // the wire whenever the expression could not be read AND the schedule
35
+ // carries no zone of its own (`ScheduleTelemetry::present` falls through to
36
+ // `configuredTimezone()`), which is exactly the row AC-9 promises exists.
37
+ //
38
+ // A blank there is not merely ugly, it is unreachable: `meta.timezones`
39
+ // contributes the platform default for a schedule carrying none, and the
40
+ // service's `scopeToTimezone()` matches those rows when the reader narrows
41
+ // to that default — so a row with no zone at all would show a bare globe
42
+ // and then vanish from every timezone the filter offers. The same helper
43
+ // the trigger settings use, so one schedule cannot read two zones
44
+ // (`utils/schedule.ts`, and the note in `NodeInspector.vue`).
45
+ timezone: scheduleTimezone(block?.timezone),
46
+ active: row.active,
47
+ workflowActive: row.workflow_active,
48
+ nextRunAt: block?.next_fire_at ?? null,
49
+ nextRunLocal: firstFiring?.local ?? null,
50
+ // When the run STARTED, which is nullable on the platform
51
+ // (`workflow_runs.started_at`): a run the scheduler has created and the
52
+ // worker has not picked up yet carries a status and no start. So this
53
+ // being null does not mean "never ran" — `lastRunStatus` is what separates
54
+ // the two, and the page states the queued run rather than showing the
55
+ // dash a schedule that has never fired gets (`specs/schedules.md` AC-12).
56
+ lastRunAt: block?.last_run?.started_at ?? null,
57
+ /** Non-null exactly when the platform holds a last run at all. */
58
+ lastRunStatus: block?.last_run?.status ?? null,
59
+ lastDispatchAt: block?.last_dispatch_at ?? null,
60
+ holdsFirings: block?.holds_firings ?? false,
61
+ notFiringReason: block?.not_firing_reason ?? null
62
+ };
63
+ }
64
+ async function load(force = false) {
65
+ if (!await gate.claim(force)) return entries.value;
66
+ error.value = null;
67
+ try {
68
+ const res = await api.get("/schedules");
69
+ entries.value = (res?.data ?? []).map(mapEntry);
70
+ meta.value = res?.meta ?? { total: 0, live_count: 0, timezones: [] };
71
+ loaded.value = true;
72
+ } catch (e) {
73
+ error.value = e instanceof IntegrationsApiError ? e.message : e?.message ?? t("integration.schedules.error.loadFailed");
74
+ loaded.value = false;
75
+ } finally {
76
+ gate.release();
77
+ }
78
+ return entries.value;
79
+ }
80
+ const total = computed(() => meta.value.total);
81
+ const activeCount = computed(() => meta.value.live_count);
82
+ const timezonesInUse = computed(() => meta.value.timezones);
83
+ return { entries, total, activeCount, timezonesInUse, loading, loaded, error, load };
39
84
  }
@@ -7,8 +7,15 @@ import type { ApiTriggerLite } from './useIntegrationsWorkflows.js';
7
7
  * is how the editor's trigger settings work.
8
8
  *
9
9
  * `loadAll()` does NOT: the workflow list carries each workflow's triggers in full,
10
- * so the tenant-wide view is derived from that response rather than fetched per
10
+ * so a tenant-wide view is derived from that response rather than fetched per
11
11
  * workflow (PO-236). What it still costs is the workflow list, once.
12
+ *
13
+ * Nothing in this module reads `loadAll()` any more. The Schedules page was its
14
+ * one caller and now reads `GET /schedules`, which is the only source that can
15
+ * report when a schedule fires next (PO-231). It is kept because these
16
+ * composables are the module's public, auto-imported surface — a host app may
17
+ * hold the tenant's triggers this way, and the Webhooks view this was also
18
+ * written for has never been built.
12
19
  */
13
20
  export interface ApiTrigger {
14
21
  id: number;