@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.
Files changed (41) hide show
  1. package/dist/styles/qdadm.css +1 -1
  2. package/package.json +9 -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 +8 -0
  10. package/src/components/tasks/TaskDialog.vue +95 -0
  11. package/src/components/tasks/TaskHost.vue +45 -0
  12. package/src/components/tasks/TaskTray.vue +54 -0
  13. package/src/composables/index.ts +11 -0
  14. package/src/composables/useListPage.filters.ts +26 -1
  15. package/src/composables/useLogStream.ts +264 -0
  16. package/src/email/index.ts +46 -0
  17. package/src/entity/storage/SdkStorage.ts +53 -1
  18. package/src/entity/storage/index.ts +2 -0
  19. package/src/i18n/defaults/core.en.generated.ts +85 -0
  20. package/src/i18n/defaults/core.en.yml +85 -0
  21. package/src/i18n/defaults/core.fr.generated.ts +85 -0
  22. package/src/i18n/defaults/core.fr.yml +85 -0
  23. package/src/index.ts +21 -0
  24. package/src/kernel/Kernel.vue.ts +9 -2
  25. package/src/kernel/Module.ts +3 -2
  26. package/src/modules/debug/DebugModule.ts +3 -1
  27. package/src/modules/debug/styles.generated.css +2895 -0
  28. package/src/notifications/NotificationModule.ts +1 -1
  29. package/src/notifications/{styles.scss → styles.css} +5 -5
  30. package/src/styles/_email.scss +188 -0
  31. package/src/styles/_log-stream.scss +167 -0
  32. package/src/styles/_payload.scss +312 -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,460 @@
