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,786 @@
1
+ {% extends "layout.html" %}
2
+ {% from "_partials/page_description.html" import page_description %}
3
+ {% block title %}{{ machine.mac }} - pixie{% endblock %}
4
+ {% block subnav %}
5
+ <div class="subnav-strip">
6
+ <div class="container">
7
+ <a href="/ui/machines" class="text-decoration-none">
8
+ <i class="bi bi-arrow-left me-1"></i>Machines
9
+ </a>
10
+ <span class="text-muted">/</span>
11
+ <code class="text-white-50">{{ machine.mac }}</code>
12
+ <ul class="nav nav-pills ms-auto">
13
+ <li class="nav-item"><a class="nav-link" href="#binding">Binding</a></li>
14
+ <li class="nav-item"><a class="nav-link" href="#inventory">Inventory</a></li>
15
+ <li class="nav-item"><a class="nav-link" href="#events">Events</a></li>
16
+ </ul>
17
+ </div>
18
+ </div>
19
+ {% endblock %}
20
+ {% block content %}
21
+ {{ page_description('pc-display-horizontal', 'Everything pixie knows about one target: telemetry, current binding, inventory the live env posts back, and events scoped to this MAC. Editing the binding changes what pixie serves on this target\'s next PXE; labels and inventory are stored independently of the binding.') }}
22
+ <div class="card shadow-sm mb-4">
23
+ <div class="card-header bg-light d-flex align-items-center">
24
+ <h2 class="h5 mb-0"><i class="bi bi-pc-display me-2"></i><code>{{ machine.mac }}</code></h2>
25
+ <span class="ms-auto">
26
+ {% if machine.boot_mode == 'nbdboot' %}
27
+ <span class="badge text-bg-primary">
28
+ <i class="bi bi-hdd-network me-1"></i>nbdboot
29
+ </span>
30
+ {% else %}
31
+ <span class="badge text-bg-secondary">
32
+ <i class="bi bi-box-arrow-right me-1"></i>{{ machine.boot_mode }}
33
+ </span>
34
+ {% endif %}
35
+ </span>
36
+ </div>
37
+ <div class="card-body">
38
+ <dl class="row mb-0">
39
+ <dt class="col-sm-3 text-muted small">Image content sha</dt>
40
+ <dd class="col-sm-9">
41
+ {% if machine.image_content_sha256 %}
42
+ <code>{{ machine.image_content_sha256 }}</code>
43
+ {% else %}
44
+ <span class="text-muted">(unbound)</span>
45
+ {% endif %}
46
+ </dd>
47
+ <dt class="col-sm-3 text-muted small">Labels</dt>
48
+ <dd class="col-sm-9">
49
+ {# Labels are metadata (search / filter / grouping) and
50
+ have no effect on the plan render. Edit them here on
51
+ their own form; the bind form below does NOT touch
52
+ labels, so a boot-mode flip cannot clobber tags an
53
+ operator set up ahead of time. #}
54
+ <form method="post"
55
+ action="/ui/machines/{{ machine.mac }}/labels/edit"
56
+ class="d-flex flex-wrap gap-2 align-items-start">
57
+ <input type="text" name="labels"
58
+ class="form-control form-control-sm font-monospace flex-grow-1"
59
+ style="max-width: 32rem;"
60
+ value="{{ machine.labels | join(', ') }}"
61
+ placeholder="rack-3, noisy, gmktec-g5"
62
+ aria-label="Comma-separated labels">
63
+ <button type="submit" class="btn btn-sm btn-outline-primary">
64
+ <i class="bi bi-check2 me-1"></i>Save labels
65
+ </button>
66
+ </form>
67
+ <div class="form-text small text-muted mt-1">
68
+ Comma-separated. Alnum-leading; a-z / 0-9 / space /
69
+ <code>.</code> <code>_</code> <code>-</code>; max 64
70
+ chars per tag, 16 tags per machine. Blank clears.
71
+ </div>
72
+ </dd>
73
+ <dt class="col-sm-3 text-muted small">Discovered at</dt>
74
+ <dd class="col-sm-9 small text-muted">{{ machine.discovered_at | fmt_ts }}</dd>
75
+ <dt class="col-sm-3 text-muted small">Last seen</dt>
76
+ <dd class="col-sm-9 small text-muted" data-live="last-seen">{{ machine.last_seen_at | fmt_ts }}</dd>
77
+ <dt class="col-sm-3 text-muted small">Last seen IP</dt>
78
+ <dd class="col-sm-9 small" data-live="last-seen-ip">
79
+ {% if machine.last_seen_ip %}<code>{{ machine.last_seen_ip }}</code>
80
+ {% else %}<span class="text-muted">-</span>{% endif %}
81
+ </dd>
82
+ </dl>
83
+ </div>
84
+ </div>
85
+
86
+ <div class="card shadow-sm mb-4" id="binding">
87
+ <div class="card-header bg-light d-flex align-items-center">
88
+ <h2 class="h5 mb-0"><i class="bi bi-pencil me-2"></i>Edit binding</h2>
89
+ <span class="ms-auto small text-primary d-none" id="bind-dirty-hint">
90
+ <i class="bi bi-dot"></i>Unsaved changes
91
+ </span>
92
+ </div>
93
+ <div class="card-body">
94
+ <form method="post" action="/ui/machines/bind" id="bind-form" class="d-flex flex-column gap-3">
95
+ <input type="hidden" name="mac" value="{{ machine.mac }}">
96
+
97
+ {# ---- Boot-mode radio-card picker ------------------------
98
+
99
+ Six cards in a 3-across grid. The underlying
100
+ ``<input type="radio">`` is visually hidden but keeps
101
+ the form serialisation + keyboard accessibility that a
102
+ plain click-a-card widget would drop. The label wraps
103
+ both the radio and the visual card so anywhere on the
104
+ card selects the mode. #}
105
+ <div>
106
+ <label class="form-label fw-semibold">Boot mode</label>
107
+ <div class="row g-2 mode-picker" role="radiogroup" aria-label="Boot mode">
108
+ {% for mode_value, meta in boot_mode_meta %}
109
+ <div class="col-lg-4 col-sm-6">
110
+ <label class="mode-card d-block h-100 mb-0" data-mode="{{ mode_value }}">
111
+ <input type="radio" name="boot_mode" value="{{ mode_value }}"
112
+ class="visually-hidden mode-radio"
113
+ {% if machine.boot_mode == mode_value %}checked{% endif %}>
114
+ <div class="mode-card-body card h-100 border">
115
+ <div class="card-body p-3 d-flex flex-column">
116
+ <div class="d-flex align-items-center mb-1">
117
+ <i class="bi bi-{{ meta.icon }} me-2 text-primary" style="font-size: 1.15rem;"></i>
118
+ <span class="fw-semibold">{{ meta.short }}</span>
119
+ <span class="ms-auto mode-check text-primary" aria-hidden="true">
120
+ <i class="bi bi-check-circle-fill"></i>
121
+ </span>
122
+ </div>
123
+ <div class="small text-muted flex-grow-1">{{ meta.desc }}</div>
124
+ <div class="small text-body-tertiary mt-2 font-monospace">
125
+ {{ mode_value }}
126
+ </div>
127
+ </div>
128
+ </div>
129
+ </label>
130
+ </div>
131
+ {% endfor %}
132
+ </div>
133
+ </div>
134
+
135
+ <div class="row g-3">
136
+ {# ---- Image picker --------------------------------- #}
137
+ <div class="col-md-6"
138
+ data-policy-relevant="pixie-flash-once pixie-flash-always nbdboot">
139
+ <label for="image_content_sha256" class="form-label fw-semibold">
140
+ Image
141
+ <span class="policy-usage small text-muted fw-normal ms-1"></span>
142
+ </label>
143
+ {% set entry_shas = bindable_entries | map(attribute='content_sha256') | list %}
144
+ <select id="image_content_sha256" name="image_content_sha256"
145
+ class="form-select" data-nbdboot-gate="1">
146
+ <option value="" {% if not machine.image_content_sha256 %}selected{% endif %}>
147
+ (unbound)
148
+ </option>
149
+ {% for e in bindable_entries %}
150
+ {# ``data-nbdboot-ready`` mirrors ``e.fetched``: bytes
151
+ on disk means pixie can serve the image over NBD
152
+ for nbdboot. The inline script disables options
153
+ whose value is "false" when ``boot_mode=nbdboot``
154
+ so an operator can't accidentally bind an
155
+ unfetched image for a nbdboot target. Flash modes
156
+ leave every option enabled (the flash pipeline
157
+ will refuse a missing blob at plan time). #}
158
+ <option value="{{ e.content_sha256 }}"
159
+ data-nbdboot-ready="{{ 'true' if e.fetched else 'false' }}"
160
+ data-image-name="{{ e.name }}"
161
+ {% if machine.image_content_sha256 == e.content_sha256 %}selected{% endif %}>
162
+ {{ e.name }}{% if e.arch %} ({{ e.arch }}){% endif %}{% if not e.fetched %} -- not fetched{% endif %}
163
+ </option>
164
+ {% endfor %}
165
+ {# Fallback: keep a bound sha visible even if its
166
+ catalog entry was deleted, so an operator can see
167
+ and clear the stale binding rather than losing it
168
+ silently. #}
169
+ {% if machine.image_content_sha256 and machine.image_content_sha256 not in entry_shas %}
170
+ <option value="{{ machine.image_content_sha256 }}" selected>
171
+ {{ machine.image_content_sha256 }} (no matching catalog entry)
172
+ </option>
173
+ {% endif %}
174
+ </select>
175
+ {% if not bindable_entries %}
176
+ <div class="form-text text-muted">
177
+ No fetched images yet. <a href="/ui/catalog">Add and fetch one on the Catalog page.</a>
178
+ </div>
179
+ {% endif %}
180
+ </div>
181
+
182
+ {# ---- Target disk picker --------------------------- #}
183
+ <div class="col-md-6" data-policy-relevant="pixie-flash-once pixie-flash-always">
184
+ <label for="target_disk_serial" class="form-label fw-semibold">
185
+ Target disk
186
+ <span class="policy-usage small text-muted fw-normal ms-1"></span>
187
+ </label>
188
+ {% set disks = (machine.inventory or {}).get("disks") or [] %}
189
+ {% if disks %}
190
+ <select id="target_disk_serial" name="target_disk_serial" class="form-select">
191
+ <option value="">- none picked (flash will refuse) -</option>
192
+ {% for d in disks %}
193
+ {% set serial = d.get("serial") or "" %}
194
+ {% if serial %}
195
+ <option value="{{ serial }}"
196
+ {% if serial == machine.target_disk_serial %}selected{% endif %}>
197
+ {{ d.get("path") or "?" }} | {{ d.get("size") or "?" }}{% if d.get("model") %} | {{ d.get("model") }}{% endif %} | serial: {{ serial }}
198
+ </option>
199
+ {% else %}
200
+ <option disabled>
201
+ {{ d.get("path") or "?" }} | (no serial; cannot flash-target)
202
+ </option>
203
+ {% endif %}
204
+ {% endfor %}
205
+ </select>
206
+ <div class="form-text">
207
+ The live env matches this SERIAL against the current disks
208
+ at flash time; a swapped or renumbered drive refuses the
209
+ flash rather than risking the wrong disk.
210
+ </div>
211
+ {% else %}
212
+ <input type="hidden" name="target_disk_serial"
213
+ value="{{ machine.target_disk_serial or '' }}">
214
+ <div class="alert alert-warning small mb-0 py-2">
215
+ <i class="bi bi-info-circle me-1"></i>
216
+ No inventory on file yet. Pick
217
+ <span class="fw-semibold">Discover inventory</span> above,
218
+ power-cycle this target, and its disks will populate here.
219
+ </div>
220
+ {% endif %}
221
+ </div>
222
+ </div>
223
+
224
+ {# ---- Per-machine extra kernel cmdline -------------------
225
+
226
+ Overrides the deploy-wide PIXIE_LIVE_ENV_EXTRA_CMDLINE
227
+ for THIS machine. Blank falls back to the global; a
228
+ non-blank value fully replaces (does not concatenate)
229
+ so an operator can pin a hardware quirk to one target
230
+ without every other machine seeing the same tokens.
231
+ Consumed by the nbdboot + pixie-live-env templates. #}
232
+ <div data-policy-relevant="nbdboot pixie-flash-once pixie-flash-always pixie-inventory pixie-tui">
233
+ <label for="extra_cmdline" class="form-label fw-semibold">
234
+ Extra kernel cmdline
235
+ <span class="policy-usage small text-muted fw-normal ms-1"></span>
236
+ </label>
237
+ <input type="text" id="extra_cmdline" name="extra_cmdline"
238
+ class="form-control font-monospace"
239
+ value="{{ machine.extra_cmdline or '' }}"
240
+ placeholder="fall back to global default"
241
+ list="extra-cmdline-suggestions"
242
+ maxlength="256">
243
+ <datalist id="extra-cmdline-suggestions">
244
+ <option value="pci=realloc=on,nocrs">
245
+ <option value="intel_iommu=off">
246
+ <option value="pci=nommconf">
247
+ </datalist>
248
+ <div class="form-text">
249
+ Tokens appended verbatim to the kernel cmdline for
250
+ <em>this machine only</em>. A non-blank value fully
251
+ overrides the deploy-wide
252
+ <code>PIXIE_LIVE_ENV_EXTRA_CMDLINE</code>. Blank
253
+ falls back to the global (currently effective:
254
+ {% if global_live_env_extra_cmdline %}
255
+ <code>{{ global_live_env_extra_cmdline }}</code>
256
+ {% else %}<em>none</em>{% endif %}).
257
+ Whitespace-separated; single line. Known-good values
258
+ per hardware in <a href="https://github.com/safl/pixie/blob/main/docs/src/hardware-quirks.md" target="_blank" rel="noreferrer">docs/hardware-quirks.md</a>.
259
+ </div>
260
+ </div>
261
+
262
+ {# ---- Preview panel --------------------------------------
263
+
264
+ Plain-English description of what will happen on the
265
+ next PXE from this target. Updated by JS on any input
266
+ change. Sits ABOVE the flash-guard hint so an operator
267
+ scanning the form always knows what "Save" will do
268
+ before they're asked whether they can Save. #}
269
+ <div class="border rounded p-3 bg-body-tertiary" id="bind-preview">
270
+ <div class="small text-muted mb-1">
271
+ <i class="bi bi-eye me-1"></i>On the next PXE from this target
272
+ </div>
273
+ <div class="preview-body">-</div>
274
+ </div>
275
+
276
+ {# ---- Flash-mode guard hint ------------------------------
277
+
278
+ Client-side echo of the server-side 422 guard: the JS
279
+ disables Save + surfaces this hint when the picked
280
+ boot_mode is pixie-flash-{once,always} AND either no
281
+ inventory is on file OR no target_disk_serial is
282
+ picked. The server rejects the same combination
283
+ regardless. #}
284
+ <div id="flash-guard-hint"
285
+ class="alert alert-warning small py-2 mb-0 d-none"
286
+ role="alert">
287
+ <i class="bi bi-exclamation-triangle me-1"></i>
288
+ <span class="hint-body"></span>
289
+ </div>
290
+
291
+ <div class="d-flex align-items-center gap-3">
292
+ <button type="submit" id="save-binding" class="btn btn-primary" disabled>
293
+ <i class="bi bi-check2 me-1"></i>Save changes
294
+ </button>
295
+ <span class="small text-muted" id="bind-clean-hint">
296
+ No changes to save.
297
+ </span>
298
+ </div>
299
+ </form>
300
+ </div>
301
+ </div>
302
+
303
+ <style>
304
+ /* ---- Bind form: radio-card boot-mode picker ------------------
305
+ *
306
+ * A card grid backed by a hidden ``<input type=radio>`` per card:
307
+ * click anywhere selects, keyboard nav works via the underlying
308
+ * radio group, and the form serialises the pick the same way a
309
+ * plain <select> would.
310
+ *
311
+ * :has() drives the checked-state styling on browsers that support
312
+ * it (Chrome 105+, Safari 15.4+, Firefox 121+). Older browsers
313
+ * fall through to the .mode-card-selected class the JS below
314
+ * toggles. Both selectors are OR'd in every rule below. */
315
+ .mode-card { cursor: pointer; }
316
+ .mode-card .mode-card-body {
317
+ transition: border-color 120ms, box-shadow 120ms, background-color 120ms;
318
+ }
319
+ .mode-card:hover .mode-card-body {
320
+ border-color: var(--bs-primary);
321
+ box-shadow: 0 0.125rem 0.35rem rgba(13, 110, 253, 0.10);
322
+ }
323
+ .mode-card .mode-check { opacity: 0; transition: opacity 120ms; }
324
+ .mode-card:has(.mode-radio:checked) .mode-card-body,
325
+ .mode-card.mode-card-selected .mode-card-body {
326
+ border-color: var(--bs-primary);
327
+ border-width: 2px;
328
+ background-color: rgba(13, 110, 253, 0.04);
329
+ }
330
+ .mode-card:has(.mode-radio:checked) .mode-check,
331
+ .mode-card.mode-card-selected .mode-check { opacity: 1; }
332
+ /* The underlying radio is visually-hidden but still receives keyboard
333
+ * focus; ring the visible card body when :focus-visible fires. */
334
+ .mode-card .mode-radio:focus-visible + .mode-card-body {
335
+ outline: 2px solid var(--bs-primary);
336
+ outline-offset: 2px;
337
+ }
338
+ </style>
339
+
340
+ <div class="card shadow-sm mb-4" id="inventory">
341
+ <div class="card-header bg-light d-flex align-items-center">
342
+ <h2 class="h5 mb-0"><i class="bi bi-hdd-stack me-2"></i>Inventory</h2>
343
+ {% if machine.inventory %}
344
+ <span class="text-muted small ms-auto">Recorded at {{ machine.inventory_at | fmt_ts }}</span>
345
+ {% endif %}
346
+ </div>
347
+ <div class="card-body">
348
+ <p class="text-muted small">
349
+ Posted by the live env's <code>pixie</code> CLI on boot via
350
+ <code>POST /pxe/{{ machine.mac }}/inventory</code>. The blob is stored verbatim;
351
+ fields below are a best-effort render of the shape emitted by
352
+ <code>lsblk</code> + <code>lshw</code>.
353
+ </p>
354
+ {% if machine.inventory %}
355
+ {# Best-effort render of the machine hardware summary. The
356
+ live env's ``pixie`` CLI writes whichever sections it can
357
+ enumerate; each block below is guarded so a partial payload
358
+ still renders the sections that DID come through. Raw lshw
359
+ JSON sits collapsed at the bottom as the always-available
360
+ fallback for a section the summary render doesn't cover. #}
361
+ {% set system = machine.inventory.get("system") or {} %}
362
+ {% if system %}
363
+ <h3 class="h6 mt-3">System</h3>
364
+ <dl class="row small mb-3">
365
+ {% if system.get("vendor") %}
366
+ <dt class="col-sm-3 text-muted">Vendor</dt>
367
+ <dd class="col-sm-9">{{ system.get("vendor") }}</dd>
368
+ {% endif %}
369
+ {% if system.get("model") %}
370
+ <dt class="col-sm-3 text-muted">Model</dt>
371
+ <dd class="col-sm-9">{{ system.get("model") }}</dd>
372
+ {% endif %}
373
+ {% if system.get("serial") %}
374
+ <dt class="col-sm-3 text-muted">Serial</dt>
375
+ <dd class="col-sm-9 font-monospace">{{ system.get("serial") }}</dd>
376
+ {% endif %}
377
+ {% if system.get("firmware") %}
378
+ <dt class="col-sm-3 text-muted">Firmware</dt>
379
+ <dd class="col-sm-9 small">{{ system.get("firmware") }}</dd>
380
+ {% endif %}
381
+ </dl>
382
+ {% endif %}
383
+
384
+ {% set cpu = machine.inventory.get("cpu") or {} %}
385
+ {% if cpu %}
386
+ <h3 class="h6 mt-3">CPU</h3>
387
+ <dl class="row small mb-3">
388
+ {% if cpu.get("model") %}
389
+ <dt class="col-sm-3 text-muted">Model</dt>
390
+ <dd class="col-sm-9">{{ cpu.get("model") }}</dd>
391
+ {% endif %}
392
+ {% if cpu.get("cores") %}
393
+ <dt class="col-sm-3 text-muted">Cores</dt>
394
+ <dd class="col-sm-9">{{ cpu.get("cores") }}{% if cpu.get("threads") %} ({{ cpu.get("threads") }} threads){% endif %}</dd>
395
+ {% endif %}
396
+ {% if cpu.get("arch") %}
397
+ <dt class="col-sm-3 text-muted">Arch</dt>
398
+ <dd class="col-sm-9"><code>{{ cpu.get("arch") }}</code></dd>
399
+ {% endif %}
400
+ </dl>
401
+ {% endif %}
402
+
403
+ {% set memory = machine.inventory.get("memory") or {} %}
404
+ {% if memory %}
405
+ <h3 class="h6 mt-3">Memory</h3>
406
+ <dl class="row small mb-3">
407
+ {% if memory.get("total") %}
408
+ <dt class="col-sm-3 text-muted">Total</dt>
409
+ <dd class="col-sm-9 font-monospace">{{ memory.get("total") }}</dd>
410
+ {% endif %}
411
+ {% if memory.get("modules") %}
412
+ <dt class="col-sm-3 text-muted">Modules</dt>
413
+ <dd class="col-sm-9">{{ memory.get("modules") }}</dd>
414
+ {% endif %}
415
+ </dl>
416
+ {% endif %}
417
+
418
+ {% set nics = machine.inventory.get("nics") or [] %}
419
+ {% if nics %}
420
+ <h3 class="h6 mt-3">Network ({{ nics|length }})</h3>
421
+ <div class="table-responsive mb-3">
422
+ <table class="table table-sm table-hover align-middle mb-0">
423
+ <thead>
424
+ <tr>
425
+ <th scope="col">Interface</th>
426
+ <th scope="col">MAC</th>
427
+ <th scope="col">Vendor / Driver</th>
428
+ <th scope="col">Link</th>
429
+ </tr>
430
+ </thead>
431
+ <tbody>
432
+ {% for n in nics %}
433
+ <tr>
434
+ <td class="font-monospace small">{{ n.get("name") or "-" }}</td>
435
+ <td><code>{{ n.get("mac") or "-" }}</code></td>
436
+ <td class="small">
437
+ {{ n.get("vendor") or "-" }}
438
+ {% if n.get("driver") %}<br><span class="text-muted">{{ n.get("driver") }}</span>{% endif %}
439
+ </td>
440
+ <td class="small">
441
+ {% if n.get("speed") %}<code>{{ n.get("speed") }}</code>{% else %}-{% endif %}
442
+ </td>
443
+ </tr>
444
+ {% endfor %}
445
+ </tbody>
446
+ </table>
447
+ </div>
448
+ {% endif %}
449
+
450
+ {% set disks = machine.inventory.get("disks") or [] %}
451
+ {% if disks %}
452
+ <h3 class="h6 mt-3">Disks ({{ disks|length }})</h3>
453
+ <div class="table-responsive">
454
+ <table class="table table-sm table-hover align-middle">
455
+ <thead>
456
+ <tr>
457
+ <th scope="col">Path</th>
458
+ <th scope="col">Size</th>
459
+ <th scope="col">Vendor / Model</th>
460
+ <th scope="col">Serial</th>
461
+ <th scope="col">Transport</th>
462
+ </tr>
463
+ </thead>
464
+ <tbody>
465
+ {% for d in disks %}
466
+ <tr>
467
+ <td><code>{{ d.get("path") or "-" }}</code></td>
468
+ <td class="font-monospace small">{{ d.get("size") or "-" }}</td>
469
+ <td>
470
+ {{ d.get("vendor") or "-" }}
471
+ {% if d.get("model") %}<br><span class="text-muted small">{{ d.get("model") }}</span>{% endif %}
472
+ </td>
473
+ <td class="small"><code>{{ d.get("serial") or "-" }}</code></td>
474
+ <td class="small">{{ d.get("tran") or "-" }}</td>
475
+ </tr>
476
+ {% endfor %}
477
+ </tbody>
478
+ </table>
479
+ </div>
480
+ {% else %}
481
+ <p class="text-muted small mb-0">No disks in the posted inventory.</p>
482
+ {% endif %}
483
+
484
+ {% if machine.inventory.get("lshw") %}
485
+ <details class="mt-3">
486
+ <summary class="text-muted small">Raw <code>lshw</code> JSON</summary>
487
+ <pre class="bg-light border rounded p-3 mt-2 small" style="max-height: 30rem; overflow: auto;">{{ machine.inventory.get("lshw") | tojson(indent=2) }}</pre>
488
+ </details>
489
+ {% endif %}
490
+ {% else %}
491
+ <p class="text-muted mb-0">
492
+ <i class="bi bi-inbox me-1"></i>
493
+ No inventory has been posted for this machine yet. The live env's
494
+ <code>pixie</code> CLI posts one on boot; check the target's PXE
495
+ reach to pixie.
496
+ </p>
497
+ {% endif %}
498
+ </div>
499
+ </div>
500
+
501
+ <div class="card shadow-sm" id="events">
502
+ <div class="card-header bg-light d-flex align-items-center">
503
+ <h2 class="h5 mb-0"><i class="bi bi-clock-history me-2"></i>Recent events</h2>
504
+ <a href="/ui/events" class="ms-auto small">All events</a>
505
+ </div>
506
+ {% if events %}
507
+ <div class="table-responsive">
508
+ <table class="table table-sm table-hover align-middle mb-0">
509
+ <thead>
510
+ <tr>
511
+ <th scope="col">When</th>
512
+ <th scope="col">Kind</th>
513
+ <th scope="col">Summary</th>
514
+ </tr>
515
+ </thead>
516
+ <tbody>
517
+ {% for e in events %}
518
+ <tr>
519
+ <td class="small text-muted text-nowrap">{{ e.ts | fmt_ts }}</td>
520
+ <td><code>{{ e.kind }}</code></td>
521
+ <td>{{ e.summary or "" }}</td>
522
+ </tr>
523
+ {% endfor %}
524
+ </tbody>
525
+ </table>
526
+ </div>
527
+ {% else %}
528
+ <div class="card-body">
529
+ <p class="text-muted small mb-0">No events recorded for this machine yet.</p>
530
+ </div>
531
+ {% endif %}
532
+ </div>
533
+
534
+ <script>
535
+ (function () {
536
+ // ---- Bind-form controller -------------------------------------
537
+ //
538
+ // Coordinates four independent behaviours over the same form:
539
+ // 1. Radio-card checked-state fallback for browsers without
540
+ // :has() support (Firefox < 121 mostly).
541
+ // 2. Mode-relevance dimming: image + target-disk pickers get
542
+ // an "opacity-50" + "(not used by <mode>)" label when the
543
+ // picked mode ignores them. The pick VALUE survives so a
544
+ // sha calibrated under ipxe-exit stays around when the
545
+ // operator flips to nbdboot.
546
+ // 3. Nbdboot-ready gate on the image picker: only fetched
547
+ // catalog entries are selectable under boot_mode=nbdboot
548
+ // (pixie needs the bytes on disk to serve them over NBD).
549
+ // 4. Flash-mode guard: pixie-flash-{once,always} refuses to
550
+ // Save without both inventory + a picked target disk. The
551
+ // server enforces the same constraint with a 422; the
552
+ // client-side mirror surfaces the reason BEFORE the click.
553
+ // 5. Dirty tracking on the Save button: enabled iff any field
554
+ // differs from its initial value at page-load, cleared by
555
+ // submitting the form.
556
+ // 6. Live "preview" panel: plain-English narration of what
557
+ // the target will do on its next PXE given the current
558
+ // picks. Same signal the operator would read from
559
+ // /pxe/<mac>/plan, without the JSON round-trip.
560
+ const form = document.getElementById("bind-form");
561
+ if (!form) return;
562
+ const modeInputs = form.querySelectorAll('input[name="boot_mode"]');
563
+ if (!modeInputs.length) return;
564
+ const modeCards = form.querySelectorAll(".mode-card");
565
+ const targets = form.querySelectorAll("[data-policy-relevant]");
566
+ const imgSelect = form.querySelector("select[data-nbdboot-gate]");
567
+ const targetSelect = form.querySelector("#target_disk_serial");
568
+ const hasInventoryDisks = !!(
569
+ targetSelect && targetSelect.tagName === "SELECT"
570
+ && targetSelect.querySelectorAll('option:not([value=""]):not([disabled])').length > 0
571
+ );
572
+ const extraCmdlineInput = form.querySelector("#extra_cmdline");
573
+ const saveBtn = form.querySelector("#save-binding");
574
+ const flashHint = form.querySelector("#flash-guard-hint");
575
+ const dirtyHint = document.getElementById("bind-dirty-hint");
576
+ const cleanHint = document.getElementById("bind-clean-hint");
577
+ const previewBody = form.querySelector("#bind-preview .preview-body");
578
+
579
+ // Human-readable descriptions for the preview panel. Mirrors the
580
+ // BOOT_MODE_META table in pixie.machines._store but pared to a
581
+ // sentence-per-mode that describes the OUTCOME on the next PXE,
582
+ // not the mode definition (the cards themselves cover that).
583
+ const MODE_PREVIEWS = {
584
+ "ipxe-exit": "Pixie exits the iPXE chain. The BIOS boot order picks the next bootable device on this target.",
585
+ "pixie-inventory": "Pixie boots its live env; the pixie CLI posts disk + NIC inventory back, then the target reboots to firmware.",
586
+ "pixie-tui": "Pixie boots its live env into an interactive TUI. An operator drives the wizard on the target's console.",
587
+ "pixie-flash-once": "Pixie boots its live env, which writes {IMAGE} to disk (serial: {DISK}), then the target reboots to firmware.",
588
+ "pixie-flash-always": "Pixie boots its live env, which re-writes {IMAGE} to disk (serial: {DISK}) on every PXE. Any local changes are lost.",
589
+ "nbdboot": "Pixie streams {IMAGE} over NBD; root is an overlay-on-tmpfs of the image. Nothing writes back to the source.",
590
+ };
591
+ const FLASH_MODES = new Set(["pixie-flash-once", "pixie-flash-always"]);
592
+ const NBDBOOT = "nbdboot";
593
+ const IMAGE_MODES = new Set([NBDBOOT, "pixie-flash-once", "pixie-flash-always"]);
594
+
595
+ // Snapshot of the initial pick per field, taken at DOMContentLoaded.
596
+ // A field is DIRTY iff its current value differs from this snapshot.
597
+ // The form submit clears dirty (the page re-renders anyway).
598
+ const initial = {
599
+ boot_mode: pickedMode() || "",
600
+ image_content_sha256: imgSelect ? imgSelect.value : "",
601
+ target_disk_serial: (targetSelect && targetSelect.tagName === "SELECT") ? targetSelect.value : "",
602
+ extra_cmdline: extraCmdlineInput ? extraCmdlineInput.value : "",
603
+ };
604
+
605
+ function pickedMode() {
606
+ for (const r of modeInputs) if (r.checked) return r.value;
607
+ return "";
608
+ }
609
+
610
+ function pickedImageName() {
611
+ if (!imgSelect) return "";
612
+ const opt = imgSelect.selectedOptions[0];
613
+ if (!opt) return "";
614
+ return opt.getAttribute("data-image-name") || opt.value || "";
615
+ }
616
+
617
+ function pickedDiskLabel() {
618
+ if (!targetSelect || targetSelect.tagName !== "SELECT") return "";
619
+ const opt = targetSelect.selectedOptions[0];
620
+ if (!opt || !opt.value) return "";
621
+ return opt.value;
622
+ }
623
+
624
+ // 1. Radio-card :has() fallback ---------------------------------
625
+ function syncCardSelectedClass() {
626
+ modeCards.forEach((card) => {
627
+ const radio = card.querySelector(".mode-radio");
628
+ card.classList.toggle("mode-card-selected", !!(radio && radio.checked));
629
+ });
630
+ }
631
+
632
+ // 2. Mode-relevance dimming -------------------------------------
633
+ function applyUsage() {
634
+ const current = pickedMode();
635
+ targets.forEach((el) => {
636
+ const allowed = (el.getAttribute("data-policy-relevant") || "").split(/\s+/);
637
+ const consumed = allowed.includes(current);
638
+ el.classList.toggle("opacity-50", !consumed);
639
+ const note = el.querySelector(".policy-usage");
640
+ if (note) note.textContent = consumed ? "" : "(not used by this mode)";
641
+ // ``disabled`` on the underlying control keeps a stray
642
+ // click or tab from landing on a value the current mode
643
+ // ignores. The submit handler re-enables everything so
644
+ // the value survives a mode flip when POSTed.
645
+ el.querySelectorAll("input, select, textarea").forEach((ctrl) => {
646
+ ctrl.disabled = !consumed;
647
+ });
648
+ });
649
+ }
650
+
651
+ // 3. Nbdboot-ready image gate -----------------------------------
652
+ function applyNbdbootGate() {
653
+ if (!imgSelect) return;
654
+ const isNbdboot = pickedMode() === NBDBOOT;
655
+ imgSelect.querySelectorAll("option[data-nbdboot-ready]").forEach((opt) => {
656
+ const ready = opt.getAttribute("data-nbdboot-ready") === "true";
657
+ opt.disabled = isNbdboot && !ready;
658
+ });
659
+ if (imgSelect.selectedOptions.length && imgSelect.selectedOptions[0].disabled) {
660
+ imgSelect.value = "";
661
+ }
662
+ }
663
+
664
+ // 4. Flash-mode guard -------------------------------------------
665
+ function applyFlashGuard() {
666
+ if (!flashHint) return false;
667
+ const isFlash = FLASH_MODES.has(pickedMode());
668
+ let msg = "";
669
+ if (isFlash) {
670
+ if (!hasInventoryDisks) {
671
+ msg = "This mode flashes a specific disk, and pixie hasn't seen this target's disks yet. Pick Discover inventory above, power-cycle, then come back.";
672
+ } else if (!targetSelect || !targetSelect.value) {
673
+ msg = "Pick which disk to flash. Pixie matches by serial at flash time; a swapped drive refuses the flash rather than risking the wrong disk.";
674
+ }
675
+ }
676
+ const body = flashHint.querySelector(".hint-body");
677
+ if (msg) {
678
+ if (body) body.textContent = msg;
679
+ flashHint.classList.remove("d-none");
680
+ return true; // guard tripped -> block Save
681
+ }
682
+ flashHint.classList.add("d-none");
683
+ return false;
684
+ }
685
+
686
+ // 5. Dirty tracking + Save button state -------------------------
687
+ function isDirty() {
688
+ if (pickedMode() !== initial.boot_mode) return true;
689
+ if (imgSelect && imgSelect.value !== initial.image_content_sha256) return true;
690
+ if (targetSelect && targetSelect.tagName === "SELECT"
691
+ && targetSelect.value !== initial.target_disk_serial) return true;
692
+ if (extraCmdlineInput && extraCmdlineInput.value !== initial.extra_cmdline) return true;
693
+ return false;
694
+ }
695
+
696
+ function applySaveState(flashGuardBlocked) {
697
+ if (!saveBtn) return;
698
+ const dirty = isDirty();
699
+ // Guard-blocked ALWAYS wins over dirty (safety > convenience).
700
+ saveBtn.disabled = flashGuardBlocked || !dirty;
701
+ if (dirtyHint) dirtyHint.classList.toggle("d-none", !dirty);
702
+ if (cleanHint) cleanHint.classList.toggle("d-none", dirty);
703
+ }
704
+
705
+ // 6. Preview panel narration ------------------------------------
706
+ function renderPreview() {
707
+ if (!previewBody) return;
708
+ const mode = pickedMode();
709
+ if (!mode) {
710
+ previewBody.textContent = "Pick a boot mode above to see what happens.";
711
+ return;
712
+ }
713
+ let text = MODE_PREVIEWS[mode] || mode;
714
+ const imgName = pickedImageName();
715
+ const diskLabel = pickedDiskLabel();
716
+ if (IMAGE_MODES.has(mode)) {
717
+ if (!imgName) {
718
+ previewBody.innerHTML = `<span class="text-warning-emphasis"><i class="bi bi-exclamation-circle me-1"></i>Pick an image above; this mode needs one.</span>`;
719
+ return;
720
+ }
721
+ text = text.replace("{IMAGE}", `<code>${imgName}</code>`);
722
+ }
723
+ if (FLASH_MODES.has(mode)) {
724
+ text = text.replace("{DISK}", diskLabel ? `<code>${diskLabel}</code>` : "-not picked-");
725
+ }
726
+ previewBody.innerHTML = text;
727
+ }
728
+
729
+ function refreshAll() {
730
+ syncCardSelectedClass();
731
+ applyUsage();
732
+ applyNbdbootGate();
733
+ const blocked = applyFlashGuard();
734
+ renderPreview();
735
+ applySaveState(blocked);
736
+ }
737
+
738
+ // Wire events.
739
+ modeInputs.forEach((r) => r.addEventListener("change", refreshAll));
740
+ if (imgSelect) imgSelect.addEventListener("change", refreshAll);
741
+ if (targetSelect) targetSelect.addEventListener("change", refreshAll);
742
+ if (extraCmdlineInput) extraCmdlineInput.addEventListener("input", refreshAll);
743
+
744
+ // Re-enable every mode-gated control at submit time so the
745
+ // browser POSTs the current values even for fields the current
746
+ // mode does not consume, matching the server's "config is
747
+ // GENERAL, mode picks what to consume" contract.
748
+ form.addEventListener("submit", () => {
749
+ targets.forEach((el) => {
750
+ el.querySelectorAll("input, select, textarea").forEach((ctrl) => {
751
+ ctrl.disabled = false;
752
+ });
753
+ });
754
+ });
755
+
756
+ refreshAll();
757
+ })();
758
+
759
+ // Live poll: refresh the ``Last seen`` / ``Last seen IP`` cells
760
+ // every 5 s so an operator watching a target boot into the live
761
+ // env sees the ``last_seen_at`` bump when the initrd's iPXE
762
+ // chain hits /pxe/<mac>. Same JSON endpoint the machines list
763
+ // poll uses so the two views stay in lockstep.
764
+ (function () {
765
+ const MAC = "{{ machine.mac }}";
766
+ async function poll() {
767
+ try {
768
+ const r = await fetch("/ui/machines-live.json", {credentials: "same-origin"});
769
+ if (!r.ok) return;
770
+ const rows = await r.json();
771
+ const d = rows[MAC];
772
+ if (!d) return;
773
+ const seen = document.querySelector('[data-live="last-seen"]');
774
+ if (seen) seen.textContent = d.last_seen_at_display || d.last_seen_at || "-";
775
+ const ip = document.querySelector('[data-live="last-seen-ip"]');
776
+ if (ip) {
777
+ ip.innerHTML = d.last_seen_ip
778
+ ? `<code>${d.last_seen_ip}</code>`
779
+ : `<span class="text-muted">-</span>`;
780
+ }
781
+ } catch (e) { /* swallow */ }
782
+ }
783
+ setInterval(poll, 5000);
784
+ })();
785
+ </script>
786
+ {% endblock %}