@quazardous/qdadm 2.31.0 → 2.33.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 (41) hide show
  1. package/dist/styles/qdadm.css +1 -1
  2. package/package.json +8 -2
  3. package/src/components/display/EmailPreview.vue +227 -0
  4. package/src/components/display/EntityTimeline.vue +408 -0
  5. package/src/components/display/LogStream.vue +364 -0
  6. package/src/components/display/PayloadDiff.vue +236 -0
  7. package/src/components/display/PayloadViewer.vue +246 -0
  8. package/src/components/index.ts +8 -0
  9. package/src/components/layout/AppLayout.vue +31 -11
  10. package/src/components/layout/defaults/DefaultMenu.vue +23 -11
  11. package/src/components/tasks/TaskDialog.vue +95 -0
  12. package/src/components/tasks/TaskHost.vue +45 -0
  13. package/src/components/tasks/TaskTray.vue +54 -0
  14. package/src/composables/index.ts +11 -0
  15. package/src/composables/useListPage.filters.ts +26 -1
  16. package/src/composables/useLogStream.ts +264 -0
  17. package/src/composables/useNavigation.ts +39 -3
  18. package/src/email/index.ts +46 -0
  19. package/src/entity/storage/SdkStorage.ts +53 -1
  20. package/src/entity/storage/index.ts +2 -0
  21. package/src/i18n/defaults/core.en.generated.ts +85 -0
  22. package/src/i18n/defaults/core.en.yml +85 -0
  23. package/src/i18n/defaults/core.fr.generated.ts +85 -0
  24. package/src/i18n/defaults/core.fr.yml +85 -0
  25. package/src/index.ts +21 -0
  26. package/src/kernel/Kernel.vue.ts +9 -2
  27. package/src/kernel/KernelContext.types.ts +11 -1
  28. package/src/module/moduleRegistry.ts +17 -1
  29. package/src/styles/_email.scss +188 -0
  30. package/src/styles/_log-stream.scss +167 -0
  31. package/src/styles/_payload.scss +312 -0
  32. package/src/styles/_sidebar.scss +8 -0
  33. package/src/styles/_tasks.scss +212 -0
  34. package/src/styles/_timeline.scss +229 -0
  35. package/src/styles/index.scss +5 -0
  36. package/src/tasks/taskStore.ts +460 -0
  37. package/src/tasks/useTaskRunner.ts +52 -0
  38. package/src/utils/ansi.ts +127 -0
  39. package/src/utils/email.ts +270 -0
  40. package/src/utils/index.ts +32 -0
  41. package/src/utils/payload.ts +480 -0
