pixie-lab 0.1.0__py3-none-any.whl
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.
- pixie/__init__.py +20 -0
- pixie/_util.py +38 -0
- pixie/catalog/__init__.py +25 -0
- pixie/catalog/_fetcher.py +513 -0
- pixie/catalog/_routes.py +392 -0
- pixie/catalog/_schema.py +263 -0
- pixie/catalog/_store.py +234 -0
- pixie/deploy/__init__.py +26 -0
- pixie/deploy/_main.py +321 -0
- pixie/deploy/_templates.py +153 -0
- pixie/disks.py +85 -0
- pixie/events/__init__.py +28 -0
- pixie/events/_kinds.py +203 -0
- pixie/events/_log.py +210 -0
- pixie/events/_routes.py +42 -0
- pixie/exports/__init__.py +17 -0
- pixie/exports/_routes.py +211 -0
- pixie/exports/_store.py +156 -0
- pixie/exports/_supervisor.py +240 -0
- pixie/flash.py +1971 -0
- pixie/images.py +473 -0
- pixie/machines/__init__.py +16 -0
- pixie/machines/_routes.py +190 -0
- pixie/machines/_store.py +623 -0
- pixie/oras.py +547 -0
- pixie/pivot/__init__.py +180 -0
- pixie/pivot/nbdboot +348 -0
- pixie/pxe/__init__.py +19 -0
- pixie/pxe/_renderer.py +244 -0
- pixie/pxe/_routes.py +386 -0
- pixie/tftp/__init__.py +21 -0
- pixie/tftp/_supervisor.py +129 -0
- pixie/tui/__init__.py +185 -0
- pixie/tui/_app.py +2219 -0
- pixie/tui_catalog.py +657 -0
- pixie/web/__init__.py +6 -0
- pixie/web/_auth.py +70 -0
- pixie/web/_settings_store.py +211 -0
- pixie/web/_static/.gitkeep +0 -0
- pixie/web/_static/bootstrap-icons.min.css +5 -0
- pixie/web/_static/bootstrap.min.css +12 -0
- pixie/web/_static/fonts/bootstrap-icons.woff +0 -0
- pixie/web/_static/fonts/bootstrap-icons.woff2 +0 -0
- pixie/web/_static/htmx.min.js +1 -0
- pixie/web/_static/pixie-favicon.png +0 -0
- pixie/web/_static/sse.js +290 -0
- pixie/web/_table_state.py +261 -0
- pixie/web/_templates/_partials/page_description.html +22 -0
- pixie/web/_templates/_partials/recent_events.html +47 -0
- pixie/web/_templates/_partials/table_helpers.html +189 -0
- pixie/web/_templates/catalog.html +364 -0
- pixie/web/_templates/catalog_detail.html +386 -0
- pixie/web/_templates/dashboard.html +256 -0
- pixie/web/_templates/events.html +125 -0
- pixie/web/_templates/ipxe/bootstrap.j2 +12 -0
- pixie/web/_templates/ipxe/exit.j2 +10 -0
- pixie/web/_templates/ipxe/nbdboot.j2 +57 -0
- pixie/web/_templates/ipxe/pixie-live-env.j2 +55 -0
- pixie/web/_templates/ipxe/unavailable.j2 +14 -0
- pixie/web/_templates/layout.html +345 -0
- pixie/web/_templates/login.html +40 -0
- pixie/web/_templates/machine_detail.html +786 -0
- pixie/web/_templates/machines.html +186 -0
- pixie/web/_templates/settings.html +265 -0
- pixie/web/main.py +1910 -0
- pixie_lab-0.1.0.dist-info/METADATA +107 -0
- pixie_lab-0.1.0.dist-info/RECORD +70 -0
- pixie_lab-0.1.0.dist-info/WHEEL +4 -0
- pixie_lab-0.1.0.dist-info/entry_points.txt +3 -0
- pixie_lab-0.1.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
{% extends "layout.html" %}
|
|
2
|
+
{% from "_partials/table_helpers.html" import search_input, sort_header, pagination_inline %}
|
|
3
|
+
{% from "_partials/recent_events.html" import recent_events_card %}
|
|
4
|
+
{% from "_partials/page_description.html" import page_description %}
|
|
5
|
+
{% block title %}Catalog - pixie{% endblock %}
|
|
6
|
+
{% block subnav %}
|
|
7
|
+
<nav class="navbar subnav-strip" aria-label="Section sub-navigation">
|
|
8
|
+
<div class="container">
|
|
9
|
+
{# LEFT: relative anchor links to in-page sections. #}
|
|
10
|
+
<span class="subnav-jumps d-flex align-items-center flex-wrap">
|
|
11
|
+
<a href="#entries">Entries</a>
|
|
12
|
+
</span>
|
|
13
|
+
{# RIGHT: inline action form. Import a catalog.toml URL and
|
|
14
|
+
upsert every entry. #}
|
|
15
|
+
<form method="post" action="/ui/catalog/import"
|
|
16
|
+
class="subnav-actions d-flex align-items-center gap-1 m-0 ms-auto">
|
|
17
|
+
<label for="catalog_url" class="text-white-50 small mb-0 flex-shrink-0"
|
|
18
|
+
title="Import a catalog.toml URL">
|
|
19
|
+
<i class="bi bi-cloud-arrow-down"></i>
|
|
20
|
+
</label>
|
|
21
|
+
<input type="url" id="catalog_url" name="url"
|
|
22
|
+
class="form-control form-control-sm"
|
|
23
|
+
style="max-width: 22rem; min-width: 0;"
|
|
24
|
+
placeholder="catalog.toml URL"
|
|
25
|
+
required>
|
|
26
|
+
<button type="submit" class="btn btn-sm btn-primary flex-shrink-0 text-nowrap">
|
|
27
|
+
Import
|
|
28
|
+
</button>
|
|
29
|
+
</form>
|
|
30
|
+
</div>
|
|
31
|
+
</nav>
|
|
32
|
+
{% endblock %}
|
|
33
|
+
{% block content %}
|
|
34
|
+
{{ page_description('journal-code', 'Images pixie can flash or netboot. Add an entry with a source URL, then fetch it to pull the bytes onto pixie. An entry is bindable once fetched; deletion is safe only after every machine pointing at it is rebound.') }}
|
|
35
|
+
<div class="card" id="entries">
|
|
36
|
+
{# Card header: title/label on the left, filter form inline in
|
|
37
|
+
the middle, pagination + per-page on the right -- same
|
|
38
|
+
toolbar shape bty uses on /ui/events. #}
|
|
39
|
+
<div class="card-header bg-light fw-semibold d-flex flex-wrap align-items-center gap-3 py-2">
|
|
40
|
+
<span class="text-nowrap"><i class="bi bi-collection me-1"></i>Catalog</span>
|
|
41
|
+
<span class="vr"></span>
|
|
42
|
+
{{ search_input('/ui/catalog', q, preserved, 'name / src / arch / format / description') }}
|
|
43
|
+
{{ pagination_inline(page_state, preserved, label='entries') }}
|
|
44
|
+
</div>
|
|
45
|
+
{% if entries %}
|
|
46
|
+
<div class="table-responsive">
|
|
47
|
+
<table class="table table-hover align-middle mb-0">
|
|
48
|
+
<thead class="table-light">
|
|
49
|
+
<tr>
|
|
50
|
+
{{ sort_header('Entry', 'name', sort, preserved) }}
|
|
51
|
+
{{ sort_header('Type', 'format', sort, preserved) }}
|
|
52
|
+
<th scope="col">Fetch state</th>
|
|
53
|
+
<th scope="col">NBD</th>
|
|
54
|
+
<th scope="col" class="text-end">Actions</th>
|
|
55
|
+
</tr>
|
|
56
|
+
</thead>
|
|
57
|
+
<tbody>
|
|
58
|
+
{% for e in entries %}
|
|
59
|
+
{% set st = fetch_states.get(e.name) or {} %}
|
|
60
|
+
{% set exp = exports_by_sha.get(e.content_sha256) if e.content_sha256 else none %}
|
|
61
|
+
<tr>
|
|
62
|
+
<td>
|
|
63
|
+
<div class="fw-semibold">
|
|
64
|
+
<a href="/ui/catalog/{{ e.name }}" class="text-decoration-none">
|
|
65
|
+
<code>{{ e.name }}</code>
|
|
66
|
+
</a>
|
|
67
|
+
</div>
|
|
68
|
+
{% if e.description %}
|
|
69
|
+
<div class="text-muted small">{{ e.description }}</div>
|
|
70
|
+
{% endif %}
|
|
71
|
+
<div class="text-muted small">
|
|
72
|
+
<i class="bi bi-link-45deg"></i>
|
|
73
|
+
<code>{{ e.src }}</code>
|
|
74
|
+
</div>
|
|
75
|
+
{% if e.netboot_src %}
|
|
76
|
+
<div class="text-muted small">
|
|
77
|
+
<i class="bi bi-box-seam"></i>
|
|
78
|
+
netboot bundle: <code>{{ e.netboot_src }}</code>
|
|
79
|
+
</div>
|
|
80
|
+
{% endif %}
|
|
81
|
+
</td>
|
|
82
|
+
<td>
|
|
83
|
+
{% if e.bindable %}
|
|
84
|
+
<span class="badge text-bg-primary">
|
|
85
|
+
<i class="bi bi-hdd me-1"></i>Disk image
|
|
86
|
+
</span>
|
|
87
|
+
{% else %}
|
|
88
|
+
<span class="badge text-bg-info">
|
|
89
|
+
<i class="bi bi-box-seam me-1"></i>Netboot bundle
|
|
90
|
+
</span>
|
|
91
|
+
{% endif %}
|
|
92
|
+
<div class="text-muted small mt-1">
|
|
93
|
+
<span class="badge text-bg-secondary">{{ e.format }}</span>
|
|
94
|
+
{% if e.arch %}<span class="ms-1">{{ e.arch }}</span>{% endif %}
|
|
95
|
+
</div>
|
|
96
|
+
</td>
|
|
97
|
+
<td data-fetch-cell="{{ e.name }}">
|
|
98
|
+
{% if st.state == "fetching" %}
|
|
99
|
+
{# Live phase pill: server-rendered starting
|
|
100
|
+
state; the poll script below rewrites this
|
|
101
|
+
cell's inner HTML every 2s when there's an
|
|
102
|
+
in-flight fetch. Phases in the sub-state
|
|
103
|
+
dict: ``downloading`` (+bytes_downloaded /
|
|
104
|
+
total_bytes), ``decompressing`` (+format),
|
|
105
|
+
``unpacking`` (netboot bundles). #}
|
|
106
|
+
{% if st.phase == "downloading" %}
|
|
107
|
+
<span class="badge text-bg-primary">
|
|
108
|
+
<i class="bi bi-cloud-download me-1"></i>downloading
|
|
109
|
+
</span>
|
|
110
|
+
{% if st.total_bytes %}
|
|
111
|
+
<div class="text-muted small font-monospace">
|
|
112
|
+
{{ st.bytes_downloaded or 0 }} / {{ st.total_bytes }} bytes
|
|
113
|
+
</div>
|
|
114
|
+
{% elif st.bytes_downloaded %}
|
|
115
|
+
<div class="text-muted small font-monospace">
|
|
116
|
+
{{ st.bytes_downloaded }} bytes so far
|
|
117
|
+
</div>
|
|
118
|
+
{% endif %}
|
|
119
|
+
{% elif st.phase == "decompressing" %}
|
|
120
|
+
<span class="badge text-bg-primary">
|
|
121
|
+
<i class="bi bi-box-arrow-in-down me-1"></i>decompressing
|
|
122
|
+
</span>
|
|
123
|
+
{% if st.format %}
|
|
124
|
+
<div class="text-muted small">{{ st.format }}</div>
|
|
125
|
+
{% endif %}
|
|
126
|
+
{% elif st.phase == "unpacking" %}
|
|
127
|
+
<span class="badge text-bg-primary">
|
|
128
|
+
<i class="bi bi-box2 me-1"></i>unpacking
|
|
129
|
+
</span>
|
|
130
|
+
{% else %}
|
|
131
|
+
<span class="badge text-bg-primary">
|
|
132
|
+
<i class="bi bi-arrow-down-circle me-1"></i>fetching…
|
|
133
|
+
</span>
|
|
134
|
+
{% endif %}
|
|
135
|
+
{% if st.started_at %}
|
|
136
|
+
<div class="text-muted small">since {{ st.started_at | fmt_ts }}</div>
|
|
137
|
+
{% endif %}
|
|
138
|
+
{% elif st.state == "error" %}
|
|
139
|
+
<span class="badge text-bg-danger" title="{{ st.error or '' }}">
|
|
140
|
+
<i class="bi bi-exclamation-circle me-1"></i>error
|
|
141
|
+
</span>
|
|
142
|
+
{% if st.error %}
|
|
143
|
+
<div class="text-muted small">{{ st.error }}</div>
|
|
144
|
+
{% endif %}
|
|
145
|
+
{% elif st.state == "unchanged" %}
|
|
146
|
+
{# The operator clicked Update but the entry was
|
|
147
|
+
already at the current sha and the on-disk
|
|
148
|
+
bytes were intact. The pill lingers ~8 s (the
|
|
149
|
+
handler auto-clears it) so the click has a
|
|
150
|
+
visible acknowledgement, then the row falls
|
|
151
|
+
back to the plain "fetched" badge. #}
|
|
152
|
+
<span class="badge text-bg-info" title="the on-disk bytes match the currently-recorded sha; nothing to fetch">
|
|
153
|
+
<i class="bi bi-check2-all me-1"></i>already at latest
|
|
154
|
+
</span>
|
|
155
|
+
{% if e.content_sha256 %}
|
|
156
|
+
<div class="text-muted small font-monospace">
|
|
157
|
+
{{ e.content_sha256[:12] }}
|
|
158
|
+
</div>
|
|
159
|
+
{% endif %}
|
|
160
|
+
{% elif e.content_sha256 %}
|
|
161
|
+
<span class="badge text-bg-success">
|
|
162
|
+
<i class="bi bi-check-circle me-1"></i>fetched
|
|
163
|
+
</span>
|
|
164
|
+
<div class="text-muted small font-monospace">
|
|
165
|
+
{{ e.content_sha256[:12] }}
|
|
166
|
+
</div>
|
|
167
|
+
{% if e.size_bytes %}
|
|
168
|
+
<div class="text-muted small">{{ e.size_bytes }} bytes</div>
|
|
169
|
+
{% endif %}
|
|
170
|
+
{% else %}
|
|
171
|
+
<span class="badge text-bg-secondary">
|
|
172
|
+
<i class="bi bi-dash-circle me-1"></i>not fetched
|
|
173
|
+
</span>
|
|
174
|
+
{% endif %}
|
|
175
|
+
</td>
|
|
176
|
+
<td>
|
|
177
|
+
{% if not e.bindable %}
|
|
178
|
+
<span class="text-muted small">
|
|
179
|
+
<i class="bi bi-dash"></i> (HTTP artifact)
|
|
180
|
+
</span>
|
|
181
|
+
{% elif exp is none %}
|
|
182
|
+
<span class="text-muted small">
|
|
183
|
+
<i class="bi bi-dash"></i> not serving
|
|
184
|
+
</span>
|
|
185
|
+
{% elif exp.status == 'running' %}
|
|
186
|
+
<span class="badge text-bg-success">
|
|
187
|
+
<i class="bi bi-broadcast me-1"></i>running
|
|
188
|
+
</span>
|
|
189
|
+
<div class="text-muted small font-monospace">port {{ exp.nbd_port }}</div>
|
|
190
|
+
{% elif exp.status == 'error' %}
|
|
191
|
+
<span class="badge text-bg-danger">
|
|
192
|
+
<i class="bi bi-exclamation-circle me-1"></i>error
|
|
193
|
+
</span>
|
|
194
|
+
{% if exp.error %}
|
|
195
|
+
<div class="text-danger small">{{ exp.error }}</div>
|
|
196
|
+
{% endif %}
|
|
197
|
+
{% else %}
|
|
198
|
+
<span class="badge text-bg-secondary">{{ exp.status }}</span>
|
|
199
|
+
{% endif %}
|
|
200
|
+
</td>
|
|
201
|
+
<td class="text-end text-nowrap">
|
|
202
|
+
{# All three action buttons are ALWAYS rendered so an
|
|
203
|
+
operator's eye finds them at the same X position in
|
|
204
|
+
every row. Verb + enabled state track the row's
|
|
205
|
+
current state: no bytes on disk -> Fetch; a prior
|
|
206
|
+
fetch failed -> Retry; already have bytes -> Update
|
|
207
|
+
(re-runs the fetch pipeline; picks up a moved
|
|
208
|
+
oras:// tag's new manifest); in-flight fetch ->
|
|
209
|
+
Fetching (disabled). Update is enabled because
|
|
210
|
+
labelling a fetched entry as "Fetch" makes an
|
|
211
|
+
operator instinctively hit it thinking bytes are
|
|
212
|
+
missing. Stop is disabled when there is no NBD
|
|
213
|
+
export to stop; Delete is always enabled. #}
|
|
214
|
+
{% set fetching = st.state == 'fetching' %}
|
|
215
|
+
{% set fetch_errored = st.state == 'error' %}
|
|
216
|
+
{% set already_fetched = e.content_sha256 and not fetch_errored %}
|
|
217
|
+
<form method="post" action="/ui/catalog/fetch" class="d-inline">
|
|
218
|
+
<input type="hidden" name="name" value="{{ e.name }}">
|
|
219
|
+
<button type="submit" class="btn btn-sm btn-outline-primary"
|
|
220
|
+
{% if fetching %}disabled{% endif %}
|
|
221
|
+
title="{% if fetching %}fetch already in flight{% elif already_fetched %}re-run the fetch pipeline; picks up a moved oras:// tag's new bytes{% elif fetch_errored %}retry the failed fetch{% else %}download the bytes for this entry{% endif %}">
|
|
222
|
+
{% if fetching %}
|
|
223
|
+
<i class="bi bi-hourglass-split me-1"></i>Fetching
|
|
224
|
+
{% elif fetch_errored %}
|
|
225
|
+
<i class="bi bi-arrow-clockwise me-1"></i>Retry
|
|
226
|
+
{% elif already_fetched %}
|
|
227
|
+
<i class="bi bi-arrow-repeat me-1"></i>Update
|
|
228
|
+
{% else %}
|
|
229
|
+
<i class="bi bi-arrow-down-circle me-1"></i>Fetch
|
|
230
|
+
{% endif %}
|
|
231
|
+
</button>
|
|
232
|
+
</form>
|
|
233
|
+
<form method="post" action="/ui/exports/delete" class="d-inline"
|
|
234
|
+
title="{% if exp is none %}no NBD export to stop{% else %}stop the NBD export ({{ exp.name }}); the catalog entry stays{% endif %}">
|
|
235
|
+
<input type="hidden" name="name" value="{{ exp.name if exp is not none else '' }}">
|
|
236
|
+
<button type="submit"
|
|
237
|
+
class="btn btn-sm btn-outline-warning"
|
|
238
|
+
aria-label="{% if exp is none %}No NBD export for {{ e.name }}{% else %}Stop NBD export {{ exp.name }}{% endif %}"
|
|
239
|
+
{% if exp is none %}disabled{% endif %}>
|
|
240
|
+
<i class="bi bi-stop-circle" aria-hidden="true"></i>
|
|
241
|
+
</button>
|
|
242
|
+
</form>
|
|
243
|
+
<form method="post" action="/ui/catalog/delete" class="d-inline"
|
|
244
|
+
title="Delete this catalog entry (does not delete the fetched blob)."
|
|
245
|
+
onsubmit="return confirm('Delete catalog entry {{ e.name }}? The fetched blob stays on disk.');">
|
|
246
|
+
<input type="hidden" name="name" value="{{ e.name }}">
|
|
247
|
+
<button type="submit"
|
|
248
|
+
class="btn btn-sm btn-outline-danger"
|
|
249
|
+
aria-label="Delete catalog entry {{ e.name }}">
|
|
250
|
+
<i class="bi bi-trash" aria-hidden="true"></i>
|
|
251
|
+
</button>
|
|
252
|
+
</form>
|
|
253
|
+
</td>
|
|
254
|
+
</tr>
|
|
255
|
+
{% endfor %}
|
|
256
|
+
</tbody>
|
|
257
|
+
</table>
|
|
258
|
+
</div>
|
|
259
|
+
{% else %}
|
|
260
|
+
<div class="card-body">
|
|
261
|
+
<p class="text-muted mb-0">
|
|
262
|
+
<i class="bi bi-inbox me-1"></i>
|
|
263
|
+
{% if q %}
|
|
264
|
+
No entries match <code>{{ q }}</code>.
|
|
265
|
+
{% else %}
|
|
266
|
+
No entries yet. Paste a catalog URL in the bar above and hit Import;
|
|
267
|
+
entries land here staged (not fetched) so you can pick the ones to
|
|
268
|
+
pull with Fetch.
|
|
269
|
+
{% endif %}
|
|
270
|
+
</p>
|
|
271
|
+
</div>
|
|
272
|
+
{% endif %}
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<script>
|
|
276
|
+
// Live fetch-state poller. As long as any row in the current view has
|
|
277
|
+
// a data-fetch-cell whose innerHTML contains "fetching" / "downloading"
|
|
278
|
+
// / "decompressing" / "unpacking", poll /ui/catalog/fetch-states.json
|
|
279
|
+
// every 2 s and rewrite the cells in place. Stops polling when nothing
|
|
280
|
+
// in the current view is in flight anymore -- an operator on a full
|
|
281
|
+
// catalog page with 200 rows won't churn network for rows that are
|
|
282
|
+
// already done. A completed row (state=='done' or 'error') triggers
|
|
283
|
+
// one full-page reload so the fetched/error pill + Actions column
|
|
284
|
+
// reflect the terminal state without hand-crafted templating here.
|
|
285
|
+
(function () {
|
|
286
|
+
const POLL_MS = 2000;
|
|
287
|
+
const cells = document.querySelectorAll("[data-fetch-cell]");
|
|
288
|
+
if (!cells.length) return;
|
|
289
|
+
|
|
290
|
+
function fmtBytes(n) {
|
|
291
|
+
if (n == null) return "";
|
|
292
|
+
if (n < 1024) return n + " B";
|
|
293
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KiB";
|
|
294
|
+
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MiB";
|
|
295
|
+
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GiB";
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function renderCell(cell, state) {
|
|
299
|
+
if (!state || state.state !== "fetching") return false;
|
|
300
|
+
let icon = "bi-arrow-down-circle", label = "fetching…";
|
|
301
|
+
let sub = "";
|
|
302
|
+
if (state.phase === "downloading") {
|
|
303
|
+
icon = "bi-cloud-download"; label = "downloading";
|
|
304
|
+
const done = state.bytes_downloaded || 0;
|
|
305
|
+
if (state.total_bytes) {
|
|
306
|
+
const pct = Math.floor((done / state.total_bytes) * 100);
|
|
307
|
+
sub = `${fmtBytes(done)} / ${fmtBytes(state.total_bytes)} (${pct}%)`;
|
|
308
|
+
} else if (done) {
|
|
309
|
+
sub = `${fmtBytes(done)} so far`;
|
|
310
|
+
}
|
|
311
|
+
} else if (state.phase === "decompressing") {
|
|
312
|
+
icon = "bi-box-arrow-in-down"; label = "decompressing";
|
|
313
|
+
if (state.format) sub = state.format;
|
|
314
|
+
} else if (state.phase === "unpacking") {
|
|
315
|
+
icon = "bi-box2"; label = "unpacking";
|
|
316
|
+
}
|
|
317
|
+
let html = `<span class="badge text-bg-primary"><i class="bi ${icon} me-1"></i>${label}</span>`;
|
|
318
|
+
if (sub) html += `<div class="text-muted small font-monospace">${sub}</div>`;
|
|
319
|
+
cell.innerHTML = html;
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let interval = null;
|
|
324
|
+
async function poll() {
|
|
325
|
+
try {
|
|
326
|
+
const r = await fetch("/ui/fetch-states.json", {credentials: "same-origin"});
|
|
327
|
+
if (!r.ok) return;
|
|
328
|
+
const states = await r.json();
|
|
329
|
+
let anyInFlight = false;
|
|
330
|
+
let anyJustFinished = false;
|
|
331
|
+
cells.forEach((cell) => {
|
|
332
|
+
const name = cell.getAttribute("data-fetch-cell");
|
|
333
|
+
const st = states[name];
|
|
334
|
+
if (st && st.state === "fetching") {
|
|
335
|
+
renderCell(cell, st);
|
|
336
|
+
anyInFlight = true;
|
|
337
|
+
} else if (cell.textContent.match(/fetching|downloading|decompressing|unpacking/i)) {
|
|
338
|
+
// Was in-flight last render; the server-side state
|
|
339
|
+
// moved on. Trigger one reload so the terminal
|
|
340
|
+
// pill + Actions column show the correct state.
|
|
341
|
+
anyJustFinished = true;
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
if (anyJustFinished) {
|
|
345
|
+
window.location.reload();
|
|
346
|
+
} else if (!anyInFlight && interval) {
|
|
347
|
+
clearInterval(interval);
|
|
348
|
+
interval = null;
|
|
349
|
+
}
|
|
350
|
+
} catch (e) { /* swallow; try again next tick */ }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Only spin up polling when a row starts life "in flight".
|
|
354
|
+
const hasInFlightRow = Array.from(cells).some((c) =>
|
|
355
|
+
c.textContent.match(/fetching|downloading|decompressing|unpacking/i)
|
|
356
|
+
);
|
|
357
|
+
if (hasInFlightRow) {
|
|
358
|
+
poll();
|
|
359
|
+
interval = setInterval(poll, POLL_MS);
|
|
360
|
+
}
|
|
361
|
+
})();
|
|
362
|
+
</script>
|
|
363
|
+
{{ recent_events_card(catalog_events, "catalog") }}
|
|
364
|
+
{% endblock %}
|