1
+ /**
2
+ * Task store - long backend tasks, queued and followed to the end (#4114)
3
+ *
4
+ * An action whose handler queues a task on a worker (create a box, refresh a
5
+ * database, run a scenario) must neither block the screen nor be
6
+ * fire-and-forget. `run()` starts it, then follows the task record until it
7
+ * ends: through live entities when the task entity is live, by polling
8
+ * otherwise, both when both are there (the poll is then the slow fallback).
9
+ *
10
+ * One store per page load, at module level: the tray in the layout, the
11
+ * dialog, and every action that starts a task share it, whatever component
12
+ * they live in. Runs of a registered kind survive a reload (sessionStorage).
13
+ */
14
+
15
+ import { reactive, computed, type ComputedRef } from 'vue'
16
+ import type { LogSource } from '../composables/useLogStream'
17
+
18
+ export type TaskStatus = 'starting' | 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
19
+
20
+ type Reader<T> = string | ((record: Record<string, unknown>) => T)
21
+
22
+ /** How to read the task record. */
23
+ export interface TaskFollow {
24
+ /** The task entity: read through its manager, and followed live if it is live. */
25
+ entity?: string
26
+ /** Or read it yourself. */
27
+ get?: (taskId: string | number) => Promise<unknown>
28
+ /** Field or reader of the status. Default `status`. */
29
+ status?: Reader<unknown>
30
+ /** Backend values to statuses, on top of the usual names (done, error, in_progress…). */
31
+ statusMap?: Record<string, Exclude<TaskStatus, 'starting'>>
32
+ /** Percent, 0–100. Default field `progress`. */
33
+ progress?: Reader<unknown>
34
+ /** The current step, shown under the bar. Default field `step`. */
35
+ step?: Reader<unknown>
36
+ /** What a finished task produced. Default field `result`. */
37
+ result?: Reader<unknown>
38
+ /** Why it failed. Default field `error`. */
39
+ error?: Reader<unknown>
40
+ /** Poll interval, ms. Default 2000, or 10 000 as a fallback when the entity is live. */
41
+ intervalMs?: number
42
+ }
43
+
44
+ /** What a kind of task shares, whatever starts it: registered once, it lets runs resume after a reload. */
45
+ export interface TaskKind {
46
+ label?: string
47
+ task: TaskFollow
48
+ /** Cancel the task on the backend. Without it, no cancel button. */
49
+ cancel?: (taskId: string | number) => Promise<unknown>
50
+ /** The task's log, shown live in the dialog (LogStream). */
51
+ log?: (taskId: string | number) => LogSource
52
+ /** Where the task's own page is (a vue-router location). */
53
+ to?: (taskId: string | number) => unknown
54
+ /** Toast when it ends. Default true. */
55
+ notify?: boolean
56
+ }
57
+
58
+ export interface TaskConfirm {
59
+ message?: string
60
+ /** The user must type this (a box name, an environment) before the task starts. */
61
+ typed?: string
62
+ /** Label of the confirm button. */
63
+ accept?: string
64
+ }
65
+
66
+ export interface RunOptions extends Partial<TaskKind> {
67
+ label: string
68
+ /** Queue the task; returns its id, or `{ task_id | taskId | id }`. */
69
+ start: () => Promise<unknown>
70
+ /** A registered kind; its settings are the defaults of this run, and the run resumes after a reload. */
71
+ kind?: string
72
+ confirm?: TaskConfirm
73
+ /** Open the follow dialog at once. Default true. */
74
+ open?: boolean
75
+ }
76
+
77
+ export interface TaskRun {
78
+ id: string
79
+ kind: string | null
80
+ label: string
81
+ taskId: string | number | null
82
+ status: TaskStatus
83
+ progress: number | null
84
+ step: string | null
85
+ result: unknown
86
+ error: string | null
87
+ startedAt: number
88
+ endedAt: number | null
89
+ cancelling: boolean
90
+ }
91
+
92
+ interface TaskHostDeps {
93
+ orchestrator?: {
94
+ get: (name: string) => unknown
95
+ toast?: Record<'success' | 'error' | 'warn' | 'info', (summary: string, detail?: string, options?: unknown) => void>
96
+ } | null
97
+ signals?: { on: (name: string, handler: (event: { data: unknown }) => void) => () => void } | null
98
+ }
99
+
100
+ interface PendingConfirm {
101
+ label: string
102
+ confirm: TaskConfirm
103
+ resolve: (ok: boolean) => void
104
+ }
105
+
106
+ const FINAL: TaskStatus[] = ['succeeded', 'failed', 'cancelled']
107
+ const STORAGE_KEY = 'qdadm:tasks'
108
+ const KEEP_FINISHED = 20
109
+
110
+ const USUAL: Record<string, Exclude<TaskStatus, 'starting'>> = {
111
+ queued: 'queued', pending: 'queued', waiting: 'queued', scheduled: 'queued', new: 'queued',
112
+ running: 'running', started: 'running', in_progress: 'running', processing: 'running', active: 'running',
113
+ succeeded: 'succeeded', success: 'succeeded', done: 'succeeded', completed: 'succeeded', finished: 'succeeded', ok: 'succeeded',
114
+ failed: 'failed', failure: 'failed', error: 'failed', errored: 'failed',
115
+ cancelled: 'cancelled', canceled: 'cancelled', aborted: 'cancelled', killed: 'cancelled',
116
+ }
117
+
118
+ let seq = 0
119
+
120
+ function read<T>(record: Record<string, unknown>, reader: Reader<T> | undefined, fallback: string): unknown {
121
+ if (typeof reader === 'function') return reader(record)
122
+ return record[reader ?? fallback]
123
+ }
124
+
125
+ function taskIdOf(started: unknown): string | number | null {
126
+ if (typeof started === 'string' || typeof started === 'number') return started
127
+ if (started && typeof started === 'object') {
128
+ const o = started as Record<string, unknown>
129
+ const id = o.task_id ?? o.taskId ?? o.id
130
+ if (typeof id === 'string' || typeof id === 'number') return id
131
+ }
132
+ return null
133
+ }
134
+
135
+ function errorText(err: unknown): string {
136
+ if (err instanceof Error) return err.message
137
+ if (typeof err === 'string') return err
138
+ if (err && typeof err === 'object' && 'message' in err) return String((err as { message: unknown }).message)
139
+ return err === null || err === undefined ? '' : JSON.stringify(err)
140
+ }
141
+
142
+ function createTaskStore() {
143
+ const state = reactive({
144
+ runs: [] as TaskRun[],
145
+ openId: null as string | null,
146
+ pendingConfirm: null as PendingConfirm | null,
147
+ hosts: 0,
148
+ })
149
+ const kinds = new Map<string, TaskKind>()
150
+ const defs = new Map<string, TaskKind>()
151
+ const stops = new Map<string, () => void>()
152
+ const pokes = new Map<string, () => void>()
153
+ let deps: TaskHostDeps = {}
154
+ let restored = false
155
+
156
+ // --- Persistence: runs of a registered kind come back after a reload ---
157
+
158
+ function persist(): void {
159
+ try {
160
+ const saved = state.runs
161
+ .filter((r) => r.kind && kinds.has(r.kind) && r.taskId !== null && !FINAL.includes(r.status))
162
+ .map((r) => ({ id: r.id, kind: r.kind, label: r.label, taskId: r.taskId, startedAt: r.startedAt }))
163
+ if (saved.length) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(saved))
164
+ else sessionStorage.removeItem(STORAGE_KEY)
165
+ } catch {
166
+ // no storage (private mode, tests): runs just do not survive a reload
167
+ }
168
+ }
169
+
170
+ function restore(): void {
171
+ if (restored) return
172
+ restored = true
173
+ let saved: Array<Pick<TaskRun, 'id' | 'kind' | 'label' | 'taskId' | 'startedAt'>> = []
174
+ try {
175
+ saved = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '[]')
176
+ } catch {
177
+ return
178
+ }
179
+ for (const s of saved) {
180
+ const kind = s.kind ? kinds.get(s.kind) : undefined
181
+ if (!kind || s.taskId === null || state.runs.some((r) => r.id === s.id)) continue
182
+ const run = addRun({ id: s.id, kind: s.kind, label: s.label, taskId: s.taskId, status: 'running', startedAt: s.startedAt })
183
+ defs.set(run.id, kind)
184
+ follow(run.id)
185
+ }
186
+ }
187
+
188
+ // --- Runs ---
189
+
190
+ function addRun(partial: Partial<TaskRun> & Pick<TaskRun, 'label'>): TaskRun {
191
+ const run: TaskRun = {
192
+ id: partial.id ?? `task-${Date.now().toString(36)}-${++seq}`,
193
+ kind: partial.kind ?? null,
194
+ label: partial.label,
195
+ taskId: partial.taskId ?? null,
196
+ status: partial.status ?? 'starting',
197
+ progress: null,
198
+ step: null,
199
+ result: null,
200
+ error: null,
201
+ startedAt: partial.startedAt ?? Date.now(),
202
+ endedAt: null,
203
+ cancelling: false,
204
+ }
205
+ state.runs.unshift(run)
206
+ // Keep every active run, and the most recent finished ones.
207
+ const finished = state.runs.filter((r) => FINAL.includes(r.status))
208
+ for (const old of finished.slice(KEEP_FINISHED)) dismiss(old.id)
209
+ return state.runs.find((r) => r.id === run.id)!
210
+ }
211
+
212
+ const find = (id: string) => state.runs.find((r) => r.id === id)
213
+
214
+ function finish(id: string, status: TaskStatus, error: string | null = null): void {
215
+ const run = find(id)
216
+ if (!run || FINAL.includes(run.status)) return
217
+ run.status = status
218
+ run.endedAt = Date.now()
219
+ run.cancelling = false
220
+ if (error) run.error = error
221
+ stops.get(id)?.()
222
+ stops.delete(id)
223
+ pokes.delete(id)
224
+ persist()
225
+ notifyEnd(run)
226
+ }
227
+
228
+ function notifyEnd(run: TaskRun): void {
229
+ const def = defs.get(run.id)
230
+ if (def?.notify === false) return
231
+ const toast = deps.orchestrator?.toast
232
+ if (!toast) return
233
+ const to = def?.to && run.taskId !== null ? def.to(run.taskId) : undefined
234
+ const opts = { emitter: 'TaskRunner', ...(to ? { to } : {}) }
235
+ if (run.status === 'succeeded') toast.success(run.label, run.step ?? undefined, opts)
236
+ else if (run.status === 'failed') toast.error(run.label, run.error ?? undefined, { ...opts, keep: 'long' })
237
+ else if (run.status === 'cancelled') toast.warn(run.label, undefined, opts)
238
+ }
239
+
240
+ /** Read the task record once and apply it to the run. */
241
+ async function refresh(id: string): Promise<void> {
242
+ const run = find(id)
243
+ const def = defs.get(id)
244
+ if (!run || !def || run.taskId === null) return
245
+ const f = def.task
246
+ let record: unknown
247
+ if (f.get) record = await f.get(run.taskId)
248
+ else if (f.entity && deps.orchestrator) {
249
+ const manager = deps.orchestrator.get(f.entity) as { get: (id: unknown, ctx?: unknown, o?: unknown) => Promise<unknown> }
250
+ record = await manager.get(run.taskId, undefined, { listCache: false })
251
+ } else throw new Error(`TaskRunner: "${run.label}" has neither task.get nor a task.entity with an orchestrator`)
252
+ const r = (record ?? {}) as Record<string, unknown>
253
+ const current = find(id)
254
+ if (!current || FINAL.includes(current.status)) return
255
+
256
+ const raw = read(r, f.status, 'status')
257
+ const key = String(raw ?? '').toLowerCase()
258
+ const status = f.statusMap?.[String(raw)] ?? f.statusMap?.[key] ?? USUAL[key] ?? 'running'
259
+ const progress = Number(read(r, f.progress, 'progress'))
260
+ current.progress = Number.isFinite(progress) && read(r, f.progress, 'progress') !== null ? Math.max(0, Math.min(100, progress)) : null
261
+ const step = read(r, f.step, 'step')
262
+ current.step = step === null || step === undefined || step === '' ? null : String(step)
263
+ current.result = read(r, f.result, 'result') ?? null
264
+ const err = read(r, f.error, 'error')
265
+ if (err) current.error = errorText(err)
266
+ if (FINAL.includes(status)) finish(id, status)
267
+ else current.status = status
268
+ }
269
+
270
+ /** Poll until the run ends; a live event on its record reads it at once. */
271
+ function follow(id: string): void {
272
+ const run = find(id)
273
+ const def = defs.get(id)
274
+ if (!run || !def) return
275
+ const live = !!(def.task.entity && deps.signals)
276
+ const base = def.task.intervalMs ?? (live ? 10_000 : 2000)
277
+ let delay = base
278
+ let timer: ReturnType<typeof setTimeout> | null = null
279
+ let stopped = false
280
+ let busy = false
281
+ let again = false
282
+
283
+ const tick = async () => {
284
+ if (stopped) return
285
+ if (busy) {
286
+ again = true
287
+ return
288
+ }
289
+ busy = true
290
+ if (timer) clearTimeout(timer)
291
+ try {
292
+ await refresh(id)
293
+ delay = base
294
+ } catch (err) {
295
+ const r = find(id)
296
+ if (r) r.error = errorText(err)
297
+ delay = Math.min(delay * 2, 30_000)
298
+ } finally {
299
+ busy = false
300
+ }
301
+ if (stopped || FINAL.includes(find(id)?.status ?? 'failed')) return
302
+ if (again) {
303
+ again = false
304
+ void tick()
305
+ return
306
+ }
307
+ timer = setTimeout(tick, delay)
308
+ }
309
+
310
+ let off: (() => void) | null = null
311
+ if (live) {
312
+ off = deps.signals!.on('entity:data-invalidate', (event) => {
313
+ const p = (event.data ?? {}) as { entity?: string; id?: unknown }
314
+ if (p.entity !== def.task.entity) return
315
+ if (p.id !== undefined && String(p.id) !== String(find(id)?.taskId)) return
316
+ void tick()
317
+ })
318
+ }
319
+ stops.set(id, () => {
320
+ stopped = true
321
+ if (timer) clearTimeout(timer)
322
+ off?.()
323
+ })
324
+ pokes.set(id, () => void tick())
325
+ void tick()
326
+ }
327
+
328
+ // --- Public ---
329
+
330
+ function registerTaskKind(name: string, kind: TaskKind): void {
331
+ kinds.set(name, kind)
332
+ }
333
+
334
+ function requestConfirm(label: string, confirm: TaskConfirm): Promise<boolean> {
335
+ if (state.hosts === 0) {
336
+ console.warn(`[qdadm] Task "${label}" asks for a confirmation, but no TaskHost is mounted (AppLayout mounts one). Not started.`)
337
+ return Promise.resolve(false)
338
+ }
339
+ state.pendingConfirm?.resolve(false)
340
+ return new Promise((resolve) => {
341
+ state.pendingConfirm = { label, confirm, resolve }
342
+ })
343
+ }
344
+
345
+ function answerConfirm(ok: boolean): void {
346
+ const pending = state.pendingConfirm
347
+ state.pendingConfirm = null
348
+ pending?.resolve(ok)
349
+ }
350
+
351
+ /**
352
+ * Start a task and follow it. Resolves with the run once the task is
353
+ * queued (or null when the confirmation was declined); the run itself
354
+ * moves on in the store.
355
+ */
356
+ async function run(options: RunOptions): Promise<TaskRun | null> {
357
+ const base = options.kind ? kinds.get(options.kind) : undefined
358
+ if (options.kind && !base && !options.task) {
359
+ throw new Error(`TaskRunner: unknown task kind "${options.kind}"`)
360
+ }
361
+ const def: TaskKind = { ...base, ...options, task: { ...base?.task, ...options.task } } as TaskKind
362
+ if (options.confirm && !(await requestConfirm(options.label, options.confirm))) return null
363
+
364
+ const r = addRun({ label: options.label, kind: options.kind ?? null })
365
+ defs.set(r.id, def)
366
+ if (options.open !== false) state.openId = r.id
367
+ try {
368
+ const taskId = taskIdOf(await options.start())
369
+ if (taskId === null) throw new Error('the start handler returned no task id')
370
+ const current = find(r.id)!
371
+ current.taskId = taskId
372
+ current.status = 'queued'
373
+ persist()
374
+ follow(r.id)
375
+ } catch (err) {
376
+ finish(r.id, 'failed', errorText(err))
377
+ }
378
+ return find(r.id) ?? null
379
+ }
380
+
381
+ async function cancel(id: string): Promise<void> {
382
+ const r = find(id)
383
+ const def = defs.get(id)
384
+ if (!r || !def?.cancel || r.taskId === null || FINAL.includes(r.status)) return
385
+ r.cancelling = true
386
+ try {
387
+ await def.cancel(r.taskId)
388
+ pokes.get(id)?.()
389
+ } catch (err) {
390
+ r.cancelling = false
391
+ r.error = errorText(err)
392
+ }
393
+ }
394
+
395
+ function dismiss(id: string): void {
396
+ stops.get(id)?.()
397
+ stops.delete(id)
398
+ pokes.delete(id)
399
+ defs.delete(id)
400
+ const i = state.runs.findIndex((r) => r.id === id)
401
+ if (i !== -1) state.runs.splice(i, 1)
402
+ if (state.openId === id) state.openId = null
403
+ persist()
404
+ }
405
+
406
+ function clearFinished(): void {
407
+ for (const r of state.runs.filter((x) => FINAL.includes(x.status))) dismiss(r.id)
408
+ }
409
+
410
+ /** Wire the orchestrator (manager reads, toasts) and the signal bus (live). Called by useTaskRunner. */
411
+ function attach(next: TaskHostDeps): void {
412
+ if (next.orchestrator) deps.orchestrator = next.orchestrator
413
+ if (next.signals) deps.signals = next.signals
414
+ restore()
415
+ }
416
+
417
+ const active: ComputedRef<TaskRun[]> = computed(() => state.runs.filter((r) => !FINAL.includes(r.status)))
418
+
419
+ return {
420
+ state,
421
+ active,
422
+ run,
423
+ cancel,
424
+ dismiss,
425
+ clearFinished,
426
+ open: (id: string | null) => (state.openId = id),
427
+ registerTaskKind,
428
+ answerConfirm,
429
+ attach,
430
+ definition: (id: string) => defs.get(id),
431
+ isFinal: (status: TaskStatus) => FINAL.includes(status),
432
+ /** For tests: stop everything and forget every run. */
433
+ reset(): void {
434
+ for (const stop of stops.values()) stop()
435
+ stops.clear()
436
+ pokes.clear()
437
+ defs.clear()
438
+ kinds.clear()
439
+ state.runs.splice(0)
440
+ state.openId = null
441
+ state.pendingConfirm = null
442
+ state.hosts = 0
443
+ deps = {}
444
+ restored = false
445
+ },
446
+ }
447
+ }
448
+
449
+ export type TaskStore = ReturnType<typeof createTaskStore>
450
+
451
+ let store: TaskStore | null = null
452
+
453
+ export function getTaskStore(): TaskStore {
454
+ return (store ??= createTaskStore())
455
+ }
456
+
457
+ /** Register a kind of task, typically in a module's connect(): its runs then resume after a reload. */
458
+ export function registerTaskKind(name: string, kind: TaskKind): void {
459
+ getTaskStore().registerTaskKind(name, kind)
460
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * useTaskRunner - start a long backend task and follow it (#4114)
3
+ *
4
+ * ```ts
5
+ * const tasks = useTaskRunner()
6
+ * tasks.run({
7
+ * label: `Create box ${name}`,
8
+ * start: () => api.post('/boxes', { name }).then((r) => r.data), // → { task_id }
9
+ * task: { entity: 'tasks' }, // follow the task record
10
+ * log: (id) => ({ poll: (from) => api.get(`/tasks/${id}/log`, { params: { from } }).then((r) => r.data) }),
11
+ * cancel: (id) => api.post(`/tasks/${id}/cancel`),
12
+ * to: (id) => ({ name: 'task-show', params: { id } }),
13
+ * })
14
+ *
15
+ * // As a row action, with a typed confirmation
16
+ * list.addAction('destroy', {
17
+ * icon: 'pi pi-trash', severity: 'danger',
18
+ * onClick: taskAction((box) => ({
19
+ * label: `Destroy ${box.name}`, kind: 'destroy-box',
20
+ * start: () => api.delete(`/boxes/${box.id}`).then((r) => r.data),
21
+ * confirm: { typed: box.name, message: 'The box and its database are deleted.' },
22
+ * })),
23
+ * })
24
+ * ```
25
+ */
26
+
27
+ import { inject } from 'vue'
28
+ import { getTaskStore, type RunOptions, type TaskRun } from './taskStore'
29
+
30
+ export function useTaskRunner() {
31
+ const store = getTaskStore()
32
+ store.attach({
33
+ orchestrator: inject('qdadmOrchestrator', null),
34
+ signals: inject('qdadmSignals', null),
35
+ })
36
+ return {
37
+ run: store.run,
38
+ cancel: store.cancel,
39
+ dismiss: store.dismiss,
40
+ open: store.open,
41
+ runs: store.state.runs,
42
+ active: store.active,
43
+ }
44
+ }
45
+
46
+ /**
47
+ * An action handler that starts a task: `onClick: taskAction((row) => ({ label, start, ... }))`.
48
+ * Works for row actions, header actions, show-page actions and plain buttons.
49
+ */
50
+ export function taskAction<A extends unknown[]>(build: (...args: A) => RunOptions): (...args: A) => Promise<TaskRun | null> {
51
+ return (...args: A) => getTaskStore().run(build(...args))
52
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * ANSI SGR parsing for log output (#4110).
3
+ *
4
+ * Only the Select Graphic Rendition sequences (`ESC [ … m`) carry meaning in
5
+ * a log: colours and weight. Every other escape (cursor moves, erase line,
6
+ * OSC titles) is dropped, since a tail has no cursor to move.
7
+ *
8
+ * The style carries from one line to the next, as in a terminal: a worker
9
+ * that prints `ESC[31m` and two lines later `ESC[0m` coloured both lines.
10
+ * So a stream is parsed in order, passing the state along.
11
+ */
12
+
13
+ export interface AnsiStyle {
14
+ fg?: string
15
+ bg?: string
16
+ bold?: boolean
17
+ dim?: boolean
18
+ italic?: boolean
19
+ underline?: boolean
20
+ inverse?: boolean
21
+ }
22
+
23
+ export interface AnsiSegment {
24
+ text: string
25
+ style: AnsiStyle
26
+ }
27
+
28
+ /** xterm's default 16 colours: 0-7 normal, 8-15 bright. */
29
+ const BASE_COLORS = [
30
+ '#000000', '#cd3131', '#0dbc79', '#e5e510', '#2472c8', '#bc3fbc', '#11a8cd', '#e5e5e5',
31
+ '#666666', '#f14c4c', '#23d18b', '#f5f543', '#3b8eea', '#d670d6', '#29b8db', '#ffffff',
32
+ ]
33
+
34
+ function color256(n: number): string | undefined {
35
+ if (n < 0 || n > 255) return undefined
36
+ if (n < 16) return BASE_COLORS[n]
37
+ if (n < 232) {
38
+ const c = n - 16
39
+ const level = (v: number) => (v === 0 ? 0 : 55 + v * 40)
40
+ const r = level(Math.floor(c / 36))
41
+ const g = level(Math.floor(c / 6) % 6)
42
+ const b = level(c % 6)
43
+ return `rgb(${r}, ${g}, ${b})`
44
+ }
45
+ const grey = 8 + (n - 232) * 10
46
+ return `rgb(${grey}, ${grey}, ${grey})`
47
+ }
48
+
49
+ // eslint-disable-next-line no-control-regex
50
+ const ESCAPE = /\x1b\[([0-9;:]*)([A-Za-z])|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g
51
+
52
+ /** The text of a line without any escape sequence. */
53
+ export function stripAnsi(text: string): string {
54
+ return text.replace(ESCAPE, '')
55
+ }
56
+
57
+ /** Apply one SGR parameter list to a style, returning the new style. */
58
+ function applySgr(style: AnsiStyle, params: string): AnsiStyle {
59
+ const codes = params === '' ? [0] : params.split(/[;:]/).map((p) => (p === '' ? 0 : Number(p)))
60
+ let next: AnsiStyle = { ...style }
61
+ for (let i = 0; i < codes.length; i++) {
62
+ const code = codes[i]!
63
+ if (code === 0) next = {}
64
+ else if (code === 1) next.bold = true
65
+ else if (code === 2) next.dim = true
66
+ else if (code === 3) next.italic = true
67
+ else if (code === 4) next.underline = true
68
+ else if (code === 7) next.inverse = true
69
+ else if (code === 22) { delete next.bold; delete next.dim }
70
+ else if (code === 23) delete next.italic
71
+ else if (code === 24) delete next.underline
72
+ else if (code === 27) delete next.inverse
73
+ else if (code >= 30 && code <= 37) next.fg = BASE_COLORS[code - 30]
74
+ else if (code >= 90 && code <= 97) next.fg = BASE_COLORS[code - 90 + 8]
75
+ else if (code === 39) delete next.fg
76
+ else if (code >= 40 && code <= 47) next.bg = BASE_COLORS[code - 40]
77
+ else if (code >= 100 && code <= 107) next.bg = BASE_COLORS[code - 100 + 8]
78
+ else if (code === 49) delete next.bg
79
+ else if (code === 38 || code === 48) {
80
+ const key = code === 38 ? 'fg' : 'bg'
81
+ let value: string | undefined
82
+ if (codes[i + 1] === 5) {
83
+ value = color256(codes[i + 2] ?? -1)
84
+ i += 2
85
+ } else if (codes[i + 1] === 2) {
86
+ const [r, g, b] = [codes[i + 2], codes[i + 3], codes[i + 4]]
87
+ if ([r, g, b].every((v) => v !== undefined && v >= 0 && v <= 255)) value = `rgb(${r}, ${g}, ${b})`
88
+ i += 4
89
+ }
90
+ if (value) next[key] = value
91
+ }
92
+ }
93
+ return next
94
+ }
95
+
96
+ /**
97
+ * Split one line into styled segments, starting from `state` (the style the
98
+ * previous line ended in). Returns the segments and the style this line ends
99
+ * in, for the next one.
100
+ */
101
+ export function parseAnsiLine(line: string, state: AnsiStyle = {}): { segments: AnsiSegment[]; state: AnsiStyle } {
102
+ const segments: AnsiSegment[] = []
103
+ let style = state
104
+ let last = 0
105
+ ESCAPE.lastIndex = 0
106
+ for (let m = ESCAPE.exec(line); m; m = ESCAPE.exec(line)) {
107
+ if (m.index > last) segments.push({ text: line.slice(last, m.index), style })
108
+ if (m[2] === 'm') style = applySgr(style, m[1] ?? '')
109
+ last = m.index + m[0].length
110
+ }
111
+ if (last < line.length) segments.push({ text: line.slice(last), style })
112
+ return { segments, state: style }
113
+ }
114
+
115
+ /** Inline CSS for a style; inverse swaps the colours it has. */
116
+ export function ansiStyleToCss(style: AnsiStyle): Record<string, string> {
117
+ const css: Record<string, string> = {}
118
+ const fg = style.inverse ? style.bg ?? 'var(--qd-log-bg)' : style.fg
119
+ const bg = style.inverse ? style.fg ?? 'var(--qd-log-fg)' : style.bg
120
+ if (fg) css.color = fg
121
+ if (bg) css.backgroundColor = bg
122
+ if (style.bold) css.fontWeight = '700'
123
+ if (style.dim) css.opacity = '0.65'
124
+ if (style.italic) css.fontStyle = 'italic'
125
+ if (style.underline) css.textDecoration = 'underline'
126
+ return css
127
+ }