OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / dashboard / blindspark.html

1682 lines · html ref:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>
/* HiveRelay UI auth shim. When the relay serves this page through a trusted
   reverse proxy (ui.exposeToken), it injects <meta name="hiverelay-ui-token">
   holding a token it will accept as a bearer credential. Attach it to API
   calls so the dashboard works behind a proxy where the localhost check
   can't apply. No meta -> no-op, so direct/localhost use is unchanged. */
(function () {
  try {
    var m = document.querySelector('meta[name="hiverelay-ui-token"]');
    var t = m && m.getAttribute('content');
    if (!t) return;
    var orig = window.fetch.bind(window);
    window.fetch = function (input, init) {
      init = init || {};
      var raw = (typeof input === 'string') ? input : (input && (input.url || input.href)) || String(input || '');
      var u = new URL(raw, window.location.href);
      var h = new Headers((init && init.headers) || (input && input.headers) || {});
      if (u.origin === window.location.origin && !h.has('Authorization')) h.set('Authorization', 'Bearer ' + t);
      init.headers = h;
      return orig(input, init);
    };
  } catch (e) { /* never block page load on the auth shim */ }
})();
</script>
<title>Blindspark</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' fill='%2316213e'/%3E%3Cpath d='M50 10 C 54 38 60 44 88 50 C 60 56 54 62 50 90 C 46 62 40 56 12 50 C 40 44 46 38 50 10 Z' fill='%23f5c842'/%3E%3C/svg%3E">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
  --bg:#0e1118;--bg-glow:#131a2b;
  --card:#151a26;--card-hi:#1a2132;--card-border:#222b3e;--card-border-hi:#2e3b56;
  --green:#3fb950;--orange:#f0a64a;--cyan:#58a6ff;--red:#f47067;--accent:#f5c842;
  --text:#d6dde8;--text-dim:#8493a8;--text-faint:#7c89a0;--text-bright:#fff;
  --mono:'SF Mono','Cascadia Code','Fira Code','Consolas',monospace;
  --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
  --radius:14px;--radius-sm:9px;
}
html{font-size:15px}
body{
  background:var(--bg) radial-gradient(1100px 520px at 50% -160px,var(--bg-glow),transparent);
  color:var(--text);font-family:var(--sans);min-height:100vh;
  display:flex;justify-content:center;padding:1.5rem 1.25rem 3rem;
}
.wrap{width:100%;max-width:680px}
a{color:var(--cyan);text-decoration:none}
button{font-family:inherit;cursor:pointer}
:focus-visible{outline:2px solid var(--cyan);outline-offset:2px;border-radius:4px}
@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}

/* ── Header ──────────────────────────────────────────────────────── */
.hdr{display:flex;align-items:center;gap:1rem;margin-bottom:1.6rem}
.brand{display:flex;align-items:center;gap:.7rem}
.spark{width:30px;height:30px;flex-shrink:0;filter:drop-shadow(0 0 8px rgba(245,200,66,.4))}
.brand-txt{display:flex;flex-direction:column;line-height:1.1}
.brand-txt h1{font-size:1.2rem;color:var(--text-bright);font-weight:650;letter-spacing:.2px}
.brand-txt span{font-size:.56rem;color:var(--text-dim);letter-spacing:.9px;text-transform:uppercase}
.hdr-right{margin-left:auto;display:flex;align-items:center;gap:.7rem}
.setup-link{
  font-size:.74rem;color:var(--text-dim);border:1px solid var(--card-border);
  border-radius:999px;padding:.34rem .8rem;transition:color .15s,border-color .15s;
}
.setup-link:hover{color:var(--text-bright);border-color:var(--card-border-hi)}
.status{
  display:flex;align-items:center;gap:.45rem;background:rgba(63,185,80,.1);color:var(--green);
  padding:.34rem .8rem;border-radius:999px;font-size:.74rem;font-family:var(--mono);
  border:1px solid rgba(63,185,80,.28);white-space:nowrap;
}
.status::before{content:'';width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 6px currentColor}
.status.off{background:rgba(244,112,103,.1);color:var(--red);border-color:rgba(244,112,103,.3)}
.status.warn{background:rgba(240,166,74,.1);color:var(--orange);border-color:rgba(240,166,74,.3)}

/* ── Identity hero ───────────────────────────────────────────────── */
.hero{
  background:linear-gradient(180deg,var(--card-hi),var(--card));
  border:1px solid var(--card-border);border-radius:var(--radius);
  padding:1.5rem 1.5rem 1.4rem;margin-bottom:1rem;text-align:center;
}
.hero-name{font-size:1.7rem;font-weight:700;color:var(--text-bright);letter-spacing:.2px;word-break:break-word}
.pk{
  display:inline-flex;align-items:center;gap:.45rem;margin-top:.7rem;
  background:var(--card);border:1px solid var(--card-border);border-radius:999px;
  padding:.36rem .85rem;font-size:.74rem;font-family:var(--mono);color:var(--text-dim);
  transition:border-color .15s,color .15s;
}
.pk:hover{border-color:var(--card-border-hi);color:var(--text)}
.pk .ic{opacity:.6;font-size:.78rem}
.hero-tag{font-size:.84rem;color:var(--text-faint);margin-top:1rem;line-height:1.5}

/* ── Stats ───────────────────────────────────────────────────────── */
.stats{display:grid;grid-template-columns:repeat(2,1fr);gap:.85rem;margin-bottom:1rem}
.stat{
  background:linear-gradient(180deg,var(--card-hi),var(--card));
  border:1px solid var(--card-border);border-radius:var(--radius);padding:1.05rem 1.15rem;
}
.s-label{font-size:.64rem;text-transform:uppercase;letter-spacing:1.3px;color:var(--text-dim);margin-bottom:.5rem}
.s-val{font-size:1.7rem;font-weight:700;font-family:var(--mono);color:var(--text-bright);line-height:1.05}
.s-val.green{color:var(--green)}
.s-val.cyan{color:var(--cyan)}
.s-val.orange{color:var(--orange)}
.s-sub{font-size:.66rem;color:var(--text-faint);margin-top:.35rem;font-family:var(--mono)}

/* ── Info rows ───────────────────────────────────────────────────── */
.panel{
  background:var(--card);border:1px solid var(--card-border);border-radius:var(--radius);
  padding:.4rem 1.25rem;margin-bottom:1rem;
}
.row{display:flex;align-items:center;gap:1rem;padding:.95rem 0;border-bottom:1px solid var(--card-border)}
.row:last-child{border-bottom:none}
.row-k{font-size:.74rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:.8px;flex-shrink:0;width:34%}
.row-v{font-size:.9rem;color:var(--text);display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;justify-content:flex-end;margin-left:auto;text-align:right}
.badge{
  font-size:.72rem;font-weight:600;padding:.25rem .65rem;border-radius:999px;
  background:rgba(88,166,255,.12);color:var(--cyan);border:1px solid rgba(88,166,255,.25);
  text-transform:capitalize;
}
.badge.review{background:rgba(240,166,74,.12);color:var(--orange);border-color:rgba(240,166,74,.25)}
.badge.open{background:rgba(63,185,80,.12);color:var(--green);border-color:rgba(63,185,80,.25)}
.row-note{font-size:.72rem;color:var(--text-faint);width:100%;text-align:right}
.addr{
  font-family:var(--mono);font-size:.78rem;color:var(--cyan);text-align:left;
  background:rgba(88,166,255,.08);border:1px solid rgba(88,166,255,.2);
  border-radius:var(--radius-sm);padding:.3rem .6rem;cursor:pointer;word-break:break-all;
}
.row-btn{
  font-size:.74rem;color:var(--text);background:var(--card-hi);border:1px solid var(--card-border-hi);
  border-radius:var(--radius-sm);padding:.32rem .65rem;transition:border-color .15s,color .15s,opacity .15s;
}
.row-btn:hover{border-color:var(--cyan);color:var(--text-bright)}
.row-btn:disabled{opacity:.55;cursor:default}
.muted{color:var(--text-faint)}

