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
+7
View File
@@ -0,0 +1,7 @@
FROM docker.io/nginx:1.27-alpine
COPY index.html /usr/share/nginx/html/index.html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: test-front
description: Test-Front — техпульт Binary Rocket (HTML/JS), статика
type: application
version: "9.9.9"
appVersion: "9.9.9"
+52
View File
@@ -0,0 +1,52 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}"
labels:
app.kubernetes.io/name: {{ $.Chart.Name }}
app.kubernetes.io/instance: {{ $.Release.Name }}
app.kubernetes.io/version: {{ $.Chart.AppVersion | quote }}
spec:
replicas: {{ $.Values.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ $.Chart.Name }}
app.kubernetes.io/instance: {{ $.Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ $.Chart.Name }}
app.kubernetes.io/instance: {{ $.Release.Name }}
spec:
{{- with $.Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
securityContext:
{{- toYaml $.Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ $.Chart.Name }}
securityContext:
{{- toYaml $.Values.securityContext | nindent 12 }}
image: "{{ $.Values.image.name }}:{{ default $.Chart.AppVersion $.Values.image.tag }}"
imagePullPolicy: {{ $.Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ $.Values.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 3
periodSeconds: 10
{{- if $.Values.resources }}
resources:
{{- toYaml $.Values.resources | nindent 12 }}
{{- end }}
+31
View File
@@ -0,0 +1,31 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}"
annotations: {}
spec:
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: {{ $.Values.gateway.serviceName }}
port:
number: {{ $.Values.gateway.port }}
- path: /ws
pathType: Prefix
backend:
service:
name: {{ $.Values.gateway.serviceName }}
port:
number: {{ $.Values.gateway.port }}
- path: /
pathType: Prefix
backend:
service:
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}"
port:
number: {{ $.Values.port }}
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}"
labels:
app.kubernetes.io/name: {{ $.Chart.Name }}
app.kubernetes.io/instance: {{ $.Release.Name }}
spec:
type: ClusterIP
ports:
- name: http
port: {{ $.Values.port }}
targetPort: http
protocol: TCP
selector:
app.kubernetes.io/name: {{ $.Chart.Name }}
app.kubernetes.io/instance: {{ $.Release.Name }}
+26
View File
@@ -0,0 +1,26 @@
replicaCount: 1
image:
name: 'images.binom.pw/smart/test-front'
pullPolicy: IfNotPresent
imagePullSecrets:
- name: regcred
port: 80
ingress:
# Один домен на два контейнера: / -> эта статика, /api и /ws -> шлюз.
# Разные пути в одном Ingress нельзя (nginx-ingress-контроллер не умеет),
# поэтому ингресс фронта обслуживает только `/`, а шлюз подключается
# вторым IngressObject с тем же host и path /api, /ws.
host: testfront.binom.pw
enabled: true
# Второй бэкенд (шлюз) — в другом релизе/чарте, но на том же хосте.
gateway:
enabled: true
serviceName: testfront-gateway-smart-updown-relayer
port: 8895
resources: {}
+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>
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_min_length 512;
gzip_types text/html application/javascript application/json text/css;
# index.html — без кеша: пульт должен подхватывать новые релизы сразу.
location = /index.html {
add_header Cache-Control "no-store";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-store";
}
}