@@ -0,0 +1,408 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * EntityTimeline - what happened to one entity, across several actors (#4111)
4
+ *
5
+ * Usage:
6
+ * <EntityTimeline :events="order.events" :actors="ACTORS" />
7
+ * <EntityTimeline :source="{ entity: 'order_events', filters: { order_id: id } }" :actors="ACTORS"
8
+ * :gaps="[{ label: 'order → ready to ship', from: (e) => e.kind === 'imported', to: (e) => e.kind === 'ready' }]">
9
+ * <template #detail="{ event }"><PayloadViewer :body="event.payload" /></template>
10
+ * </EntityTimeline>
11
+ *
12
+ * One column, each event marked with its actor's colour and icon. Lanes side
13
+ * by side read badly once the screen narrows or the timeline sits in a
14
+ * dialog, and an actor filter answers "what did the carrier do" better.
15
+ *
16
+ * In `source` mode the events are read through the entity's manager, and
17
+ * reread when the entity changes elsewhere (live entities): new events flash.
18
+ */
19
+ import { computed, inject, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue'
20
+ import { routeLocationKey, routerKey, type RouteLocationNormalizedLoaded, type Router } from 'vue-router'
21
+ import QdButton from '../base/QdButton.vue'
22
+ import { useI18n } from '../../i18n/useI18n'
23
+ import { useLiveEntity } from '../../composables/useLiveEntity'
24
+
25
+ export interface TimelineActor {
26
+ label?: string
27
+ /** Any CSS colour. */
28
+ color?: string
29
+ /** A PrimeIcons class, e.g. `pi pi-truck`. */
30
+ icon?: string
31
+ }
32
+
33
+ export interface TimelineItem {
34
+ id?: string | number
35
+ at: string | number | Date
36
+ actor?: string | null
37
+ title: string
38
+ kind?: string | null
39
+ severity?: 'success' | 'info' | 'warn' | 'danger' | 'secondary' | null
40
+ }
41
+
42
+ export interface TimelineGap {
43
+ label: string
44
+ /** The event the duration starts from: the first one that matches. */
45
+ from: (event: unknown) => boolean
46
+ /** The event it ends at: the first matching one after `from`. */
47
+ to: (event: unknown) => boolean
48
+ }
49
+
50
+ export interface TimelineSource {
51
+ entity: string
52
+ filters?: Record<string, unknown>
53
+ /** How many events to read. Default 500. */
54
+ limit?: number
55
+ }
56
+
57
+ interface Props {
58
+ events?: unknown[] | null
59
+ source?: TimelineSource | null
60
+ /** Turn an app event into what the timeline shows. Default reads `at`/`created_at`, `actor`, `title`/`label`/`message`. */
61
+ map?: ((event: unknown) => TimelineItem) | null
62
+ actors?: Record<string, TimelineActor>
63
+ order?: 'asc' | 'desc'
64
+ /** `true`: time since the previous event. A list: named durations between two events. */
65
+ gaps?: boolean | TimelineGap[]
66
+ /** Keep the actor filter in the URL under this query key. */
67
+ queryKey?: string | null
68
+ /** Scroll inside the timeline; without it the page scrolls. */
69
+ height?: string | null
70
+ }
71
+
72
+ const props = withDefaults(defineProps<Props>(), {
73
+ events: null,
74
+ source: null,
75
+ map: null,
76
+ actors: () => ({}),
77
+ order: 'asc',
78
+ gaps: false,
79
+ queryKey: null,
80
+ height: null,
81
+ })
82
+
83
+ const { t, locale } = useI18n()
84
+ const slots = useSlots()
85
+ const orchestrator = inject<{ get: (name: string) => unknown } | null>('qdadmOrchestrator', null)
86
+ const router = inject<Router | null>(routerKey, null)
87
+ const route = inject<RouteLocationNormalizedLoaded | null>(routeLocationKey, null)
88
+
89
+ // --- Loading ---
90
+
91
+ const loaded = ref<unknown[]>([])
92
+ const loading = ref(false)
93
+ const loadError = ref<string | null>(null)
94
+ const freshIds = ref(new Set<string>())
95
+ let seenIds: Set<string> | null = null
96
+ let loads = 0
97
+ const now = ref(Date.now())
98
+ let flashTimer: ReturnType<typeof setTimeout> | null = null
99
+
100
+ function managerOf(): { list: (p: Record<string, unknown>) => Promise<{ items: unknown[] }> } | null {
101
+ if (!props.source || !orchestrator) return null
102
+ return orchestrator.get(props.source.entity) as { list: (p: Record<string, unknown>) => Promise<{ items: unknown[] }> }
103
+ }
104
+
105
+ async function load(): Promise<void> {
106
+ const manager = managerOf()
107
+ if (!manager || !props.source) return
108
+ loading.value = true
109
+ try {
110
+ const { items } = await manager.list({ filters: props.source.filters ?? {}, page_size: props.source.limit ?? 500 })
111
+ loads++
112
+ loaded.value = items
113
+ loadError.value = null
114
+ } catch (err) {
115
+ loadError.value = err instanceof Error ? err.message : String(err)
116
+ } finally {
117
+ loading.value = false
118
+ }
119
+ }
120
+
121
+ if (props.source) {
122
+ void load()
123
+ useLiveEntity(props.source.entity, managerOf(), load)
124
+ watch(() => [props.source?.entity, JSON.stringify(props.source?.filters ?? {})], () => {
125
+ seenIds = null
126
+ void load()
127
+ })
128
+ }
129
+
130
+ // --- Normalising ---
131
+
132
+ function defaultMap(raw: unknown): TimelineItem {
133
+ const e = (raw ?? {}) as Record<string, unknown>
134
+ return {
135
+ id: e.id as string | number | undefined,
136
+ at: (e.at ?? e.created_at ?? e.timestamp ?? e.date) as string,
137
+ actor: (e.actor as string) ?? null,
138
+ title: String(e.title ?? e.label ?? e.message ?? e.kind ?? ''),
139
+ kind: (e.kind as string) ?? null,
140
+ severity: (e.severity as TimelineItem['severity']) ?? null,
141
+ }
142
+ }
143
+
144
+ interface Entry {
145
+ key: string
146
+ raw: unknown
147
+ item: TimelineItem
148
+ time: number
149
+ }
150
+
151
+ /** Chronological, whatever the display order. */
152
+ const chronological = computed<Entry[]>(() => {
153
+ const mapper = props.map ?? defaultMap
154
+ const list = props.source ? loaded.value : props.events ?? []
155
+ return list
156
+ .map((raw, i) => {
157
+ const item = mapper(raw)
158
+ const time = new Date(item.at).getTime()
159
+ return { key: item.id !== undefined ? String(item.id) : `${time}:${i}`, raw, item, time }
160
+ })
161
+ .filter((e) => !Number.isNaN(e.time))
162
+ .sort((a, b) => a.time - b.time)
163
+ })
164
+
165
+ // New events since the last read flash for a moment; the first read does not.
166
+ watch(chronological, (entries) => {
167
+ now.value = Date.now()
168
+ const keys = new Set(entries.map((e) => e.key))
169
+ // In source mode the list starts empty: the first read is not news either.
170
+ if (seenIds && (!props.source || loads > 1)) {
171
+ const fresh = entries.filter((e) => !seenIds!.has(e.key)).map((e) => e.key)
172
+ if (fresh.length) {
173
+ freshIds.value = new Set(fresh)
174
+ if (flashTimer) clearTimeout(flashTimer)
175
+ flashTimer = setTimeout(() => (freshIds.value = new Set()), 2500)
176
+ noteNewEvents(fresh.length)
177
+ }
178
+ }
179
+ seenIds = keys
180
+ }, { immediate: true })
181
+
182
+ // --- Actor filter ---
183
+
184
+ const actorKeys = computed(() => {
185
+ const seen = new Set<string>()
186
+ for (const e of chronological.value) if (e.item.actor) seen.add(e.item.actor)
187
+ return [...seen]
188
+ })
189
+
190
+ function readQuery(): Set<string> {
191
+ if (!props.queryKey || !route) return new Set()
192
+ const v = route.query[props.queryKey]
193
+ const s = Array.isArray(v) ? v.join(',') : v ?? ''
194
+ return new Set(String(s).split(',').filter(Boolean))
195
+ }
196
+
197
+ const hiddenActors = ref<Set<string>>(readQuery())
198
+
199
+ function toggleActor(actor: string): void {
200
+ const next = new Set(hiddenActors.value)
201
+ if (next.has(actor)) next.delete(actor)
202
+ else next.add(actor)
203
+ hiddenActors.value = next
204
+ if (props.queryKey && router && route) {
205
+ const query = { ...route.query }
206
+ if (next.size) query[props.queryKey] = [...next].join(',')
207
+ else delete query[props.queryKey]
208
+ void router.replace({ query })
209
+ }
210
+ }
211
+
212
+ const currentOrder = ref(props.order)
213
+ watch(() => props.order, (o) => (currentOrder.value = o))
214
+
215
+ const actorOf = (key: string | null | undefined): TimelineActor => (key ? props.actors[key] ?? {} : {})
216
+ const actorLabel = (key: string) => actorOf(key).label ?? key
217
+
218
+ // --- Gaps ---
219
+
220
+ const DAY = 86_400_000
221
+
222
+ /** "17 h", "2 h 5 min", "45 s": the largest unit, and the next one when it is not zero. */
223
+ function duration(ms: number): string {
224
+ const units: Array<[number, string]> = [[DAY, 'd'], [3_600_000, 'h'], [60_000, 'min'], [1000, 's']]
225
+ const abs = Math.abs(ms)
226
+ const i = units.findIndex(([size]) => abs >= size)
227
+ if (i === -1) return '0 s'
228
+ const [size, unit] = units[i]!
229
+ const first = Math.floor(abs / size)
230
+ const next = units[i + 1]
231
+ const second = next ? Math.floor((abs - first * size) / next[0]) : 0
232
+ return second > 0 && next ? `${first} ${unit} ${second} ${next[1]}` : `${first} ${unit}`
233
+ }
234
+
235
+ const namedGaps = computed(() => {
236
+ if (!Array.isArray(props.gaps)) return []
237
+ return props.gaps.map((gap) => {
238
+ const start = chronological.value.find((e) => gap.from(e.raw))
239
+ const end = start && chronological.value.find((e) => e.time >= start.time && e !== start && gap.to(e.raw))
240
+ return { label: gap.label, value: start && end ? duration(end.time - start.time) : null }
241
+ })
242
+ })
243
+
244
+ // --- Rows ---
245
+
246
+ interface Row {
247
+ type: 'day' | 'event'
248
+ key: string
249
+ day?: string
250
+ entry?: Entry
251
+ /** Time since the previous visible event, chronologically. */
252
+ since?: string | null
253
+ }
254
+
255
+ const dayFormat = computed(() => new Intl.DateTimeFormat(locale.value, { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' }))
256
+ const timeFormat = computed(() => new Intl.DateTimeFormat(locale.value, { hour: '2-digit', minute: '2-digit', second: '2-digit' }))
257
+ const fullFormat = computed(() => new Intl.DateTimeFormat(locale.value, { dateStyle: 'full', timeStyle: 'medium' }))
258
+
259
+ const rows = computed<Row[]>(() => {
260
+ const visible = chronological.value.filter((e) => !e.item.actor || !hiddenActors.value.has(e.item.actor))
261
+ const withSince = visible.map((entry, i) => ({
262
+ entry,
263
+ since: props.gaps === true && i > 0 ? duration(entry.time - visible[i - 1]!.time) : null,
264
+ }))
265
+ const ordered = currentOrder.value === 'desc' ? [...withSince].reverse() : withSince
266
+ const out: Row[] = []
267
+ let lastDay = ''
268
+ for (const { entry, since } of ordered) {
269
+ const day = dayFormat.value.format(entry.time)
270
+ if (day !== lastDay) {
271
+ out.push({ type: 'day', key: `day:${day}`, day })
272
+ lastDay = day
273
+ }
274
+ out.push({ type: 'event', key: entry.key, entry, since })
275
+ }
276
+ return out
277
+ })
278
+
279
+ // --- Relative time ---
280
+
281
+ let ticker: ReturnType<typeof setInterval> | null = null
282
+ onMounted(() => {
283
+ ticker = setInterval(() => (now.value = Date.now()), 30_000)
284
+ })
285
+
286
+ function relative(time: number): string {
287
+ const rtf = new Intl.RelativeTimeFormat(locale.value, { numeric: 'auto' })
288
+ // A clock a little behind the event's (server time, or `now` not ticked
289
+ // yet) must not read "in 2 seconds".
290
+ const diff = Math.min(0, time - now.value) || 0
291
+ const abs = Math.abs(diff)
292
+ if (abs < 60_000) return rtf.format(Math.round(diff / 1000), 'second')
293
+ if (abs < 3_600_000) return rtf.format(Math.round(diff / 60_000), 'minute')
294
+ if (abs < DAY) return rtf.format(Math.round(diff / 3_600_000), 'hour')
295
+ return rtf.format(Math.round(diff / DAY), 'day')
296
+ }
297
+
298
+ // --- Expand ---
299
+
300
+ const expandable = computed(() => !!slots.detail)
301
+ const expanded = ref(new Set<string>())
302
+ function toggle(key: string): void {
303
+ if (!expandable.value) return
304
+ const next = new Set(expanded.value)
305
+ if (next.has(key)) next.delete(key)
306
+ else next.add(key)
307
+ expanded.value = next
308
+ }
309
+
310
+ // --- New events while scrolled away ---
311
+
312
+ const scroller = ref<HTMLElement | null>(null)
313
+ const unseen = ref(0)
314
+
315
+ /** Is the end where new events land (top in desc, bottom in asc) in view? */
316
+ function newestInView(): boolean {
317
+ const el = scroller.value
318
+ if (!el || !props.height) return true
319
+ return currentOrder.value === 'desc' ? el.scrollTop < 40 : el.scrollTop + el.clientHeight >= el.scrollHeight - 40
320
+ }
321
+
322
+ function noteNewEvents(count: number): void {
323
+ if (!newestInView()) unseen.value += count
324
+ else if (currentOrder.value === 'asc') queueMicrotask(() => requestAnimationFrame(goToNewest))
325
+ }
326
+
327
+ function goToNewest(): void {
328
+ const el = scroller.value
329
+ unseen.value = 0
330
+ if (!el) return
331
+ el.scrollTop = currentOrder.value === 'desc' ? 0 : el.scrollHeight
332
+ }
333
+
334
+ function onScroll(): void {
335
+ if (newestInView()) unseen.value = 0
336
+ }
337
+
338
+ onBeforeUnmount(() => {
339
+ if (ticker) clearInterval(ticker)
340
+ if (flashTimer) clearTimeout(flashTimer)
341
+ })
342
+
343
+ defineExpose({ reload: load })
344
+ </script>
345
+
346
+ <template>
347
+ <div class="qd-timeline">
348
+ <div class="qd-timeline-toolbar">
349
+ <div v-if="actorKeys.length > 1" class="qd-timeline-actors" role="group" :aria-label="t('core.timeline.actors')">
350
+ <button v-for="key in actorKeys" :key="key" type="button" class="qd-timeline-chip"
351
+ :class="{ 'qd-timeline-chip--off': hiddenActors.has(key) }" :aria-pressed="!hiddenActors.has(key)"
352
+ :style="{ '--qd-actor': actorOf(key).color ?? 'var(--p-primary-color)' }" @click="toggleActor(key)">
353
+ <i :class="actorOf(key).icon ?? 'pi pi-circle-fill'" />
354
+ {{ actorLabel(key) }}
355
+ </button>
356
+ </div>
357
+ <QdButton class="qd-timeline-order" size="small" text rounded
358
+ :icon="currentOrder === 'asc' ? 'pi pi-sort-amount-up-alt' : 'pi pi-sort-amount-down'"
359
+ :aria-label="currentOrder === 'asc' ? t('core.timeline.oldestFirst') : t('core.timeline.newestFirst')"
360
+ v-tooltip.top="currentOrder === 'asc' ? t('core.timeline.oldestFirst') : t('core.timeline.newestFirst')"
361
+ @click="currentOrder = currentOrder === 'asc' ? 'desc' : 'asc'" />
362
+ <div v-if="namedGaps.length" class="qd-timeline-gaps">
363
+ <span v-for="gap in namedGaps" :key="gap.label" class="qd-timeline-gap-chip">
364
+ <i class="pi pi-clock" /> {{ gap.label }}: <strong>{{ gap.value ?? '—' }}</strong>
365
+ </span>
366
+ </div>
367
+ </div>
368
+
369
+ <div v-if="loadError" class="qd-timeline-error">{{ loadError }}</div>
370
+
371
+ <div ref="scroller" class="qd-timeline-body" :style="height ? { maxHeight: height, overflow: 'auto' } : undefined" @scroll="onScroll">
372
+ <div v-if="!rows.length" class="qd-timeline-empty">
373
+ {{ loading ? t('core.timeline.loading') : t('core.timeline.empty') }}
374
+ </div>
375
+ <ol v-else class="qd-timeline-list">
376
+ <template v-for="row in rows" :key="row.key">
377
+ <li v-if="row.type === 'day'" class="qd-timeline-day">{{ row.day }}</li>
378
+ <li v-else class="qd-timeline-event" :class="[
379
+ row.entry!.item.severity ? `qd-timeline-event--${row.entry!.item.severity}` : '',
380
+ { 'qd-timeline-event--fresh': freshIds.has(row.key), 'qd-timeline-event--open': expanded.has(row.key) },
381
+ ]" :style="{ '--qd-actor': actorOf(row.entry!.item.actor).color ?? 'var(--p-surface-400)' }"
382
+ :data-actor="row.entry!.item.actor ?? undefined">
383
+ <span v-if="row.since" class="qd-timeline-since">+{{ row.since }}</span>
384
+ <span class="qd-timeline-marker"><i :class="actorOf(row.entry!.item.actor).icon ?? 'pi pi-circle-fill'" /></span>
385
+ <div class="qd-timeline-content">
386
+ <component :is="expandable ? 'button' : 'div'" :type="expandable ? 'button' : undefined" class="qd-timeline-head"
387
+ :aria-expanded="expandable ? expanded.has(row.key) : undefined" @click="toggle(row.key)">
388
+ <span class="qd-timeline-title">{{ row.entry!.item.title }}</span>
389
+ <span v-if="row.entry!.item.actor" class="qd-timeline-actor">{{ actorLabel(row.entry!.item.actor) }}</span>
390
+ <time class="qd-timeline-time" :datetime="new Date(row.entry!.time).toISOString()" :title="fullFormat.format(row.entry!.time)">
391
+ {{ timeFormat.format(row.entry!.time) }} · {{ relative(row.entry!.time) }}
392
+ </time>
393
+ <i v-if="expandable" class="qd-timeline-chevron pi" :class="expanded.has(row.key) ? 'pi-chevron-up' : 'pi-chevron-down'" />
394
+ </component>
395
+ <div v-if="expandable && expanded.has(row.key)" class="qd-timeline-detail">
396
+ <div class="qd-timeline-absolute">{{ fullFormat.format(row.entry!.time) }}</div>
397
+ <slot name="detail" :event="row.entry!.raw" :item="row.entry!.item" />
398
+ </div>
399
+ </div>
400
+ </li>
401
+ </template>
402
+ </ol>
403
+ </div>
404
+
405
+ <QdButton v-if="unseen > 0" class="qd-timeline-new" size="small" rounded :icon="currentOrder === 'desc' ? 'pi pi-arrow-up' : 'pi pi-arrow-down'"
406
+ :label="t('core.timeline.newEvents', { count: unseen })" @click="goToNewest" />
407
+ </div>
408
+ </template>