v0.5.0: Wake-on-LAN, webhook notifications, Advanced tab, login logo, update check
Closes the v0.4.x chapter — NFS works end to end. Five additions: ## Wake-on-LAN (Hosts → Bound hosts) - New core::wol module: parse any MAC form, build the 102-byte magic packet, broadcast it. No special capability needed (ephemeral source port; SO_BROADCAST). Sends to the limited broadcast (255.255.255.255) AND the server's own subnet broadcast (computed from advertised IP + detected mask) so it reaches the right VLAN. - POST /api/hosts/:mac/wol — only fires for *bound* MACs (404 otherwise) so it's not an open packet sprayer. - Bound-hosts table grows a "Wake" button with inline Waking…/Sent ✓ state. ## Webhook notifications (Advanced tab) - core::notify: NotifyConfig + NotifyStore (notify.json), one provider at a time — Slack / Discord / Teams (incoming-webhook JSON) or SMTP. SMTP password is persisted but redacted on GET behind a __keep__ sentinel the UI round-trips so the secret never leaves the box. - http-api::notify: delivery — reqwest POST for chat (provider-shaped bodies), lettre for SMTP (rustls, STARTTLS/implicit TLS, no plaintext). 10s timeout; every send is best-effort. - GET/PUT /api/notify, POST /api/notify/test. - Fired fire-and-forget on the canonical "machine is imaging" boot event and on WoL — never blocks the boot path. ## UI: Advanced tab - New nav item. Holds the webhook config card and the API reference block (relocated from the bottom of Settings). ## UI: login/setup logo (FleetDM treatment) - /api/me now returns has_custom_logo + logo_rev (public bootstrap). The login, setup, and connection-error cards render the uploaded logo full-width with the "OpenPXE" wordmark dropped — matching the sidebar. ## About: update check + licenses - "Check for updates" button → GET /api/updates/check queries the Gitea releases API (derived from CARGO_PKG_REPOSITORY) and compares to the running version. Strictly on-demand — no background polling, keeps the air-gapped promise. - License card documents the MIT OR Apache-2.0 dual license with links, plus a note on bundled components (iPXE GPLv2/UBDL, samba, wimtools). Deps: lettre (SMTP, rustls) + reqwest gains the json feature. Both rustls so the static musl binary stays OpenSSL-free. Tests: 179 passing (+notify round-trip/redaction, webhook validation, WoL-unbound-404, WoL packet loopback, version-compare). clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1eb41288c3
commit
fc99973ac3
@@ -627,6 +627,14 @@ tr.unbootable td:first-child { border-left: 3px solid var(--warn); }
|
||||
}
|
||||
.auth-card .brand-row img { width: 32px; height: 32px; flex: none; }
|
||||
.auth-card .brand-row .name { font-size: 17px; font-weight: 600; letter-spacing: 0.2px; color: var(--fg); }
|
||||
/* v0.5.0: FleetDM-style custom logo on the login/setup card — the
|
||||
uploaded logo spans the card header and the "OpenPXE" wordmark is
|
||||
dropped (the logo is the brand). Matches the sidebar treatment. */
|
||||
.auth-card .brand-row.has-custom-logo { justify-content: center; gap: 0; margin-bottom: 22px; }
|
||||
.auth-card .brand-row.has-custom-logo img {
|
||||
width: auto; height: 52px; max-width: 240px;
|
||||
object-fit: contain; object-position: center;
|
||||
}
|
||||
.auth-card h2 {
|
||||
margin: 0 0 6px; font-size: 16px; font-weight: 600; color: var(--fg);
|
||||
}
|
||||
|
||||
+260
-32
@@ -973,18 +973,37 @@
|
||||
}
|
||||
}}, 'Bind MAC to target');
|
||||
|
||||
const rows = hosts.map(h => el('tr', {}, [
|
||||
el('td', {class:'mono'}, h.mac),
|
||||
el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
|
||||
el('td', {class:'mono'}, h.target),
|
||||
el('td', {}, fmtAgo(h.updated_at)),
|
||||
el('td', {style:'text-align:right'},
|
||||
el('button', {class:'danger', onclick: async () => {
|
||||
if (!confirm('Remove binding for ' + h.mac + '?')) return;
|
||||
await fetch('/api/hosts/' + encodeURIComponent(h.mac), {method:'DELETE'});
|
||||
render('hosts');
|
||||
}}, 'Remove')),
|
||||
]));
|
||||
const rows = hosts.map(h => {
|
||||
// v0.5.0: Wake-on-LAN. Only shown for bound hosts (this whole
|
||||
// table is bound hosts). The button reports its own state inline
|
||||
// so there's no shared toast to thread through.
|
||||
const wakeBtn = el('button', {class:'ghost', onclick: async () => {
|
||||
wakeBtn.disabled = true;
|
||||
const original = wakeBtn.textContent;
|
||||
wakeBtn.textContent = 'Waking…';
|
||||
try {
|
||||
const r = await postJSON('/api/hosts/' + encodeURIComponent(h.mac) + '/wol', {});
|
||||
wakeBtn.textContent = r.ok ? 'Sent ✓' : 'Failed';
|
||||
} catch (e) {
|
||||
wakeBtn.textContent = 'Failed';
|
||||
}
|
||||
setTimeout(() => { wakeBtn.textContent = original; wakeBtn.disabled = false; }, 2500);
|
||||
}}, 'Wake');
|
||||
return el('tr', {}, [
|
||||
el('td', {class:'mono'}, h.mac),
|
||||
el('td', {}, h.label || el('span', {class:'tag'}, '(unlabeled)')),
|
||||
el('td', {class:'mono'}, h.target),
|
||||
el('td', {}, fmtAgo(h.updated_at)),
|
||||
el('td', {style:'text-align:right;white-space:nowrap'}, [
|
||||
wakeBtn,
|
||||
el('button', {class:'danger', style:'margin-left:8px', onclick: async () => {
|
||||
if (!confirm('Remove binding for ' + h.mac + '?')) return;
|
||||
await fetch('/api/hosts/' + encodeURIComponent(h.mac), {method:'DELETE'});
|
||||
render('hosts');
|
||||
}}, 'Remove'),
|
||||
]),
|
||||
]);
|
||||
});
|
||||
|
||||
const table = hosts.length
|
||||
? el('table', {}, [
|
||||
@@ -1185,9 +1204,8 @@
|
||||
},
|
||||
|
||||
settings: async () => {
|
||||
const [status, docs, me, sso] = await Promise.all([
|
||||
const [status, me, sso] = await Promise.all([
|
||||
getJSON('/api/status'),
|
||||
getJSON('/api/docs').catch(() => ({ groups: [] })),
|
||||
getJSON('/api/me').catch(() => ({})),
|
||||
getJSON('/api/sso').catch(() => ({
|
||||
enabled:false, idp_name:'', metadata:'', metadata_url:'',
|
||||
@@ -1489,10 +1507,133 @@
|
||||
]),
|
||||
]);
|
||||
|
||||
// ── 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.
|
||||
// v0.5.0: the API reference moved to the Advanced tab; Settings
|
||||
// now holds just account / SSO / branding.
|
||||
return el('div', {class:'grid'}, [accountCard, ssoCard, logoCard]);
|
||||
},
|
||||
|
||||
// v0.5.0: Advanced settings — webhook/email notifications, and the
|
||||
// API reference (relocated from the bottom of Settings).
|
||||
advanced: async () => {
|
||||
const [notify, docs] = await Promise.all([
|
||||
getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
|
||||
getJSON('/api/docs').catch(() => ({ groups: [] })),
|
||||
]);
|
||||
|
||||
// ── Notification config ──
|
||||
const nMsg = el('div', {class:'msg', style:'margin-top:12px'});
|
||||
const nEnabled = el('input', {type:'checkbox'}); nEnabled.checked = !!notify.enabled;
|
||||
const nKind = el('select', {}, [
|
||||
el('option', {value:'slack'}, 'Slack'),
|
||||
el('option', {value:'discord'}, 'Discord'),
|
||||
el('option', {value:'teams'}, 'Microsoft Teams'),
|
||||
el('option', {value:'smtp'}, 'Email (SMTP)'),
|
||||
]);
|
||||
nKind.value = notify.kind || 'slack';
|
||||
const nWebhook = el('input', {type:'text', placeholder:'https://hooks.slack.com/services/…',
|
||||
value: notify.webhook_url || ''});
|
||||
// SMTP fields.
|
||||
const sHost = el('input', {type:'text', placeholder:'smtp.example.com', value: notify.smtp_host || ''});
|
||||
const sPort = el('input', {type:'number', value: String(notify.smtp_port || 587)});
|
||||
const sUser = el('input', {type:'text', placeholder:'(optional)', value: notify.smtp_username || ''});
|
||||
// GET returns the password as a redaction sentinel when one is
|
||||
// set; show an empty field with an "(unchanged)" placeholder and
|
||||
// let collectNotify() re-send the sentinel so the stored secret
|
||||
// is preserved unless the operator types a new one.
|
||||
const hasStoredPass = !!notify.smtp_password;
|
||||
const sPass = el('input', {type:'password',
|
||||
placeholder: hasStoredPass ? '•••••• (unchanged)' : '', value: ''});
|
||||
const sFrom = el('input', {type:'text', placeholder:'[email protected]', value: notify.smtp_from || ''});
|
||||
const sTo = el('input', {type:'text', placeholder:'[email protected]', value: notify.smtp_to || ''});
|
||||
const sTls = el('input', {type:'checkbox'}); sTls.checked = !!notify.smtp_implicit_tls;
|
||||
|
||||
const webhookBlock = el('div', {class:'form-row'}, [
|
||||
el('label', {class:'field', style:'grid-column:1 / -1'}, [
|
||||
el('span', {class:'name'}, 'Incoming webhook URL'),
|
||||
nWebhook,
|
||||
el('span', {class:'hint'},
|
||||
'Slack/Discord/Teams all use an "incoming webhook" URL you create in that app.'),
|
||||
]),
|
||||
]);
|
||||
const smtpBlock = el('div', {}, [
|
||||
el('div', {class:'form-row cols-2'}, [
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'SMTP host'), sHost]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Port'), sPort]),
|
||||
]),
|
||||
el('div', {class:'form-row cols-2', style:'margin-top:12px'}, [
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Username (optional)'), sUser]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Password'), sPass]),
|
||||
]),
|
||||
el('div', {class:'form-row cols-2', style:'margin-top:12px'}, [
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'From'), sFrom]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'To'), sTo]),
|
||||
]),
|
||||
el('label', {class:'check', style:'margin-top:12px'}, [
|
||||
sTls, el('span', {}, 'Implicit TLS (port 465). Leave off for STARTTLS (587).'),
|
||||
]),
|
||||
]);
|
||||
// Toggle which provider block shows.
|
||||
const syncKind = () => {
|
||||
const smtp = nKind.value === 'smtp';
|
||||
webhookBlock.style.display = smtp ? 'none' : '';
|
||||
smtpBlock.style.display = smtp ? '' : 'none';
|
||||
};
|
||||
nKind.addEventListener('change', syncKind);
|
||||
syncKind();
|
||||
|
||||
const collectNotify = () => ({
|
||||
enabled: nEnabled.checked,
|
||||
kind: nKind.value,
|
||||
webhook_url: nWebhook.value,
|
||||
smtp_host: sHost.value,
|
||||
smtp_port: Number(sPort.value) || 587,
|
||||
smtp_username: sUser.value,
|
||||
// Empty field + a stored password → send the sentinel so the
|
||||
// server keeps it. Otherwise send whatever was typed (a new
|
||||
// password, or empty to clear when none was stored).
|
||||
smtp_password: (sPass.value === '' && hasStoredPass) ? '__keep__' : sPass.value,
|
||||
smtp_from: sFrom.value,
|
||||
smtp_to: sTo.value,
|
||||
smtp_implicit_tls: sTls.checked,
|
||||
});
|
||||
|
||||
const saveBtn = el('button', {style:'margin-top:16px', onclick: async () => {
|
||||
nMsg.textContent = 'Saving…'; nMsg.className = 'msg';
|
||||
const r = await putJSON('/api/notify', collectNotify());
|
||||
if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('advanced'); }
|
||||
else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; }
|
||||
}}, 'Save notification settings');
|
||||
const testBtn = el('button', {class:'ghost', style:'margin-top:16px;margin-left:8px',
|
||||
onclick: async () => {
|
||||
nMsg.textContent = 'Sending test…'; nMsg.className = 'msg';
|
||||
// Save first so the test uses exactly what's on screen.
|
||||
const rs = await putJSON('/api/notify', collectNotify());
|
||||
if (!rs.ok) { nMsg.textContent = 'Save failed: ' + (await rs.text()); nMsg.className = 'msg err'; return; }
|
||||
const r = await postJSON('/api/notify/test', {});
|
||||
if (r.ok) { nMsg.textContent = 'Test notification sent — check your channel/inbox.'; nMsg.className = 'msg ok'; }
|
||||
else { nMsg.textContent = 'Test failed: ' + (await r.text()); nMsg.className = 'msg err'; }
|
||||
}}, 'Send test');
|
||||
|
||||
const notifyCard = el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
el('h2', {}, 'Webhook notifications'),
|
||||
el('span', {class:'sub'}, notify.enabled ? 'enabled' : 'disabled'),
|
||||
]),
|
||||
el('div', {class:'body'}, [
|
||||
el('p', {class:'msg', style:'margin-bottom:14px'},
|
||||
'Get pinged when a machine PXE-boots an image, a deployment is assigned, ' +
|
||||
'or a host is woken. One provider at a time — pick yours, paste the URL ' +
|
||||
'(or SMTP details), and Send test.'),
|
||||
el('div', {class:'form-row cols-2'}, [
|
||||
el('label', {class:'check'}, [nEnabled, el('span', {}, 'Enable notifications')]),
|
||||
el('label', {class:'field'}, [el('span', {class:'name'}, 'Provider'), nKind]),
|
||||
]),
|
||||
el('div', {style:'margin-top:14px'}, [webhookBlock, smtpBlock]),
|
||||
saveBtn, testBtn, nMsg,
|
||||
]),
|
||||
]);
|
||||
|
||||
// ── API reference (relocated from Settings) ──
|
||||
const groups = docs.groups || [];
|
||||
const apiCard = el('div', {class:'card'}, [
|
||||
el('header', {}, [
|
||||
@@ -1515,12 +1656,41 @@
|
||||
'No API documentation returned by /api/docs.')),
|
||||
]);
|
||||
|
||||
return el('div', {class:'grid'}, [accountCard, ssoCard, logoCard, apiCard]);
|
||||
return el('div', {class:'grid'}, [notifyCard, apiCard]);
|
||||
},
|
||||
|
||||
about: async () => {
|
||||
const status = await getJSON('/api/status');
|
||||
return el('div', {class:'card'}, [
|
||||
|
||||
// ── Check for updates ──
|
||||
const updMsg = el('div', {class:'msg', style:'margin-top:10px'});
|
||||
const updBtn = el('button', {onclick: async () => {
|
||||
updMsg.textContent = 'Checking the OpenPXE release feed…'; updMsg.className = 'msg';
|
||||
try {
|
||||
const r = await getJSON('/api/updates/check');
|
||||
if (r.error) {
|
||||
updMsg.replaceChildren(document.createTextNode('Couldn’t check: ' + r.error));
|
||||
updMsg.className = 'msg err';
|
||||
} else if (r.update_available) {
|
||||
const parts = [document.createTextNode('Update available: '),
|
||||
el('strong', {}, r.latest), document.createTextNode(' (you’re on ' + r.current + '). ')];
|
||||
if (r.html_url) {
|
||||
parts.push(el('a', {href:r.html_url, target:'_blank', rel:'noopener noreferrer'},
|
||||
'View release →'));
|
||||
}
|
||||
updMsg.replaceChildren(...parts);
|
||||
updMsg.className = 'msg ok';
|
||||
} else {
|
||||
updMsg.replaceChildren(document.createTextNode(
|
||||
'You’re up to date — running the latest release (' + r.current + ').'));
|
||||
updMsg.className = 'msg ok';
|
||||
}
|
||||
} catch (e) {
|
||||
updMsg.textContent = 'Couldn’t reach the release feed (offline?).'; updMsg.className = 'msg err';
|
||||
}
|
||||
}}, 'Check for updates');
|
||||
|
||||
const heroCard = el('div', {class:'card'}, [
|
||||
el('div', {class:'about-hero'}, [
|
||||
el('h2', {}, 'OpenPXE'),
|
||||
el('p', {class:'lead'},
|
||||
@@ -1535,6 +1705,7 @@
|
||||
el('a', {href:'https://openpxe.com/', target:'_blank', rel:'noopener noreferrer'},
|
||||
'https://openpxe.com/'),
|
||||
]),
|
||||
el('div', {style:'margin-top:18px'}, [updBtn, updMsg]),
|
||||
el('p', {class:'msg', style:'margin-top:18px'},
|
||||
'iPXE is an internal implementation detail. Everything the firmware ' +
|
||||
'executes is generated from the settings on these tabs — there is no ' +
|
||||
@@ -1547,6 +1718,46 @@
|
||||
'flip "testsigning" on a target machine.'),
|
||||
]),
|
||||
]);
|
||||
|
||||
// ── Licenses ──
|
||||
const licenseCard = el('div', {class:'card'}, [
|
||||
el('header', {}, el('h2', {}, 'License')),
|
||||
el('div', {class:'body'}, [
|
||||
el('p', {class:'msg', style:'margin-bottom:12px'}, [
|
||||
'OpenPXE is dual-licensed under ',
|
||||
el('strong', {}, 'MIT'), document.createTextNode(' OR '),
|
||||
el('strong', {}, 'Apache License 2.0'),
|
||||
document.createTextNode(
|
||||
' — use it under whichever fits your organization (SPDX: MIT OR Apache-2.0).'),
|
||||
]),
|
||||
el('div', {class:'license-grid', style:'display:grid;grid-template-columns:1fr 1fr;gap:14px'}, [
|
||||
el('div', {}, [
|
||||
el('h3', {style:'margin:0 0 6px'}, 'MIT License'),
|
||||
el('p', {class:'msg', style:'font-size:12px'},
|
||||
'Permission is hereby granted, free of charge, to any person obtaining a copy ' +
|
||||
'of this software and associated documentation files, to deal in the Software ' +
|
||||
'without restriction… THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.'),
|
||||
el('a', {href:'https://opensource.org/license/mit', target:'_blank', rel:'noopener noreferrer'},
|
||||
'Full MIT text →'),
|
||||
]),
|
||||
el('div', {}, [
|
||||
el('h3', {style:'margin:0 0 6px'}, 'Apache License 2.0'),
|
||||
el('p', {class:'msg', style:'font-size:12px'},
|
||||
'Licensed under the Apache License, Version 2.0. Includes an express grant of ' +
|
||||
'patent rights from contributors. Distributed on an "AS IS" BASIS, WITHOUT ' +
|
||||
'WARRANTIES OR CONDITIONS OF ANY KIND.'),
|
||||
el('a', {href:'https://www.apache.org/licenses/LICENSE-2.0', target:'_blank', rel:'noopener noreferrer'},
|
||||
'Full Apache 2.0 text →'),
|
||||
]),
|
||||
]),
|
||||
el('p', {class:'msg', style:'margin-top:14px;font-size:12px;opacity:.8'},
|
||||
'Bundled components keep their own licenses: iPXE (GPLv2 / UBDL), Samba smbclient, ' +
|
||||
'wimtools, and the Rust crates in this build. See the source repository for the ' +
|
||||
'complete NOTICE.'),
|
||||
]),
|
||||
]);
|
||||
|
||||
return el('div', {class:'grid'}, [heroCard, licenseCard]);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1559,6 +1770,7 @@
|
||||
hosts: 'Hosts',
|
||||
terminal: 'Terminal',
|
||||
settings: 'Settings',
|
||||
advanced: 'Advanced',
|
||||
about: 'About',
|
||||
};
|
||||
|
||||
@@ -1663,6 +1875,26 @@
|
||||
let chipsInterval = null;
|
||||
let authScreenEl = null;
|
||||
let ssoConfig = null;
|
||||
// v0.5.0: branding bootstrap for the pre-auth screens, populated from
|
||||
// /api/me (public). Lets the login/setup cards render the same
|
||||
// FleetDM-style full-width custom logo the dashboard sidebar uses.
|
||||
let brandInfo = { has_custom_logo: false, logo_rev: 0 };
|
||||
|
||||
// Brand row for the auth screens. With a custom logo uploaded: the
|
||||
// logo spans the card, no "OpenPXE" wordmark (the logo is the brand).
|
||||
// Default: bundled mark + "OpenPXE".
|
||||
function authBrandRow() {
|
||||
const src = '/assets/logo.svg?r=' + (brandInfo.logo_rev || 0);
|
||||
if (brandInfo.has_custom_logo) {
|
||||
return el('div', {class:'brand-row has-custom-logo'}, [
|
||||
el('img', {src, alt:'logo'}),
|
||||
]);
|
||||
}
|
||||
return el('div', {class:'brand-row'}, [
|
||||
el('img', {src, alt:''}),
|
||||
el('div', {class:'name'}, 'OpenPXE'),
|
||||
]);
|
||||
}
|
||||
|
||||
function teardownAuthScreen() {
|
||||
if (authScreenEl && authScreenEl.parentNode) {
|
||||
@@ -1719,10 +1951,7 @@
|
||||
submit.textContent = 'Sign in';
|
||||
}
|
||||
}}, [
|
||||
el('div', {class:'brand-row'}, [
|
||||
el('img', {src:'/assets/logo.svg', alt:''}),
|
||||
el('div', {class:'name'}, 'OpenPXE'),
|
||||
]),
|
||||
authBrandRow(),
|
||||
el('h2', {}, 'Sign in'),
|
||||
el('p', {class:'lede'}, 'Enter your administrator credentials. Forgot them? SSH to the host and remove work_dir/auth.json — the next launch will re-prompt for setup.'),
|
||||
el('label', {class:'field'}, [
|
||||
@@ -1787,10 +2016,7 @@
|
||||
submit.textContent = 'Create administrator';
|
||||
}
|
||||
}}, [
|
||||
el('div', {class:'brand-row'}, [
|
||||
el('img', {src:'/assets/logo.svg', alt:''}),
|
||||
el('div', {class:'name'}, 'OpenPXE'),
|
||||
]),
|
||||
authBrandRow(),
|
||||
el('h2', {}, 'First-run setup'),
|
||||
el('p', {class:'lede'}, 'Welcome. Create the administrator account that will own this OpenPXE deployment. Additional users come in through SSO later.'),
|
||||
el('label', {class:'field'}, [
|
||||
@@ -1906,15 +2132,17 @@
|
||||
let me;
|
||||
try {
|
||||
me = await fetch('/api/me').then(r => r.json());
|
||||
// Capture branding so the auth cards render the custom logo.
|
||||
brandInfo = {
|
||||
has_custom_logo: !!me.has_custom_logo,
|
||||
logo_rev: me.logo_rev || 0,
|
||||
};
|
||||
} catch (e) {
|
||||
// /api/me is unauthenticated in every state — if we can't reach
|
||||
// it the server is genuinely down, not an auth problem.
|
||||
document.body.appendChild(el('div', {class:'auth-screen'},
|
||||
el('div', {class:'auth-card'}, [
|
||||
el('div', {class:'brand-row'}, [
|
||||
el('img', {src:'/assets/logo.svg', alt:''}),
|
||||
el('div', {class:'name'}, 'OpenPXE'),
|
||||
]),
|
||||
authBrandRow(),
|
||||
el('h2', {}, 'Connection error'),
|
||||
el('p', {class:'lede'}, 'Could not reach the OpenPXE server. Refresh once it is back up.'),
|
||||
])));
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
</a>
|
||||
<a data-view="terminal">Terminal</a>
|
||||
<a data-view="settings">Settings</a>
|
||||
<a data-view="advanced">Advanced</a>
|
||||
<a data-view="about">About</a>
|
||||
</nav>
|
||||
<div class="footer">
|
||||
|
||||
Reference in New Issue
Block a user