/* ── Hosted apps ─────────────────────────────────────────────────── */
.apps-card{
  background:var(--card);border:1px solid var(--card-border);border-radius:var(--radius);
  padding:1.1rem 1.25rem 1rem;margin-bottom:1.4rem;
}
.services-card{
  background:transparent;border:0;border-radius:0;padding:0;margin:1.25rem 0 1.35rem;
}
.services-card .apps-head{padding:0 .15rem;margin-bottom:.65rem}
.services-card .apps-head .t{font-size:.72rem;color:var(--text-bright);letter-spacing:1.1px}
.apps-head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:.5rem}
.apps-head .t{font-size:.68rem;text-transform:uppercase;letter-spacing:1.3px;color:var(--text-dim)}
.apps-head .c{font-size:.74rem;color:var(--text-faint);font-family:var(--mono)}
.apps-head-actions{display:flex;align-items:center;gap:.6rem}
.apps-list{max-height:280px;overflow-y:auto;scrollbar-width:thin}
.app{display:flex;align-items:center;gap:.7rem;padding:.6rem 0;border-bottom:1px solid var(--card-border)}
.app:last-child{border-bottom:none}
.app-dot{width:7px;height:7px;border-radius:50%;background:var(--green);flex-shrink:0;box-shadow:0 0 5px var(--green)}
.app-name{font-size:.84rem;color:var(--text);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.app-key{font-size:.72rem;font-family:var(--mono);color:var(--text-faint);flex-shrink:0}
.apps-empty{text-align:center;color:var(--text-faint);font-size:.82rem;padding:1.4rem 0}
.app-icon{width:22px;height:22px;border-radius:6px;flex-shrink:0;object-fit:cover;background:var(--card-hi)}

/* ── Seed-an-app ─────────────────────────────────────────────────── */
.seedbtn{
  font-size:.72rem;color:var(--accent);background:rgba(245,200,66,.1);
  border:1px solid rgba(245,200,66,.28);border-radius:999px;padding:.22rem .7rem;
  transition:filter .15s;
}
.seedbtn:hover{filter:brightness(1.15)}
.seedform{display:none;margin:.6rem 0 .2rem;padding:.85rem;background:var(--card-hi);border:1px solid var(--card-border);border-radius:var(--radius-sm)}
.seedform.show{display:block}
.seedform label{display:block;font-size:.66rem;text-transform:uppercase;letter-spacing:.8px;color:var(--text-dim);margin:.5rem 0 .25rem}
.seedform label:first-child{margin-top:0}
.seedform input[type=text]{
  width:100%;background:var(--bg);border:1px solid var(--card-border-hi);border-radius:7px;
  color:var(--text-bright);font-family:var(--mono);font-size:.8rem;padding:.5rem .6rem;
}
.seedform input[type=text]:focus{outline:none;border-color:var(--accent)}
.seedform .chk{display:flex;align-items:center;gap:.5rem;margin-top:.7rem;font-size:.78rem;color:var(--text);text-transform:none;letter-spacing:0}
.seed-optional{text-transform:none;color:var(--text-faint)}
.seedform .hint{font-size:.68rem;color:var(--text-faint);margin-top:.5rem;line-height:1.4}
.seedform .err{font-size:.72rem;color:var(--red);margin-top:.5rem;min-height:1em}
.seedform .acts{display:flex;justify-content:flex-end;gap:.5rem;margin-top:.8rem}
.seedform .acts button{border-radius:7px;padding:.42rem .9rem;font-size:.78rem;border:1px solid var(--card-border-hi);background:transparent;color:var(--text-dim)}
.seedform .acts button.go{background:var(--accent);border-color:var(--accent);color:#10131b;font-weight:600}
.seedform .acts button[disabled]{opacity:.5;cursor:default}
.storagecap{gap:.5rem;align-items:center}
.storagecap input{width:82px;background:var(--bg);border:1px solid var(--card-border-hi);border-radius:7px;color:var(--text);padding:.4rem .5rem;font-family:var(--mono);font-size:.82rem}
.storagecap input:focus{outline:none;border-color:var(--accent)}
.storagecap .u{color:var(--text-dim);font-size:.78rem}
.storagecap button{border-radius:7px;padding:.42rem .95rem;font-size:.78rem;border:1px solid var(--card-border-hi);background:var(--card-hi);color:var(--text);cursor:pointer}
.storagecap button[disabled]{opacity:.5;cursor:default}

.svc-hero{
  padding:.15rem .15rem .25rem;margin:.1rem 0 .8rem;
}
.svc-hero-top{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem}
.svc-title{font-size:1rem;font-weight:700;color:var(--text-bright);line-height:1.25}
.svc-copy{font-size:.78rem;color:var(--text-faint);line-height:1.45;margin-top:.35rem;max-width:44rem}
.svc-pill{
  display:inline-flex;align-items:center;gap:.35rem;border-radius:999px;padding:.25rem .6rem;
  font-size:.68rem;font-family:var(--mono);white-space:nowrap;border:1px solid var(--card-border-hi);
  color:var(--text-dim);background:var(--card);
}
.svc-pill.on{color:var(--green);border-color:rgba(63,185,80,.28);background:rgba(63,185,80,.1)}
.svc-pill.warn{color:var(--orange);border-color:rgba(240,166,74,.28);background:rgba(240,166,74,.1)}
.svc-progress{
  display:flex;align-items:center;justify-content:space-between;gap:.75rem;
  margin:.75rem 0 0;padding:.68rem .78rem;border:1px solid rgba(88,166,255,.26);
  border-radius:var(--radius-sm);background:rgba(88,166,255,.08);
}
.svc-progress strong{font-size:.78rem;color:var(--text-bright)}
.svc-progress span{font-size:.68rem;color:var(--text-faint);font-family:var(--mono);text-align:right}
.svc-summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.55rem;margin:.75rem 0 .15rem}
.svc-summary-item{
  border:1px solid var(--card-border);border-radius:var(--radius-sm);
  background:var(--card-hi);padding:.62rem .7rem;min-width:0;
}
.svc-summary-item strong{display:block;font-family:var(--mono);font-size:1rem;color:var(--text-bright);line-height:1.05}
.svc-summary-item span{display:block;margin-top:.25rem;font-size:.62rem;text-transform:uppercase;letter-spacing:.8px;color:var(--text-faint);line-height:1.25}
.svc-plan{
  margin:.75rem 0 .15rem;padding:.72rem .78rem;border:1px solid rgba(88,166,255,.24);
  border-radius:var(--radius-sm);background:rgba(88,166,255,.07);
}
.svc-plan.empty{display:none}
.svc-plan.draft{border-color:rgba(245,200,66,.28);background:rgba(245,200,66,.07)}
.svc-plan strong{display:block;font-size:.78rem;color:var(--text-bright);line-height:1.25}
.svc-plan-lines{display:flex;flex-direction:column;gap:.38rem;margin-top:.48rem}
.svc-plan-line{display:flex;align-items:flex-start;gap:.42rem;flex-wrap:wrap}
.svc-plan-label{
  font-size:.62rem;text-transform:uppercase;letter-spacing:.8px;color:var(--text-dim);
  min-width:2.7rem;padding-top:.12rem;
}
.svc-plan-chip{
  border:1px solid rgba(88,166,255,.26);border-radius:999px;padding:.13rem .46rem;
  color:var(--cyan);background:rgba(88,166,255,.08);font-size:.64rem;line-height:1.25;
}
.svc-plan-chip.stop{color:var(--orange);border-color:rgba(240,166,74,.28);background:rgba(240,166,74,.08)}
.svc-plan-note{font-size:.67rem;color:var(--text-faint);line-height:1.35;margin-top:.48rem}
.svc-section{margin-top:.9rem}
.svc-section-title{
  font-size:.64rem;text-transform:uppercase;letter-spacing:1.2px;color:var(--text-dim);
  margin-bottom:.55rem;display:flex;justify-content:space-between;gap:.8rem;
}
.svc-bundles{display:grid;grid-template-columns:1fr;gap:.65rem}
.svc-bundle{
  display:flex;align-items:center;justify-content:space-between;gap:.9rem;padding:.85rem;
  border:1px solid rgba(245,200,66,.24);border-radius:var(--radius-sm);
  background:rgba(245,200,66,.07);
}
.svc-bundle strong{display:block;color:var(--text-bright);font-size:.88rem;margin-bottom:.18rem}
.svc-bundle span{display:block;color:var(--text-faint);font-size:.72rem;line-height:1.4}
.svc-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.65rem}
.svc-card{
  position:relative;display:flex;gap:.65rem;align-items:flex-start;min-height:86px;
  padding:.85rem;border:1px solid var(--card-border);border-radius:var(--radius-sm);
  background:var(--card-hi);cursor:pointer;transition:border-color .15s,background .15s,transform .15s;
}
.svc-card:hover{border-color:var(--card-border-hi);transform:translateY(-1px)}
.svc-card:focus-within{outline:2px solid var(--cyan);outline-offset:2px}
.svc-card.selected{border-color:rgba(88,166,255,.45);background:rgba(88,166,255,.08)}
.svc-card.running{border-color:rgba(63,185,80,.34)}
.svc-card.disabled{cursor:default;opacity:.72}
.svc-card.disabled:hover{transform:none;border-color:var(--card-border)}
.svc-card input{position:absolute;opacity:0;pointer-events:none}
.svc-checkmark{
  width:18px;height:18px;border-radius:5px;border:1px solid var(--card-border-hi);
  background:var(--card);flex-shrink:0;margin-top:.05rem;display:flex;align-items:center;justify-content:center;
  color:#10131b;font-size:.76rem;font-weight:800;
}
.svc-card.selected .svc-checkmark{background:var(--cyan);border-color:var(--cyan)}
.svc-card.selected .svc-checkmark::after{content:'✓'}
.svc-card-content{min-width:0;flex:1}
.svc-name{display:block;font-size:.86rem;font-weight:650;color:var(--text-bright);line-height:1.25}
.svc-desc{display:block;font-size:.7rem;color:var(--text-faint);line-height:1.35;margin-top:.25rem}
.svc-state{
  display:inline-flex;margin-top:.35rem;border-radius:999px;padding:.14rem .45rem;
  font-size:.6rem;text-transform:uppercase;letter-spacing:.65px;border:1px solid var(--card-border);
  color:var(--text-faint);background:rgba(255,255,255,.02);
}
.svc-state.live{color:var(--green);border-color:rgba(63,185,80,.28);background:rgba(63,185,80,.08)}
.svc-state.pending{color:var(--cyan);border-color:rgba(88,166,255,.28);background:rgba(88,166,255,.08)}
.svc-state.stopping{color:var(--orange);border-color:rgba(240,166,74,.28);background:rgba(240,166,74,.08)}
.svc-state.draft{color:var(--accent);border-color:rgba(245,200,66,.3);background:rgba(245,200,66,.08)}
.svc-tags{display:flex;gap:.35rem;flex-wrap:wrap;margin-top:.45rem}
.svc-tag{
  font-size:.62rem;color:var(--text-faint);border:1px solid var(--card-border);
  border-radius:999px;padding:.13rem .42rem;background:rgba(255,255,255,.02)
}
.svc-tag.running{color:var(--green);border-color:rgba(63,185,80,.25);background:rgba(63,185,80,.08)}
.svc-actions-bar{
  display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:.85rem;
  border-top:1px solid var(--card-border);padding-top:.85rem;
}
.svc-actions-bar .hint{font-size:.7rem;color:var(--text-faint);line-height:1.35}
.svc-actions-bar .buttons{display:flex;gap:.5rem;flex-wrap:wrap;justify-content:flex-end}
.svc-btn{
  border:1px solid var(--card-border-hi);border-radius:999px;background:var(--card-hi);
  color:var(--text);padding:.38rem .85rem;font-size:.74rem;transition:border-color .15s,color .15s,filter .15s;
}
.svc-btn:hover{border-color:var(--cyan);color:var(--text-bright)}
.svc-btn.primary{background:var(--accent);border-color:var(--accent);color:#10131b;font-weight:700}
.svc-btn.danger{color:var(--red);border-color:rgba(244,112,103,.28);background:rgba(244,112,103,.08)}
.svc-btn:disabled{opacity:.55;cursor:default;filter:saturate(.75)}
.svc-btn:disabled:hover{border-color:var(--card-border-hi);color:var(--text)}
.svc-live-list{display:flex;flex-direction:column;border:1px solid var(--card-border);border-radius:var(--radius-sm);overflow:hidden}
.svc-live-row{display:flex;align-items:center;gap:.65rem;padding:.68rem .78rem;border-bottom:1px solid var(--card-border)}
.svc-live-row:last-child{border-bottom:none}
.svc-dot{width:8px;height:8px;border-radius:50%;background:var(--orange);box-shadow:0 0 5px currentColor;flex-shrink:0}
.svc-dot.running{background:var(--green)}
.svc-live-main{min-width:0;flex:1}
.svc-live-main strong{display:block;font-size:.82rem;color:var(--text)}
.svc-live-main span{display:block;font-size:.67rem;color:var(--text-faint);margin-top:.12rem}
.svc-live-status{font-family:var(--mono);font-size:.68rem;color:var(--text-faint)}
.svc-model-card{
  display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr) auto;gap:.5rem;
  align-items:end;border:1px solid var(--card-border);border-radius:var(--radius-sm);
  padding:.85rem;background:var(--card-hi);
}
.svc-model-message{
  grid-column:1 / -1;min-height:1rem;font-size:.72rem;color:var(--text-faint);line-height:1.35;
}
.svc-model-message.error{color:var(--red)}
.svc-model-message.ok{color:var(--green)}
.svc-model-field label{display:block;font-size:.62rem;text-transform:uppercase;letter-spacing:1px;color:var(--text-dim);margin-bottom:.25rem}
.svc-model-field input{
  width:100%;background:var(--bg);border:1px solid var(--card-border-hi);border-radius:7px;
  color:var(--text-bright);font-family:var(--mono);font-size:.78rem;padding:.5rem .6rem;
}
.svc-meter{
  border:1px solid var(--card-border);border-radius:var(--radius-sm);overflow:hidden;background:var(--card-hi)
}
.svc-meter-box{margin-top:.7rem}
.svc-meter-row{display:flex;justify-content:space-between;gap:.8rem;padding:.62rem .75rem;border-bottom:1px solid var(--card-border)}
.svc-meter-row:last-child{border-bottom:none}
.svc-meter-note{font-size:.66rem;color:var(--text-faint);line-height:1.35;padding:.58rem .75rem;border-top:1px solid var(--card-border)}
.lease-controls{display:flex;flex-wrap:wrap;align-items:center;gap:.45rem}
.lease-rate{width:5rem}

