@nvent-addon/app 0.5.7 → 0.5.9

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/dist/module.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nventapp",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "configKey": "nventapp",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
@@ -0,0 +1,36 @@
1
+ type __VLS_Props = {
2
+ selected?: boolean;
3
+ icon?: string;
4
+ iconClass?: string;
5
+ title?: string;
6
+ subtitle?: string;
7
+ badge?: string;
8
+ badgeColor?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | 'neutral';
9
+ meta?: string;
10
+ metaSecondary?: string;
11
+ };
12
+ declare var __VLS_1: {}, __VLS_9: {}, __VLS_11: {}, __VLS_19: {}, __VLS_21: {};
13
+ type __VLS_Slots = {} & {
14
+ icon?: (props: typeof __VLS_1) => any;
15
+ } & {
16
+ title?: (props: typeof __VLS_9) => any;
17
+ } & {
18
+ badge?: (props: typeof __VLS_11) => any;
19
+ } & {
20
+ subtitle?: (props: typeof __VLS_19) => any;
21
+ } & {
22
+ meta?: (props: typeof __VLS_21) => any;
23
+ };
24
+ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
25
+ click: () => any;
26
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
27
+ onClick?: (() => any) | undefined;
28
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
29
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
30
+ declare const _default: typeof __VLS_export;
31
+ export default _default;
32
+ type __VLS_WithSlots<T, S> = T & {
33
+ new (): {
34
+ $slots: S;
35
+ };
36
+ };
@@ -0,0 +1,73 @@
1
+ <template>
2
+ <div
3
+ class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-900/50 cursor-pointer transition-colors"
4
+ :class="{
5
+ 'bg-blue-50 dark:bg-blue-950/30 border-l-2 border-l-blue-500': selected
6
+ }"
7
+ @click="$emit('click')"
8
+ >
9
+ <div class="flex items-start gap-3">
10
+ <div
11
+ v-if="icon || $slots.icon"
12
+ class="flex-shrink-0 mt-0.5"
13
+ >
14
+ <slot name="icon">
15
+ <UIcon
16
+ v-if="icon"
17
+ :name="icon"
18
+ class="w-5 h-5"
19
+ :class="iconClass"
20
+ />
21
+ </slot>
22
+ </div>
23
+ <div class="flex-1 min-w-0">
24
+ <div class="flex items-center justify-between gap-2 mb-1">
25
+ <h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
26
+ <slot name="title">
27
+ {{ title }}
28
+ </slot>
29
+ </h3>
30
+ <slot name="badge">
31
+ <UBadge
32
+ v-if="badge"
33
+ :label="badge"
34
+ :color="badgeColor"
35
+ variant="subtle"
36
+ size="xs"
37
+ class="capitalize flex-shrink-0"
38
+ />
39
+ </slot>
40
+ </div>
41
+ <p
42
+ v-if="subtitle || $slots.subtitle"
43
+ class="text-xs text-gray-500 dark:text-gray-400 font-mono truncate mb-1"
44
+ >
45
+ <slot name="subtitle">
46
+ {{ subtitle }}
47
+ </slot>
48
+ </p>
49
+ <div class="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
50
+ <slot name="meta">
51
+ <span v-if="meta">{{ meta }}</span>
52
+ <span v-if="metaSecondary">• {{ metaSecondary }}</span>
53
+ </slot>
54
+ </div>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ </template>
59
+
60
+ <script setup>
61
+ defineProps({
62
+ selected: { type: Boolean, required: false },
63
+ icon: { type: String, required: false },
64
+ iconClass: { type: String, required: false },
65
+ title: { type: String, required: false },
66
+ subtitle: { type: String, required: false },
67
+ badge: { type: String, required: false },
68
+ badgeColor: { type: String, required: false },
69
+ meta: { type: String, required: false },
70
+ metaSecondary: { type: String, required: false }
71
+ });
72
+ defineEmits(["click"]);
73
+ </script>
@@ -0,0 +1,36 @@
1
+ type __VLS_Props = {
2
+ selected?: boolean;
3
+ icon?: string;
4
+ iconClass?: string;
5
+ title?: string;
6
+ subtitle?: string;
7
+ badge?: string;
8
+ badgeColor?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | 'neutral';
9
+ meta?: string;
10
+ metaSecondary?: string;
11
+ };
12
+ declare var __VLS_1: {}, __VLS_9: {}, __VLS_11: {}, __VLS_19: {}, __VLS_21: {};
13
+ type __VLS_Slots = {} & {
14
+ icon?: (props: typeof __VLS_1) => any;
15
+ } & {
16
+ title?: (props: typeof __VLS_9) => any;
17
+ } & {
18
+ badge?: (props: typeof __VLS_11) => any;
19
+ } & {
20
+ subtitle?: (props: typeof __VLS_19) => any;
21
+ } & {
22
+ meta?: (props: typeof __VLS_21) => any;
23
+ };
24
+ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
25
+ click: () => any;
26
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
27
+ onClick?: (() => any) | undefined;
28
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
29
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
30
+ declare const _default: typeof __VLS_export;
31
+ export default _default;
32
+ type __VLS_WithSlots<T, S> = T & {
33
+ new (): {
34
+ $slots: S;
35
+ };
36
+ };
@@ -1,18 +1,40 @@
1
1
  import { type Ref } from '#imports';
2
2
  import type { FetchError } from 'ofetch';
