Рефакторинг. Переход на typescript

This commit is contained in:
Maxim
2026-06-11 08:57:47 +03:00
parent b4649f61ca
commit ea972129cb
25 changed files with 1113 additions and 299 deletions

View File

@@ -1,21 +1,84 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -e set -e # Остановка при любой ошибке
APP_NAME="gpio-monitor-server" APP_NAME="gpio-monitor-server"
CMD_PATH="./cmd/server" CMD_PATH="./cmd/server"
BUILD_DIR="./build" 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} rm -rf ${BUILD_DIR}
mkdir -p ${BUILD_DIR} mkdir -p ${BUILD_DIR}
echo ">> Downloading dependencies..." echo " Загрузка зависимостей..."
go mod tidy 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} 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 "СБОРКА ЗАВЕРШЕНА!"

View File

@@ -20,7 +20,7 @@ import (
// ===== EMBED WEB ===== // ===== EMBED WEB =====
// //
//go:embed web/* //go:embed web/dist web/css web/*.html web/*.png
var webFiles embed.FS var webFiles embed.FS
type API struct { type API struct {
@@ -245,4 +245,4 @@ func main() {
log.Println("Server started on :8080") log.Println("Server started on :8080")
log.Println("Camera proxy available at /api/cam") log.Println("Camera proxy available at /api/cam")
log.Fatal(http.ListenAndServe(":8080", nil)) log.Fatal(http.ListenAndServe(":8080", nil))
} }

34
cmd/server/web/README.md Normal file
View File

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

View File

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

View File

@@ -104,7 +104,7 @@
</div> </div>
<script type="module" src="/js/app.js"></script> <script type="module" src="/dist/app.js"></script>
</body> </body>
</html> </html>

18
cmd/server/web/dist/accordion.js vendored Normal file
View File

@@ -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);
}
});
}

View File