dialog{
  width:min(92vw,460px);border:1px solid var(--card-border-hi);border-radius:var(--radius);
  background:var(--card);color:var(--text);padding:1.1rem;box-shadow:0 24px 80px rgba(0,0,0,.45);
}
dialog::backdrop{background:rgba(4,7,14,.68)}
.dlg-title{font-size:1rem;color:var(--text-bright);font-weight:700;margin-bottom:.45rem}
.dlg-copy{font-size:.82rem;color:var(--text-faint);line-height:1.45;margin-bottom:.85rem}
.dlg-field{display:flex;flex-direction:column;gap:.4rem}
.dlg-field label{font-size:.68rem;text-transform:uppercase;letter-spacing:1px;color:var(--text-dim)}
.dlg-field input{
  width:100%;background:var(--bg);color:var(--text-bright);border:1px solid var(--card-border-hi);
  border-radius:var(--radius-sm);padding:.65rem .75rem;font-family:var(--mono);font-size:.82rem;
}
.dlg-error{min-height:1.1rem;color:var(--red);font-size:.78rem;margin-top:.5rem}
.dlg-actions{display:flex;justify-content:space-between;gap:.55rem;margin-top:1rem}
.dlg-actions span{display:flex;gap:.55rem}

/* ── Footer ──────────────────────────────────────────────────────── */
.foot{text-align:center;font-size:.74rem;color:var(--text-faint);line-height:1.6;padding:0 1rem}

.toast{
  position:fixed;left:50%;bottom:1.5rem;transform:translateX(-50%) translateY(20px);
  background:var(--card-hi);border:1px solid var(--card-border-hi);color:var(--text-bright);
  padding:.55rem 1.1rem;border-radius:999px;font-size:.8rem;opacity:0;pointer-events:none;
  transition:opacity .2s,transform .2s;z-index:200;
}
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}

@media(max-width:440px){
  .hdr{align-items:flex-start;flex-wrap:wrap}
  .hdr-right{width:100%;margin-left:0;justify-content:space-between}
  .stats{grid-template-columns:1fr}
  .row{flex-direction:column;align-items:flex-start;gap:.4rem}
  .row-v{justify-content:flex-start;margin-left:0;text-align:left}
  .row-note{text-align:left}
  .svc-hero-top,.svc-bundle,.svc-actions-bar,.svc-live-row{align-items:flex-start;flex-direction:column}
  .svc-summary,.svc-grid,.svc-model-card{grid-template-columns:1fr}
  .svc-actions-bar .buttons{justify-content:flex-start}
  .svc-btn,.row-btn,.setup-link,.status,.pk,.addr{min-height:34px}
  .svc-model-field input,.dlg-field input{min-height:38px}
  dialog{width:calc(100vw - 2rem);max-width:calc(100vw - 2rem)}
  .dlg-actions{flex-direction:column-reverse}
  .dlg-actions span{justify-content:flex-end}
}
</style>
</head>
<body>
<div class="wrap">
  <header class="hdr">
    <div class="brand">
      <svg class="spark" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
        <path d="M50 6 C 55 40 60 45 94 50 C 60 55 55 60 50 94 C 45 60 40 55 6 50 C 40 45 45 40 50 6 Z" fill="var(--accent)"/>
      </svg>
      <div class="brand-txt"><h1>Blindspark</h1><span>Blind Relay</span></div>
    </div>
    <div class="hdr-right">
      <a class="setup-link" id="setupLink" href="wizard?edit=1">&#9881; Setup</a>
      <span class="status off" id="status">Connecting</span>
    </div>
  </header>

  <section class="hero">
    <div class="hero-name" id="relayName">&mdash;</div>
    <button class="pk" id="pk" type="button" title="Copy public key"><span id="pkText">&mdash;</span><span class="ic">&#10697;</span></button>
    <p class="hero-tag" id="heroTag">Your relay is starting up&hellip;</p>
  </section>

  <section class="stats">
    <div class="stat"><div class="s-label">Apps held</div><div class="s-val green" id="apps">&mdash;</div><div class="s-sub" id="appsSub">encrypted drives</div></div>
    <div class="stat"><div class="s-label">Connected peers</div><div class="s-val cyan" id="peers">&mdash;</div><div class="s-sub">live connections</div></div>
    <div class="stat"><div class="s-label">Data served</div><div class="s-val" id="served">&mdash;</div><div class="s-sub" id="servedSub">sent to peers</div></div>
    <div class="stat"><div class="s-label">Storage used</div><div class="s-val orange" id="storagePct">&mdash;</div><div class="s-sub" id="storagePctSub">of capacity</div></div>
  </section>

  <section class="panel" id="storagePanel">
    <div class="row">
      <div class="row-k">Storage for HiveRelay
        <div class="row-note">How much of this box's disk HiveRelay may use. Lowering it sheds surplus blind fragments to fit &mdash; never below the network's safety floor.</div>
      </div>
      <div class="row-v storagecap">
        <input id="storageCapGb" type="number" min="1" step="1" inputmode="numeric" aria-label="Storage cap in GB">
        <span class="u">GB</span>
        <button id="storageCapApply" type="button" onclick="applyStorageCap()">Apply</button>
      </div>
    </div>
  </section>

  <section class="panel">
    <div class="row">
      <div class="row-k">Accept mode</div>
      <div class="row-v">
        <span class="badge" id="acceptMode">&mdash;</span>
        <span class="row-note" id="acceptNote"></span>
      </div>
    </div>
    <div class="row">
      <div class="row-k">Payout wallet</div>
      <div class="row-v" id="payout"><span class="muted">&mdash;</span></div>
    </div>
    <div class="row" id="leaseRow" hidden>
      <div class="row-k">Paid seeding</div>
      <div class="row-v" id="leaseV"><span class="muted">&mdash;</span></div>
    </div>
    <div class="row">
      <div class="row-k">Uptime</div>
      <div class="row-v" id="uptime"><span class="muted">&mdash;</span></div>
    </div>
    <div class="row">
      <div class="row-k">PearBrowser</div>
      <div class="row-v">
        <button class="addr" id="subBtn" type="button" title="Copy this relay's subscribe URL">&mdash;</button>
        <span class="row-note">Add this URL in PearBrowser to browse the apps your relay carries.</span>
      </div>
    </div>
  </section>

  <section class="apps-card services-card" id="servicesCard">
    <div class="apps-head"><span class="t">Services</span><span class="c" id="svcStatus" role="status" aria-live="polite" aria-atomic="true">Loading</span></div>
    <div id="svcBody"><div class="apps-empty">Loading&hellip;</div></div>
  </section>

  <section class="apps-card">
    <div class="apps-head">
      <span class="t">Hosted apps</span>
      <span class="apps-head-actions">
        <span class="c" id="appsCount"></span>
        <button class="seedbtn" id="seedToggle" type="button">+ Seed an app</button>
      </span>
    </div>
    <form class="seedform" id="seedForm" autocomplete="off">
      <label for="seedKey">App drive key</label>
      <input type="text" id="seedKey" spellcheck="false" placeholder="64-character hex key from `pear` / publish-app">
      <label for="seedName">Name <span class="seed-optional">(optional)</span></label>
      <input type="text" id="seedName" maxlength="120" placeholder="Shown in your catalogue">
      <label class="chk"><input type="checkbox" id="seedPin" checked> Pin permanently (keep it alive, never auto-evicted)</label>
      <div class="hint">Your relay will replicate this drive and list it in your catalogue, which PearBrowser reads. Paste a key you already published with <code>pear</code> or <code>publish-app</code>.</div>
      <div class="err" id="seedErr"></div>
      <div class="acts">
        <button type="button" id="seedCancel">Cancel</button>
        <button type="button" class="go" id="seedGo">Seed it</button>
      </div>
    </form>
    <div class="apps-list" id="appsList"><div class="apps-empty">Loading&hellip;</div></div>
  </section>

  <footer class="foot">
    Blindspark holds encrypted data it can&rsquo;t read.<br>No account &middot; no telemetry &middot; your relay, your keys.
  </footer>
