@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,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EntityTimeline (#4111)
|
|
3
|
+
* One column; each event's marker carries its actor's colour (--qd-actor).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
@use 'variables' as *;
|
|
7
|
+
|
|
8
|
+
.qd-timeline {
|
|
9
|
+
position: relative;
|
|
10
|
+
min-width: 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.qd-timeline-toolbar {
|
|
14
|
+
display: flex;
|
|
15
|
+
flex-wrap: wrap;
|
|
16
|
+
align-items: center;
|
|
17
|
+
gap: 0.5rem;
|
|
18
|
+
margin-bottom: 0.75rem;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.qd-timeline-actors,
|
|
22
|
+
.qd-timeline-gaps {
|
|
23
|
+
display: flex;
|
|
24
|
+
flex-wrap: wrap;
|
|
25
|
+
gap: 0.375rem;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.qd-timeline-gaps {
|
|
29
|
+
margin-left: auto;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.qd-timeline-chip {
|
|
33
|
+
display: inline-flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
gap: 0.375rem;
|
|
36
|
+
padding: 0.125rem 0.625rem;
|
|
37
|
+
border: 1px solid var(--qd-actor);
|
|
38
|
+
border-radius: 999px;
|
|
39
|
+
background: color-mix(in srgb, var(--qd-actor) 14%, transparent);
|
|
40
|
+
color: var(--p-text-color);
|
|
41
|
+
font-size: $font-size-xs;
|
|
42
|
+
cursor: pointer;
|
|
43
|
+
|
|
44
|
+
i {
|
|
45
|
+
color: var(--qd-actor);
|
|
46
|
+
font-size: 0.75rem;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
&--off {
|
|
50
|
+
opacity: 0.45;
|
|
51
|
+
background: transparent;
|
|
52
|
+
text-decoration: line-through;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.qd-timeline-gap-chip {
|
|
57
|
+
display: inline-flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: 0.375rem;
|
|
60
|
+
padding: 0.125rem 0.625rem;
|
|
61
|
+
border-radius: 999px;
|
|
62
|
+
background: var(--p-surface-100);
|
|
63
|
+
font-size: $font-size-xs;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.qd-timeline-error {
|
|
67
|
+
color: var(--p-red-500);
|
|
68
|
+
margin-bottom: 0.5rem;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.qd-timeline-empty {
|
|
72
|
+
color: var(--p-text-muted-color);
|
|
73
|
+
font-style: italic;
|
|
74
|
+
padding: 0.5rem 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.qd-timeline-list {
|
|
78
|
+
list-style: none;
|
|
79
|
+
margin: 0;
|
|
80
|
+
padding: 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.qd-timeline-day {
|
|
84
|
+
margin: 0.75rem 0 0.25rem;
|
|
85
|
+
font-size: $font-size-xs;
|
|
86
|
+
font-weight: 600;
|
|
87
|
+
text-transform: uppercase;
|
|
88
|
+
letter-spacing: 0.04em;
|
|
89
|
+
color: var(--p-text-muted-color);
|
|
90
|
+
|
|
91
|
+
&:first-child {
|
|
92
|
+
margin-top: 0;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.qd-timeline-event {
|
|
97
|
+
position: relative;
|
|
98
|
+
display: flex;
|
|
99
|
+
gap: 0.75rem;
|
|
100
|
+
padding: 0 0 0.75rem;
|
|
101
|
+
|
|
102
|
+
// The rail
|
|
103
|
+
&::before {
|
|
104
|
+
content: '';
|
|
105
|
+
position: absolute;
|
|
106
|
+
left: 0.875rem;
|
|
107
|
+
top: 1.75rem;
|
|
108
|
+
bottom: 0;
|
|
109
|
+
width: 2px;
|
|
110
|
+
background: var(--p-surface-200);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
&:last-child::before {
|
|
114
|
+
display: none;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
&--fresh .qd-timeline-content {
|
|
118
|
+
animation: qd-timeline-flash 2.5s ease-out;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@keyframes qd-timeline-flash {
|
|
123
|
+
0% {
|
|
124
|
+
background: color-mix(in srgb, var(--p-primary-color) 22%, transparent);
|
|
125
|
+
}
|
|
126
|
+
100% {
|
|
127
|
+
background: transparent;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.qd-timeline-since {
|
|
132
|
+
position: absolute;
|
|
133
|
+
left: 2.5rem;
|
|
134
|
+
top: -0.7rem;
|
|
135
|
+
font-size: 0.6875rem;
|
|
136
|
+
color: var(--p-text-muted-color);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.qd-timeline-marker {
|
|
140
|
+
flex: none;
|
|
141
|
+
display: inline-flex;
|
|
142
|
+
align-items: center;
|
|
143
|
+
justify-content: center;
|
|
144
|
+
width: 1.75rem;
|
|
145
|
+
height: 1.75rem;
|
|
146
|
+
border-radius: 50%;
|
|
147
|
+
background: var(--qd-actor);
|
|
148
|
+
color: #fff;
|
|
149
|
+
font-size: 0.8125rem;
|
|
150
|
+
z-index: 1;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.qd-timeline-content {
|
|
154
|
+
flex: 1;
|
|
155
|
+
min-width: 0;
|
|
156
|
+
border-radius: $radius-sm;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.qd-timeline-head {
|
|
160
|
+
display: flex;
|
|
161
|
+
flex-wrap: wrap;
|
|
162
|
+
align-items: baseline;
|
|
163
|
+
gap: 0.25rem 0.625rem;
|
|
164
|
+
width: 100%;
|
|
165
|
+
padding: 0.25rem 0.375rem;
|
|
166
|
+
border: none;
|
|
167
|
+
border-radius: $radius-sm;
|
|
168
|
+
background: transparent;
|
|
169
|
+
color: inherit;
|
|
170
|
+
font: inherit;
|
|
171
|
+
text-align: left;
|
|
172
|
+
|
|
173
|
+
&:is(button) {
|
|
174
|
+
cursor: pointer;
|
|
175
|
+
|
|
176
|
+
&:hover {
|
|
177
|
+
background: var(--p-surface-100);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.qd-timeline-title {
|
|
183
|
+
font-weight: 600;
|
|
184
|
+
|
|
185
|
+
.qd-timeline-event--danger & {
|
|
186
|
+
color: var(--p-red-600);
|
|
187
|
+
}
|
|
188
|
+
.qd-timeline-event--warn & {
|
|
189
|
+
color: var(--p-orange-600);
|
|
190
|
+
}
|
|
191
|
+
.qd-timeline-event--success & {
|
|
192
|
+
color: var(--p-green-600);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.qd-timeline-actor {
|
|
197
|
+
font-size: $font-size-xs;
|
|
198
|
+
color: var(--qd-actor);
|
|
199
|
+
font-weight: 500;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.qd-timeline-time {
|
|
203
|
+
margin-left: auto;
|
|
204
|
+
font-size: $font-size-xs;
|
|
205
|
+
color: var(--p-text-muted-color);
|
|
206
|
+
font-variant-numeric: tabular-nums;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.qd-timeline-chevron {
|
|
210
|
+
font-size: 0.75rem;
|
|
211
|
+
color: var(--p-text-muted-color);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.qd-timeline-detail {
|
|
215
|
+
padding: 0.5rem 0.375rem 0.25rem;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.qd-timeline-absolute {
|
|
219
|
+
font-size: $font-size-xs;
|
|
220
|
+
color: var(--p-text-muted-color);
|
|
221
|
+
margin-bottom: 0.5rem;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.qd-timeline-new {
|
|
225
|
+
position: absolute;
|
|
226
|
+
left: 50%;
|
|
227
|
+
bottom: 0.75rem;
|
|
228
|
+
transform: translateX(-50%);
|
|
229
|
+
}
|
package/src/styles/index.scss
CHANGED
|
@@ -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
|
+
}
|