v0.4.4: Settings tab, API reference, ISO category, branding, disk space
Settings:
- New top-level Settings tab. Carries a placeholder for the planned
LDAP / OIDC / user-management work, the new branding controls, and
the API reference at the bottom.
- Custom logo upload (PNG/SVG/JPEG/WebP/GIF up to 2 MB) replaces the
bundled brand mark via /assets/logo.svg; bytes live at
<work_dir>/branding/ and survive restart. The original "OpenPXE
v<x.y.z>" pins to the sidebar footer for support.
- API reference rendered from a new GET /api/docs into a per-method
coloured pill list grouped by area.
ISO category (Storage):
- New IsoCategory { Os, Tools } on IsoMeta with PUT
/api/isos/:id/category. Storage table's Type cell becomes a
dropdown; selecting Tools moves the ISO into the Tools submenu next
to memtest / shell / NIC info and removes it from the OS Installers
family submenu. Family detection still drives BIOS/UEFI / kernel
args; only the menu placement changes.
Storage telemetry:
- New IsoStore::disk_usage (libc::statvfs, lives in iso-store so the
http-api crate stays #![forbid(unsafe_code)]) and GET
/api/storage/disk. The Storage tab now shows free/used/total for
the volume hosting the ISO directory with an 80%/95% colour ramp.
UI polish:
- Brand block in the sidebar now matches the topbar height exactly,
so the divider runs straight across the top of the app rather than
stepping; version label moved out of the brand and pinned to the
sidebar footer ("OpenPXE v0.4.4").
- Light-mode terminal: --terminal-bg + per-level text colours track
the active theme rather than being hard-coded dark.
- About: lead paragraph spans the full content width; new Docs row
links to https://openpxe.com/.
106 tests passing (was 89 in v0.4.1, +17 across branding unit tests
and new integration coverage for category / disk / docs / branding).
cargo clippy --workspace --all-targets clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a171331a7a
commit
7b972dc049
+113
-18
@@ -54,7 +54,11 @@
|
||||
--ok: #1f9b54;
|
||||
--border: #d8dde6;
|
||||
--border-soft: #e7eaf0;
|
||||
--terminal-bg: #0d1219; /* terminal stays dark even in light mode */
|
||||
/* Light-mode terminal: the pane background and chrome track the rest
|
||||
of the light theme. Per-level text colours below recolour-on-light
|
||||
so log lines stay readable on a pale background — previously the
|
||||
terminal was locked to dark and looked like a stuck panel. */
|
||||
--terminal-bg: #ffffff;
|
||||
--shadow-card: 0 1px 0 rgba(0,0,0,0.02), 0 6px 18px rgba(20,28,52,0.06);
|
||||
}
|
||||
|
||||
@@ -87,14 +91,23 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
/* The brand block sits flush with the topbar so the sidebar+topbar reads
|
||||
as one continuous bar across the top of the app, rather than a chunky
|
||||
2-line logo block plus a separate (smaller-typeface) page title. The
|
||||
height/border-bottom match the topbar exactly so the divider runs
|
||||
straight across without a step. */
|
||||
.sidebar .brand {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 18px;
|
||||
padding: 0 18px;
|
||||
height: var(--topbar-h);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sidebar .brand img { width: 40px; height: auto; }
|
||||
.sidebar .brand strong { font-size: 16px; letter-spacing: 0.4px; }
|
||||
.sidebar .brand .sub { color: var(--fg-dim); font-size: 11px; }
|
||||
.sidebar .brand img { width: 26px; height: 26px; flex: none; }
|
||||
.sidebar .brand strong {
|
||||
font-size: 15px; font-weight: 600;
|
||||
letter-spacing: 0.2px;
|
||||
color: var(--fg);
|
||||
}
|
||||
.sidebar nav { padding: 10px 0; flex: 1; overflow-y: auto; }
|
||||
.sidebar nav a {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
@@ -140,6 +153,16 @@ code, kbd { font-family: var(--mono); font-size: 12.5px;
|
||||
.sidebar .footer .status-value.err { color: var(--err); }
|
||||
.sidebar .footer .status-value.warn { color: var(--warn); }
|
||||
.sidebar .footer .footer-sub { color: var(--fg-dimmer); margin-top: 2px; }
|
||||
/* Persistent backend identity. Sits below the advertised URL so even
|
||||
when an operator has uploaded their own logo, "what is this" stays
|
||||
answerable from the bottom-left of every page. */
|
||||
.sidebar .footer .footer-version {
|
||||
margin-top: 8px; padding-top: 8px;
|
||||
border-top: 1px dashed var(--border-soft);
|
||||
color: var(--fg-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
/* ── Top bar ───────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -436,26 +459,29 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
||||
min-height: 480px;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
/* Terminal pane colours follow the active theme. Hard-coded hexes
|
||||
(#050505, #181818, #cfd6e2 etc.) were leaving the light-mode pane
|
||||
looking dark; we keep palette-aware vars instead so the toggle works. */
|
||||
.terminal .pane {
|
||||
flex: 1; overflow: auto;
|
||||
padding: 10px 14px;
|
||||
font-family: var(--mono); font-size: 12.5px; line-height: 1.5;
|
||||
color: #cfd6e2;
|
||||
color: var(--fg);
|
||||
white-space: pre-wrap; word-break: break-word;
|
||||
}
|
||||
.terminal .pane .lvl-error { color: var(--err); }
|
||||
.terminal .pane .lvl-warn { color: var(--warn); }
|
||||
.terminal .pane .lvl-info { color: #cfd6e2; }
|
||||
.terminal .pane .lvl-debug { color: #8b94a8; }
|
||||
.terminal .pane .lvl-trace { color: #5a6379; }
|
||||
.terminal .pane .ts { color: #5a6379; }
|
||||
.terminal .pane .tg { color: #7cd3ff; }
|
||||
.terminal .pane .lvl-info { color: var(--fg); }
|
||||
.terminal .pane .lvl-debug { color: var(--fg-dim); }
|
||||
.terminal .pane .lvl-trace { color: var(--fg-dimmer); }
|
||||
.terminal .pane .ts { color: var(--fg-dimmer); }
|
||||
.terminal .pane .tg { color: var(--accent); }
|
||||
.terminal .pane .echo { color: var(--accent); }
|
||||
.terminal .input-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 14px;
|
||||
background: #050505;
|
||||
border-top: 1px solid #181818;
|
||||
background: var(--bg-panel-2);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.terminal .input-row .prompt { color: var(--accent); font-family: var(--mono); }
|
||||
.terminal .input-row input {
|
||||
@@ -466,22 +492,91 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
||||
.terminal .toolbar {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
padding: 8px 14px;
|
||||
background: #050505;
|
||||
border-bottom: 1px solid #181818;
|
||||
background: var(--bg-panel-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; color: var(--fg-dim);
|
||||
}
|
||||
.terminal .toolbar .right { margin-left: auto; display: flex; gap: 6px; }
|
||||
.terminal .toolbar button {
|
||||
padding: 3px 9px; font-size: 11px;
|
||||
background: transparent; color: var(--fg-dim); border: 1px solid #181818;
|
||||
background: transparent; color: var(--fg-dim); border: 1px solid var(--border);
|
||||
font-weight: 500;
|
||||
}
|
||||
.terminal .toolbar button:hover { color: var(--fg); background: #181818; }
|
||||
.terminal .toolbar button:hover { color: var(--fg); background: var(--bg-elev); }
|
||||
|
||||
/* ── About card ─────────────────────────────────────────────────── */
|
||||
.about-hero { padding: 20px 24px; }
|
||||
.about-hero h2 { font-size: 22px; margin: 0 0 8px; color: var(--fg); }
|
||||
.about-hero .lead { color: var(--fg-dim); font-size: 14px; max-width: 60ch; }
|
||||
/* Span the full main column rather than capping at 60ch — the page is
|
||||
read at typical desktop widths and the cap was leaving the right two
|
||||
thirds of the panel awkwardly empty. */
|
||||
.about-hero .lead { color: var(--fg-dim); font-size: 14px; max-width: none; }
|
||||
.about-hero .who { margin-top: 18px; font-size: 13px; }
|
||||
.about-hero .who span { color: var(--fg-dim); }
|
||||
.about-hero .who strong { color: var(--accent); }
|
||||
.about-hero a { color: var(--accent); }
|
||||
|
||||
/* ── API reference (Settings → bottom) ─────────────────────────── */
|
||||
.api-ref { display: grid; gap: 18px; padding: 16px; }
|
||||
.api-ref .group h3 {
|
||||
margin: 0 0 8px; font-size: 13px; color: var(--fg-dim);
|
||||
text-transform: uppercase; letter-spacing: 0.8px;
|
||||
}
|
||||
.api-ref .ep {
|
||||
display: grid; grid-template-columns: 64px minmax(200px, 1fr) 2fr;
|
||||
gap: 12px; align-items: baseline;
|
||||
padding: 6px 0; border-top: 1px solid var(--border-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
.api-ref .ep:first-child { border-top: 0; }
|
||||
.api-ref .ep .method {
|
||||
font-family: var(--mono); font-weight: 600; font-size: 11px;
|
||||
padding: 2px 6px; border-radius: 4px;
|
||||
text-align: center; letter-spacing: 0.6px;
|
||||
}
|
||||
.api-ref .ep .method.get { background: color-mix(in srgb, var(--ok) 22%, transparent); color: var(--ok); }
|
||||
.api-ref .ep .method.post { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent); }
|
||||
.api-ref .ep .method.put { background: color-mix(in srgb, var(--warn) 22%, transparent); color: var(--warn); }
|
||||
.api-ref .ep .method.delete { background: color-mix(in srgb, var(--err) 22%, transparent); color: var(--err); }
|
||||
.api-ref .ep .path { font-family: var(--mono); color: var(--fg); word-break: break-all; }
|
||||
.api-ref .ep .desc { color: var(--fg-dim); }
|
||||
@media (max-width: 900px) {
|
||||
.api-ref .ep { grid-template-columns: 1fr; gap: 4px; }
|
||||
.api-ref .ep .method { justify-self: start; }
|
||||
}
|
||||
|
||||
/* ── Disk space card ───────────────────────────────────────────── */
|
||||
.diskbar {
|
||||
height: 10px; border-radius: 5px;
|
||||
background: var(--bg-elev);
|
||||
overflow: hidden; margin-top: 8px;
|
||||
}
|
||||
.diskbar .fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent-dim), var(--accent));
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.diskbar.warn .fill { background: var(--warn); }
|
||||
.diskbar.full .fill { background: var(--err); }
|
||||
.disk-meta { display: flex; gap: 14px; font-size: 12px; color: var(--fg-dim); margin-top: 8px; flex-wrap: wrap; }
|
||||
.disk-meta strong { color: var(--fg); font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ── Logo upload (Settings) ────────────────────────────────────── */
|
||||
.logo-preview {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 12px;
|
||||
background: var(--bg-panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.logo-preview .swatch {
|
||||
width: 56px; height: 56px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
flex: none;
|
||||
}
|
||||
.logo-preview .swatch img { max-width: 48px; max-height: 48px; }
|
||||
.logo-preview .info { flex: 1; min-width: 0; }
|
||||
.logo-preview .info .name { color: var(--fg); font-weight: 600; }
|
||||
.logo-preview .info .meta { color: var(--fg-dim); font-size: 12px; margin-top: 2px; }
|
||||
|
||||
+215
-3
@@ -321,8 +321,11 @@
|
||||
},
|
||||
|
||||
storage: async () => {
|
||||
const [isos, settings, nfsRes] = await Promise.all([
|
||||
const [isos, settings, nfsRes, disk] = await Promise.all([
|
||||
getJSON('/api/isos'), getJSON('/api/settings'), getJSON('/api/nfs'),
|
||||
getJSON('/api/storage/disk').catch(() => ({
|
||||
total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
|
||||
})),
|
||||
]);
|
||||
const mounts = nfsRes.mounts || [];
|
||||
|
||||
@@ -511,6 +514,30 @@
|
||||
const editorRow = el('tr', {style:'display:none'}, editorCells);
|
||||
refreshFieldVisibility();
|
||||
|
||||
// Type cell becomes an OS/Tools <select>. Default is OS (everything
|
||||
// routes through the family-detected installer submenu); switching
|
||||
// to Tools moves the ISO into the Tools submenu next to memtest.
|
||||
// The detected family stays visible as a small subtitle below the
|
||||
// selector so the operator hasn't lost that information.
|
||||
const catSel = el('select', {
|
||||
style: 'min-width:96px;background:var(--bg);color:var(--fg);' +
|
||||
'border:1px solid var(--border);border-radius:var(--radius);' +
|
||||
'padding:4px 8px;font:inherit;font-size:12px'
|
||||
}, [
|
||||
el('option', {value: 'os'}, 'OS'),
|
||||
el('option', {value: 'tools'}, 'Tools'),
|
||||
]);
|
||||
catSel.value = (i.category || 'os');
|
||||
catSel.onchange = async () => {
|
||||
const r = await putJSON('/api/isos/' + encodeURIComponent(i.id) + '/category',
|
||||
{ category: catSel.value });
|
||||
if (!r.ok) {
|
||||
const t = await r.text();
|
||||
alert('Category change failed: ' + t);
|
||||
}
|
||||
render('storage');
|
||||
};
|
||||
|
||||
const tr = el('tr', b.ok ? {} : {class: 'unbootable'}, [
|
||||
el('td', {}, [
|
||||
el('div', {style:'display:flex;align-items:center;gap:8px'}, [
|
||||
@@ -523,7 +550,11 @@
|
||||
!b.ok ? el('div', {class:'row-warn'}, '⚠ ' + b.reason)
|
||||
: (b.warn ? el('div', {class:'row-warn'}, '⚠ ' + b.warn) : null),
|
||||
]),
|
||||
el('td', {}, el('span', {class:'tag'}, familyLabel(i.introspection.family))),
|
||||
el('td', {}, [
|
||||
catSel,
|
||||
el('div', {style:'color:var(--fg-dim);font-size:11px;margin-top:4px'},
|
||||
familyLabel(i.introspection.family)),
|
||||
]),
|
||||
el('td', {class:'num'}, fmtBytes(i.size_bytes)),
|
||||
el('td', {},
|
||||
el('span', {class:'src-badge' + (isNfs ? ' nfs' : '')},
|
||||
@@ -609,7 +640,48 @@
|
||||
el('span'),
|
||||
])) : [el('div', {class:'empty'}, 'No NFS shares mounted.')];
|
||||
|
||||
// Disk-space card. Free + used + total for the volume hosting the
|
||||
// ISO directory, with a coloured bar. Warns at 80% and goes red at
|
||||
// 95% so the operator sees the runway shrinking before uploads
|
||||
// start failing with ENOSPC.
|
||||
const total = Number(disk.total_bytes || 0);
|
||||
const avail = Number(disk.available_bytes || 0);
|
||||
const used = Number(disk.used_bytes || 0);
|
||||
const pctUsed = total > 0 ? (used / total) * 100 : 0;
|
||||
let barClass = 'diskbar';
|
||||
if (pctUsed >= 95) barClass += ' full';
|
||||
else if (pctUsed >= 80) barClass += ' warn';
|
||||
const diskCard = el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'Disk space'),
|
||||
el('span', {class:'sub'},
|
||||
total > 0
|
||||
? (pctUsed.toFixed(1) + '% used')
|
||||
: 'unavailable'),
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
el('div', {style:'color:var(--fg-dim);font-size:12px;word-break:break-all'},
|
||||
disk.path ? ('Volume: ' + disk.path) : 'Volume path unknown'),
|
||||
el('div', {class: barClass},
|
||||
el('div', {class:'fill',
|
||||
style:'width:' + Math.min(100, pctUsed).toFixed(1) + '%'})),
|
||||
el('div', {class:'disk-meta'}, [
|
||||
el('span', {}, ['Used ', el('strong', {}, fmtBytes(used))]),
|
||||
el('span', {}, ['Free ', el('strong', {}, fmtBytes(avail))]),
|
||||
el('span', {}, ['Total ', el('strong', {}, fmtBytes(total))]),
|
||||
]),
|
||||
pctUsed >= 95
|
||||
? el('p', {class:'msg err', style:'margin-top:10px'},
|
||||
'⚠ Less than 5% free. Remove old ISOs or grow the volume before uploading more.')
|
||||
: (pctUsed >= 80
|
||||
? el('p', {class:'msg', style:'color:var(--warn);margin-top:10px'},
|
||||
'Volume is getting full. Consider pruning old ISOs.')
|
||||
: null),
|
||||
]),
|
||||
]);
|
||||
|
||||
return el('div', {class:'grid'}, [
|
||||
diskCard,
|
||||
el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Upload ISO')),
|
||||
el('div', {class:'body'}, [drop, file, prog, upMsg]),
|
||||
@@ -908,6 +980,142 @@
|
||||
return term;
|
||||
},
|
||||
|
||||
settings: async () => {
|
||||
const [status, docs] = await Promise.all([
|
||||
getJSON('/api/status'),
|
||||
getJSON('/api/docs').catch(() => ({ groups: [] })),
|
||||
]);
|
||||
const hasLogo = !!status.custom_logo;
|
||||
|
||||
// ── Identity & access placeholder.
|
||||
// We haven't shipped auth yet — but operators looking at this tab
|
||||
// need to see *where* it'll live so they're not surprised when an
|
||||
// upgrade lights up real config controls under the same heading.
|
||||
const accessCard = el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Identity & access')),
|
||||
el('div', {class:'body'}, [
|
||||
el('p', {class:'msg'},
|
||||
'LDAP, SSO (OIDC), and operator user management are planned ' +
|
||||
'for a future release. Today, OpenPXE assumes a single trusted ' +
|
||||
'operator on a flat L2 network and provides no built-in ' +
|
||||
'authentication on the WebUI or HTTP API. Run behind your ' +
|
||||
'identity-aware reverse proxy (Authentik, Authelia, oauth2-proxy) ' +
|
||||
'for a hardened deployment until first-class support lands here.'),
|
||||
el('div', {class:'form-row', style:'opacity:.55;pointer-events:none'}, [
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'LDAP server URL'),
|
||||
el('input', {type:'text', placeholder:'ldaps://dc.example.com:636', disabled:'disabled'}),
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'OIDC issuer'),
|
||||
el('input', {type:'text', placeholder:'https://idp.example.com/realms/openpxe', disabled:'disabled'}),
|
||||
]),
|
||||
el('label', {class:'field'}, [
|
||||
el('span', {class:'name'}, 'User management'),
|
||||
el('input', {type:'text', placeholder:'configurable in a future release', disabled:'disabled'}),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
|
||||
// ── Custom logo upload.
|
||||
// Single-file drop-zone; PNG/SVG/JPEG/WebP/GIF up to 2 MB.
|
||||
// Persisted as <work_dir>/branding/logo.<ext> and served from
|
||||
// /assets/logo.svg in preference to the bundled mark.
|
||||
const logoFile = el('input', {
|
||||
type:'file',
|
||||
accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif',
|
||||
style:'display:none', id:'logo-file',
|
||||
});
|
||||
const logoMsg = el('div', {class:'msg', style:'margin-top:10px'});
|
||||
const logoBust = '?v=' + Date.now(); // bust the browser cache after upload
|
||||
logoFile.onchange = async () => {
|
||||
if (!logoFile.files[0]) return;
|
||||
const f = logoFile.files[0];
|
||||
const fd = new FormData(); fd.append('file', f, f.name);
|
||||
logoMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…';
|
||||
logoMsg.className = 'msg';
|
||||
const r = await fetch('/api/branding/logo', {method:'POST', body: fd});
|
||||
if (r.ok) {
|
||||
logoMsg.textContent = 'Custom logo installed. Reloading…';
|
||||
logoMsg.className = 'msg ok';
|
||||
setTimeout(() => location.reload(), 600);
|
||||
} else {
|
||||
const t = await r.text();
|
||||
logoMsg.textContent = 'Upload failed: ' + t;
|
||||
logoMsg.className = 'msg err';
|
||||
}
|
||||
};
|
||||
const logoCard = el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'Branding')),
|
||||
el('div', {class:'body'}, [
|
||||
el('p', {class:'msg', style:'margin-bottom:14px'},
|
||||
'Override the top-left brand mark with your own logo. Up to 2 MB; ' +
|
||||
'PNG, SVG, JPEG, WebP, or GIF. The original OpenPXE version is ' +
|
||||
'always shown in the bottom-left for support purposes.'),
|
||||
el('div', {class:'logo-preview'}, [
|
||||
el('div', {class:'swatch'},
|
||||
el('img', {src: '/assets/logo.svg' + logoBust, alt:'current logo'})),
|
||||
el('div', {class:'info'}, [
|
||||
el('div', {class:'name'}, hasLogo ? 'Custom logo (uploaded)' : 'Default OpenPXE mark'),
|
||||
el('div', {class:'meta'},
|
||||
hasLogo
|
||||
? 'Operator-uploaded; served from <work_dir>/branding/.'
|
||||
: 'Bundled rainbow-horizon mark. Upload an image to override.'),
|
||||
]),
|
||||
el('div', {style:'display:flex;gap:8px;flex-wrap:wrap'}, [
|
||||
el('button', {onclick: () => logoFile.click()},
|
||||
hasLogo ? 'Replace logo' : 'Upload logo'),
|
||||
hasLogo
|
||||
? el('button', {class:'danger', onclick: async () => {
|
||||
if (!confirm('Remove custom logo and revert to the OpenPXE mark?')) return;
|
||||
const r = await fetch('/api/branding/logo', {method:'DELETE'});
|
||||
if (r.ok || r.status === 204) {
|
||||
logoMsg.textContent = 'Reverted to default mark. Reloading…';
|
||||
logoMsg.className = 'msg ok';
|
||||
setTimeout(() => location.reload(), 500);
|
||||
} else {
|
||||
const t = await r.text();
|
||||
logoMsg.textContent = 'Clear failed: ' + t;
|
||||
logoMsg.className = 'msg err';
|
||||
}
|
||||
}}, 'Remove')
|
||||
: null,
|
||||
]),
|
||||
]),
|
||||
logoFile, logoMsg,
|
||||
]),
|
||||
]);
|
||||
|
||||
// ── API reference (always at the bottom of Settings).
|
||||
// Sourced from /api/docs so the hand-curated list stays the
|
||||
// single source of truth and the UI doesn't need its own copy
|
||||
// baked into the JS bundle.
|
||||
const groups = docs.groups || [];
|
||||
const apiCard = el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'API reference'),
|
||||
el('span', {class:'sub'},
|
||||
groups.reduce((n, g) => n + (g.endpoints || []).length, 0) + ' endpoints'),
|
||||
]),
|
||||
el('div', {class:'api-ref'},
|
||||
groups.length
|
||||
? groups.map(g => el('div', {class:'group'}, [
|
||||
el('h3', {}, g.name),
|
||||
...(g.endpoints || []).map(ep => el('div', {class:'ep'}, [
|
||||
el('span', {class:'method ' + (ep.method || 'get').toLowerCase()},
|
||||
ep.method || 'GET'),
|
||||
el('span', {class:'path'}, ep.path || '?'),
|
||||
el('span', {class:'desc'}, ep.summary || ''),
|
||||
])),
|
||||
]))
|
||||
: el('div', {class:'empty'},
|
||||
'No API documentation returned by /api/docs.')),
|
||||
]);
|
||||
|
||||
return el('div', {class:'grid'}, [accessCard, logoCard, apiCard]);
|
||||
},
|
||||
|
||||
about: async () => {
|
||||
const status = await getJSON('/api/status');
|
||||
return el('div', {class:'card'}, [
|
||||
@@ -920,7 +1128,10 @@
|
||||
el('div', {class:'who'}, [
|
||||
el('span', {}, 'Developer: '), el('strong', {}, 'Miles Ward'), el('br'),
|
||||
el('span', {}, 'Version: '), el('strong', {}, status.version || '?'), el('br'),
|
||||
el('span', {}, 'Base URL: '), el('strong', {}, status.public_base_url),
|
||||
el('span', {}, 'Base URL: '), el('strong', {}, status.public_base_url), el('br'),
|
||||
el('span', {}, 'Docs: '),
|
||||
el('a', {href:'https://openpxe.com/', target:'_blank', rel:'noopener noreferrer'},
|
||||
'https://openpxe.com/'),
|
||||
]),
|
||||
el('p', {class:'msg', style:'margin-top:18px'},
|
||||
'iPXE is an internal implementation detail. Everything the firmware ' +
|
||||
@@ -945,6 +1156,7 @@
|
||||
storage: 'Storage',
|
||||
hosts: 'Hosts',
|
||||
terminal: 'Terminal',
|
||||
settings: 'Settings',
|
||||
about: 'About',
|
||||
};
|
||||
|
||||
|
||||
@@ -26,11 +26,8 @@
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<img src="/assets/logo.svg" alt="" />
|
||||
<div>
|
||||
<strong>OpenPXE</strong>
|
||||
<div class="sub">v<span data-bind="version">0.4.1</span></div>
|
||||
</div>
|
||||
<img src="/assets/logo.svg" alt="OpenPXE" />
|
||||
<strong>OpenPXE</strong>
|
||||
</div>
|
||||
<nav>
|
||||
<a data-view="dashboard" class="active">Dashboard</a>
|
||||
@@ -48,6 +45,7 @@
|
||||
<span class="count" data-bind="host_count">0</span>
|
||||
</a>
|
||||
<a data-view="terminal">Terminal</a>
|
||||
<a data-view="settings">Settings</a>
|
||||
<a data-view="about">About</a>
|
||||
</nav>
|
||||
<div class="footer">
|
||||
@@ -58,6 +56,10 @@
|
||||
</div>
|
||||
<div class="footer-sub">Advertised to clients</div>
|
||||
<code>{{BASE_URL}}</code>
|
||||
<!-- The brand badge at the top can be overridden by operator-uploaded
|
||||
logos; keep "OpenPXE v…" pinned in the footer so the backend
|
||||
identity is always visible regardless of branding. -->
|
||||
<div class="footer-version">OpenPXE v<span data-bind="version">0.4.4</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user