</div>

<dialog id="walletDialog" aria-labelledby="walletDialogTitle">
  <div class="dlg-title" id="walletDialogTitle">Payout wallet</div>
  <div class="dlg-copy" id="walletHelp">Use a lightning address, BOLT12 offer, or bitcoin address you control.</div>
  <div class="dlg-field">
    <label for="walletInput">Destination</label>
    <input id="walletInput" type="text" placeholder="you@example.com, lno1..., or bc1..." autocomplete="off" spellcheck="false" aria-describedby="walletHelp walletError">
  </div>
  <div class="dlg-error" id="walletError" role="status" aria-live="polite" aria-atomic="true"></div>
  <div class="dlg-actions">
    <button class="row-btn" id="walletClear" type="button">Clear</button>
    <span>
      <button class="row-btn" id="walletCancel" type="button">Cancel</button>
      <button class="row-btn" id="walletSave" type="button">Save</button>
    </span>
  </div>
</dialog>

<div class="toast" id="toast" role="status" aria-live="polite" aria-atomic="true"></div>

<script>
'use strict';
var pubKey = '';
var payoutDestination = '';
var payoutLoaded = false;
var walletBusy = false;
var svcDraftDirty = false;
var svcConfigBusy = false;
var svcRestartPending = false;
var svcRestartExpected = [];
var svcRestartTimer = null;
var svcRestartAttempts = 0;
var svcRestartStartedAt = 0;
var svcLastAvailablePlugins = [];
var svcLastActivePlugins = [];
var svcLastConfiguredPlugins = [];
var svcModelBusy = false;
var svcModelMessageText = '';
var svcModelMessageKind = '';
var svcModelDraftId = '';
var svcModelDraftSrc = '';

function $(id){ return document.getElementById(id); }
function appBasePath(){
  var p = window.location.pathname.replace(/\/+$/, '') || '/';
  var routes = ['/dashboard', '/wizard'];
  for (var i = 0; i < routes.length; i++){
    var r = routes[i];
    if (p === r) return '';
    if (p.slice(-r.length) === r) return p.slice(0, -r.length);
  }
  return p === '/' ? '' : p;
}
var APP_BASE = appBasePath();
function appPath(path){
  if (!path) return APP_BASE || '/';
  return (APP_BASE || '') + (path.charAt(0) === '/' ? path : '/' + path);
}
$('setupLink').setAttribute('href', appPath('/wizard?edit=1'));

function fmtBytes(n){
  n = Number(n) || 0;
  if (n < 1) return '0 B';
  var u = ['B','KB','MB','GB','TB','PB'], i = Math.floor(Math.log(n)/Math.log(1024));
  i = Math.min(i, u.length-1);
  var v = n / Math.pow(1024, i);
  return (v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)) + ' ' + u[i];
}
function shortKey(k){
  if (!k) return '';
  return k.length > 16 ? k.slice(0,8) + '…' + k.slice(-6) : k;
}
function toast(msg){
  var t = $('toast'); t.textContent = msg; t.classList.add('show');
  clearTimeout(toast._t); toast._t = setTimeout(function(){ t.classList.remove('show'); }, 1600);
}
function writeClipboard(text, successMessage){
  if (!text) return;
  if (typeof navigator === 'undefined' || !navigator.clipboard || !navigator.clipboard.writeText){
    toast('Clipboard unavailable');
    return;
  }
  navigator.clipboard.writeText(text)
    .then(function(){ toast(successMessage || 'Copied'); })
    .catch(function(){ toast('Copy failed'); });
}
var API_ERROR_MAX = 180;
function apiError(out, fallback){
  var msg = (out && out.error) || fallback || 'Request failed';
  msg = String(msg)
    .replace(/^[A-Z_]+: /, '')
    .replace(/[\x00-\x1f\x7f]+/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
  if (!msg) msg = fallback || 'Request failed';
  if (msg.length > API_ERROR_MAX) msg = msg.slice(0, API_ERROR_MAX - 3) + '...';
  return msg;
}

var REQUEST_TIMEOUT_MS = 10000;

function fetchWithTimeout(path, opts){
  var options = opts || {};
  var timeout = null;
  if (typeof AbortController === 'function'){
    var controller = new AbortController();
    options.signal = controller.signal;
    timeout = setTimeout(function(){ controller.abort(); }, REQUEST_TIMEOUT_MS);
  }
  return fetch(appPath(path), options).finally(function(){
    if (timeout) clearTimeout(timeout);
  });
}

var ACCEPT = {
  review:    'New seed requests wait for your approval.',
  open:      'Accepts every seed request automatically.',
  allowlist: 'Only peers on your allowlist are accepted.',
  closed:    'Not accepting new seed requests right now.'
};

$('pk').addEventListener('click', function(){
  writeClipboard(pubKey, 'Public key copied');
});

// Three states: online (relay answered + healthy), degraded (relay answered
// but a health check is unhappy — still running), offline (no answer). The
// relay's health.healthy flag means "all checks pass", NOT "is it up" — it
// flips false on benign conditions like no peers yet or high disk, so it must
// never be shown as Offline while the API is responding.
function setStatus(state, uptimeHuman){
  var el = $('status');
  el.classList.remove('off', 'warn');
  if (state === 'online'){
    el.textContent = uptimeHuman ? 'Online · ' + uptimeHuman : 'Online';
    el.removeAttribute('title');
  } else if (state === 'degraded'){
    el.classList.add('warn');
    el.textContent = uptimeHuman ? 'Degraded · ' + uptimeHuman : 'Degraded';
    el.title = 'The relay is running. A health check is unhappy (e.g. no peers yet, or low disk) — it keeps working.';
  } else {
    el.classList.add('off');
    el.textContent = 'Offline';
    el.removeAttribute('title');
  }
}

function renderOverview(o){
  if (!o){ setStatus('offline'); return; }
  var degraded = o.health && o.health.healthy === false;
  setStatus(degraded ? 'degraded' : 'online', o.uptime ? o.uptime.human : '');
  $('apps').textContent = (o.seededApps != null ? o.seededApps : 0);
  $('peers').textContent = (o.connections != null ? o.connections : 0);
  // Honest outbound total. served.bytes sums uploads across every replicated
  // core, not just Seeder.seedCore-routed ones — so it no longer reads ~0 on
  // a registry-drive relay that's actively serving blocks (the gap that got
  // the old "Data served" tile pulled in v0.16.3). Fall back to the measured
  // seeder field, then the legacy counter, for older relays.
  var served = (o.served && o.served.bytes != null) ? o.served.bytes
    : (o.seeder && o.seeder.totalBytesServedMeasured != null) ? o.seeder.totalBytesServedMeasured
    : (o.seeder && o.seeder.totalBytesServed) || 0;
  $('served').textContent = fmtBytes(served);
  // Trust the server's honest measured value (storage.used): it prefers the
  // real corestore total over seeder.totalBytesStored, which reads ~0 on
  // registry-drive relays and once hid a disk fill. Only fall back to the
  // seeder counter if the server didn't compute a measured value.
  var st = o.storage || {};
  var stored = (st.used != null) ? st.used : ((o.seeder && o.seeder.totalBytesStored) || 0);
  // Storage used vs the configured cap — the actionable appliance metric.
  // The absolute on-disk total now rides in the sublabel so dropping the
  // standalone "Data stored" tile loses no information.
  var frac = (typeof st.pct === 'number') ? st.pct : (st.max ? stored / st.max : 0);
  $('storagePct').textContent = (Math.round(frac * 1000) / 10) + '%';
  $('storagePctSub').textContent = fmtBytes(stored) + (st.max ? ' of ' + fmtBytes(st.max) : ' stored');
  // Prefill the designation input with the current cap in GB — but never while
  // the operator is mid-edit (a background refresh must not clobber typing).
  var capInput = $('storageCapGb');
  if (capInput && st.max && document.activeElement !== capInput) {
    capInput.value = Math.max(1, Math.round(st.max / (1024 * 1024 * 1024)));
  }
  $('uptime').textContent = o.uptime ? o.uptime.human : '—';
  if (o.publicKey){
    pubKey = o.publicKey;
    $('pkText').textContent = shortKey(o.publicKey);
  }
}

function renderWizard(w){
  if (!w) return;
  if (w.relayName) $('relayName').textContent = w.relayName;
  var mode = w.acceptMode || 'review';
  var badge = $('acceptMode');
  badge.textContent = mode;
  badge.className = 'badge ' + mode;
  $('acceptNote').textContent = ACCEPT[mode] || '';
  $('heroTag').textContent = 'Your relay is online and holding encrypted data for the network.';

  if (!payoutLoaded) renderPayout(w.payoutDestination);
}

function payoutValue(dest){
  if (!dest) return '';
  if (typeof dest === 'string') return dest;
  return dest.value || dest.address || '';
}

function renderPayout(dest){
  var addr = payoutValue(dest);
  var pay = $('payout');
  payoutDestination = addr || '';
  clearNode(pay);
  if (addr){
    var chip = document.createElement('button');
    chip.type = 'button';
    chip.className = 'addr';
    chip.title = 'Copy payout destination';
    chip.setAttribute('aria-label', 'Copy payout destination');
    chip.textContent = addr.length > 22 ? addr.slice(0,10) + '…' + addr.slice(-8) : addr;
    chip.addEventListener('click', function(){
      writeClipboard(addr, 'Payout destination copied');
    });
    pay.appendChild(chip);
  } else {
    var muted = document.createElement('span');
    muted.className = 'muted';
    muted.textContent = 'Not set';
    pay.appendChild(muted);
  }
  var edit = document.createElement('button');
  edit.type = 'button';
  edit.className = 'row-btn';
  edit.textContent = addr ? 'Change' : 'Add';
  edit.addEventListener('click', openWalletDialog);
  pay.appendChild(edit);
}

function renderSubsidy(s){
  if (!s) return;
  if (Object.prototype.hasOwnProperty.call(s, 'payoutDestination')) {
    payoutLoaded = true;
    renderPayout(s.payoutDestination);
  }
}

function openWalletDialog(){
  $('walletInput').value = payoutDestination || '';
  $('walletError').textContent = '';
  $('walletClear').hidden = !payoutDestination;
  if (typeof $('walletDialog').showModal === 'function') $('walletDialog').showModal();
  else $('walletDialog').setAttribute('open', 'open');
  $('walletInput').focus();
}

function closeWalletDialog(){
  if (typeof $('walletDialog').close === 'function') $('walletDialog').close();
  else $('walletDialog').removeAttribute('open');
}

function setWalletBusy(busy){
  walletBusy = !!busy;
  $('walletSave').disabled = busy;
  $('walletClear').disabled = busy;
  $('walletCancel').disabled = busy;
  $('walletSave').textContent = busy ? 'Saving...' : 'Save';
  $('walletDialog').setAttribute('aria-busy', busy ? 'true' : 'false');
}

function saveWallet(clear){
  if (walletBusy) return;
  var value = clear ? null : $('walletInput').value.trim();
  if (!clear && !value){
    $('walletError').textContent = 'Enter a destination or clear the wallet.';
    return;
  }
  setWalletBusy(true);
  fetchWithTimeout('/api/subsidy/destination', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
    body: JSON.stringify({ destination: value })
  }).then(function(r){
    return r.json().catch(function(){ return {}; }).then(function(out){ return { ok: r.ok, out: out }; });
  }).then(function(result){
    if (!result.ok){
      $('walletError').textContent = apiError(result.out, 'Could not save wallet');
      return;
    }
    renderPayout(result.out.payoutDestination);
    closeWalletDialog();
    toast(value ? 'Payout wallet saved' : 'Payout wallet cleared');
    refreshWizard();
  }).catch(function(){
    $('walletError').textContent = 'Node is not reachable.';
  }).finally(function(){
    setWalletBusy(false);
  });
}

