@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,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>
@@ -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'
@@ -345,6 +347,9 @@ const showBreadcrumb = computed<boolean>(() => {
345
347
  <slot name="sidebar-after-nav"></slot>
346
348
  </div>
347
349
 
350
+ <!-- Running tasks (#4114): shown while there is one -->
351
+ <TaskTray />
352
+
348
353
  <SidebarBox
349
354
  v-if="authEnabled"
350
355
  id="user-zone"
@@ -421,6 +426,9 @@ const showBreadcrumb = computed<boolean>(() => {
421
426
  <!-- Notification panel (rendered outside sidebar, follows sidebar position) -->
422
427
  <Zone name="_app:notifications" />
423
428
 
429
+ <!-- Task follow dialog and typed confirmations (#4114) -->
430
+ <TaskHost />
431
+
424
432
  <!-- Unsaved Changes Dialog (auto-rendered when a form registers guardDialog) -->
425
433
  <UnsavedChangesDialog
426
434
  v-if="guardDialog"
@@ -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>
@@ -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: { request: (method: string, endpoint: string) => Promise<unknown> }
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',