Files
Calix/firmware-pusher/index.html
T

326 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GenieACS Firmware Pusher</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d1117; --surface: #161b22; --border: #30363d;
--text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
--ok: #3fb950; --err: #f85149; --warn: #d29922;
--radius: 8px;
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
.container { width: 100%; max-width: 720px; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 28px; }
.card h1 { font-size: 1.4rem; margin-bottom: 6px; }
.card p { color: var(--muted); font-size: 0.85rem; margin-bottom: 24px; }
.version-tag { background: var(--border); color: var(--muted); font-size: 0.7rem; padding: 2px 8px; border-radius: 20px; display: inline-block; margin-bottom: 20px; }
/* ── Login ─────────────────────────────────────────────── */
.login-screen { display: flex; flex-direction: column; gap: 14px; }
.login-screen h2 { font-size: 1.1rem; }
label { font-size: 0.8rem; color: var(--muted); display: block; margin-bottom: 6px; }
input { width: 100%; padding: 10px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 0.95rem; outline: none; }
input:focus { border-color: var(--accent); }
button { padding: 10px 20px; background: var(--accent); color: #fff; border: none; border-radius: var(--radius); cursor: pointer; font-size: 0.9rem; font-weight: 600; }
button:hover { opacity: 0.85; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.loading { position: relative; color: transparent; }
button.loading::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, transparent 25%, rgba(255,255,255,0.3) 50%, transparent 75%); background-size: 200% 100%; animation: shimmer 1s infinite; }
@keyframes shimmer { to { background-position: -200% 0; } }
/* ── Main UI ───────────────────────────────────────────── */
.main-ui { display: none; }
.main-ui.visible { display: block; }
.connected-as { font-size: 0.8rem; color: var(--muted); margin-bottom: 20px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 20px; }
.box { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
.box h3 { font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
select { width: 100%; padding: 8px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-size: 0.9rem; }
select option { background: var(--surface); }
.box-meta { font-size: 0.75rem; color: var(--muted); margin-top: 8px; min-height: 18px; }
.status-line { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 20px; }
#fwCount { font-size: 0.8rem; color: var(--muted); }
.btn-row { display: flex; gap: 10px; align-items: center; }
#btnPush { background: var(--ok); flex: 1; }
#btnPush:hover:not(:disabled) { background: #2ea043; }
#btnLogout { background: transparent; border: 1px solid var(--border); color: var(--muted); padding: 10px 16px; }
.status-box { font-size: 0.8rem; min-height: 48px; padding: 10px 14px; border-radius: var(--radius); margin-bottom: 16px; background: rgba(255,255,255,0.03); border: 1px solid var(--border); word-break: break-all; }
.status-box.ok { border-color: rgba(63,185,80,0.4); color: var(--ok); }
.status-box.err { border-color: rgba(248,81,73,0.4); color: var(--err); }
#statusBox { display: none; }
#statusBox.visible { display: block; }
@media (max-width: 540px) { .grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="container">
<div class="card">
<!-- Login screen -->
<div id="loginScreen" class="login-screen">
<div class="version-tag">GenieACS Firmware Pusher v1.0</div>
<h2>Sign in</h2>
<div id="loginStatus"></div>
<div>
<label for="inUser">Username</label>
<input id="inUser" type="text" placeholder="admin" value="admin" autocomplete="username">
</div>
<div>
<label for="inPass">Password</label>
<input id="inPass" type="password" placeholder="GenieACS password" autocomplete="current-password">
</div>
<button id="btnConnect" onclick="connect()">Connect</button>
</div>
<!-- Main UI -->
<div id="mainUI" class="main-ui">
<div class="version-tag">GenieACS Firmware Pusher v1.0</div>
<div class="connected-as" id="connectedAs"></div>
<div id="statusBox"></div>
<div class="grid">
<div class="box">
<h3>Device</h3>
<select id="selDevice"><option value="">Loading…</option></select>
<div class="box-meta" id="devInfo"></div>
</div>
<div class="box">
<h3>Firmware <span id="fwCount"></span></h3>
<select id="selFw"><option value="">Loading…</option></select>
<div class="box-meta" id="fwInfo"></div>
</div>
</div>
<div class="status-line">
<button id="btnPush" disabled onclick="pushFirmware()">Push Firmware</button>
<button id="btnLogout" onclick="logout()">Logout</button>
</div>
</div>
</div>
</div>
<script>
// ── Config ───────────────────────────────────────────────
// NBI is empty string → all API calls go through nginx on the same host (no CORS, no external auth)
const NBI = '';
// ── Auth helpers ───────────────────────────────────────────
function getCreds() {
return { user: sessionStorage.getItem('nbi_user'), pass: sessionStorage.getItem('nbi_pass') };
}
function b64auth() {
const { user, pass } = getCreds();
return 'Basic ' + btoa(user + ':' + pass);
}
function logout() {
sessionStorage.removeItem('nbi_user');
sessionStorage.removeItem('nbi_pass');
location.reload();
}
// ── API helpers ────────────────────────────────────────────
async function api(path, method = 'GET', body = null) {
const opts = {
method,
headers: { 'Authorization': b64auth(), 'Content-Type': 'application/json' }
};
if (body) opts.body = JSON.stringify(body);
const r = await fetch(NBI + path, opts);
if (r.status === 401) throw new Error('AUTH');
if (r.status === 204) return null;
return r.json();
}
// ── Login ──────────────────────────────────────────────────
async function connect() {
const user = document.getElementById('inUser').value.trim();
const pass = document.getElementById('inPass').value;
if (!user || !pass) { showStatus('loginStatus', 'Please enter username and password.', 'err'); return; }
const btn = document.getElementById('btnConnect');
btn.classList.add('loading'); btn.disabled = true;
clearStatus('loginStatus');
try {
// Test auth
const test = await fetch(NBI + '/devices?projection=_id', {
headers: { 'Authorization': 'Basic ' + btoa(user + ':' + pass) }
});
if (test.status === 401) throw new Error('Invalid credentials');
if (!test.ok) throw new Error('Server error: ' + test.status);
sessionStorage.setItem('nbi_user', user);
sessionStorage.setItem('nbi_pass', pass);
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('mainUI').classList.add('visible');
document.getElementById('connectedAs').textContent = 'Connected as ' + user;
await loadDevices();
await loadFiles();
} catch(e) {
if (e.message === 'AUTH' || e.message === 'Invalid credentials') {
showStatus('loginStatus', 'Invalid username or password.', 'err');
} else {
showStatus('loginStatus', 'Cannot reach GenieACS NBI API: ' + e.message, 'err');
}
} finally {
btn.classList.remove('loading'); btn.disabled = false;
}
}
// ── Load devices ───────────────────────────────────────────
async function loadDevices() {
const sel = document.getElementById('selDevice');
const info = document.getElementById('devInfo');
sel.innerHTML = '<option value="">Loading…</option>';
try {
const devs = await api('/devices?projection=_id,InternetGatewayDevice.DeviceInfo.SoftwareVersion,InternetGatewayDevice.ManagementServer.ConnectionRequestURL');
sel.innerHTML = '';
if (!devs || devs.length === 0) {
sel.innerHTML = '<option value="">No devices found</option>';
return;
}
devs.sort((a, b) => a._id.toLowerCase().localeCompare(b._id.toLowerCase()));
for (const d of devs) {
const serial = d._id.includes('-') ? d._id.split('-').pop() : d._id;
const sw = d.InternetGatewayDevice?.DeviceInfo?.SoftwareVersion?._value || '—';
const url = d.InternetGatewayDevice?.ManagementServer?.ConnectionRequestURL?._value || '';
const online = url && !url.includes('://0.') ? '🟢' : '⚫';
const opt = document.createElement('option');
opt.value = d._id;
opt.textContent = `${online} ${serial} (${sw})`;
sel.appendChild(opt);
}
sel.addEventListener('change', onDevChange);
onDevChange();
} catch(e) {
sel.innerHTML = '<option value="">Error loading devices</option>';
info.textContent = e.message;
}
}
function onDevChange() {
const btn = document.getElementById('btnPush');
const sel = document.getElementById('selDevice');
const info = document.getElementById('devInfo');
const fw = document.getElementById('selFw');
info.textContent = '';
btn.disabled = !sel.value || !fw.value;
}
// ── Load firmware files ───────────────────────────────────
async function loadFiles() {
const sel = document.getElementById('selFw');
const count = document.getElementById('fwCount');
const info = document.getElementById('fwInfo');
sel.innerHTML = '<option value="">Loading…</option>';
try {
const files = await api('/files');
sel.innerHTML = '';
if (!files || files.length === 0) {
sel.innerHTML = '<option value="">No firmware files uploaded</option>';
count.textContent = '0';
return;
}
count.textContent = files.length + ' file' + (files.length !== 1 ? 's' : '');
// Sort by version descending (use metadata.version or fall back to filename)
files.sort((a, b) => {
const va = (a.metadata?.version || a.filename || '').split('.').map(Number);
const vb = (b.metadata?.version || b.filename || '').split('.').map(Number);
for (let i = 0; i < Math.max(va.length, vb.length); i++) {
const na = va[i] || 0, nb = vb[i] || 0;
if (na !== nb) return nb - na;
}
return 0;
});
for (const f of files) {
const ver = f.metadata?.version || '';
const fname = f.filename || f._id || '';
const sz = f.length ? fmtSize(f.length) : '';
const opt = document.createElement('option');
opt.value = fname;
opt.textContent = ver ? `R${ver}${sz ? ' (' + sz + ')' : ''}` : fname;
opt.dataset.version = ver;
opt.dataset.size = f.length || '';
sel.appendChild(opt);
}
sel.addEventListener('change', onFwChange);
onFwChange();
} catch(e) {
sel.innerHTML = '<option value="">Error loading files</option>';
info.textContent = e.message;
}
}
function onFwChange() {
const btn = document.getElementById('btnPush');
const sel = document.getElementById('selDevice');
const fw = document.getElementById('selFw');
const info = document.getElementById('fwInfo');
info.textContent = '';
btn.disabled = !sel.value || !fw.value;
if (fw.value) {
const opt = fw.options[fw.selectedIndex];
if (opt && opt.dataset.version) info.textContent = 'Version: ' + opt.dataset.version;
}
}
// ── Push firmware ─────────────────────────────────────────
async function pushFirmware() {
const devId = document.getElementById('selDevice').value;
const fwFile = document.getElementById('selFw').value;
const btn = document.getElementById('btnPush');
const status = document.getElementById('statusBox');
if (!devId || !fwFile) return;
btn.disabled = true; btn.textContent = 'Pushing…';
status.className = 'status-box'; status.textContent = '';
status.classList.add('visible');
try {
const task = await api('/devices/' + encodeURIComponent(devId) + '/tasks', 'POST', {
name: 'FirmwareDownloadImage',
arguments: { File: fwFile }
});
status.innerHTML = `✓ Task created — refresh device list after 12 min to see new version`;
status.className = 'status-box ok';
setTimeout(() => { status.classList.remove('visible'); }, 8000);
} catch(e) {
if (e.message === 'AUTH') {
status.textContent = 'Session expired. Please reconnect.';
status.className = 'status-box err';
setTimeout(() => logout(), 2000);
} else {
status.textContent = 'Failed to push firmware: ' + e.message;
status.className = 'status-box err';
}
} finally {
btn.disabled = false; btn.textContent = 'Push Firmware';
}
}
// ── Status helpers ─────────────────────────────────────────
function showStatus(id, html, type) {
const el = document.getElementById(id);
if (!el) return;
el.innerHTML = html;
el.style.color = type === 'err' ? 'var(--err)' : type === 'ok' ? 'var(--ok)' : '';
}
function clearStatus(id) { showStatus(id, '', ''); }
function fmtSize(bytes) {
if (!bytes) return '';
const units = ['B','KB','MB','GB'];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; }
return bytes.toFixed(1) + ' ' + units[i];
}
// ── Init: re-attach if already logged in ───────────────────
if (sessionStorage.getItem('nbi_user')) {
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('mainUI').classList.add('visible');
document.getElementById('connectedAs').textContent = 'Connected as ' + sessionStorage.getItem('nbi_user');
loadDevices().then(loadFiles).catch(() => logout());
}
</script>
</body>
</html>