function handleWalletInputKey(event){
  if (!event || event.key !== 'Enter' || event.isComposing) return;
  event.preventDefault();
  if ($('walletSave').disabled) return;
  saveWallet(false);
}

function handleWalletDialogCancel(event){
  if (!walletBusy) return;
  if (event && typeof event.preventDefault === 'function') event.preventDefault();
}

$('walletCancel').addEventListener('click', closeWalletDialog);
$('walletSave').addEventListener('click', function(){ saveWallet(false); });
$('walletClear').addEventListener('click', function(){ saveWallet(true); });
$('walletInput').addEventListener('keydown', handleWalletInputKey);
$('walletDialog').addEventListener('cancel', handleWalletDialogCancel);

function svcPost(path, body){
  return fetchWithTimeout(path, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
    body: JSON.stringify(body || {})
  }).then(function(r){
    return r.json().catch(function(){ return {}; }).then(function(out){ return { ok: r.ok, out: out }; });
  });
}

// Storage designation: set how much of this box HiveRelay may use. The relay
// applies it live — re-caps adoption, enables eviction, and sheds surplus blind
// fragments down to the cap (never below the network's replication floor).
function applyStorageCap(){
  var gb = parseFloat($('storageCapGb').value);
  if (!(gb > 0)) { toast('Enter a storage size in GB'); return; }
  var bytes = Math.round(gb * 1024 * 1024 * 1024);
  var btn = $('storageCapApply'); btn.disabled = true;
  svcPost('/api/manage/config', { maxStorageBytes: bytes }).then(function(res){
    if (res.ok) toast('Storage set to ' + gb + ' GB — shrinking to fit');
    else toast((res.out && res.out.error) || 'Could not set storage');
    setTimeout(function(){ refresh(true); }, 900);
  }).finally(function(){ btn.disabled = false; });
}

function setSvcConfigBusy(busy){
  svcConfigBusy = !!busy;
  var status = $('svcStatus');
  if (status && svcConfigBusy) status.textContent = 'Saving';
  document.querySelectorAll('.svc-check,.svc-btn').forEach(function(control){
    control.disabled = svcConfigBusy || svcRestartPending;
  });
  var hint = $('svcActionHint');
  if (hint) updateSvcActionState();
}

function svcSetConfig(enabled, plugins){
  if (svcConfigBusy) return;
  setSvcConfigBusy(true);
  svcPost('/api/manage/services/config', { enabled: enabled, plugins: plugins }).then(function(res){
    if (!res.ok){
      toast(apiError(res.out, 'Update failed'));
      return;
    }
    svcDraftDirty = false;
    svcLastConfiguredPlugins = (res.out && res.out.config && Array.isArray(res.out.config.plugins)) ? res.out.config.plugins.slice() : plugins.slice();
    toast('Saved; restart to apply');
    refreshServices(true);
  }).catch(function(){
    toast('Node is not reachable');
  }).finally(function(){
    setSvcConfigBusy(false);
    updateSvcCards();
  });
}

function svcSelectedOrConfigured(){
  var selected = svcSelection();
  return selected.length ? selected : svcLastConfiguredPlugins.slice();
}

function svcRestartNode(){
  if (svcRestartPending || svcConfigBusy) return;
  if (svcDraftDirty){
    toast('Save selection before restart');
    return;
  }
  var expected = svcSelectedOrConfigured();
  svcPost('/api/manage/restart', {}).then(function(res){
    if (!res.ok){
      toast(apiError(res.out, 'Restart failed'));
      return;
    }
    toast('Restarting Blindspark');
    beginSvcRestartWatch(expected);
  }).catch(function(){
    toast('Node is not reachable');
  });
}

function beginSvcRestartWatch(expected){
  svcRestartPending = true;
  svcRestartExpected = Array.isArray(expected) ? expected.slice() : [];
  svcRestartAttempts = 0;
  svcRestartStartedAt = Date.now();
  if (svcRestartTimer) clearTimeout(svcRestartTimer);
  refreshServices(true);
  scheduleSvcRestartPoll(1200);
}

function scheduleSvcRestartPoll(delay){
  if (svcRestartTimer) clearTimeout(svcRestartTimer);
  svcRestartTimer = setTimeout(checkSvcRestart, delay);
}

function checkSvcRestart(){
  svcRestartAttempts++;
  Promise.all([
    jget('/api/manage/services/available'),
    jget('/api/manage/services')
  ]).then(function(results){
    var avail = results[0];
    var svcs = results[1];
    if (avail) renderServices(avail, svcs);
    if (svcRestartReady(avail)){
      svcRestartPending = false;
      svcRestartTimer = null;
      renderServices(avail, svcs);
      toast(svcRestartExpected.length ? 'Services are running' : 'Services are off');
      refresh();
      return;
    }
    if (svcRestartAttempts >= 60){
      svcRestartPending = false;
      svcRestartTimer = null;
      if (avail) renderServices(avail, svcs);
      toast('Restart still pending');
      return;
    }
    scheduleSvcRestartPoll(Math.min(5000, 1200 + svcRestartAttempts * 250));
  });
}

function svcRestartReady(avail){
  if (!avail) return false;
  if (Date.now() - svcRestartStartedAt < 2500) return false;
  var active = (avail && Array.isArray(avail.active)) ? avail.active : [];
  var available = (avail && Array.isArray(avail.available)) ? avail.available : [];
  var managedActive = active.filter(function(name){ return available.indexOf(name) !== -1; });
  var expected = svcRestartExpected.length
    ? svcRestartExpected
    : ((avail && Array.isArray(avail.plugins)) ? avail.plugins : []);
  if (!expected.length) return managedActive.length === 0;
  for (var i = 0; i < expected.length; i++){
    if (managedActive.indexOf(expected[i]) === -1) return false;
  }
  for (var j = 0; j < managedActive.length; j++){
    if (expected.indexOf(managedActive[j]) === -1) return false;
  }
  return true;
}

function setSvcModelMessage(message, kind){
  svcModelMessageText = message || '';
  svcModelMessageKind = kind || '';
  var msg = $('svcModelMessage');
  if (!msg) return;
  msg.textContent = svcModelMessageText;
  msg.className = 'svc-model-message' + (svcModelMessageKind ? ' ' + svcModelMessageKind : '');
}

function setSvcModelBusy(busy){
  svcModelBusy = !!busy;
  var disabled = svcModelBusy || svcConfigBusy || svcRestartPending;
  document.querySelectorAll('.svc-model-field input,#svcModelAdd').forEach(function(control){
    control.disabled = disabled;
    if (control.id === 'svcModelAdd') control.textContent = svcModelBusy ? 'Adding...' : 'Add model';
  });
  if (svcModelBusy) setSvcModelMessage('Adding model...', '');
}

function syncSvcModelDraft(){
  var idEl = $('svcModelId');
  var srcEl = $('svcModelSrc');
  svcModelDraftId = idEl && typeof idEl.value === 'string' ? idEl.value : '';
  svcModelDraftSrc = srcEl && typeof srcEl.value === 'string' ? srcEl.value : '';
}

function svcAddModel(){
  if (svcModelBusy) return;
  syncSvcModelDraft();
  var idEl = $('svcModelId');
  var srcEl = $('svcModelSrc');
  var id = (idEl && idEl.value ? idEl.value : '').trim();
  var src = (srcEl && srcEl.value ? srcEl.value : '').trim();
  if (!id || !src){
    setSvcModelMessage('Model ID and source required.', 'error');
    if (!id && idEl) idEl.focus();
    else if (!src && srcEl) srcEl.focus();
    return;
  }
  setSvcModelBusy(true);
  svcPost('/api/manage/ai/models', { modelId: id, qvac: { modelSrc: src } }).then(function(res){
    if (!res.ok){
      setSvcModelMessage(apiError(res.out, 'Add failed'), 'error');
      return;
    }
    setSvcModelMessage('Model added. Refreshing service state...', 'ok');
    toast('Model added: ' + id);
    svcModelDraftId = '';
    svcModelDraftSrc = '';
    if (idEl) idEl.value = '';
    if (srcEl) srcEl.value = '';
    refreshServices(true);
  }).catch(function(){
    setSvcModelMessage('Node is not reachable.', 'error');
  }).finally(function(){
    setSvcModelBusy(false);
  });
}

