task_progress 0.0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/LICENSE +21 -0
- data/MANUAL.md +851 -0
- data/README.md +158 -0
- data/RECIPES.md +413 -0
- data/config/importmap.rb +17 -0
- data/exe/task_progress +51 -0
- data/js/local_tasks.d.ts +44 -0
- data/js/local_tasks.js +229 -0
- data/js/package.json +31 -0
- data/js/paths.d.ts +5 -0
- data/js/paths.js +7 -0
- data/js/react_task_tree.d.ts +49 -0
- data/js/react_task_tree.js +123 -0
- data/js/render_queue.d.ts +24 -0
- data/js/render_queue.js +79 -0
- data/js/row_view.d.ts +25 -0
- data/js/row_view.js +69 -0
- data/js/stimulus_controller.d.ts +10 -0
- data/js/stimulus_controller.js +137 -0
- data/js/task_progress_clock.d.ts +2 -0
- data/js/task_progress_clock.js +33 -0
- data/js/task_tree.css +51 -0
- data/js/task_tree.d.ts +111 -0
- data/js/task_tree.js +444 -0
- data/lib/task_progress/assertions.rb +49 -0
- data/lib/task_progress/batch.rb +23 -0
- data/lib/task_progress/branch.rb +74 -0
- data/lib/task_progress/buffered_output.rb +63 -0
- data/lib/task_progress/cancellations_app.rb +76 -0
- data/lib/task_progress/event.rb +46 -0
- data/lib/task_progress/execution_log_output.rb +183 -0
- data/lib/task_progress/fold.rb +18 -0
- data/lib/task_progress/html_renderer.rb +153 -0
- data/lib/task_progress/job_serializer.rb +92 -0
- data/lib/task_progress/journal.rb +35 -0
- data/lib/task_progress/log.rb +263 -0
- data/lib/task_progress/memory_cache.rb +31 -0
- data/lib/task_progress/memory_stream_store.rb +142 -0
- data/lib/task_progress/mount.rb +448 -0
- data/lib/task_progress/ndjson_output.rb +24 -0
- data/lib/task_progress/node.rb +52 -0
- data/lib/task_progress/poll.rb +87 -0
- data/lib/task_progress/publishing_output.rb +30 -0
- data/lib/task_progress/ractor_output.rb +23 -0
- data/lib/task_progress/rails_defaults.rb +46 -0
- data/lib/task_progress/railtie.rb +91 -0
- data/lib/task_progress/recording_output.rb +51 -0
- data/lib/task_progress/redis_stream_store.rb +131 -0
- data/lib/task_progress/squasher.rb +59 -0
- data/lib/task_progress/sse.rb +114 -0
- data/lib/task_progress/state.rb +319 -0
- data/lib/task_progress/stream_output.rb +222 -0
- data/lib/task_progress/stream_reader.rb +76 -0
- data/lib/task_progress/summary_output.rb +37 -0
- data/lib/task_progress/terminal_output.rb +241 -0
- data/lib/task_progress/token.rb +58 -0
- data/lib/task_progress/tracked_io.rb +68 -0
- data/lib/task_progress/transaction.rb +41 -0
- data/lib/task_progress/trees_app.rb +66 -0
- data/lib/task_progress/turbo_helper.rb +18 -0
- data/lib/task_progress/turbo_sse.rb +103 -0
- data/lib/task_progress/turbo_stream_output.rb +61 -0
- data/lib/task_progress/version.rb +7 -0
- data/lib/task_progress/wire.rb +211 -0
- data/lib/task_progress.rb +1036 -0
- data/sig/generated/task_progress/assertions.rbs +36 -0
- data/sig/generated/task_progress/batch.rbs +33 -0
- data/sig/generated/task_progress/branch.rbs +47 -0
- data/sig/generated/task_progress/buffered_output.rbs +38 -0
- data/sig/generated/task_progress/cancellations_app.rbs +41 -0
- data/sig/generated/task_progress/event.rbs +20 -0
- data/sig/generated/task_progress/execution_log_output.rbs +93 -0
- data/sig/generated/task_progress/fold.rbs +25 -0
- data/sig/generated/task_progress/html_renderer.rbs +71 -0
- data/sig/generated/task_progress/journal.rbs +25 -0
- data/sig/generated/task_progress/log.rbs +140 -0
- data/sig/generated/task_progress/memory_cache.rbs +28 -0
- data/sig/generated/task_progress/memory_stream_store.rbs +83 -0
- data/sig/generated/task_progress/mount.rbs +200 -0
- data/sig/generated/task_progress/ndjson_output.rbs +18 -0
- data/sig/generated/task_progress/node.rbs +66 -0
- data/sig/generated/task_progress/poll.rbs +50 -0
- data/sig/generated/task_progress/publishing_output.rbs +24 -0
- data/sig/generated/task_progress/ractor_output.rbs +17 -0
- data/sig/generated/task_progress/rails_defaults.rbs +33 -0
- data/sig/generated/task_progress/recording_output.rbs +41 -0
- data/sig/generated/task_progress/redis_stream_store.rbs +75 -0
- data/sig/generated/task_progress/squasher.rbs +20 -0
- data/sig/generated/task_progress/sse.rbs +73 -0
- data/sig/generated/task_progress/state.rbs +165 -0
- data/sig/generated/task_progress/stream_output.rbs +142 -0
- data/sig/generated/task_progress/stream_reader.rbs +46 -0
- data/sig/generated/task_progress/summary_output.rbs +19 -0
- data/sig/generated/task_progress/terminal_output.rbs +117 -0
- data/sig/generated/task_progress/token.rbs +41 -0
- data/sig/generated/task_progress/tracked_io.rbs +48 -0
- data/sig/generated/task_progress/transaction.rbs +33 -0
- data/sig/generated/task_progress/trees_app.rbs +40 -0
- data/sig/generated/task_progress/turbo_helper.rbs +14 -0
- data/sig/generated/task_progress/turbo_sse.rbs +69 -0
- data/sig/generated/task_progress/turbo_stream_output.rbs +51 -0
- data/sig/generated/task_progress/version.rbs +6 -0
- data/sig/generated/task_progress/wire.rbs +102 -0
- data/sig/generated/task_progress.rbs +553 -0
- metadata +178 -0
data/js/local_tasks.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Batch, Node, Status, Path } from "./task_tree.js"
|
|
2
|
+
|
|
3
|
+
export interface LocalTaskOptions {
|
|
4
|
+
total?: number
|
|
5
|
+
weight?: number
|
|
6
|
+
expect?: number
|
|
7
|
+
indeterminate?: boolean
|
|
8
|
+
unit?: string
|
|
9
|
+
cancelable?: boolean
|
|
10
|
+
autofinish?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Tasks that run in this browser, grafted under a server task with TaskTree#graft */
|
|
14
|
+
export class LocalTasks {
|
|
15
|
+
constructor(name: string, options?: LocalTaskOptions & { clock?: () => number })
|
|
16
|
+
readonly log: string
|
|
17
|
+
readonly root: LocalTask
|
|
18
|
+
subscribe(fn: (batch: Batch) => void): () => void
|
|
19
|
+
squash(): Batch
|
|
20
|
+
at(path: Path): LocalTask | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class LocalTask {
|
|
24
|
+
readonly path: Path
|
|
25
|
+
readonly node: Node
|
|
26
|
+
readonly status: Status
|
|
27
|
+
readonly finished: boolean
|
|
28
|
+
readonly canceled: boolean
|
|
29
|
+
/** Aborts when the task is canceled - for fetch, say */
|
|
30
|
+
readonly signal: AbortSignal
|
|
31
|
+
subtask(name: string, options?: LocalTaskOptions): LocalTask | null
|
|
32
|
+
advance(by?: number, options?: { message?: string }): this
|
|
33
|
+
tick(options?: { message?: string }): this
|
|
34
|
+
update(percent: number, options?: { message?: string }): this
|
|
35
|
+
note(message: string): this
|
|
36
|
+
setTotal(total: number): this
|
|
37
|
+
done(): this
|
|
38
|
+
fail(error?: unknown): this
|
|
39
|
+
cancel(reason?: string): this
|
|
40
|
+
onCancel(handler: (reason?: string) => void): this
|
|
41
|
+
whenFinished(): Promise<Status>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function trackUpload(xhr: XMLHttpRequest, task: LocalTask, options?: { abortOnCancel?: boolean }): LocalTask
|
data/js/local_tasks.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// Tasks that run in this browser - hashing files before an upload, say - written the way the
|
|
2
|
+
// server writes its own: every change a batch, numbered, folded into a tree. Graft them under a
|
|
3
|
+
// server task with TaskTree#graft and the renderers draw them as its children.
|
|
4
|
+
//
|
|
5
|
+
// const work = new LocalTasks("Hash files", { cancelable: true })
|
|
6
|
+
// tree.graft(serverTask.path, work)
|
|
7
|
+
// const file = work.root.subtask("video.mp4", { total: blob.size, unit: "bytes" })
|
|
8
|
+
// file.onCancel(() => controller.abort())
|
|
9
|
+
// file.advance(chunk.byteLength)
|
|
10
|
+
//
|
|
11
|
+
// The rules are the server's: a counted task is done at its total, done() closes everything
|
|
12
|
+
// still open under it, fail() and cancel() cancel it. Changing a task that has ended does
|
|
13
|
+
// nothing - check `canceled` in loops that would otherwise carry on.
|
|
14
|
+
//
|
|
15
|
+
// Ids start with "_", which a server id never does, so a graft cannot clash with the tree it
|
|
16
|
+
// is grafted to, and a task can tell it runs here.
|
|
17
|
+
|
|
18
|
+
import { TaskTree } from "./task_tree.js"
|
|
19
|
+
|
|
20
|
+
const FINISHED = new Set(["done", "failed", "canceled"])
|
|
21
|
+
const randomId = (length) => "_" + Array.from({ length }, () => Math.floor(Math.random() * 36).toString(36)).join("")
|
|
22
|
+
|
|
23
|
+
const attributes = ({ name, total, weight, expect, indeterminate, unit, cancelable }) =>
|
|
24
|
+
Object.fromEntries(Object.entries({ name, total, weight, expect, indeterminate: indeterminate || undefined, unit, cancelable: cancelable || undefined })
|
|
25
|
+
.filter(([, value]) => value !== undefined && value !== null))
|
|
26
|
+
|
|
27
|
+
export class LocalTasks {
|
|
28
|
+
constructor(name, { clock = () => Date.now() / 1000, autofinish = true, ...options } = {}) {
|
|
29
|
+
this.log = randomId(10)
|
|
30
|
+
this.seq = 0
|
|
31
|
+
this.clock = clock
|
|
32
|
+
this.tree = new TaskTree({ clock })
|
|
33
|
+
this.listeners = new Set()
|
|
34
|
+
this.handlers = new Map() // key -> [onCancel callbacks]
|
|
35
|
+
this.waiting = new Map() // key -> [whenFinished resolvers]
|
|
36
|
+
this.signals = new Map() // key -> AbortSignal, made on first ask
|
|
37
|
+
this.autofinish = new Map()
|
|
38
|
+
this.commit([[[], "open", attributes({ name, ...options })]])
|
|
39
|
+
this.autofinish.set("", autofinish)
|
|
40
|
+
this.root = new LocalTask(this, [])
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Calls fn(batch) with every batch from now on. Returns a function that stops it
|
|
44
|
+
subscribe(fn) {
|
|
45
|
+
this.listeners.add(fn)
|
|
46
|
+
return () => this.listeners.delete(fn)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The tree so far as one batch of puts, prev 0 - what a graft starts from
|
|
50
|
+
squash() {
|
|
51
|
+
const ops = [...this.tree.each()].map((node) => [node.path, "put", putValue(node)])
|
|
52
|
+
return { v: 1, log: this.log, prev: 0, seq: this.seq, at: this.clock(), ops }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
commit(ops) {
|
|
56
|
+
if (ops.length === 0) return
|
|
57
|
+
const batch = { v: 1, log: this.log, prev: this.seq, seq: this.seq + 1, at: this.clock(), ops }
|
|
58
|
+
this.tree.apply(batch)
|
|
59
|
+
this.seq = batch.seq
|
|
60
|
+
for (const listener of this.listeners) listener(batch)
|
|
61
|
+
for (const [path, op, reason] of ops) {
|
|
62
|
+
if (op !== "cancel" && op !== "done" && op !== "fail") continue
|
|
63
|
+
const key = path.join("-")
|
|
64
|
+
const handlers = this.handlers.get(key) ?? []
|
|
65
|
+
this.handlers.delete(key)
|
|
66
|
+
if (op === "cancel") for (const handler of handlers) handler(reason ?? null)
|
|
67
|
+
for (const resolve of this.waiting.get(key) ?? []) resolve(this.tree.get(path).status)
|
|
68
|
+
this.waiting.delete(key)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
newId() {
|
|
73
|
+
let id
|
|
74
|
+
do id = randomId(4)
|
|
75
|
+
while ([...this.tree.nodes.values()].some((node) => node.path[node.path.length - 1] === id))
|
|
76
|
+
return id
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The task at `path`, or null
|
|
80
|
+
at(path) { return this.tree.get(path) ? new LocalTask(this, path) : null }
|
|
81
|
+
|
|
82
|
+
// Every task under `path` still open, deepest first
|
|
83
|
+
openUnder(path) {
|
|
84
|
+
const node = this.tree.get(path)
|
|
85
|
+
return [...this.tree.each(node)].slice(1).filter((n) => !FINISHED.has(n.status)).map((n) => n.path).reverse()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
class LocalTask {
|
|
90
|
+
constructor(tasks, path) {
|
|
91
|
+
this.tasks = tasks
|
|
92
|
+
this.path = path
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
get node() { return this.tasks.tree.get(this.path) }
|
|
96
|
+
get status() { return this.node.status }
|
|
97
|
+
get finished() { return FINISHED.has(this.status) }
|
|
98
|
+
get canceled() { return this.status === "canceled" }
|
|
99
|
+
|
|
100
|
+
subtask(name, { autofinish = true, ...options } = {}) {
|
|
101
|
+
if (this.finished) return null
|
|
102
|
+
const path = [...this.path, this.tasks.newId()]
|
|
103
|
+
this.tasks.commit([[path, "open", attributes({ name, ...options })]])
|
|
104
|
+
this.tasks.autofinish.set(path.join("-"), autofinish)
|
|
105
|
+
return new LocalTask(this.tasks, path)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
advance(by = 1, { message } = {}) {
|
|
109
|
+
if (this.finished) return this
|
|
110
|
+
const ops = [[this.path, "add", by]]
|
|
111
|
+
if (message != null) ops.push([this.path, "note", message])
|
|
112
|
+
const { count, total } = this.node
|
|
113
|
+
if (this.tasks.autofinish.get(this.path.join("-")) && total != null && count + by >= total) ops.push(...this.#closing("done"))
|
|
114
|
+
this.tasks.commit(ops)
|
|
115
|
+
return this
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
tick(options) { return this.advance(1, options) }
|
|
119
|
+
|
|
120
|
+
update(percent, { message } = {}) {
|
|
121
|
+
if (this.finished) return this
|
|
122
|
+
const ops = message != null ? [[this.path, "note", message]] : []
|
|
123
|
+
ops.push(...(percent >= 100 ? this.#closing("done") : [[this.path, "set", percent]]))
|
|
124
|
+
this.tasks.commit(ops)
|
|
125
|
+
return this
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
note(message) {
|
|
129
|
+
if (!this.finished) this.tasks.commit([[this.path, "note", message]])
|
|
130
|
+
return this
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// An AbortSignal that aborts, with the reason, once this task is canceled. Nothing is wired
|
|
134
|
+
// to it: a cancel stops only what you hand it to - `fetch(url, { signal: task.signal })`
|
|
135
|
+
get signal() {
|
|
136
|
+
const key = this.path.join("-")
|
|
137
|
+
if (!this.tasks.signals.has(key)) {
|
|
138
|
+
const controller = new AbortController()
|
|
139
|
+
this.tasks.signals.set(key, controller.signal)
|
|
140
|
+
this.onCancel((reason) => controller.abort(reason ?? "Canceled"))
|
|
141
|
+
}
|
|
142
|
+
return this.tasks.signals.get(key)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Resolves with "done", "failed" or "canceled" once the task ends - at once, if it has
|
|
146
|
+
whenFinished() {
|
|
147
|
+
if (this.finished) return Promise.resolve(this.status)
|
|
148
|
+
const key = this.path.join("-")
|
|
149
|
+
return new Promise((resolve) => this.tasks.waiting.set(key, [...(this.tasks.waiting.get(key) ?? []), resolve]))
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// For a total only learned once the work has started - the size of an upload, say
|
|
153
|
+
setTotal(total) {
|
|
154
|
+
if (!this.finished) this.tasks.commit([[this.path, "meta", { total }]])
|
|
155
|
+
return this
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
done() {
|
|
159
|
+
if (!this.finished) this.tasks.commit(this.#closing("done"))
|
|
160
|
+
return this
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fail(error) {
|
|
164
|
+
if (this.finished) return this
|
|
165
|
+
const value = { class: error?.name ?? "Error", message: String(error?.message ?? error ?? `${this.node.name} failed`) }
|
|
166
|
+
this.tasks.commit([...this.tasks.openUnder(this.path).map((path) => [path, "cancel"]), [this.path, "fail", value]])
|
|
167
|
+
return this
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
cancel(reason) {
|
|
171
|
+
if (!this.finished) this.tasks.commit(this.#closing("cancel", reason))
|
|
172
|
+
return this
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Runs once this task is canceled - at once, if it already was. Hook an AbortController here
|
|
176
|
+
onCancel(handler) {
|
|
177
|
+
if (this.canceled) handler(this.node.message)
|
|
178
|
+
else if (!this.finished) {
|
|
179
|
+
const key = this.path.join("-")
|
|
180
|
+
this.tasks.handlers.set(key, [...(this.tasks.handlers.get(key) ?? []), handler])
|
|
181
|
+
}
|
|
182
|
+
return this
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// done closes what is still open under it with a done of its own, cancel with a cancel
|
|
186
|
+
#closing(op, value) {
|
|
187
|
+
const own = op === "cancel" && value != null ? [this.path, op, value] : [this.path, op]
|
|
188
|
+
return [...this.tasks.openUnder(this.path).map((path) => (value != null ? [path, op, value] : [path, op])), own]
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Tracks an XMLHttpRequest's upload with a task: its count is the bytes sent, its total the
|
|
193
|
+
// body's size, learned from the first progress event. A 2xx response finishes it; any other
|
|
194
|
+
// status, a network error or a timeout fails it, and an abort cancels it. Call it before
|
|
195
|
+
// `xhr.send`, which is when the progress events start.
|
|
196
|
+
//
|
|
197
|
+
// It only reports. Whether canceling the task - its cancel button, or the server task it is
|
|
198
|
+
// grafted under ending badly - stops the upload is yours to say: `abortOnCancel: true` aborts
|
|
199
|
+
// the request then
|
|
200
|
+
//
|
|
201
|
+
// const xhr = new XMLHttpRequest()
|
|
202
|
+
// xhr.open("PUT", url)
|
|
203
|
+
// trackUpload(xhr, work.root.subtask(file.name, { unit: "bytes", cancelable: true }), { abortOnCancel: true })
|
|
204
|
+
// xhr.send(file)
|
|
205
|
+
export function trackUpload(xhr, task, { abortOnCancel = false } = {}) {
|
|
206
|
+
// All the bytes out is not the upload done, so the count reaching the total must not finish it
|
|
207
|
+
task.tasks.autofinish.set(task.path.join("-"), false)
|
|
208
|
+
let sent = 0
|
|
209
|
+
xhr.upload.addEventListener("progress", (event) => {
|
|
210
|
+
if (event.lengthComputable && task.node.total == null) task.setTotal(event.total)
|
|
211
|
+
if (event.loaded > sent) task.advance(event.loaded - sent)
|
|
212
|
+
sent = Math.max(sent, event.loaded)
|
|
213
|
+
})
|
|
214
|
+
xhr.addEventListener("load", () => {
|
|
215
|
+
if (xhr.status >= 200 && xhr.status < 300) task.done()
|
|
216
|
+
else task.fail(new Error(`The server answered ${xhr.status}${xhr.statusText ? ` ${xhr.statusText}` : ""}`))
|
|
217
|
+
})
|
|
218
|
+
xhr.addEventListener("error", () => task.fail(new Error("The upload failed - the network, or the server going away")))
|
|
219
|
+
xhr.addEventListener("timeout", () => task.fail(new Error("The upload timed out")))
|
|
220
|
+
xhr.addEventListener("abort", () => task.cancel("Aborted"))
|
|
221
|
+
if (abortOnCancel) task.onCancel(() => xhr.abort())
|
|
222
|
+
return task
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const FIELDS = ["name", "total", "count", "percent", "weight", "expect", "indeterminate", "unit", "cancelable", "attempt", "message", "status", "error", "stale", "opened_at", "started_at", "finished_at"]
|
|
226
|
+
|
|
227
|
+
function putValue(node) {
|
|
228
|
+
return Object.fromEntries(FIELDS.filter((field) => node[field] !== null && node[field] !== undefined && node[field] !== false).map((field) => [field, node[field]]))
|
|
229
|
+
}
|
data/js/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "task_progress",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"description": "The browser half of the task_progress gem, vendored from it - see `task_progress vendor`",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"repository": { "type": "git", "url": "git+https://github.com/julik/task_progress.git", "directory": "js" },
|
|
9
|
+
"homepage": "https://github.com/julik/task_progress",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": { "types": "./task_tree.d.ts", "default": "./task_tree.js" },
|
|
12
|
+
"./react": { "types": "./react_task_tree.d.ts", "default": "./react_task_tree.js" },
|
|
13
|
+
"./stimulus": { "types": "./stimulus_controller.d.ts", "default": "./stimulus_controller.js" },
|
|
14
|
+
"./local_tasks": { "types": "./local_tasks.d.ts", "default": "./local_tasks.js" },
|
|
15
|
+
"./row_view": { "types": "./row_view.d.ts", "default": "./row_view.js" },
|
|
16
|
+
"./clock": { "types": "./task_progress_clock.d.ts", "default": "./task_progress_clock.js" },
|
|
17
|
+
"./task_tree.css": "./task_tree.css"
|
|
18
|
+
},
|
|
19
|
+
"files": ["*.js", "*.d.ts", "*.css"],
|
|
20
|
+
"sideEffects": ["./task_progress_clock.js", "*.css"],
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@hotwired/stimulus": ">=3",
|
|
23
|
+
"react": ">=18",
|
|
24
|
+
"react-dom": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"peerDependenciesMeta": {
|
|
27
|
+
"@hotwired/stimulus": { "optional": true },
|
|
28
|
+
"react": { "optional": true },
|
|
29
|
+
"react-dom": { "optional": true }
|
|
30
|
+
}
|
|
31
|
+
}
|
data/js/paths.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** A task's place in the tree: its ancestors' ids, then its own - `[]` is the root */
|
|
2
|
+
export type Path = string[]
|
|
3
|
+
/** A path's key - its ids joined with a dash, `""` for the root */
|
|
4
|
+
export function pathKey(path: Path): string
|
|
5
|
+
export function pathFromKey(key: string): Path
|
data/js/paths.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// A path as one string: its ids joined with a dash. Ids are [0-9a-z], so a dash never occurs in
|
|
2
|
+
// one, and the key needs no escaping in a URL, a class name or a data attribute. The root's key
|
|
3
|
+
// is the empty string - where it has to be seen, call it "root". As a DOM id, prefix it: an id
|
|
4
|
+
// that starts with a digit needs CSS.escape in a selector
|
|
5
|
+
export const pathKey = (path) => path.join("-")
|
|
6
|
+
|
|
7
|
+
export const pathFromKey = (key) => (key === "" ? [] : key.split("-"))
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ReactElement } from "react"
|
|
2
|
+
import type { TaskTree, Progress, Path } from "./task_tree.js"
|
|
3
|
+
import type { RowView } from "./row_view.js"
|
|
4
|
+
import type { LocalTasks } from "./local_tasks.js"
|
|
5
|
+
|
|
6
|
+
export type { Progress } from "./task_tree.js"
|
|
7
|
+
export type { RowView } from "./row_view.js"
|
|
8
|
+
|
|
9
|
+
export interface Graft { path: Path; local: LocalTasks }
|
|
10
|
+
|
|
11
|
+
export interface FollowOptions {
|
|
12
|
+
transport?: "auto" | "sse" | "poll"
|
|
13
|
+
/** Seconds between polls */
|
|
14
|
+
poll?: number
|
|
15
|
+
/** Seconds to long-poll */
|
|
16
|
+
wait?: number
|
|
17
|
+
grafts?: Graft[]
|
|
18
|
+
/** Called once, when the root is done - `() => router.reload({ only: ["video"] })` */
|
|
19
|
+
onComplete?: (tree: TaskTree) => void
|
|
20
|
+
/** Called once, when the root failed or was canceled */
|
|
21
|
+
onFail?: (tree: TaskTree) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseTaskTree {
|
|
25
|
+
tree: TaskTree | null
|
|
26
|
+
rows: RowView[]
|
|
27
|
+
/** Asks for the row's task to be canceled; resolves false if it could not ask */
|
|
28
|
+
cancel(row: RowView): Promise<boolean | undefined> | boolean | undefined
|
|
29
|
+
canCancel: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Follows what TaskProgress.for_page hands a page, the url of an SSE stream or Poll endpoint, or tasks running in this browser */
|
|
33
|
+
export function useTaskTree(source: Progress | string | LocalTasks | null | undefined, options?: FollowOptions): UseTaskTree
|
|
34
|
+
|
|
35
|
+
export interface TaskTreeViewProps extends FollowOptions {
|
|
36
|
+
progress?: Progress
|
|
37
|
+
url?: string
|
|
38
|
+
/** Tasks running in this browser, drawn on their own */
|
|
39
|
+
local?: LocalTasks
|
|
40
|
+
cancelUrl?: string
|
|
41
|
+
cancelToken?: string
|
|
42
|
+
/** Return false to release the button */
|
|
43
|
+
onCancel?: (row: RowView) => unknown
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The rows, for an element of yours with the `tree` class around them */
|
|
47
|
+
export function TaskTreeView(props: TaskTreeViewProps): ReactElement | null
|
|
48
|
+
|
|
49
|
+
export function createTaskTreeRoot(element: Element): { render(props: TaskTreeViewProps): void; unmount(): void }
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react"
|
|
2
|
+
import { createRoot } from "react-dom/client"
|
|
3
|
+
import { followEventSourceAt, followLocal, followPollingAt, followProgress, requestCancel } from "./task_tree.js"
|
|
4
|
+
import { rowView, CANCEL_ICON } from "./row_view.js"
|
|
5
|
+
|
|
6
|
+
// No JSX, so it runs as it is - in a Vite app, or straight off an import map with no build step
|
|
7
|
+
const h = React.createElement
|
|
8
|
+
|
|
9
|
+
const NO_GRAFTS = []
|
|
10
|
+
const FINISHED = new Set(["done", "failed", "canceled"])
|
|
11
|
+
|
|
12
|
+
// Follows a tree: what TaskProgress.for_page hands a page - { events, poll, cancel, snapshot },
|
|
13
|
+
// drawn from its snapshot at once - or the url of a TaskProgress::SSE stream, or of a
|
|
14
|
+
// TaskProgress::Poll endpoint given `poll` (seconds between polls) or `wait` (seconds to
|
|
15
|
+
// long-poll). `transport` picks for a for_page object, see followProgress. Or a LocalTasks, for
|
|
16
|
+
// tasks that run in this browser alone - uploads, say - drawn as they change, with no server.
|
|
17
|
+
//
|
|
18
|
+
// Returns { tree, rows, cancel }: the tree, what to draw (see rowView), and a function that asks
|
|
19
|
+
// for a row's task to be canceled - a task running in this browser is canceled here. `onComplete`
|
|
20
|
+
// and `onFail` are called once, when the root has finished one way or the other - for an
|
|
21
|
+
// Inertia page, `onComplete: () => router.reload({ only: ["video"] })`. `grafts` - [{ path, local }]
|
|
22
|
+
// - draws tasks running in this browser under server tasks, see TaskTree#graft
|
|
23
|
+
export function useTaskTree(source, { transport, poll = 0, wait = 0, grafts = NO_GRAFTS, onComplete, onFail } = {}) {
|
|
24
|
+
const [state, setState] = useState({ tree: null, version: 0 })
|
|
25
|
+
const callbacks = useRef({})
|
|
26
|
+
callbacks.current = { onComplete, onFail }
|
|
27
|
+
const local = typeof source?.subscribe === "function" ? source : null
|
|
28
|
+
const progress = typeof source === "string" || local ? null : source
|
|
29
|
+
const url = typeof source === "string" ? source : null
|
|
30
|
+
// A for_page object is a new object every render; what it points at is what matters
|
|
31
|
+
const followKey = local ? local.log : progress ? `${progress.events} ${progress.poll}` : url
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
setState({ tree: null, version: 0 })
|
|
34
|
+
if (!followKey) return
|
|
35
|
+
let told = false
|
|
36
|
+
const draw = (tree) => {
|
|
37
|
+
setState((s) => ({ tree, version: s.version + 1 }))
|
|
38
|
+
const status = tree.root?.status
|
|
39
|
+
if (told || !FINISHED.has(status)) return
|
|
40
|
+
told = true
|
|
41
|
+
if (status === "done") callbacks.current.onComplete?.(tree)
|
|
42
|
+
else callbacks.current.onFail?.(tree)
|
|
43
|
+
}
|
|
44
|
+
const following = local
|
|
45
|
+
? followLocal(local, draw)
|
|
46
|
+
: progress
|
|
47
|
+
? followProgress(progress, draw, { transport, poll: poll || 1, wait })
|
|
48
|
+
: poll || wait ? followPollingAt(url, draw, { poll: poll || 1, wait }) : followEventSourceAt(url, draw)
|
|
49
|
+
return () => following.close()
|
|
50
|
+
}, [followKey, transport, poll, wait]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
51
|
+
// Keyed on what the grafts are, not on the array: one written inline is a new array every
|
|
52
|
+
// render, and grafting anew draws again - which would render again, every frame, for ever
|
|
53
|
+
const graftsKey = grafts.map(({ path, local }) => `${path.join("-")}:${local.log}`).join(" ")
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!state.tree) return
|
|
56
|
+
const ungrafts = grafts.map(({ path, local }) => state.tree.graft(path, local))
|
|
57
|
+
return () => ungrafts.forEach((ungraft) => ungraft())
|
|
58
|
+
}, [state.tree, graftsKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
59
|
+
const { tree } = state
|
|
60
|
+
const rows = tree ? [...tree.rows()].map((node) => rowView(tree, node)) : []
|
|
61
|
+
const cancelUrl = progress?.cancel
|
|
62
|
+
const cancel = async (row) => {
|
|
63
|
+
if (row.local) return tree?.cancelLocal(row.path, "Canceled in this browser")
|
|
64
|
+
if (!cancelUrl) return false
|
|
65
|
+
try { return (await requestCancel(cancelUrl, row.path)).ok } catch { return false }
|
|
66
|
+
}
|
|
67
|
+
return { tree, rows, cancel: cancelUrl ? cancel : (row) => (row.local ? cancel(row) : false), canCancel: !!cancelUrl }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// A cancel button, right of the bar. It stays pressed until the cancel comes down the stream -
|
|
71
|
+
// the row is then not cancelable any more, and the button hides. Hidden, it keeps its place
|
|
72
|
+
function CancelButton({ row, onCancel, offered }) {
|
|
73
|
+
const [pressed, setPressed] = useState(false)
|
|
74
|
+
const press = async () => {
|
|
75
|
+
setPressed(true)
|
|
76
|
+
if ((await onCancel(row)) === false) setPressed(false)
|
|
77
|
+
}
|
|
78
|
+
return h("button", {
|
|
79
|
+
type: "button", className: "cancel", hidden: !(offered && row.cancelable), disabled: pressed,
|
|
80
|
+
"aria-label": `Cancel ${row.name}`, onClick: press, dangerouslySetInnerHTML: { __html: CANCEL_ICON }
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function Row({ row, onCancel, offered }) {
|
|
85
|
+
return h("div", { className: `row ${row.status}${row.stale ? " stale" : ""}${row.folded ? " folded" : ""}` },
|
|
86
|
+
h("div", { className: "label", style: { paddingLeft: row.depth * 18 } },
|
|
87
|
+
h("span", { className: "icon", dangerouslySetInnerHTML: { __html: row.icon } }),
|
|
88
|
+
h("span", { className: "name" }, row.name),
|
|
89
|
+
h("span", { className: "message" }, row.message)),
|
|
90
|
+
h("div", { className: `bar${row.indeterminate ? " indeterminate" : ""}` }, h("span", { style: { width: `${row.percent}%` } })),
|
|
91
|
+
h(CancelButton, { row, onCancel, offered }),
|
|
92
|
+
h("div", { className: "measure" }, row.measure))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The tree as rows, styled by task_tree.css. Give it `progress` - what TaskProgress.for_page
|
|
96
|
+
// hands a page - or a `url`, as useTaskTree takes them, or `local`, a LocalTasks. With a `url`, `cancelUrl` and
|
|
97
|
+
// `cancelToken` say where cancel buttons ask; with `progress` they ask where it says. For
|
|
98
|
+
// anything else, pass `onCancel(row)` - return false from it to release the button. `onComplete`
|
|
99
|
+
// and `onFail` are called once the root has finished
|
|
100
|
+
export function TaskTreeView({ progress, url, local, cancelUrl, cancelToken, onCancel, onComplete, onFail, grafts, transport, poll, wait }) {
|
|
101
|
+
const { tree, rows, cancel, canCancel } = useTaskTree(local ?? progress ?? url, { transport, poll, wait, grafts, onComplete, onFail })
|
|
102
|
+
if (!tree) return null
|
|
103
|
+
const remote = !progress && cancelUrl && (async (row) => {
|
|
104
|
+
try { return (await requestCancel(cancelUrl, row.path, { token: cancelToken })).ok } catch { return false }
|
|
105
|
+
})
|
|
106
|
+
const onPress = onCancel ?? ((row) => (row.local || !remote ? cancel(row) : remote(row)))
|
|
107
|
+
// A server task can only be asked to cancel where there is somewhere to ask
|
|
108
|
+
const offered = (row) => !!(onCancel || canCancel || remote || row.local)
|
|
109
|
+
// Rows alone, for an element of yours with the `tree` class around them
|
|
110
|
+
return h(React.Fragment, null, rows.map((row) => h(Row, { key: row.key, row, onCancel: onPress, offered: offered(row) })))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// For pages that are not React apps, as React's own createRoot: render it into an element, and
|
|
114
|
+
// render again with other props - another url, `poll` - to change what it follows
|
|
115
|
+
//
|
|
116
|
+
// createTaskTreeRoot(element).render({ progress }) // or { url: "/jobs/42/progress" }
|
|
117
|
+
export function createTaskTreeRoot(element) {
|
|
118
|
+
const root = createRoot(element)
|
|
119
|
+
return {
|
|
120
|
+
render: (props) => root.render(h(TaskTreeView, props)),
|
|
121
|
+
unmount: () => root.unmount()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { TaskTree } from "./task_tree.js"
|
|
2
|
+
|
|
3
|
+
/** What changed since the last draw - see "Drawing once a frame" in the MANUAL */
|
|
4
|
+
export interface Changes {
|
|
5
|
+
batches: number
|
|
6
|
+
/** Keys of the nodes changed, and every one above them */
|
|
7
|
+
touched: Set<string>
|
|
8
|
+
/** What a renderer must not miss, in order: done, fail, cancel, say, reset, gap */
|
|
9
|
+
events: Array<{ op: string; path: string[]; value?: unknown }>
|
|
10
|
+
/** Rows came, went or moved */
|
|
11
|
+
structure: boolean
|
|
12
|
+
local: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type Schedule = (draw: () => void) => void
|
|
16
|
+
export const nextFrame: Schedule
|
|
17
|
+
|
|
18
|
+
export class RenderQueue {
|
|
19
|
+
constructor(tree: TaskTree, onFrame: (tree: TaskTree, changes: Changes) => void, options?: { schedule?: Schedule })
|
|
20
|
+
apply(batch: Batch): ApplyOutcome
|
|
21
|
+
close(): void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
import type { Batch, ApplyOutcome } from "./task_tree.js"
|
data/js/render_queue.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// A stream can carry thousands of batches a second, far more than a screen can show. Every batch is
|
|
2
|
+
// still folded into the tree as it arrives - the seq chain and the parity with Ruby depend on that -
|
|
3
|
+
// but the renderer gets called at most once a frame, with what changed since the last call.
|
|
4
|
+
//
|
|
5
|
+
// A set, add or note only matters for the state it leaves behind, so those collapse into the paths
|
|
6
|
+
// they touched. What a renderer must hear about even when a later batch in the same frame hides it
|
|
7
|
+
// goes into `events`, in order: done, fail (with the error) and cancel, say lines (the tree never holds
|
|
8
|
+
// them), and the stream starting over (reset) or breaking off (gap).
|
|
9
|
+
|
|
10
|
+
import { pathKey as key } from "./paths.js"
|
|
11
|
+
|
|
12
|
+
// requestAnimationFrame follows the display and pauses in a background tab; Node has neither
|
|
13
|
+
export const nextFrame = globalThis.requestAnimationFrame
|
|
14
|
+
? (fn) => globalThis.requestAnimationFrame(fn)
|
|
15
|
+
: (fn) => setTimeout(fn, 16)
|
|
16
|
+
|
|
17
|
+
const STRUCTURAL = new Set(["open", "put", "prune"])
|
|
18
|
+
const NOTABLE = new Set(["done", "fail", "cancel", "say"])
|
|
19
|
+
|
|
20
|
+
const blankChanges = () => ({ batches: 0, touched: new Set(), events: [], structure: false, local: false })
|
|
21
|
+
|
|
22
|
+
export class RenderQueue {
|
|
23
|
+
// onFrame(tree, changes) - changes is { batches, touched, events, structure }. `structure` says
|
|
24
|
+
// rows came, went or moved, so a renderer that patches rows in place has to lay them out again
|
|
25
|
+
constructor(tree, onFrame, { schedule = nextFrame } = {}) {
|
|
26
|
+
this.tree = tree
|
|
27
|
+
this.onFrame = onFrame
|
|
28
|
+
this.schedule = schedule
|
|
29
|
+
this.changes = blankChanges()
|
|
30
|
+
this.pending = false
|
|
31
|
+
this.closed = false
|
|
32
|
+
// What a graft of browser tasks changes is drawn the same way, in the same frames
|
|
33
|
+
tree.onLocalChange = (ops) => {
|
|
34
|
+
for (const [path, op, value] of ops) this.#note(path, op, value, null)
|
|
35
|
+
this.changes.local = true
|
|
36
|
+
this.#request()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
apply(batch) {
|
|
41
|
+
const result = this.tree.apply(batch)
|
|
42
|
+
const changes = this.changes
|
|
43
|
+
changes.batches++
|
|
44
|
+
if (batch.prev === 0) {
|
|
45
|
+
changes.events.push({ op: "reset", seq: batch.seq })
|
|
46
|
+
changes.structure = true
|
|
47
|
+
}
|
|
48
|
+
if (result === "gap") changes.events.push({ op: "gap", seq: batch.seq, prev: batch.prev })
|
|
49
|
+
if (result === "applied") for (const [path, op, value] of batch.ops) this.#note(path, op, value, batch.seq)
|
|
50
|
+
this.#request()
|
|
51
|
+
return result
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Hands over what has piled up right away, without waiting for the frame
|
|
55
|
+
flush() {
|
|
56
|
+
this.pending = false
|
|
57
|
+
if (this.closed) return
|
|
58
|
+
const changes = this.changes
|
|
59
|
+
if (changes.batches === 0 && !changes.local) return
|
|
60
|
+
this.changes = blankChanges()
|
|
61
|
+
this.onFrame(this.tree, changes)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A frame already asked for must not draw an old stream into a renderer that moved on
|
|
65
|
+
close() { this.closed = true }
|
|
66
|
+
|
|
67
|
+
#note(path, op, value, seq) {
|
|
68
|
+
// Percents roll up, so a change anywhere shows in every row above it too
|
|
69
|
+
for (let length = path.length; length >= 0; length--) this.changes.touched.add(key(path.slice(0, length)))
|
|
70
|
+
if (STRUCTURAL.has(op)) this.changes.structure = true
|
|
71
|
+
if (NOTABLE.has(op)) this.changes.events.push({ op, path, value: value ?? null, seq })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#request() {
|
|
75
|
+
if (this.pending || this.closed) return
|
|
76
|
+
this.pending = true
|
|
77
|
+
this.schedule(() => this.flush())
|
|
78
|
+
}
|
|
79
|
+
}
|
data/js/row_view.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { TaskTree, Node, Fold, Status, Path } from "./task_tree.js"
|
|
2
|
+
|
|
3
|
+
/** What one row shows */
|
|
4
|
+
export interface RowView {
|
|
5
|
+
key: string
|
|
6
|
+
path: Path
|
|
7
|
+
depth: number
|
|
8
|
+
name: string
|
|
9
|
+
status: Status
|
|
10
|
+
stale: boolean
|
|
11
|
+
/** Runs in this browser, see LocalTasks */
|
|
12
|
+
local: boolean
|
|
13
|
+
cancelable: boolean
|
|
14
|
+
/** Siblings folded into this one row */
|
|
15
|
+
folded?: boolean
|
|
16
|
+
percent: number
|
|
17
|
+
indeterminate: boolean
|
|
18
|
+
measure: string
|
|
19
|
+
/** HTML for the icon */
|
|
20
|
+
icon: string
|
|
21
|
+
message: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const CANCEL_ICON: string
|
|
25
|
+
export function rowView(tree: TaskTree, node: Node | Fold): RowView
|