Files
test-front/index.html
T
Caffeine 83f6c77538 feat: живая цена SOL по WebSocket + деплой в k3s (Dockerfile/helm)
Фронт подключается к wss://<host>/api/ws и выводит цену SOL, которую
шлюз транслирует из NATS. Автопереподключение, статус соединения,
счётчик сообщений. Добавлены Dockerfile/nginx.conf/helm для деплоя.
2026-09-13 05:53:48 +03:00

361 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<title>Binary Rocket — техтест экономики через HTTP-шлюз (фантики, без Phantom)</title>
<style>
body{font-family:monospace;background:#111;color:#eee;margin:20px;font-size:14px}
h1{font-size:16px}
.row{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0;align-items:center}
button{background:#333;color:#fff;border:1px solid #555;padding:6px 10px;cursor:pointer;font-family:monospace}
button:disabled{opacity:.4;cursor:not-allowed}
input,select{background:#222;color:#eee;border:1px solid #555;padding:5px;font-family:monospace}
input::placeholder{color:#555}
label{margin-right:2px}
#log{background:#000;border:1px solid #444;padding:10px;height:320px;overflow:auto;white-space:pre-wrap;font-size:12px;line-height:1.4}
.ok{color:#7f7} .err{color:#f77} .info{color:#88f}
fieldset{border:1px solid #444;margin:10px 0;padding:10px}
legend{font-size:13px;color:#aaa}
.bal{font-size:15px;font-weight:bold}
.hint{color:#666;font-size:12px}
</style>
</head>
<body>
<h1>Binary Rocket :: техтест через HTTP-шлюз (фантики, без Phantom)</h1>
<fieldset>
<legend>Цена SOL (WebSocket)</legend>
<div class="row">
<span style="font-size:22px;font-weight:bold">SOL: <span id="solPrice">—</span></span>
<span class="hint" id="solPriceTs"></span>
</div>
<div class="row">
<span>Статус: <span id="wsStatus" class="hint">отключено</span></span>
<span class="hint">сообщений: <span id="wsMsgCount">0</span></span>
<button id="wsToggle" onclick="wsToggle()">Подключить</button>
</div>
<div class="row">
<label>WS URL</label><input id="wsUrl" size="46" placeholder="(пусто — вычислить из gwBase)"/>
</div>
</fieldset>
<fieldset>
<legend>Конфигурация шлюза</legend>
<div class="row">
<label>Шлюз (API)</label><input id="gwBase" size="40" value="https://testfront.binom.pw/api"/>
<span class="hint">если пусто — фронт выведет и использует свой адрес (location.origin)</span>
</div>
<div class="row">
<label>Адрес</label><input id="addr" size="46" value="6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6"/>
<button onclick="fillPreset('admin')">admin</button>
<button onclick="fillPreset('bettor-1')">bettor-1</button>
<button onclick="fillPreset('bettor-2')">bettor-2</button>
</div>
<div class="hint">admin — источник фантиков; bettor-N — custodial-кошельки, зарегистрированные в keystore шлюза</div>
</fieldset>
<fieldset>
<legend>Состояние / баланс</legend>
<div class="row">
<button onclick="gwState()">Обновить state</button>
<button onclick="gwBalance()">Проверить баланс</button>
<button onclick="copyLog()">Копировать лог</button>
<button onclick="log.clear()">Очистить лог</button>
</div>
<div class="row bal">
<span>Фантики: <span id="balTok">—</span></span>
<span style="margin-left:18px">SOL: <span id="balSol">—</span></span>
</div>
<div class="hint">баланс показывается для адреса из поля «Адрес»</div>
<div id="stateInfo" class="info" style="margin-top:6px">Состояние ещё не прочитано.</div>
</fieldset>
<fieldset>
<legend>Ставка (POST /bet, без Phantom)</legend>
<div class="row">
<label>Сумма (фантики)</label><input id="betAmt" size="14" value="1000"/>
<label>Сторона</label>
<select id="betSide">
<option value="UP">UP (вверх)</option>
<option value="DOWN">DOWN (вниз)</option>
</select>
<button onclick="gwBet()">Сделать ставку</button>
</div>
<div class="hint">сумма ×1e6 → units (decimals=6); адрес берётся из поля «Адрес» (пустой адрес — шлюз сам сгенерит и зарегистрирует кошелёк)</div>
<div class="hint" id="betLimits">лимиты ставок неизвестны (обновите state)</div>
</fieldset>
<fieldset>
<legend>Закрытие (POST /close)</legend>
<div class="row">
<label>id ставки</label><input id="closeId" size="12" value=""/>
<button onclick="gwClose()">Закрыть ставку</button>
<span class="hint">id заполнится после ставки, либо введите вручную</span>
</div>
</fieldset>
<fieldset>
<legend>Лог</legend>
<div id="log"></div>
</fieldset>
<script>
// ---------- tech-test front: Binary Rocket economics via HTTP gateway (без Phantom) ----------
const log = (() => {
const el = () => document.getElementById('log');
const line = (cls, text) => { const e=el(); const d=document.createElement('div'); d.className=cls; d.textContent='['+new Date().toLocaleTimeString()+'] '+text; e.appendChild(d); e.scrollTop=e.scrollHeight; };
return { ok:(t)=>line('ok',t), err:(t)=>line('err',t), info:(t)=>line('info',t), clear:()=>el().textContent='' };
})();
const PRESETS = {
'admin': 'Huha5S9ySxHC9u7RXmeQbt8GZh1VXGzfjVPGdsYV2tKK',
'bettor-1': '6Q1koEYH3R2CdV13mwcPQTXbDjQhbyzY6N82f55o13s6',
'bettor-2': '6v2HXsowRnwrX82K7kCAKzkCpFA4gLqk1DAmRQvA96WA'
};
function fillPreset(name){
document.getElementById('addr').value = PRESETS[name] || '';
log.info('Пресет "'+name+'" → '+document.getElementById('addr').value);
}
const ADDR = () => document.getElementById('addr').value.trim();
// gwBase: если пусто — фронт выводит и использует свой адрес (location.origin)
function gwBase(){
const v = document.getElementById('gwBase').value.trim().replace(/\/+$/,'');
return v || location.origin;
}
async function gwFetch(path, body){
const url = gwBase() + path;
const opts = { method: body ? 'POST' : 'GET', headers: {'Content-Type':'application/json'} };
if(body) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
const text = await r.text();
let j = null; try{ j = JSON.parse(text); }catch(e){}
if(!r.ok) throw new Error('HTTP '+r.status+' '+(text||r.statusText));
return j;
}
// фантики (до 6 знаков) → токен-юниты (×1e6), через строку — без float-погрешности
function humanToUnits(human, dec){
dec = dec || 6;
const s = String(human==null?'':human).trim().replace(',', '.');
if(s===''||!/^\d*(\.\d*)?$/.test(s)) return null;
const dot = s.indexOf('.');
const intPart = dot===-1 ? s : s.slice(0,dot);
const frac = dot===-1 ? '' : s.slice(dot+1);
const digits = (intPart||'0') + frac.padEnd(dec,'0').slice(0,dec);
const clean = digits.replace(/^0+(?=\d)/,'');
return /^\d+$/.test(clean) ? BigInt(clean) : null;
}
let lastState = null;
async function gwState(){
try{
const j = await gwFetch('/state');
lastState = j;
document.getElementById('stateInfo').textContent =
'counter='+j.counter+
' | min='+j.minAmountHuman+' max='+j.maxAmountHuman+' фантик'+
' | x='+(Number(j.multiplierBps)/100)+
' | expiry='+j.expirySeconds+'s'+
' | paused='+j.paused+
' | vault='+j.vaultBalanceHuman+' фантик'+
' | admin='+j.admin+
' | relayer='+j.relayer;
document.getElementById('betLimits').textContent = 'лимиты шлюза: '+j.minAmountHuman+'–'+j.maxAmountHuman+' фантик';
log.ok('State: counter='+j.counter+', vault='+j.vaultBalanceHuman+' фантик, min='+j.minAmountHuman+', max='+j.maxAmountHuman);
}catch(e){ log.err('state: '+e.message); }
}
async function gwBalance(){
try{
const a = ADDR();
if(!a){ log.err('balance: введите адрес в поле «Адрес»'); return; }
const j = await gwFetch('/balance/'+encodeURIComponent(a));
document.getElementById('balTok').textContent = j.tokenHuman;
document.getElementById('balSol').textContent = Number(j.sol).toFixed(6)+' SOL';
log.ok('Баланс '+a+': фантики='+j.tokenHuman+', SOL='+j.sol+', tokenUnits='+j.tokenUnits);
}catch(e){ log.err('balance: '+e.message); }
}
let lastBetId = '';
async function gwBet(){
try{
const side = document.getElementById('betSide').value;
const units = humanToUnits(document.getElementById('betAmt').value);
if(units===null){ log.err('bet: некорректная сумма (фантики, до 6 знаков, напр 1000 или 1.5)'); return; }
if(units===0n){ log.err('bet: сумма должна быть больше нуля'); return; }
if(lastState){
const min = BigInt(lastState.minAmount), max = BigInt(lastState.maxAmount);
if(units<min||units>max) log.info('внимание: сумма вне лимитов '+lastState.minAmountHuman+'–'+lastState.maxAmountHuman+' (шлюз отклонит)');
}
const address = ADDR();
log.info('POST /bet side='+side+', amountUnits='+units.toString()+(address?' (address='+address+')':' (адрес пуст — шлюз сгенерит новый кошелёк)'));
const j = await gwFetch('/bet', {side:side, amountUnits:units.toString(), address:address});
lastBetId = String(j.id);
document.getElementById('closeId').value = lastBetId;
log.ok('Ставка принята: id='+j.id+', bettor='+j.bettor+', entryPrice='+j.entryPrice+(j.tx?' tx='+j.tx:''));
if(!address) log.info('кошелёк шлюза: '+j.bettor+' (скопируйте в поле «Адрес» для дальнейших ставок)');
}catch(e){ log.err('bet: '+e.message); }
}
async function gwClose(){
try{
const id = document.getElementById('closeId').value.trim();
if(!id){ log.err('close: нет id ставки (сначала сделайте ставку или введите id вручную)'); return; }
const j = await gwFetch('/close', {id:id});
const label = {payout_done:'ВЫИГРАЛ (payout_done)', house_won:'ПРОИГРАЛ (house_won)', open:'открыт (open)'}[j.statusName] || ('status='+j.statusName);
const detail = 'exitPrice='+j.exitPrice+(j.note?' — '+j.note:'');
if(j.statusName==='payout_done') log.ok('Закрыт #'+id+': '+label+', '+detail);
else if(j.statusName==='house_won') log.err('Закрыт #'+id+': '+label+', '+detail);
else log.info('Закрыт #'+id+': '+label+', '+detail);
}catch(e){ log.err('close: '+e.message); }
}
function copyLog(){ const t=document.getElementById('log').innerText; navigator.clipboard?.writeText(t).then(()=>log.ok('лог скопирован')).catch(()=>{}); }
// ---------- WebSocket: живая цена SOL ----------
let ws = null;
let wsReconnectTimer = null;
let wsBackoffMs = 3000;
const WS_BACKOFF_MAX = 15000;
let wsIntentClosed = false;
let wsMsgCount = 0;
let solPriceTsMs = 0;
function setWsStatus(text, cls){
const e = document.getElementById('wsStatus');
if(!e) return;
e.textContent = text;
e.className = cls || 'hint';
}
function syncWsToggleButton(){
const btn = document.getElementById('wsToggle');
if(!btn) return;
const isUp = ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING);
btn.textContent = isUp ? 'Отключить' : 'Подключить';
}
function formatSolPrice(p){
const s = String(p);
const dot = s.indexOf('.');
if(dot === -1) return s;
const intPart = s.slice(0, dot);
const frac = s.slice(dot + 1);
if(frac.length > 2) return (intPart || '0') + '.' + frac.slice(0, 2);
return s;
}
function updateSolPriceMeta(){
const e = document.getElementById('solPriceTs');
if(!e) return;
if(!solPriceTsMs){ e.textContent = ''; return; }
const sec = Math.max(0, Math.floor((Date.now() - solPriceTsMs) / 1000));
const t = new Date(solPriceTsMs).toLocaleTimeString();
e.textContent = ' ('+t+', '+sec+'с назад)';
}
function wsUrl(){
const v = document.getElementById('wsUrl').value.trim();
if(v) return v;
const base = gwBase();
let u;
if(base.startsWith('https://')) u = 'wss://' + base.slice('https://'.length);
else if(base.startsWith('http://')) u = 'ws://' + base.slice('http://'.length);
else u = base;
return u.replace(/\/+$/, '') + '/ws';
}
function connectWs(){
if(ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
wsIntentClosed = false;
const url = wsUrl();
document.getElementById('wsUrl').placeholder = url;
setWsStatus('подключение…', 'hint');
try{
ws = new WebSocket(url);
}catch(e){
log.err('ws: не удалось открыть '+url+' — '+e.message);
setWsStatus('ошибка', 'err');
scheduleWsReconnect();
syncWsToggleButton();
return;
}
ws.onopen = () => {
wsBackoffMs = 3000;
setWsStatus('подключено', 'ok');
syncWsToggleButton();
log.ok('ws: connected '+url);
};
ws.onmessage = (ev) => {
let m;
try{ m = JSON.parse(ev.data); }catch(e){ return; }
if(!m || m.type !== 'price') return;
wsMsgCount++;
document.getElementById('wsMsgCount').textContent = String(wsMsgCount);
const fullPrice = String(m.price);
const short = formatSolPrice(fullPrice);
const sp = document.getElementById('solPrice');
sp.textContent = short;
sp.title = fullPrice;
solPriceTsMs = Number(m.ts) || Date.now();
updateSolPriceMeta();
if(wsMsgCount === 1 || wsMsgCount % 50 === 0){
log.info('ws: price='+fullPrice+' (symbol='+m.symbol+')');
}
};
ws.onerror = () => {
setWsStatus('ошибка', 'err');
};
ws.onclose = () => {
ws = null;
syncWsToggleButton();
if(wsIntentClosed){
setWsStatus('отключено', 'hint');
return;
}
setWsStatus('переподключение…', 'hint');
scheduleWsReconnect();
};
}
function scheduleWsReconnect(){
if(wsReconnectTimer) return;
const delay = wsBackoffMs;
wsReconnectTimer = setTimeout(() => {
wsReconnectTimer = null;
connectWs();
}, delay);
wsBackoffMs = Math.min(WS_BACKOFF_MAX, wsBackoffMs * 2);
}
function disconnectWs(){
wsIntentClosed = true;
if(wsReconnectTimer){ clearTimeout(wsReconnectTimer); wsReconnectTimer = null; }
if(ws){
try{ ws.close(); }catch(e){}
}
}
function wsToggle(){
const isUp = ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING);
if(isUp) disconnectWs(); else connectWs();
}
(function init(){
const el = document.getElementById('gwBase');
if(!el.value.trim()){
el.placeholder = location.origin;
log.info('Шлюз (gwBase) не указан — используется адрес страницы: '+location.origin);
}
gwState();
gwBalance();
connectWs();
setInterval(updateSolPriceMeta, 1000);
})();
</script>
</body>
</html>