diff --git a/cmd/server/web/css/style.css b/cmd/server/web/css/style.css index 38a9a58..5b75ba1 100644 --- a/cmd/server/web/css/style.css +++ b/cmd/server/web/css/style.css @@ -209,11 +209,13 @@ h1 { .accordion-body { max-height: 520px; overflow: hidden; - transition: max-height 0.3s ease; + transition: max-height 0.3s ease, padding 0.3s ease; + padding: 0; } .accordion-body.collapsed { max-height: 0; + padding: 0; } .progress-with-signal { @@ -269,4 +271,15 @@ h1 { font-size: 18px; font-weight: bold; color: #00ff88; +} + +#signalChart { + width: 100%; + background: #050805; + border: 1px solid #00ff88; + border-radius: 8px; +} + +#history { + margin: 0; } \ No newline at end of file diff --git a/cmd/server/web/dashboard.html b/cmd/server/web/dashboard.html index f0a566e..eb9db6b 100644 --- a/cmd/server/web/dashboard.html +++ b/cmd/server/web/dashboard.html @@ -59,10 +59,14 @@
+
+

ГРАФИК СИГНАЛА (последние 1000)

+ +
- + diff --git a/cmd/server/web/js/accordion.js b/cmd/server/web/js/accordion.js new file mode 100644 index 0000000..e324b67 --- /dev/null +++ b/cmd/server/web/js/accordion.js @@ -0,0 +1,19 @@ +// accordion.js +import { DOM } from "./ui.js"; + +export function initAccordion() { + if (!DOM.historyHeader || !DOM.historyBody) return; + + let open = false; + + DOM.historyBody.classList.add("collapsed"); + + DOM.historyHeader.addEventListener("click", () => { + open = !open; + + DOM.historyHeader.textContent = + (open ? "▼" : "▶") + " ПРИНЯТЫЕ ДАННЫЕ"; + + DOM.historyBody.classList.toggle("collapsed", !open); + }); +} \ No newline at end of file diff --git a/cmd/server/web/js/app.js b/cmd/server/web/js/app.js index 04929fd..46ceeab 100644 --- a/cmd/server/web/js/app.js +++ b/cmd/server/web/js/app.js @@ -1,148 +1,90 @@ -// ===== SMOOTH STATE ===== -let lastValue = 0; +import { fetchHealth, fetchHistory } from "./data.js"; +import { state, smooth, addSignal } from "./state.js"; +import { + initDOM, + setStatus, + setMeta, + setBars, + setSignal, + DOM +} from "./ui.js"; -// ===== ACCORDION ===== -const historyHeader = document.getElementById("history-header"); -const historyBody = document.getElementById("history-body"); +import { initAccordion } from "./accordion.js"; +import { drawChart } from "./chart.js"; -let accordionOpen = false; +let lastChart = 0; -if (historyBody && historyHeader) { - historyBody.classList.add("collapsed"); +// ================= INIT ================= +window.addEventListener("DOMContentLoaded", () => { + initDOM(); + initAccordion(); +}); - historyHeader.addEventListener("click", () => { - accordionOpen = !accordionOpen; - historyHeader.textContent = - (accordionOpen ? "▼" : "▶") + " ПРИНЯТЫЕ ДАННЫЕ"; - - historyBody.classList.toggle("collapsed", !accordionOpen); - }); -} - -// ===== PROGRESS ===== -function setBar(id, value) { - const el = document.getElementById(id); - if (!el) return; - - value = Math.max(0, Math.min(100, value)); - el.style.width = value + "%"; -} - -// ===== SIGNAL ===== -function setSignal(level) { - const bars = document.querySelectorAll(".sig-bar"); - if (!bars.length) return; - - bars.forEach(b => b.classList.remove("active-1", "active-2", "active-3", "dim")); - - bars.forEach(b => b.classList.add("dim")); - - if (level >= 1) { - bars[0].classList.add("active-1"); - bars[0].classList.remove("dim"); - } - if (level >= 2) { - bars[1].classList.add("active-2"); - bars[1].classList.remove("dim"); - } - if (level >= 3) { - bars[2].classList.add("active-3"); - bars[2].classList.remove("dim"); - } -} - -// ===== SMOOTH ===== -function smoothBar(target) { - const alpha = 0.2; - lastValue = lastValue + alpha * (target - lastValue); - return lastValue; -} - -// ===== MEDIAN ===== -function median(values) { - if (!values || values.length === 0) return 0; - - const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - - return sorted.length % 2 - ? sorted[mid] - : (sorted[mid - 1] + sorted[mid]) / 2; -} - -// ===== UPDATE LOOP ===== +// ================= UPDATE ================= async function update() { try { - const [r1, r2] = await Promise.all([ - fetch("/api/health"), - fetch("/api/history") + const [h, hist] = await Promise.all([ + fetchHealth(), + fetchHistory() ]); - const h = await r1.json(); - const hist = await r2.json(); - // ===== STATUS ===== - const statusEl = document.getElementById("status"); - if (statusEl) statusEl.textContent = h.status || "неизвестно"; + setStatus(h.status || "неизвестно"); - const filled = h.stats?.filled ?? 0; + const arr = Array.isArray(hist.bytes) ? hist.bytes : []; + + const raw = arr[arr.length - 1] ?? 0; + + // ===== DECODE ===== + const value = raw & 0x3F; + const level = (raw >> 6) & 0x3; + const percent = (value / 63) * 100; + + // ===== STATE ===== + addSignal(value); + + // ===== UI ===== + setBars(smooth(percent)); + setSignal(level); + + // 🔥 ВОТ ЭТО ТЫ ПОТЕРЯЛ + if (DOM.lastByte) { + DOM.lastByte.textContent = value; + } + + // ===== META ===== const uptime = Math.round(h.uptime_sec || 0); - + 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 meta = document.getElementById("meta"); - if (meta) { - meta.textContent = - `работа: ${uptime}s | буфер: ${filled} | ${bps} bit/s (${Bps} B/s)`; - } - - // ===== LATEST ===== - const latest = Number(h.latest || 0); - - const latestEl = document.getElementById("latest"); - if (latestEl) latestEl.textContent = latest; - - // optional bits view - if (typeof renderBits === "function") { - renderBits(latest); - } + setMeta( + `работа: ${uptime}s | буфер: ${filled} | ${bps} bit/s (${Bps} B/s)` + ); // ===== HISTORY ===== - const arr = Array.isArray(hist.bytes) ? hist.bytes : []; - - const historyEl = document.getElementById("history"); - if (historyEl) { - historyEl.innerHTML = arr + if (DOM.history) { + DOM.history.innerHTML = arr .map((b, i) => - `[${i}] ${(Number(b) & 0xFF).toString(2).padStart(8, "0")} = ${Number(b) & 0xFF}` + `[${i}] ${(b & 0xFF).toString(2).padStart(8, "0")} = ${b & 0xFF}` ) .join("
"); } - // ===== SIGNAL PARSING ===== - const raw = arr.length ? (arr[arr.length - 1] & 0xFF) : 0; + // ===== CHART ===== + const now = performance.now(); - const barValue = raw & 0x3F; // 0–63 - const signalLevel = (raw >> 6) & 0x3; // 0–3 - - const percent = (barValue / 63) * 100; - const smooth = smoothBar(percent); - - setBar("bar1", smooth); - setSignal(signalLevel); - - const lastByte = document.getElementById("last-byte"); - if (lastByte) lastByte.textContent = barValue; + if (now - lastChart > 100) { + drawChart(state.signalHistory); + lastChart = now; + } } catch (e) { console.error("UPDATE ERROR:", e); - - const status = document.getElementById("status"); - if (status) status.textContent = "СЕРВЕР НЕДОСТУПЕН"; + setStatus("СЕРВЕР НЕДОСТУПЕН"); } } -// ===== START ===== +// ================= LOOP ================= setInterval(update, 500); update(); \ No newline at end of file diff --git a/cmd/server/web/js/chart.js b/cmd/server/web/js/chart.js new file mode 100644 index 0000000..ba10dfc --- /dev/null +++ b/cmd/server/web/js/chart.js @@ -0,0 +1,33 @@ +// chart.js +let ctx = null; + +export function drawChart(canvas, history) { + if (!canvas || !history?.length) return; + + const ctx = canvas.getContext("2d"); + + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height; + + const w = canvas.width; + const h = canvas.height; + + ctx.clearRect(0, 0, w, h); + + const stepX = w / 1000; + const scaleY = h / 63; + + ctx.strokeStyle = "#00ff88"; + ctx.beginPath(); + + for (let i = 0; i < history.length; i++) { + const x = i * stepX; + const y = h - history[i].value * scaleY; // 👈 FIX + + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + + ctx.stroke(); +} \ No newline at end of file diff --git a/cmd/server/web/js/data.js b/cmd/server/web/js/data.js new file mode 100644 index 0000000..e9f5049 --- /dev/null +++ b/cmd/server/web/js/data.js @@ -0,0 +1,10 @@ +// data.js (API слой) +export async function fetchHealth() { + const r = await fetch("/api/health"); + return r.json(); +} + +export async function fetchHistory() { + const r = await fetch("/api/history"); + return r.json(); +} diff --git a/cmd/server/web/js/state.js b/cmd/server/web/js/state.js new file mode 100644 index 0000000..80bd150 --- /dev/null +++ b/cmd/server/web/js/state.js @@ -0,0 +1,21 @@ +// state.js (вся логика состояния) +export const state = { + maxPoints: 1000, + signalHistory: [], + smoothValue: 0, +}; + +// сглаживание +export function smooth(target) { + state.smoothValue += 0.2 * (target - state.smoothValue); + return state.smoothValue; +} + +// добавление в историю +export function addSignal(value) { + state.signalHistory.push(value); + + if (state.signalHistory.length > state.maxPoints) { + state.signalHistory.shift(); + } +} \ No newline at end of file diff --git a/cmd/server/web/js/ui.js b/cmd/server/web/js/ui.js new file mode 100644 index 0000000..3019b44 --- /dev/null +++ b/cmd/server/web/js/ui.js @@ -0,0 +1,56 @@ +// ui.js (DOM слой) +export let DOM = {}; + +export function initDOM() { + DOM.status = document.getElementById("status"); + DOM.meta = document.getElementById("meta"); + DOM.latest = document.getElementById("latest"); + DOM.lastByte = document.getElementById("last-byte"); + DOM.history = document.getElementById("history"); + DOM.canvas = document.getElementById("signalChart"); + + DOM.bars = document.querySelectorAll(".sig-bar"); +} + +export function setStatus(v) { + if (DOM.status) DOM.status.textContent = v; +} + +export function setMeta(v) { + if (DOM.meta) DOM.meta.textContent = v; +} + +export function setBars(p) { + const el = document.getElementById("bar1"); + if (!el) return; + el.style.width = Math.max(0, Math.min(100, p)) + "%"; +} + +export function setSignal(level) { + 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"); + }); + + // 1 bar + 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"); + } +} \ No newline at end of file diff --git a/internal/adapter/buffer.go b/internal/adapter/buffer.go index 3c4d2f2..3a90607 100644 --- a/internal/adapter/buffer.go +++ b/internal/adapter/buffer.go @@ -2,6 +2,7 @@ package adapter import ( "sync" + "sync/atomic" "time" ) @@ -11,29 +12,39 @@ type RingBuffer struct { writePtr int count int - lastWrite time.Time + lastWrite atomic.Int64 - // ===== SPEED METRICS ===== - totalBytes uint64 + totalBytes atomic.Uint64 - lastTick time.Time - lastBytes uint64 - bytesPerSec float64 - bitsPerSec float64 + // ===== SPEED ===== + lastTick atomic.Int64 + lastBytes atomic.Uint64 + + bytesPerSec atomic.Value // float64 + bitsPerSec atomic.Value // float64 } +// ===================== func NewRingBuffer(size int) *RingBuffer { - return &RingBuffer{ - data: make([]byte, size), - lastWrite: time.Now(), - lastTick: time.Now(), + rb := &RingBuffer{ + data: make([]byte, size), } + + now := time.Now().UnixNano() + + rb.lastWrite.Store(now) + rb.lastTick.Store(now) + + rb.bytesPerSec.Store(float64(0)) + rb.bitsPerSec.Store(float64(0)) + + return rb } +// ===================== func (rb *RingBuffer) Write(b byte) { rb.mu.Lock() - // ===== WRITE ===== rb.data[rb.writePtr] = b rb.writePtr = (rb.writePtr + 1) % len(rb.data) @@ -41,40 +52,45 @@ func (rb *RingBuffer) Write(b byte) { rb.count++ } - rb.lastWrite = time.Now() - rb.totalBytes++ - rb.mu.Unlock() - // ===== SPEED CALC (outside lock) ===== - now := rb.lastWrite + // ===== ATOMIC STATS ===== + now := time.Now().UnixNano() + rb.lastWrite.Store(now) - if rb.lastTick.IsZero() { - rb.lastTick = now - rb.lastBytes = rb.totalBytes + total := rb.totalBytes.Add(1) + + // ===== SPEED CALC ===== + lastTick := rb.lastTick.Load() + if lastTick == 0 { + rb.lastTick.Store(now) + rb.lastBytes.Store(total) return } - elapsed := now.Sub(rb.lastTick).Seconds() - - if elapsed >= 1.0 { - diff := rb.totalBytes - rb.lastBytes - - rb.bytesPerSec = float64(diff) / elapsed - rb.bitsPerSec = rb.bytesPerSec * 8 - - rb.lastTick = now - rb.lastBytes = rb.totalBytes + elapsed := time.Duration(now - lastTick).Seconds() + if elapsed < 1.0 { + return } + + last := rb.lastBytes.Load() + diff := total - last + + bps := float64(diff) / elapsed + + rb.bytesPerSec.Store(bps) + rb.bitsPerSec.Store(bps * 8) + + rb.lastTick.Store(now) + rb.lastBytes.Store(total) } +// ===================== func (rb *RingBuffer) LastWriteTime() time.Time { - rb.mu.RLock() - defer rb.mu.RUnlock() - - return rb.lastWrite + return time.Unix(0, rb.lastWrite.Load()) } +// ===================== func (rb *RingBuffer) GetLatest() (byte, bool) { rb.mu.RLock() defer rb.mu.RUnlock() @@ -91,6 +107,7 @@ func (rb *RingBuffer) GetLatest() (byte, bool) { return rb.data[idx], true } +// ===================== func (rb *RingBuffer) GetLast(n int) []byte { rb.mu.RLock() defer rb.mu.RUnlock() @@ -105,7 +122,6 @@ func (rb *RingBuffer) GetLast(n int) []byte { if start < 0 { start += len(rb.data) - copy(res, rb.data[start:]) copy(res[len(rb.data)-start:], rb.data[:rb.writePtr]) } else { @@ -115,26 +131,32 @@ func (rb *RingBuffer) GetLast(n int) []byte { return res } +// ===================== func (rb *RingBuffer) IsAlive(timeout time.Duration) bool { - rb.mu.RLock() - defer rb.mu.RUnlock() - - return time.Since(rb.lastWrite) < timeout + return time.Since(time.Unix(0, rb.lastWrite.Load())) < timeout } +// ===================== func (rb *RingBuffer) Stats() map[string]interface{} { rb.mu.RLock() - defer rb.mu.RUnlock() + filled := rb.count + rb.mu.RUnlock() + + bps, _ := rb.bytesPerSec.Load().(float64) + bits, _ := rb.bitsPerSec.Load().(float64) + + total := rb.totalBytes.Load() return map[string]interface{}{ - "size": len(rb.data), - "filled": rb.count, - "last_write": rb.lastWrite, + "size": len(rb.data), + "filled": filled, - "total_bytes": rb.totalBytes, - "total_bits": rb.totalBytes * 8, + "last_write": time.Unix(0, rb.lastWrite.Load()), - "bytes_per_sec": rb.bytesPerSec, - "bits_per_sec": rb.bitsPerSec, + "total_bytes": total, + "total_bits": total * 8, + + "bytes_per_sec": bps, + "bits_per_sec": bits, } }