function handleSvcModelInputKey(event){
  if (!event || event.key !== 'Enter' || event.isComposing) return;
  event.preventDefault();
  if (svcModelBusy) return;
  svcAddModel();
}

var SERVICE_META = {
  storage: { label: 'Storage', desc: 'Durable storage APIs for apps that need server-side state.', tags: ['data'] },
  identity: { label: 'Identity', desc: 'Attestation and identity tools for app developers.', tags: ['trust'] },
  ai: { label: 'AI models', desc: 'Host local or QVAC-backed model calls for peers.', tags: ['optional', 'models'] },
  zk: { label: 'Zero-knowledge', desc: 'Proof primitives used by private games and custody flows.', tags: ['privacy'] },
  sla: { label: 'SLA receipts', desc: 'Service-level receipts and availability commitments.', tags: ['receipts'] },
  schema: { label: 'Schemas', desc: 'Shared schemas for structured app records.', tags: ['developer'] },
  arbitration: { label: 'Arbitration', desc: 'Dispute hooks for games and paid service flows.', tags: ['games'] },
  vrf: { label: 'Randomness', desc: 'Verifiable randomness for games and fair selection.', tags: ['games'] },
  poker: { label: 'Poker table API', desc: 'Card-blind signed-log tables for poker and sealed-action clients.', tags: ['games', 'api'] }
};

function svcMeta(name){
  return SERVICE_META[name] || {
    label: name ? name.charAt(0).toUpperCase() + name.slice(1) : 'Service',
    desc: 'Optional service plugin exposed by this relay.',
    tags: ['service']
  };
}

function svcCardVisualState(name, selected, configured, active, draftDirty){
  var selectedNow = selected.indexOf(name) !== -1;
  var saved = configured.indexOf(name) !== -1;
  var running = active.indexOf(name) !== -1;
  if (draftDirty && selectedNow !== saved){
    if (selectedNow) return { label: running ? 'Unsaved keep' : 'Unsaved start', className: 'draft' };
    return { label: running ? 'Unsaved stop' : 'Unsaved off', className: 'draft' };
  }
  if (saved && running) return { label: 'Live', className: 'live' };
  if (saved) return { label: 'Starts after restart', className: 'pending' };
  if (running) return { label: 'Stops after restart', className: 'stopping' };
  return { label: 'Available', className: 'idle' };
}

function svcVisualState(name, configured, active){
  return svcCardVisualState(name, configured, configured, active, false);
}

function svcSelection(){
  return Array.prototype.map.call(document.querySelectorAll('.svc-check:checked'), function(c){ return c.value; });
}

function updateSvcCards(){
  var selected = svcSelection();
  document.querySelectorAll('.svc-card').forEach(function(card){
    var input = card.querySelector('input');
    var checked = !!(input && input.checked);
    card.classList.toggle('selected', checked);
    var state = card.querySelector('.svc-state');
    if (input && state){
      var visualState = svcCardVisualState(input.value, selected, svcLastConfiguredPlugins, svcLastActivePlugins, svcDraftDirty);
      state.className = 'svc-state ' + visualState.className;
      state.textContent = visualState.label;
    }
  });
  var count = $('svcSelectedCount');
  if (count) count.textContent = selected.length + ' selected';
  var selectedSummary = $('svcSummarySelected');
  if (selectedSummary) selectedSummary.textContent = metricCount(selected.length);
  var pendingSummary = $('svcSummaryPending');
  if (pendingSummary){
    var managedActive = svcLastActivePlugins.filter(function(name){ return svcLastAvailablePlugins.indexOf(name) !== -1; });
    var startsAfterRestart = selected.filter(function(name){ return managedActive.indexOf(name) === -1; });
    var stopsAfterRestart = managedActive.filter(function(name){ return selected.indexOf(name) === -1; });
    pendingSummary.textContent = metricCount(startsAfterRestart.length + stopsAfterRestart.length);
  }
  var hint = $('svcActionHint');
  if (hint) updateSvcActionState(selected);
}

function metricCount(value){
  var n = Number(value);
  if (!Number.isFinite(n) || n < 0) return '0';
  return String(Math.floor(n));
}

function clearNode(node){
  while (node.firstChild) node.removeChild(node.firstChild);
  node.textContent = '';
}

function makeEl(tag, className, text){
  var el = document.createElement(tag);
  if (className) el.className = className;
  if (text !== undefined) el.textContent = text;
  return el;
}

function appendEl(parent, tag, className, text){
  var el = makeEl(tag, className, text);
  parent.appendChild(el);
  return el;
}

function appendSectionTitle(parent, label, detail, detailId){
  var title = appendEl(parent, 'div', 'svc-section-title');
  appendEl(title, 'span', '', label);
  if (detail !== undefined){
    var detailEl = appendEl(title, 'span', '', detail);
    if (detailId) detailEl.id = detailId;
  }
  return title;
}

function appendModelField(parent, id, label, placeholder){
  var field = appendEl(parent, 'div', 'svc-model-field');
  var labelEl = appendEl(field, 'label', '', label);
  labelEl.setAttribute('for', id);
  var input = appendEl(field, 'input');
  input.id = id;
  input.placeholder = placeholder;
  return input;
}

function appendServiceSummary(parent, label, value, detail, id){
  var item = appendEl(parent, 'div', 'svc-summary-item');
  var strong = appendEl(item, 'strong', '', value);
  if (id) strong.id = id;
  appendEl(item, 'span', '', detail ? label + ' - ' + detail : label);
  return item;
}

