diff --git a/build.sh b/build.sh index 5228c5d..fa40b48 100644 --- a/build.sh +++ b/build.sh @@ -1,21 +1,84 @@ #!/usr/bin/env bash -set -e +set -e # Остановка при любой ошибке APP_NAME="gpio-monitor-server" CMD_PATH="./cmd/server" BUILD_DIR="./build" -echo ">> Cleaning old build..." +echo "СБОРКА GPIO MONITOR SERVER" + +# ===== 1. СБОРКА TYPESCRIPT ===== +echo "" +echo "Шаг 1/4: Сборка TypeScript фронтенда..." +cd ~/temp/golang/cmd/server/web + +# Проверяем наличие node_modules +if [ ! -d "node_modules" ]; then + echo " Установка npm зависимостей..." + npm install +fi + +# Собираем TypeScript +echo " Компиляция TypeScript в JavaScript..." +npm run build + +if [ -d "dist" ]; then + echo " ✅ TypeScript скомпилирован успешно!" + echo " 📁 Скомпилированные файлы: $(ls dist/ | wc -l) файлов" +else + echo " ❌ Ошибка: папка dist не создана!" + exit 1 +fi + +# ===== 2. ПОДГОТОВКА GO СБОРКИ ===== +echo "" +echo "Шаг 2/4: Подготовка Go сборки..." +cd ~/temp/golang + +echo " Очистка старой сборки..." rm -rf ${BUILD_DIR} mkdir -p ${BUILD_DIR} -echo ">> Downloading dependencies..." +echo " Загрузка зависимостей..." go mod tidy -echo ">> Building ${APP_NAME}..." +# ===== 3. КОМПИЛЯЦИЯ GO ===== +echo "" +echo "аг 3/4: Компиляция Go сервера..." +# Для Raspberry Pi (ARM64) +echo " Целевая платформа: Linux ARM64" GOOS=linux GOARCH=arm64 go build -o ${BUILD_DIR}/${APP_NAME} ${CMD_PATH} -echo ">> Build complete:" -ls -lh ${BUILD_DIR}/${APP_NAME} +# Проверяем успешность сборки +if [ -f "${BUILD_DIR}/${APP_NAME}" ]; then + echo " ✅ Go сервер скомпилирован успешно!" +else + echo " ❌ Ошибка компиляции Go сервера!" + exit 1 +fi + +# ===== 4. ФИНАЛЬНЫЕ ДЕЙСТВИЯ ===== +echo "" +echo "Шаг 4/4: Финальная проверка..." + +# Проверяем размер +SIZE=$(ls -lh ${BUILD_DIR}/${APP_NAME} | awk '{print $5}') +echo " Размер бинарного файла: ${SIZE}" + +# Проверяем наличие embed файлов +echo " Проверка встроенных файлов..." +if [ -d "cmd/server/web/dist" ]; then + echo " ✅ TypeScript файлы (dist) включены в сборку" +fi + +if [ -d "cmd/server/web/css" ]; then + echo " ✅ CSS файлы включены в сборку" +fi + +if [ -f "cmd/server/web/dashboard.html" ]; then + echo " ✅ HTML файлы включены в сборку" +fi + +echo "СБОРКА ЗАВЕРШЕНА!" diff --git a/cmd/server/main.go b/cmd/server/main.go index 2fc3620..a411aa7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -20,7 +20,7 @@ import ( // ===== EMBED WEB ===== // -//go:embed web/* +//go:embed web/dist web/css web/*.html web/*.png var webFiles embed.FS type API struct { @@ -245,4 +245,4 @@ func main() { log.Println("Server started on :8080") log.Println("Camera proxy available at /api/cam") log.Fatal(http.ListenAndServe(":8080", nil)) -} \ No newline at end of file +} diff --git a/cmd/server/web/README.md b/cmd/server/web/README.md new file mode 100644 index 0000000..10a4083 --- /dev/null +++ b/cmd/server/web/README.md @@ -0,0 +1,34 @@ +# GPIO Monitor Web Interface + +Веб-интерфейс для мониторинга GPIO и видеопотока с камеры. + +## Требования + +- Node.js 20.x или выше +- npm 10.x или выше +- Go 1.21 + + +### 1. Установка Node.js и npm на Raspberry Pi + +```bash +# Добавление официального репозитория NodeSource +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash - + +# Установка Node.js и npm +sudo apt install -y nodejs + +# Проверка установки +node --version # v20.20.2 +npm --version # 10.8.2 + +### 2.Инициализация npm проекта + +# Переход в директорию с веб-файлами +cd ~/temp/golang/cmd/server/web + +# Инициализация package.json +npm init -y + +# Установка TypeScript и типов Node.js +npm install -D typescript @types/node diff --git a/cmd/server/web/README.md.save b/cmd/server/web/README.md.save new file mode 100644 index 0000000..6a05f70 --- /dev/null +++ b/cmd/server/web/README.md.save @@ -0,0 +1,35 @@ +# GPIO Monitor Web Interface + +Веб-интерфейс для мониторинга GPIO и видеопотока с камеры. + +## Требования + +- Node.js 20.x или выше +- npm 10.x или выше +- Go 1.21+ (для сборки сервера) + +## Установка и настройка TypeScript + +### 1. Установка Node.js и npm на Raspberry Pi + +```bash +# Добавление официального репозитория NodeSource +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash - + +# Установка Node.js и npm +sudo apt install -y nodejs + +# Проверка установки +node --version # v20.20.2 +npm --version # 10.8.2 + +### 2.Инициализация npm проекта + +# Переход в директорию с веб-файлами +cd ~/temp/golang/cmd/server/web + +# Инициализация package.json +npm init -y + +# Установка TypeScript и типов Node.js +npm install -D typescript @types/node diff --git a/cmd/server/web/dashboard.html b/cmd/server/web/dashboard.html index 7687d29..aa6683c 100644 --- a/cmd/server/web/dashboard.html +++ b/cmd/server/web/dashboard.html @@ -104,7 +104,7 @@ - + - \ No newline at end of file + diff --git a/cmd/server/web/dist/accordion.js b/cmd/server/web/dist/accordion.js new file mode 100644 index 0000000..529ebcd --- /dev/null +++ b/cmd/server/web/dist/accordion.js @@ -0,0 +1,18 @@ +// accordion.ts +import { DOM } from "./ui.js"; +export function initAccordion() { + if (!DOM.historyHeader || !DOM.historyBody) + return; + let open = false; + DOM.historyBody.classList.add("collapsed"); + DOM.historyHeader.textContent = "▶ ПРИНЯТЫЕ ДАННЫЕ"; + DOM.historyHeader.addEventListener("click", () => { + open = !open; + if (DOM.historyHeader) { + DOM.historyHeader.textContent = (open ? "▼" : "▶") + " ПРИНЯТЫЕ ДАННЫЕ"; + } + if (DOM.historyBody) { + DOM.historyBody.classList.toggle("collapsed", !open); + } + }); +} diff --git a/cmd/server/web/js/app.js b/cmd/server/web/dist/app.js similarity index 59% rename from cmd/server/web/js/app.js rename to cmd/server/web/dist/app.js index b379801..e985df7 100644 --- a/cmd/server/web/js/app.js +++ b/cmd/server/web/dist/app.js @@ -1,26 +1,16 @@ // app.js (оркестратор) import { fetchHealth, fetchHistory } from "./data.js"; import { state, smooth, addSignals, resetBufferTracking } from "./state.js"; -import { - initDOM, - setStatus, - setMeta, - setBars, - setSignal, - DOM -} from "./ui.js"; - +import { initDOM, setStatus, setMeta, setBars, setSignal, DOM } from "./ui.js"; import { initAccordion } from "./accordion.js"; import { drawChart } from "./chart.js"; import { initResize } from "./layout.js"; import { initCamera } from "./camera.js"; - let lastChart = 0; let connectionLost = false; let consecutiveErrors = 0; const MAX_ERRORS = 3; let pollTimer = null; - // ================= INIT ================= window.addEventListener("DOMContentLoaded", () => { initDOM(); @@ -28,146 +18,115 @@ window.addEventListener("DOMContentLoaded", () => { initResize(); initCamera(); }); - document.addEventListener("keydown", (e) => { - switch(e.key.toLowerCase()) { + const leftPanel = document.getElementById("leftPanel"); + const rightPanel = document.getElementById("rightPanel"); + switch (e.key.toLowerCase()) { case 'c': - // Переключение камеры const camBtn = document.getElementById('camToggle'); - if (camBtn) camBtn.click(); + if (camBtn) + camBtn.click(); break; case 'f': - // На весь экран для левой панели if (leftPanel) { leftPanel.style.width = "100%"; - if (rightPanel) rightPanel.style.display = "none"; + if (rightPanel) + rightPanel.style.display = "none"; } break; case 'escape': - if (rightPanel) rightPanel.style.display = "flex"; - applyRatio(0.6); // 60% левая, 40% правая + if (rightPanel) + rightPanel.style.display = "flex"; + if (leftPanel && rightPanel) { + const total = window.innerWidth; + const leftWidth = total * 0.6; + leftPanel.style.flex = `0 0 ${leftWidth}px`; + rightPanel.style.flex = `0 0 ${total - leftWidth}px`; + } break; } }); - // ================= UPDATE ================= async function update() { try { - const [h, hist] = await Promise.all([ + const [health, history] = await Promise.all([ fetchHealth(), fetchHistory() ]); - consecutiveErrors = 0; - if (connectionLost) { console.log("[app] Соединение восстановлено"); } - - // ===== STATUS ===== - const isOnline = h.pipe_alive && h.has_data; - + const isOnline = health.pipe_alive && health.has_data; if (isOnline) { setStatus("На связи"); connectionLost = false; - } else { + } + else { setStatus("Нет связи", true); - - if (!connectionLost) { + if (!connectionLost) resetBufferTracking(); - } connectionLost = true; } - - const arr = Array.isArray(hist.bytes) ? hist.bytes : []; + const arr = Array.isArray(history.bytes) ? history.bytes : []; const raw = arr[arr.length - 1] ?? 0; - - // ===== ДОБАВЛЯЕМ ТОЛЬКО НОВЫЕ БАЙТЫ ===== if (arr.length > 0 && isOnline) { - const bufferSize = h.stats?.filled ?? 0; + const bufferSize = health.stats?.filled ?? 0; addSignals(arr, bufferSize); } - - // ===== DECODE ===== const value = raw & 0x3F; const level = (raw >> 6) & 0x3; const percent = (value / 63) * 100; - - // ===== UI ===== if (isOnline) { setBars(smooth(percent)); setSignal(level); - } else { + } + else { setBars(0, true); setSignal(0, true); } - if (DOM.lastByte) { - DOM.lastByte.textContent = isOnline ? value : "--"; + DOM.lastByte.textContent = isOnline ? value.toString() : "--"; } - - // ===== META ===== - const uptime = Math.round(h.uptime_sec || 0); - const filled = h.stats?.filled ?? 0; - const Bps = Math.round(h.stats?.bytes_per_sec ?? 0); - - setMeta( - `Работа: ${uptime}s | Буфер: ${filled} | Приём: ${Bps} B/s` - ); - - // ===== HISTORY ===== + const uptime = Math.round(health.uptime_sec || 0); + const filled = health.stats?.filled ?? 0; + const Bps = Math.round(health.stats?.bytes_per_sec ?? 0); + setMeta(`Работа: ${uptime}s | Буфер: ${filled} | Приём: ${Bps} B/s`); if (DOM.history) { DOM.history.innerHTML = arr - .map((b, i) => - `[${i}] ${(b & 0xFF).toString(2).padStart(8, "0")} = ${b & 0xFF}` - ) + .map((b, i) => `[${i}] ${(b & 0xFF).toString(2).padStart(8, "0")} = ${b & 0xFF}`) .join("
"); } - - // ===== CHART ===== const now = performance.now(); - if (now - lastChart > 100 && DOM.canvas) { drawChart(DOM.canvas, state.signalHistory); lastChart = now; } - - } catch (e) { + } + catch (e) { consecutiveErrors++; - - if (consecutiveErrors === 1) { + if (consecutiveErrors === 1) console.warn("[app] Сервер недоступен, ждём..."); - } - setStatus("СЕРВЕР НЕДОСТУПЕН", true); setBars(0, true); setSignal(0, true); resetBufferTracking(); - - if (DOM.lastByte) { + if (DOM.lastByte) DOM.lastByte.textContent = "--"; - } - - if (consecutiveErrors >= MAX_ERRORS) { + if (consecutiveErrors >= MAX_ERRORS && pollTimer) { clearInterval(pollTimer); - pollTimer = setInterval(update, 2000); + pollTimer = window.setInterval(update, 2000); console.log("[app] Замедлили опрос до 2с"); } } } - -// ================= RECONNECT LOGIC ================= function startPolling() { - if (pollTimer) clearInterval(pollTimer); - - if (consecutiveErrors > 0) { - console.log("[app] Попытка переподключения..."); + if (pollTimer) + clearInterval(pollTimer); + if (consecutiveErrors > 0) update(); - } - - pollTimer = setInterval(update, 500); + pollTimer = window.setInterval(update, 500); } - window.addEventListener("focus", () => { if (consecutiveErrors >= MAX_ERRORS) { console.log("[app] Окно в фокусе, ускоряем опрос"); @@ -175,7 +134,5 @@ window.addEventListener("focus", () => { startPolling(); } }); - -// ================= START ================= startPolling(); -update(); \ No newline at end of file +update(); diff --git a/cmd/server/web/js/camera.js b/cmd/server/web/dist/camera.js similarity index 65% rename from cmd/server/web/js/camera.js rename to cmd/server/web/dist/camera.js index 3f33fa0..c232fa1 100644 --- a/cmd/server/web/js/camera.js +++ b/cmd/server/web/dist/camera.js @@ -1,134 +1,124 @@ -// camera.js +// camera.ts import { updateResizerVisibility } from './layout.js'; - -let rightPanel; -let camImage; -let crosshair; +let rightPanel = null; +let camImage = null; +let crosshair = null; let isOpen = false; let streamCheckInterval = null; - export function initCamera() { rightPanel = document.getElementById("rightPanel"); camImage = document.getElementById("camImage"); crosshair = document.getElementById("crosshair"); - // Проверяем доступность потока checkStreamAvailability(); - const btn = document.getElementById("camToggle"); - const streamUrl = "/api/cam"; - function open() { - if (isOpen) return; - + if (isOpen) + return; isOpen = true; - - rightPanel.classList.remove("hidden"); - + if (rightPanel) { + rightPanel.classList.remove("hidden"); + } // Показываем перекрестие if (crosshair) { crosshair.style.display = "block"; } - - camImage.src = streamUrl; - - if (btn) { - btn.querySelector('.cam-btn-text').textContent = 'Скрыть'; + if (camImage) { + camImage.src = streamUrl; + } + if (btn) { + const textSpan = btn.querySelector('.cam-btn-text'); + if (textSpan) + textSpan.textContent = 'Скрыть'; } - console.log("[camera] opened"); - updateResizerVisibility(); - // Запускаем мониторинг потока startStreamMonitoring(); } - function close() { - if (!isOpen) return; - + if (!isOpen) + return; isOpen = false; - // Скрываем перекрестие if (crosshair) { crosshair.style.display = "none"; } - // остановка stream - camImage.src = ""; - - rightPanel.classList.add("hidden"); - - if (btn) { - btn.querySelector('.cam-btn-text').textContent = 'Камера'; + if (camImage) { + camImage.src = ""; + } + if (rightPanel) { + rightPanel.classList.add("hidden"); + } + if (btn) { + const textSpan = btn.querySelector('.cam-btn-text'); + if (textSpan) + textSpan.textContent = 'Камера'; } - console.log("[camera] closed"); - updateResizerVisibility(); - // Останавливаем мониторинг stopStreamMonitoring(); } - function toggle() { if (isOpen) { close(); - } else { + } + else { open(); } } - - camImage.onload = () => { - console.log("[camera] stream started"); - if (crosshair) { - crosshair.style.opacity = '1'; - } - }; - - camImage.onerror = (e) => { - console.error("[camera] stream error", e); - if (crosshair) { - crosshair.style.opacity = '0.3'; - } - }; - + if (camImage) { + camImage.onload = () => { + console.log("[camera] stream started"); + if (crosshair) { + crosshair.style.opacity = '1'; + } + }; + camImage.onerror = (e) => { + console.error("[camera] stream error", e); + if (crosshair) { + crosshair.style.opacity = '0.3'; + } + }; + } btn?.addEventListener("click", toggle); - // Проверка доступности камеры async function checkStreamAvailability() { try { const response = await fetch('/api/stream'); const data = await response.json(); - if (data.available && data.cam) { console.log("[camera] stream available:", data.source); // Автозапуск если камера доступна if (!isOpen) { open(); } - } else { + } + else { console.log("[camera] stream not available"); if (crosshair) { crosshair.style.display = 'none'; } } - } catch (error) { + } + catch (error) { console.log("[camera] check failed:", error); } } - // Мониторинг состояния потока function startStreamMonitoring() { stopStreamMonitoring(); - streamCheckInterval = setInterval(() => { + streamCheckInterval = window.setInterval(() => { if (isOpen && camImage) { if (!camImage.complete || camImage.naturalWidth === 0) { if (crosshair) { crosshair.style.opacity = '0.3'; } - } else { + } + else { if (crosshair) { crosshair.style.opacity = '1'; } @@ -136,7 +126,6 @@ export function initCamera() { } }, 2000); } - function stopStreamMonitoring() { if (streamCheckInterval) { clearInterval(streamCheckInterval); @@ -144,18 +133,16 @@ export function initCamera() { } } } - // Экспортируем для возможности ручного управления export function showCrosshair(show = true) { - const crosshair = document.getElementById("crosshair"); - if (crosshair) { - crosshair.style.display = show ? "block" : "none"; + const crosshairElem = document.getElementById("crosshair"); + if (crosshairElem) { + crosshairElem.style.display = show ? "block" : "none"; } } - export function setCrosshairOpacity(opacity) { - const crosshair = document.getElementById("crosshair"); - if (crosshair) { - crosshair.style.opacity = opacity; + const crosshairElem = document.getElementById("crosshair"); + if (crosshairElem) { + crosshairElem.style.opacity = String(opacity); } -} \ No newline at end of file +} diff --git a/cmd/server/web/js/chart.js b/cmd/server/web/dist/chart.js similarity index 81% rename from cmd/server/web/js/chart.js rename to cmd/server/web/dist/chart.js index 895bcde..05839bf 100644 --- a/cmd/server/web/js/chart.js +++ b/cmd/server/web/dist/chart.js @@ -1,74 +1,57 @@ // chart.js export function drawChart(canvas, history) { - if (!canvas || !history?.length) return; - + if (!canvas || !history?.length) + return; const rect = canvas.getBoundingClientRect(); const displayHeight = rect.height || 200; const displayWidth = rect.width || 800; - canvas.width = displayWidth; canvas.height = displayHeight; - const ctx = canvas.getContext("2d"); + if (!ctx) + return; const w = canvas.width; const h = canvas.height; - - // ===== ОТСТУПЫ ===== const padLeft = 45; const padRight = 15; const padTop = 10; const padBottom = 25; - const plotW = w - padLeft - padRight; const plotH = h - padTop - padBottom; - - // ===== ФОН ===== + // Фон ctx.fillStyle = "#0a0f0a"; ctx.fillRect(0, 0, w, h); - - // ===== СЕТКА ===== + // Сетка const maxVal = 63; const gridLines = 4; - ctx.strokeStyle = "#0d2a0d"; ctx.lineWidth = 0.5; ctx.setLineDash([4, 4]); - for (let i = 0; i <= gridLines; i++) { const val = (maxVal / gridLines) * i; const y = padTop + plotH - (val / maxVal) * plotH; - ctx.beginPath(); ctx.moveTo(padLeft, y); ctx.lineTo(w - padRight, y); ctx.stroke(); - ctx.fillStyle = "#00ff88"; ctx.font = "11px monospace"; ctx.textAlign = "right"; - ctx.fillText(Math.round(val), padLeft - 8, y + 4); + ctx.fillText(Math.round(val).toString(), padLeft - 8, y + 4); } - ctx.setLineDash([]); - - // ===== ПОДПИСЬ ОСИ Y ===== + // Подписи осей ctx.fillStyle = "#00ff88"; ctx.font = "bold 11px monospace"; - ctx.save(); ctx.translate(12, padTop + plotH / 2); ctx.rotate(-Math.PI / 2); ctx.textAlign = "center"; ctx.fillText("ЗНАЧЕНИЕ", 0, 0); ctx.restore(); - - // ===== ПОДПИСЬ ОСИ X ===== ctx.textAlign = "center"; ctx.fillText("БУФЕР ПРИНЯТЫХ ДАННЫХ", padLeft + plotW / 2, h - 4); - - // ===== СИГНАЛ ===== const hasSignal = history.some(v => v > 0); - if (!hasSignal) { ctx.fillStyle = "#ff4444"; ctx.font = "14px monospace"; @@ -76,8 +59,6 @@ export function drawChart(canvas, history) { ctx.fillText("Ожидание сигнала...", w / 2, h / 2); return; } - - // Прореживание если точек больше чем пикселей let plotData = history; if (history.length > plotW) { const step = history.length / plotW; @@ -87,60 +68,42 @@ export function drawChart(canvas, history) { plotData.push(history[idx]); } } - ctx.strokeStyle = "#e6852d"; ctx.lineWidth = 1.5; ctx.shadowColor = "#e6852d"; ctx.shadowBlur = 3; - ctx.beginPath(); - const stepX = plotW / Math.max(plotData.length - 1, 1); - for (let i = 0; i < plotData.length; i++) { const x = padLeft + i * stepX; const y = padTop + plotH - (plotData[i] / maxVal) * plotH; - - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); + if (i === 0) + ctx.moveTo(x, y); + else + ctx.lineTo(x, y); } - ctx.stroke(); ctx.shadowBlur = 0; - - // ===== ПОСЛЕДНЯЯ ТОЧКА + ЗНАЧЕНИЕ ===== if (history.length > 0) { const lastVal = history[history.length - 1]; const lastX = padLeft + (plotData.length - 1) * stepX; const lastY = padTop + plotH - (lastVal / maxVal) * plotH; - - // Кружок ctx.fillStyle = "#c46b1f"; ctx.beginPath(); ctx.arc(lastX, lastY, 4, 0, Math.PI * 2); ctx.fill(); - - // Значение последней точки const labelText = `${lastVal}`; const labelWidth = ctx.measureText(labelText).width; - - // Фон для читаемости ctx.fillStyle = "rgba(0,0,0,0.8)"; ctx.fillRect(lastX - labelWidth / 2 - 4, lastY - 22, labelWidth + 8, 18); - - // Текст по центру над точкой ctx.fillStyle = "#00ff88"; ctx.font = "bold 12px monospace"; ctx.textAlign = "center"; ctx.fillText(labelText, lastX, lastY - 8); - - // Статистика в углу const peak = Math.max(...history); const min = Math.min(...history); - ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillRect(w - 120, padTop, 110, 42); - ctx.fillStyle = "#00ff88"; ctx.font = "10px monospace"; ctx.textAlign = "left"; @@ -148,4 +111,4 @@ export function drawChart(canvas, history) { ctx.fillText(`Мин: ${min}`, w - 110, padTop + 28); ctx.fillText(`Точек: ${history.length}`, w - 110, padTop + 42); } -} \ No newline at end of file +} diff --git a/cmd/server/web/js/data.js b/cmd/server/web/dist/data.js similarity index 63% rename from cmd/server/web/js/data.js rename to cmd/server/web/dist/data.js index e9f5049..c8b9144 100644 --- a/cmd/server/web/js/data.js +++ b/cmd/server/web/dist/data.js @@ -1,10 +1,12 @@ -// data.js (API слой) export async function fetchHealth() { const r = await fetch("/api/health"); + if (!r.ok) + throw new Error(`HTTP ${r.status}`); return r.json(); } - export async function fetchHistory() { const r = await fetch("/api/history"); + if (!r.ok) + throw new Error(`HTTP ${r.status}`); return r.json(); } diff --git a/cmd/server/web/dist/layout.js b/cmd/server/web/dist/layout.js new file mode 100644 index 0000000..b78b95a --- /dev/null +++ b/cmd/server/web/dist/layout.js @@ -0,0 +1,79 @@ +// layout.ts +let leftPanel = null; +let rightPanel = null; +let dragBar = null; +let isDragging = false; +let pendingX = null; +export function initResize() { + leftPanel = document.getElementById("leftPanel"); + rightPanel = document.getElementById("rightPanel"); + dragBar = document.getElementById("dragBar"); + if (!leftPanel || !rightPanel || !dragBar) + return; + const savedRatio = localStorage.getItem('layoutRatio'); + if (savedRatio) { + applyRatio(parseFloat(savedRatio)); + } + dragBar.addEventListener("mousedown", (e) => { + if (rightPanel.classList.contains("hidden")) + return; + isDragging = true; + document.body.style.cursor = "col-resize"; + e.preventDefault(); + }); + document.addEventListener("mouseup", () => { + if (!isDragging) + return; + isDragging = false; + document.body.style.cursor = "default"; + if (rightPanel && !rightPanel.classList.contains("hidden")) { + const total = window.innerWidth; + const leftWidth = leftPanel.getBoundingClientRect().width; + localStorage.setItem('layoutRatio', String(leftWidth / total)); + } + }); + document.addEventListener("mousemove", (e) => { + if (!isDragging) + return; + if (rightPanel && rightPanel.classList.contains("hidden")) + return; + pendingX = e.clientX; + requestAnimationFrame(() => { + if (pendingX === null) + return; + if (!leftPanel || !rightPanel || !dragBar) + return; + const total = window.innerWidth; + let leftWidth = pendingX; + const min = 300; + if (leftWidth < min) + leftWidth = min; + if (leftWidth > total - min) + leftWidth = total - min; + const rightWidth = total - leftWidth - dragBar.offsetWidth; + leftPanel.style.flex = `0 0 ${leftWidth}px`; + rightPanel.style.flex = `0 0 ${rightWidth}px`; + pendingX = null; + }); + }); +} +function applyRatio(ratio) { + const total = window.innerWidth; + const leftWidth = Math.floor(total * ratio); + const rightWidth = total - leftWidth; + if (leftPanel && rightPanel) { + leftPanel.style.flex = `0 0 ${leftWidth}px`; + rightPanel.style.flex = `0 0 ${rightWidth}px`; + } +} +export function updateResizerVisibility() { + if (!dragBar || !rightPanel || !leftPanel) + return; + if (rightPanel.classList.contains("hidden")) { + dragBar.classList.add("hidden"); + leftPanel.style.flex = "1"; + } + else { + dragBar.classList.remove("hidden"); + } +} diff --git a/cmd/server/web/js/state.js b/cmd/server/web/dist/state.js similarity index 74% rename from cmd/server/web/js/state.js rename to cmd/server/web/dist/state.js index 32a7432..90e1eec 100644 --- a/cmd/server/web/js/state.js +++ b/cmd/server/web/dist/state.js @@ -1,42 +1,33 @@ -// state.js (вся логика состояния) export const state = { maxPoints: 1000, signalHistory: [], smoothValue: 0, lastBufferSize: 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.splice(0, state.signalHistory.length - state.maxPoints); + state.signalHistory = state.signalHistory.slice(-state.maxPoints); } } - export function addSignals(values, bufferSize) { - if (!Array.isArray(values) || values.length === 0) return; - + if (!Array.isArray(values) || values.length === 0) + return; const newBytes = bufferSize - state.lastBufferSize; - if (newBytes > 0 && newBytes <= values.length) { const fresh = values.slice(-newBytes); const decoded = fresh.map(b => b & 0x3F); state.signalHistory.push(...decoded); } - state.lastBufferSize = bufferSize; - if (state.signalHistory.length > state.maxPoints) { - state.signalHistory.splice(0, state.signalHistory.length - state.maxPoints); + state.signalHistory = state.signalHistory.slice(-state.maxPoints); } } - export function resetBufferTracking() { state.lastBufferSize = 0; -} \ No newline at end of file +} diff --git a/cmd/server/web/js/ui.js b/cmd/server/web/dist/ui.js similarity index 67% rename from cmd/server/web/js/ui.js rename to cmd/server/web/dist/ui.js index a78b477..4203175 100644 --- a/cmd/server/web/js/ui.js +++ b/cmd/server/web/dist/ui.js @@ -1,6 +1,15 @@ -// ui.js (DOM слой) -export let DOM = {}; - +export const DOM = { + status: null, + meta: null, + latest: null, + lastByte: null, + history: null, + canvas: null, + bars: document.querySelectorAll(".sig-bar"), + barFill: null, + historyHeader: null, + historyBody: null, +}; export function initDOM() { DOM.status = document.getElementById("status"); DOM.meta = document.getElementById("meta"); @@ -8,69 +17,60 @@ export function initDOM() { DOM.lastByte = document.getElementById("last-byte"); 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"); + DOM.bars = document.querySelectorAll(".sig-bar"); } - export function setStatus(v, isError = false) { - if (!DOM.status) return; - + 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 { + } + else { DOM.status.classList.add("status-error"); } - } else { + } + else { DOM.status.classList.add("status-ok"); } } - export function setMeta(v) { - if (DOM.meta) DOM.meta.textContent = v; + if (DOM.meta) + DOM.meta.textContent = v; } - export function setBars(p, noConnection = false) { const el = DOM.barFill; - if (!el) return; - + if (!el) + return; if (noConnection) { - // При потере связи - сбрасываем и делаем серым el.style.width = "0%"; el.classList.add("no-connection"); - } else { + } + else { el.classList.remove("no-connection"); - el.style.width = Math.max(0, Math.min(100, p)) + "%"; + el.style.width = `${Math.max(0, Math.min(100, p))}%`; } } - export function setSignal(level, noConnection = false) { const bars = DOM.bars; - if (!bars || bars.length < 3) return; - - // Сброс - bars.forEach(b => { - b.classList.remove("active-1", "active-2", "active-3", "dim", "no-connection"); - + if (!bars || bars.length < 3) + return; + bars.forEach(bar => { + bar.classList.remove("active-1", "active-2", "active-3", "dim", "no-connection"); if (noConnection) { - b.classList.add("no-connection"); - } else { - b.classList.add("dim"); + bar.classList.add("no-connection"); + } + else { + bar.classList.add("dim"); } }); - - if (noConnection) return; - - // Уровни сигнала + if (noConnection) + return; if (level >= 1) { bars[0].classList.add("active-1"); bars[0].classList.remove("dim"); @@ -83,4 +83,4 @@ export function setSignal(level, noConnection = false) { bars[2].classList.add("active-3"); bars[2].classList.remove("dim"); } -} \ No newline at end of file +} diff --git a/cmd/server/web/js/accordion.js b/cmd/server/web/js/accordion.ts similarity index 50% rename from cmd/server/web/js/accordion.js rename to cmd/server/web/js/accordion.ts index c956ba2..861c600 100644 --- a/cmd/server/web/js/accordion.js +++ b/cmd/server/web/js/accordion.ts @@ -1,7 +1,7 @@ -// accordion.js +// accordion.ts import { DOM } from "./ui.js"; -export function initAccordion() { +export function initAccordion(): void { if (!DOM.historyHeader || !DOM.historyBody) return; let open = false; @@ -12,9 +12,12 @@ export function initAccordion() { DOM.historyHeader.addEventListener("click", () => { open = !open; - DOM.historyHeader.textContent = - (open ? "▼" : "▶") + " ПРИНЯТЫЕ ДАННЫЕ"; + if (DOM.historyHeader) { + DOM.historyHeader.textContent = (open ? "▼" : "▶") + " ПРИНЯТЫЕ ДАННЫЕ"; + } - DOM.historyBody.classList.toggle("collapsed", !open); + if (DOM.historyBody) { + DOM.historyBody.classList.toggle("collapsed", !open); + } }); -} \ No newline at end of file +} diff --git a/cmd/server/web/js/app.ts b/cmd/server/web/js/app.ts new file mode 100644 index 0000000..10fc24b --- /dev/null +++ b/cmd/server/web/js/app.ts @@ -0,0 +1,152 @@ +// app.js (оркестратор) +import { fetchHealth, fetchHistory } from "./data.js"; +import { state, smooth, addSignals, resetBufferTracking } from "./state.js"; +import { initDOM, setStatus, setMeta, setBars, setSignal, DOM } from "./ui.js"; +import { initAccordion } from "./accordion.js"; +import { drawChart } from "./chart.js"; +import { initResize } from "./layout.js"; +import { initCamera } from "./camera.js"; + +let lastChart: number = 0; +let connectionLost: boolean = false; +let consecutiveErrors: number = 0; +const MAX_ERRORS: number = 3; +let pollTimer: number | null = null; + +// ================= INIT ================= +window.addEventListener("DOMContentLoaded", () => { + initDOM(); + initAccordion(); + initResize(); + initCamera(); +}); + +document.addEventListener("keydown", (e: KeyboardEvent) => { + const leftPanel = document.getElementById("leftPanel"); + const rightPanel = document.getElementById("rightPanel"); + + switch(e.key.toLowerCase()) { + case 'c': + const camBtn = document.getElementById('camToggle'); + if (camBtn) camBtn.click(); + break; + case 'f': + if (leftPanel) { + leftPanel.style.width = "100%"; + if (rightPanel) rightPanel.style.display = "none"; + } + break; + case 'escape': + if (rightPanel) rightPanel.style.display = "flex"; + if (leftPanel && rightPanel) { + const total = window.innerWidth; + const leftWidth = total * 0.6; + leftPanel.style.flex = `0 0 ${leftWidth}px`; + rightPanel.style.flex = `0 0 ${total - leftWidth}px`; + } + break; + } +}); + +// ================= UPDATE ================= +async function update(): Promise { + try { + const [health, history] = await Promise.all([ + fetchHealth(), + fetchHistory() + ]); + + consecutiveErrors = 0; + + if (connectionLost) { + console.log("[app] Соединение восстановлено"); + } + + const isOnline = health.pipe_alive && health.has_data; + + if (isOnline) { + setStatus("На связи"); + connectionLost = false; + } else { + setStatus("Нет связи", true); + if (!connectionLost) resetBufferTracking(); + connectionLost = true; + } + + const arr = Array.isArray(history.bytes) ? history.bytes : []; + const raw = arr[arr.length - 1] ?? 0; + + if (arr.length > 0 && isOnline) { + const bufferSize = health.stats?.filled ?? 0; + addSignals(arr, bufferSize); + } + + const value = raw & 0x3F; + const level = (raw >> 6) & 0x3; + const percent = (value / 63) * 100; + + if (isOnline) { + setBars(smooth(percent)); + setSignal(level); + } else { + setBars(0, true); + setSignal(0, true); + } + + if (DOM.lastByte) { + DOM.lastByte.textContent = isOnline ? value.toString() : "--"; + } + + const uptime = Math.round(health.uptime_sec || 0); + const filled = health.stats?.filled ?? 0; + const Bps = Math.round(health.stats?.bytes_per_sec ?? 0); + + setMeta(`Работа: ${uptime}s | Буфер: ${filled} | Приём: ${Bps} B/s`); + + if (DOM.history) { + DOM.history.innerHTML = arr + .map((b, i) => `[${i}] ${(b & 0xFF).toString(2).padStart(8, "0")} = ${b & 0xFF}`) + .join("
"); + } + + const now = performance.now(); + if (now - lastChart > 100 && DOM.canvas) { + drawChart(DOM.canvas, state.signalHistory); + lastChart = now; + } + + } catch (e) { + consecutiveErrors++; + if (consecutiveErrors === 1) console.warn("[app] Сервер недоступен, ждём..."); + + setStatus("СЕРВЕР НЕДОСТУПЕН", true); + setBars(0, true); + setSignal(0, true); + resetBufferTracking(); + + if (DOM.lastByte) DOM.lastByte.textContent = "--"; + + if (consecutiveErrors >= MAX_ERRORS && pollTimer) { + clearInterval(pollTimer); + pollTimer = window.setInterval(update, 2000); + console.log("[app] Замедлили опрос до 2с"); + } + } +} + +function startPolling(): void { + if (pollTimer) clearInterval(pollTimer); + if (consecutiveErrors > 0) update(); + pollTimer = window.setInterval(update, 500); +} + +window.addEventListener("focus", () => { + if (consecutiveErrors >= MAX_ERRORS) { + console.log("[app] Окно в фокусе, ускоряем опрос"); + consecutiveErrors = 0; + startPolling(); + } +}); + +startPolling(); +update(); \ No newline at end of file diff --git a/cmd/server/web/js/camera.ts b/cmd/server/web/js/camera.ts new file mode 100644 index 0000000..d42f96f --- /dev/null +++ b/cmd/server/web/js/camera.ts @@ -0,0 +1,179 @@ +// camera.ts +import { updateResizerVisibility } from './layout.js'; + +let rightPanel: HTMLElement | null = null; +let camImage: HTMLImageElement | null = null; +let crosshair: HTMLElement | null = null; +let isOpen: boolean = false; +let streamCheckInterval: number | null = null; + +interface StreamResponse { + cam: string; + available: boolean; + source: string; +} + +export function initCamera(): void { + rightPanel = document.getElementById("rightPanel"); + camImage = document.getElementById("camImage") as HTMLImageElement; + crosshair = document.getElementById("crosshair"); + + // Проверяем доступность потока + checkStreamAvailability(); + + const btn = document.getElementById("camToggle"); + + const streamUrl = "/api/cam"; + + function open(): void { + if (isOpen) return; + + isOpen = true; + + if (rightPanel) { + rightPanel.classList.remove("hidden"); + } + + // Показываем перекрестие + if (crosshair) { + crosshair.style.display = "block"; + } + + if (camImage) { + camImage.src = streamUrl; + } + + if (btn) { + const textSpan = btn.querySelector('.cam-btn-text'); + if (textSpan) textSpan.textContent = 'Скрыть'; + } + + console.log("[camera] opened"); + + updateResizerVisibility(); + + // Запускаем мониторинг потока + startStreamMonitoring(); + } + + function close(): void { + if (!isOpen) return; + + isOpen = false; + + // Скрываем перекрестие + if (crosshair) { + crosshair.style.display = "none"; + } + + // остановка stream + if (camImage) { + camImage.src = ""; + } + + if (rightPanel) { + rightPanel.classList.add("hidden"); + } + + if (btn) { + const textSpan = btn.querySelector('.cam-btn-text'); + if (textSpan) textSpan.textContent = 'Камера'; + } + + console.log("[camera] closed"); + + updateResizerVisibility(); + + // Останавливаем мониторинг + stopStreamMonitoring(); + } + + function toggle(): void { + if (isOpen) { + close(); + } else { + open(); + } + } + + if (camImage) { + camImage.onload = () => { + console.log("[camera] stream started"); + if (crosshair) { + crosshair.style.opacity = '1'; + } + }; + + camImage.onerror = (e) => { + console.error("[camera] stream error", e); + if (crosshair) { + crosshair.style.opacity = '0.3'; + } + }; + } + + btn?.addEventListener("click", toggle); + + // Проверка доступности камеры + async function checkStreamAvailability(): Promise { + try { + const response = await fetch('/api/stream'); + const data: StreamResponse = await response.json(); + + if (data.available && data.cam) { + console.log("[camera] stream available:", data.source); + // Автозапуск если камера доступна + if (!isOpen) { + open(); + } + } else { + console.log("[camera] stream not available"); + if (crosshair) { + crosshair.style.display = 'none'; + } + } + } catch (error) { + console.log("[camera] check failed:", error); + } + } + + // Мониторинг состояния потока + function startStreamMonitoring(): void { + stopStreamMonitoring(); + streamCheckInterval = window.setInterval(() => { + if (isOpen && camImage) { + if (!camImage.complete || camImage.naturalWidth === 0) { + if (crosshair) { + crosshair.style.opacity = '0.3'; + } + } else { + if (crosshair) { + crosshair.style.opacity = '1'; + } + } + } + }, 2000); + } + + function stopStreamMonitoring(): void { + if (streamCheckInterval) { + clearInterval(streamCheckInterval); + streamCheckInterval = null; + } + } +} + +// Экспортируем для возможности ручного управления +export function showCrosshair(show: boolean = true): void { + const crosshairElem = document.getElementById("crosshair"); + if (crosshairElem) { + crosshairElem.style.display = show ? "block" : "none"; + } +} + +export function setCrosshairOpacity(opacity: string | number): void { + const crosshairElem = document.getElementById("crosshair"); + if (crosshairElem) { + crosshairElem.style.opacity = String(opacity); + } +} diff --git a/cmd/server/web/js/chart.ts b/cmd/server/web/js/chart.ts new file mode 100644 index 0000000..a44a923 --- /dev/null +++ b/cmd/server/web/js/chart.ts @@ -0,0 +1,140 @@ +// chart.js +export function drawChart(canvas: HTMLCanvasElement, history: number[]): void { + if (!canvas || !history?.length) return; + + const rect = canvas.getBoundingClientRect(); + const displayHeight = rect.height || 200; + const displayWidth = rect.width || 800; + + canvas.width = displayWidth; + canvas.height = displayHeight; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const w = canvas.width; + const h = canvas.height; + + const padLeft = 45; + const padRight = 15; + const padTop = 10; + const padBottom = 25; + const plotW = w - padLeft - padRight; + const plotH = h - padTop - padBottom; + + // Фон + ctx.fillStyle = "#0a0f0a"; + ctx.fillRect(0, 0, w, h); + + // Сетка + const maxVal = 63; + const gridLines = 4; + + ctx.strokeStyle = "#0d2a0d"; + ctx.lineWidth = 0.5; + ctx.setLineDash([4, 4]); + + for (let i = 0; i <= gridLines; i++) { + const val = (maxVal / gridLines) * i; + const y = padTop + plotH - (val / maxVal) * plotH; + + ctx.beginPath(); + ctx.moveTo(padLeft, y); + ctx.lineTo(w - padRight, y); + ctx.stroke(); + + ctx.fillStyle = "#00ff88"; + ctx.font = "11px monospace"; + ctx.textAlign = "right"; + ctx.fillText(Math.round(val).toString(), padLeft - 8, y + 4); + } + + ctx.setLineDash([]); + + // Подписи осей + ctx.fillStyle = "#00ff88"; + ctx.font = "bold 11px monospace"; + + ctx.save(); + ctx.translate(12, padTop + plotH / 2); + ctx.rotate(-Math.PI / 2); + ctx.textAlign = "center"; + ctx.fillText("ЗНАЧЕНИЕ", 0, 0); + ctx.restore(); + + ctx.textAlign = "center"; + ctx.fillText("БУФЕР ПРИНЯТЫХ ДАННЫХ", padLeft + plotW / 2, h - 4); + + const hasSignal = history.some(v => v > 0); + + if (!hasSignal) { + ctx.fillStyle = "#ff4444"; + ctx.font = "14px monospace"; + ctx.textAlign = "center"; + ctx.fillText("Ожидание сигнала...", w / 2, h / 2); + return; + } + + let plotData = history; + if (history.length > plotW) { + const step = history.length / plotW; + plotData = []; + for (let i = 0; i < plotW; i++) { + const idx = Math.floor(i * step); + plotData.push(history[idx]); + } + } + + ctx.strokeStyle = "#e6852d"; + ctx.lineWidth = 1.5; + ctx.shadowColor = "#e6852d"; + ctx.shadowBlur = 3; + + ctx.beginPath(); + const stepX = plotW / Math.max(plotData.length - 1, 1); + + for (let i = 0; i < plotData.length; i++) { + const x = padLeft + i * stepX; + const y = padTop + plotH - (plotData[i] / maxVal) * plotH; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + + ctx.stroke(); + ctx.shadowBlur = 0; + + if (history.length > 0) { + const lastVal = history[history.length - 1]; + const lastX = padLeft + (plotData.length - 1) * stepX; + const lastY = padTop + plotH - (lastVal / maxVal) * plotH; + + ctx.fillStyle = "#c46b1f"; + ctx.beginPath(); + ctx.arc(lastX, lastY, 4, 0, Math.PI * 2); + ctx.fill(); + + const labelText = `${lastVal}`; + const labelWidth = ctx.measureText(labelText).width; + + ctx.fillStyle = "rgba(0,0,0,0.8)"; + ctx.fillRect(lastX - labelWidth / 2 - 4, lastY - 22, labelWidth + 8, 18); + + ctx.fillStyle = "#00ff88"; + ctx.font = "bold 12px monospace"; + ctx.textAlign = "center"; + ctx.fillText(labelText, lastX, lastY - 8); + + const peak = Math.max(...history); + const min = Math.min(...history); + + ctx.fillStyle = "rgba(0,0,0,0.7)"; + ctx.fillRect(w - 120, padTop, 110, 42); + + ctx.fillStyle = "#00ff88"; + ctx.font = "10px monospace"; + ctx.textAlign = "left"; + ctx.fillText(`Пик: ${peak}`, w - 110, padTop + 14); + ctx.fillText(`Мин: ${min}`, w - 110, padTop + 28); + ctx.fillText(`Точек: ${history.length}`, w - 110, padTop + 42); + } +} \ No newline at end of file diff --git a/cmd/server/web/js/data.ts b/cmd/server/web/js/data.ts new file mode 100644 index 0000000..2d0e837 --- /dev/null +++ b/cmd/server/web/js/data.ts @@ -0,0 +1,30 @@ +// data.js (API слой) +// Типы ответов от сервера +interface HealthResponse { + status: string; + uptime_sec: number; + last_data_ms: number; + pipe_alive: boolean; + has_data: boolean; + latest: number; + stats: { + filled: number; + bytes_per_sec: number; + }; +} + +interface HistoryResponse { + bytes: number[]; +} + +export async function fetchHealth(): Promise { + const r = await fetch("/api/health"); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); +} + +export async function fetchHistory(): Promise { + const r = await fetch("/api/history"); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); +} diff --git a/cmd/server/web/js/layout.js b/cmd/server/web/js/layout.ts similarity index 64% rename from cmd/server/web/js/layout.js rename to cmd/server/web/js/layout.ts index 7a1613f..8cc8f54 100644 --- a/cmd/server/web/js/layout.js +++ b/cmd/server/web/js/layout.ts @@ -1,9 +1,11 @@ -//layout.js -let leftPanel, rightPanel, dragBar; -let isDragging = false; -let pendingX = null; +// layout.ts +let leftPanel: HTMLElement | null = null; +let rightPanel: HTMLElement | null = null; +let dragBar: HTMLElement | null = null; +let isDragging: boolean = false; +let pendingX: number | null = null; -export function initResize() { +export function initResize(): void { leftPanel = document.getElementById("leftPanel"); rightPanel = document.getElementById("rightPanel"); dragBar = document.getElementById("dragBar"); @@ -16,7 +18,7 @@ export function initResize() { } dragBar.addEventListener("mousedown", (e) => { - if (rightPanel.classList.contains("hidden")) return; + if (rightPanel!.classList.contains("hidden")) return; isDragging = true; document.body.style.cursor = "col-resize"; @@ -29,21 +31,22 @@ export function initResize() { isDragging = false; document.body.style.cursor = "default"; - if (!rightPanel.classList.contains("hidden")) { + if (rightPanel && !rightPanel.classList.contains("hidden")) { const total = window.innerWidth; - const leftWidth = leftPanel.getBoundingClientRect().width; - localStorage.setItem('layoutRatio', leftWidth / total); + const leftWidth = leftPanel!.getBoundingClientRect().width; + localStorage.setItem('layoutRatio', String(leftWidth / total)); } }); document.addEventListener("mousemove", (e) => { if (!isDragging) return; - if (rightPanel.classList.contains("hidden")) return; + if (rightPanel && rightPanel.classList.contains("hidden")) return; pendingX = e.clientX; requestAnimationFrame(() => { if (pendingX === null) return; + if (!leftPanel || !rightPanel || !dragBar) return; const total = window.innerWidth; let leftWidth = pendingX; @@ -62,17 +65,19 @@ export function initResize() { }); } -function applyRatio(ratio) { +function applyRatio(ratio: number): void { const total = window.innerWidth; const leftWidth = Math.floor(total * ratio); const rightWidth = total - leftWidth; - leftPanel.style.flex = `0 0 ${leftWidth}px`; - rightPanel.style.flex = `0 0 ${rightWidth}px`; + if (leftPanel && rightPanel) { + leftPanel.style.flex = `0 0 ${leftWidth}px`; + rightPanel.style.flex = `0 0 ${rightWidth}px`; + } } -export function updateResizerVisibility() { - if (!dragBar || !rightPanel) return; +export function updateResizerVisibility(): void { + if (!dragBar || !rightPanel || !leftPanel) return; if (rightPanel.classList.contains("hidden")) { dragBar.classList.add("hidden"); @@ -80,4 +85,4 @@ export function updateResizerVisibility() { } else { dragBar.classList.remove("hidden"); } -} \ No newline at end of file +} diff --git a/cmd/server/web/js/state.ts b/cmd/server/web/js/state.ts new file mode 100644 index 0000000..ea7f473 --- /dev/null +++ b/cmd/server/web/js/state.ts @@ -0,0 +1,48 @@ +// state.js (вся логика состояния) +interface AppState { + maxPoints: number; + signalHistory: number[]; + smoothValue: number; + lastBufferSize: number; +} + +export const state: AppState = { + maxPoints: 1000, + signalHistory: [], + smoothValue: 0, + lastBufferSize: 0, +}; + +export function smooth(target: number): number { + state.smoothValue += 0.2 * (target - state.smoothValue); + return state.smoothValue; +} + +export function addSignal(value: number): void { + state.signalHistory.push(value); + if (state.signalHistory.length > state.maxPoints) { + state.signalHistory = state.signalHistory.slice(-state.maxPoints); + } +} + +export function addSignals(values: number[], bufferSize: number): void { + if (!Array.isArray(values) || values.length === 0) return; + + const newBytes = bufferSize - state.lastBufferSize; + + if (newBytes > 0 && newBytes <= values.length) { + const fresh = values.slice(-newBytes); + const decoded = fresh.map(b => b & 0x3F); + state.signalHistory.push(...decoded); + } + + state.lastBufferSize = bufferSize; + + if (state.signalHistory.length > state.maxPoints) { + state.signalHistory = state.signalHistory.slice(-state.maxPoints); + } +} + +export function resetBufferTracking(): void { + state.lastBufferSize = 0; +} \ No newline at end of file diff --git a/cmd/server/web/js/ui.ts b/cmd/server/web/js/ui.ts new file mode 100644 index 0000000..19ad479 --- /dev/null +++ b/cmd/server/web/js/ui.ts @@ -0,0 +1,102 @@ +// ui.ts (DOM слой) +export interface DOMElements { + status: HTMLElement | null; + meta: HTMLElement | null; + latest: HTMLElement | null; + lastByte: HTMLElement | null; + history: HTMLElement | null; + canvas: HTMLCanvasElement | null; + bars: NodeListOf; + barFill: HTMLElement | null; + historyHeader: HTMLElement | null; + historyBody: HTMLElement | null; +} + +export const DOM: DOMElements = { + status: null, + meta: null, + latest: null, + lastByte: null, + history: null, + canvas: null, + bars: document.querySelectorAll(".sig-bar"), + barFill: null, + historyHeader: null, + historyBody: null, +}; + +export function initDOM(): void { + 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") as HTMLCanvasElement; + DOM.barFill = document.getElementById("bar1"); + DOM.historyHeader = document.getElementById("history-header"); + DOM.historyBody = document.getElementById("history-body"); + DOM.bars = document.querySelectorAll(".sig-bar"); +} + +export function setStatus(v: string, isError: boolean = false): void { + 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: string): void { + if (DOM.meta) DOM.meta.textContent = v; +} + +export function setBars(p: number, noConnection: boolean = false): void { + const el = DOM.barFill; + if (!el) return; + + 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: number, noConnection: boolean = false): void { + const bars = DOM.bars; + if (!bars || bars.length < 3) return; + + bars.forEach(bar => { + bar.classList.remove("active-1", "active-2", "active-3", "dim", "no-connection"); + if (noConnection) { + bar.classList.add("no-connection"); + } else { + bar.classList.add("dim"); + } + }); + + if (noConnection) return; + + 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"); + } +} diff --git a/cmd/server/web/package.json b/cmd/server/web/package.json new file mode 100644 index 0000000..7c108d6 --- /dev/null +++ b/cmd/server/web/package.json @@ -0,0 +1,13 @@ +{ + "name": "web", + "version": "1.0.0", + "scripts": { + "build": "tsc", + "watch": "tsc --watch", + "clean": "rm -rf dist" + }, + "devDependencies": { + "typescript": "^5.0.0", + "@types/node": "^20.0.0" + } +} diff --git a/cmd/server/web/tsconfig.json b/cmd/server/web/tsconfig.json new file mode 100644 index 0000000..9ccd856 --- /dev/null +++ b/cmd/server/web/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "lib": ["ES2020", "DOM"], + "outDir": "./dist", + "rootDir": "./js", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "bundler" + }, + "include": ["js/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/scripts/monitor-gpio.service b/scripts/monitor-gpio.service index 3adb8dc..accb83e 100644 --- a/scripts/monitor-gpio.service +++ b/scripts/monitor-gpio.service @@ -1,19 +1,16 @@ [Unit] -Description=Мониторинг GPIO на Raspberry Pi +Description=GPIO Interrupt Capture Service Requires=monitor-gpio.socket After=monitor-gpio.socket -Wants=multi-user.target [Service] Type=simple User=root WorkingDirectory=/home/user/WiringPi/examples -ExecStartPre=/bin/sleep 2 -ExecStart=/home/user/WiringPi/examples/gpio-interrupt --mode=sleep + +ExecStart=/home/user/WiringPi/examples/gpio-interrupt - StandardOutput=socket StandardError=journal + Restart=always RestartSec=2 - -[Install] -WantedBy=multi-user.target \ No newline at end of file diff --git a/scripts/view_logs.sh b/scripts/view_logs.sh index c06fea5..ba8e80e 100644 --- a/scripts/view_logs.sh +++ b/scripts/view_logs.sh @@ -33,4 +33,4 @@ if [ -d "$LOG_DIR" ]; then else echo "❌ Директория логов не найдена: $LOG_DIR" echo "Запустите сервер для создания логов" -fi \ No newline at end of file +fi