3
- interface FlowRun {
3
+ export interface FlowRun {
4
4
  id: string;
5
- [key: string]: any;
5
+ flowName: string;
6
+ status: 'running' | 'completed' | 'failed' | 'canceled' | 'stalled' | 'awaiting' | 'unknown';
7
+ createdAt: string;
8
+ startedAt?: string;
9
+ completedAt?: string;
10
+ stepCount: number;
11
+ completedSteps: number;
12
+ }
13
+ export interface FlowRunsResponse {
14
+ flowName: string;
15
+ count: number;
16
+ total: number;
17
+ offset: number;
18
+ limit: number;
19
+ hasMore: boolean;
20
+ items: FlowRun[];
6
21
  }
7
22
  /**
8
- * Composable for fetching and managing flow runs
9
- * Simple approach: Fresh fetch on every refresh, no stale cache
23
+ * Composable for fetching flow runs with pagination
24
+ * Similar pattern to useTriggerEvents - supports server-side pagination
10
25
  * Client-only to avoid hydration mismatches
11
26
  */
12
- export declare function useFlowRuns(flowId: Ref<string>): {
13
- runs: Ref<FlowRun[] | null | undefined>;
27
+ export declare function useFlowRuns(flowId: Ref<string>, options?: Ref<{
28
+ limit?: number;
29
+ offset?: number;
30
+ status?: string | null;
31
+ }> | {
32
+ limit?: number;
33
+ offset?: number;
34
+ status?: string | null;
35
+ }): {
36
+ runs: Ref<FlowRunsResponse | null | undefined>;
14
37
  refresh: () => Promise<void>;
15
38
  status: Ref<'idle' | 'pending' | 'success' | 'error'>;
16
39
  error: Ref<FetchError | null | undefined>;
17
40
  };
18
- export {};
@@ -1,28 +1,41 @@
1
- import { ref, watch, useFetch } from "#imports";
2
- export function useFlowRuns(flowId) {
1
+ import { ref, watch, useFetch, isRef } from "#imports";
2
+ export function useFlowRuns(flowId, options) {
3
3
  const refreshCounter = ref(0);
4
+ const opts = isRef(options) ? options : ref(options || {});
5
+ const buildUrl = () => {
6
+ if (!flowId.value) return "/api/_flows/__invalid__/runs";
7
+ const params = new URLSearchParams();
8
+ params.append("_t", refreshCounter.value.toString());
9
+ if (opts.value.limit) params.append("limit", opts.value.limit.toString());
10
+ if (opts.value.offset !== void 0) params.append("offset", opts.value.offset.toString());
11
+ if (opts.value.status) params.append("status", opts.value.status);
12
+ return `/api/_flows/${encodeURIComponent(flowId.value)}/runs?${params.toString()}`;
13
+ };
4
14
  const { data: runs, refresh: _refresh, status, error } = useFetch(
5
- () => `/api/_flows/${encodeURIComponent(flowId.value)}/runs?_t=${refreshCounter.value}`,
15
+ buildUrl,
6
16
  {
7
17
  immediate: false,
8
18
  watch: false,
9
19
  // Disable automatic watch to prevent SSR execution
10
20
  server: false
11
21
  // Client-only to avoid hydration issues
12
- // Don't use a key - this prevents Nuxt from caching across calls
13
22
  }
14
23
  );
15
24
  const refresh = async () => {
25
+ if (!flowId.value) return;
16
26
  refreshCounter.value++;
17
27
  await _refresh();
18
28
  };
19
29
  watch(flowId, (newFlow, oldFlow) => {
20
- if (import.meta.client && newFlow) {
21
- if (newFlow !== oldFlow || !runs.value) {
22
- refresh();
23
- }
30
+ if (import.meta.client && newFlow && newFlow !== oldFlow) {
31
+ refresh();
24
32
  }
25
33
  }, { immediate: true });
34
+ watch(() => opts.value, () => {
35
+ if (import.meta.client && flowId.value) {
36
+ refresh();
37
+ }
38
+ }, { deep: true });
26
39
  return {
27
40
  runs,
28
41
  refresh,
@@ -131,54 +131,23 @@
131
131
  </div>
132
132
  <div
133
133
  v-else
134
- ref="runsScrollContainer"
135
- class="flex-1 overflow-y-auto min-h-0 divide-y divide-gray-100 dark:divide-gray-800"
136
- @scroll="handleRunsScroll"
134
+ class="flex-1 min-h-0 flex flex-col overflow-hidden"
137
135
  >
138
- <div
139
- v-for="r in runs"
140
- :key="r.id"
141
- class="group"
142
- >
143
- <div
144
- class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-900/50 transition-colors cursor-pointer"
145
- :class="{ 'bg-gray-50 dark:bg-gray-900': selectedRunId === r.id }"
136
+ <div class="flex-1 min-h-0 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
137
+ <SelectableListItem
138
+ v-for="r in runs"
139
+ :key="r.id"
140
+ :selected="selectedRunId === r.id"
141
+ :icon="getRunStatusIcon(r.status)"
142
+ :icon-class="getRunStatusIconClass(r.status)"
143
+ :subtitle="truncateId(r.id)"
144
+ :meta="formatTime(r.createdAt)"
146
145
  @click="selectRun(r.id)"
147
146
  >
148
- <div class="flex items-start justify-between gap-3">
149
- <div class="flex-1 min-w-0">
150
- <div class="text-xs font-mono text-gray-900 dark:text-gray-100 truncate">
151
- {{ r.id?.substring(0, 8) }}...{{ r.id?.substring(r.id?.length - 4) }}
152
- </div>
153
- <div class="flex items-center gap-3 mt-1.5">
154
- <div class="text-xs text-gray-500">
155
- {{ formatTime(r.createdAt) }}
156
- </div>
157
- <!-- Step progress -->
158
- <div
159
- v-if="r.stepCount > 0"
160
- class="text-xs text-gray-500 flex items-center gap-1"
161
- >
162
- <UIcon
163
- name="i-lucide-list-checks"
164
- class="w-3 h-3"
165
- />
166
- <span>{{ r.completedSteps }}/{{ r.stepCount }}</span>
167
- </div>
168
- <!-- Duration (if completed) -->
169
- <div
170
- v-if="r.completedAt && r.startedAt"
171
- class="text-xs text-gray-500 flex items-center gap-1"
172
- >
173
- <UIcon
174
- name="i-lucide-timer"
175
- class="w-3 h-3"
176
- />
177
- <span>{{ formatDuration(r.startedAt, r.completedAt) }}</span>
178
- </div>
179
- </div>
180
- </div>
181
- <!-- Status badge -->
147
+ <template #title>
148
+ Run {{ r.id?.substring(0, 8) }}
149
+ </template>
150
+ <template #badge>
182
151
  <FlowRunStatusBadge
183
152
  :is-running="r.status === 'running'"
184
153
  :is-completed="r.status === 'completed'"
@@ -187,28 +156,56 @@
187
156
  :is-stalled="r.status === 'stalled'"
188
157
  :is-awaiting="r.status === 'awaiting'"
189
158
  />
190
- </div>
159
+ </template>
160
+ <template #meta>
161
+ <span>{{ formatTime(r.createdAt) }}</span>
162
+ <span
163
+ v-if="r.stepCount > 0"
164
+ class="flex items-center gap-1"
165
+ >
166
+ <UIcon
167
+ name="i-lucide-list-checks"
168
+ class="w-3 h-3"
169
+ />
170
+ {{ r.completedSteps }}/{{ r.stepCount }}
171
+ </span>
172
+ <span
173
+ v-if="r.completedAt && r.startedAt"
174
+ class="flex items-center gap-1"
175
+ >
176
+ <UIcon
177
+ name="i-lucide-timer"
178
+ class="w-3 h-3"
179
+ />
180
+ {{ formatDuration(r.startedAt, r.completedAt) }}
181
+ </span>
182
+ </template>
183
+ </SelectableListItem>
184
+
185
+ <!-- Loading indicator -->
186
+ <div
187
+ v-if="loadingRuns"
188
+ class="px-4 py-3 text-center text-xs text-gray-400"
189
+ >
190
+ <UIcon
191
+ name="i-lucide-loader-2"
192
+ class="w-4 h-4 animate-spin inline-block"
193
+ />
194
+ <span class="ml-2">Loading runs...</span>
191
195
  </div>
192
196
  </div>
193
197
 
194
- <!-- Loading indicator for infinite scroll -->
198
+ <!-- Pagination Footer -->
195
199
  <div
196
- v-if="loadingRuns"
197
- class="px-4 py-3 text-center text-xs text-gray-400"
200
+ v-if="totalRuns > runsPerPage"
201
+ class="border-t border-gray-200 dark:border-gray-800 px-4 py-3 flex items-center justify-center shrink-0"
198
202
  >
199
- <UIcon
200
- name="i-lucide-loader-2"
201
- class="w-4 h-4 animate-spin inline-block"
203
+ <UPagination
204
+ v-model:page="currentPage"
205
+ :items-per-page="runsPerPage"
206
+ :total="totalRuns"
207
+ size="xs"
202
208
  />
203
- <span class="ml-2">Loading more runs...</span>
204
- </div>
205
-
206
- <!-- End of list indicator -->
207
- <div
208
- v-else-if="!hasMoreRuns && runs.length > 0"
209
- class="px-4 py-3 text-center text-xs text-gray-400"
210
- >
211
- All runs loaded
212
209
  </div>
213
210
  </div>
214
211
  </div>
@@ -391,17 +388,17 @@
391
388
  </template>
392
389
 
393
390
  <script setup>
394
- import { ref, computed, watch } from "#imports";
391
+ import { ref, computed, watch, onMounted, onUnmounted } from "#imports";
395
392
  import FlowDiagram from "../../components/flow/Diagram.vue";
396
393
  import FlowRunOverview from "../../components/flow/RunOverview.vue";
397
394
  import FlowRunTimeline from "../../components/flow/RunTimeline.vue";
398
395
  import FlowRunStatusBadge from "../../components/flow/RunStatusBadge.vue";
399
396
  import ConfirmDialog from "../../components/ConfirmDialog.vue";
397
+ import SelectableListItem from "../../components/SelectableListItem.vue";
400
398
  import { useRoute, useRouter } from "#app";
401
399
  import { useAnalyzedFlows } from "../../composables/useAnalyzedFlows";
402
- import { useFlowRunsInfinite } from "../../composables/useFlowRunsInfinite";
400
+ import { useFlowRuns } from "../../composables/useFlowRuns";
403
401
  import { useFlowRunTimeline } from "../../composables/useFlowRunTimeline";
404
- import { useFlowRunsPolling } from "../../composables/useFlowRunsPolling";
405
402
  import { useComponentRouter } from "../../composables/useComponentRouter";
406
403
  const componentRouter = useComponentRouter();
407
404
  const router = useRouter();
@@ -449,18 +446,64 @@ const selectedFlowDef = computed(() => {
449
446
  });
450
447
  const selectedFlowRef = computed(() => selectedFlow.value || "");
451
448
  const selectedRunIdRef = computed(() => selectedRunId.value || "");
449
+ const runsPerPage = 20;
450
+ const currentPage = computed({
451
+ get: () => {
452
+ const page = route.query.page;
453
+ return page ? Number.parseInt(page, 10) : 1;
454
+ },
455
+ set: (value) => {
456
+ router.push({
457
+ query: {
458
+ ...route.query,
459
+ page: value > 1 ? value.toString() : void 0
460
+ }
461
+ });
462
+ }
463
+ });
464
+ const runsQueryOptions = computed(() => ({
465
+ limit: runsPerPage,
466
+ offset: (currentPage.value - 1) * runsPerPage
467
+ }));
452
468
  const {
453
- items: runs,
454
- total: totalRuns,
455
- loading: loadingRuns,
456
- hasMore: hasMoreRuns,
457
- loadMore: loadMoreRuns,
469
+ runs: runsResponse,
458
470
  refresh: refreshRuns,
459
- checkForNewRuns
460
- } = useFlowRunsInfinite(selectedFlowRef);
471
+ status: runsStatus
472
+ } = useFlowRuns(selectedFlowRef, runsQueryOptions);
473
+ const runs = computed(() => runsResponse.value?.items || []);
474
+ const totalRuns = computed(() => runsResponse.value?.total || 0);
475
+ const loadingRuns = computed(() => runsStatus.value === "pending");
461
476
  const { flowState, isConnected, isReconnecting } = useFlowRunTimeline(selectedFlowRef, selectedRunIdRef);
462
- const shouldPoll = computed(() => !!selectedFlow.value);
463
- useFlowRunsPolling(checkForNewRuns, shouldPoll);
477
+ let pollInterval = null;
478
+ const startPolling = () => {
479
+ if (pollInterval) return;
480
+ pollInterval = setInterval(() => {
481
+ if (selectedFlow.value) {
482
+ refreshRuns();
483
+ }
484
+ }, 3e3);
485
+ };
486
+ const stopPolling = () => {
487
+ if (pollInterval) {
488
+ clearInterval(pollInterval);
489
+ pollInterval = null;
490
+ }
491
+ };
492
+ watch(selectedFlow, (flow) => {
493
+ if (flow) {
494
+ startPolling();
495
+ } else {
496
+ stopPolling();
497
+ }
498
+ }, { immediate: true });
499
+ onMounted(() => {
500
+ if (import.meta.client && selectedFlow.value) {
501
+ startPolling();
502
+ }
503
+ });
504
+ onUnmounted(() => {
505
+ stopPolling();
506
+ });
464
507
  const startFlowModalOpen = ref(false);
465
508
  const flowInputJson = ref("{}");
466
509
  const jsonError = ref("");
@@ -491,17 +534,6 @@ watch(flowInputJson, (value) => {
491
534
  jsonError.value = err instanceof Error ? err.message : "Invalid JSON";
492
535
  }
493
536
  });
494
- const runsScrollContainer = ref(null);
495
- const handleRunsScroll = (event) => {
496
- if (!hasMoreRuns.value || loadingRuns.value) return;
497
- const container = event.target;
498
- const scrollTop = container.scrollTop;
499
- const scrollHeight = container.scrollHeight;
500
- const clientHeight = container.clientHeight;
501
- if (scrollTop + clientHeight >= scrollHeight - 200) {
502
- loadMoreRuns();
503
- }
504
- };
505
537
  const formatTime = (timestamp) => {
506
538
  const date = new Date(timestamp);
507
539
  const now = /* @__PURE__ */ new Date();
@@ -533,6 +565,46 @@ const formatDuration = (start, end) => {
533
565
  }
534
566
  return `${seconds}s`;
535
567
  };
568
+ const truncateId = (id) => {
569
+ if (!id || id.length <= 16) return id;
570
+ return `${id.substring(0, 8)}...${id.substring(id.length - 8)}`;
571
+ };
572
+ const getRunStatusIcon = (status) => {
573
+ switch (status) {
574
+ case "running":
575
+ return "i-lucide-loader-2";
576
+ case "completed":
577
+ return "i-lucide-check-circle";
578
+ case "failed":
579
+ return "i-lucide-x-circle";
580
+ case "canceled":
581
+ return "i-lucide-ban";
582
+ case "stalled":
583
+ return "i-lucide-alert-triangle";
584
+ case "awaiting":
585
+ return "i-lucide-pause-circle";
586
+ default:
587
+ return "i-lucide-circle";
588
+ }
589
+ };
590
+ const getRunStatusIconClass = (status) => {
591
+ switch (status) {
592
+ case "running":
593
+ return "text-blue-500 animate-spin";
594
+ case "completed":
595
+ return "text-emerald-500";
596
+ case "failed":
597
+ return "text-red-500";
598
+ case "canceled":
599
+ return "text-gray-500";
600
+ case "stalled":
601
+ return "text-amber-500";
602
+ case "awaiting":
603
+ return "text-purple-500";
604
+ default:
605
+ return "text-gray-400";
606
+ }
607
+ };
536
608
  const runSnapshot = computed(() => {
537
609
  const state = flowState.state.value;
538
610
  const flowMeta = selectedFlowMeta.value;
@@ -88,50 +88,27 @@
88
88
  class="flex-1 min-h-0 overflow-y-auto"
89
89
  >
90
90
  <div class="divide-y divide-gray-100 dark:divide-gray-800">
91
- <div
91
+ <SelectableListItem
92
92
  v-for="job in paginatedJobs"
93
93
  :key="job.id"
94
- class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-900/50 cursor-pointer transition-colors"
95
- :class="{
96
- 'bg-blue-50 dark:bg-blue-950/30 border-l-2 border-l-blue-500': selectedJobId === job.id
97
- }"
94
+ :selected="selectedJobId === job.id"
95
+ :icon="getJobIcon(job.state)"
96
+ :icon-class="getJobIconColor(job.state)"
97
+ :title="job.name"
98
+ :subtitle="truncateId(job.id)"
99
+ :badge="job.state || 'unknown'"
100
+ :badge-color="getStateBadgeColor(job.state)"
98
101
  @click="selectJob(job.id)"
99
102
  >
100
- <div class="flex items-start gap-3">
101
- <div class="flex-shrink-0 mt-0.5">
102
- <UIcon
103
- :name="getJobIcon(job.state)"
104
- class="w-5 h-5"
105
- :class="getJobIconColor(job.state)"
106
- />
107
- </div>
108
- <div class="flex-1 min-w-0">
109
- <div class="flex items-center justify-between gap-2 mb-1">
110
- <h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
111
- {{ job.name }}
112
- </h3>
113
- <UBadge
114
- :label="job.state || 'unknown'"
115
- :color="getStateBadgeColor(job.state)"
116
- variant="subtle"
117
- size="xs"
118
- class="capitalize flex-shrink-0"
119
- />
120
- </div>
121
- <p class="text-xs text-gray-500 dark:text-gray-400 font-mono truncate mb-1">
122
- {{ truncateId(job.id) }}
123
- </p>
124
- <div class="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
125
- <span v-if="job.timestamp">
126
- {{ formatTime(job.timestamp) }}
127
- </span>
128
- <span v-if="job.finishedOn && job.processedOn">
129
- • {{ formatDuration(job.processedOn, job.finishedOn) }}
130
- </span>
131
- </div>
132
- </div>
133
- </div>
134
- </div>
103
+ <template #meta>
104
+ <span v-if="job.timestamp">
105
+ {{ formatTime(job.timestamp) }}
106
+ </span>
107
+ <span v-if="job.finishedOn && job.processedOn">
108
+ • {{ formatDuration(job.processedOn, job.finishedOn) }}
109
+ </span>
110
+ </template>
111
+ </SelectableListItem>
135
112
  </div>
136
113
  </div>
137
114
 
@@ -407,6 +384,7 @@ import { useComponentRouter } from "../../composables/useComponentRouter";
407
384
  import { useRoute, useRouter } from "#app";
408
385
  import StatCard from "../../components/StatCard.vue";
409
386
  import QueueConfiguration from "../../components/QueueConfiguration.vue";
387
+ import SelectableListItem from "../../components/SelectableListItem.vue";
410
388
  const componentRouter = useComponentRouter();
411
389
  const router = useRouter();
412
390
  const route = useRoute();
@@ -184,47 +184,19 @@
184
184
  class="flex-1 min-h-0 overflow-y-auto"
185
185
  >
186
186
  <div class="divide-y divide-gray-100 dark:divide-gray-800">
187
- <div
187
+ <SelectableListItem
188
188
  v-for="(event, idx) in paginatedEvents"
189
189
  :key="idx"
190
- class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-900/50 cursor-pointer transition-colors"
191
- :class="{
192
- 'bg-blue-50 dark:bg-blue-950/30 border-l-2 border-l-blue-500': selectedEvent && selectedEvent.type === event.type && (selectedEvent.ts || selectedEvent.timestamp) === (event.ts || event.timestamp)
193
- }"
190
+ :selected="!!(selectedEvent && selectedEvent.type === event.type && (selectedEvent.ts || selectedEvent.timestamp) === (event.ts || event.timestamp))"
191
+ :icon="getEventIcon(event.type)"
192
+ :icon-class="getEventIconColor(event.type)"
193
+ :title="event.type"
194
+ :subtitle="formatDate(event.ts || event.timestamp)"
195
+ :badge="event.type.split('.')[1] || 'event'"
196
+ :badge-color="getEventBadgeColor(event.type)"
197
+ :meta="formatTime(event.ts || event.timestamp)"
194
198
  @click="selectEvent(event)"
195
- >
196
- <div class="flex items-start gap-3">
197
- <div class="flex-shrink-0 mt-0.5">
198
- <UIcon
199
- :name="getEventIcon(event.type)"
200
- class="w-5 h-5"
201
- :class="getEventIconColor(event.type)"
202
- />
203
- </div>
204
- <div class="flex-1 min-w-0">
205
- <div class="flex items-center justify-between gap-2 mb-1">
206
- <h3 class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
207
- {{ event.type }}
208
- </h3>
209
- <UBadge
210
- :label="event.type.split('.')[1] || 'event'"
211
- :color="getEventBadgeColor(event.type)"
212
- variant="subtle"
213
- size="xs"
214
- class="capitalize flex-shrink-0"
215
- />
216
- </div>
217
- <p class="text-xs text-gray-500 dark:text-gray-400 font-mono truncate mb-1">
218
- {{ formatDate(event.ts || event.timestamp) }}
219
- </p>
220
- <div class="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
221
- <span>
222
- {{ formatTime(event.ts || event.timestamp) }}
223
- </span>
224
- </div>
225
- </div>
226
- </div>
227
- </div>
199
+ />
228
200
  </div>
229
201
  </div>
230
202
 
@@ -549,6 +521,7 @@ import { useComponentRouter } from "../../composables/useComponentRouter";
549
521
  import { useTriggerWebSocket } from "../../composables/useTriggerWebSocket";
550
522
  import { useRoute, useRouter } from "#app";
551
523
  import StatCard from "../../components/StatCard.vue";
524
+ import SelectableListItem from "../../components/SelectableListItem.vue";
552
525
  const componentRouter = useComponentRouter();
553
526
  const router = useRouter();
554
527
  const route = useRoute();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nvent-addon/app",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
4
4
  "description": "nvent app module for Nuxt.js",
5
5
  "repository": "DevJoghurt/nvent",
6
6
  "license": "MIT",
@@ -1,6 +0,0 @@
1
- type __VLS_Props = {
2
- queue: string;
3
- };
4
- declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
5
- declare const _default: typeof __VLS_export;
6
- export default _default;
@@ -1,203 +0,0 @@
1
- <template>
2
- <div class="space-y-4">
3
- <div class="flex justify-end">
4
- <Button
5
- icon="i-heroicons-clock"
6
- color="neutral"
7
- variant="outline"
8
- @click="jobSchedulerEditor = !jobSchedulerEditor"
9
- >
10
- Job Scheduler
11
- </Button>
12
- </div>
13
- <UForm
14
- :schema="schema"
15
- :state="state"
16
- @submit="onSubmit"
17
- >
18
- <UCard
19
- v-if="jobSchedulerEditor"
20
- :ui="{
21
- body: 'space-y-6'
22
- }"
23
- >
24
- <UFormField
25
- label="Name"
26
- name="name"
27
- >
28
- <UInput
29
- v-model="state.name"
30
- placeholder="Name"
31
- class="w-full"
32
- />
33
- </UFormField>
34
- <UTabs
35
- v-model="state.scheduleType"
36
- :items="scheduleInputTypes"
37
- size="xs"
38
- default-value="every"
39
- variant="pill"
40
- color="neutral"
41
- :ui="{
42
- list: 'w-28 self-start',
43
- content: 'w-full'
44
- }"
45
- >
46
- <template #content="{ item }">
47
- <UFormField name="scheduleValue">
48
- <UInput
49
- v-if="item.value === 'every'"
50
- v-model="state.scheduleValue"
51
- class="w-full"
52
- type="number"
53
- />
54
- <UInput
55
- v-if="item.value === 'cron'"
56
- v-model="state.scheduleValue"
57
- class="w-full"
58
- type="string"
59
- />
60
- </UFormField>
61
- </template>
62
- </UTabs>
63
- <div>
64
- <div class="text-sm font-bold mb-2">
65
- Job
66
- </div>
67
- <div class="flex flex-col space-y-2 p-2 rounded-sm ring-1 ring-gray-200 dark:ring-gray-800 shadow">
68
- <UFormField
69
- label="Name"
70
- name="jobName"
71
- >
72
- <UInput
73
- v-model="state.jobName"
74
- placeholder="Job Name"
75
- class="w-full"
76
- />
77
- </UFormField>
78
- <UFormField
79
- label="Data"
80
- name="jobData"
81
- >
82
- <JsonEditorVue
83
- v-model="state.jobData"
84
- :main-menu-bar="false"
85
- mode="text"
86
- />
87
- </UFormField>
88
- </div>
89
- </div>
90
- <template #footer>
91
- <div class="flex justify-end">
92
- <Button
93
- type="submit"
94
- color="neutral"
95
- variant="outline"
96
- class="cursor-pointer"
97
- >
98
- Create
99
- </Button>
100
- </div>
101
- </template>
102
- </UCard>
103
- </UForm>
104
- <div>
105
- <div
106
- v-if="scheduler && scheduler.length > 0"
107
- class="space-y-4"
108
- >
109
- <div
110
- v-for="item of scheduler"
111
- :key="item.key"
112
- class="flex flex-col rounded-sm ring-1 ring-gray-200 dark:ring-gray-800 shadow p-4"
113
- >
114
- <div class="flex justify-end">
115
- <Button
116
- icon="i-heroicons-x-circle"
117
- color="error"
118
- variant="outline"
119
- class="cursor-pointer"
120
- @click="deleteScheduledJob(item.key)"
121
- />
122
- </div>
123
- <div>
124
- <span class="text-sm font-bold">Name:</span> {{ item.key }}
125
- </div>
126
- <div>
127
- <span class="text-sm font-bold">Next schedule:</span> {{
128
- new Date(item.next).toLocaleString("de", {
129
- day: "numeric",
130
- month: "short",
131
- hour: "2-digit",
132
- minute: "2-digit",
133
- second: "2-digit",
134
- hour12: false
135
- })
136
- }}
137
- </div>
138
- </div>
139
- </div>
140
- <div v-else>
141
- <UAlert
142
- color="info"
143
- title="No scheduled jobs"
144
- variant="subtle"
145
- icon="i-heroicons-information-circle"
146
- class="flex items-center space-x-2"
147
- />
148
- </div>
149
- </div>
150
- </div>
151
- </template>
152
-
153
- <script setup>
154
- import { z } from "zod";
155
- import { ref, useFetch } from "#imports";
156
- const props = defineProps({
157
- queue: { type: String, required: true }
158
- });
159
- const jobSchedulerEditor = ref(false);
160
- const scheduleInputTypes = [
161
- { label: "Every", value: "every" },
162
- { label: "Cron", value: "cron" }
163
- ];
164
- const {
165
- data: scheduler,
166
- refresh
167
- } = await useFetch(`/api/_queue/${props.queue}/job/scheduler`, {
168
- method: "GET"
169
- });
170
- const state = ref({
171
- name: void 0,
172
- scheduleType: "every",
173
- scheduleValue: void 0,
174
- jobName: void 0,
175
- jobData: void 0
176
- });
177
- const schema = z.object({
178
- name: z.string().regex(/^\S*$/gm, "No spaces allowed"),
179
- scheduleType: z.enum(["every", "cron"]),
180
- scheduleValue: z.any(),
181
- jobName: z.string().regex(/^\S*$/gm, "No spaces allowed"),
182
- jobData: z.string().default("{}")
183
- });
184
- async function onSubmit(event) {
185
- await $fetch(`/api/_queue/${props.queue}/job/scheduler`, {
186
- method: "POST",
187
- body: {
188
- name: event.data.name,
189
- scheduleType: event.data.scheduleType,
190
- scheduleValue: event.data.scheduleValue,
191
- jobName: event.data.jobName,
192
- jobData: JSON.stringify(event.data.jobData)
193
- }
194
- });
195
- refresh();
196
- }
197
- const deleteScheduledJob = async (id) => {
198
- await $fetch(`/api/_queue/${props.queue}/job/scheduler/${id}`, {
199
- method: "DELETE"
200
- });
201
- refresh();
202
- };
203
- </script>
@@ -1,6 +0,0 @@
1
- type __VLS_Props = {
2
- queue: string;
3
- };
4
- declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
5
- declare const _default: typeof __VLS_export;
6
- export default _default;
@@ -1,20 +0,0 @@
1
- import type { DropdownMenuItem } from '@nuxt/ui';
2
- type __VLS_Props = {
3
- title?: string;
4
- link: string;
5
- origin?: string | null;
6
- dropdown?: DropdownMenuItem[];
7
- };
8
- declare var __VLS_27: {};
9
- type __VLS_Slots = {} & {
10
- default?: (props: typeof __VLS_27) => any;
11
- };
12
- declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
13
- declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
14
- declare const _default: typeof __VLS_export;
15
- export default _default;
16
- type __VLS_WithSlots<T, S> = T & {
17
- new (): {
18
- $slots: S;
19
- };
20
- };
@@ -1,69 +0,0 @@
1
- <template>
2
- <div
3
- :to="link"
4
- class="rounded-lg divide-y divide-gray-200 dark:divide-gray-800 ring-1 ring-gray-200 dark:ring-gray-800 shadow bg-white dark:bg-gray-900"
5
- >
6
- <div class="px-4 py-5 sm:p-6">
7
- <div class="flex flex-col md:flex-row">
8
- <div class="flex-none flex flex-col justify-between space-y-2">
9
- <ULink
10
- class="inline-flex items-center gap-1"
11
- @click="push(link)"
12
- >
13
- <span class="text-lg font-semibold">{{ title }}</span>
14
- <UIcon
15
- name="i-heroicons-arrow-up-right"
16
- class="w-5 h-5 text-primary-500"
17
- />
18
- </ULink>
19
- <div class="flex flex-wrap items-center gap-2">
20
- <div class="inline-flex gap-1 items-center">
21
- <UIcon
22
- name="i-heroicons-check-circle"
23
- class="w-4 h-4 text-green-500"
24
- />
25
- <span class="text-sm">Active</span>
26
- </div>
27
- <UBadge
28
- v-if="origin"
29
- size="sm"
30
- color="neutral"
31
- >
32
- <span v-if="origin === 'local'">Local</span>
33
- <span v-if="origin === 'remote'">Remote</span>
34
- </UBadge>
35
- </div>
36
- </div>
37
- <div class="grow pr-12">
38
- <div class="flex flex-row gap-4 justify-end">
39
- <slot />
40
- </div>
41
- </div>
42
- <div class="flex-none">
43
- <div class="flex gap-2 items-center">
44
- <UDropdownMenu
45
- :items="dropdown"
46
- >
47
- <UButton
48
- icon="i-heroicons-ellipsis-vertical"
49
- color="neutral"
50
- variant="outline"
51
- />
52
- </UDropdownMenu>
53
- </div>
54
- </div>
55
- </div>
56
- </div>
57
- </div>
58
- </template>
59
-
60
- <script setup>
61
- import { useComponentRouter } from "#imports";
62
- defineProps({
63
- title: { type: String, required: false, default: "" },
64
- link: { type: String, required: true },
65
- origin: { type: [String, null], required: false, default: null },
66
- dropdown: { type: Array, required: false }
67
- });
68
- const { push } = useComponentRouter();
69
- </script>
@@ -1,20 +0,0 @@
1
- import type { DropdownMenuItem } from '@nuxt/ui';
2
- type __VLS_Props = {
3
- title?: string;
4
- link: string;
5
- origin?: string | null;
6
- dropdown?: DropdownMenuItem[];
7
- };
8
- declare var __VLS_27: {};
9
- type __VLS_Slots = {} & {
10
- default?: (props: typeof __VLS_27) => any;
11
- };
12
- declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
13
- declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
14
- declare const _default: typeof __VLS_export;
15
- export default _default;
16
- type __VLS_WithSlots<T, S> = T & {
17
- new (): {
18
- $slots: S;
19
- };
20
- };
@@ -1,24 +0,0 @@
1
- import { type Ref } from '#imports';
2
- /**
3
- * Composable for infinite scroll flow runs with pagination
4
- */
5
- export declare function useFlowRunsInfinite(flowId: Ref<string>): {
6
- items: import("vue").ComputedRef<{
7
- id: string;
8
- flowName: string;
9
- status: "running" | "completed" | "failed" | "canceled" | "stalled" | "awaiting" | "unknown";
10
- createdAt: string;
11
- startedAt?: string | undefined;
12
- completedAt?: string | undefined;
13
- stepCount: number;
14
- completedSteps: number;
15
- }[]>;
16
- total: import("vue").ComputedRef<number>;
17
- loading: import("vue").ComputedRef<boolean>;
18
- hasMore: import("vue").ComputedRef<boolean>;
19
- error: import("vue").ComputedRef<Error | null>;
20
- loadMore: () => void;
21
- refresh: () => Promise<void>;
22
- checkForNewRuns: () => Promise<void>;
23
- reset: () => void;
24
- };
@@ -1,123 +0,0 @@
1
- import { ref, computed, watch } from "#imports";
2
- export function useFlowRunsInfinite(flowId) {
3
- const items = ref([]);
4
- const total = ref(0);
5
- const offset = ref(0);
6
- const limit = 50;
7
- const loading = ref(false);
8
- const hasMore = ref(true);
9
- const error = ref(null);
10
- const newestRunId = ref(null);
11
- const reset = () => {
12
- items.value = [];
13
- total.value = 0;
14
- offset.value = 0;
15
- hasMore.value = true;
16
- error.value = null;
17
- newestRunId.value = null;
18
- };
19
- const fetchPage = async (resetData = false) => {
20
- if (!flowId.value || loading.value) return;
21
- if (resetData) {
22
- reset();
23
- }
24
- if (!hasMore.value && !resetData) return;
25
- try {
26
- loading.value = true;
27
- error.value = null;
28
- const response = await $fetch(
29
- `/api/_flows/${encodeURIComponent(flowId.value)}/runs`,
30
- {
31
- query: {
32
- limit,
33
- offset: resetData ? 0 : offset.value,
34
- _t: Date.now()
35
- // Cache busting
36
- }
37
- }
38
- );
39
- if (resetData) {
40
- items.value = response.items;
41
- offset.value = response.items.length;
42
- if (response.items.length > 0) {
43
- newestRunId.value = response.items[0].id;
44
- }
45
- } else {
46
- items.value.push(...response.items);
47
- offset.value += response.items.length;
48
- }
49
- total.value = response.total;
50
- hasMore.value = response.hasMore;
51
- } catch (err) {
52
- console.error("[useFlowRunsInfinite] fetch error:", err);
53
- error.value = err instanceof Error ? err : new Error(String(err));
54
- } finally {
55
- loading.value = false;
56
- }
57
- };
58
- const loadMore = () => {
59
- if (!loading.value && hasMore.value) {
60
- fetchPage(false);
61
- }
62
- };
63
- const refresh = async () => {
64
- await fetchPage(true);
65
- };
66
- const checkForNewRuns = async () => {
67
- if (!flowId.value || loading.value) return;
68
- try {
69
- const response = await $fetch(
70
- `/api/_flows/${encodeURIComponent(flowId.value)}/runs`,
71
- {
72
- query: {
73
- limit: Math.max(items.value.length, 10),
74
- // Fetch at least as many as we have loaded
75
- offset: 0,
76
- _t: Date.now()
77
- }
78
- }
79
- );
80
- if (response.items.length === 0) return;
81
- const latestRunId = response.items[0].id;
82
- const updatedItems = [...items.value];
83
- const newRuns = [];
84
- for (const freshRun of response.items) {
85
- const existingIndex = updatedItems.findIndex((r) => r.id === freshRun.id);
86
- if (existingIndex >= 0) {
87
- updatedItems[existingIndex] = freshRun;
88
- } else {
89
- newRuns.push(freshRun);
90
- }
91
- }
92
- if (newRuns.length > 0) {
93
- items.value = [...newRuns, ...updatedItems];
94
- newestRunId.value = latestRunId;
95
- } else {
96
- items.value = updatedItems;
97
- }
98
- total.value = response.total;
99
- if (!newestRunId.value) {
100
- newestRunId.value = latestRunId;
101
- }
102
- } catch (err) {
103
- console.error("[useFlowRunsInfinite] checkForNewRuns error:", err);
104
- }
105
- };
106
- watch(flowId, (newFlow, oldFlow) => {
107
- if (import.meta.client && newFlow && newFlow !== oldFlow) {
108
- refresh();
109
- }
110
- }, { immediate: true });
111
- return {
112
- items: computed(() => items.value),
113
- total: computed(() => total.value),
114
- loading: computed(() => loading.value),
115
- hasMore: computed(() => hasMore.value),
116
- error: computed(() => error.value),
117
- loadMore,
118
- refresh,
119
- checkForNewRuns,
120
- // For polling - prepends new runs without resetting
121
- reset
122
- };
123
- }
@@ -1,9 +0,0 @@
1
- import { type Ref } from '#imports';
2
- /**
3
- * Composable for auto-polling flow runs list
4
- * Polls continuously to keep the list fresh
5
- */
6
- export declare function useFlowRunsPolling(refresh: () => Promise<void>, shouldPoll: Ref<boolean>, intervalMs?: number): {
7
- pause: () => void;
8
- resume: () => void;
9
- };
@@ -1,33 +0,0 @@
1
- import { watch, onBeforeUnmount } from "#imports";
2
- export function useFlowRunsPolling(refresh, shouldPoll, intervalMs = 3e3) {
3
- let intervalId = null;
4
- const pause = () => {
5
- if (intervalId) {
6
- clearInterval(intervalId);
7
- intervalId = null;
8
- }
9
- };
10
- const resume = () => {
11
- if (!intervalId) {
12
- intervalId = setInterval(async () => {
13
- if (shouldPoll.value) {
14
- await refresh();
15
- }
16
- }, intervalMs);
17
- }
18
- };
19
- watch(shouldPoll, (should) => {
20
- if (should) {
21
- resume();
22
- } else {
23
- pause();
24
- }
25
- }, { immediate: true });
26
- onBeforeUnmount(() => {
27
- pause();
28
- });
29
- return {
30
- pause,
31
- resume
32
- };
33
- }