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,386 @@
1
+ {% extends "layout.html" %}
2
+ {% block title %}{{ entry.name }} - Catalog - pixie{% endblock %}
3
+ {% block subnav %}
4
+ <nav class="navbar subnav-strip" aria-label="Section sub-navigation">
5
+ <div class="container">
6
+ <span class="subnav-jumps d-flex align-items-center flex-wrap">
7
+ <a href="/ui/catalog"><i class="bi bi-arrow-left me-1"></i>Catalog</a>
8
+ <span class="vr"></span>
9
+ <span class="text-white-50"><code>{{ entry.name }}</code></span>
10
+ </span>
11
+ </div>
12
+ </nav>
13
+ {% endblock %}
14
+ {% block content %}
15
+ {% if warn_update %}
16
+ <div class="alert alert-warning d-flex align-items-start" role="alert">
17
+ <i class="bi bi-exclamation-triangle-fill me-2 mt-1"></i>
18
+ <div class="flex-grow-1">
19
+ <strong>Update would overwrite bytes in use.</strong>
20
+ <div class="mt-1 small">
21
+ The current blob for <code>{{ entry.name }}</code>
22
+ (sha <code>{{ entry.content_sha256[:12] }}</code>) is
23
+ referenced by:
24
+ {% if blob_using_machines %}
25
+ <div class="mt-1">
26
+ <span class="text-muted">Machines bound to this image:</span>
27
+ <ul class="mb-0">
28
+ {% for mac in blob_using_machines %}
29
+ <li><a href="/ui/machines/{{ mac }}"><code>{{ mac }}</code></a></li>
30
+ {% endfor %}
31
+ </ul>
32
+ </div>
33
+ {% endif %}
34
+ {% if blob_running_exports %}
35
+ <div class="mt-1">
36
+ <span class="text-muted">Running NBD exports:</span>
37
+ <ul class="mb-0">
38
+ {% for name in blob_running_exports %}
39
+ <li><code>{{ name }}</code></li>
40
+ {% endfor %}
41
+ </ul>
42
+ </div>
43
+ {% endif %}
44
+ <div class="mt-2">
45
+ Update re-runs the fetch pipeline; if the upstream sha has moved
46
+ (an <code>oras://</code> tag pointing at new bytes) the new
47
+ content will land at a new content-address and the machines /
48
+ exports above will still be pinned to the OLD sha until you
49
+ re-bind them. Point those machines at the new binding first, or
50
+ click "Update anyway" to overwrite.
51
+ </div>
52
+ </div>
53
+ <form method="post" action="/ui/catalog/fetch" class="mt-2 d-inline">
54
+ <input type="hidden" name="name" value="{{ entry.name }}">
55
+ <input type="hidden" name="force" value="1">
56
+ <button type="submit" class="btn btn-sm btn-warning">
57
+ <i class="bi bi-arrow-repeat me-1"></i>Update anyway
58
+ </button>
59
+ </form>
60
+ <a href="/ui/catalog/{{ entry.name }}"
61
+ class="btn btn-sm btn-outline-secondary ms-1">Cancel</a>
62
+ </div>
63
+ </div>
64
+ {% endif %}
65
+ {% if warn_delete_blob %}
66
+ <div class="alert alert-warning d-flex align-items-start" role="alert">
67
+ <i class="bi bi-exclamation-triangle-fill me-2 mt-1"></i>
68
+ <div class="flex-grow-1">
69
+ <strong>Blob is in use.</strong>
70
+ <div class="mt-1 small">
71
+ The bytes for <code>{{ entry.name }}</code>
72
+ (sha <code>{{ entry.content_sha256[:12] }}</code>)
73
+ are referenced by:
74
+ {% if blob_using_machines %}
75
+ <div class="mt-1">
76
+ <span class="text-muted">Machines bound to this image:</span>
77
+ <ul class="mb-0">
78
+ {% for mac in blob_using_machines %}
79
+ <li><a href="/ui/machines/{{ mac }}"><code>{{ mac }}</code></a></li>
80
+ {% endfor %}
81
+ </ul>
82
+ </div>
83
+ {% endif %}
84
+ {% if blob_running_exports %}
85
+ <div class="mt-1">
86
+ <span class="text-muted">Running NBD exports:</span>
87
+ <ul class="mb-0">
88
+ {% for name in blob_running_exports %}
89
+ <li><code>{{ name }}</code></li>
90
+ {% endfor %}
91
+ </ul>
92
+ </div>
93
+ {% endif %}
94
+ <div class="mt-2">
95
+ Change those machines' bindings on the
96
+ <a href="/ui/machines">Machines</a> page (or bind them to a
97
+ different image) before deleting the blob, or click
98
+ "Delete blob anyway" -- pixie will stop the running exports
99
+ and drop the file.
100
+ </div>
101
+ </div>
102
+ <form method="post" action="/ui/catalog/delete-blob" class="mt-2 d-inline">
103
+ <input type="hidden" name="name" value="{{ entry.name }}">
104
+ <input type="hidden" name="force" value="1">
105
+ <button type="submit" class="btn btn-sm btn-warning">
106
+ <i class="bi bi-eraser me-1"></i>Delete blob anyway
107
+ </button>
108
+ </form>
109
+ <a href="/ui/catalog/{{ entry.name }}"
110
+ class="btn btn-sm btn-outline-secondary ms-1">Cancel</a>
111
+ </div>
112
+ </div>
113
+ {% endif %}
114
+ {% if warn_delete %}
115
+ <div class="alert alert-warning d-flex align-items-start" role="alert">
116
+ <i class="bi bi-exclamation-triangle-fill me-2 mt-1"></i>
117
+ <div class="flex-grow-1">
118
+ <strong>Delete would leave a broken chain.</strong>
119
+ {% if orphans_bundle_name %}
120
+ <div class="mt-1 small">
121
+ Deleting <code>{{ entry.name }}</code> orphans the fetched netboot bundle
122
+ <code>{{ orphans_bundle_name }}</code>. The bundle stays on disk but no
123
+ disk-image entry will point at it -- nbdboot for this image will not work
124
+ until you either restore this entry or delete the bundle too.
125
+ </div>
126
+ {% endif %}
127
+ {% if breaks_nbdboot_for %}
128
+ <div class="mt-1 small">
129
+ Deleting <code>{{ entry.name }}</code> breaks nbdboot for
130
+ {{ breaks_nbdboot_for|length }} disk image{{ 's' if breaks_nbdboot_for|length != 1 else '' }}
131
+ that name{{ '' if breaks_nbdboot_for|length != 1 else 's' }} it as
132
+ <code>netboot_src</code>:
133
+ <ul class="mb-0 mt-1">
134
+ {% for name in breaks_nbdboot_for %}
135
+ <li><code>{{ name }}</code></li>
136
+ {% endfor %}
137
+ </ul>
138
+ </div>
139
+ {% endif %}
140
+ <form method="post" action="/ui/catalog/delete" class="mt-2 d-inline">
141
+ <input type="hidden" name="name" value="{{ entry.name }}">
142
+ <input type="hidden" name="force" value="1">
143
+ <button type="submit" class="btn btn-sm btn-danger">
144
+ <i class="bi bi-trash me-1"></i>Delete anyway
145
+ </button>
146
+ </form>
147
+ <a href="/ui/catalog" class="btn btn-sm btn-outline-secondary ms-1">Cancel</a>
148
+ </div>
149
+ </div>
150
+ {% endif %}
151
+ <div class="card shadow-sm mb-4">
152
+ <div class="card-header bg-light d-flex align-items-center">
153
+ <h2 class="h5 mb-0">
154
+ {% if entry.bindable %}
155
+ <i class="bi bi-hdd me-2"></i>
156
+ {% else %}
157
+ <i class="bi bi-box-seam me-2"></i>
158
+ {% endif %}
159
+ <code>{{ entry.name }}</code>
160
+ </h2>
161
+ <span class="ms-auto">
162
+ {% if entry.bindable %}
163
+ <span class="badge text-bg-primary"><i class="bi bi-hdd me-1"></i>Disk image</span>
164
+ {% else %}
165
+ <span class="badge text-bg-info"><i class="bi bi-box-seam me-1"></i>Netboot bundle</span>
166
+ {% endif %}
167
+ </span>
168
+ </div>
169
+ <div class="card-body">
170
+ {% if entry.description %}
171
+ <p class="mb-3">{{ entry.description }}</p>
172
+ {% else %}
173
+ <p class="text-muted small mb-3">
174
+ <i class="bi bi-info-circle me-1"></i>No description recorded.
175
+ {% if not entry.bindable %}
176
+ Netboot bundles are usually the sibling of a disk-image entry; see below for the pairing.
177
+ {% endif %}
178
+ </p>
179
+ {% endif %}
180
+ <dl class="row mb-0">
181
+ <dt class="col-sm-3 text-muted small">Format</dt>
182
+ <dd class="col-sm-9">
183
+ <span class="badge text-bg-secondary">{{ entry.format }}</span>
184
+ {% if entry.arch %}<span class="ms-1 text-muted small">{{ entry.arch }}</span>{% endif %}
185
+ </dd>
186
+ <dt class="col-sm-3 text-muted small">Source</dt>
187
+ <dd class="col-sm-9"><code class="user-select-all">{{ entry.src }}</code></dd>
188
+ {% if entry.netboot_src %}
189
+ <dt class="col-sm-3 text-muted small">Netboot sibling URL</dt>
190
+ <dd class="col-sm-9"><code class="user-select-all">{{ entry.netboot_src }}</code></dd>
191
+ {% endif %}
192
+ <dt class="col-sm-3 text-muted small">Fetch state</dt>
193
+ <dd class="col-sm-9">
194
+ {% if fetch_state.state == 'fetching' %}
195
+ <span class="badge text-bg-primary"><i class="bi bi-arrow-down-circle me-1"></i>fetching</span>
196
+ {% elif fetch_state.state == 'error' %}
197
+ <span class="badge text-bg-danger"><i class="bi bi-exclamation-circle me-1"></i>error</span>
198
+ <div class="text-muted small">{{ fetch_state.error }}</div>
199
+ {% elif entry.content_sha256 %}
200
+ <span class="badge text-bg-success"><i class="bi bi-check-circle me-1"></i>fetched</span>
201
+ {% if entry.fetched_at %}<span class="text-muted small ms-2">at {{ entry.fetched_at | fmt_ts }}</span>{% endif %}
202
+ {% else %}
203
+ <span class="badge text-bg-secondary"><i class="bi bi-dash-circle me-1"></i>not fetched</span>
204
+ {% endif %}
205
+ </dd>
206
+ {% if entry.content_sha256 %}
207
+ <dt class="col-sm-3 text-muted small">Content sha</dt>
208
+ <dd class="col-sm-9"><code class="user-select-all">{{ entry.content_sha256 }}</code></dd>
209
+ {% endif %}
210
+ {% if entry.size_bytes %}
211
+ <dt class="col-sm-3 text-muted small">Size</dt>
212
+ <dd class="col-sm-9 font-monospace">{{ entry.size_bytes }} bytes</dd>
213
+ {% endif %}
214
+ <dt class="col-sm-3 text-muted small">Added at</dt>
215
+ <dd class="col-sm-9 small text-muted">{{ entry.added_at | fmt_ts }}</dd>
216
+ </dl>
217
+ </div>
218
+ <div class="card-footer bg-light py-2">
219
+ {% set fetching = fetch_state.state == 'fetching' %}
220
+ {% set fetch_errored = fetch_state.state == 'error' %}
221
+ {% set already_fetched = entry.content_sha256 and not fetch_errored %}
222
+ <form method="post" action="/ui/catalog/fetch" class="d-inline">
223
+ <input type="hidden" name="name" value="{{ entry.name }}">
224
+ <button type="submit" class="btn btn-sm btn-outline-primary"
225
+ {% if fetching %}disabled{% endif %}
226
+ 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 %}">
227
+ {% if fetching %}
228
+ <i class="bi bi-hourglass-split me-1"></i>Fetching
229
+ {% elif fetch_errored %}
230
+ <i class="bi bi-arrow-clockwise me-1"></i>Retry
231
+ {% elif already_fetched %}
232
+ <i class="bi bi-arrow-repeat me-1"></i>Update
233
+ {% else %}
234
+ <i class="bi bi-arrow-down-circle me-1"></i>Fetch
235
+ {% endif %}
236
+ </button>
237
+ </form>
238
+ <form method="post" action="/ui/catalog/delete-blob" class="d-inline"
239
+ title="{% if already_fetched %}delete the on-disk bytes for this entry; the row stays so a Fetch re-runs the pipeline{% else %}no blob on disk yet{% endif %}"
240
+ onsubmit="return confirm('Delete the on-disk blob for {{ entry.name }}? The catalog row stays; a Fetch re-runs the pipeline.');">
241
+ <input type="hidden" name="name" value="{{ entry.name }}">
242
+ <button type="submit" class="btn btn-sm btn-outline-warning"
243
+ {% if not already_fetched %}disabled{% endif %}>
244
+ <i class="bi bi-eraser me-1"></i>Delete blob
245
+ </button>
246
+ </form>
247
+ <form method="post" action="/ui/catalog/delete" class="d-inline"
248
+ title="delete this catalog entry (the row); on-disk bytes are not touched here -- use Delete blob for that"
249
+ onsubmit="return confirm('Delete catalog entry {{ entry.name }}? The on-disk blob (if any) stays; use Delete blob for the bytes.');">
250
+ <input type="hidden" name="name" value="{{ entry.name }}">
251
+ <button type="submit" class="btn btn-sm btn-outline-danger">
252
+ <i class="bi bi-trash me-1"></i>Delete entry
253
+ </button>
254
+ </form>
255
+ </div>
256
+ </div>
257
+
258
+ {# NBD serving state, disk images only #}
259
+ {% if entry.bindable %}
260
+ <div class="card shadow-sm mb-4">
261
+ <div class="card-header bg-light">
262
+ <h2 class="h5 mb-0"><i class="bi bi-hdd-network me-2"></i>NBD serving</h2>
263
+ </div>
264
+ <div class="card-body">
265
+ {% if export is none %}
266
+ <p class="text-muted mb-0 small">
267
+ <i class="bi bi-dash-circle me-1"></i>
268
+ Not currently exported. An NBD export is spawned automatically when a machine
269
+ is bound to <code>boot_mode=nbdboot</code> pointing at this entry.
270
+ </p>
271
+ {% else %}
272
+ <dl class="row mb-0">
273
+ <dt class="col-sm-3 text-muted small">Status</dt>
274
+ <dd class="col-sm-9">
275
+ {% if export.status == 'running' %}
276
+ <span class="badge text-bg-success"><i class="bi bi-broadcast me-1"></i>running</span>
277
+ {% elif export.status == 'error' %}
278
+ <span class="badge text-bg-danger"><i class="bi bi-exclamation-circle me-1"></i>error</span>
279
+ <div class="text-danger small">{{ export.error }}</div>
280
+ {% else %}
281
+ <span class="badge text-bg-secondary">{{ export.status }}</span>
282
+ {% endif %}
283
+ </dd>
284
+ <dt class="col-sm-3 text-muted small">NBD port</dt>
285
+ <dd class="col-sm-9 font-monospace">{{ export.nbd_port or '-' }}</dd>
286
+ <dt class="col-sm-3 text-muted small">Export name</dt>
287
+ <dd class="col-sm-9"><code class="user-select-all">{{ export.name }}</code></dd>
288
+ </dl>
289
+ {% endif %}
290
+ </div>
291
+ </div>
292
+ {% endif %}
293
+
294
+ {# Relation: this entry -> its netboot sibling (disk images) #}
295
+ {% if entry.bindable and entry.netboot_src %}
296
+ <div class="card shadow-sm mb-4">
297
+ <div class="card-header bg-light">
298
+ <h2 class="h5 mb-0">
299
+ <i class="bi bi-box-seam me-2"></i>Netboot bundle sibling
300
+ </h2>
301
+ </div>
302
+ <div class="card-body">
303
+ {% if netboot_sibling is none %}
304
+ <p class="text-muted mb-0 small">
305
+ <i class="bi bi-question-circle me-1"></i>
306
+ No catalog entry matches <code>{{ entry.netboot_src }}</code>.
307
+ Import the sibling bundle to enable nbdboot for this disk image.
308
+ </p>
309
+ {% else %}
310
+ <p class="mb-2 small">
311
+ Ramboot for <code>{{ entry.name }}</code> chains into this bundle's kernel + initrd:
312
+ </p>
313
+ <ul class="list-group list-group-flush">
314
+ <li class="list-group-item d-flex align-items-center">
315
+ <div class="flex-grow-1">
316
+ <a href="/ui/catalog/{{ netboot_sibling.name }}"
317
+ class="fw-semibold text-decoration-none">
318
+ <code>{{ netboot_sibling.name }}</code>
319
+ </a>
320
+ <div class="text-muted small">
321
+ <span class="badge text-bg-secondary">{{ netboot_sibling.format }}</span>
322
+ {% if netboot_sibling.fetched %}
323
+ <span class="badge text-bg-success ms-1">fetched</span>
324
+ {% else %}
325
+ <span class="badge text-bg-secondary ms-1">not fetched</span>
326
+ {% endif %}
327
+ </div>
328
+ </div>
329
+ <i class="bi bi-arrow-right text-muted"></i>
330
+ </li>
331
+ </ul>
332
+ {% endif %}
333
+ </div>
334
+ </div>
335
+ {% endif %}
336
+
337
+ {# Reverse relation: netboot bundles show every disk image pointing at them #}
338
+ {% if not entry.bindable and disk_image_users %}
339
+ <div class="card shadow-sm mb-4">
340
+ <div class="card-header bg-light">
341
+ <h2 class="h5 mb-0">
342
+ <i class="bi bi-hdd me-2"></i>Disk images using this bundle
343
+ </h2>
344
+ </div>
345
+ <div class="card-body">
346
+ <p class="mb-2 small">
347
+ Every disk image that names <code>{{ entry.src }}</code> as its
348
+ <code>netboot_src</code> boots with this bundle's kernel + initrd.
349
+ </p>
350
+ <ul class="list-group list-group-flush">
351
+ {% for e in disk_image_users %}
352
+ <li class="list-group-item d-flex align-items-center">
353
+ <div class="flex-grow-1">
354
+ <a href="/ui/catalog/{{ e.name }}"
355
+ class="fw-semibold text-decoration-none">
356
+ <code>{{ e.name }}</code>
357
+ </a>
358
+ <div class="text-muted small">
359
+ <span class="badge text-bg-secondary">{{ e.format }}</span>
360
+ {% if e.arch %}<span class="ms-1">{{ e.arch }}</span>{% endif %}
361
+ {% if e.fetched %}
362
+ <span class="badge text-bg-success ms-1">fetched</span>
363
+ {% else %}
364
+ <span class="badge text-bg-secondary ms-1">not fetched</span>
365
+ {% endif %}
366
+ </div>
367
+ </div>
368
+ <i class="bi bi-arrow-right text-muted"></i>
369
+ </li>
370
+ {% endfor %}
371
+ </ul>
372
+ </div>
373
+ </div>
374
+ {% endif %}
375
+
376
+ {% if not entry.bindable and not disk_image_users %}
377
+ <div class="card shadow-sm">
378
+ <div class="card-body">
379
+ <p class="text-muted small mb-0">
380
+ <i class="bi bi-info-circle me-1"></i>
381
+ No disk-image entry names this bundle as its <code>netboot_src</code> yet.
382
+ </p>
383
+ </div>
384
+ </div>
385
+ {% endif %}
386
+ {% endblock %}
@@ -0,0 +1,256 @@
1
+ {% extends "layout.html" %}
2
+ {% from "_partials/page_description.html" import page_description %}
3
+ {% block title %}Dashboard - pixie{% endblock %}
4
+ {% block subnav %}
5
+ <div class="subnav-strip">
6
+ <div class="container">
7
+ <span class="text-white-50">Overview</span>
8
+ </div>
9
+ </div>
10
+ {% endblock %}
11
+ {% block content %}
12
+ {{ page_description('speedometer2', 'Live counts across pixie: machines the operator manages, catalog images pixie can serve, NBD exports pixie is running right now. All tiles refresh every 5 s from the same endpoints the pages below use.') }}
13
+ <div class="row g-3 mb-4">
14
+ <div class="col-md-4">
15
+ <div class="card shadow-sm h-100">
16
+ <div class="card-body">
17
+ <div class="d-flex align-items-center mb-2">
18
+ <i class="bi bi-pc-display fs-4 text-primary me-2"></i>
19
+ <h2 class="h6 mb-0">Machines</h2>
20
+ </div>
21
+ <div class="display-6 fw-semibold" data-stat="machines_total">{{ stats.machines_total }}</div>
22
+ <dl class="row small text-muted mb-0 mt-2">
23
+ <dt class="col-6">Bound</dt>
24
+ <dd class="col-6 text-end" data-stat="machines_bound">{{ stats.machines_bound }}</dd>
25
+ <dt class="col-6">Inventoried</dt>
26
+ <dd class="col-6 text-end" data-stat="machines_with_inventory">{{ stats.machines_with_inventory }}</dd>
27
+ </dl>
28
+ </div>
29
+ <div class="card-footer bg-light border-top-0 py-2">
30
+ <a href="/ui/machines" class="text-decoration-none small">
31
+ Open <i class="bi bi-arrow-right"></i>
32
+ </a>
33
+ </div>
34
+ </div>
35
+ </div>
36
+ <div class="col-md-4">
37
+ <div class="card shadow-sm h-100">
38
+ <div class="card-body">
39
+ <div class="d-flex align-items-center mb-2">
40
+ <i class="bi bi-collection fs-4 text-primary me-2"></i>
41
+ <h2 class="h6 mb-0">Catalog</h2>
42
+ </div>
43
+ <div class="display-6 fw-semibold" data-stat="catalog_total">{{ stats.catalog_total }}</div>
44
+ <dl class="row small text-muted mb-0 mt-2">
45
+ <dt class="col-6">Disk images</dt>
46
+ <dd class="col-6 text-end font-monospace" data-stat="catalog_images">
47
+ {{ stats.catalog_images_fetched }} / {{ stats.catalog_images_total }}
48
+ </dd>
49
+ <dt class="col-6">Netboot bundles</dt>
50
+ <dd class="col-6 text-end font-monospace" data-stat="catalog_bundles">
51
+ {{ stats.catalog_bundles_fetched }} / {{ stats.catalog_bundles_total }}
52
+ </dd>
53
+ </dl>
54
+ </div>
55
+ <div class="card-footer bg-light border-top-0 py-2">
56
+ <a href="/ui/catalog" class="text-decoration-none small">
57
+ Open <i class="bi bi-arrow-right"></i>
58
+ </a>
59
+ </div>
60
+ </div>
61
+ </div>
62
+ <div class="col-md-4">
63
+ <div class="card shadow-sm h-100">
64
+ <div class="card-body">
65
+ <div class="d-flex align-items-center mb-2">
66
+ <i class="bi bi-hdd-network fs-4 text-primary me-2"></i>
67
+ <h2 class="h6 mb-0">NBD serving</h2>
68
+ </div>
69
+ <div class="display-6 fw-semibold" data-stat="exports_total">{{ stats.exports_total }}</div>
70
+ <dl class="row small text-muted mb-0 mt-2">
71
+ <dt class="col-6"><span class="text-success">Running</span></dt>
72
+ <dd class="col-6 text-end" data-stat="exports_running">{{ stats.exports_running }}</dd>
73
+ <dt class="col-6"><span class="text-danger">Error</span></dt>
74
+ <dd class="col-6 text-end" data-stat="exports_error">{{ stats.exports_error }}</dd>
75
+ </dl>
76
+ </div>
77
+ <div class="card-footer bg-light border-top-0 py-2">
78
+ <a href="/ui/catalog" class="text-decoration-none small">
79
+ Open <i class="bi bi-arrow-right"></i>
80
+ </a>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ </div>
85
+
86
+ {# Live-env media state. The pixie live env (netboot-pc bake) has to
87
+ be staged under PIXIE_LIVE_ENV_DIR before any pixie-* boot mode
88
+ works; without it the plan renderer emits an "unavailable" iPXE
89
+ chain and a bound target sits at a legible "media not staged"
90
+ screen. Surface the readiness signal + per-file bytes so an
91
+ operator can tell at a glance whether the boot modes are live. #}
92
+ <div class="card shadow-sm mb-4">
93
+ <div class="card-header bg-light d-flex align-items-center">
94
+ <h2 class="h5 mb-0"><i class="bi bi-boombox me-2"></i>Live-env media</h2>
95
+ {% if stats.live_env_ready %}
96
+ <span class="badge text-bg-success ms-auto">
97
+ <i class="bi bi-check-circle me-1"></i>ready
98
+ </span>
99
+ {% else %}
100
+ <span class="badge text-bg-warning ms-auto">
101
+ <i class="bi bi-exclamation-triangle me-1"></i>not staged
102
+ </span>
103
+ {% endif %}
104
+ </div>
105
+ <div class="card-body">
106
+ {% if stats.live_env_ready %}
107
+ <p class="small mb-2">
108
+ The three files below are staged at
109
+ <code>{{ stats.live_env_dir }}</code> and
110
+ <code>pixie-tui</code>, <code>pixie-inventory</code>, and
111
+ <code>pixie-flash-*</code> can serve the live env from
112
+ <code>/boot/pixie-live-env/</code>.
113
+ </p>
114
+ {% else %}
115
+ <p class="small text-warning mb-2">
116
+ Live-env media is missing under
117
+ <code>{{ stats.live_env_dir or '(unset)' }}</code>.
118
+ Every <code>pixie-*</code> boot_mode falls back to the
119
+ "unavailable" iPXE plan until the three files below are
120
+ present. Copy vmlinuz + initrd + live.squashfs from the
121
+ latest <code>pixie-netboot-pc-x86_64</code> bake into
122
+ that directory. Restart the container is NOT required
123
+ (the renderer checks the files each render).
124
+ </p>
125
+ {% endif %}
126
+ <dl class="row small mb-0">
127
+ {% for name, size in stats.live_env_files.items() %}
128
+ <dt class="col-sm-3 font-monospace">{{ name }}</dt>
129
+ <dd class="col-sm-9">
130
+ {% if size is not none %}
131
+ <span class="text-success"><i class="bi bi-check-circle me-1"></i>{{ (size / 1024 / 1024) | round(1) }} MiB</span>
132
+ {% else %}
133
+ <span class="text-warning"><i class="bi bi-x-circle me-1"></i>missing</span>
134
+ {% endif %}
135
+ </dd>
136
+ {% endfor %}
137
+ </dl>
138
+ </div>
139
+ </div>
140
+
141
+ <div class="card shadow-sm">
142
+ <div class="card-header bg-light d-flex align-items-center">
143
+ <h2 class="h5 mb-0"><i class="bi bi-clock-history me-2"></i>Recent events</h2>
144
+ <a href="/ui/events" class="ms-auto small">All events</a>
145
+ </div>
146
+ {% if events %}
147
+ <div class="table-responsive">
148
+ <table class="table table-hover align-middle mb-0">
149
+ <thead>
150
+ <tr>
151
+ <th scope="col">When</th>
152
+ <th scope="col">Kind</th>
153
+ <th scope="col">Related to</th>
154
+ <th scope="col">Summary</th>
155
+ </tr>
156
+ </thead>
157
+ <tbody data-events-feed>
158
+ {# ``Related to`` reads as ``<what>: <which>``: the
159
+ subject_kind badge is the category, subject_id the
160
+ specific name / MAC / export name. #}
161
+ {% for e in events %}
162
+ <tr>
163
+ <td class="small text-muted text-nowrap">{{ e.ts | fmt_ts }}</td>
164
+ <td><code>{{ e.kind }}</code></td>
165
+ <td>
166
+ {% if e.subject_kind %}
167
+ <span class="badge text-bg-light border me-1">{{ e.subject_kind }}</span>
168
+ <code class="small">{{ e.subject_id or '' }}</code>
169
+ {% else %}
170
+ <span class="text-muted small">-</span>
171
+ {% endif %}
172
+ </td>
173
+ <td>{{ e.summary or "" }}</td>
174
+ </tr>
175
+ {% endfor %}
176
+ </tbody>
177
+ </table>
178
+ </div>
179
+ {% else %}
180
+ <div class="card-body">
181
+ <p class="text-muted small mb-0">
182
+ <i class="bi bi-inbox me-1"></i>No events yet.
183
+ </p>
184
+ </div>
185
+ {% endif %}
186
+ </div>
187
+
188
+ <script>
189
+ // Dashboard cards + recent-events feed poll every 5 s so the
190
+ // operator sees counts + fresh events tick without a page reload.
191
+ // Same shape as the machines / fetch-states pollers: request the
192
+ // JSON, walk it, update matching [data-stat] / [data-event-row]
193
+ // cells in place. Silently skips a lost or 500'd poll (retries on
194
+ // the next tick) so a suspended laptop resumes cleanly on wake.
195
+ (function () {
196
+ const POLL_MS = 5000;
197
+
198
+ async function pollStats() {
199
+ try {
200
+ const r = await fetch("/ui/dashboard-live.json", {credentials: "same-origin"});
201
+ if (!r.ok) return;
202
+ const s = await r.json();
203
+ const setN = (key, n) => {
204
+ document.querySelectorAll(`[data-stat="${key}"]`).forEach((el) => {
205
+ if (el.textContent.trim() !== String(n)) el.textContent = n;
206
+ });
207
+ };
208
+ setN("machines_total", s.machines_total);
209
+ setN("machines_bound", s.machines_bound);
210
+ setN("machines_with_inventory", s.machines_with_inventory);
211
+ setN("catalog_total", s.catalog_total);
212
+ setN("exports_total", s.exports_total);
213
+ setN("exports_running", s.exports_running);
214
+ setN("exports_error", s.exports_error);
215
+ document.querySelectorAll('[data-stat="catalog_images"]').forEach((el) => {
216
+ const t = `${s.catalog_images_fetched} / ${s.catalog_images_total}`;
217
+ if (el.textContent.trim() !== t) el.textContent = t;
218
+ });
219
+ document.querySelectorAll('[data-stat="catalog_bundles"]').forEach((el) => {
220
+ const t = `${s.catalog_bundles_fetched} / ${s.catalog_bundles_total}`;
221
+ if (el.textContent.trim() !== t) el.textContent = t;
222
+ });
223
+ } catch (e) { /* swallow */ }
224
+ }
225
+
226
+ async function pollEvents() {
227
+ try {
228
+ const r = await fetch("/ui/events-live.json?limit=10", {credentials: "same-origin"});
229
+ if (!r.ok) return;
230
+ const body = await r.json();
231
+ const feed = document.querySelector("[data-events-feed]");
232
+ if (!feed || !body.events) return;
233
+ const rows = body.events.map((e) => {
234
+ // ``Related to`` cell -- see dashboard.html thead
235
+ // note: badge (category) + code (specific id).
236
+ const subject = e.subject_kind
237
+ ? `<span class="badge text-bg-light border me-1">${e.subject_kind}</span><code class="small">${e.subject_id || ""}</code>`
238
+ : `<span class="text-muted small">-</span>`;
239
+ return `<tr>
240
+ <td class="small text-muted text-nowrap">${e.ts_display || e.ts}</td>
241
+ <td><code>${e.kind}</code></td>
242
+ <td>${subject}</td>
243
+ <td>${e.summary || ""}</td>
244
+ </tr>`;
245
+ }).join("");
246
+ feed.innerHTML = rows;
247
+ } catch (e) { /* swallow */ }
248
+ }
249
+
250
+ pollStats();
251
+ pollEvents();
252
+ setInterval(pollStats, POLL_MS);
253
+ setInterval(pollEvents, POLL_MS);
254
+ })();
255
+ </script>
256
+ {% endblock %}