feat: живая цена SOL по WebSocket + деплой в k3s (Dockerfile/helm)
Фронт подключается к wss://<host>/api/ws и выводит цену SOL, которую шлюз транслирует из NATS. Автопереподключение, статус соединения, счётчик сообщений. Добавлены Dockerfile/nginx.conf/helm для деплоя.
This commit is contained in:
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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 }}
|
||||||
@@ -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
@@ -23,6 +23,22 @@
|
|||||||
<body>
|
<body>
|
||||||
<h1>Binary Rocket :: техтест через HTTP-шлюз (фантики, без Phantom)</h1>
|
<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>
|
<fieldset>
|
||||||
<legend>Конфигурация шлюза</legend>
|
<legend>Конфигурация шлюза</legend>
|
||||||
<div class="row">
|
<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(()=>{}); }
|
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(){
|
(function init(){
|
||||||
const el = document.getElementById('gwBase');
|
const el = document.getElementById('gwBase');
|
||||||
if(!el.value.trim()){
|
if(!el.value.trim()){
|
||||||
@@ -207,6 +352,8 @@ function copyLog(){ const t=document.getElementById('log').innerText; navigator.
|
|||||||
}
|
}
|
||||||
gwState();
|
gwState();
|
||||||
gwBalance();
|
gwBalance();
|
||||||
|
connectWs();
|
||||||
|
setInterval(updateSolPriceMeta, 1000);
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+20
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user