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/row_view.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { humanBytes } from "./task_tree.js"
|
|
2
|
+
import { pathKey } from "./paths.js"
|
|
3
|
+
|
|
4
|
+
const GLYPHS = { done: "✓", failed: "✗", canceled: "⊘", pending: "·" }
|
|
5
|
+
const FINISHED = new Set(["done", "failed", "canceled"])
|
|
6
|
+
|
|
7
|
+
// A circle with a cross in it, drawn in the row's text color - the same in every font
|
|
8
|
+
export const CANCEL_ICON = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><circle cx="8" cy="8" r="6.25" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M5.6 5.6l4.8 4.8M10.4 5.6l-4.8 4.8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>'
|
|
9
|
+
|
|
10
|
+
// What one row shows, worked out once for both demos - a task, or siblings folded into one row
|
|
11
|
+
// (see TaskTree#rows)
|
|
12
|
+
export function rowView(tree, node) {
|
|
13
|
+
if (node.fold) return foldView(node)
|
|
14
|
+
const percent = tree.percentOf(node)
|
|
15
|
+
const leaf = node.children.length === 0
|
|
16
|
+
const indeterminate = leaf && node.indeterminate && node.status === "active"
|
|
17
|
+
let measure = `${percent}%`
|
|
18
|
+
if (leaf && node.total != null) {
|
|
19
|
+
measure = node.unit === "bytes" ? `${humanBytes(node.count)} / ${humanBytes(node.total)}` : `${node.count} / ${node.total}`
|
|
20
|
+
} else if (indeterminate) {
|
|
21
|
+
measure = elapsed(tree, node)
|
|
22
|
+
}
|
|
23
|
+
// A task called done with something still running under it - browser work grafted to it, say -
|
|
24
|
+
// is not through, and is drawn as running until it is
|
|
25
|
+
const status = node.status === "done" && !tree.complete(node) ? "active" : node.status
|
|
26
|
+
// Every state is one inner span, so the .icon box around it never changes; the spinner is drawn in CSS
|
|
27
|
+
const glyph = GLYPHS[status]
|
|
28
|
+
const icon = glyph ? `<span>${glyph}</span>` : '<span class="spin"></span>'
|
|
29
|
+
return {
|
|
30
|
+
key: pathKey(node.path), path: node.path, depth: node.path.length, name: node.name, status, stale: node.stale,
|
|
31
|
+
local: !!tree.local || node.path.some((id) => String(id).startsWith("_")), // runs in this browser, see LocalTasks
|
|
32
|
+
cancelable: !!node.cancelable && !FINISHED.has(node.status),
|
|
33
|
+
percent, indeterminate, measure, icon,
|
|
34
|
+
message: [node.attempt > 1 ? `attempt ${node.attempt}` : null,
|
|
35
|
+
node.status === "failed" ? node.error?.message : [pace(tree, node), node.message].filter(Boolean).join(" · ")].filter(Boolean).join(" · ")
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function foldView({ path, status, count }) {
|
|
40
|
+
const done = status === "done"
|
|
41
|
+
return {
|
|
42
|
+
key: pathKey(path), path, depth: path.length, name: `${count.toLocaleString("en-US")} more ${done ? "done" : "waiting"}`,
|
|
43
|
+
status, stale: false, local: false, cancelable: false, folded: true,
|
|
44
|
+
percent: done ? 100 : 0, indeterminate: false, measure: "", icon: `<span>${GLYPHS[status]}</span>`, message: ""
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The rate over the run so far and the time left at it, as the terminal shows them
|
|
49
|
+
function pace(tree, node) {
|
|
50
|
+
if (node.status !== "active" || node.children.length > 0 || !node.started_at || node.count === 0) return ""
|
|
51
|
+
const seconds = tree.now() - node.started_at
|
|
52
|
+
if (seconds < 1) return ""
|
|
53
|
+
const rate = node.count / seconds
|
|
54
|
+
const per = node.unit === "bytes" ? `${humanBytes(rate)}/s` : `${rate < 10 ? rate.toFixed(1) : Math.floor(rate)}/s`
|
|
55
|
+
if (node.total == null || node.total <= node.count) return per
|
|
56
|
+
return `${per}, ${duration((node.total - node.count) / rate)} left`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function duration(seconds) {
|
|
60
|
+
if (seconds < 60) return `${seconds.toFixed(1)}s`
|
|
61
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m${String(Math.floor(seconds % 60)).padStart(2, "0")}s`
|
|
62
|
+
return `${Math.floor(seconds / 3600)}h${String(Math.floor((seconds % 3600) / 60)).padStart(2, "0")}m`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Measured on the clock the times came from, never this machine's
|
|
66
|
+
function elapsed(tree, node) {
|
|
67
|
+
if (!node.started_at) return ""
|
|
68
|
+
return `${Math.max(0, (node.finished_at ?? tree.now()) - node.started_at).toFixed(1)}s`
|
|
69
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import type { Path } from "./task_tree.js"
|
|
3
|
+
import type { LocalTasks } from "./local_tasks.js"
|
|
4
|
+
|
|
5
|
+
/** Register as "task-tree" - see the MANUAL */
|
|
6
|
+
export default class TaskTreeController extends Controller {
|
|
7
|
+
graft(path: Path, local: LocalTasks): void
|
|
8
|
+
/** Draws tasks running in this browser on their own, in place of whatever it followed */
|
|
9
|
+
show(local: LocalTasks): void
|
|
10
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { followEventSourceAt, followLocal, followPollingAt, followProgress, requestCancel, pathKey } from "./task_tree.js"
|
|
3
|
+
import { rowView, CANCEL_ICON } from "./row_view.js"
|
|
4
|
+
|
|
5
|
+
// Draws a tree. Give it what TaskProgress.for_page hands a page, as the progress value - it draws
|
|
6
|
+
// the snapshot at once, follows the stream, falls back to polling where SSE does not get through,
|
|
7
|
+
// and its cancel buttons ask where it says:
|
|
8
|
+
// <div data-controller="task-tree" data-task-tree-progress-value="<%= TaskProgress.for_page(key).to_json %>"></div>
|
|
9
|
+
// Or point it at a TaskProgress SSE stream with the url value:
|
|
10
|
+
// <div data-controller="task-tree" data-task-tree-url-value="/jobs/abc/events"></div>
|
|
11
|
+
// Changing the value follows the new stream instead. Where SSE does not get through, point it at
|
|
12
|
+
// a TaskProgress::Poll endpoint and give it a poll value - seconds between polls - and a wait
|
|
13
|
+
// value to long-poll instead. Draws once a frame at most, and then only
|
|
14
|
+
// the rows the batches in that frame touched. Fires task-tree:render after every draw.
|
|
15
|
+
//
|
|
16
|
+
// A cancelable task gets a cancel button. Pressing it fires task-tree:cancel with the task's
|
|
17
|
+
// path, and - unless that is prevented - creates a cancellation at the cancel-url value, if there
|
|
18
|
+
// is one, signed with the cancel-token value. A task running in this browser is canceled here.
|
|
19
|
+
//
|
|
20
|
+
// `graft(path, localTasks)` draws tasks running in this browser under a server task, until the
|
|
21
|
+
// url value changes - see TaskTree#graft. `show(localTasks)` draws them on their own instead, with
|
|
22
|
+
// no server at all - uploads, say. Reach the controller through an outlet, or with
|
|
23
|
+
// application.getControllerForElementAndIdentifier(element, "task-tree")
|
|
24
|
+
export default class extends Controller {
|
|
25
|
+
static values = { url: String, progress: Object, cancelUrl: String, cancelToken: String, poll: Number, wait: Number }
|
|
26
|
+
|
|
27
|
+
urlValueChanged() { this.#follow() }
|
|
28
|
+
|
|
29
|
+
progressValueChanged() { this.#follow() }
|
|
30
|
+
|
|
31
|
+
#follow(local = null) {
|
|
32
|
+
this.#stop()
|
|
33
|
+
this.rows = new Map()
|
|
34
|
+
this.keys = null
|
|
35
|
+
this.element.replaceChildren()
|
|
36
|
+
const draw = (tree, changes) => this.render(tree, changes)
|
|
37
|
+
this.local = local
|
|
38
|
+
if (local) return (this.following = followLocal(local, draw))
|
|
39
|
+
const progress = this.progressValue
|
|
40
|
+
if (progress?.events || progress?.poll) {
|
|
41
|
+
const transport = this.pollValue || this.waitValue ? "poll" : "auto"
|
|
42
|
+
this.following = followProgress(progress, draw, { transport, poll: this.pollValue || 1, wait: this.waitValue })
|
|
43
|
+
} else if (this.urlValue) {
|
|
44
|
+
this.following = this.pollValue || this.waitValue
|
|
45
|
+
? followPollingAt(this.urlValue, draw, { poll: this.pollValue || 1, wait: this.waitValue })
|
|
46
|
+
: followEventSourceAt(this.urlValue, draw)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Stimulus does not tell a controller its values again when its element comes back - moved in
|
|
51
|
+
// the page, or kept across visits with data-turbo-permanent - so it picks up where it was
|
|
52
|
+
connect() { if (!this.following) this.#follow(this.local) }
|
|
53
|
+
|
|
54
|
+
show(local) { this.#follow(local) }
|
|
55
|
+
|
|
56
|
+
disconnect() { this.#stop() }
|
|
57
|
+
|
|
58
|
+
graft(path, local) {
|
|
59
|
+
if (this.following) this.ungrafts.push(this.following.tree.graft(path, local))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
#stop() {
|
|
63
|
+
for (const ungraft of this.ungrafts ?? []) ungraft()
|
|
64
|
+
this.ungrafts = []
|
|
65
|
+
this.following?.close()
|
|
66
|
+
this.following = null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
render(tree, changes) {
|
|
70
|
+
const shown = [...tree.rows()]
|
|
71
|
+
// A task finishing can fold it away, so which rows there are is checked every time, not only when tasks come and go
|
|
72
|
+
const keys = shown.map((node) => pathKey(node.path)).join(" ")
|
|
73
|
+
const rebuilt = changes.structure || keys !== this.keys
|
|
74
|
+
if (rebuilt) {
|
|
75
|
+
const rows = new Map()
|
|
76
|
+
for (const node of shown) {
|
|
77
|
+
const k = pathKey(node.path)
|
|
78
|
+
rows.set(k, this.rows.get(k) ?? this.#row())
|
|
79
|
+
}
|
|
80
|
+
this.rows = rows
|
|
81
|
+
this.keys = keys
|
|
82
|
+
this.element.replaceChildren(...rows.values())
|
|
83
|
+
}
|
|
84
|
+
for (const node of shown) {
|
|
85
|
+
const k = pathKey(node.path)
|
|
86
|
+
// An active row reads the clock for its rate, ETA or elapsed time, so it moves without a batch for it
|
|
87
|
+
if (rebuilt || node.fold || changes.touched.has(k) || node.status === "active") this.#patch(this.rows.get(k), rowView(tree, node))
|
|
88
|
+
}
|
|
89
|
+
this.dispatch("render", { detail: changes })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
#row() {
|
|
93
|
+
const el = document.createElement("div")
|
|
94
|
+
el.innerHTML = `
|
|
95
|
+
<div class="label"><span class="icon"></span><span class="name"></span><span class="message"></span></div>
|
|
96
|
+
<div class="bar"><span></span></div>
|
|
97
|
+
<button type="button" class="cancel" hidden>${CANCEL_ICON}</button>
|
|
98
|
+
<div class="measure"></div>`
|
|
99
|
+
el.view = {}
|
|
100
|
+
el.querySelector(".cancel").addEventListener("click", (event) => this.#cancel(event.currentTarget, el.view))
|
|
101
|
+
return el
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#cancel(button, row) {
|
|
105
|
+
const { defaultPrevented } = this.dispatch("cancel", { detail: { path: row.path, name: row.name }, cancelable: true })
|
|
106
|
+
if (defaultPrevented) return
|
|
107
|
+
if (row.local) return this.following?.tree.cancelLocal(row.path, "Canceled in this browser")
|
|
108
|
+
const url = this.progressValue?.cancel || this.cancelUrlValue
|
|
109
|
+
if (!url) return
|
|
110
|
+
button.disabled = true // until the cancel comes down the stream, or the server says no
|
|
111
|
+
requestCancel(url, row.path, { token: this.cancelTokenValue || undefined })
|
|
112
|
+
.then((response) => { if (!response.ok) button.disabled = false }, () => { button.disabled = false })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Writes only what differs from the last draw, so an unchanged row costs no DOM work
|
|
116
|
+
#patch(el, row) {
|
|
117
|
+
const last = el.view
|
|
118
|
+
const set = (field, write) => { if (last[field] !== row[field]) write(row[field]) }
|
|
119
|
+
if (last.status !== row.status || last.stale !== row.stale || last.folded !== row.folded) {
|
|
120
|
+
el.className = `row ${row.status}${row.stale ? " stale" : ""}${row.folded ? " folded" : ""}`
|
|
121
|
+
}
|
|
122
|
+
set("depth", (depth) => { el.querySelector(".label").style.paddingLeft = `${depth * 18}px` })
|
|
123
|
+
set("icon", (icon) => { el.querySelector(".icon").innerHTML = icon })
|
|
124
|
+
set("name", (name) => { el.querySelector(".name").textContent = name })
|
|
125
|
+
set("message", (message) => { el.querySelector(".message").textContent = message })
|
|
126
|
+
set("indeterminate", (on) => { el.querySelector(".bar").classList.toggle("indeterminate", on) })
|
|
127
|
+
set("percent", (percent) => { el.querySelector(".bar span").style.width = `${percent}%` })
|
|
128
|
+
set("measure", (measure) => { el.querySelector(".measure").textContent = measure })
|
|
129
|
+
set("cancelable", (on) => {
|
|
130
|
+
const button = el.querySelector(".cancel")
|
|
131
|
+
button.hidden = !on
|
|
132
|
+
button.disabled = false
|
|
133
|
+
})
|
|
134
|
+
set("name", (name) => { el.querySelector(".cancel").setAttribute("aria-label", `Cancel ${name}`) })
|
|
135
|
+
el.view = row
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// <task-progress-clock started="…" drawn="…" [finished="…"]> - an elapsed time that ticks on
|
|
2
|
+
// between the frames a server sends (see TaskProgress::HTMLRenderer), so a page drawn a few
|
|
3
|
+
// times a second, or less, still counts the seconds. Its times are the server's: `drawn` is when
|
|
4
|
+
// the server drew it, and the time since then is measured here, so the two clocks never have to
|
|
5
|
+
// agree. A finished one stands still. Import this module once on the page, and the element
|
|
6
|
+
// does the rest
|
|
7
|
+
|
|
8
|
+
export class TaskProgressClock extends HTMLElement {
|
|
9
|
+
static observedAttributes = ["started", "drawn", "finished"]
|
|
10
|
+
|
|
11
|
+
connectedCallback() {
|
|
12
|
+
this.#sync()
|
|
13
|
+
this.timer = setInterval(() => this.#tick(), 100)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
disconnectedCallback() { clearInterval(this.timer) }
|
|
17
|
+
|
|
18
|
+
// A morph that redraws the tree changes the attributes, and the clock starts over from them
|
|
19
|
+
attributeChangedCallback() { this.#sync() }
|
|
20
|
+
|
|
21
|
+
#sync() { this.syncedAt = performance.now() / 1000 }
|
|
22
|
+
|
|
23
|
+
#tick() {
|
|
24
|
+
const started = Number(this.getAttribute("started"))
|
|
25
|
+
if (!started) return
|
|
26
|
+
const finished = this.getAttribute("finished")
|
|
27
|
+
const now = finished ? Number(finished) : Number(this.getAttribute("drawn")) + (performance.now() / 1000 - this.syncedAt)
|
|
28
|
+
const text = `${Math.max(0, now - started).toFixed(1)}s`
|
|
29
|
+
if (this.textContent !== text) this.textContent = text
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!customElements.get("task-progress-clock")) customElements.define("task-progress-clock", TaskProgressClock)
|
data/js/task_tree.css
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/* The rows of a task tree, as the Stimulus controller and the React component draw them.
|
|
2
|
+
The palette sits on :where(.tree), which weighs nothing - any selector of yours overrides it */
|
|
3
|
+
:where(.tree) {
|
|
4
|
+
--bg: #fbfaf7; --fg: #1d1d1b; --muted: #77756f; --line: #e6e3dc;
|
|
5
|
+
--track: #ebe8e1; --fill: #2f6f4f; --fail: #b3261e; --stale: #a06a00;
|
|
6
|
+
--stripe: color-mix(in srgb, var(--fill) 60%, white);
|
|
7
|
+
}
|
|
8
|
+
@media (prefers-color-scheme: dark) {
|
|
9
|
+
:where(.tree) { --bg: #161615; --fg: #ecebe6; --muted: #9a978f; --line: #2c2b29; --track: #2a2927; --fill: #5fb98c; --fail: #ff7a70; --stale: #e0a640; --stripe: color-mix(in srgb, var(--fill) 55%, black); }
|
|
10
|
+
}
|
|
11
|
+
.tree { border-top: 1px solid var(--line); }
|
|
12
|
+
/* Rows have one fixed height: every cell is a single line and the icon sits in a fixed box */
|
|
13
|
+
/* label | bar | cancel | measure - the cancel column is always there, so every bar lines up */
|
|
14
|
+
.row { display: grid; grid-template-columns: minmax(0, 1fr) 140px 20px 110px; gap: 12px; align-items: center; box-sizing: border-box; height: 35px; padding: 0; overflow: hidden; border-bottom: 1px solid var(--line); font-size: 14px; line-height: 20px; }
|
|
15
|
+
.row .label { display: flex; gap: 8px; align-items: center; min-width: 0; height: 20px; overflow: hidden; }
|
|
16
|
+
.row .name { flex: 0 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
17
|
+
.row .message { flex: 1 1 0; min-width: 0; color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
18
|
+
.row .measure { min-width: 0; color: var(--muted); font-size: 12px; text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
19
|
+
.row.failed .name, .row.failed .message { color: var(--fail); }
|
|
20
|
+
.row.stale .name::after { content: " (stale)"; color: var(--stale); }
|
|
21
|
+
.row.folded .name { color: var(--muted); }
|
|
22
|
+
/* Same box for every state, whatever font the glyph falls back to; only the inner .spin rotates */
|
|
23
|
+
.icon { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; line-height: 14px; font-size: 13px; overflow: hidden; }
|
|
24
|
+
.icon > span { display: block; line-height: 14px; }
|
|
25
|
+
/* Right of the bar, the way a Finder copy shows it. A fixed box, so a row is as tall with the
|
|
26
|
+
button as without it - and hidden it keeps its place, so nothing after it moves */
|
|
27
|
+
.row button.cancel { all: unset; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 20px; height: 20px; margin-left: -6px; border-radius: 50%; color: var(--muted); cursor: pointer; }
|
|
28
|
+
.row button.cancel:hover, .row button.cancel:focus-visible { color: var(--fail); background: var(--track); }
|
|
29
|
+
.row button.cancel:disabled { cursor: progress; opacity: 0.4; }
|
|
30
|
+
.row button.cancel[hidden] { display: inline-flex; visibility: hidden; }
|
|
31
|
+
/* Server-drawn rows (TaskProgress::HTMLRenderer) put the button in a form, or leave the column empty */
|
|
32
|
+
.row .cancel-slot { display: inline-flex; width: 20px; height: 20px; margin: 0; }
|
|
33
|
+
.bar { height: 6px; background: var(--track); border-radius: 3px; overflow: hidden; }
|
|
34
|
+
.bar > span { display: block; height: 100%; background: var(--fill); transition: width 0.2s ease-out; }
|
|
35
|
+
.row.failed .bar > span { background: var(--fail); }
|
|
36
|
+
.row.canceled .name { color: var(--muted); }
|
|
37
|
+
.row.canceled .bar > span { background: var(--muted); }
|
|
38
|
+
/* Work with no measure is a barber pole: the whole bar in stripes, crawling along. One tile is
|
|
39
|
+
one stripe and one gap, so moving the background by a tile is seamless */
|
|
40
|
+
.bar.indeterminate > span {
|
|
41
|
+
width: 100% !important; transition: none;
|
|
42
|
+
background-color: var(--fill);
|
|
43
|
+
background-image: linear-gradient(-45deg, var(--stripe) 25%, transparent 25%, transparent 50%, var(--stripe) 50%, var(--stripe) 75%, transparent 75%, transparent);
|
|
44
|
+
background-size: 12px 12px;
|
|
45
|
+
animation: stripes 0.6s linear infinite;
|
|
46
|
+
}
|
|
47
|
+
@keyframes stripes { from { background-position: 0 0; } to { background-position: 12px 0; } }
|
|
48
|
+
@media (prefers-reduced-motion: reduce) { .bar.indeterminate > span { animation: none; } }
|
|
49
|
+
.icon > .spin { box-sizing: border-box; width: 11px; height: 11px; border: 2px solid var(--track); border-top-color: var(--fill); border-radius: 50%; animation: spin 0.9s linear infinite; }
|
|
50
|
+
@keyframes spin { to { transform: rotate(360deg); } }
|
|
51
|
+
@media (max-width: 560px) { .row { grid-template-columns: minmax(0, 1fr) 80px 20px; } .row .measure { display: none; } }
|
data/js/task_tree.d.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Path } from "./paths.js"
|
|
2
|
+
import type { Changes, Schedule } from "./render_queue.js"
|
|
3
|
+
|
|
4
|
+
export { pathKey, pathFromKey } from "./paths.js"
|
|
5
|
+
export type { Path } from "./paths.js"
|
|
6
|
+
export type { Changes } from "./render_queue.js"
|
|
7
|
+
|
|
8
|
+
/** One op: `[path, op, value]` - see the wire format in the MANUAL */
|
|
9
|
+
export type Op = [Path, string] | [Path, string, unknown]
|
|
10
|
+
|
|
11
|
+
/** A batch, as TaskProgress::Wire writes it */
|
|
12
|
+
export interface Batch {
|
|
13
|
+
v: 1
|
|
14
|
+
log: string
|
|
15
|
+
prev: number
|
|
16
|
+
seq: number
|
|
17
|
+
at: number
|
|
18
|
+
ops: Op[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type Status = "pending" | "active" | "done" | "failed" | "canceled"
|
|
22
|
+
|
|
23
|
+
/** One task, as the tree holds it */
|
|
24
|
+
export interface Node {
|
|
25
|
+
path: Path
|
|
26
|
+
name: string
|
|
27
|
+
total: number | null
|
|
28
|
+
count: number
|
|
29
|
+
percent: number
|
|
30
|
+
weight: number
|
|
31
|
+
expect: number | null
|
|
32
|
+
indeterminate: boolean
|
|
33
|
+
cancelable: boolean
|
|
34
|
+
attempt: number | null
|
|
35
|
+
unit: string | null
|
|
36
|
+
message: string | null
|
|
37
|
+
status: Status
|
|
38
|
+
error: { class: string; message: string; backtrace?: string[] } | null
|
|
39
|
+
stale: boolean
|
|
40
|
+
opened_at: number
|
|
41
|
+
started_at: number | null
|
|
42
|
+
finished_at: number | null
|
|
43
|
+
children: string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Siblings drawn as one row, see TaskTree#rows */
|
|
47
|
+
export interface Fold {
|
|
48
|
+
fold: true
|
|
49
|
+
path: Path
|
|
50
|
+
status: "done" | "pending"
|
|
51
|
+
count: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type ApplyOutcome = "applied" | "duplicate" | "gap" | "unsupported"
|
|
55
|
+
|
|
56
|
+
/** What TaskProgress.for_page hands a page */
|
|
57
|
+
export interface Progress {
|
|
58
|
+
events?: string
|
|
59
|
+
poll?: string
|
|
60
|
+
cancel?: string
|
|
61
|
+
snapshot?: Batch
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const WIRE_VERSION: 1
|
|
65
|
+
export const FOLD_OVER: number
|
|
66
|
+
export const FOLD_KEEP: number
|
|
67
|
+
|
|
68
|
+
export class TaskTree {
|
|
69
|
+
constructor(options?: { clock?: () => number })
|
|
70
|
+
log: string | null
|
|
71
|
+
seq: number | null
|
|
72
|
+
stale: boolean
|
|
73
|
+
readonly root: Node | undefined
|
|
74
|
+
apply(batch: Batch): ApplyOutcome
|
|
75
|
+
get(path: Path): Node | undefined
|
|
76
|
+
childrenOf(node: Node): Node[]
|
|
77
|
+
each(node?: Node): Generator<Node>
|
|
78
|
+
rows(node?: Node, options?: { foldOver?: number; keep?: number }): Generator<Node | Fold>
|
|
79
|
+
complete(node: Node): boolean
|
|
80
|
+
fraction(node: Node): number
|
|
81
|
+
percentOf(node: Node): number
|
|
82
|
+
now(): number
|
|
83
|
+
/** The LocalTasks this whole tree mirrors, see followLocal */
|
|
84
|
+
local: import("./local_tasks.js").LocalTasks | null
|
|
85
|
+
graft(path: Path, local: import("./local_tasks.js").LocalTasks): () => void
|
|
86
|
+
cancelLocal(path: Path, reason?: string): void
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface Following {
|
|
90
|
+
readonly tree: TaskTree
|
|
91
|
+
close(): void
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface FeedOptions {
|
|
95
|
+
schedule?: Schedule
|
|
96
|
+
clock?: () => number
|
|
97
|
+
/** A batch to start from - the snapshot a page was rendered with */
|
|
98
|
+
initial?: Batch
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type OnChange = (tree: TaskTree, changes: Changes) => void
|
|
102
|
+
|
|
103
|
+
export function batchFeed(onChange: OnChange, options?: FeedOptions): Following & { push(batch: Batch): ApplyOutcome }
|
|
104
|
+
export function followEventSourceAt(url: string, onChange: OnChange, options?: FeedOptions & { onUnreachable?: () => void; unreachableAfter?: number }): Following
|
|
105
|
+
/** `poll` and `wait` are in seconds */
|
|
106
|
+
export function followPollingAt(url: string, onChange: OnChange, options?: FeedOptions & { poll?: number; wait?: number }): Following
|
|
107
|
+
/** Draws tasks that run in this browser on their own, with no server tree around them */
|
|
108
|
+
export function followLocal(local: import("./local_tasks.js").LocalTasks, onChange: OnChange, options?: FeedOptions): Following
|
|
109
|
+
export function followProgress(progress: Progress, onChange: OnChange, options?: FeedOptions & { transport?: "auto" | "sse" | "poll"; poll?: number; wait?: number; unreachableAfter?: number }): Following
|
|
110
|
+
export function requestCancel(url: string, path: Path, options?: { token?: string }): Promise<Response>
|
|
111
|
+
export function humanBytes(n: number): string
|