From 4d9f03c10f221360f9366f74188531c1201090de Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 8 May 2026 10:57:42 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D0=B3=D0=BE=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/web/css/style.css | 244 ++++++++++++++--------------------- cmd/server/web/js/app.js | 39 ++++-- cmd/server/web/js/ui.js | 56 ++++++-- internal/adapter/buffer.go | 142 +++++++++++++++----- 4 files changed, 276 insertions(+), 205 deletions(-) diff --git a/cmd/server/web/css/style.css b/cmd/server/web/css/style.css index ffa8237..4662c86 100644 --- a/cmd/server/web/css/style.css +++ b/cmd/server/web/css/style.css @@ -17,26 +17,22 @@ h1 { } /* ===== GRID ===== */ - .grid { display: grid; grid-template-columns: 1fr; gap: 12px; } -/* Desktop */ @media (min-width: 900px) { .grid { grid-template-columns: 1fr 1fr; } - .wide { grid-column: span 2; } } /* ===== CARD ===== */ - .card { border: 1px solid #00ff88; border-radius: 10px; @@ -51,107 +47,39 @@ h1 { } /* ===== STATUS ===== */ - #status { font-size: 28px; font-weight: bold; margin-bottom: 8px; } +/* СТАТУСЫ - ЦВЕТА */ +.status-ok { + color: #00ff88 !important; +} + +.status-lost { + color: #ffffff !important; + animation: blink 1.5s infinite; +} + +.status-error { + color: #ff4444 !important; +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.2; } +} + +/* ===== META ===== */ #meta { font-size: 14px; opacity: 0.8; word-break: break-word; } -/* ===== LATEST ===== */ - -#latest { - font-size: 36px; - font-weight: bold; - margin-bottom: 12px; -} - -/* ===== BITS ===== */ - -#bits { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 8px; -} - -.bit { - height: 52px; - border: 1px solid #00ff88; - border-radius: 8px; - display: flex; - justify-content: center; - align-items: center; - font-size: 22px; - font-weight: bold; -} - -.bit.on { - background: #00ff88; - color: #000; -} - -/* Tablet/Desktop */ -@media (min-width: 700px) { - #bits { - grid-template-columns: repeat(8, 1fr); - } -} - -/* ===== HISTORY ===== */ - -#history { - font-size: 13px; - line-height: 1.35; - max-height: 420px; - overflow-y: auto; - white-space: nowrap; - padding-right: 4px; -} - -/* Desktop */ -@media (min-width: 900px) { - #history { - font-size: 12px; - max-height: 520px; - } -} - -/* ===== PHONE ===== */ - -@media (max-width: 500px) { - - h1 { - font-size: 18px; - } - - #status { - font-size: 22px; - } - - #latest { - font-size: 30px; - } - - .bit { - height: 44px; - font-size: 18px; - } - - #history { - font-size: 16px; - line-height: 1.5; - max-height: 55vh; - } -} - /* ===== PROGRESS BARS ===== */ - .progress-row { margin-bottom: 16px; } @@ -174,6 +102,18 @@ h1 { 0 0 4px rgba(0,0,0,0.8); } +.progress-with-signal { + display: flex; + align-items: center; + gap: 0; +} + +.progress-90 { + flex: 0 0 86%; + margin-right: 10px; +} + +/* ЗАЛИВКА ПРОГРЕСС-БАРА */ .fill { height: 100%; width: 0%; @@ -184,49 +124,11 @@ h1 { transition: width 0.25s linear; } -/* mobile */ - -@media (max-width: 500px) { - .progress { - height: 30px; - padding: 5px; - } - - .progress-label { - font-size: 15px; - } -} - -.accordion-header { - cursor: pointer; - user-select: none; -} - -.accordion-header:hover { - opacity: 0.7; -} - -.accordion-body { - max-height: 520px; - overflow: hidden; - transition: max-height 0.3s ease, padding 0.3s ease; - padding: 0; -} - -.accordion-body.collapsed { - max-height: 0; - padding: 0; -} - -.progress-with-signal { - display: flex; - align-items: center; - gap: 0; -} - -.progress-90 { - flex: 0 0 86%; - margin-right: 10px; +/* Заливка при потере связи */ +.fill.no-connection { + background: #555555 !important; + box-shadow: none !important; + width: 0% !important; } /* ===== SIGNAL ICON ===== */ @@ -252,27 +154,35 @@ h1 { .sig-bar-2 { height: 66%; } .sig-bar-3 { height: 100%; } -/* Активные — зелёная заливка */ -.sig-bar.active-1, -.sig-bar.active-2, -.sig-bar.active-3, -.sig-bar.active-4 { - background-color: #00ff88; - border-color: #00ff88; -} - -/* Неактивные — прозрачные, только контур */ +/* Неактивные сигнальные бары */ .sig-bar.dim { background-color: transparent; border-color: #1a3a1a; } +/* Активные сигнальные бары */ +.sig-bar.active-1, +.sig-bar.active-2, +.sig-bar.active-3 { + background-color: #00ff88; + border-color: #00ff88; +} + +/* Нет связи - сигнальные бары */ +.sig-bar.no-connection { + background-color: #555555 !important; + border-color: #555555 !important; + opacity: 0.3 !important; +} + +/* ===== LAST BYTE ===== */ #last-byte { font-size: 18px; font-weight: bold; color: #00ff88; } +/* ===== CHART ===== */ #signalChart { width: 100%; background: #050805; @@ -280,26 +190,62 @@ h1 { border-radius: 8px; } +/* ===== HISTORY ===== */ #history { + font-size: 13px; + line-height: 1.35; + max-height: 420px; + overflow-y: auto; + white-space: nowrap; + padding-right: 4px; margin: 0; } +/* ===== ACCORDION ===== */ .accordion-header { cursor: pointer; user-select: none; - transition: background-color 0.2s; + padding: 10px 0; + margin: 0; } .accordion-header:hover { - background-color: rgba(255, 255, 255, 0.05); + opacity: 0.7; } .accordion-body { + max-height: 520px; overflow: hidden; - transition: max-height 0.3s ease-out; - max-height: 2000px; + transition: max-height 0.3s ease, opacity 0.3s ease; } .accordion-body.collapsed { max-height: 0; + opacity: 0; +} + +/* ===== MOBILE ===== */ +@media (max-width: 500px) { + h1 { + font-size: 18px; + } + + #status { + font-size: 22px; + } + + .progress { + height: 30px; + padding: 5px; + } + + .progress-label { + font-size: 15px; + } + + #history { + font-size: 16px; + line-height: 1.5; + max-height: 55vh; + } } \ No newline at end of file diff --git a/cmd/server/web/js/app.js b/cmd/server/web/js/app.js index 3b50eae..b8c8471 100644 --- a/cmd/server/web/js/app.js +++ b/cmd/server/web/js/app.js @@ -14,6 +14,7 @@ import { initAccordion } from "./accordion.js"; import { drawChart } from "./chart.js"; let lastChart = 0; +let connectionLost = false; // ================= INIT ================= window.addEventListener("DOMContentLoaded", () => { @@ -30,10 +31,17 @@ async function update() { ]); // ===== STATUS ===== - setStatus(h.status || "неизвестно"); + const isOnline = h.pipe_alive && h.has_data; + + if (isOnline) { + setStatus("На связи"); + connectionLost = false; + } else { + setStatus("Нет связи", true); // true = lost connection + connectionLost = true; + } const arr = Array.isArray(hist.bytes) ? hist.bytes : []; - const raw = arr[arr.length - 1] ?? 0; // ===== DECODE ===== @@ -45,11 +53,17 @@ async function update() { addSignal(value); // ===== UI ===== - setBars(smooth(percent)); - setSignal(level); + if (isOnline) { + setBars(smooth(percent)); + setSignal(level); + } else { + // При потере связи - сбрасываем индикаторы + setBars(0, true); // true = no connection + setSignal(0, true); // true = no connection + } if (DOM.lastByte) { - DOM.lastByte.textContent = value; + DOM.lastByte.textContent = isOnline ? value : "--"; } // ===== META ===== @@ -57,9 +71,10 @@ async function update() { const filled = h.stats?.filled ?? 0; const bps = Math.round(h.stats?.bits_per_sec ?? 0); const Bps = Math.round(h.stats?.bytes_per_sec ?? 0); + const idle = Math.round((h.last_data_ms || 0) / 1000); setMeta( - `работа: ${uptime}s | буфер: ${filled} | ${bps} bit/s (${Bps} B/s)` + `работа: ${uptime}s | буфер: ${filled} | ${bps} bit/s (${Bps} B/s) | idle: ${idle}s` ); // ===== HISTORY ===== @@ -74,14 +89,20 @@ async function update() { // ===== CHART ===== const now = performance.now(); - if (now - lastChart > 100) { - drawChart(state.signalHistory); + if (now - lastChart > 100 && DOM.canvas) { + drawChart(DOM.canvas, state.signalHistory); lastChart = now; } } catch (e) { console.error("UPDATE ERROR:", e); - setStatus("СЕРВЕР НЕДОСТУПЕН"); + setStatus("СЕРВЕР НЕДОСТУПЕН", true); + setBars(0, true); + setSignal(0, true); + + if (DOM.lastByte) { + DOM.lastByte.textContent = "--"; + } } } diff --git a/cmd/server/web/js/ui.js b/cmd/server/web/js/ui.js index 675a75d..a78b477 100644 --- a/cmd/server/web/js/ui.js +++ b/cmd/server/web/js/ui.js @@ -9,48 +9,76 @@ export function initDOM() { DOM.history = document.getElementById("history"); DOM.canvas = document.getElementById("signalChart"); DOM.bars = document.querySelectorAll(".sig-bar"); + DOM.barFill = document.getElementById("bar1"); + // Аккордеон DOM.historyHeader = document.getElementById("history-header"); DOM.historyBody = document.getElementById("history-body"); } -export function setStatus(v) { - if (DOM.status) DOM.status.textContent = v; +export function setStatus(v, isError = false) { + if (!DOM.status) return; + + DOM.status.textContent = v; + + // Стили для статуса + DOM.status.classList.remove("status-ok", "status-error", "status-lost"); + + if (isError) { + if (v === "Нет связи") { + DOM.status.classList.add("status-lost"); + } else { + DOM.status.classList.add("status-error"); + } + } else { + DOM.status.classList.add("status-ok"); + } } export function setMeta(v) { if (DOM.meta) DOM.meta.textContent = v; } -export function setBars(p) { - const el = document.getElementById("bar1"); +export function setBars(p, noConnection = false) { + const el = DOM.barFill; if (!el) return; - el.style.width = Math.max(0, Math.min(100, p)) + "%"; + + if (noConnection) { + // При потере связи - сбрасываем и делаем серым + el.style.width = "0%"; + el.classList.add("no-connection"); + } else { + el.classList.remove("no-connection"); + el.style.width = Math.max(0, Math.min(100, p)) + "%"; + } } -export function setSignal(level) { +export function setSignal(level, noConnection = false) { const bars = DOM.bars; if (!bars || bars.length < 3) return; - // reset + // Сброс bars.forEach(b => { - b.classList.remove("active-1", "active-2", "active-3"); - b.classList.add("dim"); + b.classList.remove("active-1", "active-2", "active-3", "dim", "no-connection"); + + if (noConnection) { + b.classList.add("no-connection"); + } else { + b.classList.add("dim"); + } }); - // 1 bar + if (noConnection) return; + + // Уровни сигнала if (level >= 1) { bars[0].classList.add("active-1"); bars[0].classList.remove("dim"); } - - // 2 bar if (level >= 2) { bars[1].classList.add("active-2"); bars[1].classList.remove("dim"); } - - // 3 bar if (level >= 3) { bars[2].classList.add("active-3"); bars[2].classList.remove("dim"); diff --git a/internal/adapter/buffer.go b/internal/adapter/buffer.go index 3a90607..c06f7fb 100644 --- a/internal/adapter/buffer.go +++ b/internal/adapter/buffer.go @@ -17,26 +17,33 @@ type RingBuffer struct { totalBytes atomic.Uint64 // ===== SPEED ===== - lastTick atomic.Int64 - lastBytes atomic.Uint64 + speedMu sync.Mutex + lastCalcTime time.Time + lastCalcBytes uint64 - bytesPerSec atomic.Value // float64 - bitsPerSec atomic.Value // float64 + // Текущие значения скорости + currentBPS atomic.Value // float64 + currentBPSBits atomic.Value // float64 + + // Для скользящего окна + recentBytes []timestampedByte + recentMu sync.Mutex +} + +type timestampedByte struct { + timestamp time.Time } // ===================== func NewRingBuffer(size int) *RingBuffer { rb := &RingBuffer{ - data: make([]byte, size), + data: make([]byte, size), + lastCalcTime: time.Now(), + recentBytes: make([]timestampedByte, 0, 1000), // храним последние 1000 байт } - now := time.Now().UnixNano() - - rb.lastWrite.Store(now) - rb.lastTick.Store(now) - - rb.bytesPerSec.Store(float64(0)) - rb.bitsPerSec.Store(float64(0)) + rb.currentBPS.Store(float64(0)) + rb.currentBPSBits.Store(float64(0)) return rb } @@ -55,34 +62,103 @@ func (rb *RingBuffer) Write(b byte) { rb.mu.Unlock() // ===== ATOMIC STATS ===== - now := time.Now().UnixNano() - rb.lastWrite.Store(now) + now := time.Now() + rb.lastWrite.Store(now.UnixNano()) + rb.totalBytes.Add(1) - total := rb.totalBytes.Add(1) + // ===== SPEED CALC (обновляем редко) ===== + rb.updateSpeedPeriodically(now) - // ===== SPEED CALC ===== - lastTick := rb.lastTick.Load() - if lastTick == 0 { - rb.lastTick.Store(now) - rb.lastBytes.Store(total) + // ===== Для скользящего окна ===== + rb.addTimestampedByte(now) +} + +// Периодическое обновление скорости (простое среднее) +func (rb *RingBuffer) updateSpeedPeriodically(now time.Time) { + rb.speedMu.Lock() + defer rb.speedMu.Unlock() + + elapsed := now.Sub(rb.lastCalcTime) + + // Обновляем не чаще чем раз в 250мс + if elapsed < 250*time.Millisecond { return } - elapsed := time.Duration(now - lastTick).Seconds() - if elapsed < 1.0 { - return + currentTotal := rb.totalBytes.Load() + bytesInPeriod := currentTotal - rb.lastCalcBytes + + if elapsed.Seconds() > 0 { + bps := float64(bytesInPeriod) / elapsed.Seconds() + rb.currentBPS.Store(bps) + rb.currentBPSBits.Store(bps * 8) } - last := rb.lastBytes.Load() - diff := total - last + rb.lastCalcTime = now + rb.lastCalcBytes = currentTotal +} - bps := float64(diff) / elapsed +// Скользящее окно +func (rb *RingBuffer) addTimestampedByte(now time.Time) { + rb.recentMu.Lock() + defer rb.recentMu.Unlock() - rb.bytesPerSec.Store(bps) - rb.bitsPerSec.Store(bps * 8) + // Добавляем новую запись + rb.recentBytes = append(rb.recentBytes, timestampedByte{timestamp: now}) - rb.lastTick.Store(now) - rb.lastBytes.Store(total) + // Удаляем старые записи (старше 1 секунды) + cutoff := now.Add(-1 * time.Second) + cutIdx := 0 + for i, tb := range rb.recentBytes { + if tb.timestamp.After(cutoff) { + cutIdx = i + break + } + } + + if cutIdx > 0 { + rb.recentBytes = rb.recentBytes[cutIdx:] + } + + // Вычисляем скорость на основе скользящего окна + if len(rb.recentBytes) > 1 { + windowDuration := rb.recentBytes[len(rb.recentBytes)-1].timestamp.Sub(rb.recentBytes[0].timestamp) + if windowDuration.Seconds() > 0 { + bps := float64(len(rb.recentBytes)) / windowDuration.Seconds() + rb.currentBPS.Store(bps) + rb.currentBPSBits.Store(bps * 8) + } + } +} + +// Получить скорость на основе скользящего окна (альтернативный метод) +func (rb *RingBuffer) GetWindowSpeed() (bytesPerSec, bitsPerSec float64) { + rb.recentMu.Lock() + defer rb.recentMu.Unlock() + + now := time.Now() + cutoff := now.Add(-1 * time.Second) + + // Считаем байты за последнюю секунду + count := 0 + for i := len(rb.recentBytes) - 1; i >= 0; i-- { + if rb.recentBytes[i].timestamp.Before(cutoff) { + break + } + count++ + } + + if count > 0 { + // Находим время самого старого байта в окне + oldest := rb.recentBytes[len(rb.recentBytes)-count].timestamp + duration := now.Sub(oldest).Seconds() + if duration > 0 { + bytesPerSec = float64(count) / duration + bitsPerSec = bytesPerSec * 8 + } + } + + return } // ===================== @@ -142,8 +218,8 @@ func (rb *RingBuffer) Stats() map[string]interface{} { filled := rb.count rb.mu.RUnlock() - bps, _ := rb.bytesPerSec.Load().(float64) - bits, _ := rb.bitsPerSec.Load().(float64) + bps, _ := rb.currentBPS.Load().(float64) + bits, _ := rb.currentBPSBits.Load().(float64) total := rb.totalBytes.Load() @@ -159,4 +235,4 @@ func (rb *RingBuffer) Stats() map[string]interface{} { "bytes_per_sec": bps, "bits_per_sec": bits, } -} +} \ No newline at end of file