diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f5c5fea --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/helm/Chart.yaml b/helm/Chart.yaml new file mode 100644 index 0000000..36bb104 --- /dev/null +++ b/helm/Chart.yaml @@ -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" diff --git a/helm/templates/deployment.yaml b/helm/templates/deployment.yaml new file mode 100644 index 0000000..c4beee3 --- /dev/null +++ b/helm/templates/deployment.yaml @@ -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 }} diff --git a/helm/templates/ingress.yaml b/helm/templates/ingress.yaml new file mode 100644 index 0000000..7c9e744 --- /dev/null +++ b/helm/templates/ingress.yaml @@ -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 }} diff --git a/helm/templates/service.yaml b/helm/templates/service.yaml new file mode 100644 index 0000000..2d192db --- /dev/null +++ b/helm/templates/service.yaml @@ -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 }} diff --git a/helm/values.yaml b/helm/values.yaml new file mode 100644 index 0000000..e93f081 --- /dev/null +++ b/helm/values.yaml @@ -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: {} diff --git a/index.html b/index.html index 1168038..6e459fb 100644 --- a/index.html +++ b/index.html @@ -23,6 +23,22 @@

Binary Rocket :: техтест через HTTP-шлюз (фантики, без Phantom)

+
+ Цена SOL (WebSocket) +
+ SOL: — + +
+
+ Статус: отключено + сообщений: 0 + +
+
+ +
+
+
Конфигурация шлюза
@@ -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); })(); diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..3148d08 --- /dev/null +++ b/nginx.conf @@ -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"; + } +}