<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Metis Control</title>
  <style>
    :root{
      --bg:#081018;
      --bg-soft:#0e1722;
      --panel:#101c29;
      --panel-strong:#172535;
      --line:rgba(149,177,205,.18);
      --line-strong:rgba(149,177,205,.28);
      --ink:#f3f7fb;
      --muted:#9bb0c4;
      --brand:#3da7ff;
      --brand-deep:#1c6ca8;
      --accent:#ff9a4a;
      --success:#3dd08c;
      --danger:#ff6f6f;
      --warn:#f2c14c;
      --shadow:0 24px 60px rgba(0,0,0,.35);
    }
    *{box-sizing:border-box}
    body{
      margin:0;
      min-height:100vh;
      font-family:"Avenir Next","Trebuchet MS","Segoe UI",sans-serif;
      color:var(--ink);
      background:
        radial-gradient(circle at top left, rgba(61,167,255,.20), transparent 28rem),
        radial-gradient(circle at top right, rgba(255,154,74,.16), transparent 26rem),
        linear-gradient(180deg, #071018 0%, #0a131d 50%, #0b1622 100%);
    }
    .frame{
      max-width:1320px;
      margin:0 auto;
      padding:2rem 1.25rem 3rem;
    }
    .mast{
      display:flex;
      justify-content:space-between;
      align-items:flex-end;
      gap:1.5rem;
      margin-bottom:1rem;
    }
    .eyebrow{
      letter-spacing:.14em;
      text-transform:uppercase;
      font-size:.72rem;
      color:#81c6ff;
      margin-bottom:.35rem;
      font-weight:800;
    }
    h1{
      margin:0;
      font-size:clamp(2rem,4vw,3.4rem);
      line-height:1;
    }
    .sub{
      max-width:56rem;
      color:var(--muted);
      margin-top:.7rem;
      font-size:1rem;
    }
    .badge{
      display:inline-flex;
      align-items:center;
      gap:.45rem;
      padding:.78rem 1rem;
      background:rgba(16,28,41,.82);
      border:1px solid var(--line);
      border-radius:999px;
      box-shadow:var(--shadow);
      font-size:.92rem;
    }
    .banner{
      display:flex;
      align-items:flex-start;
      gap:.75rem;
      padding:1rem 1.1rem;
      border-radius:1rem;
      border:1px solid var(--line);
      background:rgba(16,28,41,.84);
      margin-bottom:1rem;
      box-shadow:var(--shadow);
    }
    .banner.hidden{display:none}
    .banner.info{border-color:rgba(61,167,255,.32);background:rgba(17,36,54,.88)}
    .banner.success{border-color:rgba(61,208,140,.32);background:rgba(10,41,31,.9)}
    .banner.error{border-color:rgba(255,111,111,.32);background:rgba(56,18,18,.9)}
    .banner.warn{border-color:rgba(242,193,76,.32);background:rgba(53,40,13,.9)}
    .banner strong{display:block;margin-bottom:.15rem}
    .grid{
      display:grid;
      grid-template-columns:1.16fr .9fr;
      gap:1rem;
    }
    .stack{display:grid;gap:1rem}
    .card{
      background:linear-gradient(180deg, rgba(16,28,41,.95), rgba(12,21,31,.94));
      border:1px solid var(--line);
      border-radius:1.35rem;
      padding:1.1rem;
      box-shadow:var(--shadow);
    }
    .card h2{
      margin:0 0 .35rem;
      font-size:1rem;
      text-transform:uppercase;
      letter-spacing:.1em;
      color:#8bccff;
    }
    .hint{
      color:var(--muted);
      font-size:.92rem;
      margin-bottom:1rem;
    }
    .microcopy{
      color:var(--muted);
      font-size:.84rem;
      margin-top:.5rem;
      min-height:1.2rem;
    }
    .form-grid{
      display:grid;
      grid-template-columns:repeat(2,minmax(0,1fr));
      gap:.85rem;
    }
    label{
      display:grid;
      gap:.35rem;
      font-weight:700;
      font-size:.92rem;
    }
    select, button{
      width:100%;
      border-radius:.95rem;
      border:1px solid var(--line-strong);
      padding:.9rem .95rem;
      font:inherit;
    }
    select{
      background:rgba(11,21,33,.96);
      color:var(--ink);
      min-height:3.1rem;
    }
    select:focus, button:focus{
      outline:2px solid rgba(61,167,255,.28);
      outline-offset:2px;
    }
    button{
      cursor:pointer;
      background:linear-gradient(135deg,var(--brand) 0%,var(--brand-deep) 100%);
      color:#fff;
      border:none;
      font-weight:800;
      letter-spacing:.03em;
      box-shadow:0 14px 30px rgba(17,66,102,.30);
      transition:transform .12s ease, opacity .12s ease;
    }
    button:hover{transform:translateY(-1px)}
    button.secondary{
      background:rgba(18,30,43,.96);
      color:var(--ink);
      border:1px solid var(--line-strong);
      box-shadow:none;
    }
    button:disabled{
      opacity:.55;
      cursor:not-allowed;
      transform:none;
    }
    .actions{
      display:grid;
      grid-template-columns:repeat(2,minmax(0,1fr));
      gap:.7rem;
      margin-top:.9rem;
    }
    .list{
      display:grid;
      gap:.7rem;
      max-height:30rem;
      overflow:auto;
    }
    .item{
      border:1px solid rgba(149,177,205,.14);
      border-radius:1rem;
      padding:.85rem .95rem;
      background:rgba(8,17,27,.78);
    }
    .item-head{
      display:flex;
      justify-content:space-between;
      gap:1rem;
      margin-bottom:.35rem;
      font-weight:700;
    }
    .meta{
      color:var(--muted);
      font-size:.85rem;
    }
    .bar{
      height:.55rem;
      background:rgba(149,177,205,.12);
      border-radius:999px;
      overflow:hidden;
      margin-top:.7rem;
    }
    .bar > span{
      display:block;
      height:100%;
      background:linear-gradient(90deg,var(--accent),var(--brand));
    }
    .pill{
      display:inline-block;
      padding:.2rem .55rem;
      border-radius:999px;
      font-size:.75rem;
      text-transform:uppercase;
      letter-spacing:.08em;
      background:rgba(61,167,255,.12);
      color:#9bd1ff;
    }
    .pill.done{background:rgba(61,208,140,.12);color:var(--success)}
    .pill.error{background:rgba(255,111,111,.12);color:var(--danger)}
    .pill.running{background:rgba(255,154,74,.14);color:var(--accent)}
    .mini{
      display:grid;
      grid-template-columns:repeat(2,minmax(0,1fr));
      gap:.7rem;
    }
    .stat{
      padding:.8rem .9rem;
      border-radius:1rem;
      background:rgba(8,17,27,.72);
      border:1px solid rgba(149,177,205,.12);
    }
    .stat strong{display:block;font-size:1.35rem}
    .row{
      display:flex;
      justify-content:space-between;
      gap:1rem;
      align-items:center;
    }
    code{
      font-family:"IBM Plex Mono","SFMono-Regular","Menlo",monospace;
      font-size:.88em;
    }
    @media (max-width: 980px){
      .grid,.form-grid,.actions,.mini{grid-template-columns:1fr}
      .mast{align-items:flex-start;flex-direction:column}
      .row{align-items:flex-start;flex-direction:column}
    }
  </style>
</head>
<body>
  <main class="frame">
    <section class="mast">
      <div>
        <div class="eyebrow">Atlas Recovery Plane</div>
        <h1>Metis Control</h1>
        <p class="sub">Build replacement node images, verify removable media on the Texas flash host, and keep image templates fresh with sentinel-driven drift tracking.</p>
      </div>
      <div class="badge"><strong>Default flash host:</strong> <span id="default-host">{{.State.DefaultFlashHost}}</span></div>
    </section>

    <section id="status-banner" class="banner hidden" aria-live="polite">
      <div>
        <strong id="status-title">Ready</strong>
        <div id="status-text">Metis is ready.</div>
      </div>
    </section>

    <section class="grid">
      <div class="stack">
        <article class="card">
          <h2>Replacement Run</h2>
          <p class="hint">Use the mode that matches the recovery step you are in: build a fresh image, flash the latest published image, or run the full build-and-flash path in one shot.</p>
          <div class="form-grid">
            <label>Target node
              <select id="node-select"></select>
            </label>
            <label>Flash host
              <select id="host-select"></select>
            </label>
            <label style="grid-column:1 / -1">Detected removable media
              <select id="device-select"></select>
            </label>
          </div>
          <div class="microcopy" id="target-note"></div>
          <div class="microcopy" id="host-note"></div>
          <div class="microcopy" id="device-note"></div>
          <div class="microcopy" id="artifact-note"></div>
          <div class="actions">
            <button class="secondary" id="refresh-devices">Refresh media</button>
            <button class="secondary" id="build-only">Build image only</button>
            <button class="secondary" id="flash-only">Flash latest image</button>
            <button id="replace-run">Build and flash</button>
          </div>
        </article>

        <article class="card">
          <h2>Live Jobs</h2>
          <p class="hint">Progress updates stream from the running Metis operation. The replacement flow automatically tries to clear the stale Kubernetes node object before the card write.</p>
          <div id="jobs" class="list"></div>
        </article>
      </div>

      <div class="stack">
        <article class="card">
          <h2>Sentinel Watch</h2>
          <p class="hint">Ariadne should hit the internal sentinel watch route on a schedule. You can also run it manually here when you want the latest template recommendations immediately.</p>
          <div class="mini">
            <div class="stat">
              <span class="meta">Tracked nodes</span>
              <strong id="snapshot-count">0</strong>
            </div>
            <div class="stat">
              <span class="meta">Class targets</span>
              <strong id="target-count">0</strong>
            </div>
          </div>
          <div class="actions" style="grid-template-columns:1fr">
            <button id="sentinel-watch">Run sentinel watch now</button>
          </div>
        </article>

        <article class="card">
          <div class="row">
            <div>
              <h2>Recent Changes</h2>
              <p class="hint">This stream keeps the image/template story digestible: builds, flashes, snapshot intake, and sentinel-driven target changes all land here.</p>
            </div>
          </div>
          <div id="events" class="list"></div>
        </article>
      </div>
    </section>
  </main>
  <script id="boot" type="application/json">{{.BootJSON}}</script>
  <script>
    const boot = JSON.parse(document.getElementById('boot').textContent);
    let state = boot;
    let busy = false;
    let lastJobAlert = '';

    const nodeSelect = document.getElementById('node-select');
    const hostSelect = document.getElementById('host-select');
    const deviceSelect = document.getElementById('device-select');
    const jobsEl = document.getElementById('jobs');
    const eventsEl = document.getElementById('events');
    const snapshotCountEl = document.getElementById('snapshot-count');
    const targetCountEl = document.getElementById('target-count');
    const targetNoteEl = document.getElementById('target-note');
    const hostNoteEl = document.getElementById('host-note');
    const deviceNoteEl = document.getElementById('device-note');
    const artifactNoteEl = document.getElementById('artifact-note');
    const bannerEl = document.getElementById('status-banner');
    const bannerTitleEl = document.getElementById('status-title');
    const bannerTextEl = document.getElementById('status-text');
    const actionButtons = Array.from(document.querySelectorAll('button'));

    function fmtTime(value){
      if(!value){ return 'pending'; }
      const date = new Date(value);
      return isNaN(date.getTime()) ? value : date.toLocaleString();
    }

    function fmtBytes(value){
      if(!value){ return '0 B'; }
      const units = ['B','KiB','MiB','GiB','TiB'];
      let size = Number(value);
      let idx = 0;
      while(size >= 1024 && idx < units.length - 1){
        size /= 1024;
        idx += 1;
      }
      return size.toFixed(size >= 10 || idx === 0 ? 0 : 1) + ' ' + units[idx];
    }

    function fmtDuration(startValue, endValue){
      if(!startValue){ return ''; }
      const start = new Date(startValue);
      if(isNaN(start.getTime())){ return ''; }
      const end = endValue ? new Date(endValue) : new Date();
      if(isNaN(end.getTime())){ return ''; }
      let seconds = Math.max(0, Math.round((end.getTime() - start.getTime()) / 1000));
      const hours = Math.floor(seconds / 3600);
      seconds -= hours * 3600;
      const minutes = Math.floor(seconds / 60);
      seconds -= minutes * 60;
      if(hours){ return hours + 'h ' + minutes + 'm'; }
      if(minutes){ return minutes + 'm ' + seconds + 's'; }
      return seconds + 's';
    }

    function prettyLabel(value){
      return String(value || '')
        .replaceAll('_', ' ')
        .replace(/\b\w/g, (match)=>match.toUpperCase());
    }

    function prettyJobKind(kind){
      switch(kind){
        case 'build': return 'Build';
        case 'flash': return 'Flash Latest';
        case 'replace': return 'Build + Flash';
        case 'idle': return 'Idle';
        default: return prettyLabel(kind);
      }
    }

    function banner(kind, title, text){
      bannerEl.className = 'banner ' + kind;
      bannerTitleEl.textContent = title;
      bannerTextEl.textContent = text;
    }

    function clearBanner(){
      bannerEl.className = 'banner hidden';
    }

    function setBusy(nextBusy){
      busy = nextBusy;
      actionButtons.forEach((button)=>{
        button.disabled = nextBusy;
      });
    }

    function bestDevicePath(){
      return state.preferred_device || (state.devices[0] ? state.devices[0].path : '');
    }

    function setOptions(select, values, labeler, emptyLabel){
      const current = select.value;
      select.innerHTML = '';
      if(!values.length){
        const option = document.createElement('option');
        option.value = '';
        option.textContent = emptyLabel;
        select.appendChild(option);
        select.value = '';
        return;
      }
      values.forEach((value)=>{
        const option = document.createElement('option');
        option.value = value;
        option.textContent = labeler ? labeler(value) : value;
        select.appendChild(option);
      });
      if(current && values.includes(current)){
        select.value = current;
      }
    }

    function renderJobs(){
      jobsEl.innerHTML = '';
      const jobs = state.jobs.length ? state.jobs : [{kind:'idle',status:'done',message:'No active or recent Metis jobs yet.',progress_pct:100,started_at:new Date().toISOString(),finished_at:new Date().toISOString()}];
      jobs.forEach((job)=>{
        const wrap = document.createElement('div');
        wrap.className = 'item';
        const statusClass = job.status === 'error' ? 'error' : (job.status === 'done' ? 'done' : (job.status === 'running' ? 'running' : ''));
        const title = prettyJobKind(job.kind) + (job.node ? ' · ' + job.node : '');
        const started = fmtTime(job.started_at) + (job.device ? ' · ' + job.device : '') + (job.host ? ' · ' + job.host : '');
        const timingBits = [];
        if(job.stage){ timingBits.push('stage: ' + prettyLabel(job.stage)); }
        const stageDuration = fmtDuration(job.stage_started_at || job.started_at, job.finished_at);
        if(stageDuration){
          timingBits.push((job.status === 'running' ? 'stage elapsed ' : 'stage duration ') + stageDuration);
        }
        const totalDuration = fmtDuration(job.started_at, job.finished_at);
        if(totalDuration && job.stage_started_at){
          timingBits.push((job.status === 'running' ? 'total elapsed ' : 'total duration ') + totalDuration);
        }
        if(job.updated_at && job.status === 'running'){
          timingBits.push('last update ' + fmtDuration(job.updated_at, new Date().toISOString()) + ' ago');
        }
        const detailBits = [];
        if(job.written_bytes){ detailBits.push(fmtBytes(job.written_bytes) + ' / ' + fmtBytes(job.total_bytes)); }
        if(job.artifact){ detailBits.push(job.artifact); }
        if(job.error){ detailBits.push(job.error); }
        wrap.innerHTML =
          '<div class="item-head">' +
            '<span>' + title + '</span>' +
            '<span class="pill ' + statusClass + '">' + job.status + '</span>' +
          '</div>' +
          '<div>' + (job.message || job.stage || 'queued') + '</div>' +
          '<div class="meta">' + started + '</div>' +
          '<div class="meta">' + timingBits.join(' · ') + '</div>' +
          '<div class="meta">' + detailBits.join(' · ') + '</div>' +
          '<div class="bar"><span style="width:' + Math.max(0, Math.min(100, job.progress_pct || 0)) + '%"></span></div>';
        jobsEl.appendChild(wrap);
      });

      const newestError = jobs.find((job)=>job.status === 'error');
      if(newestError){
        const signature = [newestError.id, newestError.error || newestError.message || newestError.stage || 'error'].join(':');
        if(signature !== lastJobAlert){
          lastJobAlert = signature;
          banner('error', 'Metis job failed', newestError.error || newestError.message || 'Check the live jobs panel for details.');
        }
      }
    }

    function renderEvents(){
      eventsEl.innerHTML = '';
      state.events.forEach((event)=>{
        const wrap = document.createElement('div');
        wrap.className = 'item';
        wrap.innerHTML =
          '<div class="item-head">' +
            '<span>' + event.summary + '</span>' +
            '<span class="meta">' + fmtTime(event.time) + '</span>' +
          '</div>' +
          '<div class="meta"><code>' + event.kind + '</code></div>';
        eventsEl.appendChild(wrap);
      });
    }

    function render(){
      const nodeNames = (state.nodes || []).map((node)=>node.name).filter(Boolean);
      setOptions(nodeSelect, nodeNames, null, 'No replacement nodes available');
      if(!nodeSelect.value && nodeNames.length){
        nodeSelect.value = nodeNames[0];
      }
      const trackedNodes = Math.max((state.snapshots || []).length, (state.flash_hosts || []).length);
      targetNoteEl.textContent = nodeNames.length
        ? 'Only nodes with full replacement definitions appear here. Current replacement coverage: ' + nodeNames.length + ' node(s)' + (trackedNodes ? ' across ' + trackedNodes + ' tracked cluster node(s).' : '.')
        : 'No inventory-backed replacement nodes are loaded yet.';

      setOptions(hostSelect, state.flash_hosts || [], null, 'No flash hosts available');
      if(state.selected_host && (state.flash_hosts || []).includes(state.selected_host)){
        hostSelect.value = state.selected_host;
      }
      if(!hostSelect.value && state.default_flash_host){
        hostSelect.value = state.default_flash_host;
      }

      const devicePaths = (state.devices || []).map((device)=>device.path);
      setOptions(deviceSelect, devicePaths, (path)=>{
        const dev = state.devices.find((item)=>item.path === path);
        if(!dev){ return path; }
        const parts = [dev.path, fmtBytes(dev.size_bytes)];
        if(dev.model){ parts.push(dev.model); }
        else if(dev.transport){ parts.push(dev.transport); }
        return parts.join(' · ');
      }, state.device_error || 'No removable media detected');

      const preferredDevice = bestDevicePath();
      const selectedDeviceStillExists = devicePaths.includes(deviceSelect.value);
      if(preferredDevice && (!selectedDeviceStillExists || !deviceSelect.value)){
        deviceSelect.value = preferredDevice;
      }

      const selectedHost = hostSelect.value || state.default_flash_host;
      hostNoteEl.textContent = 'Metis will inspect media and run the flash writer/verifier on ' + selectedHost + ' through a short-lived in-cluster worker. ' + state.default_flash_host + ' remains the default flash host.';

      if(state.device_error){
        deviceNoteEl.textContent = state.device_error;
      } else if(state.devices.length){
        deviceNoteEl.textContent = 'Best candidate preselected: ' + (bestDevicePath() || 'none');
      } else {
        deviceNoteEl.textContent = 'Insert an SD card or removable drive on the selected flash host, then refresh media.';
      }

      const artifact = (state.artifacts || {})[nodeSelect.value];
      const hasArtifact = !!(artifact && artifact.ref);
      artifactNoteEl.textContent = hasArtifact
        ? 'Latest published image: ' + artifact.ref + (artifact.build_tag ? ' · build ' + artifact.build_tag : '') + '. Flash latest writes this image directly; build and flash rebuilds first.'
        : 'Build image only publishes <node>:latest into Harbor. Flash latest stays disabled until Metis has a recorded latest image.';

      document.getElementById('build-only').disabled = busy || !nodeSelect.value;
      document.getElementById('flash-only').disabled = busy || !nodeSelect.value || !deviceSelect.value || !!state.device_error || !hasArtifact;
      document.getElementById('refresh-devices').disabled = busy;
      document.getElementById('replace-run').disabled = busy || !nodeSelect.value || !deviceSelect.value || !!state.device_error;
      document.getElementById('sentinel-watch').disabled = busy;
      deviceSelect.disabled = busy || !devicePaths.length;

      renderJobs();
      renderEvents();
      snapshotCountEl.textContent = state.snapshots.length;
      targetCountEl.textContent = Object.keys(state.targets || {}).length;
    }

    async function refreshState(opts = {}){
      const host = hostSelect.value || state.default_flash_host;
      const resp = await fetch('/api/state?host=' + encodeURIComponent(host));
      if(!resp.ok){
        const text = await resp.text();
        throw new Error(text || 'Could not refresh Metis state');
      }
      state = await resp.json();
      render();
      if(!opts.silent && state.device_error){
        banner('warn', 'Flash host needs attention', state.device_error);
      }
    }

    async function refreshDevices(){
      const host = hostSelect.value || state.default_flash_host;
      const resp = await fetch('/api/devices?host=' + encodeURIComponent(host));
      if(!resp.ok){
        const text = await resp.text();
        throw new Error(text || 'Could not refresh removable media');
      }
      const payload = await resp.json();
      state.devices = payload.devices || [];
      state.selected_host = host;
      state.device_error = '';
      render();
    }

    async function post(path, body){
      const resp = await fetch(path, {
        method:'POST',
        headers:{'Content-Type':'application/json'},
        body: JSON.stringify(body)
      });
      if(!resp.ok){
        const text = await resp.text();
        throw new Error(text || ('Request failed for ' + path));
      }
      const contentType = resp.headers.get('content-type') || '';
      return contentType.includes('application/json') ? resp.json() : {};
    }

    function requireValue(value, message){
      if(stringsafe(value)){
        return true;
      }
      banner('error', 'Missing input', message);
      return false;
    }

    function stringsafe(value){
      return !!String(value || '').trim();
    }

    async function runAction(title, pending, fn){
      try {
        setBusy(true);
        banner('info', title, pending);
        await fn();
      } catch (error) {
        banner('error', title + ' failed', error.message || String(error));
      } finally {
        setBusy(false);
        render();
      }
    }

    document.getElementById('refresh-devices').addEventListener('click', async ()=>{
      await runAction('Refreshing media', 'Checking removable devices on the selected flash host.', async ()=>{
        await refreshDevices();
        await refreshState({silent:true});
        if(state.device_error){
          banner('warn', 'Flash host needs attention', state.device_error);
          return;
        }
        banner('success', 'Media refreshed', state.devices.length ? 'Detected ' + state.devices.length + ' flash candidate(s).' : 'No removable media candidates are visible yet.');
      });
    });

    document.getElementById('build-only').addEventListener('click', async ()=>{
      if(!requireValue(nodeSelect.value, 'Choose the target node image you want Metis to build first.')){
        return;
      }
      await runAction('Starting image build', 'Queueing the node image build now.', async ()=>{
        await post('/api/jobs/build', {node: nodeSelect.value});
        await refreshState({silent:true});
        banner('success', 'Image build queued', 'Metis started building the replacement image for ' + nodeSelect.value + '. Successful build-only runs publish ' + nodeSelect.value + ':latest to Harbor and keep the newest 3 builds.');
      });
    });

    document.getElementById('flash-only').addEventListener('click', async ()=>{
      if(!requireValue(nodeSelect.value, 'Choose the target node whose latest published image should be flashed.')){
        return;
      }
      if(!requireValue(deviceSelect.value, 'Choose removable media before starting a flash-only run.')){
        return;
      }
      if(state.device_error){
        banner('error', 'Flash host unavailable', state.device_error);
        return;
      }
      await runAction('Starting flash latest', 'Queueing a flash-only run from the latest published Harbor image.', async ()=>{
        await post('/api/jobs/flash', {node: nodeSelect.value, host: hostSelect.value, device: deviceSelect.value});
        await refreshState({silent:true});
        banner('success', 'Flash-only workflow queued', 'Metis is flashing the latest published image for ' + nodeSelect.value + ' onto ' + deviceSelect.value + ' and will verify it before finishing.');
      });
    });

    document.getElementById('replace-run').addEventListener('click', async ()=>{
      if(!requireValue(nodeSelect.value, 'Choose the target node whose SD card image should be built and flashed.')){
        return;
      }
      if(!requireValue(deviceSelect.value, 'Choose removable media before starting a build-and-flash run.')){
        return;
      }
      if(state.device_error){
        banner('error', 'Flash host unavailable', state.device_error);
        return;
      }
      await runAction('Starting build and flash', 'Queueing the full replacement workflow now.', async ()=>{
        await post('/api/jobs/replace', {node: nodeSelect.value, host: hostSelect.value, device: deviceSelect.value});
        await refreshState({silent:true});
        banner('success', 'Replacement workflow queued', 'Metis is rebuilding the image for ' + nodeSelect.value + ', flashing ' + deviceSelect.value + ', and verifying the media before finishing.');
      });
    });

    document.getElementById('sentinel-watch').addEventListener('click', async ()=>{
      await runAction('Running sentinel watch', 'Refreshing template recommendations from the latest snapshots.', async ()=>{
        await post('/api/sentinel/watch', {});
        await refreshState({silent:true});
        banner('success', 'Sentinel watch complete', 'Metis refreshed its template recommendations.');
      });
    });

    hostSelect.addEventListener('change', async ()=>{
      await runAction('Changing flash host', 'Loading removable media candidates for the selected flash host.', async ()=>{
        await refreshState({silent:true});
        await refreshDevices();
        await refreshState({silent:true});
        if(!state.device_error){
          banner('success', 'Flash host ready', 'Loaded removable media candidates for ' + (hostSelect.value || state.default_flash_host) + '.');
        }
      });
    });
    nodeSelect.addEventListener('change', render);

    render();
    clearBanner();
    (async ()=>{
      try {
        await refreshDevices();
      } catch (_error) {
        // Initial media scan can fail if the selected host is unavailable.
      }
    })();

    async function pollLoop(){
      try {
        await refreshState({silent:true});
      } catch (_error) {
        // Keep the live dashboard calm during background polling.
      }
      const running = (state.jobs || []).some((job)=>job.status === 'running');
      setTimeout(pollLoop, running ? 2000 : 5000);
    }
    pollLoop();
  </script>
</body>
</html>
