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.
Files changed (70) hide show
  1. pixie/__init__.py +20 -0
  2. pixie/_util.py +38 -0
  3. pixie/catalog/__init__.py +25 -0
  4. pixie/catalog/_fetcher.py +513 -0
  5. pixie/catalog/_routes.py +392 -0
  6. pixie/catalog/_schema.py +263 -0
  7. pixie/catalog/_store.py +234 -0
  8. pixie/deploy/__init__.py +26 -0
  9. pixie/deploy/_main.py +321 -0
  10. pixie/deploy/_templates.py +153 -0
  11. pixie/disks.py +85 -0
  12. pixie/events/__init__.py +28 -0
  13. pixie/events/_kinds.py +203 -0
  14. pixie/events/_log.py +210 -0
  15. pixie/events/_routes.py +42 -0
  16. pixie/exports/__init__.py +17 -0
  17. pixie/exports/_routes.py +211 -0
  18. pixie/exports/_store.py +156 -0
  19. pixie/exports/_supervisor.py +240 -0
  20. pixie/flash.py +1971 -0
  21. pixie/images.py +473 -0
  22. pixie/machines/__init__.py +16 -0
  23. pixie/machines/_routes.py +190 -0
  24. pixie/machines/_store.py +623 -0
  25. pixie/oras.py +547 -0
  26. pixie/pivot/__init__.py +180 -0
  27. pixie/pivot/nbdboot +348 -0
  28. pixie/pxe/__init__.py +19 -0
  29. pixie/pxe/_renderer.py +244 -0
  30. pixie/pxe/_routes.py +386 -0
  31. pixie/tftp/__init__.py +21 -0
  32. pixie/tftp/_supervisor.py +129 -0
  33. pixie/tui/__init__.py +185 -0
  34. pixie/tui/_app.py +2219 -0
  35. pixie/tui_catalog.py +657 -0
  36. pixie/web/__init__.py +6 -0
  37. pixie/web/_auth.py +70 -0
  38. pixie/web/_settings_store.py +211 -0
  39. pixie/web/_static/.gitkeep +0 -0
  40. pixie/web/_static/bootstrap-icons.min.css +5 -0
  41. pixie/web/_static/bootstrap.min.css +12 -0
  42. pixie/web/_static/fonts/bootstrap-icons.woff +0 -0
  43. pixie/web/_static/fonts/bootstrap-icons.woff2 +0 -0
  44. pixie/web/_static/htmx.min.js +1 -0
  45. pixie/web/_static/pixie-favicon.png +0 -0
  46. pixie/web/_static/sse.js +290 -0
  47. pixie/web/_table_state.py +261 -0
  48. pixie/web/_templates/_partials/page_description.html +22 -0
  49. pixie/web/_templates/_partials/recent_events.html +47 -0
  50. pixie/web/_templates/_partials/table_helpers.html +189 -0
  51. pixie/web/_templates/catalog.html +364 -0
  52. pixie/web/_templates/catalog_detail.html +386 -0
  53. pixie/web/_templates/dashboard.html +256 -0
  54. pixie/web/_templates/events.html +125 -0
  55. pixie/web/_templates/ipxe/bootstrap.j2 +12 -0
  56. pixie/web/_templates/ipxe/exit.j2 +10 -0
  57. pixie/web/_templates/ipxe/nbdboot.j2 +57 -0
  58. pixie/web/_templates/ipxe/pixie-live-env.j2 +55 -0
  59. pixie/web/_templates/ipxe/unavailable.j2 +14 -0
  60. pixie/web/_templates/layout.html +345 -0
  61. pixie/web/_templates/login.html +40 -0
  62. pixie/web/_templates/machine_detail.html +786 -0
  63. pixie/web/_templates/machines.html +186 -0
  64. pixie/web/_templates/settings.html +265 -0
  65. pixie/web/main.py +1910 -0
  66. pixie_lab-0.1.0.dist-info/METADATA +107 -0
  67. pixie_lab-0.1.0.dist-info/RECORD +70 -0
  68. pixie_lab-0.1.0.dist-info/WHEEL +4 -0
  69. pixie_lab-0.1.0.dist-info/entry_points.txt +3 -0
  70. 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&hellip;
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 %}