// OpenPXE web UI — vanilla JS, no build step, no framework, no network
// dependencies. Uses fetch() + EventSource only.
//
// 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
// framework at 3 AM. Plain dumb DOM construction is the design.

(function () {
  const $ = (sel, root = document) => root.querySelector(sel);
  const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
  const el = (tag, attrs = {}, children = []) => {
    const e = document.createElement(tag);
    for (const [k, v] of Object.entries(attrs)) {
      if (k === 'class') e.className = v;
      else if (k === 'html') e.innerHTML = v;
      else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2), v);
      else if (v !== false && v != null) e.setAttribute(k, v);
    }
    for (const c of [].concat(children)) {
      if (c == null || c === false) continue;
      if (typeof c === 'string') e.appendChild(document.createTextNode(c));
      else e.appendChild(c);
    }
    return e;
  };
  const fmtBytes = (n) => {
    const u = ['B','KB','MB','GB','TB']; let i = 0;
    while (n >= 1024 && i < u.length-1) { n /= 1024; i++; }
    return n.toFixed(n >= 10 || i === 0 ? 0 : 1) + ' ' + u[i];
  };
  const fmtAgo = (ts) => {
    const d = (ts instanceof Date) ? ts : new Date(ts);
    if (isNaN(d.getTime())) return '-';
    const ds = Math.floor((Date.now() - d.getTime()) / 1000);
    if (ds < 0)     return 'in ' + Math.abs(ds) + 's';
    if (ds < 60)    return ds + 's ago';
    if (ds < 3600)  return Math.floor(ds/60) + 'm ago';
    if (ds < 86400) return Math.floor(ds/3600) + 'h ago';
    return d.toLocaleString();
  };
  const fmtUptime = (secs) => {
    secs = Math.max(0, Math.floor(secs || 0));
    const h = Math.floor(secs/3600), m = Math.floor((secs%3600)/60), s = secs%60;
    if (h) return h + 'h ' + m + 'm';
    if (m) return m + 'm ' + s + 's';
    return s + 's';
  };
  const familyLabel = (f) => ({
    debian_ubuntu: 'Debian / Ubuntu', rhel_fedora: 'RHEL family',
    open_suse: 'openSUSE', arch: 'Arch', alpine: 'Alpine',
    windows_pe: 'Windows', unknown: 'Unknown',
  }[f] || f);
  const archLabel = (a) => {
    if (!a) return '—';
    if (typeof a === 'string') return a;
    if (a && typeof a === 'object') {
      if ('Unknown' in a) return 'unknown(0x' + a.Unknown.toString(16) + ')';
      return JSON.stringify(a);
    }
    return String(a);
  };

  // ── network helpers ──────────────────────────────────────────────
  // All three helpers funnel through a 401 detector. When the server
  // says "auth required" mid-session — most commonly because the
  // operator's session expired while the tab was idle — we transparently
  // swap the SPA out for the login screen rather than letting the UI
  // throw a generic error.
  function maybeAuthBounce(r) {
    if (r && r.status === 401) {
      // Render the login screen without reloading; any in-flight
      // promises still return their values to the original caller.
      showAuthScreen('login');
    }
    return r;
  }
  async function getJSON(url) {
    const r = maybeAuthBounce(await fetch(url));
    if (!r.ok) throw new Error(url + ': ' + r.status);
    return r.json();
  }
  async function putJSON(url, body) {
    return maybeAuthBounce(await fetch(url, {
      method:'PUT',
      headers:{'Content-Type':'application/json'},
      body: JSON.stringify(body),
    }));
  }
  async function postJSON(url, body) {
    return maybeAuthBounce(await fetch(url, {
      method:'POST',
      headers:{'Content-Type':'application/json'},
      body: JSON.stringify(body),
    }));
  }

  // 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: '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') + ' deploying')),
        el('div', {class: 'bar-track'},
          el('div', {class: 'bar-fill', style: 'width:' + pct + '%'})),
      ]),
    ]);
    return root;
  }

  // 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. Shared by the Storage tab and the Dashboard (v0.4.68).
  function diskSpaceCard(disk) {
    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';
    return 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),
      ]),
    ]);
  }

  // Categorize an ISO row's "bootable now" status — drives the amber
  // tint borrowed from Bootimus v0.1.62. Returns {ok, reason}.
  function bootability(iso, settings) {
    const fam = iso.introspection.family;
    // v0.5.8: Windows ISOs boot via iPXE HTTP sanboot of the raw image —
    // no Settings toggle, no SMB, no size limit. Always bootable.
    if (fam === 'windows_pe') {
      return { ok: true };
    }
    if (!iso.introspection.kernel_path) {
      // Not Windows and no Linux kernel/initrd detected. Small images can
      // still try the sanboot fallback; large ones almost certainly aren't
      // network-bootable installers (e.g. appliance bundles like VMware
      // VCSA) — flag them clearly instead of with a Linux-centric message.
      if (iso.size_bytes > 1.5 * 1024 * 1024 * 1024) {
        return { ok: false, reason: "not a recognized network-bootable installer (no Windows or Linux boot files found) — this image can't be PXE-booted" };
      }
      return { ok: true, warn: 'no kernel detected — sanboot fallback may not work' };
    }
    return { ok: true };
  }

  // v0.5.2: pretty label for an unattended file's detected kind.
  function unattendedKindLabel(k) {
    return ({
      kickstart: 'Kickstart', preseed: 'Preseed', autoinstall: 'Autoinstall',
      answer_file: 'Answer file', unknown: 'Unknown',
    })[k] || (k || 'Unknown');
  }

  // v0.5.2: build the shared "deployment profile" field group — auto
  // hostname, auto IP, and an unattended-file picker — reused by the
  // Hosts pin form and the Queue "Profile" modal. `files` is the
  // /api/unattended list; `profile` seeds the current values. Returns the
  // wrapper element plus a `read()` that yields the API body shape.
  function buildProfileFields(profile, files, layoutClass) {
    profile = profile || {};
    files = files || [];
    const hostnameInput = el('input', {type:'text', spellcheck:'false',
      placeholder:'e.g. node-7', value: profile.auto_hostname || ''});
    const ipInput = el('input', {type:'text', spellcheck:'false',
      placeholder:'e.g. 10.0.0.7', value: profile.auto_ip || ''});
    const sel = el('select', {},
      [el('option', {value:''}, '— none —')].concat(
        files.map(f => el('option', {value: f.id},
          f.filename + ' · ' + unattendedKindLabel(f.kind)))));
    sel.value = profile.unattended_file || '';
    const wrap = el('div', {class: layoutClass || 'form-row cols-3'}, [
      el('label', {class:'field'}, [
        el('span', {class:'name'}, 'Auto hostname (optional)'), hostnameInput]),
      el('label', {class:'field'}, [
        el('span', {class:'name'}, 'Auto IP address (optional)'), ipInput]),
      el('label', {class:'field'}, [
        el('span', {class:'name'}, 'Unattended file'), sel]),
    ]);
    return {
      wrap,
      read() {
        return {
          auto_hostname: hostnameInput.value.trim() || null,
          auto_ip: ipInput.value.trim() || null,
          unattended_file: sel.value || null,
        };
      },
    };
  }

  // v0.5.2: minimal modal overlay. `onSave(msgEl)` runs on Save and may
  // return a falsy value to keep the modal open (e.g. on validation
  // error) or anything truthy to close it.
  function openModal(titleText, contentEls, onSave) {
    const overlay = el('div', {class:'modal-overlay'});
    const close = () => { if (overlay.parentNode) overlay.parentNode.removeChild(overlay); };
    const msg = el('div', {class:'msg', style:'margin-top:10px'});
    const cancelBtn = el('button', {class:'ghost', type:'button', onclick: close}, 'Cancel');
    const saveBtn = el('button', {class:'submit', type:'button'}, 'Save');
    saveBtn.onclick = async () => {
      saveBtn.disabled = true;
      try {
        const ok = await onSave(msg);
        if (ok) close();
      } finally {
        saveBtn.disabled = false;
      }
    };
    overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
    document.addEventListener('keydown', function esc(e) {
      if (e.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
    });
    overlay.appendChild(el('div', {class:'modal-box'}, [
      el('h2', {}, titleText),
      ...(Array.isArray(contentEls) ? contentEls : [contentEls]),
      msg,
      el('div', {class:'modal-actions'}, [cancelBtn, saveBtn]),
    ]));
    document.body.appendChild(overlay);
    return { close };
  }

  // ── views ────────────────────────────────────────────────────────
  const views = {
    dashboard: async () => {
      const status = await getJSON('/api/status');
      const isos = await getJSON('/api/isos');
      const clients = (await getJSON('/api/clients')).clients || [];
      const entries = (await getJSON('/api/queue')).entries || [];
      // v0.4.68: surface the same disk-space card the Storage tab shows,
      // so operators see capacity at a glance from the landing page.
      // Tolerate the endpoint being unavailable (e.g. statvfs failure)
      // the same way the Storage tab does.
      const disk = await getJSON('/api/storage/disk').catch(() => ({
        total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
      }));

      const ipxeOk = (status.ipxe_assets || []).length > 0;
      const stats = el('div', {class: 'statstrip'}, [
        el('div', {class: 'card'}, el('div', {class: 'stat'}, [
          el('div', {class: 'label'}, 'Server status'),
          el('div', {class: 'value', style: 'font-size:18px;color:' + (ipxeOk ? 'var(--ok)' : 'var(--err)')},
              ipxeOk ? 'Ready' : 'Not ready'),
          el('div', {class: 'trend'},
            ipxeOk ? 'Bootloaders bundled, accepting clients'
                   : 'No iPXE binaries bundled'),
        ])),
        el('div', {class: 'card'}, [
          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 in queue'),
          ]),
          queueProgressWidget(status.imaging_count || 0, status.queue_count || 0),
        ]),
        el('div', {class: 'card'}, el('div', {class: 'stat'}, [
          el('div', {class: 'label'}, 'Images available'),
          el('div', {class: 'value'}, String(isos.length)),
          el('div', {class: 'trend'},
            isos.filter(i => i.introspection.family === 'windows_pe').length + ' Windows · ' +
            isos.filter(i => i.introspection.family !== 'windows_pe').length + ' Linux  ·  ' +
            // v0.4.67+v0.5.5: count all remote-share protocols. Label
            // generically since operators may use any mix of SMB/NFS/SFTP.
            ((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) +
            ' remote share' +
            (((status.smb_share_reachable || 0) + (status.nfs_share_reachable || 0) + (status.sftp_share_reachable || 0)) === 1 ? '' : 's')),
        ])),
        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'}, 'OpenPXE ' + status.version),
        ])),
      ]);

      // Recent connections — the operator's at-a-glance "who tried to
      // boot" log. Use last_seen desc (already sorted by API).
      const recent = clients.slice(0, 8);
      const recentRows = recent.map(c => {
        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 + ' in queue');
        return el('tr', {}, [
          el('td', {class: 'mono'}, c.mac),
          el('td', {}, c.last_ip ? String(c.last_ip) : '-'),
          el('td', {}, el('span', {class:'tag arch'}, archLabel(c.arch))),
          el('td', {}, fmtAgo(c.last_seen)),
          el('td', {}, status),
        ]);
      });
      const recentBlock = el('div', {class: 'card'}, [
        el('header', {}, [
          el('h2', {}, 'Recent connections'),
          el('span', {class: 'sub'}, clients.length + ' total'),
        ]),
        recent.length
          ? el('table', {}, [
              el('thead', {}, el('tr', {}, [
                el('th',{},'MAC'), el('th',{},'IP'), el('th',{},'Arch'),
                el('th',{},'Last seen'), el('th',{},'Status'),
              ])),
              el('tbody', {}, recentRows),
            ])
          : el('div', {class: 'empty'}, 'No PXE clients have contacted this server yet.'),
      ]);

      // At-a-glance pool of problem images — Bootimus-style early warning.
      const settings = status.settings;
      const problems = isos.map(i => ({i, b: bootability(i, settings)})).filter(x => !x.b.ok);
      const problemsBlock = problems.length ? el('div', {class:'card'}, [
        el('header', {}, [el('h2', {}, 'Images that won\'t boot with current settings')]),
        el('div', {class:'body'},
          problems.map(({i, b}) => el('div', {class:'row-warn'},
            '⚠ ' + i.filename + ' — ' + b.reason)))
      ]) : null;

      return el('div', {class:'grid'}, [stats, diskSpaceCard(disk), recentBlock, problemsBlock].filter(Boolean));
    },

    network: async () => {
      const net = await getJSON('/api/network');
      const dns = el('input', {type:'text', value: net.dns_server || '',
                                placeholder: 'Optional, e.g. 8.8.8.8 or 1.1.1.1'});
      const msg = el('div', {class:'msg'});

      const save = el('button', {onclick: async () => {
        const r = await putJSON('/api/network', { dns_server: dns.value });
        if (r.status === 204) { msg.textContent = 'Saved.'; msg.className = 'msg ok'; }
        else { msg.textContent = 'Save failed: ' + r.status; msg.className = 'msg err'; }
      }}, 'Save');

      const networkCard = el('div', {class:'card'}, [
        el('header', {}, el('h2', {}, 'Network')),
        el('div', {class:'body'}, [
          el('div', {class:'kv'}, [
            el('div', {class:'k'}, 'Server IP'),
            el('div', {class:'v'}, net.server_ip || '?'),
            el('div', {class:'k'}, 'NIC name'),
            el('div', {class:'v'}, net.nic_name || '(auto-detect failed)'),
            el('div', {class:'k'}, 'Subnet mask'),
            el('div', {class:'v'}, net.subnet_mask || '?'),
            el('div', {class:'k'}, 'Gateway'),
            el('div', {class:'v'}, net.gateway || '?'),
            el('div', {class:'k'}, 'Public base URL'),
            el('div', {class:'v'}, net.public_base_url),
          ]),
          el('p', {class:'msg'},
            'Server IP, NIC, mask, and gateway are auto-detected at startup. ' +
            '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'},
              '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.'),
          ]),
          save, msg,
        ]),
      ]);

      return el('div', {class:'grid'}, [networkCard]);
    },

    queue: async () => {
      const [{ entries = [] }, isos, unattRes] = await Promise.all([
        getJSON('/api/queue'), getJSON('/api/isos'),
        getJSON('/api/unattended').catch(() => ({ files: [] })),
      ]);
      const unattendedFiles = unattRes.files || [];
      const targets = isos.flatMap(i => i.boot_entries.map(e => ({
        id: e.id, title: e.title + '  — ' + familyLabel(i.introspection.family)
      })));
      const pick = el('select', {},
        [el('option', {value: ''}, '— choose an image —')]
        .concat(targets.map(t => el('option', {value: t.id}, t.title)))
      );
      const launch = el('button', {}, 'Launch for all waiting');
      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/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; }
        msg.textContent = 'Launched ' + j.assigned + ' client' + (j.assigned===1?'':'s') + ' → ' + j.target;
        msg.className = 'msg ok';
        render('queue');
      };

      const track = entries.length
        ? el('div', {class:'queue-track'},
            entries.map(g => {
              const prof = g.profile || {};
              const hasProfile = prof.auto_hostname || prof.auto_ip || prof.unattended_file;
              const profSummary = hasProfile
                ? el('div', {class:'meta', style:'margin-top:2px'},
                    '⚙ ' + [
                      prof.auto_hostname ? 'host ' + prof.auto_hostname : null,
                      prof.auto_ip ? 'ip ' + prof.auto_ip : null,
                      prof.unattended_file ? 'unattended: ' + prof.unattended_file : null,
                    ].filter(Boolean).join(' · '))
                : null;
              return el('div', {class:'queue-row' + (g.assigned_target ? ' assigned' : '')}, [
                el('div', {class:'pos'}, '#' + g.position),
                el('div', {}, [
                  el('div', {class:'mac'}, g.mac),
                  el('div', {class:'meta'},
                    (g.ip ? String(g.ip) + ' · ' : '') + archLabel(g.arch) + ' · joined ' + fmtAgo(g.joined_at)),
                  profSummary,
                ]),
                el('div', {}, g.assigned_target
                  ? el('span', {class:'tag ok'}, '→ ' + g.assigned_target)
                  : el('span', {class:'tag accent'}, 'waiting')),
                // v0.5.2: per-device deployment profile (auto hostname/IP +
                // unattended file), same fields as a Hosts pin.
                el('button', {class: hasProfile ? 'accent' : 'ghost', onclick: () => {
                  const fields = buildProfileFields(prof, unattendedFiles, 'form-row');
                  openModal('Deployment profile · ' + g.mac, [
                    el('p', {class:'msg', style:'margin-bottom:12px'},
                      'On assignment this device boots with the chosen unattended ' +
                      'file; {{HOSTNAME}}/{{IP}}/{{MAC}} are filled into the answer file.'),
                    fields.wrap,
                  ], async (msg) => {
                    const r = await putJSON('/api/queue/' + encodeURIComponent(g.id) + '/profile', fields.read());
                    if (r.ok) { render('queue'); return true; }
                    msg.textContent = 'Save failed: ' + (await r.text());
                    msg.className = 'msg err';
                    return false;
                  });
                }}, 'Profile'),
                el('button', {class:'ghost', onclick: async () => {
                  await fetch('/api/queue/' + encodeURIComponent(g.id), {method:'DELETE'});
                  render('queue');
                }}, 'Release'),
              ]);
            })
          )
        : el('div', {class:'empty'},
            'No clients queued. Boot a client and choose "Queued Deployment" in the PXE menu.');

      const imaging = entries.filter(g => g.assigned_target).length;

      return el('div', {class:'grid'}, [
        el('div', {class:'card'}, [
          el('header', {}, el('h2', {}, 'Status')),
          queueProgressWidget(imaging, entries.length),
        ]),
        el('div', {class:'card'}, [
          el('header', {}, el('h2', {}, 'Launch image for queued clients')),
          el('div', {class:'body'}, [
            el('label', {class:'field'}, [
              el('span', {class:'name'}, 'Target image'),
              pick,
              el('span', {class:'hint'},
                'Selecting "Launch" starts every waiting client on the chosen image simultaneously.'),
            ]),
            launch, msg,
          ]),
        ]),
        el('div', {class:'card'}, [
          el('header', {}, [
            el('h2', {}, 'Queue positions'),
            el('span', {class:'sub'}, entries.length + ' waiting'),
          ]),
          el('div', {class:'body'}, track),
        ]),
      ]);
    },

    storage: async () => {
      // v0.4.65: kernel-mount NFS replaced with userspace SMB via
      // smbclient — works in any container regardless of host kernel
      // modules or capabilities.
      // v0.4.67: NFSv3 added back as an in-process Rust client
      // (nfs3_client crate). Both protocols available side-by-side;
      // operators pick whichever their NAS prefers.
      const [isos, settings, smbRes, nfsRes, sftpRes, disk, unattRes] = await Promise.all([
        getJSON('/api/isos'), getJSON('/api/settings'),
        getJSON('/api/smb-shares'),
        getJSON('/api/nfs-shares'),
        getJSON('/api/sftp-shares'),
        getJSON('/api/storage/disk').catch(() => ({
          total_bytes: 0, available_bytes: 0, used_bytes: 0, path: '?',
        })),
        getJSON('/api/unattended').catch(() => ({ files: [] })),
      ]);
      const shares = smbRes.shares || [];
      const nfsShares = nfsRes.shares || [];
      const sftpShares = sftpRes.shares || [];
      const unattendedFiles = unattRes.files || [];

      // ── Upload card ──
      const drop = el('div', {class:'drop', id:'drop'}, [
        el('div', {}, ['Drop an ', el('strong', {}, '.iso'), ' here, or click to choose.']),
        el('div', {style:'font-size:12px;margin-top:6px'},
           'Linux + Windows installers auto-detected on upload. Streaming, no 502s on big files.'),
      ]);
      const file = el('input', {type:'file', accept:'.iso,application/octet-stream',
                                style:'display:none', id:'file'});
      const prog = el('div', {class:'progress', id:'prog'}, el('div', {class:'bar', id:'bar'}));
      const upMsg = el('div', {class:'msg', id:'upmsg'});
      // v0.5.8: cancel button — shown only while an upload is in flight.
      const cancelUpload = el('button', {class:'danger', type:'button',
        style:'display:none;margin-top:12px', id:'cancel-upload'}, 'Cancel upload');

      drop.onclick = () => file.click();
      drop.addEventListener('dragover', e => { e.preventDefault(); drop.classList.add('hover'); });
      drop.addEventListener('dragleave', () => drop.classList.remove('hover'));
      drop.addEventListener('drop', e => {
        e.preventDefault(); drop.classList.remove('hover');
        if (e.dataTransfer.files[0]) upload(e.dataTransfer.files[0]);
      });
      file.onchange = () => { if (file.files[0]) upload(file.files[0]); };

      // Chunked upload telemetry. The old browser path posted one huge
      // multipart body, which left operators staring at 0% when a reverse
      // proxy buffered or rejected the request before OpenPXE saw it. This
      // path writes small raw chunks; each acknowledged chunk advances the
      // bar and leaves a visible .partial file in the ISO directory.
      async function upload(f) {
        const started = Date.now();
        const bar = $('#bar');
        const setStatus = (text, cls) => { upMsg.textContent = text; upMsg.className = 'msg ' + (cls || ''); };
        const update = (loaded, total, phase) => {
          const pct = total > 0 ? Math.min(100, (loaded / total) * 100) : 100;
          bar.style.width = pct.toFixed(1) + '%';
          const elapsed = Math.max(0.001, (Date.now() - started) / 1000);
          const rate = loaded > 0 ? loaded / elapsed : 0;
          const remain = rate > 0 ? (total - loaded) / rate : 0;
          setStatus(
            phase + ' ' + f.name + ' - ' +
            fmtBytes(loaded) + ' of ' + fmtBytes(total) +
            ' (' + pct.toFixed(1) + '%, ' + fmtBytes(rate) + '/s' +
            (remain > 0 ? ', ' + Math.ceil(remain) + 's left' : '') + ')');
        };
        const failText = async (r) => {
          const text = (await r.text()).slice(0, 240);
          let hint = '';
          if (r.status === 413)      hint = ' - body too large. A proxy likely rejected this chunk.';
          else if (r.status === 502) hint = ' - bad gateway. Proxy lost the upstream mid-stream.';
          else if (r.status === 504) hint = ' - gateway timeout. Try the LAN IP directly.';
          else if (r.status === 409) hint = ' - name conflict or offset mismatch. Remove the old ISO and retry.';
          return 'HTTP ' + r.status + ' ' + text + hint;
        };

        let uploadId = null;
        // v0.5.8: cancel + leave-page guard. The AbortController stops the
        // in-flight chunk; the beforeunload listener warns the operator
        // that navigating away aborts the upload (the server-side partial
        // is then cleaned up by the DELETE in the catch below).
        const ac = new AbortController();
        let canceled = false;
        const warnLeave = (e) => { e.preventDefault(); e.returnValue = ''; return ''; };
        window.addEventListener('beforeunload', warnLeave);
        cancelUpload.style.display = '';
        cancelUpload.onclick = () => { canceled = true; ac.abort(); };
        setStatus('Preparing upload for ' + f.name + ' (' + fmtBytes(f.size) + ')');
        prog.classList.add('active');
        bar.style.width = '1%';

        try {
          const begin = await postJSON('/api/uploads', {
            filename: f.name,
            size_bytes: f.size,
          });
          if (!begin.ok) throw new Error(await failText(begin));
          const session = await begin.json();
          uploadId = session.upload_id;
          const chunkSize = Math.max(1024 * 1024, Number(session.chunk_size || 8 * 1024 * 1024));

          let offset = Number(session.offset || 0);
          let finished = null;
          do {
            const end = Math.min(offset + chunkSize, f.size);
            const complete = end >= f.size;
            const r = await fetch('/api/uploads/' + encodeURIComponent(uploadId), {
              method: 'PUT',
              headers: {
                'x-openpxe-upload-offset': String(offset),
                'x-openpxe-upload-complete': complete ? 'true' : 'false',
              },
              body: f.slice(offset, end),
              signal: ac.signal,
            });
            if (!r.ok) throw new Error(await failText(r));
            const j = await r.json();
            offset = Number(j.offset || end);
            update(offset, f.size, complete ? 'Analyzing' : 'Uploading');
            if (j.complete) finished = j.iso || true;
          } while (!finished);

          setStatus('Uploaded and analyzed: ' + f.name + ' (' + fmtBytes(f.size) + ')', 'ok');
          render('storage');
        } catch (err) {
          if (uploadId) {
            try { await fetch('/api/uploads/' + encodeURIComponent(uploadId), {method: 'DELETE'}); }
            catch {}
          }
          if (canceled || (err && err.name === 'AbortError')) {
            setStatus('Upload canceled — partial file discarded.', '');
          } else {
            setStatus('Upload failed: ' + (err && err.message ? err.message : String(err)), 'err');
          }
        } finally {
          window.removeEventListener('beforeunload', warnLeave);
          cancelUpload.style.display = 'none';
          cancelUpload.onclick = null;
          prog.classList.remove('active');
          if (!upMsg.className.includes('ok')) bar.style.width = '0';
        }
      }

      // ── ISO table (mixed local + SMB) ──
      // Each row gets a "Password" cell that toggles a small inline
      // editor (a checkbox + a password field + Save button) inside the
      // *next* row of the table. Keeps the markup flat and avoids the
      // overhead of a real modal.
      const rowsAndEditors = [];
      // v0.5.8: list Available images alphabetically by filename
      // (case-insensitive, natural numeric order) instead of newest-first.
      const sortedIsos = [...isos].sort((a, b) =>
        (a.filename || '').localeCompare(b.filename || '', undefined, { sensitivity: 'base', numeric: true }));
      sortedIsos.forEach(i => {
        const b = bootability(i, settings);
        // v0.4.65: SMB userspace consumer (smbclient).
        // v0.4.67: NFS back as in-process Rust client (nfs3_client).
        // Both render with the same "remote" badge colour — they
        // share the same "on a remote share, can't be deleted from
        // here" semantics in the UI.
        const isSmb = i.source && i.source.kind === 'smb';
        const isNfs = i.source && i.source.kind === 'nfs';
        const isRemote = isSmb || isNfs;
        const protectedNow = !!i.password_hash;

        // The inline editor row is hidden by default; the Password
        // button toggles its `display`. Pre-built so toggle is cheap.
        const pwCheck = el('input', {type:'checkbox'});
        pwCheck.checked = protectedNow;
        const pwInput = el('input', {
          type: 'password', spellcheck: 'false',
          autocomplete: 'new-password', autocapitalize: 'off',
          placeholder: protectedNow ? '(unchanged — type to replace)' : 'choose a password',
        });
        const pwInputWrap = el('label', {class:'field', style:'flex:1;margin:0'}, [
          el('span', {class:'name'}, 'Password'),
          pwInput,
        ]);
        // Toggle the password field's visibility off when the checkbox
        // is unchecked, so the operator's intent is unambiguous on Save.
        const refreshFieldVisibility = () => {
          pwInputWrap.style.display = pwCheck.checked ? '' : 'none';
        };
        pwCheck.onchange = refreshFieldVisibility;
        const pwMsg = el('div', {class:'msg', style:'margin-top:6px'});
        const pwSave = el('button', {style:'flex:none', onclick: async () => {
          let resp;
          if (pwCheck.checked) {
            // Empty input + previously protected = keep the old password
            // (operator just toggled the box on but didn't type). We
            // detect this by sending the API only when the field has
            // content; otherwise no-op + show hint.
            if (!pwInput.value && !protectedNow) {
              pwMsg.textContent = 'Enter a password to enable.';
              pwMsg.className = 'msg err';
              return;
            }
            if (!pwInput.value && protectedNow) {
              pwMsg.textContent = 'Password unchanged.';
              pwMsg.className = 'msg ok';
              return;
            }
            resp = await putJSON(
              '/api/isos/' + encodeURIComponent(i.id) + '/password',
              { password: pwInput.value });
          } else {
            resp = await fetch(
              '/api/isos/' + encodeURIComponent(i.id) + '/password',
              {method: 'DELETE'});
          }
          if (resp.ok || resp.status === 204) {
            // Wipe the input field before re-rendering so the
            // plaintext doesn't sit in DOM longer than necessary.
            pwInput.value = '';
            render('storage');
          } else {
            const t = await resp.text();
            pwMsg.textContent = 'Save failed: ' + t;
            pwMsg.className = 'msg err';
          }
        }}, 'Save password');

        const editorCells = el('td', {colspan: '7', style:'background:var(--bg-panel-2);padding:14px 18px'}, [
          el('div', {style:'display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap'}, [
            el('label', {class:'check', style:'flex:none;margin:0'}, [
              pwCheck,
              el('span', {}, 'Password protect this image'),
            ]),
            pwInputWrap,
            pwSave,
          ]),
          el('div', {class:'msg', style:'margin-top:8px;font-size:11.5px'},
            'Operators booting this ISO will be prompted on the PXE client. ' +
            'Stored bcrypt-hashed; the plaintext never leaves the request.'),
          pwMsg,
        ]);
        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'}, [
              protectedNow ? el('span', {
                title: 'Password protected',
                style:'color:var(--accent);font-size:13px'
              }, '🔒') : null,
              el('span', {}, i.filename),
            ]),
            !b.ok ? el('div', {class:'row-warn'}, '⚠ ' + b.reason)
                  : (b.warn ? el('div', {class:'row-warn'}, '⚠ ' + b.warn) : null),
          ]),
          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' + (isRemote ? ' nfs' : '')},
              isSmb ? ('smb:' + i.source.share_id)
                : isNfs ? ('nfs:' + i.source.share_id) : 'local')),
          el('td', {},
            protectedNow
              ? el('span', {class:'tag accent'}, 'protected')
              : el('span', {class:'tag', style:'opacity:.55'}, 'open')),
          el('td', {}, fmtAgo(i.uploaded_at)),
          el('td', {style:'text-align:right;white-space:nowrap'}, [
            el('button', {class:'ghost', style:'margin-right:6px', onclick: () => {
              editorRow.style.display = (editorRow.style.display === 'none') ? '' : 'none';
            }}, protectedNow ? 'Password ✎' : 'Set password'),
            isRemote
              // v0.4.65/v0.4.67: remote-sourced ISOs live on the
              // share — OpenPXE doesn't own those bytes. Surface a
              // tag instead of a destructive button.
              ? el('span', {class:'tag', style:'opacity:.6'},
                  isSmb ? 'on SMB' : 'on NFS')
              : el('button', {class:'danger', onclick: async () => {
                  if (!confirm('Remove this image?')) return;
                  await fetch('/api/isos/' + encodeURIComponent(i.id), {method:'DELETE'});
                  render('storage');
                }}, 'Remove'),
          ]),
        ]);
        rowsAndEditors.push(tr, editorRow);
      });
      const isoTable = isos.length
        ? el('table', {}, [
            el('thead', {}, el('tr', {}, [
              el('th',{},'Name'), el('th',{},'Type'),
              el('th',{class:'num'},'Size'),
              el('th',{},'Source'), el('th',{},'Auth'),
              el('th',{},'Uploaded'), el('th',{},''),
            ])),
            el('tbody', {}, rowsAndEditors),
          ])
        : el('div', {class:'empty'}, 'No images yet. Upload an ISO or add an SMB share.');

      // ── Remote shares section (v0.5.1) ──
      // SMB + NFS unified into one "Remote shares" card with a protocol
      // dropdown. The two protocols keep their own backend endpoints
      // (/api/smb-shares, /api/nfs-shares) and the same add/scan/remove
      // UX; the form just swaps the relevant fields. This declutters the
      // Storage tab and leaves room for a future "Config files" card.
      const shareMsg = el('div', {class:'msg'});

      // Shared structured-error renderer ({error, stderr, hint}) for both
      // protocols' add calls.
      const showShareError = async (r) => {
        let bodyJson = null;
        let raw = null;
        try { bodyJson = await r.clone().json(); }
        catch (_) { raw = await r.text().catch(() => 'connect failed'); }
        const msg = bodyJson && bodyJson.error ? bodyJson.error : (raw || 'connect failed');
        const hint = bodyJson && bodyJson.hint;
        const parts = [el('div', {}, [
          el('strong', {}, 'Connect failed: '),
          document.createTextNode(msg),
        ])];
        if (hint) {
          parts.push(el('div', {style:'margin-top:6px;opacity:.78;font-size:12px'}, hint));
        }
        shareMsg.replaceChildren(...parts);
        shareMsg.className = 'msg err';
      };

      // Protocol picker — swaps which field block is visible. v0.5.2:
      // NFS is the default (listed first) — it has no credential fields,
      // so the form lands cleaner than the SMB guest/user/password row.
      const protoSelect = el('select', {}, [
        el('option', {value:'nfs'}, 'NFS (NFSv3)'),
        el('option', {value:'smb'}, 'SMB / CIFS'),
        el('option', {value:'sftp'}, 'SFTP (SSH)'),
      ]);

      // SMB inputs.
      const smbServer = el('input', {type:'text', placeholder:'192.168.1.51'});
      const smbShare = el('input', {type:'text', placeholder:'isos'});
      const smbGuest = el('input', {type:'checkbox'}); smbGuest.checked = true;
      const smbUser = el('input', {type:'text', placeholder:'(disabled when Guest)'});
      const smbPass = el('input', {type:'password', placeholder:'(disabled when Guest)'});
      const syncAuthDisabled = () => {
        smbUser.disabled = smbGuest.checked;
        smbPass.disabled = smbGuest.checked;
        smbUser.style.opacity = smbGuest.checked ? '0.55' : '1';
        smbPass.style.opacity = smbGuest.checked ? '0.55' : '1';
      };
      smbGuest.addEventListener('change', syncAuthDisabled);
      syncAuthDisabled();
      const smbFields = el('div', {}, [
        el('div', {class:'form-row cols-2'}, [
          el('label', {class:'field'}, [el('span', {class:'name'}, 'SMB server'), smbServer]),
          el('label', {class:'field'}, [el('span', {class:'name'}, 'Share name'), smbShare]),
        ]),
        el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
          el('label', {class:'check'}, [smbGuest, el('span', {}, 'Guest (anonymous read)')]),
          el('label', {class:'field'}, [el('span', {class:'name'}, 'Username'), smbUser]),
          el('label', {class:'field'}, [el('span', {class:'name'}, 'Password'), smbPass]),
        ]),
      ]);

      const smbRowEls = shares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
        el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
        el('div', {}, [
          el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'SMB'),
            document.createTextNode('//' + m.server + '/' + m.share)]),
          el('div', {class:'meta'},
            (m.guest ? 'guest' : ('user: ' + (m.username || '?'))) + ' · ' +
            (m.reachable ? m.iso_count + ' isos' : 'not reachable')),
          m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
          m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
        ]),
        el('button', {class:'ghost', onclick: async () => {
          const r = await postJSON('/api/smb-shares/' + encodeURIComponent(m.id) + '/scan', {});
          if (r.ok) render('storage');
        }}, 'Re-scan'),
        el('button', {class:'danger', onclick: async () => {
          if (!confirm('Forget //' + m.server + '/' + m.share + '?')) return;
          await fetch('/api/smb-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
          render('storage');
        }}, 'Remove'),
        el('span'),
      ]));

      // NFS inputs. The NFSv3 client is in-process (nfs3_client crate) so
      // NFS-sourced ISOs support HTTP Range — SMB-sourced ones can't seek
      // mid-stream. No auth fields: NFSv3 access is gated by client IP on
      // the server's export list, not client-supplied credentials.
      const nfsServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
      const nfsExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
      const nfsFields = el('div', {}, [
        el('div', {class:'form-row cols-2'}, [
          el('label', {class:'field'}, [el('span', {class:'name'}, 'NFS server'), nfsServerIn]),
          el('label', {class:'field'}, [el('span', {class:'name'}, 'Export path'), nfsExportIn]),
        ]),
      ]);

      // SFTP inputs (v0.5.5). Pure-Rust russh client, in-process, so
      // SFTP-sourced ISOs support HTTP Range like NFS. Auth is password
      // OR an SSH private key (PEM, optional passphrase); the server's
      // host key is pinned trust-on-first-use on the first connect.
      const sftpServerIn = el('input', {type:'text', placeholder:'10.0.0.5'});
      const sftpExportIn = el('input', {type:'text', placeholder:'/srv/isos'});
      const sftpUserIn = el('input', {type:'text', placeholder:'root'});
      const sftpPortIn = el('input', {type:'number', placeholder:'22', min:'1', max:'65535'});
      const sftpAuthMode = el('select', {}, [
        el('option', {value:'password'}, 'Password'),
        el('option', {value:'key'}, 'SSH private key'),
      ]);
      const sftpPassIn = el('input', {type:'password', placeholder:'••••••••'});
      const sftpKeyIn = el('textarea', {rows:'4',
        placeholder:'-----BEGIN OPENSSH PRIVATE KEY-----',
        style:'width:100%;font-family:ui-monospace,monospace;font-size:12px;resize:vertical'});
      const sftpPassphraseIn = el('input', {type:'password',
        placeholder:'(only if the private key is encrypted)'});
      const sftpPassBlock = el('label', {class:'field'},
        [el('span', {class:'name'}, 'Password'), sftpPassIn]);
      const sftpKeyBlock = el('div', {}, [
        el('label', {class:'field'},
          [el('span', {class:'name'}, 'SSH private key (PEM)'), sftpKeyIn]),
        el('label', {class:'field', style:'margin-top:10px'},
          [el('span', {class:'name'}, 'Key passphrase (optional)'), sftpPassphraseIn]),
      ]);
      const syncSftpAuth = () => {
        const key = sftpAuthMode.value === 'key';
        sftpPassBlock.style.display = key ? 'none' : '';
        sftpKeyBlock.style.display = key ? '' : 'none';
      };
      sftpAuthMode.addEventListener('change', syncSftpAuth);
      syncSftpAuth();
      const sftpFields = el('div', {}, [
        el('div', {class:'form-row cols-2'}, [
          el('label', {class:'field'}, [el('span', {class:'name'}, 'SSH server'), sftpServerIn]),
          el('label', {class:'field'}, [el('span', {class:'name'}, 'Export path'), sftpExportIn]),
        ]),
        el('div', {class:'form-row cols-3', style:'margin-top:14px'}, [
          el('label', {class:'field'}, [el('span', {class:'name'}, 'Username'), sftpUserIn]),
          el('label', {class:'field'}, [el('span', {class:'name'}, 'Port'), sftpPortIn]),
          el('label', {class:'field'}, [el('span', {class:'name'}, 'Auth'), sftpAuthMode]),
        ]),
        el('div', {style:'margin-top:14px'}, [sftpPassBlock, sftpKeyBlock]),
      ]);

      // Swap the visible field block + clear any stale message.
      const syncProto = () => {
        const p = protoSelect.value;
        smbFields.style.display = p === 'smb' ? '' : 'none';
        nfsFields.style.display = p === 'nfs' ? '' : 'none';
        sftpFields.style.display = p === 'sftp' ? '' : 'none';
        shareMsg.replaceChildren();
        shareMsg.className = 'msg';
      };
      protoSelect.addEventListener('change', syncProto);

      // One add button; dispatches to the selected protocol's endpoint.
      const addShare = el('button', {onclick: async () => {
        if (protoSelect.value === 'smb') {
          if (!smbServer.value || !smbShare.value) {
            shareMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
            shareMsg.className = 'msg err'; return;
          }
          if (!smbGuest.checked && !smbUser.value) {
            shareMsg.replaceChildren(document.createTextNode('Username is required when Guest is unchecked.'));
            shareMsg.className = 'msg err'; return;
          }
          shareMsg.replaceChildren(document.createTextNode('Connecting…'));
          shareMsg.className = 'msg';
          const body = { server: smbServer.value, share: smbShare.value, guest: smbGuest.checked };
          if (!smbGuest.checked) { body.username = smbUser.value; body.password = smbPass.value; }
          const r = await postJSON('/api/smb-shares', body);
          if (r.ok) {
            shareMsg.replaceChildren(document.createTextNode('Connected.'));
            shareMsg.className = 'msg ok';
            render('storage');
          } else { await showShareError(r); }
        } else if (protoSelect.value === 'sftp') {
          if (!sftpServerIn.value || !sftpExportIn.value || !sftpUserIn.value) {
            shareMsg.replaceChildren(document.createTextNode('Server, export, and username are required.'));
            shareMsg.className = 'msg err'; return;
          }
          const useKey = sftpAuthMode.value === 'key';
          if (useKey && !sftpKeyIn.value.trim()) {
            shareMsg.replaceChildren(document.createTextNode('Paste the SSH private key, or switch Auth to Password.'));
            shareMsg.className = 'msg err'; return;
          }
          if (!useKey && !sftpPassIn.value) {
            shareMsg.replaceChildren(document.createTextNode('Password is required, or switch Auth to SSH private key.'));
            shareMsg.className = 'msg err'; return;
          }
          shareMsg.replaceChildren(document.createTextNode('Connecting…'));
          shareMsg.className = 'msg';
          const body = {
            server: sftpServerIn.value,
            export: sftpExportIn.value,
            username: sftpUserIn.value,
          };
          if (sftpPortIn.value) { body.port = parseInt(sftpPortIn.value, 10); }
          if (useKey) {
            body.private_key = sftpKeyIn.value;
            if (sftpPassphraseIn.value) { body.passphrase = sftpPassphraseIn.value; }
          } else {
            body.password = sftpPassIn.value;
          }
          const r = await postJSON('/api/sftp-shares', body);
          if (r.ok) {
            shareMsg.replaceChildren(document.createTextNode('Connected.'));
            shareMsg.className = 'msg ok';
            render('storage');
          } else { await showShareError(r); }
        } else {
          if (!nfsServerIn.value || !nfsExportIn.value) {
            shareMsg.replaceChildren(document.createTextNode('Server and export are required.'));
            shareMsg.className = 'msg err'; return;
          }
          shareMsg.replaceChildren(document.createTextNode('Connecting…'));
          shareMsg.className = 'msg';
          const r = await postJSON('/api/nfs-shares', { server: nfsServerIn.value, export: nfsExportIn.value });
          if (r.ok) {
            shareMsg.replaceChildren(document.createTextNode('Connected.'));
            shareMsg.className = 'msg ok';
            render('storage');
          } else { await showShareError(r); }
        }
      }}, 'Add share');

      const nfsRowEls = nfsShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
        el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
        el('div', {}, [
          el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'NFS'),
            document.createTextNode(m.server + ':' + m.export)]),
          el('div', {class:'meta'},
            'NFSv3 · ' + (m.reachable ? m.iso_count + ' isos' : 'not reachable')),
          m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
          m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
        ]),
        el('button', {class:'ghost', onclick: async () => {
          const r = await postJSON('/api/nfs-shares/' + encodeURIComponent(m.id) + '/scan', {});
          if (r.ok) render('storage');
        }}, 'Re-scan'),
        el('button', {class:'danger', onclick: async () => {
          if (!confirm('Forget ' + m.server + ':' + m.export + '?')) return;
          await fetch('/api/nfs-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
          render('storage');
        }}, 'Remove'),
        el('span'),
      ]));

      const sftpRowEls = sftpShares.map(m => el('div', {class: 'nfs-row' + (m.reachable ? '' : ' down')}, [
        el('span', {class: 'dot ' + (m.reachable ? 'ok' : 'err')}),
        el('div', {}, [
          el('div', {class:'id'}, [el('span', {class:'proto-badge'}, 'SFTP'),
            document.createTextNode(m.username + '@' + m.server + ':' + m.export)]),
          el('div', {class:'meta'},
            'SSH · ' + (m.auth === 'key' ? 'key' : 'password') + ' · ' +
            (m.reachable ? m.iso_count + ' isos' : 'not reachable')),
          m.host_key_fingerprint
            ? el('div', {style:'margin-top:4px;opacity:.65;font-size:11px;font-family:ui-monospace,monospace;word-break:break-all'},
                'host key ' + m.host_key_fingerprint)
            : null,
          m.last_error ? el('div', {class:'err'}, '⚠ ' + m.last_error) : null,
          m.last_hint ? el('div', {style:'margin-top:4px;opacity:.78;font-size:12px'}, m.last_hint) : null,
        ]),
        el('button', {class:'ghost', onclick: async () => {
          const r = await postJSON('/api/sftp-shares/' + encodeURIComponent(m.id) + '/scan', {});
          if (r.ok) render('storage');
        }}, 'Re-scan'),
        el('button', {class:'danger', onclick: async () => {
          if (!confirm('Forget ' + m.server + ':' + m.export + '?')) return;
          await fetch('/api/sftp-shares/' + encodeURIComponent(m.id), {method:'DELETE'});
          render('storage');
        }}, 'Remove'),
        el('span'),
      ]));

      const totalShares = shares.length + nfsShares.length + sftpShares.length;
      const remoteRows = totalShares
        ? [...smbRowEls, ...nfsRowEls, ...sftpRowEls]
        : [el('div', {class:'empty'}, 'No remote shares configured.')];
      syncProto();

      const diskCard = diskSpaceCard(disk);

      // ── Advanced: unattended answer-file upload (v0.5.2) ──
      // Mirrors the Settings "Advanced" disclosure. Kickstart / Preseed /
      // Autoinstall / Windows answer files land in their own directory
      // (never the ISO listing or PXE menu) and are referenced by host
      // pins + queue profiles.
      const unattMsg = el('div', {class:'msg', style:'margin-top:10px'});
      const unattFile = el('input', {
        type:'file',
        accept:'.ks,.cfg,.seed,.yaml,.yml,.xml',
        style:'display:none', id:'unatt-file',
      });
      async function uploadUnattended(f) {
        const fd = new FormData(); fd.append('file', f, f.name);
        unattMsg.textContent = 'Uploading ' + f.name + ' (' + fmtBytes(f.size) + ')…';
        unattMsg.className = 'msg';
        const r = await fetch('/api/unattended', {method:'POST', body: fd});
        if (r.ok) {
          unattMsg.textContent = 'Stored ' + f.name + '.';
          unattMsg.className = 'msg ok';
          render('storage');
        } else {
          unattMsg.textContent = 'Upload failed: ' + (await r.text());
          unattMsg.className = 'msg err';
        }
      }
      const unattDrop = el('div', {class:'drop', id:'unatt-drop'}, [
        el('div', {}, 'Drop a Kickstart, Preseed, Autoinstall, or Answer File here.'),
        el('div', {style:'font-size:12px;margin-top:6px'},
          'Accepted: .ks · .cfg · .seed · .yaml · .yml · .xml (or user-data). ' +
          'Use {{HOSTNAME}}, {{IP}}, {{MAC}} as placeholders — they are filled in per host at boot.'),
      ]);
      unattDrop.onclick = () => unattFile.click();
      unattDrop.addEventListener('dragover', e => { e.preventDefault(); unattDrop.classList.add('hover'); });
      unattDrop.addEventListener('dragleave', () => unattDrop.classList.remove('hover'));
      unattDrop.addEventListener('drop', e => {
        e.preventDefault(); unattDrop.classList.remove('hover');
        if (e.dataTransfer.files[0]) uploadUnattended(e.dataTransfer.files[0]);
      });
      unattFile.onchange = () => { if (unattFile.files[0]) uploadUnattended(unattFile.files[0]); };

      const unattRows = unattendedFiles.length
        ? unattendedFiles.map(f => el('div', {class:'nfs-row'}, [
            el('span', {class:'dot ok'}),
            el('div', {}, [
              el('div', {class:'id'}, [
                el('span', {class:'proto-badge'}, unattendedKindLabel(f.kind)),
                document.createTextNode(f.filename),
              ]),
              el('div', {class:'meta'}, fmtBytes(f.size_bytes) + ' · id ' + f.id),
            ]),
            el('span'),
            el('button', {class:'danger', onclick: async () => {
              if (!confirm('Delete unattended file ' + f.filename + '?')) return;
              await fetch('/api/unattended/' + encodeURIComponent(f.id), {method:'DELETE'});
              render('storage');
            }}, 'Delete'),
            el('span'),
          ]))
        : [el('div', {class:'empty'}, 'No unattended files yet.')];

      const unattendedAdvanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [
        el('summary', {class:'advanced-summary'}, 'Advanced'),
        el('div', {class:'card', style:'margin-top:14px'}, [
          el('header', {}, [
            el('h2', {}, 'Unattended file upload'),
            el('span', {class:'sub'}, unattendedFiles.length + ' file' + (unattendedFiles.length === 1 ? '' : 's')),
          ]),
          el('div', {class:'body'}, [
            unattDrop, unattFile, unattMsg,
            el('div', {style:'margin-top:16px;display:grid;gap:8px'}, unattRows),
            el('p', {class:'msg', style:'margin-top:14px'},
              'These answer files drive unattended installs. Attach one to a ' +
              'host pin (Hosts tab) or a queued device (Queue → Profile); on ' +
              'boot OpenPXE injects the matching kernel argument and serves the ' +
              'file with the host’s name/IP filled in. Stored separately from ISOs.'),
          ]),
        ]),
      ]);

      return el('div', {}, [el('div', {class:'grid'}, [
        diskCard,
        el('div', {class:'card'}, [
          el('header', {}, el('h2', {}, 'Upload ISO')),
          el('div', {class:'body'}, [drop, file, prog, upMsg, cancelUpload]),
        ]),
        // v0.5.1: SMB + NFS unified into one "Remote shares" card with a
        // protocol dropdown. Backend endpoints are unchanged; this is a
        // pure UI consolidation that declutters the Storage tab.
        el('div', {class:'card'}, [
          el('header', {}, [
            el('h2', {}, 'Remote shares'),
            el('span', {class:'sub'}, totalShares + ' configured'),
          ]),
          el('div', {class:'body'}, [
            el('div', {class:'form-row cols-2'}, [
              el('label', {class:'field'}, [
                el('span', {class:'name'}, 'Protocol'),
                protoSelect,
              ]),
              el('span'),
            ]),
            el('div', {style:'margin-top:14px'}, [smbFields, nfsFields, sftpFields]),
            addShare, shareMsg,
            el('div', {style:'margin-top:18px;display:grid;gap:8px'}, remoteRows),
            el('p', {class:'msg', style:'margin-top:14px'},
              'Remote .iso libraries are read on demand — no local cache to ' +
              'preserve disk usage. Support for NFS 3.0, SMB, and SFTP (SSH). ' +
              'Ensure that the hosts IP address is provisioned.'),
          ]),
        ]),
        el('div', {class:'card'}, [
          el('header', {}, [
            el('h2', {}, 'Available images'),
            el('span', {class:'sub'}, isos.length + ' image' + (isos.length === 1 ? '' : 's')),
          ]),
          isoTable,
        ]),
      ]), unattendedAdvanced]);
    },

    hosts: async () => {
      const [{ hosts = [] }, isos, bootLogRes, unattRes] = await Promise.all([
        getJSON('/api/hosts'), getJSON('/api/isos'),
        getJSON('/api/boot-log').catch(() => ({ events: [] })),
        getJSON('/api/unattended').catch(() => ({ files: [] })),
      ]);
      const bootEvents = bootLogRes.events || [];
      const unattendedFiles = unattRes.files || [];
      const targets = isos.flatMap(i => i.boot_entries.map(e => ({
        id: e.id, title: e.title + '  — ' + familyLabel(i.introspection.family),
      })));

      // Reserved menu shortcuts that the operator might want to bind.
      const reserved = [
        {id: '_local',        title: '↳ Boot from Local HDD (built-in)'},
        {id: '_queue',         title: '↳ Queued Deployment (built-in)'},
        {id: '_tools_menu',   title: '↳ Tools menu (built-in)'},
      ];

      const macInput   = el('input', {type:'text', placeholder:'aa:bb:cc:dd:ee:ff', spellcheck:'false'});
      const labelInput = el('input', {type:'text', placeholder:'optional, e.g. "rack-3 spine"'});
      const targetSel = el('select', {},
        [el('option', {value:''}, '— choose a target —')]
        .concat(reserved.map(t => el('option', {value: t.id}, t.title)))
        .concat(targets.map(t => el('option', {value: t.id}, t.title))));
      const msg = el('div', {class:'msg'});
      // v0.5.2: optional unattended-install profile — auto hostname, auto
      // IP, and an answer-file picker. On boot, a bound MAC with an
      // unattended file selected has the right kernel arg injected
      // (inst.ks / preseed url / autoinstall ds=nocloud) and the
      // hostname/IP templated into the served answer file.
      const profileFields = buildProfileFields({}, unattendedFiles, 'form-row cols-3');

      const upsertBtn = el('button', {onclick: async () => {
        if (!macInput.value || !targetSel.value) {
          msg.textContent = 'MAC and target are required.'; msg.className = 'msg err'; return;
        }
        const r = await postJSON('/api/hosts', Object.assign({
          mac: macInput.value, target: targetSel.value, label: labelInput.value,
        }, profileFields.read()));
        if (r.ok) {
          msg.textContent = 'Saved.'; msg.className = 'msg ok';
          render('hosts');
        } else {
          const t = await r.text();
          msg.textContent = 'Save failed: ' + t; msg.className = 'msg err';
        }
      }}, 'Bind MAC to target');

      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');
        const autoDeploy = (h.auto_hostname || h.auto_ip || h.unattended_file)
          ? el('div', {style:'font-size:12px;line-height:1.5'}, [
              h.unattended_file ? el('div', {}, [el('span', {class:'tag accent'}, 'unattended'), document.createTextNode(' ' + h.unattended_file)]) : null,
              h.auto_hostname ? el('div', {class:'mono'}, 'host: ' + h.auto_hostname) : null,
              h.auto_ip ? el('div', {class:'mono'}, 'ip: ' + h.auto_ip) : null,
            ])
          : el('span', {class:'tag'}, '—');
        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', {}, autoDeploy),
          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', {}, [
            el('thead', {}, el('tr', {}, [
              el('th',{},'MAC'), el('th',{},'Label'),
              el('th',{},'Target'), el('th',{},'Auto-deploy'),
              el('th',{},'Updated'), el('th',{},''),
            ])),
            el('tbody', {}, rows),
          ])
        : el('div', {class:'empty'}, 'No host bindings yet. Pin a MAC to a boot target to skip the menu for that machine.');

      return el('div', {class:'grid'}, [
        el('div', {class:'card'}, [
          el('header', {}, el('h2', {}, 'Pin MAC to boot target')),
          el('div', {class:'body'}, [
            el('div', {class:'form-row'}, [
              el('label', {class:'field'}, [el('span', {class:'name'}, 'MAC address'), macInput]),
              el('label', {class:'field'}, [el('span', {class:'name'}, 'Label (optional)'), labelInput]),
              el('label', {class:'field', style:'grid-column:1 / -1'}, [
                el('span', {class:'name'}, 'Target'),
                targetSel,
                el('span', {class:'hint'},
                  'Built-in shortcuts skip the menu entirely. Per-ISO entries chain straight to the boot script.'),
              ]),
            ]),
            el('div', {style:'margin-top:16px'}, profileFields.wrap),
            upsertBtn, msg,
            el('p', {class:'msg', style:'margin-top:14px'},
              'When a client with a bound MAC requests boot.ipxe, OpenPXE ' +
              'short-circuits past the interactive menu and chains directly. ' +
              'If an unattended file is selected, the matching kernel argument ' +
              'is injected and the hostname/IP are templated into the answer file.'),
          ]),
        ]),
        el('div', {class:'card'}, [
          el('header', {}, [
            el('h2', {}, 'Bound hosts'),
            el('span', {class:'sub'}, hosts.length + ' binding' + (hosts.length === 1 ? '' : 's')),
          ]),
          table,
        ]),
        el('div', {class:'card'}, [
          el('header', {}, [
            el('h2', {}, 'Host log'),
            el('span', {class:'sub'},
              bootEvents.length + ' event' + (bootEvents.length === 1 ? '' : 's')),
          ]),
          bootEvents.length
            ? el('table', {}, [
                el('thead', {}, el('tr', {}, [
                  el('th', {}, 'Time'),
                  el('th', {}, 'MAC'),
                  el('th', {}, 'IP'),
                  el('th', {}, 'Image'),
                ])),
                el('tbody', {},
                  bootEvents.map(e => el('tr', {}, [
                    el('td', {}, fmtAgo(e.timestamp)),
                    el('td', {class:'mono'}, e.mac || el('span', {class:'tag'}, '(unknown)')),
                    el('td', {class:'mono'}, e.ip ? String(e.ip) : '—'),
                    el('td', {}, [
                      el('span', {style:'font-weight:600'}, e.target_title || e.target_id),
                      el('div', {class:'meta',
                        style:'color:var(--fg-dim);font-size:11.5px;margin-top:2px'},
                        e.target_id),
                    ]),
                  ]))),
              ])
            : el('div', {class:'empty'},
                'No boot events yet. When a PXE client chains a boot entry, ' +
                'it lands here with the MAC, IP, and image it received.'),
        ]),
      ]);
    },

    terminal: async () => {
      // Two-pane layout: live log on top (auto-scrolling), command line
      // on bottom. Mirrors the Minecraft-server console feel from the
      // brief — output and input share one continuous timeline.
      const pane = el('div', {class:'pane'});
      const input = el('input', {type:'text', placeholder:'type a command, or "help"', spellcheck:'false', autocapitalize:'off', autocomplete:'off'});
      const auto = el('input', {type:'checkbox'}); auto.checked = true;
      const clearBtn = el('button', {onclick: () => { pane.innerHTML = ''; }}, 'Clear pane');
      const tailBtn = el('button', {onclick: () => { auto.checked = !auto.checked; }}, 'Auto-scroll');

      const term = el('div', {class:'terminal'}, [
        el('div', {class:'toolbar'}, [
          el('span', {}, 'Live log + operator console'),
          el('div', {class:'right'}, [
            el('label', {class:'check', style:'border:0;padding:0;margin:0;background:transparent'},
              [auto, el('span', {style:'color:var(--fg-dim);font-size:11px'}, 'Auto-scroll')]),
            clearBtn,
          ]),
        ]),
        pane,
        el('div', {class:'input-row'}, [
          el('span', {class:'prompt'}, '>'),
          input,
        ]),
      ]);

      function append(line, kind) {
        const lvl = (line.level || 'info').toLowerCase();
        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 || 'openpxe') + '] '),
          line.message,
          '\n',
        ]);
        if (kind === 'echo') {
          span.firstChild.nextSibling.textContent = '';
          span.firstChild.textContent = '';
          span.classList.add('echo');
        }
        pane.appendChild(span);
        if (auto.checked) pane.scrollTop = pane.scrollHeight;
      }

      // Initial fetch — show recent buffer in case SSE is slow to open.
      try {
        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:'openpxe::ui',
                message: 'failed to load recent logs: ' + e.message});
      }

      // Live SSE stream. EventSource auto-reconnects on disconnect.
      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:'openpxe::ui', message: ev.data}); }
      };
      es.addEventListener('lagged', (ev) => {
        const j = JSON.parse(ev.data || '{}');
        append({timestamp: new Date().toISOString(), level:'warn',
                target:'openpxe::ui',
                message: 'log stream lagged: ' + (j.skipped || '?') + ' lines skipped'});
      });
      es.onerror = () => {
        // EventSource quietly retries; surface a hint without spamming.
        // We only append once, on transition from connected → erroring.
        if (!term._notedErr) {
          term._notedErr = true;
          append({timestamp: new Date().toISOString(), level:'warn', target:'openpxe::ui',
                  message: 'log stream connection lost — auto-reconnecting'});
          setTimeout(() => { term._notedErr = false; }, 5000);
        }
      };
      // Close the SSE when the view changes — avoids piling up streams.
      term._cleanup = () => es.close();

      // Command history (in-memory only, ↑/↓ to recall).
      const history = [];
      let hi = -1;

      input.addEventListener('keydown', async (e) => {
        if (e.key === 'Enter') {
          const cmd = input.value;
          if (!cmd.trim()) return;
          input.value = '';
          history.unshift(cmd); if (history.length > 100) history.pop();
          hi = -1;
          // The echo also comes back from the server in the live tail,
          // so we don't append it locally — keeps the order consistent.
          try {
            const r = await postJSON('/api/terminal', {command: cmd});
            const j = await r.json();
            const out = j.output || '';
            if (out === '\f') { pane.innerHTML = ''; return; }
            // Output also gets pushed onto the LogBus by the server, but
            // include it locally so even if the SSE stream dropped we
            // see it. Tagged "echo" so it stands out from regular logs.
            append({timestamp: new Date().toISOString(), level: j.ok ? 'info' : 'warn',
                    target: 'terminal-output', message: out});
          } catch (err) {
            append({timestamp: new Date().toISOString(), level:'error',
                    target:'openpxe::ui', message: 'command failed: ' + err.message});
          }
        } else if (e.key === 'ArrowUp') {
          if (history.length === 0) return;
          hi = Math.min(hi + 1, history.length - 1);
          input.value = history[hi];
          e.preventDefault();
        } else if (e.key === 'ArrowDown') {
          hi = Math.max(hi - 1, -1);
          input.value = hi < 0 ? '' : history[hi];
          e.preventDefault();
        }
      });

      // Welcome banner.
      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).
      setTimeout(() => input.focus(), 50);

      return term;
    },

    settings: async () => {
      const [status, me, sso, notify, docs] = await Promise.all([
        getJSON('/api/status'),
        getJSON('/api/me').catch(() => ({})),
        getJSON('/api/sso').catch(() => ({
          enabled:false, idp_name:'', metadata:'', metadata_url:'',
        })),
        // v0.5.1: the former Advanced tab folds in here, so Settings
        // fetches the notify config + API docs it needs too.
        getJSON('/api/notify').catch(() => ({ enabled:false, kind:'slack' })),
        getJSON('/api/docs').catch(() => ({ groups: [] })),
      ]);

      // ── Account card (Forms admin credentials, v0.4.5).
      // Sonarr/Radarr-style: the admin enters their current password
      // before changing username or password. On success the server
      // revokes every other session, so a forgotten browser tab can't
      // keep operating with stale credentials.
      const currentPw = el('input', {type:'password', autocomplete:'current-password'});
      const newUser   = el('input', {type:'text', autocomplete:'username',
        placeholder: (me.user && me.user.username) || 'admin'});
      const newPw     = el('input', {type:'password', autocomplete:'new-password',
        placeholder: 'leave blank to keep current'});
      const newPwConfirm = el('input', {type:'password', autocomplete:'new-password',
        placeholder: 'confirm new password'});
      const accountMsg = el('div', {class:'msg', style:'margin-top:8px'});
      // v0.4.63: explicit top margin so the action button sits clearly
      // beneath the input row instead of butting against the password
      // fields. Mirrors the `Save SSO settings` button below for visual
      // parity between the two settings cards.
      const accountSave = el('button', {onclick: async () => {
        accountMsg.textContent = ''; accountMsg.className = 'msg';
        if (!currentPw.value) {
          accountMsg.textContent = 'Current password is required.';
          accountMsg.className = 'msg err';
          return;
        }
        if (newPw.value && newPw.value !== newPwConfirm.value) {
          accountMsg.textContent = 'New password and confirmation do not match.';
          accountMsg.className = 'msg err';
          return;
        }
        if (!newUser.value && !newPw.value) {
          accountMsg.textContent = 'Nothing to change. Fill in a new username or password.';
          accountMsg.className = 'msg err';
          return;
        }
        const body = { current_password: currentPw.value };
        if (newUser.value) body.new_username = newUser.value;
        if (newPw.value)   body.new_password = newPw.value;
        const r = await putJSON('/api/me/credentials', body);
        // Clear the typed plaintext immediately — minimises DOM dwell time.
        currentPw.value = ''; newPw.value = ''; newPwConfirm.value = '';
        if (r.ok) {
          accountMsg.textContent = 'Credentials updated. Other sessions were signed out.';
          accountMsg.className = 'msg ok';
          // Refresh the settings view to pick up the new "logged in as" display.
          setTimeout(() => render('settings'), 600);
        } else {
          const j = await r.json().catch(() => ({}));
          accountMsg.textContent = j.error || ('Update failed: HTTP ' + r.status);
          accountMsg.className = 'msg err';
        }
      }}, 'Update credentials');
      const accountCard = el('div', {class:'card'}, [
        el('header', {}, [
          el('h2', {}, 'Administrator account'),
          el('span', {class:'sub'},
            (me && me.user && me.user.username)
              ? ('signed in as ' + me.user.username)
              : 'signed in'),
        ]),
        el('div', {class:'body'}, [
          el('p', {class:'msg', style:'margin-bottom:14px'},
            'Rotate the administrator login. Your current password is required ' +
            'to make any change; on success every other browser session is ' +
            'signed out so a stale cookie can\'t keep operating.'),
          el('div', {class:'form-row'}, [
            el('label', {class:'field'}, [
              el('span', {class:'name'}, 'Current password'),
              currentPw,
            ]),
            el('label', {class:'field'}, [
              el('span', {class:'name'}, 'New username (optional)'),
              newUser,
            ]),
            el('label', {class:'field'}, [
              el('span', {class:'name'}, 'New password (optional)'),
              newPw,
            ]),
            el('label', {class:'field'}, [
              el('span', {class:'name'}, 'Confirm new password'),
              newPwConfirm,
            ]),
          ]),
          accountSave, accountMsg,
        ]),
      ]);

      // ── SSO card (FleetDM-shaped, storage-only for v0.4.5).
      // Operators paste either a metadata URL or the raw XML; tabs
      // switch the visible field. Saving validates server-side. The
      // actual SAML login flow ships in a later release — we surface
      // a yellow "config saved, runtime pending" line when usable.
      const ssoEnabled = el('input', {type:'checkbox'});
      ssoEnabled.checked = !!sso.enabled;
      const ssoName = el('input', {type:'text', placeholder:'e.g. Okta, Azure AD',
        value: sso.idp_name || ''});
      // v0.4.6: optional FleetDM-style IdP logo URL. The login screen
      // will render this as the brand mark on the "Sign in with X"
      // button once the runtime SSO flow ships; for v0.4.6 we just
      // persist it.
      const ssoLogo = el('input', {type:'text',
        placeholder:'https://idp.example.com/logo.svg',
        value: sso.idp_logo_url || ''});
      const ssoUrl = el('input', {type:'text', placeholder:'https://idp.example.com/metadata',
        value: sso.metadata_url || ''});
      // The textarea inherits the same chrome via the global
      // `label.field textarea` rule, plus the monospace family for
      // pasting raw XML. Children come after the attrs object — the
      // initial value is the only "child".
      const ssoXml = el('textarea',
        {rows:'6',
         spellcheck:'false', autocapitalize:'off',
         placeholder:'<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"…',
         style:'font-family:var(--mono);font-size:12px;resize:vertical'},
        sso.metadata || '');
      // The mode picker is a styled <select> so it aligns with text
      // inputs in the same `.form-row` — the global `label.field
      // select` rule takes care of the chrome.
      const ssoMode = el('select', {}, [
        el('option', {value:'url'}, 'Metadata URL'),
        el('option', {value:'xml'}, 'Metadata XML'),
      ]);
      ssoMode.value = sso.metadata && !sso.metadata_url ? 'xml' : 'url';
      // v0.4.63: the IdP metadata URL now sits inside the 4-col header
      // grid as column 4, so the SSO row is column-for-column aligned with
      // the Administrator account row above. When the operator switches
      // to XML mode, column 4 collapses (display:none) and the multi-line
      // XML textarea takes its own full-width row below — there's no way
      // to fit a 6-row textarea into a single grid cell without making
      // the rest of the row look stretched.
      const urlWrap = el('label', {class:'field'}, [
        el('span', {class:'name'}, 'IdP metadata URL'),
        ssoUrl,
      ]);
      const xmlWrap = el('label', {class:'field', style:'margin-top:14px'}, [
        el('span', {class:'name'}, 'IdP metadata XML'),
        ssoXml,
        el('span', {class:'hint'},
          'Paste the raw <EntityDescriptor>…</EntityDescriptor> document from your IdP.'),
      ]);
      // Hint that used to live under the URL field; surfaced once below
      // the whole row so it doesn't compete with the in-grid layout.
      const urlHint = el('p', {class:'msg', style:'margin-top:10px;margin-bottom:0'},
        'OpenPXE fetches this metadata URL at sign-in to verify the IdP’s signature. ' +
        'Any IdP-authenticated user gets an operator session.');
      const refreshSsoFields = () => {
        if (ssoMode.value === 'url') {
          urlWrap.style.display = ''; xmlWrap.style.display = 'none';
          urlHint.style.display = '';
        } else {
          urlWrap.style.display = 'none'; xmlWrap.style.display = '';
          urlHint.style.display = 'none';
        }
      };
      ssoMode.onchange = refreshSsoFields;
      const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'});
      const ssoSave = el('button', {onclick: async () => {
        ssoMsg.textContent = ''; ssoMsg.className = 'msg';
        const payload = {
          enabled: ssoEnabled.checked,
          idp_name: ssoName.value,
          idp_logo_url: ssoLogo.value,
          metadata:     ssoMode.value === 'xml' ? ssoXml.value : '',
          metadata_url: ssoMode.value === 'url' ? ssoUrl.value : '',
        };
        const r = await putJSON('/api/sso', payload);
        if (r.ok) {
          ssoMsg.textContent = ssoEnabled.checked
            ? 'SSO saved and live. The login page now shows a “Sign in with …” button.'
            : 'SSO configuration saved (disabled).';
          ssoMsg.className = 'msg ok';
        } else {
          const t = await r.text();
          ssoMsg.textContent = 'Save failed: ' + t;
          ssoMsg.className = 'msg err';
        }
      }}, 'Save SSO settings');
      const ssoCard = el('div', {class:'card'}, [
        el('header', {}, [
          el('h2', {}, 'Single sign-on (SAML)'),
          el('span', {class:'sub'},
            sso.enabled
              ? (sso.metadata_url || sso.metadata
                  ? 'live · active'
                  : 'enabled but missing source')
              : 'disabled'),
        ]),
        el('div', {class:'body'}, [
          el('p', {class:'msg', style:'margin-bottom:14px'},
            'SAML single sign-on is live. With it enabled, the login page shows ' +
            'a “Sign in with …” button that hands off to your IdP; OpenPXE ' +
            'verifies the signed assertion against the IdP metadata and mints an ' +
            'operator session for any authenticated user. The local administrator ' +
            'account above always remains available as a fallback.'),
          el('label', {class:'check', style:'margin-bottom:14px;max-width:280px'}, [
            ssoEnabled,
            el('span', {}, 'Enable single sign-on'),
          ]),
          // v0.4.63: 4-column form-row that matches the Administrator
          // account card above column-for-column — display name / logo
          // URL / metadata source / metadata URL. All four controls share
          // the same `label.field` chrome so they line up cleanly. When
          // the operator picks "Metadata XML" the URL column collapses
          // and the multi-line textarea drops below the row.
          el('div', {class:'form-row'}, [
            el('label', {class:'field'}, [
              el('span', {class:'name'}, 'IdP display name'),
              ssoName,
            ]),
            el('label', {class:'field'}, [
              el('span', {class:'name'}, 'IdP logo URL'),
              ssoLogo,
            ]),
            el('label', {class:'field'}, [
              el('span', {class:'name'}, 'Metadata source'),
              ssoMode,
            ]),
            urlWrap,
          ]),
          xmlWrap,
          urlHint,
          ssoSave, ssoMsg,
        ]),
      ]);
      // Wire up + paint the initial visibility now that all elements
      // referenced by `refreshSsoFields` are attached.
      refreshSsoFields();

      // ── Custom logos (v0.5.2). Three independent slots on one row,
      // FleetDM-style: Light + Dark feed the WebUI top-left and the form
      // login page (whichever theme is active picks its variant); Client
      // is the raster painted above the PXE boot menu. Each slot has a
      // preview, an upload (PNG/SVG/JPEG/WebP/GIF up to 2 MB; the Client
      // slot is raster-only), and a clear.
      const logoMsg = el('div', {class:'msg', style:'margin-top:10px'});
      const bust = '?v=' + Date.now(); // bust the preview cache after a change
      const brandingPresence = status.branding || { light:false, dark:false, client:false };
      // Each swatch previews on a background matching where the mark
      // lands (light page / dark page / dark PXE screen), independent of
      // the operator's current page theme — so the Dark slot always reads
      // as dark even while viewing Settings in light mode.
      const slotDefs = [
        { slot:'light',  title:'Light mode', preview:'/assets/logo.svg?theme=light' + '&' + bust.slice(1),
          swatchBg:'#f4f5f7', hint:'Shown on light-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
        { slot:'dark',   title:'Dark mode',  preview:'/assets/logo.svg?theme=dark' + '&' + bust.slice(1),
          swatchBg:'#0e1014', hint:'Shown on dark-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
        { slot:'client', title:'Client',     preview:'/branding/pxe-logo' + bust,
          swatchBg:'#0e1014', hint:'Above the PXE boot menu.', accept:'image/png,image/jpeg,image/webp,image/gif' },
      ];
      const slotCol = (def) => {
        const set = !!brandingPresence[def.slot];
        const input = el('input', {type:'file', accept:def.accept, style:'display:none'});
        input.onchange = async () => {
          if (!input.files[0]) return;
          const f = input.files[0];
          const fd = new FormData(); fd.append('file', f, f.name);
          logoMsg.textContent = 'Uploading ' + def.title + ' logo (' + fmtBytes(f.size) + ')…';
          logoMsg.className = 'msg';
          const r = await fetch('/api/branding/logo/' + def.slot, {method:'POST', body: fd});
          if (r.ok) {
            logoMsg.textContent = def.title + ' logo installed. Reloading…';
            logoMsg.className = 'msg ok';
            setTimeout(() => location.reload(), 600);
          } else {
            logoMsg.textContent = 'Upload failed: ' + (await r.text());
            logoMsg.className = 'msg err';
          }
        };
        return el('div', {class:'logo-slot'}, [
          el('div', {class:'logo-slot-head'}, [
            el('span', {class:'name'}, def.title),
            set ? el('span', {class:'tag ok'}, 'set') : el('span', {class:'tag'}, 'default'),
          ]),
          el('div', {class:'swatch', style:'background:' + def.swatchBg},
            el('img', {src: def.preview, alt: def.title + ' logo'})),
          el('div', {class:'logo-slot-hint'}, def.hint),
          el('div', {style:'display:flex;gap:6px;flex-wrap:wrap'}, [
            el('button', {class:'ghost', onclick: () => input.click()}, set ? 'Replace' : 'Upload'),
            set ? el('button', {class:'danger', onclick: async () => {
              if (!confirm('Remove the ' + def.title + ' logo?')) return;
              const r = await fetch('/api/branding/logo/' + def.slot, {method:'DELETE'});
              if (r.ok || r.status === 204) {
                logoMsg.textContent = def.title + ' logo cleared. Reloading…';
                logoMsg.className = 'msg ok';
                setTimeout(() => location.reload(), 500);
              } else {
                logoMsg.textContent = 'Clear failed: ' + (await r.text());
                logoMsg.className = 'msg err';
              }
            }}, 'Remove') : null,
          ]),
          input,
        ]);
      };
      const logoCard = el('div', {class:'card'}, [
        el('header', {}, el('h2', {}, 'Branding')),
        el('div', {class:'body'}, [
          el('p', {class:'msg', style:'margin-bottom:14px'},
            'Upload your own brand marks. Up to 2 MB each; PNG, SVG, JPEG, ' +
            'WebP, or GIF (the Client logo must be a raster). The Light and Dark ' +
            'marks appear in the top-left and on the sign-in page depending on ' +
            'theme; the Client mark sits above the PXE boot menu. The favicon ' +
            'and the version string in the bottom-left always stay OpenPXE.'),
          el('div', {class:'logo-slots'}, slotDefs.map(slotCol)),
          logoMsg,
        ]),
      ]);

      // v0.5.1: the former "Advanced" sidebar tab now lives here, folded
      // into a collapsible disclosure beneath the core settings cards —
      // webhook/email notifications + the API reference. Keeps Settings
      // clean by default while leaving the knobs one click away.
      const [notifyCard, apiCard] = views._advancedCards(notify, docs);
      const advanced = el('details', {class:'advanced-disclosure', style:'margin-top:18px'}, [
        el('summary', {class:'advanced-summary'}, 'Advanced'),
        el('div', {class:'grid', style:'margin-top:14px'}, [notifyCard, apiCard]),
      ]);
      return el('div', {}, [
        el('div', {class:'grid'}, [accountCard, ssoCard, logoCard]),
        advanced,
      ]);
    },

    // v0.5.1: builds the two "Advanced" cards — webhook/email notifications
    // and the API reference. There is no longer an Advanced sidebar tab;
    // the Settings view folds these into a collapsible disclosure and
    // passes in the pre-fetched `notify` + `docs` payloads.
    _advancedCards: (notify, docs) => {

      // ── 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:'openpxe@example.com', value: notify.smtp_from || ''});
      const sTo   = el('input', {type:'text', placeholder:'ops@example.com', 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', {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('settings'); }
        else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; }
      }}, 'Save notification settings');
      const testBtn = el('button', {class:'ghost', style:'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', {}, [
          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 [notifyCard, apiCard];
    },

    about: async () => {
      const status = await getJSON('/api/status');

      // ── 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'},
            'Air-gapped network PXE boot, container-native, that anyone can run. ' +
            'No CDN calls, no telemetry, no surprise external dependencies — ship ' +
            'the image once, run it forever.'),
          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('br'),
            el('span', {}, 'Docs: '),
            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 ' +
            'hand-written .ipxe path anywhere in this product.'),
          el('p', {class:'msg'},
            'Vision: a deployment-grade tool that works on first try in the most ' +
            'awkward environments — air-gapped labs, customer sites without ' +
            'internet, OpenShift clusters with strict SCCs — without ever asking ' +
            'an operator to install drivers signed with test certificates or to ' +
            '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]);
    },
  };

  // ── shell ────────────────────────────────────────────────────────
  const viewTitles = {
    dashboard: 'Dashboard',
    network: 'Network',
    queue: 'Queue',
    storage: 'Storage',
    hosts: 'Hosts',
    terminal: 'Terminal',
    settings: 'Settings',
    about: 'About',
  };

  // Theme toggle. The data-attribute is set on <html> by the inline
  // 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('openpxe-theme', theme); } catch {}
    applyBrandLogos(theme);
  }
  function currentTheme() {
    return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
  }
  // v0.5.2: point the sidebar + login brand marks at the theme's logo
  // slot so a light/dark toggle swaps the logo too (FleetDM-style).
  function applyBrandLogos(theme) {
    theme = theme || currentTheme();
    const rev = (brandInfo && brandInfo.logo_rev) || 0;
    const url = '/assets/logo.svg?theme=' + theme + '&r=' + rev;
    document.querySelectorAll('.sidebar .brand img, .brand-row img').forEach(img => {
      img.src = url;
    });
  }
  document.addEventListener('DOMContentLoaded', () => {
    applyBrandLogos();
    const btn = $('#theme-toggle');
    if (btn) {
      btn.addEventListener('click', () => {
        applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
      });
    }
  });
  // Keyboard shortcut: T toggles theme (skip when typing in an input).
  document.addEventListener('keydown', (e) => {
    if (e.key !== 't' && e.key !== 'T') return;
    if (/^(INPUT|TEXTAREA|SELECT)$/.test((e.target && e.target.tagName) || '')) return;
    applyTheme(currentTheme() === 'light' ? 'dark' : 'light');
  });

  let currentBody = null;

  async function render(view) {
    view = view || 'dashboard';
    $$('.sidebar nav a').forEach(a => a.classList.toggle('active', a.dataset.view === view));
    $('[data-bind=view_title]').textContent = viewTitles[view] || view;
    const root = $('#view-root');
    // Clean up any per-view resources (e.g. terminal SSE) before swap.
    if (currentBody && typeof currentBody._cleanup === 'function') {
      try { currentBody._cleanup(); } catch (e) { /* ignore */ }
    }
    root.innerHTML = '';
    // 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:'mark'}),
      el('div', {}, 'Loading…'),
    ]));
    try {
      const body = await views[view]();
      root.innerHTML = '';
      root.appendChild(body);
      currentBody = body;
    } catch (e) {
      root.innerHTML = '';
      root.appendChild(el('div', {class:'msg err'}, 'Error: ' + e.message));
    }
  }

  // Set the sidebar footer "Service status:" line. The chip itself moved
  // off the topbar in v0.4.x: operators wanted readiness, advertised
  // URL, and the boot IP grouped together as the bottom-left summary.
  function setReady(state) {
    const dot = $('[data-bind=ready_dot]');
    const lbl = $('[data-bind=ready_label]');
    if (!dot || !lbl) return;
    const map = {
      ready:       { cls: 'ok',  text: 'Ready' },
      notready:    { cls: 'err', text: 'Not ready' },
      unreachable: { cls: 'err', text: 'Unreachable' },
    };
    const m = map[state] || { cls: 'warn', text: 'Checking…' };
    dot.className = 'dot ' + m.cls;
    lbl.className = 'status-value ' + m.cls;
    lbl.textContent = m.text;
  }

  async function refreshChips() {
    try {
      const s = await getJSON('/api/status');
      const r = await fetch('/readyz');
      $$('[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=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));
      setReady(r.ok ? 'ready' : 'notready');
    } catch {
      setReady('unreachable');
    }
  }

  document.addEventListener('click', (e) => {
    const a = e.target.closest('.sidebar nav a[data-view]');
    if (a) { e.preventDefault(); render(a.dataset.view); }
  });

  // ── Auth bootstrap (v0.4.5) ──────────────────────────────────────
  // Before painting the dashboard, ask /api/me whether the operator
  // needs to bootstrap an admin (`setup_required`), sign in
  // (`!authenticated`), or just load the dashboard. The auth screen
  // takes over the viewport completely — no half-rendered chrome
  // bleeding through. Sonarr/Radarr-style.
  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?theme=' + currentTheme() + '&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) {
      authScreenEl.parentNode.removeChild(authScreenEl);
    }
    authScreenEl = null;
    document.querySelector('.shell').style.display = '';
  }

  function buildLoginCard() {
    const usernameInput = el('input', {type:'text', name:'username', autocomplete:'username', autofocus:'autofocus', spellcheck:'false'});
    const passwordInput = el('input', {type:'password', name:'password', autocomplete:'current-password'});
    const err = el('div', {class:'auth-err', style:'display:none'});
    const submit = el('button', {class:'submit', type:'submit'}, 'Sign in');

    // v0.5.1: surface a failed/blocked SSO round-trip. The ACS handler
    // redirects back to "/?sso_error=..." on any failure; we show a
    // generic, non-leaky message and scrub the query so a refresh is clean.
    const ssoErr = new URLSearchParams(window.location.search).get('sso_error');
    if (ssoErr) {
      err.textContent = ssoErr === 'idp_initiated'
        ? 'IdP-initiated SSO is disabled. Use the “Sign in with …” button, or enable it under Settings → SSO.'
        : (ssoErr === 'unavailable' || ssoErr === 'metadata')
          ? 'Single sign-on is unavailable right now. Sign in with the local admin, or check the SSO settings.'
          : 'SSO sign-in failed. Please try again, or sign in with the local admin.';
      err.style.display = '';
      window.history.replaceState({}, '', window.location.pathname);
    }

    // v0.5.2: FleetDM-style separation. The local credential form is its
    // own self-contained <form>; when SSO is enabled, a distinct
    // "Sign in with …" button sits below a divider — the credential
    // fields no longer double as the SSO trigger.
    const ssoLive = ssoConfig && ssoConfig.enabled && (ssoConfig.metadata_url || ssoConfig.metadata);
    const ssoBlock = ssoLive
      ? el('div', {class:'sso-block'}, [
          el('div', {class:'auth-divider'}, el('span', {}, 'or')),
          el('button', {type:'button', class:'sso-btn', onclick: () => {
            // SP-initiated SAML login: hand off to the IdP. /api/sso/acs
            // verifies the response, mints the operator session, and
            // redirects back to the dashboard.
            window.location.assign('/api/sso/login');
          }}, [
            ssoConfig.idp_logo_url
              ? el('img', {class:'sso-logo', src: ssoConfig.idp_logo_url, alt:'', onerror: function(){ this.style.display='none'; }})
              : null,
            el('span', {}, 'Sign in with ' + (ssoConfig.idp_name || 'SSO')),
          ]),
        ])
      : null;

    const form = el('form', {class:'auth-form local-login', onsubmit: async (e) => {
      e.preventDefault();
      err.style.display = 'none';
      submit.disabled = true;
      submit.textContent = 'Signing in…';
      try {
        const r = await fetch('/api/login', {
          method:'POST',
          headers:{'Content-Type':'application/json'},
          body: JSON.stringify({username: usernameInput.value, password: passwordInput.value}),
        });
        if (r.ok) {
          passwordInput.value = '';
          teardownAuthScreen();
          await startDashboard();
          return;
        }
        const j = await r.json().catch(() => ({}));
        err.textContent = j.error || ('Sign-in failed: HTTP ' + r.status);
        err.style.display = '';
      } catch (ex) {
        err.textContent = 'Network error: ' + (ex && ex.message ? ex.message : ex);
        err.style.display = '';
      } finally {
        submit.disabled = false;
        submit.textContent = 'Sign in';
      }
    }}, [
      el('label', {class:'field'}, [
        el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Username'),
        usernameInput,
      ]),
      el('label', {class:'field'}, [
        el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Password'),
        passwordInput,
      ]),
      submit,
    ]);
    return el('div', {class:'login-stack'}, [
      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.'),
      form,
      ssoBlock,
      err,
      el('div', {class:'auth-foot'}, 'OpenPXE · ' + (window.location.host || '')),
    ]);
  }

  function buildSetupCard() {
    const usernameInput = el('input', {type:'text', name:'username', autocomplete:'username', autofocus:'autofocus', spellcheck:'false'});
    const passwordInput = el('input', {type:'password', name:'password', autocomplete:'new-password'});
    const confirmInput  = el('input', {type:'password', name:'confirm', autocomplete:'new-password'});
    const err = el('div', {class:'auth-err', style:'display:none'});
    const submit = el('button', {class:'submit', type:'submit'}, 'Create administrator');

    const form = el('form', {class:'auth-form', onsubmit: async (e) => {
      e.preventDefault();
      err.style.display = 'none';
      if (passwordInput.value !== confirmInput.value) {
        err.textContent = 'Passwords do not match.';
        err.style.display = '';
        return;
      }
      if (passwordInput.value.length < 8) {
        err.textContent = 'Password must be at least 8 characters.';
        err.style.display = '';
        return;
      }
      submit.disabled = true;
      submit.textContent = 'Creating…';
      try {
        const r = await fetch('/api/setup', {
          method:'POST',
          headers:{'Content-Type':'application/json'},
          body: JSON.stringify({username: usernameInput.value, password: passwordInput.value}),
        });
        if (r.ok) {
          passwordInput.value = '';
          confirmInput.value = '';
          teardownAuthScreen();
          await startDashboard();
          return;
        }
        const j = await r.json().catch(() => ({}));
        err.textContent = j.error || ('Setup failed: HTTP ' + r.status);
        err.style.display = '';
      } catch (ex) {
        err.textContent = 'Network error: ' + (ex && ex.message ? ex.message : ex);
        err.style.display = '';
      } finally {
        submit.disabled = false;
        submit.textContent = 'Create administrator';
      }
    }}, [
      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'}, [
        el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Username'),
        usernameInput,
      ]),
      el('label', {class:'field'}, [
        el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Password (≥8 chars)'),
        passwordInput,
      ]),
      el('label', {class:'field'}, [
        el('div', {style:'color:var(--fg-dim);font-size:12px;margin-bottom:4px'}, 'Confirm password'),
        confirmInput,
      ]),
      submit,
      err,
      el('div', {class:'auth-foot'}, 'OpenPXE · ' + (window.location.host || '')),
    ]);
    return form;
  }

  function showAuthScreen(mode) {
    // Tear down any previous screen + the dashboard chrome.
    if (authScreenEl && authScreenEl.parentNode) {
      authScreenEl.parentNode.removeChild(authScreenEl);
    }
    const shell = document.querySelector('.shell');
    if (shell) shell.style.display = 'none';
    if (chipsInterval) { clearInterval(chipsInterval); chipsInterval = null; }

    const card = (mode === 'setup' ? buildSetupCard() : buildLoginCard());
    authScreenEl = el('div', {class:'auth-screen'},
      el('div', {class:'auth-card'}, card));
    document.body.appendChild(authScreenEl);
    // Focus the first visible input (autofocus on dynamically created
    // inputs doesn't fire in all browsers).
    setTimeout(() => {
      const inp = authScreenEl.querySelector('input[type="text"], input[type="password"]');
      if (inp) inp.focus();
    }, 30);
  }

  async function startDashboard() {
    // v0.4.6: light up the top-right user-menu chip. The button is
    // hidden in index.html until /api/me confirms a signed-in session,
    // so we don't show the icon (then hide it) when the user lands
    // on /login. Clicking the icon opens a small popover with
    // Name / Edit account / Sign out.
    try {
      const me = await fetch('/api/me').then(r => r.ok ? r.json() : null);
      const wrap = $('[data-bind=user_menu_wrap]');
      const pop  = $('[data-bind=user_menu_pop]');
      const name = $('[data-bind=user_pop_name]');
      const edit = $('[data-bind=user_pop_edit]');
      const out  = $('[data-bind=user_pop_logout]');
      const btn  = $('#user-menu-btn');
      if (wrap && me && me.authenticated && me.user) {
        wrap.style.display = '';
        if (name) name.textContent = me.user.username;
        if (btn) btn.title = 'Signed in as ' + me.user.username;
        if (btn && !btn._wired) {
          btn._wired = true;
          btn.addEventListener('click', (e) => {
            e.stopPropagation();
            const open = !pop.hidden;
            pop.hidden = open;
            btn.setAttribute('aria-expanded', String(!open));
          });
        }
        if (edit && !edit._wired) {
          edit._wired = true;
          edit.addEventListener('click', () => {
            pop.hidden = true;
            btn.setAttribute('aria-expanded', 'false');
            render('settings');
          });
        }
        if (out && !out._wired) {
          out._wired = true;
          out.addEventListener('click', async () => {
            pop.hidden = true;
            btn.setAttribute('aria-expanded', 'false');
            await fetch('/api/logout', {method:'POST'}).catch(() => {});
            wrap.style.display = 'none';
            showAuthScreen('login');
          });
        }
        // Click-outside-to-close, wired once. Stored on document so we
        // don't re-attach every render.
        if (!document._userPopWired) {
          document._userPopWired = true;
          document.addEventListener('click', (e) => {
            if (pop.hidden) return;
            if (e.target.closest('.user-menu')) return;
            pop.hidden = true;
            btn.setAttribute('aria-expanded', 'false');
          });
          document.addEventListener('keydown', (e) => {
            if (e.key === 'Escape' && !pop.hidden) {
              pop.hidden = true;
              btn.setAttribute('aria-expanded', 'false');
            }
          });
        }
      }
    } catch (e) { /* surfaces elsewhere */ }
    render('dashboard');
    await refreshChips();
    if (!chipsInterval) chipsInterval = setInterval(refreshChips, 3000);
  }

  async function bootstrap() {
    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'}, [
          authBrandRow(),
          el('h2', {}, 'Connection error'),
          el('p', {class:'lede'}, 'Could not reach the OpenPXE server. Refresh once it is back up.'),
        ])));
      return;
    }
    // Preload the SSO config so the login card can offer the operator
    // an "Sign in with X" button when configured. Failure is harmless.
    try { ssoConfig = await fetch('/api/sso').then(r => r.ok ? r.json() : null); }
    catch { ssoConfig = null; }

    if (me.setup_required) {
      showAuthScreen('setup');
      return;
    }
    if (!me.authenticated) {
      showAuthScreen('login');
      return;
    }
    await startDashboard();
  }

  bootstrap();
})();
