RC-7
release-tag / release-image (push) Failing after 2m44s

This commit is contained in:
2026-08-11 16:58:07 +02:00
parent 185ccf1101
commit bcfbef390f
44 changed files with 4778 additions and 172 deletions
+21
View File
@@ -0,0 +1,21 @@
package customerui
import (
"embed"
"io/fs"
"net/http"
)
//go:embed dist/public/* dist/admin/*
var dist embed.FS
func handler(sub string) http.Handler {
root, err := fs.Sub(dist, sub)
if err != nil {
panic(err)
}
return http.FileServer(http.FS(root))
}
func Public() http.Handler { return handler("dist/public") }
func Admin() http.Handler { return handler("dist/admin") }
+1
View File
@@ -0,0 +1 @@
const $=id=>document.getElementById(id);let manual=false;function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}function msg(t,e=false){$('msg').textContent=t;$('msg').className='msg'+(e?' err':'');$('msg').classList.remove('hidden')}async function api(p,o={}){if(o.body&&typeof o.body!=='string'){o.headers={'Content-Type':'application/json',...(o.headers||{})};o.body=JSON.stringify(o.body)}const r=await fetch(p,{credentials:'same-origin',...o});const x=await r.json().catch(()=>({}));if(!r.ok)throw new Error(x.error||`HTTP ${r.status}`);return x}const cr=m=>(Number(m||0)/1e6).toLocaleString('de-DE',{maximumFractionDigits:3});async function load(){const x=await api('/api/admin/overview');manual=x.manual_credits_enabled;$('loginBox').classList.add('hidden');$('panel').classList.remove('hidden');$('manual').textContent=manual?'MANUELLER TEST-CREDIT-BYPASS IST AKTIV. Nur auf dem privaten Listener verwenden.':'Manuelle Credits sind deaktiviert (CS_ALLOW_MANUAL_CREDITS=0).';$('customers').innerHTML=(x.customers||[]).map(c=>`<div class="customer" data-id="${esc(c.id)}"><div><strong>${esc(c.username)}</strong><div class="small">${esc(c.id)} · Reward ${esc(c.reward_client_id||'—')}</div></div><div>${cr(c.balance_micros)} Credits</div><div>${c.running}/${c.workers} Worker aktiv</div>${manual?'<div class="grant"><input class="amount" type="number" min="0.001" step="1" placeholder="Credits"><button class="grantBtn">+ TEST</button></div>':'<div></div>'}</div>`).join('');document.querySelectorAll('.grantBtn').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');const n=Number(row.querySelector('.amount').value);if(!n)return;try{await api('/api/admin/credits/grant',{method:'POST',body:{customer_id:row.dataset.id,credits:n,reason:'admin-ui'}});msg(`${n} Test-Credits gebucht`);await load()}catch(e){msg(e.message,true)}})}$('login').onclick=async()=>{try{await api('/api/admin/login',{method:'POST',body:{Username:$('user').value,Password:$('pass').value}});await load()}catch(e){msg(e.message,true)}};$('logout').onclick=async()=>{await api('/api/admin/logout',{method:'POST'}).catch(()=>{});location.reload()};load().catch(()=>{});
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Neural Hunt · Customer Admin</title><link rel="stylesheet" href="/styles.css"></head><body><main><div class="eyebrow">NEURAL HUNT · PRIVATE CONTROL PLANE</div><h1>Customer Service Admin</h1><div id="msg" class="msg hidden"></div><section id="loginBox"><label>Admin<input id="user"></label><label>Passwort<input id="pass" type="password"></label><button id="login">ANMELDEN</button></section><section id="panel" class="hidden"><div class="head"><p>Dieser Listener gehört ausschließlich hinter VPN / privates Netz.</p><button id="logout">ABMELDEN</button></div><div id="manual" class="notice"></div><div id="customers"></div></section></main><script src="/app.js" defer></script></body></html>
+1
View File
@@ -0,0 +1 @@
:root{color-scheme:dark;--bg:#080b10;--card:#111821;--line:#293746;--text:#edf4fb;--muted:#8fa1b2;--accent:#54f0a6}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 ui-monospace,monospace}main{max-width:1100px;margin:auto;padding:34px 20px}.eyebrow{color:var(--accent);letter-spacing:.2em}h1{font:700 38px system-ui}section,.customer{border:1px solid var(--line);background:var(--card);border-radius:12px;padding:16px;margin:12px 0}label{display:block;color:var(--muted);margin:10px 0}input{display:block;width:100%;margin-top:5px;background:#090e14;color:var(--text);border:1px solid var(--line);border-radius:8px;padding:10px}button{background:var(--accent);border:0;border-radius:8px;padding:9px 12px;font-weight:900;cursor:pointer}.hidden{display:none!important}.head,.row{display:flex;justify-content:space-between;gap:12px;align-items:center}.customer{display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:10px;align-items:center}.small{font-size:12px;color:var(--muted);word-break:break-all}.grant{display:flex;gap:6px}.grant input{margin:0}.msg,.notice{padding:10px;border-radius:8px;background:#18261f;margin:10px 0}.msg.err{background:#32171d}@media(max-width:800px){.customer{grid-template-columns:1fr}.head,.row{align-items:flex-start;flex-direction:column}}
+15
View File
@@ -0,0 +1,15 @@
const $=id=>document.getElementById(id);let state={me:null,tasks:[],workers:[]};
function msg(t,err=false){const e=$('msg');e.textContent=t;e.className='msg'+(err?' err':'');e.classList.remove('hidden');setTimeout(()=>e.classList.add('hidden'),5000)}
async function api(path,opt={}){const o={credentials:'same-origin',...opt};if(o.body&&typeof o.body!=='string'){o.headers={...(o.headers||{}),'Content-Type':'application/json'};o.body=JSON.stringify(o.body)}const r=await fetch(path,o);const ct=r.headers.get('content-type')||'';const b=ct.includes('json')?await r.json():await r.text();if(!r.ok)throw new Error(b?.error||b||`HTTP ${r.status}`);return b}
const credits=m=>(Number(m||0)/1e6).toLocaleString('de-DE',{maximumFractionDigits:3});
async function boot(){try{await load()}catch(e){$('auth').classList.remove('hidden');$('portal').classList.add('hidden');$('logout').classList.add('hidden')}const q=new URLSearchParams(location.search);if(q.get('paypal')==='return'&&q.get('token')){try{await api('/api/billing/paypal/capture',{method:'POST',body:{order_id:q.get('token')}});history.replaceState({},'',location.pathname);msg('PayPal-Zahlung verbucht');await load()}catch(e){msg(e.message,true)}}}
async function load(){const me=await api('/api/me');state.me=me;const [tasks,workers,ledger,packages]=await Promise.all([api('/api/tasks'),api('/api/workers'),api('/api/ledger'),api('/api/billing/packages')]);state.tasks=Array.isArray(tasks)?tasks:[];state.workers=workers||[];$('auth').classList.add('hidden');$('portal').classList.remove('hidden');$('logout').classList.remove('hidden');$('balance').textContent=credits(me.balance_micros);$('rate').textContent=credits(me.worker_rate_micros_per_minute);$('workerCount').textContent=workers.length;$('runningCount').textContent=`${workers.filter(x=>x.status==='running').length} aktiv`;$('rewardId').textContent=me.customer.reward_client_id||'noch nicht gekoppelt';renderTasks();renderWorkers();renderLedger(ledger||[]);renderPackages(packages)}
function taskName(t){return t.display_name||`Task ${String(t.id).slice(-8)}`}
function renderTasks(){const html=state.tasks.map(t=>`<option value="${esc(t.id)}">${esc(taskName(t))} · ${t.range_bits} bit${t.paused?' · PAUSED':''}</option>`).join('');$('newTask').innerHTML=html}
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]))}
function renderWorkers(){const host=$('workers');if(!state.workers.length){host.innerHTML='<article class="muted">Noch keine Worker angelegt.</article>';return}host.innerHTML=state.workers.map(w=>{const opts=state.tasks.map(t=>`<option value="${esc(t.id)}" ${t.id===w.task_id?'selected':''}>${esc(taskName(t))}</option>`).join('');return `<article class="worker" data-id="${esc(w.id)}"><span class="status ${w.status==='error'?'error':''}">${esc(w.status)}</span><h2>${esc(w.id)}</h2><div class="meta">Worker-ID: ${esc(w.worker_client_id||'wird beim ersten Start erzeugt')}</div><label>Task<select class="task">${opts}</select></label><label>Beacon<select class="path"><option value="auto" ${w.beacon_path==='auto'?'selected':''}>AUTO</option><option value="pulse" ${w.beacon_path==='pulse'?'selected':''}>PULSE</option><option value="flux" ${w.beacon_path==='flux'?'selected':''}>FLUX</option><option value="orbit" ${w.beacon_path==='orbit'?'selected':''}>ORBIT</option></select></label><div class="meta">${w.last_error?`Fehler: ${esc(w.last_error)}`:`Rate: ${credits(w.rate_micros_per_minute)} Credits/min`}</div><div class="buttons"><button data-act="save" class="ghost">ZUORDNUNG</button>${w.status==='running'?'<button data-act="stop" class="ghost">STOP</button>':'<button data-act="start">START</button>'}<button data-act="getid" class="ghost">IDENTITY ↓</button><button data-act="putid" class="ghost">IDENTITY ↑</button><button data-act="delete" class="danger">LÖSCHEN</button><input class="idfile hidden" type="file" accept="application/json,.json"></div></article>`}).join('');host.querySelectorAll('button[data-act]').forEach(b=>b.onclick=()=>workerAction(b.closest('.worker'),b.dataset.act));host.querySelectorAll('.idfile').forEach(i=>i.onchange=()=>uploadIdentity(i.closest('.worker'),i.files?.[0]))}
async function workerAction(card,act){const id=card.dataset.id;try{if(act==='save')await api(`/api/workers/${encodeURIComponent(id)}`,{method:'PUT',body:{TaskID:card.querySelector('.task').value,BeaconPath:card.querySelector('.path').value}});if(act==='start')await api(`/api/workers/${encodeURIComponent(id)}/start`,{method:'POST'});if(act==='stop')await api(`/api/workers/${encodeURIComponent(id)}/stop`,{method:'POST'});if(act==='delete'){if(!confirm('Worker UND seine private Identity dauerhaft löschen? Vorher Identity herunterladen, falls sie erhalten bleiben soll.'))return;await api(`/api/workers/${encodeURIComponent(id)}`,{method:'DELETE'})}if(act==='getid'){const r=await fetch(`/api/workers/${encodeURIComponent(id)}/identity`,{credentials:'same-origin'});if(!r.ok){const x=await r.json().catch(()=>({error:'Download fehlgeschlagen'}));throw new Error(x.error)}const blob=await r.blob();const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`${id}-identity.json`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);return}if(act==='putid'){card.querySelector('.idfile').click();return}msg('Worker aktualisiert');await load()}catch(e){msg(e.message,true)}}
async function uploadIdentity(card,file){if(!file)return;if(!confirm('Die aktuelle Worker-Identity wird ersetzt. Der Worker wird dabei gestoppt. Fortfahren?'))return;try{const r=await fetch(`/api/workers/${encodeURIComponent(card.dataset.id)}/identity`,{method:'PUT',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:await file.text()});const x=await r.json().catch(()=>({}));if(!r.ok)throw new Error(x.error||'Upload fehlgeschlagen');msg('Identity ersetzt');await load()}catch(e){msg(e.message,true)}}
function renderLedger(xs){$('ledger').innerHTML=xs.length?xs.map(x=>`<div class="ledger-row"><span>${esc(x.reason)}</span><span>${new Date(x.created_at).toLocaleString()}</span><strong class="${x.delta_micros>=0?'credit':'debit'}">${x.delta_micros>=0?'+':''}${credits(x.delta_micros)}</strong></div>`).join(''):'<div class="muted">Noch keine Buchungen.</div>'}
function renderPackages(v){const h=$('packages');$('paypalNote').textContent=v.paypal_enabled?`PayPal ${v.environment||''} · Credits werden erst nach serverseitig bestätigtem Capture verbucht.`:'PayPal ist derzeit deaktiviert.';h.innerHTML=(v.packages||[]).map(p=>`<div class="package"><span><strong>${credits(p.credits_micros)} Credits</strong><br><small>${(p.amount_cents/100).toFixed(2)} ${esc(p.currency)}</small></span><button data-pkg="${esc(p.id)}" ${v.paypal_enabled?'':'disabled'}>PAYPAL</button></div>`).join('');h.querySelectorAll('[data-pkg]').forEach(b=>b.onclick=async()=>{try{const x=await api('/api/billing/paypal/order',{method:'POST',body:{package_id:b.dataset.pkg}});location.href=x.approval_url}catch(e){msg(e.message,true)}})}
$('login').onclick=async()=>{try{await api('/api/login',{method:'POST',body:{Username:$('loginUser').value,Password:$('loginPass').value}});await load()}catch(e){msg(e.message,true)}};$('register').onclick=async()=>{try{await api('/api/register',{method:'POST',body:{Username:$('regUser').value,Password:$('regPass').value}});await load()}catch(e){msg(e.message,true)}};$('logout').onclick=async()=>{await api('/api/logout',{method:'POST'}).catch(()=>{});location.reload()};$('saveReward').onclick=async()=>{try{const code=$('rewardLinkCode').value.trim();if(!code)throw new Error('Hosted-Code einfügen');await api('/api/reward-identity',{method:'PUT',body:{link_code:code}});$('rewardLinkCode').value='';msg('Haupt-Identität sicher gekoppelt');await load()}catch(e){msg(e.message,true)}};$('clearReward').onclick=async()=>{if(!confirm('Reward-Kopplung wirklich lösen? Laufende Worker sollten vorher gestoppt werden.'))return;try{await api('/api/reward-identity',{method:'PUT',body:{clear:true}});msg('Reward-Kopplung gelöst');await load()}catch(e){msg(e.message,true)}};$('newWorker').onclick=()=> $('newWorkerBox').classList.toggle('hidden');$('createWorker').onclick=async()=>{try{await api('/api/workers',{method:'POST',body:{TaskID:$('newTask').value,BeaconPath:$('newPath').value}});$('newWorkerBox').classList.add('hidden');await load()}catch(e){msg(e.message,true)}};boot();
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Neural Hunt · Customer Service</title><link rel="stylesheet" href="/styles.css"></head>
<body><main>
<header><div><div class="eyebrow">NEURAL HUNT</div><h1>Customer Service</h1><p class="muted">PrePaid Worker · Task-Zuordnung · Reward Management</p></div><button id="logout" class="ghost hidden">ABMELDEN</button></header>
<div id="msg" class="msg hidden"></div>
<section id="auth" class="grid auth-grid">
<article><h2>Anmelden</h2><label>Benutzername<input id="loginUser" autocomplete="username"></label><label>Passwort<input id="loginPass" type="password" autocomplete="current-password"></label><button id="login">ANMELDEN</button></article>
<article><h2>Konto erstellen</h2><label>Benutzername<input id="regUser" autocomplete="username"></label><label>Passwort · min. 12 Zeichen<input id="regPass" type="password" autocomplete="new-password"></label><button id="register">REGISTRIEREN</button></article>
</section>
<div id="portal" class="hidden">
<section class="stats"><article><span>GUTHABEN</span><strong id="balance">—</strong><small>PrePaid Credits</small></article><article><span>KOSTEN</span><strong id="rate">—</strong><small>pro laufendem Worker / Minute</small></article><article><span>WORKER</span><strong id="workerCount">—</strong><small id="runningCount">— aktiv</small></article></section>
<section class="grid two">
<article><h2>Haupt-Identität / Rewards</h2><p class="muted">Gewinne aller verwalteten Worker werden deiner Haupt-Identität zugeordnet. Aus Sicherheitsgründen reicht eine öffentliche Client-ID nicht: Erzeuge im Neural-Hunt-Spiel als eingeloggte Haupt-Identität einen einmaligen <b>HOSTED CODE</b> und füge ihn hier ein.</p><div class="meta">Aktuell: <code id="rewardId">noch nicht gekoppelt</code></div><label>Einmaliger Hosted-Code<input id="rewardLinkCode" autocomplete="off" placeholder="nhlink_…"></label><div class="buttons"><button id="saveReward">IDENTITÄT KOPPELN</button><button id="clearReward" class="ghost">KOPPLUNG LÖSEN</button></div><p class="hint">Der Code gilt 10 Minuten und nur einmal. Dein privater P-256-Schlüssel bleibt im Browser/CLI und wird niemals an Customer Service übertragen. Worker behalten jeweils eine eigene kryptografische Identität und Presence.</p></article>
<article><h2>PrePaid aufladen</h2><div id="packages" class="packages"></div><p id="paypalNote" class="hint"></p></article>
</section>
<section><div class="section-head"><div><h2>Worker</h2><p class="muted">Jeder Worker hat eine eigene persistente Identity und kann unabhängig einem Task zugeordnet werden.</p></div><button id="newWorker">+ WORKER</button></div><div id="newWorkerBox" class="new-worker hidden"><label>Task<select id="newTask"></select></label><label>Beacon-Pfad<select id="newPath"><option value="auto">AUTO</option><option value="pulse">PULSE</option><option value="flux">FLUX</option><option value="orbit">ORBIT</option></select></label><button id="createWorker">ERSTELLEN</button></div><div id="workers" class="worker-grid"></div></section>
<section><h2>Abrechnung</h2><p class="muted">Abgerechnet wird in bezahlten Worker-Zeitfenstern. Wenn das PrePaid-Guthaben nicht mehr für die nächste Minute reicht, stoppt der Service den Worker automatisch.</p><div id="ledger" class="ledger"></div></section>
</div>
</main><script src="/app.js" defer></script></body></html>
+1
View File
@@ -0,0 +1 @@
:root{color-scheme:dark;--bg:#070a0f;--card:#101720;--line:#263443;--text:#eef5fb;--muted:#8da0b1;--accent:#54f0a6;--warn:#ffca56;--danger:#ff6978}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 20% 0,#112318 0,#070a0f 38%);color:var(--text);font:15px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}main{max-width:1180px;margin:auto;padding:32px 20px 80px}header{display:flex;justify-content:space-between;align-items:center;margin-bottom:28px}h1{font:700 clamp(32px,5vw,58px)/1 system-ui;margin:4px 0}h2{font:700 21px system-ui;margin:0 0 12px}.eyebrow{letter-spacing:.28em;color:var(--accent);font-weight:800}.muted,.hint,small{color:var(--muted)}article,section>article,.new-worker,.worker,.ledger-row{background:rgba(16,23,32,.88);border:1px solid var(--line);border-radius:14px;padding:18px}.grid{display:grid;gap:18px}.auth-grid,.two{grid-template-columns:repeat(2,minmax(0,1fr))}.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:18px}.stats article{display:flex;flex-direction:column}.stats span{font-size:12px;letter-spacing:.18em;color:var(--muted)}.stats strong{font:700 30px system-ui;margin:5px 0}.section-head{display:flex;justify-content:space-between;align-items:end;margin:28px 0 12px}.section-head h2{margin:0}label{display:flex;flex-direction:column;gap:6px;color:var(--muted);margin:12px 0}input,select,button{font:inherit}input,select{width:100%;background:#080d13;color:var(--text);border:1px solid #344555;border-radius:9px;padding:11px}button{background:var(--accent);color:#04110b;border:0;border-radius:9px;padding:11px 14px;font-weight:900;cursor:pointer}button:disabled{opacity:.45;cursor:not-allowed}.ghost{background:#1a2530;color:var(--text);border:1px solid var(--line)}.danger{background:#3a171c;color:#ffbac2;border:1px solid #67313a}.hidden{display:none!important}.msg{position:sticky;top:12px;z-index:5;padding:12px 15px;border:1px solid var(--line);background:#14211b;border-radius:10px;margin-bottom:14px}.msg.err{background:#2c1418;color:#ffd5da}.packages{display:grid;gap:8px}.package{display:flex;align-items:center;justify-content:space-between;background:#0b1118;border:1px solid var(--line);padding:11px;border-radius:9px}.worker-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(310px,1fr));gap:14px}.worker{position:relative}.worker .status{display:inline-flex;padding:4px 8px;border-radius:999px;background:#15231d;color:var(--accent);font-size:12px;text-transform:uppercase}.worker .status.error{background:#30171b;color:#ff8c98}.worker .meta{font-size:12px;color:var(--muted);word-break:break-all}.buttons{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px}.buttons button{padding:8px 10px;font-size:12px}.new-worker{display:grid;grid-template-columns:2fr 1fr auto;gap:12px;align-items:end;margin-bottom:14px}.new-worker label{margin:0}.ledger{display:grid;gap:7px}.ledger-row{display:grid;grid-template-columns:1fr 1fr auto;gap:8px;padding:10px 12px}.credit{color:var(--accent)}.debit{color:var(--warn)}section{margin-top:18px}@media(max-width:760px){.auth-grid,.two,.stats{grid-template-columns:1fr}.new-worker{grid-template-columns:1fr}.ledger-row{grid-template-columns:1fr}header{align-items:flex-start}}