function svcLabel(name){
  var label = svcMeta(name).label || name || 'Service';
  label = String(label)
    .replace(/[\x00-\x1f\x7f]+/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
  if (!label) label = 'Service';
  if (label.length > 32) label = label.slice(0, 29) + '...';
  return label;
}

function svcManagedActive(){
  return svcLastActivePlugins.filter(function(name){ return svcLastAvailablePlugins.indexOf(name) !== -1; });
}

function svcPlanDelta(selected){
  var target = Array.isArray(selected) ? selected.slice() : [];
  var baseline = svcDraftDirty ? svcLastConfiguredPlugins.slice() : svcManagedActive();
  var starts = target.filter(function(name){ return baseline.indexOf(name) === -1; });
  var stops = baseline.filter(function(name){ return target.indexOf(name) === -1; });
  return { starts: starts, stops: stops };
}

function svcCompactList(names){
  var labels = names.slice(0, 3).map(svcLabel);
  if (names.length > 3) labels.push('+' + metricCount(names.length - 3) + ' more');
  return labels.join(', ');
}

function svcPlanSentence(delta){
  var parts = [];
  if (delta.starts.length) parts.push('start ' + svcCompactList(delta.starts));
  if (delta.stops.length) parts.push('stop ' + svcCompactList(delta.stops));
  return parts.join('; ');
}

function appendSvcPlanLine(parent, label, names, mode){
  var line = appendEl(parent, 'div', 'svc-plan-line');
  appendEl(line, 'span', 'svc-plan-label', label);
  names.slice(0, 4).forEach(function(name){
    appendEl(line, 'span', 'svc-plan-chip ' + mode, svcLabel(name));
  });
  if (names.length > 4) appendEl(line, 'span', 'svc-plan-chip ' + mode, '+' + metricCount(names.length - 4) + ' more');
  return line;
}

function renderSvcPlan(selected){
  var plan = $('svcPlan');
  if (!plan) return;
  clearNode(plan);
  var delta = svcPlanDelta(selected);
  if (!delta.starts.length && !delta.stops.length){
    plan.className = 'svc-plan empty';
    return;
  }
  plan.className = 'svc-plan ' + (svcDraftDirty ? 'draft' : 'pending');
  appendEl(plan, 'strong', '', svcDraftDirty ? 'Unsaved service changes' : 'Restart needed for saved services');
  var lines = appendEl(plan, 'div', 'svc-plan-lines');
  if (delta.starts.length) appendSvcPlanLine(lines, 'Start', delta.starts, 'start');
  if (delta.stops.length) appendSvcPlanLine(lines, 'Stop', delta.stops, 'stop');
  appendEl(plan, 'div', 'svc-plan-note', svcDraftDirty ? 'Save this selection, then restart when ready.' : 'Restart Blindspark when you want these saved changes live.');
}

function svcActionMessage(selected){
  if (svcConfigBusy) return 'Saving service selection...';
  if (svcRestartPending) return 'Restarting. Waiting for selected services to converge.';
  var delta = svcPlanDelta(selected);
  if (svcDraftDirty && (delta.starts.length || delta.stops.length)) {
    return 'Unsaved: ' + svcPlanSentence(delta) + '. Save selection before restarting.';
  }
  if (delta.starts.length || delta.stops.length) {
    return 'Saved change pending: ' + svcPlanSentence(delta) + '. Restart Blindspark to apply.';
  }
  return 'Saved selections are live. Running services match the selected set.';
}

function updateSvcActionState(selected){
  var choice = Array.isArray(selected) ? selected : svcSelection();
  var hint = $('svcActionHint');
  if (hint) hint.textContent = svcActionMessage(choice);
  renderSvcPlan(choice);
}

function renderServices(avail, svcs){
  var enabled = !!(avail && avail.enabled);
  var available = (avail && Array.isArray(avail.available)) ? avail.available : [];
  var configured = (avail && Array.isArray(avail.plugins)) ? avail.plugins : [];
  var active = (avail && Array.isArray(avail.active)) ? avail.active : [];
  var bundles = (avail && avail.bundles && typeof avail.bundles === 'object') ? avail.bundles : {};
  var list = (svcs && Array.isArray(svcs.services)) ? svcs.services : [];
  var controlsDisabled = svcRestartPending || svcConfigBusy;
  svcLastAvailablePlugins = available.slice();
  svcLastActivePlugins = active.slice();
  svcLastConfiguredPlugins = configured.slice();
  $('svcStatus').textContent = svcConfigBusy ? 'Saving' : (svcRestartPending ? 'Restarting' : (enabled ? (active.length ? active.length + ' running' : 'Configured') : 'Off'));
  $('servicesCard').setAttribute('aria-busy', controlsDisabled ? 'true' : 'false');

  var body = $('svcBody');
  clearNode(body);
  if (!avail){
    appendEl(body, 'div', 'apps-empty', 'Service management is unavailable.');
    return;
  }

  var hero = document.createElement('div');
  hero.className = 'svc-hero';
  var heroTop = appendEl(hero, 'div', 'svc-hero-top');
  var heroCopy = appendEl(heroTop, 'div');
  appendEl(heroCopy, 'div', 'svc-title', 'Optional service hosting');
  appendEl(heroCopy, 'div', 'svc-copy', 'Choose the capabilities this relay should offer to apps. Saving stores the next-start selection; restart Blindspark when you want the change live.');
  appendEl(heroTop, 'span', 'svc-pill ' + (enabled ? 'on' : 'warn'), svcRestartPending ? 'restarting' : (enabled ? 'configured' : 'not configured'));
  body.appendChild(hero);

  var managedActive = active.filter(function(name){ return available.indexOf(name) !== -1; });
  var startsAfterRestart = configured.filter(function(name){ return active.indexOf(name) === -1; });
  var stopsAfterRestart = managedActive.filter(function(name){ return configured.indexOf(name) === -1; });
  var pendingRestartCount = startsAfterRestart.length + stopsAfterRestart.length;
  var summary = appendEl(body, 'div', 'svc-summary');
  appendServiceSummary(summary, 'Selected', metricCount(configured.length), enabled ? 'next restart' : 'services off', 'svcSummarySelected');
  appendServiceSummary(summary, 'Running', metricCount(managedActive.length), 'live now', 'svcSummaryRunning');
  appendServiceSummary(summary, 'Pending', metricCount(pendingRestartCount), pendingRestartCount ? 'needs restart' : 'in sync', 'svcSummaryPending');
  var plan = appendEl(body, 'div', 'svc-plan empty');
  plan.id = 'svcPlan';

  if (svcRestartPending){
    var expected = svcRestartExpected.length ? svcRestartExpected : configured;
    var ready = expected.filter(function(name){ return active.indexOf(name) !== -1; }).length;
    var progress = document.createElement('div');
    progress.className = 'svc-progress';
    progress.setAttribute('role', 'status');
    progress.setAttribute('aria-live', 'polite');
    progress.setAttribute('aria-atomic', 'true');
    appendEl(progress, 'strong', '', 'Restarting Blindspark');
    appendEl(progress, 'span', '', expected.length ? ready + '/' + expected.length + ' selected running' : 'waiting for services to stop');
    body.appendChild(progress);
  }

  if (bundles.poker && bundles.poker.length){
    var bundleSection = document.createElement('div');
    bundleSection.className = 'svc-section';
    appendSectionTitle(bundleSection, 'Presets');
    var bundlesWrap = document.createElement('div');
    bundlesWrap.className = 'svc-bundles';
    var bundle = document.createElement('div');
    bundle.className = 'svc-bundle';
    var bundleCopy = appendEl(bundle, 'div');
    appendEl(bundleCopy, 'strong', '', 'Poker services');
    appendEl(bundleCopy, 'span', '', 'Starts the poker table API plus randomness, arbitration, and proof services for compatible clients.');
    var preset = document.createElement('button');
    preset.type = 'button';
    preset.className = 'svc-btn primary';
    preset.textContent = 'Select preset';
    preset.title = 'Selects ' + bundles.poker.join(', ');
    preset.disabled = controlsDisabled;
    preset.addEventListener('click', function(){
      var set = {};
      svcSelection().forEach(function(p){ set[p] = true; });
      bundles.poker.forEach(function(p){ set[p] = true; });
      document.querySelectorAll('.svc-check').forEach(function(cb){ cb.checked = !!set[cb.value]; });
      svcDraftDirty = true;
      updateSvcCards();
      toast('Poker preset selected; save to apply');
    });
    bundle.appendChild(preset);
    bundlesWrap.appendChild(bundle);
    bundleSection.appendChild(bundlesWrap);
    body.appendChild(bundleSection);
  }

  var catalog = document.createElement('div');
  catalog.className = 'svc-section';
  appendSectionTitle(catalog, 'Available services', configured.length + ' selected', 'svcSelectedCount');
  var checklist = document.createElement('div');
  checklist.className = 'svc-grid';
  available.forEach(function(name){
    var meta = svcMeta(name);
    var running = active.indexOf(name) !== -1;
    var visualState = svcCardVisualState(name, configured, configured, active, false);
    var label = document.createElement('label');
    label.className = 'svc-card' + (configured.indexOf(name) !== -1 ? ' selected' : '') + (running ? ' running' : '') + (controlsDisabled ? ' disabled' : '');
    var cb = document.createElement('input');
    cb.type = 'checkbox';
    cb.className = 'svc-check';
    cb.value = name;
    cb.checked = configured.indexOf(name) !== -1;
    cb.disabled = controlsDisabled;
    cb.addEventListener('change', function(){
      svcDraftDirty = true;
      updateSvcCards();
    });
    label.appendChild(cb);
    var mark = document.createElement('span');
    mark.className = 'svc-checkmark';
    label.appendChild(mark);
    var content = document.createElement('span');
    content.className = 'svc-card-content';
    appendEl(content, 'span', 'svc-name', meta.label);
    appendEl(content, 'span', 'svc-state ' + visualState.className, visualState.label);
    appendEl(content, 'span', 'svc-desc', meta.desc);
    var tags = appendEl(content, 'span', 'svc-tags');
    (meta.tags || []).forEach(function(tag){
      appendEl(tags, 'span', 'svc-tag', tag);
    });
    label.appendChild(content);
    checklist.appendChild(label);
  });
  catalog.appendChild(checklist);
  body.appendChild(catalog);

  var actions = document.createElement('div');
  actions.className = 'svc-actions-bar';
  var hint = appendEl(actions, 'div', 'hint', svcActionMessage(configured));
  hint.id = 'svcActionHint';
  hint.setAttribute('role', 'status');
  hint.setAttribute('aria-live', 'polite');
  hint.setAttribute('aria-atomic', 'true');
  var buttons = appendEl(actions, 'div', 'buttons');
  if (enabled){
    var off = document.createElement('button');
    off.type = 'button';
    off.className = 'svc-btn danger';
    off.textContent = 'Turn off services';
    off.disabled = controlsDisabled;
    off.addEventListener('click', function(){ svcSetConfig(false, []); });
    buttons.appendChild(off);
  }
  var restart = document.createElement('button');
  restart.type = 'button';
  restart.className = 'svc-btn';
  restart.textContent = 'Restart now';
  restart.disabled = controlsDisabled;
  restart.addEventListener('click', svcRestartNode);
  buttons.appendChild(restart);
  var save = document.createElement('button');
  save.type = 'button';
  save.className = 'svc-btn primary';
  save.textContent = 'Save selection';
  save.disabled = controlsDisabled;
  save.addEventListener('click', function(){
    var selected = svcSelection();
    svcSetConfig(selected.length > 0, selected);
  });
  buttons.appendChild(save);
  body.appendChild(actions);
  renderSvcPlan(configured);

  if (list.length){
    var live = document.createElement('div');
    live.className = 'svc-section';
    appendSectionTitle(live, 'Running now', list.length + ' provider' + (list.length === 1 ? '' : 's'));
    var liveList = document.createElement('div');
    liveList.className = 'svc-live-list';
    list.forEach(function(s){
      var meta = svcMeta(s.name || '');
      var status = s.status || (s.running ? 'running' : 'stopped');
      var row = document.createElement('div');
      row.className = 'svc-live-row';
      appendEl(row, 'span', 'svc-dot ' + (s.running ? 'running' : ''));
      var main = appendEl(row, 'span', 'svc-live-main');
      appendEl(main, 'strong', '', meta.label);
      appendEl(main, 'span', '', meta.desc);
      appendEl(row, 'span', 'svc-live-status', status);
      liveList.appendChild(row);
    });
    live.appendChild(liveList);
    body.appendChild(live);
  }

  if (enabled){
    var meterBox = document.createElement('div');
    meterBox.id = 'svcMeterBox';
    meterBox.className = 'svc-meter-box';
    body.appendChild(meterBox);
    renderMetering(active);
  }

  if (active.indexOf('ai') !== -1){
    var modelControlsDisabled = controlsDisabled || svcModelBusy;
    var ai = document.createElement('div');
    ai.className = 'svc-section';
    appendSectionTitle(ai, 'AI model');
    var form = document.createElement('div');
    form.className = 'svc-model-card';
    var modelId = appendModelField(form, 'svcModelId', 'Model ID', 'qvac-small');
    var modelSrc = appendModelField(form, 'svcModelSrc', 'QVAC source or path', '/data/models/qvac-small');
    modelId.value = svcModelDraftId;
    modelSrc.value = svcModelDraftSrc;
    modelId.disabled = modelControlsDisabled;
    modelSrc.disabled = modelControlsDisabled;
    modelId.addEventListener('input', syncSvcModelDraft);
    modelSrc.addEventListener('input', syncSvcModelDraft);
    modelId.addEventListener('keydown', handleSvcModelInputKey);
    modelSrc.addEventListener('keydown', handleSvcModelInputKey);
    var add = document.createElement('button');
    add.type = 'button';
    add.id = 'svcModelAdd';
    add.className = 'svc-btn primary';
    add.textContent = svcModelBusy ? 'Adding...' : 'Add model';
    add.disabled = modelControlsDisabled;
    add.addEventListener('click', svcAddModel);
    form.appendChild(add);
    var msg = appendEl(form, 'div', 'svc-model-message');
    msg.id = 'svcModelMessage';
    msg.setAttribute('role', 'status');
    msg.setAttribute('aria-live', 'polite');
    msg.textContent = svcModelMessageText;
    msg.className = 'svc-model-message' + (svcModelMessageKind ? ' ' + svcModelMessageKind : '');
    ai.appendChild(form);
    body.appendChild(ai);
  }
}

function renderMetering(active){
  var box = $('svcMeterBox');
  if (!box) return;
  var jobs = [jget('/api/usage').catch(function(){ return null; })];
  var hasPoker = active && active.indexOf('vrf') !== -1 && active.indexOf('arbitration') !== -1;
  jobs.push(hasPoker ? jget('/api/poker/usage').catch(function(){ return null; }) : Promise.resolve(null));
  Promise.all(jobs).then(function(results){
    clearNode(box);
    var usage = results[0];
    var poker = results[1];
    var verified = usage && usage.verified ? usage.verified : null;
    appendSectionTitle(box, 'Verified usage', 'signed only');
    var meter = appendEl(box, 'div', 'svc-meter');
    if (verified){
      var totals = verified.totals || {};
      var keys = Object.keys(totals);
      appendMeterRow(meter, 'Signed receipts', metricCount(verified.count));
      keys.forEach(function(key){
        appendMeterRow(meter, key, totals[key]);
      });
    } else {
      appendMeterRow(meter, 'Signed receipts', '0');
    }
    if (poker && typeof poker.appends === 'number'){
      appendMeterRow(meter, 'Poker appends', metricCount(poker.appends));
      appendMeterRow(meter, 'Poker seats', metricCount(poker.seats));
    }
    appendEl(meter, 'div', 'svc-meter-note', 'Only counterparty-signed receipts are payout-eligible. Local call counters are intentionally not shown as earnings.');
  }).catch(function(){});
}

function appendMeterRow(parent, label, value){
  var row = appendEl(parent, 'div', 'svc-meter-row');
  appendEl(row, 'span', '', label);
  appendEl(row, 'span', '', value);
  return row;
}

// ── Paid seeding (operator-facing) — set the per-GiB/day rate, watch
// leases + sats earned. Hidden unless lease.enabled on the relay. Funds
// settle to the operator's own LN node; the relay never holds a balance.
function renderLease(s){
  var row = $('leaseRow'); var v = $('leaseV');
  if (!s || !s.enabled){ row.hidden = true; return; }
  row.hidden = false;
  clearNode(v);
  var ctl = document.createElement('div');
  ctl.className = 'lease-controls';
  var inp = document.createElement('input');
  inp.type = 'number'; inp.min = '0'; inp.step = '1';
  inp.value = (s.satsPerGiBDay != null ? s.satsPerGiBDay : 0);
  inp.className = 'lease-rate'; inp.setAttribute('aria-label', 'sats per GiB per day');
  var unit = document.createElement('span'); unit.className = 'row-note'; unit.textContent = 'sats/GiB/day';
  var save = document.createElement('button'); save.type = 'button'; save.className = 'seedbtn'; save.textContent = 'Save';
  save.addEventListener('click', function(){
    var val = Number(inp.value);
    if (!isFinite(val) || val < 0){ toast('Enter a non-negative rate'); return; }
    save.disabled = true;
    fetchWithTimeout('/api/lease/config', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
      body: JSON.stringify({ satsPerGiBDay: val })
    })
      .then(function(r){ return r.json().catch(function(){ return null; }); })
      .then(function(j){ save.disabled = false; if (j && j.ok){ toast('Rate updated'); fetchLease(true); } else { toast((j && j.error) || 'Update failed'); } })
      .catch(function(){ save.disabled = false; toast('Request failed'); });
  });
  ctl.appendChild(inp); ctl.appendChild(unit); ctl.appendChild(save);
  var note = document.createElement('span'); note.className = 'row-note';
  var earned = Number(s.totalLeasedSats || 0).toLocaleString();
  note.textContent = (s.activeLeases || 0) + ' active · ' + (s.leaseCount || 0) + ' paid · ' + earned + ' sats earned';
  v.appendChild(ctl); v.appendChild(note);
}
function fetchLease(force){
  if (!canPoll(force, leaseRefreshBusy)) return Promise.resolve(null);
  leaseRefreshBusy = true;
  return jget('/api/lease').then(renderLease).finally(function(){
    leaseRefreshBusy = false;
  });
}

