Name update
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "pxeforge-webui"
|
||||
name = "openpxe-webui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "Embedded single-file web UI for PXEForge"
|
||||
description = "Embedded single-file web UI for OpenPXE"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" fill="none">
|
||||
<title>PXEForge — forging</title>
|
||||
<defs>
|
||||
<linearGradient id="afBody" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#aab3c2"/>
|
||||
<stop offset="55%" stop-color="#7d8696"/>
|
||||
<stop offset="100%" stop-color="#525a6b"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="afFace" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#cdd5e1"/>
|
||||
<stop offset="100%" stop-color="#9aa3b3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="afBase" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#3b4252"/>
|
||||
<stop offset="100%" stop-color="#252a36"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="afSpark" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#fff5b8" stop-opacity="1"/>
|
||||
<stop offset="40%" stop-color="#ff9a3a" stop-opacity="0.9"/>
|
||||
<stop offset="100%" stop-color="#ff5a18" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="afEmber" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="#ffd47a" stop-opacity="1"/>
|
||||
<stop offset="100%" stop-color="#ff7322" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Anvil shifted down so sparks have room to rise above -->
|
||||
<g transform="translate(0,40)">
|
||||
<!-- Horn + face -->
|
||||
<path d="M14 36 L72 30 L162 30 L162 46 L72 46 Z"
|
||||
fill="url(#afFace)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
|
||||
<!-- Body / waist -->
|
||||
<path d="M70 46 L160 46 L142 70 L88 70 Z"
|
||||
fill="url(#afBody)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
|
||||
<!-- Pillar -->
|
||||
<rect x="92" y="70" width="46" height="26" fill="url(#afBody)"
|
||||
stroke="#1d2330" stroke-width="2.4"/>
|
||||
<!-- Base -->
|
||||
<path d="M62 96 L168 96 L160 110 L70 110 Z"
|
||||
fill="url(#afBase)" stroke="#0d1018" stroke-width="2.4" stroke-linejoin="round"/>
|
||||
<line x1="74" y1="34" x2="158" y2="34" stroke="#e6ecf5" stroke-width="1.2" opacity="0.7"/>
|
||||
<!-- Soft underglow on top face where the sparks land -->
|
||||
<ellipse cx="115" cy="33" rx="42" ry="6" fill="url(#afEmber)" opacity="0.55">
|
||||
<animate attributeName="opacity" values="0.35;0.7;0.35"
|
||||
dur="1.6s" repeatCount="indefinite"/>
|
||||
</ellipse>
|
||||
</g>
|
||||
|
||||
<!-- Sparks. SMIL animations only — no JS, no CSS needed. Each spark
|
||||
rises, fades, restarts at a staggered delay for an organic feel. -->
|
||||
<g class="sparks">
|
||||
<circle cx="116" cy="62" r="3.4" fill="url(#afSpark)" opacity="0">
|
||||
<animate attributeName="cy" from="62" to="14" dur="1.4s" repeatCount="indefinite"/>
|
||||
<animate attributeName="cx" values="116;112;120;116" dur="1.4s" repeatCount="indefinite"/>
|
||||
<animate attributeName="r" values="2;3.6;1.4" dur="1.4s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0;1;0" dur="1.4s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="105" cy="62" r="2.4" fill="url(#afSpark)" opacity="0">
|
||||
<animate attributeName="cy" from="62" to="22" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
|
||||
<animate attributeName="cx" values="105;101;108;104" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
|
||||
<animate attributeName="r" values="1.6;2.8;1" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0;1;0" dur="1.7s" begin="0.25s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="125" cy="62" r="2.8" fill="url(#afSpark)" opacity="0">
|
||||
<animate attributeName="cy" from="62" to="6" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
|
||||
<animate attributeName="cx" values="125;130;121;127" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
|
||||
<animate attributeName="r" values="1.8;3.2;1.2" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0;1;0" dur="1.9s" begin="0.55s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="113" cy="62" r="2" fill="url(#afEmber)" opacity="0">
|
||||
<animate attributeName="cy" from="62" to="32" dur="1.2s" begin="0.9s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0;0.9;0" dur="1.2s" begin="0.9s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx="132" cy="62" r="2.2" fill="url(#afSpark)" opacity="0">
|
||||
<animate attributeName="cy" from="62" to="20" dur="1.5s" begin="1.2s" repeatCount="indefinite"/>
|
||||
<animate attributeName="cx" values="132;138;128" dur="1.5s" begin="1.2s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" values="0;1;0" dur="1.5s" begin="1.2s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.6 KiB |
+31
-31
@@ -1,4 +1,4 @@
|
||||
/* PXEForge web UI — Netbox-style minimal layout, fully offline.
|
||||
/* OpenPXE web UI — Netbox-style minimal layout, fully offline.
|
||||
*
|
||||
* Theme tokens live on `:root` (dark default) and `:root[data-theme=light]`.
|
||||
* Both palettes share variable *names*, so component CSS uses
|
||||
@@ -279,35 +279,35 @@ label.check input { accent-color: var(--accent); }
|
||||
.progress.active { display: block; }
|
||||
.progress .bar { height: 100%; width: 0%; background: var(--accent); transition: width .25s; }
|
||||
|
||||
/* ── Forge progress widget ─────────────────────────────────────────
|
||||
Anvil-with-sparks animation paired with a horizontal progress bar.
|
||||
Used on the Forge Gate tab to give a sense of the "in-flight"
|
||||
imaging count without having to read a number. */
|
||||
.forge-progress {
|
||||
/* ── Imaging progress widget ───────────────────────────────────────
|
||||
Animated brand mark paired with a horizontal progress bar; surfaces
|
||||
on Dashboard and the Queue tab. Renamed from `.forge-progress` in
|
||||
v0.3.0 — the anvil-themed naming is gone with the rebrand. */
|
||||
.queue-progress {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
.forge-progress .anvil {
|
||||
width: 64px; height: 64px; flex: none;
|
||||
background: url("/assets/anvil-forge.svg") no-repeat center / contain;
|
||||
filter: drop-shadow(0 0 14px color-mix(in srgb, var(--warn) 40%, transparent));
|
||||
.queue-progress .mark {
|
||||
width: 56px; height: 56px; flex: none; border-radius: 50%;
|
||||
background: url("/assets/loader.svg") no-repeat center / contain;
|
||||
filter: drop-shadow(0 0 16px rgba(255, 255, 255, 0.10));
|
||||
}
|
||||
.forge-progress .info { flex: 1; min-width: 0; }
|
||||
.forge-progress .info .label {
|
||||
.queue-progress .info { flex: 1; min-width: 0; }
|
||||
.queue-progress .info .label {
|
||||
font-size: 12.5px; color: var(--fg-dim); margin-bottom: 6px;
|
||||
}
|
||||
.forge-progress .bar-track {
|
||||
.queue-progress .bar-track {
|
||||
height: 8px; background: var(--bg-elev); border-radius: 4px;
|
||||
overflow: hidden; position: relative;
|
||||
}
|
||||
.forge-progress .bar-fill {
|
||||
.queue-progress .bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--warn), var(--accent));
|
||||
background: linear-gradient(90deg, var(--accent-dim), var(--accent));
|
||||
width: 0%;
|
||||
transition: width 0.4s ease;
|
||||
position: relative;
|
||||
}
|
||||
.forge-progress .bar-fill::after {
|
||||
.queue-progress .bar-fill::after {
|
||||
/* Subtle moving sheen so the bar feels alive even at 0% movement. */
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background: linear-gradient(
|
||||
@@ -315,24 +315,24 @@ label.check input { accent-color: var(--accent); }
|
||||
rgba(255,255,255,0) 0%,
|
||||
rgba(255,255,255,0.18) 50%,
|
||||
rgba(255,255,255,0) 100%);
|
||||
animation: forge-sheen 1.6s linear infinite;
|
||||
animation: queue-sheen 1.6s linear infinite;
|
||||
}
|
||||
@keyframes forge-sheen {
|
||||
@keyframes queue-sheen {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(100%); }
|
||||
}
|
||||
.forge-progress.idle .anvil { filter: none; opacity: 0.45; }
|
||||
.forge-progress.idle .bar-fill::after { animation: none; }
|
||||
.queue-progress.idle .mark { filter: grayscale(0.85) opacity(0.55); }
|
||||
.queue-progress.idle .bar-fill::after { animation: none; }
|
||||
|
||||
/* ── Page-load anvil ──────────────────────────────────────────────── */
|
||||
/* ── Page-load loader ────────────────────────────────────────────── */
|
||||
.loader {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 12px;
|
||||
padding: 40px 20px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.loader .anvil {
|
||||
width: 110px; height: 110px;
|
||||
background: url("/assets/anvil-forge.svg") no-repeat center / contain;
|
||||
.loader .mark {
|
||||
width: 96px; height: 96px;
|
||||
background: url("/assets/loader.svg") no-repeat center / contain;
|
||||
}
|
||||
|
||||
/* ── Top bar readiness chip ──────────────────────────────────────── */
|
||||
@@ -381,22 +381,22 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
||||
@media (max-width: 900px) { .form-row { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ── Gate queue "horse race" visual ──────────────────────────────── */
|
||||
.gate-track {
|
||||
.queue-track {
|
||||
display: grid; gap: 6px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.gate-row {
|
||||
.queue-row {
|
||||
display: grid; grid-template-columns: 32px 1fr auto auto; align-items: center;
|
||||
gap: 14px;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-panel-2); border-radius: var(--radius);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
.gate-row.assigned { border-left-color: var(--ok); }
|
||||
.gate-row .pos { font-family: var(--mono); font-size: 15px; color: var(--accent); font-weight: 600; }
|
||||
.gate-row.assigned .pos { color: var(--ok); }
|
||||
.gate-row .mac { font-family: var(--mono); font-size: 13px; }
|
||||
.gate-row .meta { color: var(--fg-dim); font-size: 12px; }
|
||||
.queue-row.assigned { border-left-color: var(--ok); }
|
||||
.queue-row .pos { font-family: var(--mono); font-size: 15px; color: var(--accent); font-weight: 600; }
|
||||
.queue-row.assigned .pos { color: var(--ok); }
|
||||
.queue-row .mac { font-family: var(--mono); font-size: 13px; }
|
||||
.queue-row .meta { color: var(--fg-dim); font-size: 12px; }
|
||||
|
||||
.empty { color: var(--fg-dim); padding: 30px; text-align: center; }
|
||||
.msg { color: var(--fg-dim); font-size: 12.5px; margin-top: 8px; }
|
||||
|
||||
+47
-47
@@ -1,7 +1,7 @@
|
||||
// PXEForge web UI — vanilla JS, no build step, no framework, no network
|
||||
// OpenPXE web UI — vanilla JS, no build step, no framework, no network
|
||||
// dependencies. Uses fetch() + EventSource only.
|
||||
//
|
||||
// Tabs (Phase 4): Dashboard / Network / Forge Gate / Storage / Terminal /
|
||||
// Tabs (Phase 4): Dashboard / Network / Queue / Storage / Terminal /
|
||||
// About. The shell swaps a single view into #view-root.
|
||||
//
|
||||
// Keep this readable — nobody wants to debug a clever vanilla-JS
|
||||
@@ -75,19 +75,19 @@
|
||||
return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
|
||||
}
|
||||
|
||||
// Animated "anvil + progress bar" widget. Built once here and inlined
|
||||
// wherever a card wants to convey "an image is being forged onto the
|
||||
// bench right now." Used on the dashboard and on the Forge Gate.
|
||||
function forgeProgressWidget(imaging, gateTotal) {
|
||||
const total = Math.max(gateTotal, imaging, 1);
|
||||
// Animated brand-mark + progress bar widget. Built once here and inlined
|
||||
// wherever a card wants to convey "an image is being deployed onto a
|
||||
// queued client right now." Used on the Dashboard and the Queue tab.
|
||||
function queueProgressWidget(imaging, queueTotal) {
|
||||
const total = Math.max(queueTotal, imaging, 1);
|
||||
const pct = imaging > 0 ? Math.round((imaging / total) * 100) : 0;
|
||||
const root = el('div', {class: 'forge-progress' + (imaging === 0 ? ' idle' : '')}, [
|
||||
el('div', {class: 'anvil', 'aria-hidden': 'true'}),
|
||||
const root = el('div', {class: 'queue-progress' + (imaging === 0 ? ' idle' : '')}, [
|
||||
el('div', {class: 'mark', 'aria-hidden': 'true'}),
|
||||
el('div', {class: 'info'}, [
|
||||
el('div', {class: 'label'},
|
||||
imaging === 0
|
||||
? 'No active imaging'
|
||||
: (imaging + ' of ' + total + ' device' + (total === 1 ? '' : 's') + ' being forged')),
|
||||
: (imaging + ' of ' + total + ' device' + (total === 1 ? '' : 's') + ' deploying')),
|
||||
el('div', {class: 'bar-track'},
|
||||
el('div', {class: 'bar-fill', style: 'width:' + pct + '%'})),
|
||||
]),
|
||||
@@ -120,7 +120,7 @@
|
||||
const status = await getJSON('/api/status');
|
||||
const isos = await getJSON('/api/isos');
|
||||
const clients = (await getJSON('/api/clients')).clients || [];
|
||||
const gates = (await getJSON('/api/gate')).gates || [];
|
||||
const entries = (await getJSON('/api/queue')).entries || [];
|
||||
|
||||
const ipxeOk = (status.ipxe_assets || []).length > 0;
|
||||
const stats = el('div', {class: 'statstrip'}, [
|
||||
@@ -136,9 +136,9 @@
|
||||
el('div', {class: 'stat', style: 'padding-bottom:0'}, [
|
||||
el('div', {class: 'label'}, 'Imaging now'),
|
||||
el('div', {class: 'value'}, String(status.imaging_count || 0)),
|
||||
el('div', {class: 'trend'}, (status.waiting_count || 0) + ' waiting at gate'),
|
||||
el('div', {class: 'trend'}, (status.waiting_count || 0) + ' waiting in queue'),
|
||||
]),
|
||||
forgeProgressWidget(status.imaging_count || 0, status.gate_count || 0),
|
||||
queueProgressWidget(status.imaging_count || 0, status.queue_count || 0),
|
||||
]),
|
||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||
el('div', {class: 'label'}, 'Images available'),
|
||||
@@ -151,7 +151,7 @@
|
||||
el('div', {class: 'card'}, el('div', {class: 'stat'}, [
|
||||
el('div', {class: 'label'}, 'Uptime'),
|
||||
el('div', {class: 'value', style: 'font-size:22px'}, fmtUptime(status.uptime_secs)),
|
||||
el('div', {class: 'trend'}, 'PXEForge ' + status.version),
|
||||
el('div', {class: 'trend'}, 'OpenPXE ' + status.version),
|
||||
])),
|
||||
]);
|
||||
|
||||
@@ -159,10 +159,10 @@
|
||||
// boot" log. Use last_seen desc (already sorted by API).
|
||||
const recent = clients.slice(0, 8);
|
||||
const recentRows = recent.map(c => {
|
||||
const g = gates.find(g => g.mac === c.mac);
|
||||
const g = entries.find(g => g.mac === c.mac);
|
||||
let status = el('span', {class: 'tag ok'}, 'active');
|
||||
if (g && g.assigned_target) status = el('span', {class:'tag ok'}, 'assigned: ' + g.assigned_target);
|
||||
else if (g) status = el('span', {class:'tag accent'}, '#' + g.position + ' at gate');
|
||||
else if (g) status = el('span', {class:'tag accent'}, '#' + g.position + ' in queue');
|
||||
return el('tr', {}, [
|
||||
el('td', {class: 'mono'}, c.mac),
|
||||
el('td', {}, c.last_ip ? String(c.last_ip) : '-'),
|
||||
@@ -229,13 +229,13 @@
|
||||
]),
|
||||
el('p', {class:'msg'},
|
||||
'Server IP, NIC, mask, and gateway are auto-detected at startup. ' +
|
||||
'To change them, set PXEFORGE_PUBLIC_IP and restart — editing them ' +
|
||||
'To change them, set OPENPXE_PUBLIC_IP and restart — editing them ' +
|
||||
'from a hot UI would silently break PXE for every client mid-boot.'),
|
||||
el('label', {class:'field', style:'margin-top:18px'}, [
|
||||
el('span', {class:'name'}, 'DNS server (optional, informational)'),
|
||||
dns,
|
||||
el('span', {class:'hint'},
|
||||
'PXEForge does not run a DNS server itself; this field records ' +
|
||||
'OpenPXE does not run a DNS server itself; this field records ' +
|
||||
'what your upstream DNS is so you don\'t have to dig it out at ' +
|
||||
'3 AM during a deployment.'),
|
||||
]),
|
||||
@@ -247,8 +247,8 @@
|
||||
},
|
||||
|
||||
gate: async () => {
|
||||
const [{ gates = [] }, isos] = await Promise.all([
|
||||
getJSON('/api/gate'), getJSON('/api/isos'),
|
||||
const [{ entries = [] }, isos] = await Promise.all([
|
||||
getJSON('/api/queue'), getJSON('/api/isos'),
|
||||
]);
|
||||
const targets = isos.flatMap(i => i.boot_entries.map(e => ({
|
||||
id: e.id, title: e.title + ' — ' + familyLabel(i.introspection.family)
|
||||
@@ -261,7 +261,7 @@
|
||||
const msg = el('div', {class:'msg'});
|
||||
launch.onclick = async () => {
|
||||
if (!pick.value) { msg.textContent = 'Pick an image first.'; msg.className='msg err'; return; }
|
||||
const r = await postJSON('/api/gate/assign', { target: pick.value, gate_ids: [] });
|
||||
const r = await postJSON('/api/queue/assign', { target: pick.value, entry_ids: [] });
|
||||
if (!r.ok) { msg.textContent = 'Assign failed: ' + r.status; msg.className='msg err'; return; }
|
||||
const j = await r.json();
|
||||
if (!j.ok) { msg.textContent = 'Assign failed: ' + (j.error || 'unknown'); msg.className='msg err'; return; }
|
||||
@@ -270,9 +270,9 @@
|
||||
render('gate');
|
||||
};
|
||||
|
||||
const track = gates.length
|
||||
? el('div', {class:'gate-track'},
|
||||
gates.map(g => el('div', {class:'gate-row' + (g.assigned_target ? ' assigned' : '')}, [
|
||||
const track = entries.length
|
||||
? el('div', {class:'queue-track'},
|
||||
entries.map(g => el('div', {class:'queue-row' + (g.assigned_target ? ' assigned' : '')}, [
|
||||
el('div', {class:'pos'}, '#' + g.position),
|
||||
el('div', {}, [
|
||||
el('div', {class:'mac'}, g.mac),
|
||||
@@ -283,20 +283,20 @@
|
||||
? el('span', {class:'tag ok'}, '→ ' + g.assigned_target)
|
||||
: el('span', {class:'tag accent'}, 'waiting')),
|
||||
el('button', {class:'ghost', onclick: async () => {
|
||||
await fetch('/api/gate/' + encodeURIComponent(g.id), {method:'DELETE'});
|
||||
await fetch('/api/queue/' + encodeURIComponent(g.id), {method:'DELETE'});
|
||||
render('gate');
|
||||
}}, 'Release'),
|
||||
]))
|
||||
)
|
||||
: el('div', {class:'empty'},
|
||||
'No clients at the gate. Boot a client and choose "Gated Deployment" in the PXE menu.');
|
||||
'No clients at the gate. Boot a client and choose "Queued Deployment" in the PXE menu.');
|
||||
|
||||
const imaging = gates.filter(g => g.assigned_target).length;
|
||||
const imaging = entries.filter(g => g.assigned_target).length;
|
||||
|
||||
return el('div', {class:'grid'}, [
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Forge')),
|
||||
forgeProgressWidget(imaging, gates.length),
|
||||
queueProgressWidget(imaging, entries.length),
|
||||
]),
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Launch an image across the gate')),
|
||||
@@ -313,7 +313,7 @@
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'Gate positions'),
|
||||
el('span', {class:'sub'}, gates.length + ' waiting'),
|
||||
el('span', {class:'sub'}, entries.length + ' waiting'),
|
||||
]),
|
||||
el('div', {class:'body'}, track),
|
||||
]),
|
||||
@@ -516,7 +516,7 @@
|
||||
// Reserved menu shortcuts that the operator might want to bind.
|
||||
const reserved = [
|
||||
{id: '_local', title: '↳ Boot from Local HDD (built-in)'},
|
||||
{id: '_gate', title: '↳ Gated Deployment (built-in)'},
|
||||
{id: '_queue', title: '↳ Queued Deployment (built-in)'},
|
||||
{id: '_tools_menu', title: '↳ Tools menu (built-in)'},
|
||||
];
|
||||
|
||||
@@ -583,7 +583,7 @@
|
||||
]),
|
||||
upsertBtn, msg,
|
||||
el('p', {class:'msg', style:'margin-top:14px'},
|
||||
'When a client with a bound MAC requests boot.ipxe, PXEForge ' +
|
||||
'When a client with a bound MAC requests boot.ipxe, OpenPXE ' +
|
||||
'short-circuits past the interactive menu and chains directly. ' +
|
||||
'Inspired by Tinkerbell smee\'s MAC-prepended URL pattern.'),
|
||||
]),
|
||||
@@ -629,7 +629,7 @@
|
||||
const ts = (line.timestamp || new Date().toISOString()).replace(/\.\d+/, '').replace('T', ' ').replace('Z', '');
|
||||
const span = el('span', {class: 'lvl-' + lvl}, [
|
||||
el('span', {class:'ts'}, ts + ' '),
|
||||
el('span', {class:'tg'}, '[' + (line.target || 'pxeforge') + '] '),
|
||||
el('span', {class:'tg'}, '[' + (line.target || 'openpxe') + '] '),
|
||||
line.message,
|
||||
'\n',
|
||||
]);
|
||||
@@ -647,7 +647,7 @@
|
||||
const r = await getJSON('/api/log/recent');
|
||||
for (const l of (r.lines || [])) append(l);
|
||||
} catch (e) {
|
||||
append({timestamp: new Date().toISOString(), level:'warn', target:'pxeforge::ui',
|
||||
append({timestamp: new Date().toISOString(), level:'warn', target:'openpxe::ui',
|
||||
message: 'failed to load recent logs: ' + e.message});
|
||||
}
|
||||
|
||||
@@ -655,12 +655,12 @@
|
||||
const es = new EventSource('/api/log/stream');
|
||||
es.onmessage = (ev) => {
|
||||
try { append(JSON.parse(ev.data)); }
|
||||
catch { append({timestamp: new Date().toISOString(), level:'debug', target:'pxeforge::ui', message: ev.data}); }
|
||||
catch { append({timestamp: new Date().toISOString(), level:'debug', target:'openpxe::ui', message: ev.data}); }
|
||||
};
|
||||
es.addEventListener('lagged', (ev) => {
|
||||
const j = JSON.parse(ev.data || '{}');
|
||||
append({timestamp: new Date().toISOString(), level:'warn',
|
||||
target:'pxeforge::ui',
|
||||
target:'openpxe::ui',
|
||||
message: 'log stream lagged: ' + (j.skipped || '?') + ' lines skipped'});
|
||||
});
|
||||
es.onerror = () => {
|
||||
@@ -668,7 +668,7 @@
|
||||
// We only append once, on transition from connected → erroring.
|
||||
if (!term._notedErr) {
|
||||
term._notedErr = true;
|
||||
append({timestamp: new Date().toISOString(), level:'warn', target:'pxeforge::ui',
|
||||
append({timestamp: new Date().toISOString(), level:'warn', target:'openpxe::ui',
|
||||
message: 'log stream connection lost — auto-reconnecting'});
|
||||
setTimeout(() => { term._notedErr = false; }, 5000);
|
||||
}
|
||||
@@ -701,7 +701,7 @@
|
||||
target: 'terminal-output', message: out});
|
||||
} catch (err) {
|
||||
append({timestamp: new Date().toISOString(), level:'error',
|
||||
target:'pxeforge::ui', message: 'command failed: ' + err.message});
|
||||
target:'openpxe::ui', message: 'command failed: ' + err.message});
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
if (history.length === 0) return;
|
||||
@@ -716,7 +716,7 @@
|
||||
});
|
||||
|
||||
// Welcome banner.
|
||||
append({timestamp: new Date().toISOString(), level:'info', target:'pxeforge::terminal',
|
||||
append({timestamp: new Date().toISOString(), level:'info', target:'openpxe::terminal',
|
||||
message: 'Connected. Type "help" for available commands.'});
|
||||
|
||||
// Focus the input on next tick (after view swap completes).
|
||||
@@ -729,7 +729,7 @@
|
||||
const status = await getJSON('/api/status');
|
||||
return el('div', {class:'card'}, [
|
||||
el('div', {class:'about-hero'}, [
|
||||
el('h2', {}, 'PXEForge'),
|
||||
el('h2', {}, 'OpenPXE'),
|
||||
el('p', {class:'lead'},
|
||||
'Air-gapped network PXE boot, container-native, that anyone can run. ' +
|
||||
'No CDN calls, no telemetry, no surprise external dependencies — ship ' +
|
||||
@@ -758,7 +758,7 @@
|
||||
const viewTitles = {
|
||||
dashboard: 'Dashboard',
|
||||
network: 'Network',
|
||||
gate: 'Forge Gate',
|
||||
gate: 'Queue',
|
||||
storage: 'Storage',
|
||||
hosts: 'Hosts',
|
||||
terminal: 'Terminal',
|
||||
@@ -769,7 +769,7 @@
|
||||
// script in index.html before paint; we just flip it here and persist.
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
try { localStorage.setItem('pxeforge-theme', theme); } catch {}
|
||||
try { localStorage.setItem('openpxe-theme', theme); } catch {}
|
||||
}
|
||||
function currentTheme() {
|
||||
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
|
||||
@@ -801,12 +801,12 @@
|
||||
try { currentBody._cleanup(); } catch (e) { /* ignore */ }
|
||||
}
|
||||
root.innerHTML = '';
|
||||
// Animated anvil loader. Replaces the old text "Loading…" so the
|
||||
// user gets a sense of "the forge is heating up" instead of a flat
|
||||
// spinner. The SVG itself drives all animation via SMIL, no JS.
|
||||
// Animated rainbow-mark loader replacing the old plain-text
|
||||
// \"Loading…\". The SVG drives all animation via SMIL — no JS, no
|
||||
// CSS keyframes.
|
||||
root.appendChild(el('div', {class:'loader'}, [
|
||||
el('div', {class:'anvil'}),
|
||||
el('div', {}, 'Heating the forge…'),
|
||||
el('div', {class:'mark'}),
|
||||
el('div', {}, 'Loading…'),
|
||||
]));
|
||||
try {
|
||||
const body = await views[view]();
|
||||
@@ -826,7 +826,7 @@
|
||||
$$('[data-bind=version]').forEach(n => n.textContent = s.version);
|
||||
$$('[data-bind=iso_count],[data-bind=iso_count2]').forEach(n => n.textContent = String(s.iso_count));
|
||||
$$('[data-bind=client_count],[data-bind=client_count2]').forEach(n => n.textContent = String(s.client_count));
|
||||
$$('[data-bind=gate_count],[data-bind=gate_count2]').forEach(n => n.textContent = String(s.gate_count));
|
||||
$$('[data-bind=queue_count],[data-bind=queue_count2]').forEach(n => n.textContent = String(s.queue_count));
|
||||
$$('[data-bind=host_count]').forEach(n => n.textContent = String(s.host_bindings || 0));
|
||||
const chip = $('[data-bind=ready_chip]');
|
||||
if (chip) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<title>PXEForge</title>
|
||||
<title>OpenPXE</title>
|
||||
<link rel="stylesheet" href="/assets/app.css" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg" />
|
||||
<!-- Theme is read from localStorage *before* paint to avoid the
|
||||
@@ -13,7 +13,7 @@
|
||||
<script>
|
||||
(function() {
|
||||
try {
|
||||
var stored = localStorage.getItem('pxeforge-theme');
|
||||
var stored = localStorage.getItem('openpxe-theme');
|
||||
var theme = stored || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
} catch (e) {
|
||||
@@ -28,16 +28,16 @@
|
||||
<div class="brand">
|
||||
<img src="/assets/logo.svg" alt="" />
|
||||
<div>
|
||||
<strong>PXEForge</strong>
|
||||
<div class="sub">v<span data-bind="version">0.2.0</span></div>
|
||||
<strong>OpenPXE</strong>
|
||||
<div class="sub">v<span data-bind="version">0.3.0</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<nav>
|
||||
<a data-view="dashboard" class="active">Dashboard</a>
|
||||
<a data-view="network">Network</a>
|
||||
<a data-view="gate">
|
||||
Forge Gate
|
||||
<span class="count" data-bind="gate_count">0</span>
|
||||
<a data-view="queue">
|
||||
Queue
|
||||
<span class="count" data-bind="queue_count">0</span>
|
||||
</a>
|
||||
<a data-view="storage">
|
||||
Storage
|
||||
@@ -62,7 +62,7 @@
|
||||
<span class="chip" data-bind="ready_chip" title="Server readiness">checking…</span>
|
||||
<span class="chip"><strong data-bind="iso_count2">0</strong> images</span>
|
||||
<span class="chip"><strong data-bind="client_count2">0</strong> clients</span>
|
||||
<span class="chip"><strong data-bind="gate_count2">0</strong> at gate</span>
|
||||
<span class="chip"><strong data-bind="queue_count2">0</strong> in queue</span>
|
||||
<button id="theme-toggle" class="theme-toggle" type="button"
|
||||
aria-label="Toggle light/dark theme" title="Toggle theme (T)">
|
||||
<!-- Two glyphs; CSS shows whichever matches the active theme. -->
|
||||
|
||||
+10
-10
@@ -1,7 +1,7 @@
|
||||
//! Offline-only web UI. Everything the browser needs (HTML, CSS, JS, SVG
|
||||
//! logo) is embedded in the compiled binary via `include_str!` /
|
||||
//! `include_bytes!`. No CDN, no external fonts, no remote images —
|
||||
//! PXEForge renders identically on an air-gapped network.
|
||||
//! OpenPXE renders identically on an air-gapped network.
|
||||
//!
|
||||
//! Layout follows the Netbox Labs pattern: dark left sidebar with primary
|
||||
//! nav, top bar with secondary tabs, card-dense content panels.
|
||||
@@ -23,14 +23,14 @@ pub fn app_css() -> &'static str { APP_CSS }
|
||||
#[must_use]
|
||||
pub fn logo_svg() -> &'static str { LOGO_SVG }
|
||||
|
||||
/// Animated forging anvil — sparks rise + glow pulse. Used for the
|
||||
/// imaging-progress widget and any "I'm working" loading state. Pure
|
||||
/// SVG + SMIL, no JS, no GIF.
|
||||
/// Larger, faster-cycling rainbow disc — used for the page-load
|
||||
/// transition and the imaging-progress widget on Dashboard / Queue.
|
||||
/// Pure SVG + SMIL, no JS, no GIF.
|
||||
#[must_use]
|
||||
pub fn anvil_forge_svg() -> &'static str { ANVIL_FORGE_SVG }
|
||||
pub fn loader_svg() -> &'static str { LOADER_SVG }
|
||||
|
||||
const INDEX_HTML: &str = include_str!("index.html");
|
||||
const APP_CSS: &str = include_str!("app.css");
|
||||
const APP_JS: &str = include_str!("app.js");
|
||||
const LOGO_SVG: &str = include_str!("logo.svg");
|
||||
const ANVIL_FORGE_SVG: &str = include_str!("anvil-forge.svg");
|
||||
const INDEX_HTML: &str = include_str!("index.html");
|
||||
const APP_CSS: &str = include_str!("app.css");
|
||||
const APP_JS: &str = include_str!("app.js");
|
||||
const LOGO_SVG: &str = include_str!("logo.svg");
|
||||
const LOADER_SVG: &str = include_str!("loader.svg");
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>OpenPXE — loading</title>
|
||||
<!-- Larger, bolder version of the brand mark for "I'm working" states:
|
||||
page transitions, the imaging-progress widget on Dashboard / Queue.
|
||||
The gradient slide is faster (3s) and we add a subtle scale pulse
|
||||
so the disc looks alive even when paired with a static progress bar.
|
||||
White inner glow keeps the colours legible against the panel bg. -->
|
||||
<defs>
|
||||
<linearGradient id="opxRainbowLg" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#330f1f"/>
|
||||
<stop offset="12.56%" stop-color="#c83228"/>
|
||||
<stop offset="25.06%" stop-color="#fb8841"/>
|
||||
<stop offset="37.56%" stop-color="#d3dd92"/>
|
||||
<stop offset="50.06%" stop-color="#59824f"/>
|
||||
<stop offset="62.06%" stop-color="#002414"/>
|
||||
<stop offset="74.06%" stop-color="#00143d"/>
|
||||
<stop offset="86.06%" stop-color="#2874d7"/>
|
||||
<stop offset="100%" stop-color="#99c2ff"/>
|
||||
<animate attributeName="x1" values="0;-1;0" dur="3s" repeatCount="indefinite"/>
|
||||
<animate attributeName="x2" values="1;0;1" dur="3s" repeatCount="indefinite"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="opxGlow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stop-color="rgba(255,255,255,0.6)"/>
|
||||
<stop offset="60%" stop-color="rgba(255,255,255,0.05)"/>
|
||||
<stop offset="100%" stop-color="rgba(255,255,255,0)"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<g>
|
||||
<circle cx="32" cy="32" r="26" fill="url(#opxRainbowLg)"
|
||||
stroke="rgba(0,0,0,0.2)" stroke-width="1.2">
|
||||
<animate attributeName="r" values="25;27;25" dur="2.4s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<!-- Inner highlight to give the disc a hint of dimensionality. -->
|
||||
<circle cx="28" cy="26" r="14" fill="url(#opxGlow)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
+21
-28
@@ -1,32 +1,25 @@
|
||||
<svg viewBox="0 0 200 130" xmlns="http://www.w3.org/2000/svg" fill="none">
|
||||
<title>PXEForge</title>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>OpenPXE</title>
|
||||
<!-- Brand mark to match openpxe.com: a circular medallion filled with
|
||||
the "rainbow-horizon" gradient, sliding 200% across to give a slow
|
||||
hue rotation. Subtle stroke + drop shadow for legibility on either
|
||||
theme. SMIL keeps it self-driving with no JS or CSS dependency. -->
|
||||
<defs>
|
||||
<linearGradient id="anvilBody" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#aab3c2"/>
|
||||
<stop offset="55%" stop-color="#7d8696"/>
|
||||
<stop offset="100%" stop-color="#525a6b"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="anvilFace" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#cdd5e1"/>
|
||||
<stop offset="100%" stop-color="#9aa3b3"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="anvilBase" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#3b4252"/>
|
||||
<stop offset="100%" stop-color="#252a36"/>
|
||||
<linearGradient id="opxRainbow" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#330f1f"/>
|
||||
<stop offset="12.56%" stop-color="#c83228"/>
|
||||
<stop offset="25.06%" stop-color="#fb8841"/>
|
||||
<stop offset="37.56%" stop-color="#d3dd92"/>
|
||||
<stop offset="50.06%" stop-color="#59824f"/>
|
||||
<stop offset="62.06%" stop-color="#002414"/>
|
||||
<stop offset="74.06%" stop-color="#00143d"/>
|
||||
<stop offset="86.06%" stop-color="#2874d7"/>
|
||||
<stop offset="100%" stop-color="#99c2ff"/>
|
||||
<animate attributeName="x1" values="0;-1;0" dur="12s" repeatCount="indefinite"/>
|
||||
<animate attributeName="x2" values="1;0;1" dur="12s" repeatCount="indefinite"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Horn (left point) + face (top) -->
|
||||
<path d="M14 36 L72 30 L162 30 L162 46 L72 46 Z"
|
||||
fill="url(#anvilFace)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
|
||||
<!-- Body / waist -->
|
||||
<path d="M70 46 L160 46 L142 70 L88 70 Z"
|
||||
fill="url(#anvilBody)" stroke="#1d2330" stroke-width="2.4" stroke-linejoin="round"/>
|
||||
<!-- Base plinth -->
|
||||
<path d="M62 96 L168 96 L160 110 L70 110 Z"
|
||||
fill="url(#anvilBase)" stroke="#0d1018" stroke-width="2.4" stroke-linejoin="round"/>
|
||||
<!-- Pillar between body and base -->
|
||||
<rect x="92" y="70" width="46" height="26" fill="url(#anvilBody)"
|
||||
stroke="#1d2330" stroke-width="2.4"/>
|
||||
<!-- Highlight along top face -->
|
||||
<line x1="74" y1="34" x2="158" y2="34" stroke="#e6ecf5" stroke-width="1.2" opacity="0.7"/>
|
||||
<!-- Outer hairline ring softens the edge in light mode; subtle in dark. -->
|
||||
<circle cx="12" cy="12" r="10.5" fill="url(#opxRainbow)"
|
||||
stroke="rgba(0,0,0,0.18)" stroke-width="0.6"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.3 KiB |
Reference in New Issue
Block a user