feat: живая цена SOL по WebSocket + деплой в k3s (Dockerfile/helm)

Фронт подключается к wss://<host>/api/ws и выводит цену SOL, которую
шлюз транслирует из NATS. Автопереподключение, статус соединения,
счётчик сообщений. Добавлены Dockerfile/nginx.conf/helm для деплоя.
This commit is contained in:
Caffeine
2026-09-13 05:53:48 +03:00
parent 5cd6a0a532
commit 83f6c77538
8 changed files with 306 additions and 0 deletions
+147
View File
@@ -23,6 +23,22 @@
<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">
@@ -199,6 +215,135 @@ async function gwClose(){
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()){
@@ -207,6 +352,8 @@ function copyLog(){ const t=document.getElementById('log').innerText; navigator.
}
gwState();
gwBalance();
connectWs();
setInterval(updateSolPriceMeta, 1000);
})();
</script>
</body>