Рефакторинг. Переход на typescript
This commit is contained in:
75
build.sh
75
build.sh
@@ -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 "СБОРКА ЗАВЕРШЕНА!"
|
||||||
|
|||||||
@@ -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
34
cmd/server/web/README.md
Normal 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
|
||||||
35
cmd/server/web/README.md.save
Normal file
35
cmd/server/web/README.md.save
Normal 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
|
||||||
@@ -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
18
cmd/server/web/dist/accordion.js
vendored
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
79
cmd/server/web/dist/layout.js
vendored
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
152
cmd/server/web/js/app.ts
Normal 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
179
cmd/server/web/js/camera.ts
Normal 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
140
cmd/server/web/js/chart.ts
Normal 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
30
cmd/server/web/js/data.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
48
cmd/server/web/js/state.ts
Normal file
48
cmd/server/web/js/state.ts
Normal 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
102
cmd/server/web/js/ui.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
13
cmd/server/web/package.json
Normal file
13
cmd/server/web/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
16
cmd/server/web/tsconfig.json
Normal file
16
cmd/server/web/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
@@ -33,4 +33,4 @@ if [ -d "$LOG_DIR" ]; then
|
|||||||
else
|
else
|
||||||
echo "❌ Директория логов не найдена: $LOG_DIR"
|
echo "❌ Директория логов не найдена: $LOG_DIR"
|
||||||
echo "Запустите сервер для создания логов"
|
echo "Запустите сервер для создания логов"
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user