@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.
- package/dist/styles/qdadm.css +1 -1
- package/package.json +8 -2
- package/src/components/display/EmailPreview.vue +227 -0
- package/src/components/display/EntityTimeline.vue +408 -0
- package/src/components/display/LogStream.vue +364 -0
- package/src/components/display/PayloadDiff.vue +236 -0
- package/src/components/display/PayloadViewer.vue +246 -0
- package/src/components/index.ts +8 -0
- package/src/components/layout/AppLayout.vue +31 -11
- package/src/components/layout/defaults/DefaultMenu.vue +23 -11
- package/src/components/tasks/TaskDialog.vue +95 -0
- package/src/components/tasks/TaskHost.vue +45 -0
- package/src/components/tasks/TaskTray.vue +54 -0
- package/src/composables/index.ts +11 -0
- package/src/composables/useListPage.filters.ts +26 -1
- package/src/composables/useLogStream.ts +264 -0
- package/src/composables/useNavigation.ts +39 -3
- package/src/email/index.ts +46 -0
- package/src/entity/storage/SdkStorage.ts +53 -1
- package/src/entity/storage/index.ts +2 -0
- package/src/i18n/defaults/core.en.generated.ts +85 -0
- package/src/i18n/defaults/core.en.yml +85 -0
- package/src/i18n/defaults/core.fr.generated.ts +85 -0
- package/src/i18n/defaults/core.fr.yml +85 -0
- package/src/index.ts +21 -0
- package/src/kernel/Kernel.vue.ts +9 -2
- package/src/kernel/KernelContext.types.ts +11 -1
- package/src/module/moduleRegistry.ts +17 -1
- package/src/styles/_email.scss +188 -0
- package/src/styles/_log-stream.scss +167 -0
- package/src/styles/_payload.scss +312 -0
- package/src/styles/_sidebar.scss +8 -0
- package/src/styles/_tasks.scss +212 -0
- package/src/styles/_timeline.scss +229 -0
- package/src/styles/index.scss +5 -0
- package/src/tasks/taskStore.ts +460 -0
- package/src/tasks/useTaskRunner.ts +52 -0
- package/src/utils/ansi.ts +127 -0
- package/src/utils/email.ts +270 -0
- package/src/utils/index.ts +32 -0
- 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>
|