function renderApps(list){
  var box = $('appsList');
  clearNode(box);
  if (!Array.isArray(list) || list.length === 0){
    appendEl(box, 'div', 'apps-empty', 'No apps held yet. They appear as peers ask your relay to seed them.');
    $('appsCount').textContent = '';
    return;
  }
  $('appsCount').textContent = list.length + (list.length === 1 ? ' app' : ' apps');
  for (var i = 0; i < list.length; i++){
    var a = list[i];
    var name = a.name && a.name !== 'Unknown App' ? a.name : 'Unnamed app';
    var appKey = String(a.appKey || '');
    var idKey = String(a.appKey || a.id || '');
    var row = appendEl(box, 'div', 'app');
    appendEl(row, 'span', 'app-dot');
    var nameEl = appendEl(row, 'span', 'app-name', name);
    nameEl.title = appKey;
    appendEl(row, 'span', 'app-key', shortKey(idKey));
  }
}

function jget(path){
  return fetchWithTimeout(path, { headers: { 'Accept': 'application/json' } })
    .then(function(r){ return r.ok ? r.json() : null; })
    .catch(function(){ return null; });
}

var overviewRefreshBusy = false;
var wizardRefreshBusy = false;
var servicesRefreshBusy = false;
var servicesRefreshPendingForce = false;
var leaseRefreshBusy = false;

function canPoll(force, busy){
  if (busy) return false;
  return force === true || !(typeof document !== 'undefined' && document.hidden === true);
}

function refresh(force){
  if (!canPoll(force, overviewRefreshBusy)) return;
  overviewRefreshBusy = true;
  Promise.all([
    jget('/api/overview'),
    jget('/api/apps'),
    jget('/api/subsidy')
  ]).then(function(results){
    renderOverview(results[0]);
    renderApps(results[1]);
    renderSubsidy(results[2]);
  }).finally(function(){
    overviewRefreshBusy = false;
  });
}

// Wizard state changes rarely; fetch once on load + occasionally.
function refreshWizard(force){
  if (!canPoll(force, wizardRefreshBusy)) return;
  wizardRefreshBusy = true;
  jget('/api/wizard').then(renderWizard).finally(function(){
    wizardRefreshBusy = false;
  });
}

// ── Seed an app (operator pastes a drive key; relay seeds + pins it) ──
var HEX64 = /^[0-9a-f]{64}$/i;
$('seedToggle').addEventListener('click', function(){
  var f = $('seedForm');
  f.classList.toggle('show');
  if (f.classList.contains('show')) $('seedKey').focus();
});
$('seedCancel').addEventListener('click', function(){
  $('seedForm').classList.remove('show'); $('seedErr').textContent = '';
});
$('seedGo').addEventListener('click', function(){
  var form = $('seedForm');
  if (typeof form.requestSubmit === 'function') form.requestSubmit();
  else form.dispatchEvent(new Event('submit', { cancelable: true }));
});
$('seedForm').addEventListener('submit', function(e){
  e.preventDefault();
  var key = $('seedKey').value.trim().toLowerCase();
  var err = $('seedErr');
  if (!HEX64.test(key)){ err.textContent = 'Enter a 64-character hex drive key.'; return; }
  var nm = $('seedName').value.trim();
  var body = { appKey: key, durability: $('seedPin').checked ? 1 : 0 };
  if (nm) body.name = nm;
  var go = $('seedGo'); go.disabled = true; err.textContent = '';
  // The auth shim attaches Authorization: Bearer behind the proxy; on a
  // plain-localhost appliance the same-origin request is trusted.
  fetchWithTimeout('/seed', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
    .then(function(r){ return r.json().then(function(j){ return { ok: r.ok, j: j }; }, function(){ return { ok: r.ok, j: {} }; }); })
    .then(function(res){
      go.disabled = false;
      if (!res.ok){ err.textContent = (res.j && res.j.error) || 'Seed failed.'; return; }
      toast('Seeding ' + (nm || shortKey(key)) + ' — it will appear below');
      $('seedForm').classList.remove('show');
      $('seedKey').value = ''; $('seedName').value = '';
      setTimeout(function(){ refresh(true); }, 1200);
    })
    .catch(function(){ go.disabled = false; err.textContent = 'Request failed — is the relay reachable?'; });
});

// ── PearBrowser subscribe URL (this relay's own reachable base URL) ──
(function(){
  var b = $('subBtn'); if (!b) return;
  var url = window.location.origin;
  b.textContent = url.replace(/^https?:\/\//, '');
  b.addEventListener('click', function(){
    navigator.clipboard && navigator.clipboard.writeText(url)
      .then(function(){ toast('Relay URL copied — add it in PearBrowser'); })
      .catch(function(){ toast(url); });
  });
})();

function refreshServices(force){
  if (servicesRefreshBusy) {
    if (force === true) servicesRefreshPendingForce = true;
    return;
  }
  if (!canPoll(force, false)) return;
  servicesRefreshBusy = true;
  Promise.all([
    jget('/api/manage/services/available'),
    jget('/api/manage/services')
  ]).then(function(results){
    if (svcDraftDirty && force !== true) return;
    renderServices(results[0], results[1]);
  }).catch(function(){
    if (svcDraftDirty && force !== true) return;
    renderServices(null, null);
  }).finally(function(){
    servicesRefreshBusy = false;
    if (servicesRefreshPendingForce) {
      servicesRefreshPendingForce = false;
      refreshServices(true);
    }
  });
}

function refreshVisible(force){
  refresh(force);
  refreshWizard(force);
  refreshServices(force);
  fetchLease(force);
}

refreshVisible(true);
setInterval(function(){ refresh(false); }, 5000);
setInterval(function(){ refreshWizard(false); }, 30000);
setInterval(function(){ fetchLease(false); }, 30000);
setInterval(function(){ refreshServices(false); }, 15000);
document.addEventListener('visibilitychange', function(){
  if (document.hidden !== true) refreshVisible(true);
});
</script>
</body>
</html>