@quazardous/qdadm 2.32.0 → 2.33.1
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 +9 -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 +8 -0
- 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/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/Module.ts +3 -2
- package/src/modules/debug/DebugModule.ts +3 -1
- package/src/modules/debug/styles.generated.css +2895 -0
- package/src/notifications/NotificationModule.ts +1 -1
- package/src/notifications/{styles.scss → styles.css} +5 -5
- 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/_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,364 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* LogStream - live tail of a backend log (#4110)
|
|
4
|
+
*
|
|
5
|
+
* Shows a task's log as the worker writes it, then the whole log once done.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* <LogStream :source="{ poll: (from) => fetchLog(task.id, from) }" error-pattern="ERROR|FATAL" />
|
|
9
|
+
* <LogStream :stream="myStream" height="60vh" /> // a useLogStream() the page already holds
|
|
10
|
+
*
|
|
11
|
+
* - follow: on by default; scrolling up pauses it, scrolling back to the end
|
|
12
|
+
* (or End, or the "new lines" button) resumes it.
|
|
13
|
+
* - Without wrapping, only the rows on screen are rendered (fixed line
|
|
14
|
+
* height), so a log of tens of thousands of lines scrolls freely. With
|
|
15
|
+
* wrapping, rows have no fixed height: every row is in the DOM and the
|
|
16
|
+
* browser skips painting the ones off screen (content-visibility).
|
|
17
|
+
*/
|
|
18
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
19
|
+
import InputText from 'primevue/inputtext'
|
|
20
|
+
import QdButton from '../base/QdButton.vue'
|
|
21
|
+
import { useI18n } from '../../i18n/useI18n'
|
|
22
|
+
import { ansiStyleToCss } from '../../utils/ansi'
|
|
23
|
+
import { useLogStream, type LogLine, type LogSource, type UseLogStreamReturn } from '../../composables/useLogStream'
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
/** Where the lines come from; ignored when `stream` is given. */
|
|
27
|
+
source?: LogSource | null
|
|
28
|
+
/** A stream the page already holds (shared with another view). */
|
|
29
|
+
stream?: UseLogStreamReturn | null
|
|
30
|
+
/** Lines matching it are marked, counted, and reachable from the toolbar. */
|
|
31
|
+
errorPattern?: RegExp | string | null
|
|
32
|
+
/** A timestamp at the start of a line, shown dimmed. */
|
|
33
|
+
timestampPattern?: RegExp | string | null
|
|
34
|
+
height?: string
|
|
35
|
+
/** Row height in px, without wrapping. Matches the stylesheet's line-height. */
|
|
36
|
+
lineHeight?: number
|
|
37
|
+
wrap?: boolean
|
|
38
|
+
follow?: boolean
|
|
39
|
+
lineNumbers?: boolean
|
|
40
|
+
filename?: string
|
|
41
|
+
/** Download with the ANSI escapes kept. */
|
|
42
|
+
downloadRaw?: boolean
|
|
43
|
+
maxLines?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
47
|
+
source: null,
|
|
48
|
+
stream: null,
|
|
49
|
+
errorPattern: null,
|
|
50
|
+
timestampPattern: null,
|
|
51
|
+
height: '24rem',
|
|
52
|
+
lineHeight: 20,
|
|
53
|
+
wrap: false,
|
|
54
|
+
follow: true,
|
|
55
|
+
lineNumbers: true,
|
|
56
|
+
filename: 'log.txt',
|
|
57
|
+
downloadRaw: false,
|
|
58
|
+
maxLines: 100_000,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const { t } = useI18n()
|
|
62
|
+
|
|
63
|
+
const log: UseLogStreamReturn =
|
|
64
|
+
props.stream ??
|
|
65
|
+
useLogStream(props.source ?? { lines: [] }, { errorPattern: props.errorPattern, maxLines: props.maxLines })
|
|
66
|
+
|
|
67
|
+
const viewport = ref<HTMLElement | null>(null)
|
|
68
|
+
const scrollTop = ref(0)
|
|
69
|
+
const viewportHeight = ref(0)
|
|
70
|
+
const wrapping = ref(props.wrap)
|
|
71
|
+
const following = ref(props.follow)
|
|
72
|
+
const unseen = ref(0)
|
|
73
|
+
const query = ref('')
|
|
74
|
+
const onlyMatches = ref(false)
|
|
75
|
+
const currentMatch = ref(-1)
|
|
76
|
+
const currentError = ref(-1)
|
|
77
|
+
const OVERSCAN = 20
|
|
78
|
+
|
|
79
|
+
// A stream passed in may have been built without the pattern: flag here too.
|
|
80
|
+
const errorRe = computed(() => {
|
|
81
|
+
const p = props.errorPattern
|
|
82
|
+
if (!p) return null
|
|
83
|
+
return typeof p === 'string' ? new RegExp(p, 'i') : new RegExp(p.source, p.flags.replace(/[gy]/g, ''))
|
|
84
|
+
})
|
|
85
|
+
const timestampRe = computed(() => {
|
|
86
|
+
const p = props.timestampPattern
|
|
87
|
+
if (!p) return null
|
|
88
|
+
const source = typeof p === 'string' ? p : p.source
|
|
89
|
+
return new RegExp(source.startsWith('^') ? source : `^(?:${source})`, typeof p === 'string' ? '' : p.flags.replace(/[gy]/g, ''))
|
|
90
|
+
})
|
|
91
|
+
const isError = (line: LogLine) => line.error || (errorRe.value ? errorRe.value.test(line.text) : false)
|
|
92
|
+
|
|
93
|
+
const needle = computed(() => query.value.trim().toLowerCase())
|
|
94
|
+
const isMatch = (line: LogLine) => needle.value !== '' && line.text.toLowerCase().includes(needle.value)
|
|
95
|
+
|
|
96
|
+
/** The lines on show: all of them, or the matching ones. */
|
|
97
|
+
const view = computed<LogLine[]>(() =>
|
|
98
|
+
onlyMatches.value && needle.value ? log.lines.value.filter(isMatch) : log.lines.value
|
|
99
|
+
)
|
|
100
|
+
const matchIndexes = computed(() => {
|
|
101
|
+
if (!needle.value) return []
|
|
102
|
+
const out: number[] = []
|
|
103
|
+
view.value.forEach((line, i) => isMatch(line) && out.push(i))
|
|
104
|
+
return out
|
|
105
|
+
})
|
|
106
|
+
const errorIndexes = computed(() => {
|
|
107
|
+
const out: number[] = []
|
|
108
|
+
view.value.forEach((line, i) => isError(line) && out.push(i))
|
|
109
|
+
return out
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const visibleRows = computed(() => Math.ceil((viewportHeight.value || 400) / props.lineHeight))
|
|
113
|
+
// Clamped: when the view shrinks (only matches), scrollTop can still point
|
|
114
|
+
// past its end until the browser catches up.
|
|
115
|
+
const windowStart = computed(() => {
|
|
116
|
+
if (wrapping.value) return 0
|
|
117
|
+
const fromScroll = Math.floor(scrollTop.value / props.lineHeight) - OVERSCAN
|
|
118
|
+
return Math.max(0, Math.min(fromScroll, view.value.length - visibleRows.value - OVERSCAN))
|
|
119
|
+
})
|
|
120
|
+
const windowRows = computed(() => {
|
|
121
|
+
if (wrapping.value) return view.value
|
|
122
|
+
const visible = visibleRows.value
|
|
123
|
+
return view.value.slice(windowStart.value, windowStart.value + visible + OVERSCAN * 2)
|
|
124
|
+
})
|
|
125
|
+
const totalHeight = computed(() => view.value.length * props.lineHeight)
|
|
126
|
+
|
|
127
|
+
interface Piece {
|
|
128
|
+
text: string
|
|
129
|
+
style: Record<string, string>
|
|
130
|
+
cls: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A row's styled segments, cut again where the timestamp and the matches start and end. */
|
|
134
|
+
function pieces(line: LogLine): Piece[] {
|
|
135
|
+
const ranges: Array<[number, number, string]> = []
|
|
136
|
+
const ts = timestampRe.value?.exec(line.text)
|
|
137
|
+
if (ts && ts[0].length > 0) ranges.push([0, ts[0].length, 'qd-log-ts'])
|
|
138
|
+
if (needle.value) {
|
|
139
|
+
const lower = line.text.toLowerCase()
|
|
140
|
+
for (let at = lower.indexOf(needle.value); at !== -1; at = lower.indexOf(needle.value, at + needle.value.length)) {
|
|
141
|
+
ranges.push([at, at + needle.value.length, 'qd-log-mark'])
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const out: Piece[] = []
|
|
145
|
+
let offset = 0
|
|
146
|
+
for (const seg of line.segments) {
|
|
147
|
+
const style = ansiStyleToCss(seg.style)
|
|
148
|
+
const end = offset + seg.text.length
|
|
149
|
+
const cuts = new Set([offset, end])
|
|
150
|
+
for (const [a, b] of ranges) {
|
|
151
|
+
if (a > offset && a < end) cuts.add(a)
|
|
152
|
+
if (b > offset && b < end) cuts.add(b)
|
|
153
|
+
}
|
|
154
|
+
const sorted = [...cuts].sort((x, y) => x - y)
|
|
155
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
156
|
+
const a = sorted[i]!
|
|
157
|
+
const b = sorted[i + 1]!
|
|
158
|
+
const cls = ranges.filter(([ra, rb]) => ra <= a && rb >= b).map((r) => r[2]).join(' ')
|
|
159
|
+
out.push({ text: seg.text.slice(a - offset, b - offset), style, cls })
|
|
160
|
+
}
|
|
161
|
+
offset = end
|
|
162
|
+
}
|
|
163
|
+
return out
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- Scrolling and follow ---
|
|
167
|
+
|
|
168
|
+
// No "programmatic scroll" flag: setting scrollTop to where it already is
|
|
169
|
+
// fires no scroll event, so such a flag would stay up and swallow the user's
|
|
170
|
+
// next scroll. Each scroll just asks whether the end is in view.
|
|
171
|
+
// Where the last scroll to the end left the viewport. Below it, the user has
|
|
172
|
+
// scrolled up; at it, nothing moved — including a log mounted hidden (a
|
|
173
|
+
// dialog not laid out yet), whose first scroll to the end did nothing.
|
|
174
|
+
let lastAutoTop = 0
|
|
175
|
+
|
|
176
|
+
function scrollToEnd(): void {
|
|
177
|
+
const el = viewport.value
|
|
178
|
+
if (!el) return
|
|
179
|
+
el.scrollTop = el.scrollHeight
|
|
180
|
+
scrollTop.value = el.scrollTop
|
|
181
|
+
lastAutoTop = el.scrollTop
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function onScroll(): void {
|
|
185
|
+
const el = viewport.value
|
|
186
|
+
if (!el) return
|
|
187
|
+
scrollTop.value = el.scrollTop
|
|
188
|
+
const atEnd = el.scrollTop + el.clientHeight >= el.scrollHeight - props.lineHeight
|
|
189
|
+
following.value = atEnd
|
|
190
|
+
if (atEnd) unseen.value = 0
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function resume(): void {
|
|
194
|
+
following.value = true
|
|
195
|
+
unseen.value = 0
|
|
196
|
+
void nextTick(scrollToEnd)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function onKeydown(event: KeyboardEvent): void {
|
|
200
|
+
if (event.key === 'End') {
|
|
201
|
+
event.preventDefault()
|
|
202
|
+
resume()
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
watch(
|
|
207
|
+
() => log.lines.value,
|
|
208
|
+
(next, prev) => {
|
|
209
|
+
const added = Math.max(0, (next.at(-1)?.n ?? 0) - (prev?.at(-1)?.n ?? 0))
|
|
210
|
+
// Asked of the DOM, still at its old height: the scroll event of a user
|
|
211
|
+
// who just scrolled up may not have arrived yet, and following on would
|
|
212
|
+
// yank them back down.
|
|
213
|
+
const el = viewport.value
|
|
214
|
+
if (following.value && el && el.scrollTop < lastAutoTop - props.lineHeight) {
|
|
215
|
+
following.value = false
|
|
216
|
+
}
|
|
217
|
+
if (following.value) void nextTick(scrollToEnd)
|
|
218
|
+
else unseen.value += added
|
|
219
|
+
}
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
/** Bring a row of the view to the middle of the viewport, and stop following. */
|
|
223
|
+
function reveal(index: number): void {
|
|
224
|
+
const el = viewport.value
|
|
225
|
+
if (!el) return
|
|
226
|
+
following.value = false
|
|
227
|
+
if (wrapping.value) {
|
|
228
|
+
void nextTick(() => {
|
|
229
|
+
el.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'center' })
|
|
230
|
+
})
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
el.scrollTop = Math.max(0, index * props.lineHeight - el.clientHeight / 2)
|
|
234
|
+
scrollTop.value = el.scrollTop
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function step(list: number[], current: { value: number }, delta: 1 | -1): void {
|
|
238
|
+
if (list.length === 0) return
|
|
239
|
+
current.value = (current.value + delta + list.length) % list.length
|
|
240
|
+
reveal(list[current.value]!)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const nextMatch = () => step(matchIndexes.value, currentMatch, 1)
|
|
244
|
+
const previousMatch = () => step(matchIndexes.value, currentMatch, -1)
|
|
245
|
+
const nextError = () => step(errorIndexes.value, currentError, 1)
|
|
246
|
+
|
|
247
|
+
watch([needle, onlyMatches], () => {
|
|
248
|
+
currentMatch.value = -1
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
function toggleWrap(): void {
|
|
252
|
+
wrapping.value = !wrapping.value
|
|
253
|
+
if (following.value) void nextTick(scrollToEnd)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function toggleFollow(): void {
|
|
257
|
+
if (following.value) following.value = false
|
|
258
|
+
else resume()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- Download ---
|
|
262
|
+
|
|
263
|
+
function download(): void {
|
|
264
|
+
const body = log.lines.value.map((l) => (props.downloadRaw ? l.raw : l.text)).join('\n')
|
|
265
|
+
const url = URL.createObjectURL(new Blob([body + '\n'], { type: 'text/plain;charset=utf-8' }))
|
|
266
|
+
const a = document.createElement('a')
|
|
267
|
+
a.href = url
|
|
268
|
+
a.download = props.filename
|
|
269
|
+
a.click()
|
|
270
|
+
setTimeout(() => URL.revokeObjectURL(url), 0)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// --- Status ---
|
|
274
|
+
|
|
275
|
+
const statusLabel = computed(() => {
|
|
276
|
+
switch (log.status.value) {
|
|
277
|
+
case 'streaming':
|
|
278
|
+
return t('core.log.streaming')
|
|
279
|
+
case 'done':
|
|
280
|
+
return t('core.log.done')
|
|
281
|
+
case 'error':
|
|
282
|
+
return t('core.log.failed', { error: log.error.value ?? '' })
|
|
283
|
+
default:
|
|
284
|
+
return t('core.log.stopped')
|
|
285
|
+
}
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
let resizeObserver: ResizeObserver | null = null
|
|
289
|
+
|
|
290
|
+
onMounted(() => {
|
|
291
|
+
const el = viewport.value
|
|
292
|
+
if (!el) return
|
|
293
|
+
viewportHeight.value = el.clientHeight
|
|
294
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
295
|
+
resizeObserver = new ResizeObserver(() => {
|
|
296
|
+
viewportHeight.value = el.clientHeight
|
|
297
|
+
})
|
|
298
|
+
resizeObserver.observe(el)
|
|
299
|
+
}
|
|
300
|
+
if (following.value) scrollToEnd()
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
onBeforeUnmount(() => resizeObserver?.disconnect())
|
|
304
|
+
|
|
305
|
+
defineExpose({ log, resume, reveal })
|
|
306
|
+
</script>
|
|
307
|
+
|
|
308
|
+
<template>
|
|
309
|
+
<div class="qd-log-stream" :class="{ 'qd-log-stream--wrap': wrapping }">
|
|
310
|
+
<div class="qd-log-toolbar">
|
|
311
|
+
<span class="qd-log-status" :class="`qd-log-status--${log.status.value}`">
|
|
312
|
+
<span class="qd-log-status-dot" />
|
|
313
|
+
{{ statusLabel }}
|
|
314
|
+
</span>
|
|
315
|
+
<span class="qd-log-count">{{ t('core.log.lines', { count: log.lines.value.length }) }}</span>
|
|
316
|
+
<span class="qd-log-spacer-x" />
|
|
317
|
+
<span class="qd-log-search">
|
|
318
|
+
<InputText v-model="query" size="small" :placeholder="t('core.log.search')" :aria-label="t('core.log.search')"
|
|
319
|
+
@keydown.enter.prevent="$event.shiftKey ? previousMatch() : nextMatch()" />
|
|
320
|
+
<span v-if="needle" class="qd-log-search-count">
|
|
321
|
+
{{ currentMatch >= 0 ? currentMatch + 1 : 0 }}/{{ matchIndexes.length }}
|
|
322
|
+
</span>
|
|
323
|
+
<QdButton icon="pi pi-chevron-up" text rounded size="small" :disabled="!matchIndexes.length"
|
|
324
|
+
:aria-label="t('core.log.previous')" v-tooltip.top="t('core.log.previous')" @click="previousMatch" />
|
|
325
|
+
<QdButton icon="pi pi-chevron-down" text rounded size="small" :disabled="!matchIndexes.length"
|
|
326
|
+
:aria-label="t('core.log.next')" v-tooltip.top="t('core.log.next')" @click="nextMatch" />
|
|
327
|
+
<QdButton icon="pi pi-filter" rounded size="small" :text="!onlyMatches" :disabled="!needle"
|
|
328
|
+
:aria-label="t('core.log.onlyMatches')" :aria-pressed="onlyMatches" v-tooltip.top="t('core.log.onlyMatches')"
|
|
329
|
+
@click="onlyMatches = !onlyMatches" />
|
|
330
|
+
</span>
|
|
331
|
+
<QdButton v-if="errorPattern" class="qd-log-errors" icon="pi pi-exclamation-triangle" size="small" text
|
|
332
|
+
:severity="errorIndexes.length ? 'danger' : 'secondary'" :disabled="!errorIndexes.length"
|
|
333
|
+
:label="t('core.log.errors', { count: errorIndexes.length })" v-tooltip.top="t('core.log.firstError')"
|
|
334
|
+
@click="nextError" />
|
|
335
|
+
<QdButton icon="pi pi-align-left" rounded size="small" :text="!wrapping" :aria-label="t('core.log.wrap')"
|
|
336
|
+
:aria-pressed="wrapping" v-tooltip.top="t('core.log.wrap')" @click="toggleWrap" />
|
|
337
|
+
<QdButton icon="pi pi-angle-double-down" rounded size="small" :text="!following" :aria-label="t('core.log.follow')"
|
|
338
|
+
:aria-pressed="following" v-tooltip.top="t('core.log.follow')" @click="toggleFollow" />
|
|
339
|
+
<QdButton icon="pi pi-download" text rounded size="small" :disabled="!log.lines.value.length"
|
|
340
|
+
:aria-label="t('core.log.download')" v-tooltip.top="t('core.log.download')" @click="download" />
|
|
341
|
+
</div>
|
|
342
|
+
|
|
343
|
+
<div ref="viewport" class="qd-log-viewport" :style="{ height, '--qd-log-line-height': `${lineHeight}px` }" tabindex="0"
|
|
344
|
+
@scroll="onScroll" @keydown="onKeydown">
|
|
345
|
+
<div v-if="!view.length" class="qd-log-empty">{{ t('core.log.empty') }}</div>
|
|
346
|
+
<div v-else :style="wrapping ? undefined : { height: `${totalHeight}px`, position: 'relative' }">
|
|
347
|
+
<div :style="wrapping ? undefined : { transform: `translateY(${windowStart * lineHeight}px)` }">
|
|
348
|
+
<div v-for="(line, i) in windowRows" :key="line.n" class="qd-log-line" :data-index="windowStart + i"
|
|
349
|
+
:class="{
|
|
350
|
+
'qd-log-line--error': isError(line),
|
|
351
|
+
'qd-log-line--current': matchIndexes[currentMatch] === windowStart + i || errorIndexes[currentError] === windowStart + i,
|
|
352
|
+
}">
|
|
353
|
+
<span v-if="lineNumbers" class="qd-log-n">{{ line.n }}</span>
|
|
354
|
+
<span class="qd-log-text"><span v-for="(p, j) in pieces(line)" :key="j" :class="p.cls" :style="p.style">{{ p.text }}</span></span>
|
|
355
|
+
</div>
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
<button v-if="!following && unseen > 0" type="button" class="qd-log-resume" @click="resume">
|
|
361
|
+
<i class="pi pi-arrow-down" /> {{ t('core.log.newLines', { count: unseen }) }}
|
|
362
|
+
</button>
|
|
363
|
+
</div>
|
|
364
|
+
</template>
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* PayloadDiff - compare two payloads, "expected" against "captured" (#4112)
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* <PayloadDiff :left="expected" :right="capture.body" content-type="application/xml" />
|
|
7
|
+
* <PayloadDiff :left="feedA" :right="feedB" format="csv" csv-key="sku" />
|
|
8
|
+
*
|
|
9
|
+
* Both sides are normalised first (JSON with sorted keys, XML indented, form
|
|
10
|
+
* fields sorted), then compared line by line: formatting noise disappears,
|
|
11
|
+
* so what is left reads as a difference of content. A CSV with `csv-key` is
|
|
12
|
+
* compared row by row on that column, cell by cell by header name.
|
|
13
|
+
*/
|
|
14
|
+
import { computed, ref } from 'vue'
|
|
15
|
+
import QdButton from '../base/QdButton.vue'
|
|
16
|
+
import { useI18n } from '../../i18n/useI18n'
|
|
17
|
+
import {
|
|
18
|
+
bodyText,
|
|
19
|
+
detectFormat,
|
|
20
|
+
diffCsv,
|
|
21
|
+
diffLines,
|
|
22
|
+
maskCsv,
|
|
23
|
+
normalizeLines,
|
|
24
|
+
parseCsv,
|
|
25
|
+
type DiffOp,
|
|
26
|
+
type PayloadBody,
|
|
27
|
+
type PayloadFormat,
|
|
28
|
+
type PayloadMask,
|
|
29
|
+
} from '../../utils/payload'
|
|
30
|
+
|
|
31
|
+
interface Props {
|
|
32
|
+
left?: PayloadBody
|
|
33
|
+
right?: PayloadBody
|
|
34
|
+
labels?: [string, string] | null
|
|
35
|
+
mode?: 'split' | 'inline'
|
|
36
|
+
format?: PayloadFormat | null
|
|
37
|
+
contentType?: string | null
|
|
38
|
+
/** CSV only: compare rows matched on this column. */
|
|
39
|
+
csvKey?: string | null
|
|
40
|
+
mask?: PayloadMask | null
|
|
41
|
+
/** Unchanged lines kept around each change; longer runs fold. */
|
|
42
|
+
context?: number
|
|
43
|
+
maxHeight?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
47
|
+
left: null,
|
|
48
|
+
right: null,
|
|
49
|
+
labels: null,
|
|
50
|
+
mode: 'split',
|
|
51
|
+
format: null,
|
|
52
|
+
contentType: null,
|
|
53
|
+
csvKey: null,
|
|
54
|
+
mask: null,
|
|
55
|
+
context: 3,
|
|
56
|
+
maxHeight: '32rem',
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const { t } = useI18n()
|
|
60
|
+
const labels = computed(() => props.labels ?? [t('core.payload.expected'), t('core.payload.captured')])
|
|
61
|
+
const currentMode = ref(props.mode)
|
|
62
|
+
|
|
63
|
+
const format = computed<PayloadFormat>(() => props.format ?? detectFormat(props.left ?? props.right, props.contentType))
|
|
64
|
+
|
|
65
|
+
// --- Keyed CSV ---
|
|
66
|
+
|
|
67
|
+
const csvDiff = computed(() => {
|
|
68
|
+
if (format.value !== 'csv' || !props.csvKey) return null
|
|
69
|
+
const l = maskCsv(parseCsv(bodyText(props.left)).rows, props.mask, true)
|
|
70
|
+
const r = maskCsv(parseCsv(bodyText(props.right)).rows, props.mask, true)
|
|
71
|
+
return diffCsv(l, r, props.csvKey)
|
|
72
|
+
})
|
|
73
|
+
const csvCounts = computed(() => {
|
|
74
|
+
const out = { added: 0, removed: 0, changed: 0, same: 0 }
|
|
75
|
+
for (const row of csvDiff.value?.rows ?? []) out[row.status]++
|
|
76
|
+
return out
|
|
77
|
+
})
|
|
78
|
+
const showSame = ref(false)
|
|
79
|
+
|
|
80
|
+
// --- Lines ---
|
|
81
|
+
|
|
82
|
+
const ops = computed<DiffOp[]>(() =>
|
|
83
|
+
csvDiff.value ? [] : diffLines(normalizeLines(props.left, format.value, props.mask), normalizeLines(props.right, format.value, props.mask))
|
|
84
|
+
)
|
|
85
|
+
const counts = computed(() => ({
|
|
86
|
+
added: ops.value.filter((o) => o.type === 'insert').length,
|
|
87
|
+
removed: ops.value.filter((o) => o.type === 'delete').length,
|
|
88
|
+
}))
|
|
89
|
+
const identical = computed(() => (csvDiff.value ? csvCounts.value.added + csvCounts.value.removed + csvCounts.value.changed === 0 : counts.value.added + counts.value.removed === 0))
|
|
90
|
+
|
|
91
|
+
interface Hunk {
|
|
92
|
+
type: 'ops' | 'fold'
|
|
93
|
+
key: string
|
|
94
|
+
ops: DiffOp[]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const unfolded = ref(new Set<string>())
|
|
98
|
+
|
|
99
|
+
/** Long runs of unchanged lines fold, keeping `context` lines around each change. */
|
|
100
|
+
const hunks = computed<Hunk[]>(() => {
|
|
101
|
+
const list = ops.value
|
|
102
|
+
const keep = new Array<boolean>(list.length).fill(false)
|
|
103
|
+
list.forEach((op, i) => {
|
|
104
|
+
if (op.type === 'equal') return
|
|
105
|
+
for (let j = Math.max(0, i - props.context); j <= Math.min(list.length - 1, i + props.context); j++) keep[j] = true
|
|
106
|
+
})
|
|
107
|
+
const out: Hunk[] = []
|
|
108
|
+
let i = 0
|
|
109
|
+
while (i < list.length) {
|
|
110
|
+
const start = i
|
|
111
|
+
const kept = keep[i] || list.length <= props.context * 2 + 1
|
|
112
|
+
while (i < list.length && (keep[i] || list.length <= props.context * 2 + 1) === kept) i++
|
|
113
|
+
const slice = list.slice(start, i)
|
|
114
|
+
const key = `${start}`
|
|
115
|
+
if (kept || slice.length < 4 || unfolded.value.has(key)) out.push({ type: 'ops', key, ops: slice })
|
|
116
|
+
else out.push({ type: 'fold', key, ops: slice })
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
function unfold(key: string): void {
|
|
122
|
+
unfolded.value = new Set([...unfolded.value, key])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface SplitRow {
|
|
126
|
+
left?: DiffOp
|
|
127
|
+
right?: DiffOp
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Side by side: a run of deletions faces the run of insertions that follows it. */
|
|
131
|
+
function splitRows(list: DiffOp[]): SplitRow[] {
|
|
132
|
+
const rows: SplitRow[] = []
|
|
133
|
+
let i = 0
|
|
134
|
+
while (i < list.length) {
|
|
135
|
+
const op = list[i]!
|
|
136
|
+
if (op.type === 'equal') {
|
|
137
|
+
rows.push({ left: op, right: op })
|
|
138
|
+
i++
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
const dels: DiffOp[] = []
|
|
142
|
+
const ins: DiffOp[] = []
|
|
143
|
+
while (i < list.length && list[i]!.type === 'delete') dels.push(list[i++]!)
|
|
144
|
+
while (i < list.length && list[i]!.type === 'insert') ins.push(list[i++]!)
|
|
145
|
+
for (let k = 0; k < Math.max(dels.length, ins.length); k++) rows.push({ left: dels[k], right: ins[k] })
|
|
146
|
+
}
|
|
147
|
+
return rows
|
|
148
|
+
}
|
|
149
|
+
</script>
|
|
150
|
+
|
|
151
|
+
<template>
|
|
152
|
+
<div class="qd-payload qd-payload-diff">
|
|
153
|
+
<div class="qd-payload-toolbar">
|
|
154
|
+
<span class="qd-payload-format">{{ format.toUpperCase() }}</span>
|
|
155
|
+
<template v-if="csvDiff">
|
|
156
|
+
<span class="pl-count pl-count--add">+{{ csvCounts.added }}</span>
|
|
157
|
+
<span class="pl-count pl-count--del">−{{ csvCounts.removed }}</span>
|
|
158
|
+
<span class="pl-count pl-count--chg">~{{ csvCounts.changed }}</span>
|
|
159
|
+
<label class="qd-payload-check"><input v-model="showSame" type="checkbox" /> {{ t('core.payload.showSame', { count: csvCounts.same }) }}</label>
|
|
160
|
+
</template>
|
|
161
|
+
<template v-else>
|
|
162
|
+
<span class="pl-count pl-count--add">+{{ counts.added }}</span>
|
|
163
|
+
<span class="pl-count pl-count--del">−{{ counts.removed }}</span>
|
|
164
|
+
</template>
|
|
165
|
+
<span class="qd-payload-spacer" />
|
|
166
|
+
<QdButton v-if="!csvDiff" size="small" text :icon="currentMode === 'split' ? 'pi pi-bars' : 'pi pi-pause'"
|
|
167
|
+
:label="currentMode === 'split' ? t('core.payload.inline') : t('core.payload.split')"
|
|
168
|
+
@click="currentMode = currentMode === 'split' ? 'inline' : 'split'" />
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div v-if="identical" class="qd-payload-identical"><i class="pi pi-check" /> {{ t('core.payload.identical') }}</div>
|
|
172
|
+
|
|
173
|
+
<div class="qd-payload-body" :style="{ maxHeight }">
|
|
174
|
+
<table v-if="csvDiff" class="qd-payload-table pl-csv-diff">
|
|
175
|
+
<thead>
|
|
176
|
+
<tr>
|
|
177
|
+
<th class="pl-n" />
|
|
178
|
+
<th v-for="h in csvDiff.header" :key="h">{{ h }}</th>
|
|
179
|
+
</tr>
|
|
180
|
+
</thead>
|
|
181
|
+
<tbody>
|
|
182
|
+
<template v-for="row in csvDiff.rows" :key="row.key">
|
|
183
|
+
<tr v-if="row.status !== 'same' || showSame" :class="`pl-row--${row.status}`">
|
|
184
|
+
<td class="pl-n">{{ row.status === 'added' ? '+' : row.status === 'removed' ? '−' : row.status === 'changed' ? '~' : '' }}</td>
|
|
185
|
+
<td v-for="(h, c) in csvDiff.header" :key="h" :class="{ 'pl-cell--changed': row.changed.includes(c) }">
|
|
186
|
+
<template v-if="row.changed.includes(c)">
|
|
187
|
+
<del>{{ row.left?.[c] }}</del> <ins>{{ row.right?.[c] }}</ins>
|
|
188
|
+
</template>
|
|
189
|
+
<template v-else>{{ (row.right ?? row.left)?.[c] }}</template>
|
|
190
|
+
</td>
|
|
191
|
+
</tr>
|
|
192
|
+
</template>
|
|
193
|
+
</tbody>
|
|
194
|
+
</table>
|
|
195
|
+
|
|
196
|
+
<table v-else-if="currentMode === 'split'" class="pl-diff pl-diff--split">
|
|
197
|
+
<thead>
|
|
198
|
+
<tr>
|
|
199
|
+
<th colspan="2">{{ labels[0] }}</th>
|
|
200
|
+
<th colspan="2">{{ labels[1] }}</th>
|
|
201
|
+
</tr>
|
|
202
|
+
</thead>
|
|
203
|
+
<tbody>
|
|
204
|
+
<template v-for="hunk in hunks" :key="hunk.key">
|
|
205
|
+
<tr v-if="hunk.type === 'fold'" class="pl-fold">
|
|
206
|
+
<td colspan="4"><button type="button" @click="unfold(hunk.key)">{{ t('core.payload.unchanged', { count: hunk.ops.length }) }}</button></td>
|
|
207
|
+
</tr>
|
|
208
|
+
<tr v-for="(row, i) in (hunk.type === 'ops' ? splitRows(hunk.ops) : [])" v-else :key="`${hunk.key}:${i}`">
|
|
209
|
+
<td class="pl-n">{{ row.left?.left !== undefined ? row.left.left + 1 : '' }}</td>
|
|
210
|
+
<td :class="row.left ? (row.left.type === 'delete' ? 'pl-del' : '') : 'pl-empty'">{{ row.left?.text }}</td>
|
|
211
|
+
<td class="pl-n">{{ row.right?.right !== undefined ? row.right.right + 1 : '' }}</td>
|
|
212
|
+
<td :class="row.right ? (row.right.type === 'insert' ? 'pl-ins' : '') : 'pl-empty'">{{ row.right?.text }}</td>
|
|
213
|
+
</tr>
|
|
214
|
+
</template>
|
|
215
|
+
</tbody>
|
|
216
|
+
</table>
|
|
217
|
+
|
|
218
|
+
<table v-else class="pl-diff pl-diff--inline">
|
|
219
|
+
<tbody>
|
|
220
|
+
<template v-for="hunk in hunks" :key="hunk.key">
|
|
221
|
+
<tr v-if="hunk.type === 'fold'" class="pl-fold">
|
|
222
|
+
<td colspan="4"><button type="button" @click="unfold(hunk.key)">{{ t('core.payload.unchanged', { count: hunk.ops.length }) }}</button></td>
|
|
223
|
+
</tr>
|
|
224
|
+
<tr v-for="(op, i) in (hunk.type === 'ops' ? hunk.ops : [])" v-else :key="`${hunk.key}:${i}`"
|
|
225
|
+
:class="op.type === 'delete' ? 'pl-del' : op.type === 'insert' ? 'pl-ins' : ''">
|
|
226
|
+
<td class="pl-n">{{ op.left !== undefined ? op.left + 1 : '' }}</td>
|
|
227
|
+
<td class="pl-n">{{ op.right !== undefined ? op.right + 1 : '' }}</td>
|
|
228
|
+
<td class="pl-sign">{{ op.type === 'delete' ? '−' : op.type === 'insert' ? '+' : '' }}</td>
|
|
229
|
+
<td>{{ op.text }}</td>
|
|
230
|
+
</tr>
|
|
231
|
+
</template>
|
|
232
|
+
</tbody>
|
|
233
|
+
</table>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</template>
|