<!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">⚙ Setup</a>
<span class="status off" id="status">Connecting</span>
</div>
</header>
<section class="hero">
<div class="hero-name" id="relayName">—</div>
<button class="pk" id="pk" type="button" title="Copy public key"><span id="pkText">—</span><span class="ic">⧉</span></button>
<p class="hero-tag" id="heroTag">Your relay is starting up…</p>
</section>
<section class="stats">
<div class="stat"><div class="s-label">Apps held</div><div class="s-val green" id="apps">—</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">—</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">—</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">—</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 — 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">—</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">—</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">—</span></div>
</div>
<div class="row">
<div class="row-k">Uptime</div>
<div class="row-v" id="uptime"><span class="muted">—</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">—</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…</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…</div></div>
</section>
<footer class="foot">
Blindspark holds encrypted data it can’t read.<br>No account · no telemetry · 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>