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.
Files changed (107) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +29 -0
  3. data/LICENSE +21 -0
  4. data/MANUAL.md +851 -0
  5. data/README.md +158 -0
  6. data/RECIPES.md +413 -0
  7. data/config/importmap.rb +17 -0
  8. data/exe/task_progress +51 -0
  9. data/js/local_tasks.d.ts +44 -0
  10. data/js/local_tasks.js +229 -0
  11. data/js/package.json +31 -0
  12. data/js/paths.d.ts +5 -0
  13. data/js/paths.js +7 -0
  14. data/js/react_task_tree.d.ts +49 -0
  15. data/js/react_task_tree.js +123 -0
  16. data/js/render_queue.d.ts +24 -0
  17. data/js/render_queue.js +79 -0
  18. data/js/row_view.d.ts +25 -0
  19. data/js/row_view.js +69 -0
  20. data/js/stimulus_controller.d.ts +10 -0
  21. data/js/stimulus_controller.js +137 -0
  22. data/js/task_progress_clock.d.ts +2 -0
  23. data/js/task_progress_clock.js +33 -0
  24. data/js/task_tree.css +51 -0
  25. data/js/task_tree.d.ts +111 -0
  26. data/js/task_tree.js +444 -0
  27. data/lib/task_progress/assertions.rb +49 -0
  28. data/lib/task_progress/batch.rb +23 -0
  29. data/lib/task_progress/branch.rb +74 -0
  30. data/lib/task_progress/buffered_output.rb +63 -0
  31. data/lib/task_progress/cancellations_app.rb +76 -0
  32. data/lib/task_progress/event.rb +46 -0
  33. data/lib/task_progress/execution_log_output.rb +183 -0
  34. data/lib/task_progress/fold.rb +18 -0
  35. data/lib/task_progress/html_renderer.rb +153 -0
  36. data/lib/task_progress/job_serializer.rb +92 -0
  37. data/lib/task_progress/journal.rb +35 -0
  38. data/lib/task_progress/log.rb +263 -0
  39. data/lib/task_progress/memory_cache.rb +31 -0
  40. data/lib/task_progress/memory_stream_store.rb +142 -0
  41. data/lib/task_progress/mount.rb +448 -0
  42. data/lib/task_progress/ndjson_output.rb +24 -0
  43. data/lib/task_progress/node.rb +52 -0
  44. data/lib/task_progress/poll.rb +87 -0
  45. data/lib/task_progress/publishing_output.rb +30 -0
  46. data/lib/task_progress/ractor_output.rb +23 -0
  47. data/lib/task_progress/rails_defaults.rb +46 -0
  48. data/lib/task_progress/railtie.rb +91 -0
  49. data/lib/task_progress/recording_output.rb +51 -0
  50. data/lib/task_progress/redis_stream_store.rb +131 -0
  51. data/lib/task_progress/squasher.rb +59 -0
  52. data/lib/task_progress/sse.rb +114 -0
  53. data/lib/task_progress/state.rb +319 -0
  54. data/lib/task_progress/stream_output.rb +222 -0
  55. data/lib/task_progress/stream_reader.rb +76 -0
  56. data/lib/task_progress/summary_output.rb +37 -0
  57. data/lib/task_progress/terminal_output.rb +241 -0
  58. data/lib/task_progress/token.rb +58 -0
  59. data/lib/task_progress/tracked_io.rb +68 -0
  60. data/lib/task_progress/transaction.rb +41 -0
  61. data/lib/task_progress/trees_app.rb +66 -0
  62. data/lib/task_progress/turbo_helper.rb +18 -0
  63. data/lib/task_progress/turbo_sse.rb +103 -0
  64. data/lib/task_progress/turbo_stream_output.rb +61 -0
  65. data/lib/task_progress/version.rb +7 -0
  66. data/lib/task_progress/wire.rb +211 -0
  67. data/lib/task_progress.rb +1036 -0
  68. data/sig/generated/task_progress/assertions.rbs +36 -0
  69. data/sig/generated/task_progress/batch.rbs +33 -0
  70. data/sig/generated/task_progress/branch.rbs +47 -0
  71. data/sig/generated/task_progress/buffered_output.rbs +38 -0
  72. data/sig/generated/task_progress/cancellations_app.rbs +41 -0
  73. data/sig/generated/task_progress/event.rbs +20 -0
  74. data/sig/generated/task_progress/execution_log_output.rbs +93 -0
  75. data/sig/generated/task_progress/fold.rbs +25 -0
  76. data/sig/generated/task_progress/html_renderer.rbs +71 -0
  77. data/sig/generated/task_progress/journal.rbs +25 -0
  78. data/sig/generated/task_progress/log.rbs +140 -0
  79. data/sig/generated/task_progress/memory_cache.rbs +28 -0
  80. data/sig/generated/task_progress/memory_stream_store.rbs +83 -0
  81. data/sig/generated/task_progress/mount.rbs +200 -0
  82. data/sig/generated/task_progress/ndjson_output.rbs +18 -0
  83. data/sig/generated/task_progress/node.rbs +66 -0
  84. data/sig/generated/task_progress/poll.rbs +50 -0
  85. data/sig/generated/task_progress/publishing_output.rbs +24 -0
  86. data/sig/generated/task_progress/ractor_output.rbs +17 -0
  87. data/sig/generated/task_progress/rails_defaults.rbs +33 -0
  88. data/sig/generated/task_progress/recording_output.rbs +41 -0
  89. data/sig/generated/task_progress/redis_stream_store.rbs +75 -0
  90. data/sig/generated/task_progress/squasher.rbs +20 -0
  91. data/sig/generated/task_progress/sse.rbs +73 -0
  92. data/sig/generated/task_progress/state.rbs +165 -0
  93. data/sig/generated/task_progress/stream_output.rbs +142 -0
  94. data/sig/generated/task_progress/stream_reader.rbs +46 -0
  95. data/sig/generated/task_progress/summary_output.rbs +19 -0
  96. data/sig/generated/task_progress/terminal_output.rbs +117 -0
  97. data/sig/generated/task_progress/token.rbs +41 -0
  98. data/sig/generated/task_progress/tracked_io.rbs +48 -0
  99. data/sig/generated/task_progress/transaction.rbs +33 -0
  100. data/sig/generated/task_progress/trees_app.rbs +40 -0
  101. data/sig/generated/task_progress/turbo_helper.rbs +14 -0
  102. data/sig/generated/task_progress/turbo_sse.rbs +69 -0
  103. data/sig/generated/task_progress/turbo_stream_output.rbs +51 -0
  104. data/sig/generated/task_progress/version.rbs +6 -0
  105. data/sig/generated/task_progress/wire.rbs +102 -0
  106. data/sig/generated/task_progress.rbs +553 -0
  107. 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,2 @@
1
+ /** <task-progress-clock started drawn [finished]> - defined when the module is imported */
2
+ export class TaskProgressClock extends HTMLElement {}
@@ -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