@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,246 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* PayloadViewer - a captured message in its own format (#4112)
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* <PayloadViewer :body="capture.body" :content-type="capture.content_type" />
|
|
7
|
+
* <PayloadViewer :body="capture.body" :request="{ method: 'POST', url, status: 200, headers }" :mask="maskKeys()" />
|
|
8
|
+
*
|
|
9
|
+
* JSON goes to JsonViewer; XML is indented and highlighted (tokenized by
|
|
10
|
+
* hand, never parsed as HTML); CSV is a table; form-encoded a key/value
|
|
11
|
+
* table; text gets line numbers; a binary file shows its size, the first
|
|
12
|
+
* bytes in hex, and a download.
|
|
13
|
+
*/
|
|
14
|
+
import { computed, inject, ref } from 'vue'
|
|
15
|
+
import QdButton from '../base/QdButton.vue'
|
|
16
|
+
import JsonViewer from '../editors/JsonViewer.vue'
|
|
17
|
+
import { useI18n } from '../../i18n/useI18n'
|
|
18
|
+
import {
|
|
19
|
+
bodyText,
|
|
20
|
+
detectFormat,
|
|
21
|
+
FORMAT_EXTENSIONS,
|
|
22
|
+
hexDump,
|
|
23
|
+
maskCsv,
|
|
24
|
+
maskHeaders,
|
|
25
|
+
maskJson,
|
|
26
|
+
parseCsv,
|
|
27
|
+
parseForm,
|
|
28
|
+
parseJson,
|
|
29
|
+
prettyXml,
|
|
30
|
+
piecesText,
|
|
31
|
+
toBytes,
|
|
32
|
+
isBinary,
|
|
33
|
+
type PayloadBody,
|
|
34
|
+
type PayloadFormat,
|
|
35
|
+
type PayloadMask,
|
|
36
|
+
} from '../../utils/payload'
|
|
37
|
+
import { formatBytes } from '../../utils/formatters'
|
|
38
|
+
|
|
39
|
+
export interface PayloadRequest {
|
|
40
|
+
method?: string
|
|
41
|
+
url?: string
|
|
42
|
+
status?: number | null
|
|
43
|
+
headers?: Record<string, unknown> | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Props {
|
|
47
|
+
body?: PayloadBody
|
|
48
|
+
contentType?: string | null
|
|
49
|
+
/** Force a format instead of detecting it. */
|
|
50
|
+
format?: PayloadFormat | null
|
|
51
|
+
/** HTTP context shown above the body. */
|
|
52
|
+
request?: PayloadRequest | null
|
|
53
|
+
/** Hide values (tokens, passwords) before display, copy and download. */
|
|
54
|
+
mask?: PayloadMask | null
|
|
55
|
+
maxHeight?: string
|
|
56
|
+
/** CSV rows shown before "show all". */
|
|
57
|
+
csvRows?: number
|
|
58
|
+
filename?: string | null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
62
|
+
body: null,
|
|
63
|
+
contentType: null,
|
|
64
|
+
format: null,
|
|
65
|
+
request: null,
|
|
66
|
+
mask: null,
|
|
67
|
+
maxHeight: '28rem',
|
|
68
|
+
csvRows: 200,
|
|
69
|
+
filename: null,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const { t } = useI18n()
|
|
73
|
+
const orchestrator = inject<{ toast: { success: (...a: unknown[]) => void; error: (...a: unknown[]) => void } } | null>('qdadmOrchestrator', null)
|
|
74
|
+
|
|
75
|
+
const contentTypeHeader = computed(() => {
|
|
76
|
+
const headers = props.request?.headers ?? {}
|
|
77
|
+
const key = Object.keys(headers).find((k) => k.toLowerCase() === 'content-type')
|
|
78
|
+
return key ? String(headers[key]) : null
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const format = computed<PayloadFormat>(() => detectFormat(props.body, props.contentType ?? contentTypeHeader.value, props.format))
|
|
82
|
+
|
|
83
|
+
// --- Renderings ---
|
|
84
|
+
|
|
85
|
+
const json = computed(() => {
|
|
86
|
+
if (format.value !== 'json') return null
|
|
87
|
+
const parsed = parseJson(props.body)
|
|
88
|
+
return parsed.ok ? { ok: true as const, value: maskJson(parsed.value, props.mask) } : parsed
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const xml = computed(() => (format.value === 'xml' ? prettyXml(bodyText(props.body), props.mask) : null))
|
|
92
|
+
|
|
93
|
+
const csvHeader = ref(true)
|
|
94
|
+
const csvAll = ref(false)
|
|
95
|
+
const csv = computed(() => {
|
|
96
|
+
if (format.value !== 'csv') return null
|
|
97
|
+
const { separator, rows } = parseCsv(bodyText(props.body))
|
|
98
|
+
return { separator, rows: maskCsv(rows, props.mask, csvHeader.value) }
|
|
99
|
+
})
|
|
100
|
+
const csvHead = computed(() => (csv.value && csvHeader.value ? csv.value.rows[0] ?? [] : null))
|
|
101
|
+
const csvBody = computed(() => {
|
|
102
|
+
if (!csv.value) return []
|
|
103
|
+
const rows = csvHeader.value ? csv.value.rows.slice(1) : csv.value.rows
|
|
104
|
+
return csvAll.value ? rows : rows.slice(0, props.csvRows)
|
|
105
|
+
})
|
|
106
|
+
const csvTotal = computed(() => (csv.value ? csv.value.rows.length - (csvHeader.value ? 1 : 0) : 0))
|
|
107
|
+
const csvWidth = computed(() => Math.max(0, ...(csv.value?.rows.map((r) => r.length) ?? [0])))
|
|
108
|
+
|
|
109
|
+
const form = computed(() => (format.value === 'form' ? parseForm(bodyText(props.body), props.mask) : null))
|
|
110
|
+
|
|
111
|
+
const textLines = computed(() => (format.value === 'text' ? bodyText(props.body).replace(/\r\n?/g, '\n').split('\n') : []))
|
|
112
|
+
|
|
113
|
+
const bytes = computed(() => (format.value === 'binary' ? toBytes(props.body) : null))
|
|
114
|
+
const hex = computed(() => (bytes.value ? hexDump(bytes.value) : []))
|
|
115
|
+
|
|
116
|
+
/** Why the body is shown as text: the format it claims did not parse. */
|
|
117
|
+
const fallback = computed<string | null>(() => {
|
|
118
|
+
if (json.value && !json.value.ok) return t('core.payload.malformed', { format: 'JSON', error: json.value.error })
|
|
119
|
+
if (xml.value && !xml.value.ok) return t('core.payload.malformed', { format: 'XML', error: xml.value.error ?? '' })
|
|
120
|
+
return null
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// --- Request block ---
|
|
124
|
+
|
|
125
|
+
const headers = computed(() => maskHeaders(props.request?.headers, props.mask))
|
|
126
|
+
const statusClass = computed(() => {
|
|
127
|
+
const s = props.request?.status
|
|
128
|
+
if (!s) return ''
|
|
129
|
+
return s >= 500 ? 'pl-status--error' : s >= 400 ? 'pl-status--warn' : s >= 300 ? 'pl-status--info' : 'pl-status--ok'
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// --- Copy and download: what is shown, masked ---
|
|
133
|
+
|
|
134
|
+
const shownText = computed<string>(() => {
|
|
135
|
+
if (json.value?.ok) return JSON.stringify(json.value.value, null, 2)
|
|
136
|
+
if (xml.value?.ok) return xml.value.lines.map(piecesText).join('\n')
|
|
137
|
+
if (csv.value) return csv.value.rows.map((r) => r.map((c) => (/["\n]|[,;\t|]/.test(c) ? `"${c.replace(/"/g, '""')}"` : c)).join(csv.value!.separator)).join('\n')
|
|
138
|
+
if (form.value) return new URLSearchParams(form.value).toString()
|
|
139
|
+
return bodyText(props.body)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
async function copy(): Promise<void> {
|
|
143
|
+
try {
|
|
144
|
+
await navigator.clipboard.writeText(shownText.value)
|
|
145
|
+
orchestrator?.toast.success(t('core.payload.copied'), undefined, 'PayloadViewer')
|
|
146
|
+
} catch {
|
|
147
|
+
orchestrator?.toast.error(t('core.payload.copyFailed'), undefined, 'PayloadViewer')
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function download(): void {
|
|
152
|
+
const blob = isBinary(props.body) && format.value === 'binary'
|
|
153
|
+
? new Blob([toBytes(props.body) as BlobPart], { type: props.contentType ?? 'application/octet-stream' })
|
|
154
|
+
: new Blob([shownText.value], { type: `${props.contentType ?? 'text/plain'};charset=utf-8` })
|
|
155
|
+
const url = URL.createObjectURL(blob)
|
|
156
|
+
const a = document.createElement('a')
|
|
157
|
+
a.href = url
|
|
158
|
+
a.download = props.filename ?? `payload.${FORMAT_EXTENSIONS[format.value]}`
|
|
159
|
+
a.click()
|
|
160
|
+
setTimeout(() => URL.revokeObjectURL(url), 0)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
defineExpose({ format })
|
|
164
|
+
</script>
|
|
165
|
+
|
|
166
|
+
<template>
|
|
167
|
+
<div class="qd-payload" :data-format="format">
|
|
168
|
+
<div v-if="request" class="qd-payload-request">
|
|
169
|
+
<div class="qd-payload-line">
|
|
170
|
+
<span v-if="request.method" class="pl-method">{{ request.method.toUpperCase() }}</span>
|
|
171
|
+
<span v-if="request.url" class="pl-url">{{ request.url }}</span>
|
|
172
|
+
<span v-if="request.status" class="pl-status" :class="statusClass">{{ request.status }}</span>
|
|
173
|
+
</div>
|
|
174
|
+
<details v-if="headers.length" class="qd-payload-headers">
|
|
175
|
+
<summary>{{ t('core.payload.headers', { count: headers.length }) }}</summary>
|
|
176
|
+
<table>
|
|
177
|
+
<tbody>
|
|
178
|
+
<tr v-for="[name, value] in headers" :key="name">
|
|
179
|
+
<th>{{ name }}</th>
|
|
180
|
+
<td>{{ value }}</td>
|
|
181
|
+
</tr>
|
|
182
|
+
</tbody>
|
|
183
|
+
</table>
|
|
184
|
+
</details>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<div class="qd-payload-toolbar">
|
|
188
|
+
<span class="qd-payload-format">{{ format.toUpperCase() }}</span>
|
|
189
|
+
<span v-if="bytes" class="qd-payload-size">{{ formatBytes(bytes.length) }}</span>
|
|
190
|
+
<label v-if="csv" class="qd-payload-check">
|
|
191
|
+
<input v-model="csvHeader" type="checkbox" /> {{ t('core.payload.headerRow') }}
|
|
192
|
+
</label>
|
|
193
|
+
<span class="qd-payload-spacer" />
|
|
194
|
+
<QdButton v-if="format !== 'binary'" icon="pi pi-copy" text rounded size="small" :aria-label="t('core.payload.copy')"
|
|
195
|
+
v-tooltip.top="t('core.payload.copy')" @click="copy" />
|
|
196
|
+
<QdButton icon="pi pi-download" text rounded size="small" :aria-label="t('core.payload.download')"
|
|
197
|
+
v-tooltip.top="t('core.payload.download')" @click="download" />
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div v-if="fallback" class="qd-payload-warning"><i class="pi pi-exclamation-triangle" /> {{ fallback }}</div>
|
|
201
|
+
|
|
202
|
+
<div class="qd-payload-body" :style="{ maxHeight }">
|
|
203
|
+
<JsonViewer v-if="json && json.ok" :model-value="(json.value as never)" />
|
|
204
|
+
|
|
205
|
+
<pre v-else-if="xml" class="qd-payload-code"><template v-for="(line, i) in xml.lines" :key="i"><span class="pl-n">{{ i + 1 }}</span><span v-for="(p, j) in line" :key="j" :class="p.cls">{{ p.text }}</span>
|
|
206
|
+
</template></pre>
|
|
207
|
+
|
|
208
|
+
<div v-else-if="csv" class="qd-payload-table-wrap">
|
|
209
|
+
<table class="qd-payload-table">
|
|
210
|
+
<thead v-if="csvHead">
|
|
211
|
+
<tr>
|
|
212
|
+
<th class="pl-n" />
|
|
213
|
+
<th v-for="(h, i) in csvHead" :key="i">{{ h }}</th>
|
|
214
|
+
</tr>
|
|
215
|
+
</thead>
|
|
216
|
+
<tbody>
|
|
217
|
+
<tr v-for="(row, r) in csvBody" :key="r">
|
|
218
|
+
<td class="pl-n">{{ r + 1 }}</td>
|
|
219
|
+
<td v-for="c in csvWidth" :key="c">{{ row[c - 1] ?? '' }}</td>
|
|
220
|
+
</tr>
|
|
221
|
+
</tbody>
|
|
222
|
+
</table>
|
|
223
|
+
<button v-if="!csvAll && csvTotal > csvBody.length" type="button" class="qd-payload-more" @click="csvAll = true">
|
|
224
|
+
{{ t('core.payload.showAll', { count: csvTotal }) }}
|
|
225
|
+
</button>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<table v-else-if="form" class="qd-payload-table qd-payload-kv">
|
|
229
|
+
<tbody>
|
|
230
|
+
<tr v-for="([k, v], i) in form" :key="i">
|
|
231
|
+
<th>{{ k }}</th>
|
|
232
|
+
<td>{{ v }}</td>
|
|
233
|
+
</tr>
|
|
234
|
+
</tbody>
|
|
235
|
+
</table>
|
|
236
|
+
|
|
237
|
+
<div v-else-if="bytes" class="qd-payload-binary">
|
|
238
|
+
<p>{{ t('core.payload.binary', { size: formatBytes(bytes.length) }) }}</p>
|
|
239
|
+
<pre class="qd-payload-code">{{ hex.join('\n') }}</pre>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<pre v-else class="qd-payload-code"><template v-for="(line, i) in (fallback ? bodyText(body).split('\n') : textLines)" :key="i"><span class="pl-n">{{ i + 1 }}</span>{{ line }}
|
|
243
|
+
</template></pre>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
</template>
|
package/src/components/index.ts
CHANGED
|
@@ -71,6 +71,14 @@ export { default as RichCardsGrid } from './display/RichCardsGrid.vue'
|
|
|
71
71
|
export { default as CopyableId } from './display/CopyableId.vue'
|
|
72
72
|
export { default as EmptyState } from './display/EmptyState.vue'
|
|
73
73
|
export { default as IntensityBar } from './display/IntensityBar.vue'
|
|
74
|
+
export { default as LogStream } from './display/LogStream.vue'
|
|
75
|
+
export { default as EntityTimeline } from './display/EntityTimeline.vue'
|
|
76
|
+
export { default as PayloadViewer } from './display/PayloadViewer.vue'
|
|
77
|
+
export { default as PayloadDiff } from './display/PayloadDiff.vue'
|
|
78
|
+
export { default as EmailPreview } from './display/EmailPreview.vue'
|
|
79
|
+
export { default as TaskDialog } from './tasks/TaskDialog.vue'
|
|
80
|
+
export { default as TaskHost } from './tasks/TaskHost.vue'
|
|
81
|
+
export { default as TaskTray } from './tasks/TaskTray.vue'
|
|
74
82
|
export { default as BoolCell } from './BoolCell.vue'
|
|
75
83
|
export { default as SeverityTag } from './SeverityTag.vue'
|
|
76
84
|
export { default as InfoBanner } from './InfoBanner.vue'
|
|
@@ -24,6 +24,8 @@ import { useNavContext, type BreadcrumbItem, type NavLinkItem } from '../../comp
|
|
|
24
24
|
import QdButton from '../base/QdButton.vue'
|
|
25
25
|
import Breadcrumb from 'primevue/breadcrumb'
|
|
26
26
|
import UnsavedChangesDialog from '../dialogs/UnsavedChangesDialog.vue'
|
|
27
|
+
import TaskHost from '../tasks/TaskHost.vue'
|
|
28
|
+
import TaskTray from '../tasks/TaskTray.vue'
|
|
27
29
|
import SidebarBox from './SidebarBox.vue'
|
|
28
30
|
import Zone from './Zone.vue'
|
|
29
31
|
import NavlinksGroup from './NavlinksGroup.vue'
|
|
@@ -313,17 +315,29 @@ const showBreadcrumb = computed<boolean>(() => {
|
|
|
313
315
|
></i>
|
|
314
316
|
</div>
|
|
315
317
|
<div class="nav-section-items" :class="{ 'nav-section-collapsed': !isSectionExpanded(section) }">
|
|
316
|
-
<
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
318
|
+
<template v-for="item in section.items" :key="item.route ?? item.href">
|
|
319
|
+
<a
|
|
320
|
+
v-if="item.href"
|
|
321
|
+
:href="item.href"
|
|
322
|
+
:target="item.target"
|
|
323
|
+
:rel="item.rel"
|
|
324
|
+
class="nav-item nav-item-link"
|
|
325
|
+
>
|
|
326
|
+
<i :class="item.icon"></i>
|
|
327
|
+
<span>{{ item.label }}</span>
|
|
328
|
+
<span v-if="item.external" class="pi pi-external-link nav-item-external" aria-label="opens in a new tab"></span>
|
|
329
|
+
</a>
|
|
330
|
+
<RouterLink
|
|
331
|
+
v-else
|
|
332
|
+
:to="{ name: item.route }"
|
|
333
|
+
class="nav-item"
|
|
334
|
+
:class="{ 'nav-item-active': isNavActive(item) }"
|
|
335
|
+
@click="handleNavClick($event, item)"
|
|
336
|
+
>
|
|
337
|
+
<i :class="item.icon"></i>
|
|
338
|
+
<span>{{ item.label }}</span>
|
|
339
|
+
</RouterLink>
|
|
340
|
+
</template>
|
|
327
341
|
</div>
|
|
328
342
|
</div>
|
|
329
343
|
</nav>
|
|
@@ -333,6 +347,9 @@ const showBreadcrumb = computed<boolean>(() => {
|
|
|
333
347
|
<slot name="sidebar-after-nav"></slot>
|
|
334
348
|
</div>
|
|
335
349
|
|
|
350
|
+
<!-- Running tasks (#4114): shown while there is one -->
|
|
351
|
+
<TaskTray />
|
|
352
|
+
|
|
336
353
|
<SidebarBox
|
|
337
354
|
v-if="authEnabled"
|
|
338
355
|
id="user-zone"
|
|
@@ -409,6 +426,9 @@ const showBreadcrumb = computed<boolean>(() => {
|
|
|
409
426
|
<!-- Notification panel (rendered outside sidebar, follows sidebar position) -->
|
|
410
427
|
<Zone name="_app:notifications" />
|
|
411
428
|
|
|
429
|
+
<!-- Task follow dialog and typed confirmations (#4114) -->
|
|
430
|
+
<TaskHost />
|
|
431
|
+
|
|
412
432
|
<!-- Unsaved Changes Dialog (auto-rendered when a form registers guardDialog) -->
|
|
413
433
|
<UnsavedChangesDialog
|
|
414
434
|
v-if="guardDialog"
|
|
@@ -110,17 +110,29 @@ watch(() => route?.path, () => {
|
|
|
110
110
|
></i>
|
|
111
111
|
</div>
|
|
112
112
|
<div class="nav-section-items" :class="{ 'nav-section-collapsed': !isSectionExpanded(section) }">
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
113
|
+
<template v-for="item in section.items" :key="item.route ?? item.href">
|
|
114
|
+
<a
|
|
115
|
+
v-if="item.href"
|
|
116
|
+
:href="item.href"
|
|
117
|
+
:target="item.target"
|
|
118
|
+
:rel="item.rel"
|
|
119
|
+
class="nav-item nav-item-link"
|
|
120
|
+
>
|
|
121
|
+
<i :class="item.icon"></i>
|
|
122
|
+
<span>{{ item.label }}</span>
|
|
123
|
+
<span v-if="item.external" class="pi pi-external-link nav-item-external" aria-label="opens in a new tab"></span>
|
|
124
|
+
</a>
|
|
125
|
+
<RouterLink
|
|
126
|
+
v-else
|
|
127
|
+
:to="{ name: item.route }"
|
|
128
|
+
class="nav-item"
|
|
129
|
+
:class="{ 'nav-item-active': isNavActive(item) }"
|
|
130
|
+
@click="handleNavClick($event, item)"
|
|
131
|
+
>
|
|
132
|
+
<i :class="item.icon"></i>
|
|
133
|
+
<span>{{ item.label }}</span>
|
|
134
|
+
</RouterLink>
|
|
135
|
+
</template>
|
|
124
136
|
</div>
|
|
125
137
|
</div>
|
|
126
138
|
</nav>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* TaskDialog - follow one task run (#4114)
|
|
4
|
+
*
|
|
5
|
+
* Status, progress (indeterminate without a figure), current step, the live
|
|
6
|
+
* log, then the result or the error, a link to the task, and cancel when the
|
|
7
|
+
* backend allows it. Closing it does not stop anything: the run goes on in
|
|
8
|
+
* the tray. TaskHost renders it; a page may render its own with a #result slot.
|
|
9
|
+
*/
|
|
10
|
+
import { computed, onBeforeUnmount, ref } from 'vue'
|
|
11
|
+
import { RouterLink } from 'vue-router'
|
|
12
|
+
import Dialog from 'primevue/dialog'
|
|
13
|
+
import ProgressBar from 'primevue/progressbar'
|
|
14
|
+
import QdButton from '../base/QdButton.vue'
|
|
15
|
+
import LogStream from '../display/LogStream.vue'
|
|
16
|
+
import JsonViewer from '../editors/JsonViewer.vue'
|
|
17
|
+
import { useI18n } from '../../i18n/useI18n'
|
|
18
|
+
import { getTaskStore } from '../../tasks/taskStore'
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
runId: string | null
|
|
22
|
+
width?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const props = withDefaults(defineProps<Props>(), { width: '46rem' })
|
|
26
|
+
const emit = defineEmits<{ close: [] }>()
|
|
27
|
+
|
|
28
|
+
const { t } = useI18n()
|
|
29
|
+
const store = getTaskStore()
|
|
30
|
+
|
|
31
|
+
const run = computed(() => (props.runId ? store.state.runs.find((r) => r.id === props.runId) ?? null : null))
|
|
32
|
+
const def = computed(() => (props.runId ? store.definition(props.runId) : undefined))
|
|
33
|
+
const final = computed(() => (run.value ? store.isFinal(run.value.status) : false))
|
|
34
|
+
const logSource = computed(() => (run.value?.taskId != null && def.value?.log ? def.value.log(run.value.taskId) : null))
|
|
35
|
+
const link = computed(() => (run.value?.taskId != null && def.value?.to ? def.value.to(run.value.taskId) : null))
|
|
36
|
+
const canCancel = computed(() => !!def.value?.cancel && !final.value && run.value?.taskId != null)
|
|
37
|
+
const resultIsObject = computed(() => run.value?.result !== null && typeof run.value?.result === 'object')
|
|
38
|
+
|
|
39
|
+
const now = ref(Date.now())
|
|
40
|
+
const ticker = setInterval(() => (now.value = Date.now()), 1000)
|
|
41
|
+
onBeforeUnmount(() => clearInterval(ticker))
|
|
42
|
+
|
|
43
|
+
const elapsed = computed(() => {
|
|
44
|
+
if (!run.value) return ''
|
|
45
|
+
const ms = (run.value.endedAt ?? now.value) - run.value.startedAt
|
|
46
|
+
const s = Math.round(ms / 1000)
|
|
47
|
+
return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min ${s % 60} s`
|
|
48
|
+
})
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<Dialog :visible="!!run" modal :header="run?.label" :style="{ width }" :breakpoints="{ '640px': '96vw' }"
|
|
53
|
+
class="qd-task-dialog" @update:visible="(v: boolean) => !v && emit('close')">
|
|
54
|
+
<template v-if="run">
|
|
55
|
+
<div class="qd-task-head">
|
|
56
|
+
<span class="qd-task-status" :class="`qd-task-status--${run.status}`">
|
|
57
|
+
<i v-if="!final" class="pi pi-spin pi-spinner" />
|
|
58
|
+
<i v-else-if="run.status === 'succeeded'" class="pi pi-check-circle" />
|
|
59
|
+
<i v-else-if="run.status === 'failed'" class="pi pi-times-circle" />
|
|
60
|
+
<i v-else class="pi pi-ban" />
|
|
61
|
+
{{ t(`core.task.status.${run.status}`) }}
|
|
62
|
+
</span>
|
|
63
|
+
<span class="qd-task-elapsed">{{ elapsed }}</span>
|
|
64
|
+
<span v-if="run.taskId !== null" class="qd-task-id">#{{ run.taskId }}</span>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<ProgressBar v-if="!final" class="qd-task-progress" :mode="run.progress === null ? 'indeterminate' : 'determinate'"
|
|
68
|
+
:value="Math.round(run.progress ?? 0)" :show-value="run.progress !== null" />
|
|
69
|
+
<div v-if="run.step" class="qd-task-step">{{ run.step }}</div>
|
|
70
|
+
|
|
71
|
+
<LogStream v-if="logSource" :key="run.id" class="qd-task-log" :source="logSource" height="16rem"
|
|
72
|
+
error-pattern="error|fatal|failed" />
|
|
73
|
+
|
|
74
|
+
<div v-if="run.status === 'failed' && run.error" class="qd-task-error"><i class="pi pi-exclamation-triangle" /> {{ run.error }}</div>
|
|
75
|
+
<div v-else-if="!final && run.error" class="qd-task-warning">{{ t('core.task.readError', { error: run.error }) }}</div>
|
|
76
|
+
|
|
77
|
+
<div v-if="run.status === 'succeeded' && run.result !== null" class="qd-task-result">
|
|
78
|
+
<slot name="result" :run="run">
|
|
79
|
+
<JsonViewer v-if="resultIsObject" :model-value="(run.result as never)" height="12rem" />
|
|
80
|
+
<span v-else>{{ run.result }}</span>
|
|
81
|
+
</slot>
|
|
82
|
+
</div>
|
|
83
|
+
</template>
|
|
84
|
+
|
|
85
|
+
<template #footer>
|
|
86
|
+
<RouterLink v-if="link" :to="(link as never)" class="qd-task-link" @click="emit('close')">
|
|
87
|
+
<i class="pi pi-external-link" /> {{ t('core.task.open') }}
|
|
88
|
+
</RouterLink>
|
|
89
|
+
<span class="qd-task-spacer" />
|
|
90
|
+
<QdButton v-if="canCancel" severity="danger" text :loading="run?.cancelling" icon="pi pi-stop-circle"
|
|
91
|
+
:label="t('core.task.cancel')" @click="run && store.cancel(run.id)" />
|
|
92
|
+
<QdButton :label="final ? t('core.task.close') : t('core.task.background')" severity="secondary" @click="emit('close')" />
|
|
93
|
+
</template>
|
|
94
|
+
</Dialog>
|
|
95
|
+
</template>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* TaskHost - where task dialogs live (#4114)
|
|
4
|
+
*
|
|
5
|
+
* Renders the follow dialog of the open run and the typed confirmation of a
|
|
6
|
+
* dangerous task. AppLayout mounts one; an app with its own layout mounts
|
|
7
|
+
* it once, near the root. Without a host, a task that asks for confirmation
|
|
8
|
+
* does not start.
|
|
9
|
+
*/
|
|
10
|
+
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
11
|
+
import InputText from 'primevue/inputtext'
|
|
12
|
+
import SimpleDialog from '../dialogs/SimpleDialog.vue'
|
|
13
|
+
import TaskDialog from './TaskDialog.vue'
|
|
14
|
+
import { useI18n } from '../../i18n/useI18n'
|
|
15
|
+
import { useTaskRunner } from '../../tasks/useTaskRunner'
|
|
16
|
+
import { getTaskStore } from '../../tasks/taskStore'
|
|
17
|
+
|
|
18
|
+
useTaskRunner()
|
|
19
|
+
const store = getTaskStore()
|
|
20
|
+
const { t } = useI18n()
|
|
21
|
+
|
|
22
|
+
store.state.hosts++
|
|
23
|
+
onBeforeUnmount(() => store.state.hosts--)
|
|
24
|
+
|
|
25
|
+
const pending = computed(() => store.state.pendingConfirm)
|
|
26
|
+
const typed = ref('')
|
|
27
|
+
watch(pending, () => (typed.value = ''))
|
|
28
|
+
const expected = computed(() => pending.value?.confirm.typed ?? null)
|
|
29
|
+
const confirmable = computed(() => expected.value === null || typed.value === expected.value)
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<TaskDialog :run-id="store.state.openId" @close="store.open(null)" />
|
|
34
|
+
<SimpleDialog :visible="!!pending" :title="pending?.label ?? ''" :confirm-label="pending?.confirm.accept ?? t('core.task.start')"
|
|
35
|
+
confirm-severity="danger" :confirm-disabled="!confirmable" :cancel-label="t('core.task.dontStart')"
|
|
36
|
+
@confirm="store.answerConfirm(true)" @cancel="store.answerConfirm(false)"
|
|
37
|
+
@update:visible="(v: boolean) => !v && pending && store.answerConfirm(false)">
|
|
38
|
+
<p v-if="pending?.confirm.message" class="qd-task-confirm-message">{{ pending.confirm.message }}</p>
|
|
39
|
+
<template v-if="expected !== null">
|
|
40
|
+
<label class="qd-task-confirm-label" for="qd-task-typed">{{ t('core.task.typeToConfirm', { value: expected }) }}</label>
|
|
41
|
+
<InputText id="qd-task-typed" v-model="typed" class="qd-task-confirm-input" autocomplete="off" spellcheck="false"
|
|
42
|
+
@keydown.enter="confirmable && store.answerConfirm(true)" />
|
|
43
|
+
</template>
|
|
44
|
+
</SimpleDialog>
|
|
45
|
+
</template>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* TaskTray - the running tasks, in the sidebar (#4114)
|
|
4
|
+
*
|
|
5
|
+
* Shown while there is a run to show. Each run reopens its dialog; finished
|
|
6
|
+
* ones can be cleared.
|
|
7
|
+
*/
|
|
8
|
+
import { computed, ref } from 'vue'
|
|
9
|
+
import Popover from 'primevue/popover'
|
|
10
|
+
import QdButton from '../base/QdButton.vue'
|
|
11
|
+
import { useI18n } from '../../i18n/useI18n'
|
|
12
|
+
import { getTaskStore } from '../../tasks/taskStore'
|
|
13
|
+
|
|
14
|
+
const store = getTaskStore()
|
|
15
|
+
const { t } = useI18n()
|
|
16
|
+
const popover = ref<InstanceType<typeof Popover> | null>(null)
|
|
17
|
+
|
|
18
|
+
const runs = computed(() => store.state.runs)
|
|
19
|
+
const activeCount = computed(() => store.active.value.length)
|
|
20
|
+
const hasFinished = computed(() => runs.value.some((r) => store.isFinal(r.status)))
|
|
21
|
+
|
|
22
|
+
function openRun(id: string): void {
|
|
23
|
+
store.open(id)
|
|
24
|
+
popover.value?.hide()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const icon = (status: string) =>
|
|
28
|
+
({ succeeded: 'pi pi-check-circle', failed: 'pi pi-times-circle', cancelled: 'pi pi-ban' })[status] ?? 'pi pi-spin pi-spinner'
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<div v-if="runs.length" class="qd-task-tray">
|
|
33
|
+
<button type="button" class="qd-task-tray-button" :class="{ 'qd-task-tray-button--busy': activeCount > 0 }"
|
|
34
|
+
:aria-label="t('core.task.tray', { count: activeCount })" @click="popover?.toggle($event)">
|
|
35
|
+
<i :class="activeCount ? 'pi pi-spin pi-cog' : 'pi pi-check'" />
|
|
36
|
+
<span class="qd-task-tray-label">{{ activeCount ? t('core.task.tray', { count: activeCount }) : t('core.task.trayDone') }}</span>
|
|
37
|
+
<span v-if="activeCount" class="qd-task-tray-count">{{ activeCount }}</span>
|
|
38
|
+
</button>
|
|
39
|
+
<Popover ref="popover" class="qd-task-popover">
|
|
40
|
+
<ul class="qd-task-list">
|
|
41
|
+
<li v-for="r in runs" :key="r.id">
|
|
42
|
+
<button type="button" class="qd-task-item" :class="`qd-task-item--${r.status}`" @click="openRun(r.id)">
|
|
43
|
+
<i :class="icon(r.status)" />
|
|
44
|
+
<span class="qd-task-item-label">{{ r.label }}</span>
|
|
45
|
+
<span class="qd-task-item-state">
|
|
46
|
+
{{ r.progress !== null && !store.isFinal(r.status) ? `${Math.round(r.progress)} %` : t(`core.task.status.${r.status}`) }}
|
|
47
|
+
</span>
|
|
48
|
+
</button>
|
|
49
|
+
</li>
|
|
50
|
+
</ul>
|
|
51
|
+
<QdButton v-if="hasFinished" size="small" text icon="pi pi-trash" :label="t('core.task.clear')" @click="store.clearFinished()" />
|
|
52
|
+
</Popover>
|
|
53
|
+
</div>
|
|
54
|
+
</template>
|
package/src/composables/index.ts
CHANGED
|
@@ -110,6 +110,17 @@ export {
|
|
|
110
110
|
type LayoutMeta,
|
|
111
111
|
} from './useLayoutResolver'
|
|
112
112
|
export { useSSEBridge, type UseSSEBridgeReturn } from './useSSEBridge'
|
|
113
|
+
export {
|
|
114
|
+
useLogStream,
|
|
115
|
+
type LogSource,
|
|
116
|
+
type LogLine,
|
|
117
|
+
type LogPollResult,
|
|
118
|
+
type LogPushApi,
|
|
119
|
+
type LogStreamStatus,
|
|
120
|
+
type UseLogStreamOptions,
|
|
121
|
+
type UseLogStreamReturn,
|
|
122
|
+
} from './useLogStream'
|
|
123
|
+
export { useTaskRunner, taskAction } from '../tasks/useTaskRunner'
|
|
113
124
|
export {
|
|
114
125
|
useDeferred,
|
|
115
126
|
useDeferredValue,
|
|
@@ -23,7 +23,10 @@ export interface UseListFiltersDeps {
|
|
|
23
23
|
/** Entity name used for the registry lookup. */
|
|
24
24
|
entityName: string
|
|
25
25
|
/** EntityManager (needs .request for optionsEndpoint mode). */
|
|
26
|
-
manager: {
|
|
26
|
+
manager: {
|
|
27
|
+
request: (method: string, endpoint: string) => Promise<unknown>
|
|
28
|
+
storage?: { supportsPathRequests?: boolean } | null
|
|
29
|
+
}
|
|
27
30
|
/** Orchestrator, forwarded to FilterQuery for optionsEntity mode. */
|
|
28
31
|
orchestrator: QueryOrchestratorLike | null | undefined
|
|
29
32
|
/** Loaded list items (source for optionsFromCache mode). */
|
|
@@ -184,9 +187,31 @@ export function useListFilters(deps: UseListFiltersDeps): UseListFiltersReturn {
|
|
|
184
187
|
)
|
|
185
188
|
}
|
|
186
189
|
|
|
190
|
+
const warnedEndpoints = new Set<string>()
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* An `optionsEndpoint` filter on a storage that cannot run a path request
|
|
194
|
+
* (#4069) — an `SdkStorage` without its `request` option. The load fails
|
|
195
|
+
* later, behind a dropdown reading "No available options"; said here, by
|
|
196
|
+
* name, where the declaration can still be fixed.
|
|
197
|
+
*/
|
|
198
|
+
function warnIfEndpointUnreachable(name: string, filterConfig: FilterOptions): void {
|
|
199
|
+
if (!filterConfig?.optionsEndpoint) return
|
|
200
|
+
if (manager?.storage?.supportsPathRequests !== false) return
|
|
201
|
+
if (warnedEndpoints.has(name)) return
|
|
202
|
+
warnedEndpoints.add(name)
|
|
203
|
+
console.warn(
|
|
204
|
+
`[qdadm] Filter "${name}" on "${entityName}" loads its options from an ` +
|
|
205
|
+
`endpoint, but this entity's storage cannot run a path request, so the ` +
|
|
206
|
+
`dropdown will stay empty. Give the SdkStorage a request option, or ` +
|
|
207
|
+
`pass the filter its options directly.`
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
187
211
|
function addFilter(name: string, filterConfig: FilterOptions): void {
|
|
188
212
|
warnIfReservedName(name)
|
|
189
213
|
warnIfUnknownType(name, filterConfig?.type)
|
|
214
|
+
warnIfEndpointUnreachable(name, filterConfig)
|
|
190
215
|
filtersMap.value.set(name, {
|
|
191
216
|
name,
|
|
192
217
|
type: 'select',
|