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>
|
||||
<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
@@ -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