@@ -1,26 +1,16 @@
// app.js (оркестратор) // app.js (оркестратор)
import { fetchHealth, fetchHistory } from "./data.js"; import { fetchHealth, fetchHistory } from "./data.js";
import { state, smooth, addSignals, resetBufferTracking } from "./state.js"; import { state, smooth, addSignals, resetBufferTracking } from "./state.js";
import { import { initDOM, setStatus, setMeta, setBars, setSignal, DOM } from "./ui.js";
initDOM,
setStatus,
setMeta,
setBars,
setSignal,
DOM
} from "./ui.js";
import { initAccordion } from "./accordion.js"; import { initAccordion } from "./accordion.js";
import { drawChart } from "./chart.js"; import { drawChart } from "./chart.js";
import { initResize } from "./layout.js"; import { initResize } from "./layout.js";
import { initCamera } from "./camera.js"; import { initCamera } from "./camera.js";
let lastChart = 0; let lastChart = 0;
let connectionLost = false; let connectionLost = false;
let consecutiveErrors = 0; let consecutiveErrors = 0;
const MAX_ERRORS = 3; const MAX_ERRORS = 3;
let pollTimer = null; let pollTimer = null;
// ================= INIT ================= // ================= INIT =================
window.addEventListener("DOMContentLoaded", () => { window.addEventListener("DOMContentLoaded", () => {
initDOM(); initDOM();
@@ -28,146 +18,115 @@ window.addEventListener("DOMContentLoaded", () => {
initResize(); initResize();
initCamera(); initCamera();
}); });
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
switch(e.key.toLowerCase()) { const leftPanel = document.getElementById("leftPanel");
const rightPanel = document.getElementById("rightPanel");
switch (e.key.toLowerCase()) {
case 'c': case 'c':
// Переключение камеры
const camBtn = document.getElementById('camToggle'); const camBtn = document.getElementById('camToggle');
if (camBtn) camBtn.click(); if (camBtn)
camBtn.click();
break; break;
case 'f': case 'f':
// На весь экран для левой панели
if (leftPanel) { if (leftPanel) {
leftPanel.style.width = "100%"; leftPanel.style.width = "100%";
if (rightPanel) rightPanel.style.display = "none"; if (rightPanel)
rightPanel.style.display = "none";
} }
break; break;
case 'escape': case 'escape':
if (rightPanel) rightPanel.style.display = "flex"; if (rightPanel)
applyRatio(0.6); // 60% левая, 40% правая 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; break;
} }
}); });
// ================= UPDATE ================= // ================= UPDATE =================
async function update() { async function update() {
try { try {
const [h, hist] = await Promise.all([ const [health, history] = await Promise.all([
fetchHealth(), fetchHealth(),
fetchHistory() fetchHistory()
]); ]);
consecutiveErrors = 0; consecutiveErrors = 0;
if (connectionLost) { if (connectionLost) {
console.log("[app] Соединение восстановлено"); console.log("[app] Соединение восстановлено");
} }
const isOnline = health.pipe_alive && health.has_data;
// ===== STATUS =====
const isOnline = h.pipe_alive && h.has_data;
if (isOnline) { if (isOnline) {
setStatus("На связи"); setStatus("На связи");
connectionLost = false; connectionLost = false;
} else { }
else {
setStatus("Нет связи", true); setStatus("Нет связи", true);
if (!connectionLost)
if (!connectionLost) {
resetBufferTracking(); resetBufferTracking();
}
connectionLost = true; connectionLost = true;
} }
const arr = Array.isArray(history.bytes) ? history.bytes : [];
const arr = Array.isArray(hist.bytes) ? hist.bytes : [];
const raw = arr[arr.length - 1] ?? 0; const raw = arr[arr.length - 1] ?? 0;
// ===== ДОБАВЛЯЕМ ТОЛЬКО НОВЫЕ БАЙТЫ =====
if (arr.length > 0 && isOnline) { if (arr.length > 0 && isOnline) {
const bufferSize = h.stats?.filled ?? 0; const bufferSize = health.stats?.filled ?? 0;
addSignals(arr, bufferSize); addSignals(arr, bufferSize);
} }
// ===== DECODE =====
const value = raw & 0x3F; const value = raw & 0x3F;
const level = (raw >> 6) & 0x3; const level = (raw >> 6) & 0x3;
const percent = (value / 63) * 100; const percent = (value / 63) * 100;
// ===== UI =====
if (isOnline) { if (isOnline) {
setBars(smooth(percent)); setBars(smooth(percent));
setSignal(level); setSignal(level);
} else { }
else {
setBars(0, true); setBars(0, true);
setSignal(0, true); setSignal(0, true);
} }
if (DOM.lastByte) { if (DOM.lastByte) {
DOM.lastByte.textContent = isOnline ? value : "--"; DOM.lastByte.textContent = isOnline ? value.toString() : "--";
} }
const uptime = Math.round(health.uptime_sec || 0);
// ===== META ===== const filled = health.stats?.filled ?? 0;
const uptime = Math.round(h.uptime_sec || 0); const Bps = Math.round(health.stats?.bytes_per_sec ?? 0);
const filled = h.stats?.filled ?? 0; setMeta(`Работа: ${uptime}s | Буфер: ${filled} | Приём: ${Bps} B/s`);
const Bps = Math.round(h.stats?.bytes_per_sec ?? 0);
setMeta(
`Работа: ${uptime}s | Буфер: ${filled} | Приём: ${Bps} B/s`
);
// ===== HISTORY =====
if (DOM.history) { if (DOM.history) {
DOM.history.innerHTML = arr DOM.history.innerHTML = arr
.map((b, i) => .map((b, i) => `[${i}] ${(b & 0xFF).toString(2).padStart(8, "0")} = ${b & 0xFF}`)
`[${i}] ${(b & 0xFF).toString(2).padStart(8, "0")} = ${b & 0xFF}`
)
.join("<br>"); .join("<br>");
} }
// ===== CHART =====
const now = performance.now(); const now = performance.now();
if (now - lastChart > 100 && DOM.canvas) { if (now - lastChart > 100 && DOM.canvas) {
drawChart(DOM.canvas, state.signalHistory); drawChart(DOM.canvas, state.signalHistory);
lastChart = now; lastChart = now;
} }
}
} catch (e) { catch (e) {
consecutiveErrors++; consecutiveErrors++;
if (consecutiveErrors === 1)
if (consecutiveErrors === 1) {
console.warn("[app] Сервер недоступен, ждём..."); console.warn("[app] Сервер недоступен, ждём...");
}
setStatus("СЕРВЕР НЕДОСТУПЕН", true); setStatus("СЕРВЕР НЕДОСТУПЕН", true);
setBars(0, true); setBars(0, true);
setSignal(0, true); setSignal(0, true);
resetBufferTracking(); resetBufferTracking();
if (DOM.lastByte)
if (DOM.lastByte) {
DOM.lastByte.textContent = "--"; DOM.lastByte.textContent = "--";
} if (consecutiveErrors >= MAX_ERRORS && pollTimer) {
if (consecutiveErrors >= MAX_ERRORS) {
clearInterval(pollTimer); clearInterval(pollTimer);
pollTimer = setInterval(update, 2000); pollTimer = window.setInterval(update, 2000);
console.log("[app] Замедлили опрос до 2с"); console.log("[app] Замедлили опрос до 2с");
} }
} }
} }
// ================= RECONNECT LOGIC =================
function startPolling() { function startPolling() {
if (pollTimer) clearInterval(pollTimer); if (pollTimer)
clearInterval(pollTimer);
if (consecutiveErrors > 0) { if (consecutiveErrors > 0)
console.log("[app] Попытка переподключения...");
update(); update();
} pollTimer = window.setInterval(update, 500);
pollTimer = setInterval(update, 500);
} }
window.addEventListener("focus", () => { window.addEventListener("focus", () => {
if (consecutiveErrors >= MAX_ERRORS) { if (consecutiveErrors >= MAX_ERRORS) {
console.log("[app] Окно в фокусе, ускоряем опрос"); console.log("[app] Окно в фокусе, ускоряем опрос");
@@ -175,7 +134,5 @@ window.addEventListener("focus", () => {
startPolling(); startPolling();
} }
}); });
// ================= START =================
startPolling(); startPolling();
update(); update();

View File

@@ -1,134 +1,124 @@
// camera.js // camera.ts
import { updateResizerVisibility } from './layout.js'; import { updateResizerVisibility } from './layout.js';
let rightPanel = null;
let rightPanel; let camImage = null;
let camImage; let crosshair = null;
let crosshair;
let isOpen = false; let isOpen = false;
let streamCheckInterval = null; let streamCheckInterval = null;
export function initCamera() { export function initCamera() {
rightPanel = document.getElementById("rightPanel"); rightPanel = document.getElementById("rightPanel");
camImage = document.getElementById("camImage"); camImage = document.getElementById("camImage");
crosshair = document.getElementById("crosshair"); crosshair = document.getElementById("crosshair");
// Проверяем доступность потока // Проверяем доступность потока
checkStreamAvailability(); checkStreamAvailability();
const btn = document.getElementById("camToggle"); const btn = document.getElementById("camToggle");
const streamUrl = "/api/cam"; const streamUrl = "/api/cam";
function open() { function open() {
if (isOpen) return; if (isOpen)
return;
isOpen = true; isOpen = true;
if (rightPanel) {
rightPanel.classList.remove("hidden"); rightPanel.classList.remove("hidden");
}
// Показываем перекрестие // Показываем перекрестие
if (crosshair) { if (crosshair) {
crosshair.style.display = "block"; crosshair.style.display = "block";
} }
if (camImage) {
camImage.src = streamUrl; camImage.src = streamUrl;
}
if (btn) { if (btn) {
btn.querySelector('.cam-btn-text').textContent = 'Скрыть'; const textSpan = btn.querySelector('.cam-btn-text');
if (textSpan)
textSpan.textContent = 'Скрыть';
} }
console.log("[camera] opened"); console.log("[camera] opened");
updateResizerVisibility(); updateResizerVisibility();
// Запускаем мониторинг потока // Запускаем мониторинг потока
startStreamMonitoring(); startStreamMonitoring();
} }
function close() { function close() {
if (!isOpen) return; if (!isOpen)
return;
isOpen = false; isOpen = false;
// Скрываем перекрестие // Скрываем перекрестие
if (crosshair) { if (crosshair) {
crosshair.style.display = "none"; crosshair.style.display = "none";
} }
// остановка stream // остановка stream
camImage.src = ""; if (camImage) {
camImage.src = "";
rightPanel.classList.add("hidden"); }
if (rightPanel) {
if (btn) { rightPanel.classList.add("hidden");
btn.querySelector('.cam-btn-text').textContent = 'Камера'; }
if (btn) {
const textSpan = btn.querySelector('.cam-btn-text');
if (textSpan)
textSpan.textContent = 'Камера';
} }
console.log("[camera] closed"); console.log("[camera] closed");
updateResizerVisibility(); updateResizerVisibility();
// Останавливаем мониторинг // Останавливаем мониторинг
stopStreamMonitoring(); stopStreamMonitoring();
} }
function toggle() { function toggle() {
if (isOpen) { if (isOpen) {
close(); close();
} else { }
else {
open(); open();
} }
} }
if (camImage) {
camImage.onload = () => { camImage.onload = () => {
console.log("[camera] stream started"); console.log("[camera] stream started");
if (crosshair) { if (crosshair) {
crosshair.style.opacity = '1'; crosshair.style.opacity = '1';
} }
}; };
camImage.onerror = (e) => {
camImage.onerror = (e) => { console.error("[camera] stream error", e);
console.error("[camera] stream error", e); if (crosshair) {
if (crosshair) { crosshair.style.opacity = '0.3';
crosshair.style.opacity = '0.3'; }
} };
}; }
btn?.addEventListener("click", toggle); btn?.addEventListener("click", toggle);
// Проверка доступности камеры // Проверка доступности камеры
async function checkStreamAvailability() { async function checkStreamAvailability() {
try { try {
const response = await fetch('/api/stream'); const response = await fetch('/api/stream');
const data = await response.json(); const data = await response.json();
if (data.available && data.cam) { if (data.available && data.cam) {
console.log("[camera] stream available:", data.source); console.log("[camera] stream available:", data.source);
// Автозапуск если камера доступна // Автозапуск если камера доступна
if (!isOpen) { if (!isOpen) {
open(); open();
} }
} else { }
else {
console.log("[camera] stream not available"); console.log("[camera] stream not available");
if (crosshair) { if (crosshair) {
crosshair.style.display = 'none'; crosshair.style.display = 'none';
} }
} }
} catch (error) { }
catch (error) {
console.log("[camera] check failed:", error); console.log("[camera] check failed:", error);
} }
} }
// Мониторинг состояния потока // Мониторинг состояния потока
function startStreamMonitoring() { function startStreamMonitoring() {
stopStreamMonitoring(); stopStreamMonitoring();
streamCheckInterval = setInterval(() => { streamCheckInterval = window.setInterval(() => {
if (isOpen && camImage) { if (isOpen && camImage) {
if (!camImage.complete || camImage.naturalWidth === 0) { if (!camImage.complete || camImage.naturalWidth === 0) {
if (crosshair) { if (crosshair) {
crosshair.style.opacity = '0.3'; crosshair.style.opacity = '0.3';
} }
} else { }
else {
if (crosshair) { if (crosshair) {
crosshair.style.opacity = '1'; crosshair.style.opacity = '1';
} }
@@ -136,7 +126,6 @@ export function initCamera() {
} }
}, 2000); }, 2000);
} }
function stopStreamMonitoring() { function stopStreamMonitoring() {
if (streamCheckInterval) { if (streamCheckInterval) {
clearInterval(streamCheckInterval); clearInterval(streamCheckInterval);
@@ -144,18 +133,16 @@ export function initCamera() {
} }
} }
} }
// Экспортируем для возможности ручного управления // Экспортируем для возможности ручного управления
export function showCrosshair(show = true) { export function showCrosshair(show = true) {
const crosshair = document.getElementById("crosshair"); const crosshairElem = document.getElementById("crosshair");
if (crosshair) { if (crosshairElem) {
crosshair.style.display = show ? "block" : "none"; crosshairElem.style.display = show ? "block" : "none";
} }
} }
export function setCrosshairOpacity(opacity) { export function setCrosshairOpacity(opacity) {
const crosshair = document.getElementById("crosshair"); const crosshairElem = document.getElementById("crosshair");
if (crosshair) { if (crosshairElem) {
crosshair.style.opacity = opacity; crosshairElem.style.opacity = String(opacity);
} }
} }

View File

@@ -1,74 +1,57 @@
// chart.js // chart.js
export function drawChart(canvas, history) { export function drawChart(canvas, history) {
if (!canvas || !history?.length) return; if (!canvas || !history?.length)
return;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const displayHeight = rect.height || 200; const displayHeight = rect.height || 200;
const displayWidth = rect.width || 800; const displayWidth = rect.width || 800;
canvas.width = displayWidth; canvas.width = displayWidth;
canvas.height = displayHeight; canvas.height = displayHeight;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx)
return;
const w = canvas.width; const w = canvas.width;
const h = canvas.height; const h = canvas.height;
// ===== ОТСТУПЫ =====
const padLeft = 45; const padLeft = 45;
const padRight = 15; const padRight = 15;
const padTop = 10; const padTop = 10;
const padBottom = 25; const padBottom = 25;
const plotW = w - padLeft - padRight; const plotW = w - padLeft - padRight;
const plotH = h - padTop - padBottom; const plotH = h - padTop - padBottom;
// Фон
// ===== ФОН =====
ctx.fillStyle = "#0a0f0a"; ctx.fillStyle = "#0a0f0a";
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
// Сетка
// ===== СЕТКА =====
const maxVal = 63; const maxVal = 63;
const gridLines = 4; const gridLines = 4;
ctx.strokeStyle = "#0d2a0d"; ctx.strokeStyle = "#0d2a0d";
ctx.lineWidth = 0.5; ctx.lineWidth = 0.5;
ctx.setLineDash([4, 4]); ctx.setLineDash([4, 4]);
for (let i = 0; i <= gridLines; i++) { for (let i = 0; i <= gridLines; i++) {
const val = (maxVal / gridLines) * i; const val = (maxVal / gridLines) * i;
const y = padTop + plotH - (val / maxVal) * plotH; const y = padTop + plotH - (val / maxVal) * plotH;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(padLeft, y); ctx.moveTo(padLeft, y);
ctx.lineTo(w - padRight, y); ctx.lineTo(w - padRight, y);
ctx.stroke(); ctx.stroke();
ctx.fillStyle = "#00ff88"; ctx.fillStyle = "#00ff88";
ctx.font = "11px monospace"; ctx.font = "11px monospace";
ctx.textAlign = "right"; ctx.textAlign = "right";
ctx.fillText(Math.round(val), padLeft - 8, y + 4); ctx.fillText(Math.round(val).toString(), padLeft - 8, y + 4);
} }
ctx.setLineDash([]); ctx.setLineDash([]);
// Подписи осей
// ===== ПОДПИСЬ ОСИ Y =====
ctx.fillStyle = "#00ff88"; ctx.fillStyle = "#00ff88";
ctx.font = "bold 11px monospace"; ctx.font = "bold 11px monospace";
ctx.save(); ctx.save();
ctx.translate(12, padTop + plotH / 2); ctx.translate(12, padTop + plotH / 2);
ctx.rotate(-Math.PI / 2); ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.fillText("ЗНАЧЕНИЕ", 0, 0); ctx.fillText("ЗНАЧЕНИЕ", 0, 0);
ctx.restore(); ctx.restore();
// ===== ПОДПИСЬ ОСИ X =====
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.fillText("БУФЕР ПРИНЯТЫХ ДАННЫХ", padLeft + plotW / 2, h - 4); ctx.fillText("БУФЕР ПРИНЯТЫХ ДАННЫХ", padLeft + plotW / 2, h - 4);
// ===== СИГНАЛ =====
const hasSignal = history.some(v => v > 0); const hasSignal = history.some(v => v > 0);
if (!hasSignal) { if (!hasSignal) {
ctx.fillStyle = "#ff4444"; ctx.fillStyle = "#ff4444";
ctx.font = "14px monospace"; ctx.font = "14px monospace";
@@ -76,8 +59,6 @@ export function drawChart(canvas, history) {
ctx.fillText("Ожидание сигнала...", w / 2, h / 2); ctx.fillText("Ожидание сигнала...", w / 2, h / 2);
return; return;
} }
// Прореживание если точек больше чем пикселей
let plotData = history; let plotData = history;
if (history.length > plotW) { if (history.length > plotW) {
const step = history.length / plotW; const step = history.length / plotW;
@@ -87,60 +68,42 @@ export function drawChart(canvas, history) {
plotData.push(history[idx]); plotData.push(history[idx]);
} }
} }
ctx.strokeStyle = "#e6852d"; ctx.strokeStyle = "#e6852d";
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.shadowColor = "#e6852d"; ctx.shadowColor = "#e6852d";
ctx.shadowBlur = 3; ctx.shadowBlur = 3;
ctx.beginPath(); ctx.beginPath();
const stepX = plotW / Math.max(plotData.length - 1, 1); const stepX = plotW / Math.max(plotData.length - 1, 1);
for (let i = 0; i < plotData.length; i++) { for (let i = 0; i < plotData.length; i++) {
const x = padLeft + i * stepX; const x = padLeft + i * stepX;
const y = padTop + plotH - (plotData[i] / maxVal) * plotH; const y = padTop + plotH - (plotData[i] / maxVal) * plotH;
if (i === 0)
if (i === 0) ctx.moveTo(x, y); ctx.moveTo(x, y);
else ctx.lineTo(x, y); else
ctx.lineTo(x, y);
} }
ctx.stroke(); ctx.stroke();
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
// ===== ПОСЛЕДНЯЯ ТОЧКА + ЗНАЧЕНИЕ =====
if (history.length > 0) { if (history.length > 0) {
const lastVal = history[history.length - 1]; const lastVal = history[history.length - 1];
const lastX = padLeft + (plotData.length - 1) * stepX; const lastX = padLeft + (plotData.length - 1) * stepX;
const lastY = padTop + plotH - (lastVal / maxVal) * plotH; const lastY = padTop + plotH - (lastVal / maxVal) * plotH;
// Кружок
ctx.fillStyle = "#c46b1f"; ctx.fillStyle = "#c46b1f";
ctx.beginPath(); ctx.beginPath();
ctx.arc(lastX, lastY, 4, 0, Math.PI * 2); ctx.arc(lastX, lastY, 4, 0, Math.PI * 2);
ctx.fill(); ctx.fill();
// Значение последней точки
const labelText = `${lastVal}`; const labelText = `${lastVal}`;
const labelWidth = ctx.measureText(labelText).width; const labelWidth = ctx.measureText(labelText).width;
// Фон для читаемости
ctx.fillStyle = "rgba(0,0,0,0.8)"; ctx.fillStyle = "rgba(0,0,0,0.8)";
ctx.fillRect(lastX - labelWidth / 2 - 4, lastY - 22, labelWidth + 8, 18); ctx.fillRect(lastX - labelWidth / 2 - 4, lastY - 22, labelWidth + 8, 18);
// Текст по центру над точкой
ctx.fillStyle = "#00ff88"; ctx.fillStyle = "#00ff88";
ctx.font = "bold 12px monospace"; ctx.font = "bold 12px monospace";
ctx.textAlign = "center"; ctx.textAlign = "center";
ctx.fillText(labelText, lastX, lastY - 8); ctx.fillText(labelText, lastX, lastY - 8);
// Статистика в углу
const peak = Math.max(...history); const peak = Math.max(...history);
const min = Math.min(...history); const min = Math.min(...history);
ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillStyle = "rgba(0,0,0,0.7)";
ctx.fillRect(w - 120, padTop, 110, 42); ctx.fillRect(w - 120, padTop, 110, 42);
ctx.fillStyle = "#00ff88"; ctx.fillStyle = "#00ff88";
ctx.font = "10px monospace"; ctx.font = "10px monospace";
ctx.textAlign = "left"; ctx.textAlign = "left";
@@ -148,4 +111,4 @@ export function drawChart(canvas, history) {
ctx.fillText(`Мин: ${min}`, w - 110, padTop + 28); ctx.fillText(`Мин: ${min}`, w - 110, padTop + 28);
ctx.fillText(`Точек: ${history.length}`, w - 110, padTop + 42); ctx.fillText(`Точек: ${history.length}`, w - 110, padTop + 42);
} }
} }

View File

@@ -1,10 +1,12 @@
// data.js (API слой)
export async function fetchHealth() { export async function fetchHealth() {
const r = await fetch("/api/health"); const r = await fetch("/api/health");
if (!r.ok)
throw new Error(`HTTP ${r.status}`);
return r.json(); return r.json();
} }
export async function fetchHistory() { export async function fetchHistory() {
const r = await fetch("/api/history"); const r = await fetch("/api/history");
if (!r.ok)
throw new Error(`HTTP ${r.status}`);
return r.json(); return r.json();
} }

79
cmd/server/web/dist/layout.js vendored Normal file
View File

@@ -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");
}
}

View File

@@ -1,42 +1,33 @@
// state.js (вся логика состояния)
export const state = { export const state = {
maxPoints: 1000, maxPoints: 1000,
signalHistory: [], signalHistory: [],
smoothValue: 0, smoothValue: 0,
lastBufferSize: 0, lastBufferSize: 0,
}; };
export function smooth(target) { export function smooth(target) {
state.smoothValue += 0.2 * (target - state.smoothValue); state.smoothValue += 0.2 * (target - state.smoothValue);
return state.smoothValue; return state.smoothValue;
} }
export function addSignal(value) { export function addSignal(value) {
state.signalHistory.push(value); state.signalHistory.push(value);
if (state.signalHistory.length > state.maxPoints) { 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) { 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; const newBytes = bufferSize - state.lastBufferSize;
if (newBytes > 0 && newBytes <= values.length) { if (newBytes > 0 && newBytes <= values.length) {
const fresh = values.slice(-newBytes); const fresh = values.slice(-newBytes);
const decoded = fresh.map(b => b & 0x3F); const decoded = fresh.map(b => b & 0x3F);
state.signalHistory.push(...decoded); state.signalHistory.push(...decoded);
} }
state.lastBufferSize = bufferSize; state.lastBufferSize = bufferSize;
if (state.signalHistory.length > state.maxPoints) { 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() { export function resetBufferTracking() {
state.lastBufferSize = 0; state.lastBufferSize = 0;
} }

View File

@@ -1,6 +1,15 @@
// ui.js (DOM слой) export const DOM = {
export let 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() { export function initDOM() {
DOM.status = document.getElementById("status"); DOM.status = document.getElementById("status");
DOM.meta = document.getElementById("meta"); DOM.meta = document.getElementById("meta");
@@ -8,69 +17,60 @@ export function initDOM() {
DOM.lastByte = document.getElementById("last-byte"); DOM.lastByte = document.getElementById("last-byte");
DOM.history = document.getElementById("history"); DOM.history = document.getElementById("history");
DOM.canvas = document.getElementById("signalChart"); DOM.canvas = document.getElementById("signalChart");
DOM.bars = document.querySelectorAll(".sig-bar");
DOM.barFill = document.getElementById("bar1"); DOM.barFill = document.getElementById("bar1");
// Аккордеон
DOM.historyHeader = document.getElementById("history-header"); DOM.historyHeader = document.getElementById("history-header");
DOM.historyBody = document.getElementById("history-body"); DOM.historyBody = document.getElementById("history-body");
DOM.bars = document.querySelectorAll(".sig-bar");
} }
export function setStatus(v, isError = false) { export function setStatus(v, isError = false) {
if (!DOM.status) return; if (!DOM.status)
return;
DOM.status.textContent = v; DOM.status.textContent = v;
// Стили для статуса
DOM.status.classList.remove("status-ok", "status-error", "status-lost"); DOM.status.classList.remove("status-ok", "status-error", "status-lost");
if (isError) { if (isError) {
if (v === "Нет связи") { if (v === "Нет связи") {
DOM.status.classList.add("status-lost"); DOM.status.classList.add("status-lost");
} else { }
else {
DOM.status.classList.add("status-error"); DOM.status.classList.add("status-error");
} }
} else { }
else {
DOM.status.classList.add("status-ok"); DOM.status.classList.add("status-ok");
} }
} }
export function setMeta(v) { export function setMeta(v) {
if (DOM.meta) DOM.meta.textContent = v; if (DOM.meta)
DOM.meta.textContent = v;
} }
export function setBars(p, noConnection = false) { export function setBars(p, noConnection = false) {
const el = DOM.barFill; const el = DOM.barFill;
if (!el) return; if (!el)
return;
if (noConnection) { if (noConnection) {
// При потере связи - сбрасываем и делаем серым
el.style.width = "0%"; el.style.width = "0%";
el.classList.add("no-connection"); el.classList.add("no-connection");
} else { }
else {
el.classList.remove("no-connection"); 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) { export function setSignal(level, noConnection = false) {
const bars = DOM.bars; const bars = DOM.bars;
if (!bars || bars.length < 3) return; if (!bars || bars.length < 3)
return;
// Сброс bars.forEach(bar => {
bars.forEach(b => { bar.classList.remove("active-1", "active-2", "active-3", "dim", "no-connection");
b.classList.remove("active-1", "active-2", "active-3", "dim", "no-connection");
if (noConnection) { if (noConnection) {
b.classList.add("no-connection"); bar.classList.add("no-connection");
} else { }
b.classList.add("dim"); else {
bar.classList.add("dim");
} }
}); });
if (noConnection)
if (noConnection) return; return;
// Уровни сигнала
if (level >= 1) { if (level >= 1) {
bars[0].classList.add("active-1"); bars[0].classList.add("active-1");
bars[0].classList.remove("dim"); bars[0].classList.remove("dim");
@@ -83,4 +83,4 @@ export function setSignal(level, noConnection = false) {
bars[2].classList.add("active-3"); bars[2].classList.add("active-3");
bars[2].classList.remove("dim"); bars[2].classList.remove("dim");
} }
} }

View File

@@ -1,7 +1,7 @@
// accordion.js // accordion.ts
import { DOM } from "./ui.js"; import { DOM } from "./ui.js";
export function initAccordion() { export function initAccordion(): void {
if (!DOM.historyHeader || !DOM.historyBody) return; if (!DOM.historyHeader || !DOM.historyBody) return;
let open = false; let open = false;
@@ -12,9 +12,12 @@ export function initAccordion() {
DOM.historyHeader.addEventListener("click", () => { DOM.historyHeader.addEventListener("click", () => {
open = !open; open = !open;
DOM.historyHeader.textContent = if (DOM.historyHeader) {
(open ? "▼" : "▶") + " ПРИНЯТЫЕ ДАННЫЕ"; DOM.historyHeader.textContent = (open ? "▼" : "▶") + " ПРИНЯТЫЕ ДАННЫЕ";
}
DOM.historyBody.classList.toggle("collapsed", !open); if (DOM.historyBody) {
DOM.historyBody.classList.toggle("collapsed", !open);
}
}); });
} }

152
cmd/server/web/js/app.ts Normal file
View File

@@ -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<void> {
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("<br>");
}
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();

179
cmd/server/web/js/camera.ts Normal file
View File

@@ -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<void> {
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);
}
}

140
cmd/server/web/js/chart.ts Normal file
View File

@@ -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);
}
}

30
cmd/server/web/js/data.ts Normal file
View File

@@ -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<HealthResponse> {
const r = await fetch("/api/health");
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
export async function fetchHistory(): Promise<HistoryResponse> {
const r = await fetch("/api/history");
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}

View File

@@ -1,9 +1,11 @@
//layout.js // layout.ts
let leftPanel, rightPanel, dragBar; let leftPanel: HTMLElement | null = null;
let isDragging = false; let rightPanel: HTMLElement | null = null;
let pendingX = 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"); leftPanel = document.getElementById("leftPanel");
rightPanel = document.getElementById("rightPanel"); rightPanel = document.getElementById("rightPanel");
dragBar = document.getElementById("dragBar"); dragBar = document.getElementById("dragBar");
@@ -16,7 +18,7 @@ export function initResize() {
} }
dragBar.addEventListener("mousedown", (e) => { dragBar.addEventListener("mousedown", (e) => {
if (rightPanel.classList.contains("hidden")) return; if (rightPanel!.classList.contains("hidden")) return;
isDragging = true; isDragging = true;
document.body.style.cursor = "col-resize"; document.body.style.cursor = "col-resize";
@@ -29,21 +31,22 @@ export function initResize() {
isDragging = false; isDragging = false;
document.body.style.cursor = "default"; document.body.style.cursor = "default";
if (!rightPanel.classList.contains("hidden")) { if (rightPanel && !rightPanel.classList.contains("hidden")) {
const total = window.innerWidth; const total = window.innerWidth;
const leftWidth = leftPanel.getBoundingClientRect().width; const leftWidth = leftPanel!.getBoundingClientRect().width;
localStorage.setItem('layoutRatio', leftWidth / total); localStorage.setItem('layoutRatio', String(leftWidth / total));
} }
}); });
document.addEventListener("mousemove", (e) => { document.addEventListener("mousemove", (e) => {
if (!isDragging) return; if (!isDragging) return;
if (rightPanel.classList.contains("hidden")) return; if (rightPanel && rightPanel.classList.contains("hidden")) return;
pendingX = e.clientX; pendingX = e.clientX;
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (pendingX === null) return; if (pendingX === null) return;
if (!leftPanel || !rightPanel || !dragBar) return;
const total = window.innerWidth; const total = window.innerWidth;
let leftWidth = pendingX; let leftWidth = pendingX;
@@ -62,17 +65,19 @@ export function initResize() {
}); });
} }
function applyRatio(ratio) { function applyRatio(ratio: number): void {
const total = window.innerWidth; const total = window.innerWidth;
const leftWidth = Math.floor(total * ratio); const leftWidth = Math.floor(total * ratio);
const rightWidth = total - leftWidth; const rightWidth = total - leftWidth;
leftPanel.style.flex = `0 0 ${leftWidth}px`; if (leftPanel && rightPanel) {
rightPanel.style.flex = `0 0 ${rightWidth}px`; leftPanel.style.flex = `0 0 ${leftWidth}px`;
rightPanel.style.flex = `0 0 ${rightWidth}px`;
}
} }
export function updateResizerVisibility() { export function updateResizerVisibility(): void {
if (!dragBar || !rightPanel) return; if (!dragBar || !rightPanel || !leftPanel) return;
if (rightPanel.classList.contains("hidden")) { if (rightPanel.classList.contains("hidden")) {
dragBar.classList.add("hidden"); dragBar.classList.add("hidden");
@@ -80,4 +85,4 @@ export function updateResizerVisibility() {
} else { } else {
dragBar.classList.remove("hidden"); dragBar.classList.remove("hidden");
} }
} }

View File

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

102
cmd/server/web/js/ui.ts Normal file
View File

@@ -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<HTMLElement>;
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");
}
}

View File

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

View File

@@ -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"]
}

View File

@@ -1,19 +1,16 @@
[Unit] [Unit]
Description=Мониторинг GPIO на Raspberry Pi Description=GPIO Interrupt Capture Service
Requires=monitor-gpio.socket Requires=monitor-gpio.socket
After=monitor-gpio.socket After=monitor-gpio.socket
Wants=multi-user.target
[Service] [Service]
Type=simple Type=simple
User=root User=root
WorkingDirectory=/home/user/WiringPi/examples 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 StandardOutput=socket
StandardError=journal StandardError=journal
Restart=always Restart=always
RestartSec=2 RestartSec=2
[Install]
WantedBy=multi-user.target

View File

@@ -33,4 +33,4 @@ if [ -d "$LOG_DIR" ]; then
else else
echo "❌ Директория логов не найдена: $LOG_DIR" echo "❌ Директория логов не найдена: $LOG_DIR"
echo "Запустите сервер для создания логов" echo "Запустите сервер для создания логов"
fi fi