Compare commits

...

9 Commits

Author SHA1 Message Date
Maxim
594a62a9f6 Доработка сборки deb пакета 2026-06-23 15:28:10 +03:00
Maxim
387823d36a Доработка git 2026-06-23 11:26:23 +03:00
Maxim
118e49c7f9 Отладка звука 2026-06-23 10:10:14 +03:00
Maxim
78069b0aa4 В процессе работы 2026-06-23 09:07:43 +03:00
Maxim
877b29d5b4 В процессе работы 2026-06-22 14:38:07 +03:00
Maxim
eab9e17212 Исправление индикации 2026-06-22 11:54:14 +03:00
Maxim
827210d29a Корректировка ошибок. Стили, прокрутка, шрифты 2026-06-22 10:41:29 +03:00
Maxim
5d15f215be Добавление 8 каналов в ui 2026-06-19 15:56:30 +03:00
Maxim
311d09f0b3 Оптимизация журналирования событий 2026-06-19 10:34:07 +03:00
38 changed files with 2445 additions and 1103 deletions

14
.gitignore vendored
View File

@@ -105,4 +105,16 @@ logs/
# Файлы дампа памяти
*.dump
*.core
*.core
# Debian packaging
*.deb
*.dsc
*.changes
debian/*.log
debian/*.debhelper
debian/*.substvars
debian/files
debian/gpio-monitor-server/
debian/debhelper-build-stamp
debian/.debhelper/

114
Makefile
View File

@@ -1,6 +1,6 @@
# Makefile для GPIO Monitor Server
.PHONY: all build clean run test fmt lint help install dev
.PHONY: all build clean run test fmt lint help install dev deb prepare-debian
# Переменные
APP_NAME := gpio-monitor-server
@@ -8,6 +8,8 @@ CMD_PATH := ./cmd/server
BUILD_DIR := ./build
BINARY := $(BUILD_DIR)/$(APP_NAME)
WEB_DIR := ./cmd/server/web
DEB_DIR := ./debian
DEB_PACKAGE := $(DEB_DIR)/$(APP_NAME)
# Целевая платформа (по умолчанию)
GOOS ?= linux
@@ -43,15 +45,15 @@ build-frontend:
fi && \
echo " Компиляция TypeScript..." && \
npm run build && \
echo "$(GREEN) TypeScript скомпилирован успешно!$(NC)"
echo "$(GREEN)TypeScript скомпилирован успешно!$(NC)"
## build-backend - Сборка Go сервера
build-backend:
@echo "$(YELLOW)🔧 Сборка Go сервера...$(NC)"
@echo "$(YELLOW)Сборка Go сервера...$(NC)"
@mkdir -p $(BUILD_DIR)
@echo " Целевая платформа: $(GOOS)/$(GOARCH)"
@GOOS=$(GOOS) GOARCH=$(GOARCH) go build $(LDFLAGS) -o $(BINARY) $(CMD_PATH)
@echo "$(GREEN) Go сервер скомпилирован успешно!$(NC)"
@echo "$(GREEN)Go сервер скомпилирован успешно!$(NC)"
## run - Запуск сервера (сборка + запуск)
run: build
@@ -60,7 +62,7 @@ run: build
## dev - Режим разработки (без оптимизации, с hot-reload)
dev:
@echo "$(YELLOW) Режим разработки...$(NC)"
@echo "$(YELLOW)Режим разработки...$(NC)"
@go run $(CMD_PATH) -port :8080
## clean - Очистка артефактов сборки
@@ -69,7 +71,9 @@ clean:
@rm -rf $(BUILD_DIR)
@rm -rf $(WEB_DIR)/dist
@rm -rf $(WEB_DIR)/node_modules
@echo "$(GREEN) Очищено$(NC)"
@rm -rf $(DEB_PACKAGE)
@rm -f *.deb
@echo "$(GREEN)Очищено$(NC)"
## test - Запуск тестов
test:
@@ -78,18 +82,18 @@ test:
## fmt - Форматирование кода
fmt:
@echo "$(YELLOW) Форматирование кода...$(NC)"
@echo "$(YELLOW)Форматирование кода...$(NC)"
@go fmt ./...
@cd $(WEB_DIR) && npm run format 2>/dev/null || true
## lint - Проверка кода
lint:
@echo "$(YELLOW) Проверка кода...$(NC)"
@echo "$(YELLOW)Проверка кода...$(NC)"
@golangci-lint run ./... 2>/dev/null || echo " Установите golangci-lint: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"
## deps - Установка зависимостей
deps:
@echo "$(YELLOW) Установка зависимостей...$(NC)"
@echo "$(YELLOW)Установка зависимостей...$(NC)"
@go mod download
@go mod tidy
@cd $(WEB_DIR) && npm install
@@ -98,11 +102,11 @@ deps:
install: build
@echo "$(YELLOW)Установка $(APP_NAME)...$(NC)"
@sudo cp $(BINARY) /usr/local/bin/$(APP_NAME)
@echo "$(GREEN) Установлено в /usr/local/bin/$(APP_NAME)$(NC)"
@echo "$(GREEN)Установлено в /usr/local/bin/$(APP_NAME)$(NC)"
## uninstall - Удаление из системы
uninstall:
@echo "$(YELLOW)Удаление $(APP_NAME)...$(NC)"
@echo "$(YELLOW)🗑Удаление $(APP_NAME)...$(NC)"
@sudo rm -f /usr/local/bin/$(APP_NAME)
@echo "$(GREEN)Удалено$(NC)"
@@ -123,6 +127,71 @@ help:
@echo " make dev - Запуск в режиме разработки"
@echo " make clean - Очистка"
@echo " make install - Установка в систему"
@echo " make deb - Сборка DEB пакета"
# ============================================
# СБОРКА DEB ПАКЕТА
# ============================================
## prepare-debian - Подготовка структуры для deb-пакета
prepare-debian:
@echo "$(YELLOW)Подготовка структуры DEB пакета...$(NC)"
@# Создаем основную структуру
@mkdir -p $(DEB_PACKAGE)/DEBIAN
@mkdir -p $(DEB_PACKAGE)/usr/bin
@mkdir -p $(DEB_PACKAGE)/usr/share/doc/$(APP_NAME)
@mkdir -p $(DEB_PACKAGE)/opt/gpio-monitoring/web
@mkdir -p $(DEB_PACKAGE)/etc/gpio-monitoring
@mkdir -p $(DEB_PACKAGE)/etc/systemd/system
@# Копируем DEBIAN файлы
@if [ -f $(DEB_DIR)/control ]; then cp $(DEB_DIR)/control $(DEB_PACKAGE)/DEBIAN/; fi
@if [ -f $(DEB_DIR)/preinst ]; then cp $(DEB_DIR)/preinst $(DEB_PACKAGE)/DEBIAN/; fi
@if [ -f $(DEB_DIR)/postinst ]; then cp $(DEB_DIR)/postinst $(DEB_PACKAGE)/DEBIAN/; fi
@if [ -f $(DEB_DIR)/prerm ]; then cp $(DEB_DIR)/prerm $(DEB_PACKAGE)/DEBIAN/; fi
@if [ -f $(DEB_DIR)/postrm ]; then cp $(DEB_DIR)/postrm $(DEB_PACKAGE)/DEBIAN/; fi
@# Копируем документацию
@if [ -f $(DEB_DIR)/copyright ]; then cp $(DEB_DIR)/copyright $(DEB_PACKAGE)/usr/share/doc/$(APP_NAME)/; fi
@if [ -f $(DEB_DIR)/changelog ]; then \
cp $(DEB_DIR)/changelog $(DEB_PACKAGE)/usr/share/doc/$(APP_NAME)/; \
gzip -9 -n $(DEB_PACKAGE)/usr/share/doc/$(APP_NAME)/changelog 2>/dev/null || true; \
fi
@# Устанавливаем права
@chmod 755 $(DEB_PACKAGE)/DEBIAN/* 2>/dev/null || true
@echo "$(GREEN)Структура подготовлена$(NC)"
## deb - Сборка DEB пакета
deb: clean build-frontend build-backend prepare-debian
@echo "$(YELLOW)Сборка DEB пакета...$(NC)"
@# Копируем бинарник
@cp $(BINARY) $(DEB_PACKAGE)/usr/bin/
@# Копируем веб-файлы
@if [ -d "$(WEB_DIR)/dist" ]; then cp -r $(WEB_DIR)/dist $(DEB_PACKAGE)/opt/gpio-monitoring/web/; fi
@if [ -d "$(WEB_DIR)/css" ]; then cp -r $(WEB_DIR)/css $(DEB_PACKAGE)/opt/gpio-monitoring/web/; fi
@if [ -d "$(WEB_DIR)/fonts" ]; then cp -r $(WEB_DIR)/fonts $(DEB_PACKAGE)/opt/gpio-monitoring/web/; fi
@if ls $(WEB_DIR)/*.html 1>/dev/null 2>&1; then cp $(WEB_DIR)/*.html $(DEB_PACKAGE)/opt/gpio-monitoring/web/ 2>/dev/null || true; fi
@if [ -f "$(WEB_DIR)/favicon.png" ]; then cp $(WEB_DIR)/favicon.png $(DEB_PACKAGE)/opt/gpio-monitoring/web/ 2>/dev/null || true; fi
@# Копируем systemd сервис
@if [ -f "$(DEB_DIR)/$(APP_NAME).service" ]; then cp $(DEB_DIR)/$(APP_NAME).service $(DEB_PACKAGE)/etc/systemd/system/; fi
@# Копируем конфиг
@if [ -f "$(DEB_DIR)/$(APP_NAME).conf" ]; then cp $(DEB_DIR)/$(APP_NAME).conf $(DEB_PACKAGE)/etc/gpio-monitoring/config; fi
@# Копируем скрипт для логов
@if [ -f "$(DEB_DIR)/usr/bin/gpio-logs" ]; then \
cp $(DEB_DIR)/usr/bin/gpio-logs $(DEB_PACKAGE)/usr/bin/; \
chmod +x $(DEB_PACKAGE)/usr/bin/gpio-logs; \
fi
@# Устанавливаем правильные права
@chown -R root:root $(DEB_PACKAGE) 2>/dev/null || true
@# Создаем архив с контрольными суммами
@cd $(DEB_PACKAGE) && find . -type f ! -path "./DEBIAN/*" -exec md5sum {} \; > DEBIAN/md5sums 2>/dev/null || true
@# Собираем пакет
@dpkg-deb --root-owner-group --build $(DEB_PACKAGE) .
@mkdir -p $(BUILD_DIR)
@mv *.deb $(BUILD_DIR)/
@echo "$(GREEN)DEB пакет создан: $(BUILD_DIR)/$(APP_NAME).deb$(NC)"
@ls -lh $(BUILD_DIR)/*.deb
@echo ""
@echo "$(GREEN)Информация о пакете:$(NC)"
@dpkg-deb -I $(BUILD_DIR)/*.deb | head -15
# ============================================
# ДОПОЛНИТЕЛЬНЫЕ ЦЕЛИ
@@ -157,3 +226,26 @@ docker-build:
docker-run:
@echo "$(YELLOW)Запуск в Docker...$(NC)"
@docker run -p 8080:8080 gpio-monitor:latest
## deb-clean - Очистка временных файлов deb-пакета
deb-clean:
@echo "$(YELLOW)Очистка deb-временных файлов...$(NC)"
@rm -rf $(DEB_PACKAGE)
@rm -f $(BUILD_DIR)/$(APP_NAME).deb
@echo "$(GREEN)Очищено$(NC)"
## deb-info - Показать информацию о deb-пакете
deb-info:
@if [ -f "$(BUILD_DIR)/$(APP_NAME).deb" ]; then \
dpkg-deb -I $(BUILD_DIR)/$(APP_NAME).deb; \
else \
echo "$(RED)Пакет не найден. Сначала выполните: make deb$(NC)"; \
fi
## deb-contents - Показать содержимое deb-пакета
deb-contents:
@if [ -f "$(BUILD_DIR)/$(APP_NAME).deb" ]; then \
dpkg-deb -c $(BUILD_DIR)/$(APP_NAME).deb; \
else \
echo "$(RED)Пакет не найден. Сначала выполните: make deb$(NC)"; \
fi

159
Makefile.backup Normal file
View File

@@ -0,0 +1,159 @@
# Makefile для GPIO Monitor Server
.PHONY: all build clean run test fmt lint help install dev
# Переменные
APP_NAME := gpio-monitor-server
CMD_PATH := ./cmd/server
BUILD_DIR := ./build
BINARY := $(BUILD_DIR)/$(APP_NAME)
WEB_DIR := ./cmd/server/web
# Целевая платформа (по умолчанию)
GOOS ?= linux
GOARCH ?= arm64
# Флаги сборки
LDFLAGS := -ldflags="-s -w"
# Цвета для вывода
RED := \033[0;31m
GREEN := \033[0;32m
YELLOW := \033[0;33m
NC := \033[0m # No Color
# ============================================
# ОСНОВНЫЕ ЦЕЛИ
# ============================================
all: help
## build - Полная сборка проекта (фронтенд + бекенд)
build: build-frontend build-backend
@echo "$(GREEN)Сборка завершена!$(NC)"
@ls -lh $(BINARY)
## build-frontend - Сборка TypeScript фронтенда
build-frontend:
@echo "$(YELLOW)Сборка TypeScript фронтенда...$(NC)"
@cd $(WEB_DIR) && \
if [ ! -d "node_modules" ]; then \
echo " Установка npm зависимостей..."; \
npm install; \
fi && \
echo " Компиляция TypeScript..." && \
npm run build && \
echo "$(GREEN) TypeScript скомпилирован успешно!$(NC)"
## build-backend - Сборка Go сервера
build-backend:
@echo "$(YELLOW)🔧 Сборка Go сервера...$(NC)"
@mkdir -p $(BUILD_DIR)
@echo " Целевая платформа: $(GOOS)/$(GOARCH)"
@GOOS=$(GOOS) GOARCH=$(GOARCH) go build $(LDFLAGS) -o $(BINARY) $(CMD_PATH)
@echo "$(GREEN) Go сервер скомпилирован успешно!$(NC)"
## run - Запуск сервера (сборка + запуск)
run: build
@echo "$(YELLOW)Запуск сервера...$(NC)"
@$(BINARY)
## dev - Режим разработки (без оптимизации, с hot-reload)
dev:
@echo "$(YELLOW) Режим разработки...$(NC)"
@go run $(CMD_PATH) -port :8080
## clean - Очистка артефактов сборки
clean:
@echo "$(YELLOW)Очистка...$(NC)"
@rm -rf $(BUILD_DIR)
@rm -rf $(WEB_DIR)/dist
@rm -rf $(WEB_DIR)/node_modules
@echo "$(GREEN) Очищено$(NC)"
## test - Запуск тестов
test:
@echo "$(YELLOW)Запуск тестов...$(NC)"
@go test -v ./...
## fmt - Форматирование кода
fmt:
@echo "$(YELLOW) Форматирование кода...$(NC)"
@go fmt ./...
@cd $(WEB_DIR) && npm run format 2>/dev/null || true
## lint - Проверка кода
lint:
@echo "$(YELLOW) Проверка кода...$(NC)"
@golangci-lint run ./... 2>/dev/null || echo " Установите golangci-lint: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"
## deps - Установка зависимостей
deps:
@echo "$(YELLOW) Установка зависимостей...$(NC)"
@go mod download
@go mod tidy
@cd $(WEB_DIR) && npm install
## install - Установка бинарника в систему
install: build
@echo "$(YELLOW)Установка $(APP_NAME)...$(NC)"
@sudo cp $(BINARY) /usr/local/bin/$(APP_NAME)
@echo "$(GREEN) Установлено в /usr/local/bin/$(APP_NAME)$(NC)"
## uninstall - Удаление из системы
uninstall:
@echo "$(YELLOW)Удаление $(APP_NAME)...$(NC)"
@sudo rm -f /usr/local/bin/$(APP_NAME)
@echo "$(GREEN)Удалено$(NC)"
## help - Показать справку
help:
@echo "$(GREEN)GPIO Monitor Server - команды Makefile$(NC)"
@echo ""
@echo "Доступные команды:"
@awk '/^##/ { \
cmd = $$0; \
gsub(/^## /, "", cmd); \
printf " $(GREEN)%-20s$(NC) %s\n", $$2, cmd \
}' $(MAKEFILE_LIST) | sort
@echo ""
@echo "Примеры:"
@echo " make build - Полная сборка"
@echo " make run - Сборка и запуск"
@echo " make dev - Запуск в режиме разработки"
@echo " make clean - Очистка"
@echo " make install - Установка в систему"
# ============================================
# ДОПОЛНИТЕЛЬНЫЕ ЦЕЛИ
# ============================================
## build-arm64 - Сборка для ARM64 (Raspberry Pi)
build-arm64:
@$(MAKE) build GOOS=linux GOARCH=arm64
## build-amd64 - Сборка для AMD64
build-amd64:
@$(MAKE) build GOOS=linux GOARCH=amd64
## build-mac - Сборка для macOS
build-mac:
@$(MAKE) build GOOS=darwin GOARCH=amd64
## build-windows - Сборка для Windows
build-windows:
@$(MAKE) build GOOS=windows GOARCH=amd64
## build-all - Сборка для всех платформ
build-all: build-arm64 build-amd64 build-mac build-windows
@echo "$(GREEN)Все платформы собраны$(NC)"
## docker-build - Сборка Docker образа
docker-build:
@echo "$(YELLOW)Сборка Docker образа...$(NC)"
@docker build -t gpio-monitor:latest .
## docker-run - Запуск в Docker
docker-run:
@echo "$(YELLOW)Запуск в Docker...$(NC)"
@docker run -p 8080:8080 gpio-monitor:latest

View File

@@ -391,3 +391,7 @@ python3 scripts/emulator.py
- Встроенный интерфейс: `http://<IP>:1984`
---
### Сборка пакета
```bash
make deb

View File

@@ -24,8 +24,7 @@ import (
)
// ===== EMBED WEB =====
//
//go:embed web/dist web/css web/*.html web/*.png
//go:embed web/dist web/css web/fonts web/*.html web/*.png
var webFiles embed.FS
type API struct {
@@ -436,7 +435,7 @@ func (a *API) HandleLatest(w http.ResponseWriter, r *http.Request) {
}
func (a *API) HandleHistory(w http.ResponseWriter, r *http.Request) {
raw := a.buf.GetLast(100)
raw := a.buf.GetLast(300)
out := make([]int, len(raw))
for i, v := range raw {

View File

@@ -1,4 +1,4 @@
/* Стили аудио-индикатора */
/* web/css/audio.css */
@keyframes pulse {
0%, 100% {
opacity: 1;
@@ -10,6 +10,7 @@
}
}
/* ===== АУДИО ИНДИКАТОР ===== */
#audio-indicator {
font-family: var(--font-tech);
font-size: var(--font-size-lg);
@@ -17,14 +18,231 @@
border-radius: 6px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid #333;
min-width: 50px;
min-width: 60px; /* Фиксированная минимальная ширина */
width: 60px; /* Фиксированная ширина */
text-align: center;
user-select: none;
transition: all 0.3s ease;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
}
#audio-indicator.active {
border-color: #00ff88;
box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
animation: pulse 1s ease-in-out infinite;
}
#audio-indicator.muted {
color: #666 !important;
border-color: #333;
animation: none !important;
box-shadow: none !important;
}
/* ===== ОБЕРТКА ДЛЯ АУДИО ===== */
.audio-wrapper {
position: relative;
display: inline-flex;
align-items: center;
}
/* ===== РЕГУЛЯТОР ГРОМКОСТИ ===== */
.volume-control {
position: absolute;
top: calc(100% + 2px);
left: 50%;
transform: translateX(-50%);
background: rgba(10, 20, 15, 0.95);
border: 1px solid rgba(0, 255, 136, 0.25);
border-radius: 8px;
padding: 10px 16px;
display: none;
align-items: center;
backdrop-filter: blur(10px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.7);
z-index: 1000;
animation: volumeFadeIn 0.15s ease;
min-width: 100px;
}
.audio-wrapper:hover .volume-control {
display: flex;
}
.volume-control:hover {
display: flex !important;
}
@keyframes volumeFadeIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(-4px) scale(0.96);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
}
/* ===== ОБЕРТКА ДЛЯ ПОЛЗУНКА ===== */
.volume-slider-wrapper {
position: relative;
width: 80px;
height: 20px;
display: flex;
align-items: center;
}
/* ===== ТРЕК (фон) ===== */
.volume-track {
position: absolute;
left: 0;
right: 0;
top: 50%;
transform: translateY(-50%);
height: 4px;
background: rgba(255, 255, 255, 0.12);
border-radius: 2px;
pointer-events: none;
overflow: hidden;
}
/* ===== ЗАЛИВКА ТРЕКА ===== */
.volume-track-fill {
height: 100%;
width: 0%;
background: #00ff88;
border-radius: 2px;
transition: width 0.05s linear;
box-shadow: 0 0 10px rgba(0, 255, 136, 0.3);
}
/* ===== ПОЛЗУНОК ===== */
.volume-slider {
-webkit-appearance: none;
appearance: none;
position: relative;
width: 100%;
height: 20px;
background: transparent;
outline: none;
cursor: pointer;
z-index: 2;
margin: 0;
padding: 0;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #00ff88;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 16px rgba(0, 255, 136, 0.5);
transition: all 0.15s ease;
border: 2px solid rgba(0, 255, 136, 0.3);
margin-top: -5px;
}
.volume-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
box-shadow: 0 0 24px rgba(0, 255, 136, 0.7);
border-color: #00ff88;
}
.volume-slider::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
background: transparent;
border: none;
border-radius: 2px;
}
.volume-slider::-moz-range-thumb {
width: 14px;
height: 14px;
background: #00ff88;
border: 2px solid rgba(0, 255, 136, 0.3);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 16px rgba(0, 255, 136, 0.5);
}
.volume-slider::-moz-range-thumb:hover {
transform: scale(1.15);
box-shadow: 0 0 24px rgba(0, 255, 136, 0.7);
border-color: #00ff88;
}
.volume-slider::-moz-range-track {
width: 100%;
height: 4px;
background: transparent;
border: none;
border-radius: 2px;
}
/* ===== МОБИЛЬНАЯ ВЕРСИЯ ===== */
@media (max-width: 768px) {
.volume-control {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
top: auto;
padding: 12px 20px;
border-radius: 10px;
background: rgba(10, 20, 15, 0.98);
border: 1px solid rgba(0, 255, 136, 0.3);
display: none !important;
min-width: 140px;
}
.audio-wrapper .volume-control.visible {
display: flex !important;
animation: volumeFadeInMobile 0.2s ease;
}
@keyframes volumeFadeInMobile {
from {
opacity: 0;
transform: translateX(-50%) translateY(10px) scale(0.95);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
}
.volume-slider-wrapper {
width: 120px;
}
}
@media (max-width: 480px) {
.volume-control {
padding: 10px 16px;
bottom: 70px;
min-width: 120px;
}
.volume-slider-wrapper {
width: 100px;
}
}
@media (hover: none) {
.audio-wrapper:hover .volume-control {
display: none !important;
}
}
.audio-wrapper.touch-active .volume-control {
display: flex !important;
}

View File

@@ -120,6 +120,33 @@ html {
overscroll-behavior: contain;
}
/* ===== ГЛОБАЛЬНЫЕ СТИЛИ СКРОЛЛБАРОВ ===== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(0, 255, 136, 0.05);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 255, 136, 0.3);
border-radius: 4px;
transition: background 0.3s ease;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 255, 136, 0.5);
}
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: rgba(0, 255, 136, 0.3) rgba(0, 255, 136, 0.05);
}
/* Убираем скролл-бары на мобильных где не нужно */
@media (max-width: 768px) {
.panel::-webkit-scrollbar,
@@ -128,3 +155,4 @@ html {
width: 3px;
}
}

View File

@@ -1,21 +1,20 @@
/* Стили карточек (card, accordion, статусы) */
/* Стили карточек, каналов, статусов и истории */
/* ===== CARD ===== */
.card {
border: 1px solid #00ff88;
border-radius: 10px;
padding: 14px;
background: rgba(0,255,136,0.05);
overflow: hidden;
overflow: visible;
height: 100%;
display: flex;
flex-direction: column;
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 0 30px rgba(0, 255, 136, 0.05);
}
.card h3 {
margin: 0 0 10px;
font-size: var(--font-size-lg);
@@ -24,7 +23,7 @@
font-weight: var(--font-weight-bold);
}
/* ===== STATUS ===== */
/* ===== СТАТУС ===== */
#status {
font-family: var(--font-tech);
font-size: var(--font-size-3xl);
@@ -32,23 +31,19 @@
margin-bottom: 8px;
letter-spacing: 0.05em;
}
.status-ok {
color: #00ff88 !important;
text-shadow: 0 0 30px rgba(0, 255, 136, 0.3);
}
.status-lost {
color: #ffffff !important;
animation: blink 1.5s infinite;
text-shadow: 0 0 30px rgba(255, 255, 255, 0.1);
}
.status-error {
color: #ff4444 !important;
text-shadow: 0 0 30px rgba(255, 68, 68, 0.3);
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.2; }
@@ -62,7 +57,7 @@
letter-spacing: 0.02em;
}
/* ===== ACCORDION ===== */
/* ===== АККОРДЕОН ===== */
.accordion-header {
cursor: pointer;
user-select: none;
@@ -73,23 +68,47 @@
letter-spacing: 0.05em;
transition: all 0.3s ease;
}
.accordion-header:hover {
opacity: 0.7;
text-shadow: 0 0 20px rgba(0, 255, 136, 0.2);
}
.accordion-body {
max-height: 520px;
overflow: hidden;
transition: max-height 0.3s ease, opacity 0.3s ease;
}
.accordion-body.collapsed {
max-height: 0;
opacity: 0;
}
/* ===== ИСТОРИЯ ===== */
#history {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
line-height: 1.35;
max-height: 400px;
overflow-y: auto;
overflow-x: auto;
white-space: nowrap;
padding-right: 4px;
margin: 0;
letter-spacing: 0.02em;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
#history::-webkit-scrollbar {
width: 6px;
height: 6px;
}
#history::-webkit-scrollbar-track {
background: rgba(0, 255, 136, 0.05);
}
#history::-webkit-scrollbar-thumb {
background: #00ff88;
border-radius: 3px;
}
/* ===== LAST BYTE ===== */
#last-byte {
font-family: var(--font-tech);
@@ -99,28 +118,359 @@
text-shadow: 0 0 20px rgba(0, 255, 136, 0.2);
}
/* ===== HISTORY ===== */
#history {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
line-height: 1.35;
max-height: 420px;
overflow-y: auto;
white-space: nowrap;
padding-right: 4px;
/* ===== КАНАЛЫ ===== */
.channels-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
flex-wrap: wrap;
gap: 8px;
}
.channels-header h3 {
margin: 0;
font-size: var(--font-size-lg);
}
.channels-controls {
display: flex;
gap: 4px;
}
.channels-toggle-all {
background: rgba(0, 255, 136, 0.08);
border: 1px solid rgba(0, 255, 136, 0.2);
color: #00ff88;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-family: var(--font-mono);
transition: all 0.2s ease;
line-height: 1.4;
min-height: 30px;
}
.channels-toggle-all:hover {
background: rgba(0, 255, 136, 0.15);
border-color: #00ff88;
}
.channels-toggle-all:active {
transform: scale(0.95);
}
/* ===== СЕТКА КАНАЛОВ ===== */
.channels-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
max-height: 500px;
overflow-y: auto;
overflow-x: visible;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
padding-right: 4px;
}
.channels-grid::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.channels-grid::-webkit-scrollbar-track {
background: rgba(0, 255, 136, 0.05);
}
.channels-grid::-webkit-scrollbar-thumb {
background: rgba(0, 255, 136, 0.3);
border-radius: 3px;
}
/* Карточка канала */
.channel-item {
background: rgba(0, 255, 136, 0.03);
border: 1px solid rgba(0, 255, 136, 0.1);
border-radius: 6px;
overflow: hidden;
transition: all 0.2s ease;
}
.channel-item:hover {
border-color: rgba(0, 255, 136, 0.25);
background: rgba(0, 255, 136, 0.06);
}
.channel-item.channel-active {
border-color: rgba(0, 255, 136, 0.4);
background: rgba(0, 255, 136, 0.08);
}
.channel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px;
cursor: pointer;
user-select: none;
transition: background 0.2s;
}
.channel-header:hover {
background: rgba(0, 255, 136, 0.05);
}
.channel-label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: #88ffbb;
font-family: var(--font-tech);
}
.channel-value {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
color: #ffffff;
font-family: var(--font-tech);
min-width: 32px;
text-align: right;
letter-spacing: 0.05em;
padding: 2px 8px;
border-radius: 4px;
transition: all 0.2s ease;
}
.channel-toggle {
color: #666;
font-size: 10px;
transition: transform 0.3s;
margin-left: 6px;
}
.channel-toggle.collapsed {
transform: rotate(-90deg);
}
.channel-body {
padding: 0 10px 10px 10px;
transition: all 0.3s ease;
}
.channel-body.hidden {
display: none;
}
.channel-progress {
display: flex;
align-items: center;
gap: 8px;
}
.channel-progress .progress {
flex: 1;
height: 24px;
padding: 3px;
background: #2f2f2f;
border: 2px solid #555;
border-radius: 4px;
overflow: hidden;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.6);
}
.channel-progress .progress .fill {
height: 100%;
background: #e6852d;
border-radius: 2px;
transition: width 0.15s ease;
width: 0%;
box-shadow:
inset 0 1px 2px rgba(255,255,255,0.3),
0 0 15px rgba(255, 165, 0, 0.5);
}
.channel-item.channel-active .fill {
box-shadow:
inset 0 1px 2px rgba(255,255,255,0.4),
0 0 25px rgba(255, 165, 0, 0.7);
}
.channel-stats {
font-size: var(--font-size-xs);
color: #666;
margin-top: 4px;
font-family: var(--font-mono);
letter-spacing: 0.02em;
}
#history::-webkit-scrollbar {
width: 6px;
/* ===== ОСНОВНЫЕ ПРОГРЕСС-БАРЫ (bar1, bar2, bar3) ===== */
.progress-row {
margin-bottom: 16px;
}
.progress-label {
font-family: var(--font-mono);
font-size: var(--font-size-base);
margin-bottom: 6px;
color: #ffffff;
opacity: 0.9;
letter-spacing: 0.03em;
}
.progress {
width: 100%;
height: 34px;
padding: 4px;
background: #222;
border: 2px solid #444;
border-radius: 4px;
overflow: hidden;
box-shadow: inset 0 2px 4px rgba(0,0,0,0.4);
}
.progress-with-signal {
display: flex;
align-items: center;
gap: 0;
}
.progress-90 {
flex: 0 0 86%;
margin-right: 10px;
}
.fill {
height: 100%;
width: 0%;
background: #e6852d;
border-radius: 2px;
box-shadow:
inset 0 1px 2px rgba(255,255,255,0.3),
0 0 12px rgba(230,133,45,0.5);
transition: width 0.25s linear;
}
.fill.no-connection {
background: #555555 !important;
box-shadow: none !important;
width: 0 !important;
}
#history::-webkit-scrollbar-track {
background: rgba(0, 255, 136, 0.05);
/* ===== ЗВУК И ВЫСОТА ===== */
.card:has(#bar2) .progress,
.card:has(#bar3) .progress {
height: 34px;
padding: 6px;
background: #222;
border: 2px solid #444;
border-radius: 4px;
}
.card:has(#bar2) .fill,
.card:has(#bar3) .fill {
background: #e6852d;
box-shadow:
inset 0 1px 2px rgba(255,255,255,0.3),
0 0 12px rgba(230,133,45,0.5);
}
#history::-webkit-scrollbar-thumb {
background: #00ff88;
border-radius: 3px;
/* ===== ИНДИКАТОРЫ СИГНАЛА ===== */
.signal-icon {
display: flex;
align-items: flex-end;
gap: 4px;
width: 34px;
height: 34px;
flex-shrink: 0;
}
.sig-bar {
flex: 1;
border-radius: 2px;
min-width: 5px;
border: 2px solid #e6852d;
background-color: transparent;
transition: background-color 0.3s, border-color 0.3s;
}
.sig-bar-1 { height: 33%; }
.sig-bar-2 { height: 66%; }
.sig-bar-3 { height: 100%; }
.sig-bar.dim {
background-color: transparent;
border-color: #1a3a1a;
}
.sig-bar.active-1,
.sig-bar.active-2,
.sig-bar.active-3 {
background-color: #e6852d;
border-color: #c46b1f;
}
.sig-bar.no-connection {
background-color: #555555 !important;
border-color: #555555 !important;
opacity: 0.3 !important;
}
/* ===== АНИМАЦИЯ ===== */
.channel-item.updating .channel-value {
animation: pulse-value 0.15s ease;
}
@keyframes pulse-value {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2); color: #ff0; }
}
/* ===== КАРТОЧКИ НА ДЕСКТОПЕ ===== */
@media (min-width: 769px) {
.card {
max-height: 100%;
overflow: visible;
}
.channels-card {
max-height: 600px;
overflow: visible;
display: flex;
flex-direction: column;
}
.channels-card .channels-grid {
max-height: 500px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.history-card {
max-height: 500px;
overflow: visible;
display: flex;
flex-direction: column;
}
.history-card #history {
max-height: 400px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
}
/* ===== МОБИЛЬНАЯ ВЕРСИЯ ===== */
@media (max-width: 768px) {
.channels-grid {
grid-template-columns: 1fr;
gap: 4px;
max-height: 400px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.channels-header {
flex-direction: column;
align-items: stretch;
gap: 6px;
}
.channels-controls {
justify-content: flex-end;
}
.channel-label {
font-size: var(--font-size-xs);
}
.channel-value {
font-size: var(--font-size-sm);
}
#history {
max-height: 300px;
}
.history-card #history {
max-height: 280px;
overflow-y: auto;
}
}
@media (max-width: 480px) {
.channels-grid {
max-height: 350px;
gap: 3px;
}
.channel-header {
padding: 4px 8px;
}
.channel-body {
padding: 0 8px 8px 8px;
}
.channel-stats {
font-size: 9px;
}
#history {
max-height: 200px;
font-size: var(--font-size-xs);
}
.history-card #history {
max-height: 180px;
}
}

View File

@@ -3,58 +3,56 @@
/* JetBrains Mono */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
src: url('/fonts/JetBrainsMono-Regular.woff2') format('woff2');
font-weight: 400;
src: url('/fonts/JetBrainsMono/ttf/JetBrainsMono-Regular.ttf') format('truetype');
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
src: url('/fonts/JetBrainsMono/ttf/JetBrainsMono-Bold.ttf') format('truetype');
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
src: url('/fonts/JetBrainsMono-Bold.woff2') format('woff2');
font-weight: 700;
src: url('/fonts/JetBrainsMono/ttf/JetBrainsMono-ExtraBold.ttf') format('truetype');
font-style: normal;
font-display: swap;
}
/* Orbitron - для заголовков */
/* Orbitron - используем существующий файл */
@font-face {
font-family: 'Orbitron';
font-style: normal;
font-weight: 400;
src: url('/fonts/Orbitron/static/Orbitron-Regular.ttf') format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Orbitron';
font-style: normal;
font-weight: 600;
src: url('/fonts/Orbitron/static/Orbitron-SemiBold.ttf') format('truetype');
font-display: swap;
}
@font-face {
font-family: 'Orbitron';
font-style: normal;
src: url('/fonts/orbitron-bold-webfont.woff') format('woff');
font-weight: 700;
src: url('/fonts/Orbitron/static/Orbitron-Bold.ttf') format('truetype');
font-style: normal;
font-display: swap;
}
/* Share Tech Mono - для статусов и значений */
/* Share Tech Mono */
@font-face {
font-family: 'Share Tech Mono';
font-style: normal;
font-weight: 400;
src: url('/fonts/ShareTechMono-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* ===== ПЕРЕМЕННЫЕ СО ШРИФТАМИ ===== */
:root {
--font-mono: 'JetBrains Mono', 'Share Tech Mono', 'Courier New', monospace;
--font-display: 'Orbitron', 'Share Tech Mono', 'Arial Black', sans-serif;
--font-tech: 'Share Tech Mono', 'JetBrains Mono', 'Courier New', monospace;
/* Размеры шрифтов */
--font-size-xs: 11px;
--font-size-sm: 13px;
--font-size-base: 15px;
--font-size-lg: 18px;
--font-size-xl: 22px;
--font-size-2xl: 28px;
--font-size-3xl: 36px;
/* Веса шрифтов */
--font-weight-normal: 400;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--font-weight-extrabold: 800;
}

View File

@@ -5,23 +5,28 @@
width: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
/* панели */
.panel {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
padding-right: 4px;
overscroll-behavior: auto;
}
/* LEFT */
.left {
flex: 1 1 auto;
min-width: 300px;
padding: 10px;
padding: 10px 10%; /* ← X% от ширины экрана */
overflow-y: auto;
overflow-x: hidden;
height: 100%;
overscroll-behavior: auto;
}
/* RIGHT */
.right {
flex: 0 0 40%;
min-width: 0;
@@ -29,7 +34,6 @@
position: relative;
overflow: hidden;
}
.right.hidden {
flex: 0 0 0 !important;
width: 0 !important;
@@ -38,7 +42,6 @@
pointer-events: none;
}
/* ===== RESIZER ===== */
.resizer {
width: 8px;
min-width: 8px;
@@ -48,11 +51,9 @@
flex-shrink: 0;
z-index: 10;
}
.resizer:hover {
opacity: 0.8;
}
.resizer.hidden {
width: 0 !important;
min-width: 0 !important;
@@ -60,17 +61,106 @@
pointer-events: none;
}
/* ===== GRID ===== */
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
align-items: stretch;
max-width: 1200px;
margin: 0 auto;
}
@media (min-width: 900px) {
/* ===== МОБИЛЬНАЯ ВЕРСИЯ ===== */
@media (max-width: 768px) {
.layout {
flex-direction: column;
height: auto !important;
min-height: 100vh;
gap: 8px;
overflow: visible !important;
padding: 0 8px;
width: 100%;
max-width: 100%;
margin: 0;
}
.panel {
height: auto !important;
overflow-y: visible !important;
-webkit-overflow-scrolling: auto;
padding-right: 0;
}
.left {
flex: 1 1 auto !important;
min-width: 0 !important;
padding: 10px 16px !important;
width: 100% !important;
overflow-y: visible !important;
height: auto !important;
padding-bottom: 20px;
}
.grid {
max-width: 100%;
margin: 0;
}
.right {
flex: 0 0 auto !important;
min-width: 100% !important;
width: 100% !important;
height: 40vh;
min-height: 300px;
opacity: 1 !important;
pointer-events: auto !important;
overflow: hidden;
flex-shrink: 0;
}
.right.hidden {
display: none !important;
flex: 0 0 0 !important;
height: 0 !important;
min-height: 0 !important;
}
.resizer {
display: none !important;
}
}
@media (max-width: 480px) {
.layout {
min-height: 100vh;
gap: 6px;
}
.right {
height: 35vh;
min-height: 200px;
}
}
/* ===== АЛЬБОМНАЯ ОРИЕНТАЦИЯ ===== */
@media (max-width: 768px) and (orientation: landscape) {
.layout {
flex-direction: row;
height: calc(100vh - 80px) !important;
min-height: auto;
overflow: hidden !important;
}
.left {
flex: 1 1 60% !important;
min-width: 0 !important;
padding-right: 8px;
overflow-y: auto !important;
height: 100% !important;
}
.right {
flex: 0 0 40% !important;
min-width: 0 !important;
height: 100% !important;
min-height: 0;
}
.grid {
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.wide {
grid-column: span 2;

View File

@@ -1,119 +1,63 @@
/* ===== РЕСПОНСИВНЫЙ ДИЗАЙН ===== */
/* ============================================
ПЛАНШЕТЫ (768px - 1024px)
============================================ */
/* ===== ПЛАНШЕТЫ ===== */
@media (max-width: 1024px) {
/* Уменьшаем отступы */
body {
padding: 10px;
}
/* Хедер */
.header-bar {
gap: 10px;
grid-template-columns: 1fr auto;
}
.header-bar h1 {
font-size: var(--font-size-xl);
}
.header-time {
font-size: var(--font-size-sm);
padding: 4px 10px;
}
.cam-btn {
font-size: var(--font-size-sm);
padding: 6px 12px;
}
/* Карточки */
.card {
padding: 12px 14px;
}
.card h3 {
font-size: var(--font-size-base);
}
#status {
font-size: var(--font-size-2xl);
}
/* Прогресс-бары */
.progress {
height: 28px;
padding: 4px;
}
.progress-label {
font-size: var(--font-size-sm);
}
/* Сигнальные иконки */
.signal-icon {
width: 28px;
height: 28px;
}
body { padding: 10px; }
.header-bar { gap: 10px; grid-template-columns: 1fr auto; }
.header-bar h1 { font-size: var(--font-size-xl); }
.header-time { font-size: var(--font-size-sm); padding: 4px 10px; }
.cam-btn { font-size: var(--font-size-sm); padding: 6px 12px; }
.card { padding: 12px 14px; }
.card h3 { font-size: var(--font-size-base); }
#status { font-size: var(--font-size-2xl); }
.progress { height: 28px; padding: 4px; }
.progress-label { font-size: var(--font-size-sm); }
.signal-icon { width: 28px; height: 28px; }
}
/* ============================================
МОБИЛЬНЫЕ УСТРОЙСТВА (до 768px)
============================================ */
/* ===== МОБИЛЬНЫЕ ===== */
@media (max-width: 768px) {
/* ===== ОБЩИЕ ===== */
body {
padding: 8px;
font-size: var(--font-size-base);
overflow-y: auto;
min-height: 100vh;
height: auto;
}
/* Увеличиваем область касания для всех интерактивных элементов */
button,
.cam-btn,
.tab-btn,
.pagination-btn,
.log-file-item {
button, .cam-btn, .tab-btn, .pagination-btn, .log-file-item {
min-height: 44px;
min-width: 44px;
}
/* ===== ХЕДЕР ===== */
.header-bar {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
margin-bottom: 12px;
flex-shrink: 0;
}
.header-bar h1 {
font-size: var(--font-size-lg);
text-align: center;
letter-spacing: 0.05em;
}
.header-bar > div {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
}
.header-time {
font-size: var(--font-size-sm);
padding: 4px 10px;
flex: 1;
min-width: 80px;
}
#audio-indicator {
font-size: var(--font-size-base);
padding: 4px 10px;
min-width: 40px;
}
.cam-btn {
font-size: var(--font-size-sm);
padding: 8px 14px;
@@ -121,8 +65,6 @@
flex: 1;
min-width: 80px;
}
/* Ссылка "Журнал" */
.header-bar a[href="/logs.html"] {
font-size: var(--font-size-sm);
padding: 6px 12px;
@@ -130,438 +72,125 @@
flex: 1;
min-width: 60px;
}
/* ===== МАКЕТ ===== */
.layout {
flex-direction: column;
height: auto;
gap: 8px;
}
.panel {
height: auto;
overflow-y: visible;
}
.left {
flex: 1 1 auto !important;
min-width: 0 !important;
padding: 0;
width: 100% !important;
}
.right {
flex: 0 0 auto !important;
min-width: 100% !important;
width: 100% !important;
height: 40vh;
min-height: 300px;
opacity: 1 !important;
pointer-events: auto !important;
}
.right.hidden {
display: none !important;
flex: 0 0 0 !important;
height: 0 !important;
min-height: 0 !important;
}
.resizer {
display: none !important;
}
/* ===== СЕТКА ===== */
.grid {
grid-template-columns: 1fr;
gap: 8px;
}
.wide {
grid-column: span 1;
}
/* ===== КАРТОЧКИ ===== */
.card {
padding: 12px;
border-radius: 8px;
}
.card h3 {
font-size: var(--font-size-base);
margin-bottom: 8px;
}
#status {
font-size: var(--font-size-2xl);
}
#meta {
font-size: var(--font-size-sm);
}
/* ===== ПРОГРЕСС-БАРЫ ===== */
.progress-row {
margin-bottom: 12px;
}
.progress-label {
font-size: var(--font-size-sm);
margin-bottom: 4px;
}
.progress {
height: 30px;
padding: 4px;
border-width: 2px;
}
.progress-with-signal {
flex-wrap: nowrap;
}
.progress-90 {
flex: 1;
margin-right: 8px;
}
.signal-icon {
width: 30px;
height: 30px;
flex-shrink: 0;
}
.sig-bar {
min-width: 4px;
border-width: 1.5px;
}
/* ===== АККОРДЕОН ===== */
.accordion-header {
padding: 8px 0;
font-size: var(--font-size-base);
}
.accordion-body {
max-height: 400px;
}
#history {
font-size: var(--font-size-sm);
max-height: 300px;
line-height: 1.5;
}
/* ===== КАМЕРА ===== */
.cam-modal {
height: 100%;
min-height: 300px;
}
.cam-header {
padding: 6px 10px;
font-size: var(--font-size-sm);
}
.cam-frame {
object-fit: cover;
}
.crosshair-box {
width: 80px;
height: 80px;
border-width: 1.5px;
}
/* ===== ГОРЯЧИЕ КЛАВИШИ ===== */
.hotkey-hint {
font-size: var(--font-size-xs);
padding: 4px 0;
}
.hotkey-hint kbd {
padding: 2px 6px;
font-size: var(--font-size-xs);
}
.wide { grid-column: span 1; }
.card { padding: 12px; border-radius: 8px; overflow: visible !important; height: auto !important; }
.card h3 { font-size: var(--font-size-base); margin-bottom: 8px; }
#status { font-size: var(--font-size-2xl); }
#meta { font-size: var(--font-size-sm); }
.progress-row { margin-bottom: 12px; }
.progress-label { font-size: var(--font-size-sm); margin-bottom: 4px; }
.progress { height: 30px; padding: 4px; border-width: 2px; }
.progress-with-signal { flex-wrap: nowrap; }
.progress-90 { flex: 1; margin-right: 8px; }
.signal-icon { width: 30px; height: 30px; flex-shrink: 0; }
.sig-bar { min-width: 4px; border-width: 1.5px; }
.accordion-header { padding: 8px 0; font-size: var(--font-size-base); }
.accordion-body { max-height: 400px; overflow-y: auto; -webkit-overflow-scrolling: touch; }
.cam-modal { height: 100%; min-height: 300px; }
.cam-header { padding: 6px 10px; font-size: var(--font-size-sm); }
.cam-frame { object-fit: cover; }
.crosshair-box { width: 80px; height: 80px; border-width: 1.5px; }
.hotkey-hint { font-size: var(--font-size-xs); padding: 4px 0; flex-shrink: 0; }
.hotkey-hint kbd { padding: 2px 6px; font-size: var(--font-size-xs); }
.channels-grid { overscroll-behavior: auto !important; max-height: none !important; overflow-y: visible !important; }
}
/* ============================================
МАЛЕНЬКИЕ ТЕЛЕФОНЫ (до 480px)
============================================ */
/* ===== МАЛЕНЬКИЕ ТЕЛЕФОНЫ ===== */
@media (max-width: 480px) {
body {
padding: 6px;
}
/* Хедер */
.header-bar h1 {
font-size: var(--font-size-base);
}
.header-bar > div {
flex-wrap: wrap;
}
.header-time {
font-size: var(--font-size-xs);
padding: 4px 8px;
min-width: 60px;
}
#audio-indicator {
font-size: var(--font-size-sm);
padding: 4px 8px;
min-width: 35px;
}
.cam-btn {
font-size: var(--font-size-xs);
padding: 6px 10px;
min-width: 60px;
}
/* Карточки */
.card {
padding: 10px;
border-radius: 6px;
}
.card h3 {
font-size: var(--font-size-sm);
}
#status {
font-size: var(--font-size-xl);
}
#meta {
font-size: var(--font-size-xs);
}
/* Прогресс-бары */
.progress {
height: 24px;
padding: 3px;
border-width: 2px;
}
.progress-label {
font-size: var(--font-size-xs);
}
.signal-icon {
width: 24px;
height: 24px;
}
.sig-bar {
min-width: 3px;
border-width: 1px;
}
body { padding: 6px; }
.header-bar h1 { font-size: var(--font-size-base); }
.header-bar > div { flex-wrap: wrap; }
.header-time { font-size: var(--font-size-xs); padding: 4px 8px; min-width: 60px; }
#audio-indicator { font-size: var(--font-size-sm); padding: 4px 8px; min-width: 35px; }
.cam-btn { font-size: var(--font-size-xs); padding: 6px 10px; min-width: 60px; }
.card { padding: 10px; border-radius: 6px; }
.card h3 { font-size: var(--font-size-sm); }
#status { font-size: var(--font-size-xl); }
#meta { font-size: var(--font-size-xs); }
.progress { height: 24px; padding: 3px; border-width: 2px; }
.progress-label { font-size: var(--font-size-xs); }
.signal-icon { width: 24px; height: 24px; }
.sig-bar { min-width: 3px; border-width: 1px; }
.sig-bar-1 { height: 30%; }
.sig-bar-2 { height: 60%; }
.sig-bar-3 { height: 90%; }
/* Камера */
.right {
height: 35vh;
min-height: 200px;
}
.cam-header {
padding: 4px 8px;
font-size: var(--font-size-xs);
}
.crosshair-box {
width: 60px;
height: 60px;
}
/* Аккордеон */
#history {
font-size: var(--font-size-xs);
max-height: 200px;
}
.right { height: 35vh; min-height: 200px; }
.cam-header { padding: 4px 8px; font-size: var(--font-size-xs); }
.crosshair-box { width: 60px; height: 60px; }
#history { font-size: var(--font-size-xs); max-height: 200px; }
}
/* ============================================
ОРИЕНТАЦИЯ (ALBUM / PORTRAIT)
============================================ */
@media (max-width: 768px) and (orientation: landscape) {
.layout {
flex-direction: row;
height: calc(100vh - 80px);
}
.left {
flex: 1 1 60% !important;
min-width: 0 !important;
padding-right: 8px;
overflow-y: auto;
height: 100%;
}
.right {
flex: 0 0 40% !important;
min-width: 0 !important;
height: 100%;
min-height: 0;
}
.grid {
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.wide {
grid-column: span 2;
}
#status {
font-size: var(--font-size-xl);
}
}
/* ============================================
УЛУЧШЕНИЯ ДЛЯ ТАЧ-ИНТЕРФЕЙСА
============================================ */
/* ===== ТАЧ-ИНТЕРФЕЙС ===== */
@media (hover: none) and (pointer: coarse) {
/* Увеличиваем область касания */
button,
.cam-btn,
.tab-btn,
.pagination-btn,
.log-file-item,
.accordion-header,
.pagination-goto-btn {
button, .cam-btn, .tab-btn, .pagination-btn, .log-file-item,
.accordion-header, .pagination-goto-btn {
min-height: 44px;
min-width: 44px;
padding: 8px 16px;
}
/* Убираем hover-эффекты, оставляем active */
.cam-btn:hover,
.tab-btn:hover,
.pagination-btn:hover,
.log-file-item:hover {
.cam-btn:hover, .tab-btn:hover, .pagination-btn:hover, .log-file-item:hover {
transform: none;
}
.cam-btn:active,
.tab-btn:active,
.pagination-btn:active,
.log-file-item:active {
.cam-btn:active, .tab-btn:active, .pagination-btn:active, .log-file-item:active {
transform: scale(0.95);
background: rgba(0, 255, 136, 0.15);
}
/* Увеличиваем отступы для скролла */
.panel,
.logs-content,
.logs-sidebar {
.panel, .logs-content, .logs-sidebar, #history, #dataTableContainer, .accordion-body {
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
overscroll-behavior: contain;
}
/* Увеличиваем размер полей ввода */
.pagination-input {
min-height: 44px;
font-size: var(--font-size-base);
padding: 8px 12px;
}
/* Увеличиваем чекбоксы и переключатели */
input[type="checkbox"],
input[type="radio"] {
width: 20px;
height: 20px;
}
.pagination-input { min-height: 44px; font-size: var(--font-size-base); padding: 8px 12px; }
input[type="checkbox"], input[type="radio"] { width: 20px; height: 20px; }
.header-link, .cam-btn, .cam-close-btn { min-height: 44px !important; min-width: 44px !important; padding: 8px 14px !important; }
.header-link:active, .cam-btn:active { transform: scale(0.95); }
}
/* ============================================
ТЁМНАЯ ТЕМА (опционально)
============================================ */
/* ===== ТЁМНАЯ ТЕМА ===== */
@media (prefers-color-scheme: dark) {
body {
background: #0a0f0a;
}
body { background: #0a0f0a; }
}
/* ============================================
ПЕЧАТЬ
============================================ */
/* ===== ПЕЧАТЬ ===== */
@media print {
.header-bar,
.cam-btn,
.resizer,
.right,
.pagination-container,
.hotkey-hint {
.header-bar, .cam-btn, .resizer, .right, .pagination-container, .hotkey-hint {
display: none !important;
}
.layout {
display: block !important;
height: auto !important;
}
.panel {
height: auto !important;
overflow: visible !important;
}
.card {
break-inside: avoid;
border: 1px solid #00ff88;
}
}
/* ===== ДОПОЛНИТЕЛЬНЫЕ УЛУЧШЕНИЯ ДЛЯ ХЕДЕРА ===== */
@media (max-width: 768px) {
.header-bar {
display: flex !important;
flex-direction: column !important;
align-items: stretch !important;
gap: 8px !important;
}
.header-controls {
display: flex !important;
flex-wrap: wrap !important;
justify-content: center !important;
gap: 6px !important;
}
.layout { display: block !important; height: auto !important; }
.panel { height: auto !important; overflow: visible !important; }
.card { break-inside: avoid; border: 1px solid #00ff88; }
}
@media (max-width: 480px) {
.header-controls {
gap: 4px !important;
}
/* ===== СКРОЛЛБАРЫ ===== */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: rgba(0, 255, 136, 0.05); border-radius: 4px; }
::-webkit-scrollbar-thumb { background: rgba(0, 255, 136, 0.3); border-radius: 4px; transition: background 0.3s ease; }
::-webkit-scrollbar-thumb:hover { background: rgba(0, 255, 136, 0.5); }
* { scrollbar-width: thin; scrollbar-color: rgba(0, 255, 136, 0.3) rgba(0, 255, 136, 0.05); }
.panel::-webkit-scrollbar, .logs-sidebar::-webkit-scrollbar,
.logs-content::-webkit-scrollbar, #history::-webkit-scrollbar,
.accordion-body::-webkit-scrollbar, .channels-grid::-webkit-scrollbar {
width: 6px;
}
/* ===== УЛУЧШЕНИЯ ДЛЯ ТАЧ-ИНТЕРФЕЙСА ===== */
@media (hover: none) and (pointer: coarse) {
.header-link,
.cam-btn,
.cam-close-btn {
min-height: 44px !important;
min-width: 44px !important;
padding: 8px 14px !important;
}
.header-link:active,
.cam-btn:active {
transform: scale(0.95);
}
.panel::-webkit-scrollbar-track, .logs-sidebar::-webkit-scrollbar-track,
.logs-content::-webkit-scrollbar-track, #history::-webkit-scrollbar-track,
.accordion-body::-webkit-scrollbar-track, .channels-grid::-webkit-scrollbar-track {
background: rgba(0, 255, 136, 0.05);
border-radius: 3px;
}
/* ===== КНОПКА ЗАКРЫТИЯ КАМЕРЫ НА МОБИЛЬНЫХ ===== */
@media (max-width: 768px) {
.cam-close-btn {
font-size: var(--font-size-lg);
padding: 6px 14px;
min-height: 44px;
min-width: 44px;
}
.panel::-webkit-scrollbar-thumb, .logs-sidebar::-webkit-scrollbar-thumb,
.logs-content::-webkit-scrollbar-thumb, #history::-webkit-scrollbar-thumb,
.accordion-body::-webkit-scrollbar-thumb, .channels-grid::-webkit-scrollbar-thumb {
background: rgba(0, 255, 136, 0.3);
border-radius: 3px;
}
.panel::-webkit-scrollbar-thumb:hover, .logs-sidebar::-webkit-scrollbar-thumb:hover,
.logs-content::-webkit-scrollbar-thumb:hover, #history::-webkit-scrollbar-thumb:hover,
.accordion-body::-webkit-scrollbar-thumb:hover, .channels-grid::-webkit-scrollbar-thumb:hover {
background: rgba(0, 255, 136, 0.5);
}

View File

@@ -9,11 +9,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>G-Port Панель</title>
<link rel="icon" type="image/png" href="/favicon.png">
<!-- Локальные шрифты -->
<link rel="stylesheet" href="/css/fonts.css">
<!-- ===== СТИЛИ ===== -->
<link rel="stylesheet" href="/css/base.css">
<link rel="stylesheet" href="/css/header.css">
<link rel="stylesheet" href="/css/layout.css">
@@ -23,71 +19,91 @@
<link rel="stylesheet" href="/css/audio.css">
<link rel="stylesheet" href="/css/responsive.css">
</head>
<body>
<div class="header-bar">
<h1>G-Port Warden</h1>
<div class="header-controls">
<a href="/logs.html" class="header-link">Журнал</a>
<!-- Аудио индикатор с регулятором -->
<div class="audio-wrapper" id="audioWrapper">
<div id="audio-indicator" class="audio-indicator">🔇</div>
<!-- Регулятор громкости -->
<div class="volume-control" id="volumeControl">
<div class="volume-slider-wrapper">
<input type="range" id="volumeSlider" class="volume-slider"
min="0" max="100" value="0" step="1">
<div class="volume-track">
<div class="volume-track-fill" id="volumeTrackFill"></div>
</div>
</div>
</div>
</div>
<div id="server-time" class="header-time">--:--:--</div>
<div id="audio-indicator" class="audio-indicator">🔇</div>
<button id="camToggle" class="cam-btn">КАМЕРА</button>
</div>
</div>
<!-- Остальной код без изменений -->
<div class="layout">
<!-- LEFT: графики / дашборд -->
<!-- LEFT -->
<div class="panel left" id="leftPanel">
<div class="grid">
<div class="card wide">
<!-- Карточка состояния -->
<div class="card wide" style="flex-shrink: 0;">
<h3>СОСТОЯНИЕ</h3>
<div id="status">...</div>
<div id="meta"></div>
</div>
<div class="card wide">
<h3>КАНАЛ</h3>
<div class="progress-row">
<div class="progress-label">
Канал 1 — <span id="last-byte">0</span>
</div>
<div class="progress-with-signal">
<div class="progress progress-90">
<div class="fill" id="bar1"></div>
</div>
<div class="signal-icon" id="signal-icon" aria-label="Уровень сигнала" role="img">
<div class="sig-bar sig-bar-1"></div>
<div class="sig-bar sig-bar-2"></div>
<div class="sig-bar sig-bar-3"></div>
</div>
<!-- Карточка каналов (8 штук) -->
<div class="card wide channels-card">
<div class="channels-header">
<h3>КАНАЛЫ СИГНАЛОВ</h3>
<div class="channels-controls">
<button class="channels-toggle-all" id="expandAllBtn"></button>
<button class="channels-toggle-all" id="collapseAllBtn"></button>
</div>
</div>
<div class="progress-row">
<div class="progress-label">🔊 Звук</div>
<div class="progress">
<div class="fill" id="bar2"></div>
</div>
</div>
<div class="progress-row">
<div class="progress-label">📐 Высота</div>
<div class="progress">
<div class="fill" id="bar3"></div>
</div>
<div class="channels-grid" id="channelsContainer">
<!-- Каналы генерируются JS -->
</div>
</div>
<!-- Карточка со звуком -->
<div class="card wide">
<h3>🔊 ЗВУК</h3>
<div class="progress-row">
<div class="progress-label">Уровень звука</div>
<div class="progress">
<div class="fill" id="bar2" style="width:0%"></div>
</div>
</div>
<div style="font-family:var(--font-tech);font-size:var(--font-size-sm);color:#666;margin-top:4px;">
<span id="sound-level">0</span> / 100
</div>
</div>
<!-- Карточка с высотой -->
<div class="card wide">
<h3>📐 ВЫСОТА</h3>
<div class="progress-row">
<div class="progress-label">Уровень высоты</div>
<div class="progress">
<div class="fill" id="bar3" style="width:0%"></div>
</div>
</div>
<div style="font-family:var(--font-tech);font-size:var(--font-size-sm);color:#666;margin-top:4px;">
<span id="height-level">0</span> / 100
</div>
</div>
<!-- Карточка истории -->
<div class="card wide history-card">
<h3 class="accordion-header" id="history-header">▶ ПРИНЯТЫЕ ДАННЫЕ</h3>
<div class="accordion-body" id="history-body">
<div id="history"></div>
</div>
</div>
</div>
</div>
@@ -103,8 +119,6 @@
</div>
<div class="cam-frame-wrapper">
<img id="camImage" src="" alt="Camera" class="cam-frame">
<!-- ПЕРЕКРЕСТИЕ -->
<div class="cam-crosshair" id="crosshair">
<div class="crosshair-v"></div>
<div class="crosshair-h"></div>
@@ -113,15 +127,12 @@
</div>
</div>
</div>
</div>
<!-- Подсказка по горячим клавишам -->
<div class="hotkey-hint">
(ENG lang key) <kbd>C</kbd> — камера &nbsp;|&nbsp; <kbd>M</kbd> — звук &nbsp;|&nbsp; <kbd>Esc</kbd>сброс
</div>
<script type="module" src="/dist/app.js"></script>
</body>
</html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,25 +1,29 @@
// app.js (оркестратор)
// web/src/app.ts - Оркестратор приложения
import {
fetchHealth,
fetchHistory
fetchHealth,
fetchHistory
} from "./data.js";
import {
state,
addSignals,
resetBufferTracking,
updatePeakHolder,
updateSignalPeakHolder,
setPeakHolderListener
state,
addSignals,
resetBufferTracking,
updatePeakHolder,
updateSignalPeakHolder,
setPeakHolderListener
} from "./state.js";
import {
initDOM,
setServerTime,
setStatus,
setMeta,
setBars,
setSignal,
setAudioIndicator,
DOM
initDOM,
setServerTime,
setStatus,
setMeta,
setBars,
setSignal,
setAudioIndicator,
DOM,
setChannelValue,
setChannelCurrentValue,
channelElements
} from "./ui.js";
import { initAccordion } from "./accordion.js";
import { drawChart } from "./chart.js";
@@ -34,237 +38,393 @@ const MAX_ERRORS: number = 3;
let pollTimer: number | null = null;
let lastSignalLevel: number = 0;
// ===== ИНИЦИАЛИЗАЦИЯ РЕГУЛЯТОРА ГРОМКОСТИ =====
function initVolumeControl(): void {
const slider = document.getElementById('volumeSlider') as HTMLInputElement | null;
const trackFill = document.getElementById('volumeTrackFill') as HTMLElement | null;
const indicator = document.getElementById('audio-indicator');
const wrapper = document.getElementById('audioWrapper');
if (!slider || !trackFill || !indicator) return;
// Функция обновления заливки
function updateTrackFill(volume: number): void {
if (trackFill) {
trackFill.style.width = `${volume}%`;
}
}
// Функция обновления индикатора в зависимости от громкости и уровня сигнала
function updateIndicator(volume: number, signalLevel: number): void {
if (!indicator) return;
if (volume === 0) {
indicator.textContent = '🔇';
indicator.classList.add('muted');
indicator.classList.remove('active');
} else if (signalLevel > 0) {
indicator.textContent = '🔊';
indicator.classList.remove('muted');
indicator.classList.add('active');
} else {
indicator.textContent = '🔇';
indicator.classList.add('muted');
indicator.classList.remove('active');
}
}
// Устанавливаем начальное состояние (звук выключен)
audioEngine.setVolume(0);
updateTrackFill(0);
lastSignalLevel = 0;
updateIndicator(0, 0);
// Обработчик изменения громкости
slider.addEventListener('input', () => {
const volume = parseInt(slider.value);
updateTrackFill(volume);
audioEngine.setVolume(volume);
if (volume === 0) {
// Громкость 0 - звук выключен
updateIndicator(0, 0);
if (lastSignalLevel > 0) {
audioEngine.updateLevel(0);
}
} else {
// Громкость > 0 - показываем состояние сигнала
updateIndicator(volume, lastSignalLevel);
if (lastSignalLevel > 0) {
audioEngine.updateLevel(lastSignalLevel);
}
}
});
// Для тач-устройств
if (wrapper) {
wrapper.addEventListener('click', (e) => {
const control = document.getElementById('volumeControl');
if (control && window.innerWidth <= 768) {
control.classList.toggle('visible');
e.stopPropagation();
}
});
document.addEventListener('click', (e) => {
const control = document.getElementById('volumeControl');
if (control && window.innerWidth <= 768) {
if (!wrapper.contains(e.target as Node)) {
control.classList.remove('visible');
}
}
});
}
// Сохраняем настройки
const savedVolume = localStorage.getItem('gport_volume');
if (savedVolume) {
const vol = parseInt(savedVolume);
if (vol >= 0 && vol <= 100) {
slider.value = String(vol);
slider.dispatchEvent(new Event('input'));
}
}
slider.addEventListener('change', () => {
localStorage.setItem('gport_volume', slider.value);
});
}
// ================= INIT =================
window.addEventListener("DOMContentLoaded", () => {
initDOM();
initAccordion();
initResize();
initCamera();
initDOM();
initAccordion();
initResize();
initCamera();
initVolumeControl();
// Инициализация звука при первом клике
document.addEventListener('click', () => {
audioEngine.updateLevel(0);
}, { once: true });
document.addEventListener('click', () => {
audioEngine.updateLevel(0);
}, { once: true });
setPeakHolderListener(() => {
if (!connectionLost) {
setBars(state.peakValue);
setSignal(state.peakSignalLevel);
setPeakHolderListener(() => {
if (!connectionLost) {
setBars(state.peakValue);
setSignal(state.peakSignalLevel);
const signalLevel = state.peakSignalLevel;
if (signalLevel !== lastSignalLevel) {
lastSignalLevel = signalLevel;
audioEngine.updateLevel(signalLevel);
const signalLevel = state.peakSignalLevel;
const currentVolume = audioEngine.getVolume();
// Обновляем индикатор звука
const isActive = signalLevel > 0;
setAudioIndicator(isActive, signalLevel);
}
}
});
if (signalLevel !== lastSignalLevel) {
lastSignalLevel = signalLevel;
// Обновляем индикатор в зависимости от громкости и уровня сигнала
const indicator = document.getElementById('audio-indicator');
if (indicator) {
if (currentVolume > 0 && signalLevel > 0) {
indicator.textContent = '🔊';
indicator.classList.remove('muted');
indicator.classList.add('active');
audioEngine.updateLevel(signalLevel);
} else if (currentVolume > 0 && signalLevel === 0) {
indicator.textContent = '🔇';
indicator.classList.add('muted');
indicator.classList.remove('active');
audioEngine.updateLevel(0);
} else {
indicator.textContent = '🔇';
indicator.classList.add('muted');
indicator.classList.remove('active');
audioEngine.updateLevel(0);
}
}
setAudioIndicator(signalLevel > 0 && currentVolume > 0, signalLevel);
}
}
});
});
document.addEventListener('DOMContentLoaded', () => {
// Обработчик для кнопки закрытия камеры
const closeBtn = document.getElementById('camCloseBtn');
const toggleBtn = document.getElementById('camToggle');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
if (toggleBtn) {
toggleBtn.click(); // Эмулируем нажатие кнопки переключения
toggleBtn.click();
}
});
}
});
document.addEventListener("keydown", (e: KeyboardEvent) => {
const leftPanel = document.getElementById("leftPanel");
const rightPanel = document.getElementById("rightPanel");
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;
case 'm':
// Toggle звука
if (lastSignalLevel > 0) {
// Временно отключаем
audioEngine.updateLevel(0);
setAudioIndicator(false);
console.log('[Audio] Звук отключён (M)');
// Сохраняем уровень для восстановления
window._mutedLevel = lastSignalLevel;
lastSignalLevel = 0;
} else {
// Восстанавливаем уровень
const level = window._mutedLevel || state.peakSignalLevel;
if (level > 0) {
audioEngine.updateLevel(level);
lastSignalLevel = level;
setAudioIndicator(true, level);
console.log('[Audio] Звук включён (M)');
}
}
break;
}
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;
case 'm':
if (lastSignalLevel > 0 && audioEngine.getVolume() > 0) {
audioEngine.updateLevel(0);
setAudioIndicator(false);
window._mutedLevel = lastSignalLevel;
lastSignalLevel = 0;
} else {
const level = window._mutedLevel || state.peakSignalLevel;
if (level > 0 && audioEngine.getVolume() > 0) {
audioEngine.updateLevel(level);
lastSignalLevel = level;
setAudioIndicator(true, level);
}
}
break;
}
});
// ================= UPDATE =================
async function update(): Promise<void> {
try {
const [health, history] = await Promise.all([
fetchHealth(),
fetchHistory()
]);
try {
const [health, history] = await Promise.all([
fetchHealth(),
fetchHistory()
]);
consecutiveErrors = 0;
consecutiveErrors = 0;
if (health.server_time) {
setServerTime(health.server_time);
}
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;
if (lastSignalLevel > 0) {
lastSignalLevel = 0;
audioEngine.updateLevel(0);
setAudioIndicator(false);
}
}
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) {
const displayValue = updatePeakHolder(percent);
setBars(displayValue);
const displaySignalLevel = updateSignalPeakHolder(level);
setSignal(displaySignalLevel);
const currentSignalLevel = state.peakSignalLevel;
if (currentSignalLevel !== lastSignalLevel) {
lastSignalLevel = currentSignalLevel;
audioEngine.updateLevel(currentSignalLevel);
setAudioIndicator(currentSignalLevel > 0, currentSignalLevel);
if (health.server_time) {
setServerTime(health.server_time);
}
} else {
const isOnline = health.pipe_alive && health.has_data;
if (isOnline) {
setStatus("На связи");
connectionLost = false;
} else {
setStatus("Нет связи", true);
if (!connectionLost) resetBufferTracking();
connectionLost = true;
if (lastSignalLevel > 0) {
lastSignalLevel = 0;
audioEngine.updateLevel(0);
setAudioIndicator(false);
}
}
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);
}
// ===== НАХОДИМ МАКСИМУМ ИЗ БУФЕРА =====
let maxSignal = 0;
let maxLevel = 0;
let currentSignal = 0;
let currentLevel = 0;
if (isOnline && arr.length > 0) {
for (const byte of arr) {
const signal = byte & 0x3F;
const amp = (byte >> 6) & 0x3;
if (signal > maxSignal) {
maxSignal = signal;
}
if (amp > maxLevel) {
maxLevel = amp;
}
}
currentSignal = raw & 0x3F;
currentLevel = (raw >> 6) & 0x3;
}
const percent = (maxSignal / 63) * 100;
// ===== ОБНОВЛЯЕМ КАНАЛЫ =====
if (isOnline && arr.length > 0) {
setChannelValue(0, maxSignal, maxLevel);
setChannelCurrentValue(0, currentSignal, currentLevel);
for (let i = 1; i < 8; i++) {
setChannelValue(i, 0, 0);
setChannelCurrentValue(i, 0, 0);
if (channelElements[i]) {
channelElements[i].item.classList.remove('channel-active');
}
}
if (channelElements[0]) {
channelElements[0].item.classList.add('channel-active');
}
if (DOM.lastByte) {
DOM.lastByte.textContent = currentSignal.toString();
}
} else {
for (let i = 0; i < 8; i++) {
setChannelValue(i, 0, 0);
setChannelCurrentValue(i, 0, 0);
if (channelElements[i]) {
channelElements[i].item.classList.remove('channel-active');
}
}
if (DOM.lastByte) {
DOM.lastByte.textContent = '--';
}
}
if (isOnline) {
updatePeakHolder(percent);
updateSignalPeakHolder(maxLevel);
} else {
setBars(0, true);
setSignal(0, true);
if (lastSignalLevel > 0) {
lastSignalLevel = 0;
audioEngine.updateLevel(0);
setAudioIndicator(false);
}
}
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) {
if (arr.length === 0) {
DOM.history.innerHTML = 'Нет данных';
} else {
const reversed = [...arr].reverse();
DOM.history.innerHTML = reversed
.map((b, i) => {
const originalIndex = arr.length - 1 - i;
const binary = (b & 0xFF).toString(2).padStart(8, "0");
return `[${originalIndex}] ${binary} = ${b & 0xFF}`;
})
.join("<br>");
}
}
const now = performance.now();
if (now - lastChart > 100 && DOM.canvas) {
drawChart(DOM.canvas, state.signalHistory);
lastChart = now;
}
} catch (e) {
consecutiveErrors++;
setStatus("СЕРВЕР НЕДОСТУПЕН", true);
setServerTime("--:--:--");
setBars(0, true);
setSignal(0, true);
resetBufferTracking();
if (lastSignalLevel > 0) {
lastSignalLevel = 0;
audioEngine.updateLevel(0);
setAudioIndicator(false);
lastSignalLevel = 0;
audioEngine.updateLevel(0);
setAudioIndicator(false);
}
if (DOM.lastByte) DOM.lastByte.textContent = "--";
if (DOM.history) {
DOM.history.innerHTML = "Ошибка загрузки данных";
}
if (consecutiveErrors >= MAX_ERRORS && pollTimer) {
clearInterval(pollTimer);
}
}
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);
setServerTime("--:--:--");
setBars(0, true);
setSignal(0, true);
resetBufferTracking();
if (lastSignalLevel > 0) {
lastSignalLevel = 0;
audioEngine.updateLevel(0);
setAudioIndicator(false);
}
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);
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();
}
if (consecutiveErrors >= MAX_ERRORS) {
consecutiveErrors = 0;
startPolling();
}
});
window.addEventListener("beforeunload", () => {
audioEngine.dispose();
audioEngine.dispose();
});
declare global {
interface Window {
_mutedLevel?: number;
}
interface Window {
_mutedLevel?: number;
}
}
startPolling();

View File

@@ -1,4 +1,4 @@
// audio.ts - Звуковой движок для сигнала
// web/src/audio.ts - Звуковой движок для сигнала
export class AudioEngine {
private audioContext: AudioContext | null = null;
@@ -8,20 +8,73 @@ export class AudioEngine {
private currentLevel: number = 0;
private currentFrequency: number = 800;
private fadeTimeout: number | null = null;
private readonly FADE_TIME: number = 0.05; // 50 мс для плавного старта/остановки
private readonly FADE_TIME: number = 0.05;
private readonly FREQUENCY_MAP: Record<number, number> = {
1: 800, // Уровень 1 → 800 Гц
1: 600, // Уровень 1 → 600 Гц
2: 1000, // Уровень 2 → 1000 Гц
3: 1200 // Уровень 3 → 1200 Гц
};
// ===== НОВОЕ: громкость (0-100) =====
private volume: number = 0;
private maxVolume: number = 0.45; // 45% максимум
constructor() {
// Инициализация при первом использовании
}
/**
* Инициализация AudioContext (требует взаимодействия пользователя)
* Установка громкости (0-100)
*/
public setVolume(volume: number): void {
this.volume = Math.max(0, Math.min(100, volume));
this.updateVolume();
}
/**
* Получение текущей громкости (0-100)
*/
public getVolume(): number {
return this.volume;
}
/**
* Применение громкости к усилителю
*/
private updateVolume(): void {
if (!this.gainNode) return;
const normalized = (this.volume / 100) * this.maxVolume;
const currentLevel = this.currentLevel;
// Если уровень 0 или громкость 0 - выключаем звук
if (currentLevel === 0 || this.volume === 0) {
this.gainNode.gain.value = 0;
return;
}
// Рассчитываем громкость на основе уровня и общей громкости
const volumeMap: Record<number, number> = {
1: 0.10 * (normalized / this.maxVolume),
2: 0.25 * (normalized / this.maxVolume),
3: 0.45 * (normalized / this.maxVolume)
};
const targetVolume = Math.min(volumeMap[currentLevel] || 0, this.maxVolume * (this.volume / 100));
try {
const currentTime = this.audioContext?.currentTime || 0;
this.gainNode.gain.exponentialRampToValueAtTime(
Math.max(targetVolume, 0.001),
currentTime + this.FADE_TIME
);
} catch (error) {
this.gainNode.gain.value = targetVolume;
}
}
// ===== ОСТАЛЬНЫЕ МЕТОДЫ =====
private async initAudio(): Promise<boolean> {
try {
if (!this.audioContext) {
@@ -57,16 +110,13 @@ export class AudioEngine {
if (!this.oscillator) return;
try {
// Плавное изменение частоты за 50 мс
const currentTime = this.audioContext?.currentTime || 0;
this.oscillator.frequency.exponentialRampToValueAtTime(
frequency,
currentTime + this.FADE_TIME
);
this.currentFrequency = frequency;
console.log(`[Audio] Частота: ${frequency} Гц`);
} catch (error) {
// Если не удалось плавно, устанавливаем сразу
this.oscillator.frequency.value = frequency;
this.currentFrequency = frequency;
}
@@ -78,30 +128,26 @@ export class AudioEngine {
private createSoundGraph(level: number): void {
if (!this.audioContext) return;
// Останавливаем старый звук
this.stopSound();
const frequency = this.getFrequencyForLevel(level);
this.currentFrequency = frequency;
// Создаём осциллятор
this.oscillator = this.audioContext.createOscillator();
this.oscillator.type = 'sine';
this.oscillator.frequency.value = frequency;
// Создаём усилитель
this.gainNode = this.audioContext.createGain();
this.gainNode.gain.value = 0; // Начинаем с тишины
this.gainNode.gain.value = 0;
// Подключаем: осциллятор → усилитель → выход
this.oscillator.connect(this.gainNode);
this.gainNode.connect(this.audioContext.destination);
// Запускаем осциллятор
this.oscillator.start();
this.isPlaying = true;
console.log(`[Audio] Звук создан, частота: ${frequency} Гц, уровень: ${level}`);
// Применяем текущую громкость
this.updateVolume();
}
/**
@@ -109,18 +155,14 @@ export class AudioEngine {
* @param level - уровень сигнала (0-3)
*/
public async updateLevel(level: number): Promise<void> {
// Инициализация при первом вызове
if (!this.audioContext) {
const initialized = await this.initAudio();
if (!initialized) {
return;
}
if (!initialized) return;
}
const context = this.audioContext;
if (!context) return;
// Если AudioContext не готов, пробуем восстановить
if (context.state !== 'running') {
try {
await context.resume();
@@ -130,83 +172,42 @@ export class AudioEngine {
}
}
// Приводим уровень к числу
const newLevel = Math.max(0, Math.min(3, Math.round(level)));
// Если уровень не изменился — ничего не делаем
if (newLevel === this.currentLevel) {
return;
}
const oldLevel = this.currentLevel;
this.currentLevel = newLevel;
// Получаем частоту для нового уровня
const newFrequency = this.getFrequencyForLevel(newLevel);
// Если уровень > 0 и звук не создан — создаём
if (newLevel > 0 && !this.oscillator) {
this.createSoundGraph(newLevel);
// Устанавливаем громкость после создания
this.setVolume(newLevel);
// Если громкость 0 - ничего не включаем
if (this.volume === 0) {
this.stopSound();
return;
}
const newFrequency = this.getFrequencyForLevel(newLevel);
if (newLevel > 0 && !this.oscillator) {
this.createSoundGraph(newLevel);
return;
}
// Если уровень 0 — выключаем звук
if (newLevel === 0) {
this.fadeOut();
return;
}
// Если звук не играет — включаем заново
if (!this.isPlaying || !this.oscillator) {
this.createSoundGraph(newLevel);
this.setVolume(newLevel);
return;
}
// Изменяем частоту, если она изменилась
if (newFrequency !== this.currentFrequency) {
this.updateFrequency(newFrequency);
}
// Обновляем громкость
this.setVolume(newLevel);
console.log(`[Audio] Уровень: ${oldLevel}${newLevel}, Частота: ${newFrequency} Гц`);
}
/**
* Установка громкости для уровня
*/
private setVolume(level: number): void {
if (!this.gainNode || !this.audioContext) return;
// Расчёт громкости: уровень 1 = 10%, 2 = 25%, 3 = 45%
const volumeMap: Record<number, number> = {
1: 0.10,
2: 0.25,
3: 0.45
};
const targetVolume = volumeMap[level] || 0;
try {
const currentGain = this.gainNode.gain.value;
// Если звук выключен или очень тихий, включаем сразу
if (currentGain < 0.01) {
this.gainNode.gain.setValueAtTime(targetVolume, this.audioContext.currentTime);
} else {
// Плавное изменение за 50 мс
this.gainNode.gain.exponentialRampToValueAtTime(
Math.max(targetVolume, 0.001),
this.audioContext.currentTime + this.FADE_TIME
);
}
} catch (error) {
// fallback
this.gainNode.gain.value = targetVolume;
}
this.updateVolume();
}
/**
@@ -215,28 +216,22 @@ export class AudioEngine {
private fadeOut(): void {
if (!this.gainNode || !this.audioContext) return;
// Отменяем старый таймер
if (this.fadeTimeout) {
clearTimeout(this.fadeTimeout);
this.fadeTimeout = null;
}
try {
// Плавно убавляем громкость за 100 мс
const currentTime = this.audioContext.currentTime;
this.gainNode.gain.exponentialRampToValueAtTime(0.001, currentTime + this.FADE_TIME * 2);
} catch (error) {
// fallback
this.gainNode.gain.value = 0;
}
// Останавливаем звук после затухания
this.fadeTimeout = window.setTimeout(() => {
this.stopSound();
this.fadeTimeout = null;
}, this.FADE_TIME * 2 * 1000 + 50);
console.log('[Audio] Затухание...');
}
/**
@@ -247,18 +242,14 @@ export class AudioEngine {
try {
this.oscillator.stop();
this.oscillator.disconnect();
} catch (e) {
// Игнорируем ошибки при остановке
}
} catch (e) {}
this.oscillator = null;
}
if (this.gainNode) {
try {
this.gainNode.disconnect();
} catch (e) {
// Игнорируем ошибки
}
} catch (e) {}
this.gainNode = null;
}
@@ -268,8 +259,6 @@ export class AudioEngine {
clearTimeout(this.fadeTimeout);
this.fadeTimeout = null;
}
console.log('[Audio] Звук остановлен');
}
// ============================================
@@ -279,15 +268,13 @@ export class AudioEngine {
/**
* Проверка, активен ли звук
*/
// noinspection JSUnusedGlobalSymbols
public isActive(): boolean {
return this.isPlaying && this.currentLevel > 0;
return this.isPlaying && this.currentLevel > 0 && this.volume > 0;
}
/**
* Получение текущей частоты
*/
// noinspection JSUnusedGlobalSymbols
public getCurrentFrequency(): number {
return this.currentFrequency;
}
@@ -295,7 +282,6 @@ export class AudioEngine {
/**
* Получение текущего уровня
*/
// noinspection JSUnusedGlobalSymbols
public getCurrentLevel(): number {
return this.currentLevel;
}
@@ -303,23 +289,19 @@ export class AudioEngine {
/**
* Полная остановка и освобождение ресурсов
*/
// noinspection JSUnusedGlobalSymbols
public async dispose(): Promise<void> {
this.stopSound();
if (this.audioContext) {
try {
await this.audioContext.close();
} catch (e) {
// Игнорируем
}
} catch (e) {}
this.audioContext = null;
}
this.currentLevel = 0;
this.currentFrequency = 800;
this.volume = 0;
console.log('[Audio] Ресурсы освобождены');
}
}
// noinspection JSUnusedGlobalSymbols
export const audioEngine = new AudioEngine();

View File

@@ -556,13 +556,13 @@ function initDataPaginationControls(): void {
<div class="pagination-controls">
<button id="dataFirstPage" class="pagination-btn" title="Первая страница (самые новые)">⏮</button>
<button id="dataPrevPage" class="pagination-btn" title="Предыдущая страница (более новые)">◀</button>
<span id="dataPageInfo" class="pagination-info">Страница 1 из 1</span>
<button id="dataNextPage" class="pagination-btn" title="Следующая страница (более старые)">▶</button>
<button id="dataLastPage" class="pagination-btn" title="Последняя страница (самые старые)">⏭</button>
<span class="pagination-goto">
<input type="number" id="dataPageInput" min="1" value="1" class="pagination-input">
<button id="dataGoToPage" class="pagination-btn pagination-goto-btn">Перейти</button>
</span>
<span id="dataPageInfo" class="pagination-info">Страница 1 из 1</span>
</div>
`;
@@ -618,13 +618,13 @@ function initEventsPaginationControls(): void {
<div class="pagination-controls">
<button id="firstPage" class="pagination-btn" title="Первая страница (самые новые)">⏮</button>
<button id="prevPage" class="pagination-btn" title="Предыдущая страница (более новые)">◀</button>
<span id="pageInfo" class="pagination-info">Страница 1 из 1</span>
<button id="nextPage" class="pagination-btn" title="Следующая страница (более старые)">▶</button>
<button id="lastPage" class="pagination-btn" title="Последняя страница (самые старые)">⏭</button>
<span class="pagination-goto">
<input type="number" id="pageInput" min="1" value="1" class="pagination-input">
<button id="goToPage" class="pagination-btn pagination-goto-btn">Перейти</button>
</span>
<span id="pageInfo" class="pagination-info">Страница 1 из 1</span>
</div>
`;

View File

@@ -1,23 +1,26 @@
// state.ts - Peak Holder алгоритм с таймером
// state.ts - Peak Holder с фиксированным временем удержания
interface AppState {
maxPoints: number;
signalHistory: number[];
lastBufferSize: number;
// Peak Holder
// Peak Holder для амплитуды
peakValue: number; // Текущее удерживаемое пиковое значение амплитуды
currentValue: number; // Текущее реальное значение амплитуды
currentValue: number; // Текущий максимум из буфера
peakTimer: number | null; // Таймер для сброса пика амплитуды
peakHoldTime: number; // Время удержания пика в миллисекундах
peakLocked: boolean; // Заблокирован ли пик (не сбрасывается до таймера)
// Peak Holder для уровня сигнала
peakSignalLevel: number;
currentSignalLevel: number;
signalPeakTimer: number | null;
signalPeakHoldTime: number;
signalPeakLocked: boolean;
hasPeak: boolean;
lastMaxSignal: number;
}
type PeakHolderListener = () => void;
@@ -30,14 +33,17 @@ export const state: AppState = {
peakValue: 0,
currentValue: 0,
peakTimer: null,
peakHoldTime: 3000, // 3 секунды
peakHoldTime: 3000,
peakLocked: false,
peakSignalLevel: 0,
currentSignalLevel: 0,
signalPeakTimer: null,
signalPeakHoldTime: 3000,
signalPeakLocked: false,
hasPeak: false,
lastMaxSignal: 0,
};
let peakHolderListener: PeakHolderListener | null = null;
@@ -46,20 +52,111 @@ let peakHolderListener: PeakHolderListener | null = null;
// ПУБЛИЧНЫЕ ФУНКЦИИ
// ============================================
export function setPeakHolderListener(listener: PeakHolderListener | null): void {
peakHolderListener = listener;
}
function notifyPeakHolderChange(): void {
if (peakHolderListener) {
peakHolderListener();
}
}
/**
* Установка времени удержания пика сигнала
* @param ms - время в миллисекундах
* Обновление пикового значения амплитуды
* @param currentMax - текущий максимум из буфера (в процентах 0-100)
* @returns пиковое значение
*/
// noinspection JSUnusedGlobalSymbols
export function updatePeakHolder(currentMax: number): number {
state.currentValue = currentMax;
state.lastMaxSignal = currentMax;
// Если пик заблокирован - ничего не меняем, только обновляем currentValue
if (state.peakLocked) {
return state.peakValue;
}
// Если новое значение БОЛЬШЕ текущего пика - обновляем пик и блокируем
if (currentMax > state.peakValue) {
state.peakValue = currentMax;
state.hasPeak = true;
state.peakLocked = true;
scheduleAmplitudePeakRelease();
notifyPeakHolderChange();
return state.peakValue;
}
// Если новое значение МЕНЬШЕ или РАВНО - ничего не делаем
return state.peakValue;
}
/**
* Обновление пикового уровня сигнала
* @param currentMaxLevel - текущий максимум уровня из буфера (0-3)
* @returns пиковый уровень
*/
export function updateSignalPeakHolder(currentMaxLevel: number): number {
state.currentSignalLevel = currentMaxLevel;
if (state.signalPeakLocked) {
return state.peakSignalLevel;
}
if (currentMaxLevel > state.peakSignalLevel) {
state.peakSignalLevel = currentMaxLevel;
state.hasPeak = true;
state.signalPeakLocked = true;
scheduleSignalPeakRelease();
notifyPeakHolderChange();
return state.peakSignalLevel;
}
return state.peakSignalLevel;
}
// ============================================
// ВНУТРЕННИЕ ФУНКЦИИ
// ============================================
function scheduleAmplitudePeakRelease(): void {
if (state.peakTimer !== null) {
clearTimeout(state.peakTimer);
state.peakTimer = null;
}
state.peakTimer = window.setTimeout(() => {
// Сбрасываем пик до текущего значения
state.peakValue = state.currentValue;
state.peakTimer = null;
state.hasPeak = false;
state.peakLocked = false;
notifyPeakHolderChange();
}, state.peakHoldTime);
}
function scheduleSignalPeakRelease(): void {
if (state.signalPeakTimer !== null) {
clearTimeout(state.signalPeakTimer);
state.signalPeakTimer = null;
}
state.signalPeakTimer = window.setTimeout(() => {
state.peakSignalLevel = state.currentSignalLevel;
state.signalPeakTimer = null;
state.hasPeak = false;
state.signalPeakLocked = false;
notifyPeakHolderChange();
}, state.signalPeakHoldTime);
}
// ============================================
// ОСТАЛЬНЫЕ ФУНКЦИИ
// ============================================
export function setSignalPeakHoldTime(ms: number): void {
state.signalPeakHoldTime = ms;
}
/**
* Получение информации о пике сигнала
* @returns { peak: number; current: number; timerActive: boolean }
*/
// noinspection JSUnusedGlobalSymbols
export function getSignalPeakInfo(): { peak: number; current: number; timerActive: boolean } {
return {
peak: state.peakSignalLevel,
@@ -68,11 +165,6 @@ export function getSignalPeakInfo(): { peak: number; current: number; timerActiv
};
}
/**
* Добавление нового значения сигнала в историю
* @param value - значение сигнала
*/
// noinspection JSUnusedGlobalSymbols
export function addSignal(value: number): void {
state.signalHistory.push(value);
if (state.signalHistory.length > state.maxPoints) {
@@ -80,65 +172,6 @@ export function addSignal(value: number): void {
}
}
/**
* Установка слушателя для обновления пика
* @param listener - функция-слушатель
*/
// noinspection JSUnusedGlobalSymbols
export function setPeakHolderListener(listener: PeakHolderListener | null): void {
peakHolderListener = listener;
}
/**
* Обновление пикового значения амплитуды
* @param target - текущее значение
* @returns пиковое значение
*/
// noinspection JSUnusedGlobalSymbols
export function updatePeakHolder(target: number): number {
// Обновляем текущее значение
state.currentValue = target;
// Если новое значение больше текущего пика
if (target > state.peakValue) {
// Устанавливаем новый пик
state.peakValue = target;
state.hasPeak = true;
scheduleAmplitudePeakRelease();
} else if (target < state.peakValue && state.peakTimer === null) {
// Пик выше текущего значения, но таймер пропал — перезапускаем удержание
scheduleAmplitudePeakRelease();
} // Возвращаем текущий пик
return state.peakValue;
}
/**
* Обновление пикового уровня сигнала
* @param level - текущий уровень
* @returns пиковый уровень
*/
// noinspection JSUnusedGlobalSymbols
export function updateSignalPeakHolder(level: number): number {
// Обновляем текущее значение
state.currentSignalLevel = level;
// Если новый уровень больше пика — устанавливаем новый пик
if (level > state.peakSignalLevel) {
state.peakSignalLevel = level;
state.hasPeak = true;
scheduleSignalPeakRelease();
} else if (level < state.peakSignalLevel && state.signalPeakTimer === null) {
scheduleSignalPeakRelease();
}
// Возвращаем текущий пик
return state.peakSignalLevel;
}
/**
* Принудительный сброс пика сигнала
*/
// noinspection JSUnusedGlobalSymbols
export function forceResetSignalPeak(): void {
if (state.signalPeakTimer !== null) {
clearTimeout(state.signalPeakTimer);
@@ -147,12 +180,11 @@ export function forceResetSignalPeak(): void {
state.peakSignalLevel = 0;
state.currentSignalLevel = 0;
state.hasPeak = false;
state.lastMaxSignal = 0;
state.signalPeakLocked = false;
notifyPeakHolderChange();
}
/**
* Принудительный сброс всех пиков
*/
// noinspection JSUnusedGlobalSymbols
export function forceResetPeak(): void {
if (state.peakTimer !== null) {
clearTimeout(state.peakTimer);
@@ -161,15 +193,12 @@ export function forceResetPeak(): void {
state.peakValue = 0;
state.currentValue = 0;
state.hasPeak = false;
state.lastMaxSignal = 0;
state.peakLocked = false;
forceResetSignalPeak();
notifyPeakHolderChange();
}
/**
* Добавление массива значений сигнала
* @param values - массив значений
* @param bufferSize - размер буфера
*/
// noinspection JSUnusedGlobalSymbols
export function addSignals(values: number[], bufferSize: number): void {
if (!Array.isArray(values) || values.length === 0) return;
@@ -188,56 +217,7 @@ export function addSignals(values: number[], bufferSize: number): void {
}
}
/**
* Сброс отслеживания буфера
*/
// noinspection JSUnusedGlobalSymbols
export function resetBufferTracking(): void {
state.lastBufferSize = 0;
forceResetPeak();
}
// ============================================
// ВНУТРЕННИЕ ФУНКЦИИ
// ============================================
function notifyPeakHolderChange(): void {
if (peakHolderListener) {
peakHolderListener();
}
}
function scheduleAmplitudePeakRelease(): void {
// Сбрасываем старый таймер
if (state.peakTimer !== null) {
clearTimeout(state.peakTimer);
state.peakTimer = null;
}
// Запускаем новый таймер на 3 секунды
state.peakTimer = window.setTimeout(() => {
state.peakValue = state.currentValue;
state.peakTimer = null;
state.hasPeak = false;
console.log(`[Peak] Сброс пика до: ${state.peakValue}`);
notifyPeakHolderChange();
}, state.peakHoldTime);
}
function scheduleSignalPeakRelease(): void {
// Сбрасываем старый таймер
if (state.signalPeakTimer !== null) {
clearTimeout(state.signalPeakTimer);
state.signalPeakTimer = null;
}
// Запускаем новый таймер на 3 секунды
state.signalPeakTimer = window.setTimeout(() => {
// Сбрасываем пик к текущему уровню
state.peakSignalLevel = state.currentSignalLevel;
state.signalPeakTimer = null;
state.hasPeak = false;
console.log(`[SignalPeak] Сброс пика до: ${state.peakSignalLevel}`);
notifyPeakHolderChange();
}, state.signalPeakHoldTime);
}

View File

@@ -1,4 +1,5 @@
// ui.ts (DOM слой)
// ui.ts - Управление UI элементами
export interface DOMElements {
serverTime: HTMLElement | null;
status: HTMLElement | null;
@@ -9,6 +10,10 @@ export interface DOMElements {
canvas: HTMLCanvasElement | null;
bars: NodeListOf<HTMLElement>;
barFill: HTMLElement | null;
bar2: HTMLElement | null;
bar3: HTMLElement | null;
soundLevel: HTMLElement | null;
heightLevel: HTMLElement | null;
historyHeader: HTMLElement | null;
historyBody: HTMLElement | null;
audioIndicator: HTMLElement | null;
@@ -24,6 +29,10 @@ export const DOM: DOMElements = {
canvas: null,
bars: document.querySelectorAll(".sig-bar"),
barFill: null,
bar2: null,
bar3: null,
soundLevel: null,
heightLevel: null,
historyHeader: null,
historyBody: null,
audioIndicator: null,
@@ -38,6 +47,10 @@ export function initDOM(): void {
DOM.history = document.getElementById("history");
DOM.canvas = document.getElementById("signalChart") as HTMLCanvasElement;
DOM.barFill = document.getElementById("bar1");
DOM.bar2 = document.getElementById("bar2");
DOM.bar3 = document.getElementById("bar3");
DOM.soundLevel = document.getElementById("sound-level");
DOM.heightLevel = document.getElementById("height-level");
DOM.historyHeader = document.getElementById("history-header");
DOM.historyBody = document.getElementById("history-body");
DOM.bars = document.querySelectorAll(".sig-bar");
@@ -51,9 +64,9 @@ export function setServerTime(v: string): void {
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");
@@ -69,23 +82,29 @@ 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))}%`;
const percent = Math.max(0, Math.min(100, p));
el.style.width = `${percent}%`;
el.style.background = '#e6852d';
el.style.backgroundColor = '#e6852d';
el.style.backgroundImage = 'none';
}
}
// ===== ИНДИКАТОРЫ УРОВНЯ СИГНАЛА =====
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) {
@@ -94,9 +113,9 @@ export function setSignal(level: number, noConnection: boolean = false): void {
bar.classList.add("dim");
}
});
if (noConnection) return;
if (level >= 1) {
bars[0].classList.add("active-1");
bars[0].classList.remove("dim");
@@ -111,21 +130,286 @@ export function setSignal(level: number, noConnection: boolean = false): void {
}
}
// ФУНКЦИЮ ДЛЯ ИНДИКАТОРА ЗВУКА
export function setAudioIndicator(active: boolean, level: number = 0): void {
const el = DOM.audioIndicator;
if (!el) return;
if (active && level > 0) {
el.textContent = `🔊 ${level}`;
// Проверяем громкость
const volume = localStorage.getItem('gport_volume');
const currentVolume = volume ? parseInt(volume) : 0;
if (active && level > 0 && currentVolume > 0) {
el.textContent = '🔊';
el.style.color = '#00ff88';
el.style.opacity = '1';
// Анимация пульсации
el.style.animation = 'pulse 1s ease-in-out infinite';
el.classList.remove('muted');
el.classList.add('active');
} else {
el.textContent = '🔇';
el.style.color = '#666';
el.style.opacity = '0.5';
el.style.animation = 'none';
el.classList.add('muted');
el.classList.remove('active');
}
}
}
export function setSoundLevel(level: number): void {
if (DOM.bar2) {
const percent = Math.max(0, Math.min(100, level));
DOM.bar2.style.width = `${percent}%`;
}
if (DOM.soundLevel) {
DOM.soundLevel.textContent = Math.round(level).toString();
}
}
export function setHeightLevel(level: number): void {
if (DOM.bar3) {
const percent = Math.max(0, Math.min(100, level));
DOM.bar3.style.width = `${percent}%`;
}
if (DOM.heightLevel) {
DOM.heightLevel.textContent = Math.round(level).toString();
}
}
// ===== 8 КАНАЛОВ =====
export interface ChannelState {
value: number; // Максимальное значение (для прогресс-бара)
currentValue: number; // Текущее значение (для отображения)
strength: number; // Максимальный уровень
currentStrength: number; // Текущий уровень
expanded: boolean;
}
export const channels: ChannelState[] = Array(8).fill(null).map(() => ({
value: 0,
currentValue: 0,
strength: 0,
currentStrength: 0,
expanded: true
}));
export let channelElements: any[] = [];
export function initChannels() {
const container = document.getElementById('channelsContainer');
if (!container) return;
container.innerHTML = '';
channelElements = [];
for (let i = 0; i < 8; i++) {
const item = document.createElement('div');
item.className = 'channel-item';
item.dataset.channel = String(i);
if (i === 0) {
item.classList.add('channel-active');
}
item.innerHTML = `
<div class="channel-header" data-channel="${i}">
<span class="channel-label">Канал ${i + 1}</span>
<span style="display:flex;align-items:center;gap:6px;">
<span class="channel-value" id="ch-val-${i}">--</span>
<span class="channel-toggle" id="ch-toggle-${i}">▼</span>
</span>
</div>
<div class="channel-body" id="ch-body-${i}">
<div class="channel-progress">
<div class="progress">
<div class="fill" id="ch-bar-${i}" style="width:0%; background: #e6852d;"></div>
</div>
<div class="signal-icon" id="ch-signal-${i}">
<div class="sig-bar sig-bar-1"></div>
<div class="sig-bar sig-bar-2"></div>
<div class="sig-bar sig-bar-3"></div>
</div>
</div>
<div class="channel-stats" id="ch-stats-${i}">--</div>
</div>
`;
container.appendChild(item);
channelElements[i] = {
item: item,
value: document.getElementById(`ch-val-${i}`),
bar: document.getElementById(`ch-bar-${i}`),
signal: document.getElementById(`ch-signal-${i}`),
stats: document.getElementById(`ch-stats-${i}`),
toggle: document.getElementById(`ch-toggle-${i}`),
body: document.getElementById(`ch-body-${i}`)
};
const header = item.querySelector('.channel-header');
header?.addEventListener('click', () => {
toggleChannel(i);
});
}
}
export function toggleChannel(index: number) {
const ch = channels[index];
ch.expanded = !ch.expanded;
const el = channelElements[index];
if (!el) return;
if (ch.expanded) {
el.body.classList.remove('hidden');
el.toggle.textContent = '▼';
} else {
el.body.classList.add('hidden');
el.toggle.textContent = '▶';
}
}
export function getChannelElement(index: number) {
return channelElements[index] || null;
}
/**
* Установка МАКСИМАЛЬНОГО значения для прогресс-бара
* (используется для отображения пика)
*/
export function setChannelValue(index: number, value: number, strength: number) {
if (index < 0 || index > 7) return;
const ch = channels[index];
const el = channelElements[index];
if (!el) return;
ch.value = value;
ch.strength = strength;
const maxValue = 63;
const percent = (value / maxValue) * 100;
// Обновляем прогресс-бар
if (el.bar) {
el.bar.style.width = `${Math.min(100, percent)}%`;
el.bar.style.background = '#e6852d';
el.bar.style.backgroundColor = '#e6852d';
el.bar.style.backgroundImage = 'none';
}
// Обновляем индикатор сигнала (уровень)
const bars = el.signal?.querySelectorAll('.sig-bar');
if (bars) {
bars.forEach((bar: Element, i: number) => {
const isActive = i < strength;
(bar as HTMLElement).style.opacity = isActive ? '1' : '0.2';
});
}
// Обновляем статистику
const strengthNames = ['Отсутствует', 'Низкая', 'Средняя', 'Высокая'];
if (el.stats) {
el.stats.textContent = `${ch.currentValue}/${maxValue} | ${strengthNames[ch.currentStrength] || '---'} (пик: ${value})`;
}
if (el.item) {
el.item.classList.remove('no-data');
}
}
/**
* Установка ТЕКУЩЕГО значения для отображения в цифрах
* (используется для показа реального состояния)
*/
export function setChannelCurrentValue(index: number, value: number, strength: number) {
if (index < 0 || index > 7) return;
const ch = channels[index];
const el = channelElements[index];
if (!el) return;
ch.currentValue = value;
ch.currentStrength = strength;
// Обновляем числовое значение
if (el.value) {
el.value.textContent = value.toString();
}
// Обновляем статистику (показываем текущее значение и пик)
const maxValue = 63;
const strengthNames = ['Отсутствует', 'Низкая', 'Средняя', 'Высокая'];
if (el.stats) {
el.stats.textContent = `${value}/${maxValue} | ${strengthNames[strength] || '---'} (пик: ${ch.value})`;
}
// Обновляем индикатор сигнала (текущий уровень)
const bars = el.signal?.querySelectorAll('.sig-bar');
if (bars) {
bars.forEach((bar: Element, i: number) => {
const isActive = i < strength;
// Если текущий уровень меньше максимального - добавляем класс dim
if (isActive) {
(bar as HTMLElement).classList.remove('dim');
(bar as HTMLElement).classList.add('active-' + (i + 1));
} else {
(bar as HTMLElement).classList.add('dim');
(bar as HTMLElement).classList.remove('active-' + (i + 1));
}
});
}
}
export function updateAllChannels(rawData?: number[]) {
for (let i = 0; i < 8; i++) {
setChannelValue(i, 0, 0);
setChannelCurrentValue(i, 0, 0);
if (channelElements[i]) {
channelElements[i].item.classList.remove('channel-active');
}
}
if (rawData && rawData.length > 0) {
const lastByte = rawData[rawData.length - 1];
const value = lastByte & 0x3F;
const strength = (lastByte >> 6) & 0x3;
setChannelValue(0, value, strength);
setChannelCurrentValue(0, value, strength);
if (channelElements[0]) {
channelElements[0].item.classList.add('channel-active');
}
if (DOM.lastByte) {
DOM.lastByte.textContent = value.toString();
}
} else {
if (DOM.lastByte) {
DOM.lastByte.textContent = '--';
}
}
}
document.addEventListener('DOMContentLoaded', () => {
initChannels();
updateAllChannels();
const expandAll = document.getElementById('expandAllBtn');
const collapseAll = document.getElementById('collapseAllBtn');
if (expandAll) {
expandAll.addEventListener('click', () => {
channels.forEach((ch, i) => {
if (!ch.expanded) toggleChannel(i);
});
});
}
if (collapseAll) {
collapseAll.addEventListener('click', () => {
channels.forEach((ch, i) => {
if (ch.expanded) toggleChannel(i);
});
});
}
});

9
debian/changelog vendored Normal file
View File

@@ -0,0 +1,9 @@
gpio-monitor-server (1.0.0-1) stable; urgency=medium
* Initial release
* GPIO monitoring with web interface
* Systemd service with auto-restart
* Log rotation and retention
* Support for Raspberry Pi GPIO
-- НТЦ200 Mon, 23 Jun 2026 12:00:00 +0300

14
debian/control vendored Normal file
View File

@@ -0,0 +1,14 @@
Source: gpio-monitor-server
Section: utils
Priority: optional
Maintainer: НТЦ200 <ntc200@example.com>
Build-Depends: debhelper (>= 11), dh-golang, golang-go, npm
Standards-Version: 4.5.0
Homepage: https://example.com/gpio-monitor
Package: gpio-monitor-server
Version: 1.0.0-1
Architecture: arm64
Depends: systemd
Description: GPIO Monitor Server
Сервер для мониторинга GPIO с веб-интерфейсом
и системой логирования.

29
debian/copyright vendored Normal file
View File

@@ -0,0 +1,29 @@
Format:
Upstream-Name: gpio-monitor-server
Source:
Files: *
Copyright: 2024 НТЦ200
License: MIT
Files: debian/*
Copyright: 2024 НТЦ200
License: MIT
License: MIT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

5
debian/dirs vendored Normal file
View File

@@ -0,0 +1,5 @@
usr/bin
opt/gpio-monitoring/web
etc/gpio-monitoring
var/log/gpio-monitoring
var/lib/gpio-monitoring

15
debian/gpio-monitor-server.conf vendored Normal file
View File

@@ -0,0 +1,15 @@
# Конфигурация GPIO Monitor Server
# Файл: /etc/gpio-monitoring/config
# Параметры запуска
PIPE_PATH=/tmp/gpio_pipe
RETENTION_HOURS=72
RETENTION_MB=2000
RETENTION_INTERVAL=60
ROTATION_CHECK_INTERVAL=1
BUFFER_SIZE=10240
HUMAN_LOG_INTERVAL=5
PORT=8080
# Настройки камеры (если используется)
CAMERA_URL=http://127.0.0.1:1984/api/stream.mjpeg?src=cam_mjpeg

39
debian/gpio-monitor-server.service vendored Normal file
View File

@@ -0,0 +1,39 @@
[Unit]
Description=GPIO Monitor Server
After=network.target
Wants=network.target
[Service]
Type=simple
User=gpio-monitor
Group=gpio-monitor
WorkingDirectory=/opt/gpio-monitoring
# Запуск сервера с параметрами
ExecStart=/usr/bin/gpio-monitor-server \
-pipe /tmp/gpio_pipe \
-retention-hours=72 \
-retention-mb=2000 \
-retention-interval=60 \
-rotation-check-interval=1 \
-buffer-size=10240 \
-human-log-interval=5
# Перезапуск при падении
Restart=on-failure
RestartSec=10s
# Лимиты и безопасность
LimitNOFILE=4096
NoNewPrivileges=yes
# Логирование
StandardOutput=journal
StandardError=journal
SyslogIdentifier=gpio-monitor
# Настройки для доступа к GPIO
AmbientCapabilities=CAP_SYS_RAWIO
[Install]
WantedBy=multi-user.target

2
debian/install vendored Normal file
View File

@@ -0,0 +1,2 @@
# Бинарник будет установлен в /usr/bin автоматически
debian/usr/bin/gpio-logs usr/bin/

33
debian/postinst vendored Normal file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# chmod +x debian/postinst
set -e
case "$1" in
configure)
# Создаем директории
mkdir -p /var/log/gpio-monitoring
mkdir -p /var/lib/gpio-monitoring
mkdir -p /opt/gpio-monitoring/web
# Устанавливаем права
chown -R gpio-monitor:gpio-monitor /var/log/gpio-monitoring
chown -R gpio-monitor:gpio-monitor /var/lib/gpio-monitoring
chown -R gpio-monitor:gpio-monitor /opt/gpio-monitoring
# Перезагружаем systemd
systemctl daemon-reload 2>/dev/null || true
# Включаем сервис, но не запускаем автоматически
systemctl enable gpio-monitor-server.service 2>/dev/null || true
systemctl start gpio-monitor-server.service
# Создаем symlink для логов
ln -sf /var/log/gpio-monitoring /opt/gpio-monitoring/logs 2>/dev/null || true
echo "GPIO Monitor Server установлен"
echo "Для запуска: sudo systemctl start gpio-monitor-server"
;;
esac
exit 0

19
debian/postrm vendored Normal file
View File

@@ -0,0 +1,19 @@
#!/bin/bash
#chmod +x debian/postrm
set -e
case "$1" in
purge)
# Удаляем пользователя только при полной очистке
userdel gpio-monitor 2>/dev/null || true
groupdel gpio-monitor 2>/dev/null || true
# Удаляем директории
rm -rf /var/log/gpio-monitoring
rm -rf /var/lib/gpio-monitoring
rm -rf /opt/gpio-monitoring
;;
esac
exit 0

24
debian/preinst vendored Normal file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
#chmod +x debian/preinst
set -e
# Создаем пользователя и группу
if ! getent group gpio-monitor >/dev/null; then
groupadd --system gpio-monitor
fi
if ! getent passwd gpio-monitor >/dev/null; then
useradd --system \
--gid gpio-monitor \
--home /var/lib/gpio-monitoring \
--shell /usr/sbin/nologin \
--comment "GPIO Monitor Service" \
gpio-monitor
fi
# Добавляем пользователя в нужные группы для доступа к GPIO
usermod -a -G gpio,dialout,gpio-monitor gpio-monitor 2>/dev/null || true
exit 0

15
debian/prerm vendored Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
#chmod +x debian/prerm
set -e
case "$1" in
remove|upgrade|deconfigure)
# Останавливаем сервис
systemctl stop gpio-monitor-server.service 2>/dev/null || true
systemctl disable gpio-monitor-server.service 2>/dev/null || true
systemctl daemon-reload 2>/dev/null || true
;;
esac
exit 0

24
debian/rules vendored Normal file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/make -f
# chmod +x debian/rules
# Удаляем стандартные правила
%:
dh $@ --buildsystem=golang --with=golang
# Переопределяем сборку
override_dh_auto_build:
# Сборка фронтенда
cd cmd/server/web && npm install && npm run build
# Сборка бекенда
GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o debian/gpio-monitor-server/usr/bin/gpio-monitor-server ./cmd/server
# Установка файлов
override_dh_install:
dh_install
# Копируем статические файлы для веба
mkdir -p debian/gpio-monitor-server/opt/gpio-monitoring/web
cp -r cmd/server/web/dist debian/gpio-monitor-server/opt/gpio-monitoring/web/
cp -r cmd/server/web/css debian/gpio-monitor-server/opt/gpio-monitoring/web/
cp -r cmd/server/web/fonts debian/gpio-monitor-server/opt/gpio-monitoring/web/
cp cmd/server/web/*.html debian/gpio-monitor-server/opt/gpio-monitoring/web/ 2>/dev/null || true
cp cmd/server/web/favicon.png debian/gpio-monitor-server/opt/gpio-monitoring/web/ 2>/dev/null || true

31
debian/usr/bin/gpio-logs vendored Normal file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Просмотр логов GPIO Monitor
if [ "$1" = "-f" ]; then
sudo journalctl -u gpio-monitor-server -f
elif [ "$1" = "-e" ]; then
sudo tail -f /var/log/gpio-monitoring/events_human.log
elif [ "$1" = "-g" ]; then
sudo tail -f /var/log/gpio-monitoring/gpio_human.log
elif [ "$1" = "-d" ]; then
sudo tail -f /var/log/gpio-monitoring/data/*.bin 2>/dev/null | xxd
elif [ "$1" = "-h" ] || [ "$1" = "--help" ]; then
echo "Использование: gpio-logs [OPTION]"
echo " -f Просмотр логов сервиса (journalctl)"
echo " -e Просмотр логов событий"
echo " -g Просмотр GPIO логов (human-readable)"
echo " -d Просмотр бинарных данных (raw)"
echo " -h Показать эту справку"
else
echo "Использование: gpio-logs [OPTION]"
echo " -f Просмотр логов сервиса (journalctl)"
echo " -e Просмотр логов событий"
echo " -g Просмотр GPIO логов (human-readable)"
echo " -d Просмотр бинарных данных (raw)"
echo " -h Показать эту справку"
echo ""
echo "Примеры:"
echo " gpio-logs -f # следить за логами сервиса"
echo " gpio-logs -e # смотреть события"
echo " gpio-logs -g # смотреть GPIO логи"
fi

View File

@@ -7,7 +7,17 @@ import (
// GetDataDir возвращает стандартную директорию для данных приложения
func GetDataDir() (string, error) {
// Используем XDG Base Directory Specification
// Проверяем, установлен ли пакет (существует ли /opt/gpio-monitoring)
if _, err := os.Stat("/opt/gpio-monitoring"); err == nil {
// Используем стандартную директорию для deb пакета
dataDir := "/var/lib/gpio-monitoring"
if err := os.MkdirAll(dataDir, 0755); err != nil {
return "", err
}
return dataDir, nil
}
// Для разработки - используем XDG Base Directory Specification
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
@@ -15,8 +25,6 @@ func GetDataDir() (string, error) {
// ~/.local/share/gpio-monitoring
dataDir := filepath.Join(homeDir, ".local", "share", "gpio-monitoring")
// Создаём директорию если её нет
if err := os.MkdirAll(dataDir, 0755); err != nil {
return "", err
}
@@ -26,6 +34,16 @@ func GetDataDir() (string, error) {
// GetLogsDir возвращает директорию для логов
func GetLogsDir() (string, error) {
// Проверяем, установлен ли пакет
if _, err := os.Stat("/opt/gpio-monitoring"); err == nil {
logsDir := "/var/log/gpio-monitoring"
if err := os.MkdirAll(logsDir, 0755); err != nil {
return "", err
}
return logsDir, nil
}
// Для разработки
dataDir, err := GetDataDir()
if err != nil {
return "", err
@@ -82,4 +100,27 @@ func GetGPIOLogPath() (string, error) {
}
return filepath.Join(logsDir, "gpio_human.log"), nil
}
// GetWebDir возвращает директорию с веб-файлами
func GetWebDir() string {
// Проверяем стандартные пути
possiblePaths := []string{
"/opt/gpio-monitoring/web",
"/usr/share/gpio-monitoring/web",
"./cmd/server/web",
"../cmd/server/web",
}
for _, path := range possiblePaths {
if _, err := os.Stat(path); err == nil {
// Проверяем, что есть index.html
if _, err := os.Stat(filepath.Join(path, "index.html")); err == nil {
return path
}
}
}
// Fallback для разработки
return "./cmd/server/web"
}

View File

@@ -1,6 +1,7 @@
package logger
import (
"fmt"
"log"
"os"
"path/filepath"
@@ -70,37 +71,48 @@ func (r *Retention) Cleanup() {
return
}
if r.eventLogger != nil {
r.eventLogger.Event("ЗАПУЩЕНА_ОЧИСТКАОГОВ")
}
// 1. Удаляем старые файлы (если включено)
deletedByAge := 0
var deletedByAgeBytes int64
if r.maxAgeHours > 0 {
deletedByAge = r.cleanByAge(dataDir)
deletedByAge, deletedByAgeBytes = r.cleanByAge(dataDir)
}
// 2. Проверяем общий размер и удаляем самые старые если превышен лимит (если включено)
deletedBySize := 0
var deletedBySizeBytes int64
if r.maxSizeBytes > 0 {
deletedBySize = r.cleanBySize(dataDir)
deletedBySize, deletedBySizeBytes = r.cleanBySize(dataDir)
}
if r.eventLogger != nil && (deletedByAge > 0 || deletedBySize > 0) {
r.eventLogger.Event("УДАЛЕНОАЙЛОВ")
log.Printf("Очистка логов: удалено %d файлов (по возрасту: %d, по размеру: %d)",
deletedByAge+deletedBySize, deletedByAge, deletedBySize)
totalDeleted := deletedByAge + deletedBySize
totalDeletedBytes := deletedByAgeBytes + deletedBySizeBytes
// Логируем ТОЛЬКО если были удаления
if totalDeleted > 0 {
// Логируем суммарное событие
if r.eventLogger != nil {
eventMsg := fmt.Sprintf("ОЧИСТКАОГОВ_УДАЛЕНО_%d_ФАЙЛОВ_%d_MB",
totalDeleted, totalDeletedBytes/(1024*1024))
r.eventLogger.Event(eventMsg)
}
// Пишем в консоль для отладки
log.Printf("Очистка логов: удалено %d файлов (%.2f MB) [по возрасту: %d, по размеру: %d]",
totalDeleted, float64(totalDeletedBytes)/(1024*1024), deletedByAge, deletedBySize)
}
}
func (r *Retention) cleanByAge(dataDir string) int {
// cleanByAge возвращает количество удаленных файлов и их общий размер в байтах
func (r *Retention) cleanByAge(dataDir string) (int, int64) {
cutoff := time.Now().Add(-time.Duration(r.maxAgeHours) * time.Hour)
deleted := 0
var deletedBytes int64
// Получаем все .bin файлы
files, err := filepath.Glob(filepath.Join(dataDir, "gpio-*.bin"))
if err != nil {
return 0
return 0, 0
}
for _, file := range files {
@@ -118,25 +130,24 @@ func (r *Retention) cleanByAge(dataDir string) int {
if fileTime.Before(cutoff) {
if err := os.Remove(file); err == nil {
deleted++
if r.eventLogger != nil {
r.eventLogger.Event("УДАЛЕНАЙЛ_ПО_ВОЗРАСТУ")
}
deletedBytes += info.Size()
}
}
}
return deleted
return deleted, deletedBytes
}
func (r *Retention) cleanBySize(dataDir string) int {
// cleanBySize возвращает количество удаленных файлов и их общий размер в байтах
func (r *Retention) cleanBySize(dataDir string) (int, int64) {
// Получаем все файлы
files, err := filepath.Glob(filepath.Join(dataDir, "gpio-*.bin"))
if err != nil {
return 0
return 0, 0
}
if len(files) == 0 {
return 0
return 0, 0
}
// Сортируем по времени (старыми первыми)
@@ -175,22 +186,23 @@ func (r *Retention) cleanBySize(dataDir string) int {
// Удаляем самые старые пока не уложимся в лимит
deleted := 0
var deletedBytes int64
// Сохраняем как минимум 1 файл
for totalSize > r.maxSizeBytes && len(fileList) > 1 {
// Удаляем самый старый файл
oldest := fileList[0]
if err := os.Remove(oldest.path); err == nil {
totalSize -= oldest.size
deletedBytes += oldest.size
deleted++
fileList = fileList[1:]
if r.eventLogger != nil {
r.eventLogger.Event("УДАЛЕНАЙЛ_ПО_РАЗМЕРУ")
}
} else {
break
}
}
return deleted
return deleted, deletedBytes
}
func (r *Retention) parseFilenameTime(filename string) time.Time {

View File

@@ -70,7 +70,7 @@ func (r *RotatingLogger) rotate() error {
dataLogger, err := NewDataLogger(filename)
if err != nil {
if r.eventLogger != nil {
r.eventLogger.Event("ROTATION_FAILED")
r.eventLogger.Event("ПЛАНОВАЯ_РОТАЦИЯ_НЕУДАЧНА")
}
return err
}
@@ -78,7 +78,7 @@ func (r *RotatingLogger) rotate() error {
r.dataLogger = dataLogger
r.currentHour = newHour
if r.eventLogger != nil {
r.eventLogger.Event("ROTATION_COMPLETE")
r.eventLogger.Event("ПЛАНОВАЯ_РОТАЦИЯ")
}
return nil
}

View File

@@ -23,8 +23,12 @@ type PipeReader struct {
lastAlertTime map[byte]time.Time
// Настройки
alertCooldownSec time.Duration
alertCooldownSec time.Duration
humanLogIntervalSec time.Duration
// Флаги состояния
pipeState string // "unknown", "found", "not_found"
lastLoggedState string // для отслеживания изменений
}
func NewPipeReader(
@@ -36,14 +40,26 @@ func NewPipeReader(
config *logger.Config,
) *PipeReader {
return &PipeReader{
buf: buf,
dataLogger: dataLogger,
humanLogger: humanLogger,
monitor: monitor,
eventLog: eventLog,
lastAlertTime: make(map[byte]time.Time),
alertCooldownSec: config.AlertCooldownSec,
buf: buf,
dataLogger: dataLogger,
humanLogger: humanLogger,
monitor: monitor,
eventLog: eventLog,
lastAlertTime: make(map[byte]time.Time),
alertCooldownSec: config.AlertCooldownSec,
humanLogIntervalSec: config.HumanLogIntervalSec,
pipeState: "unknown",
lastLoggedState: "unknown",
}
}
func (pr *PipeReader) logStateChange(newState string, eventName string) {
if newState != pr.lastLoggedState {
if pr.eventLog != nil {
pr.eventLog.Event(eventName)
}
pr.lastLoggedState = newState
}
}
@@ -59,8 +75,10 @@ func (pr *PipeReader) Start(ctx context.Context, pipePath string) {
}
if _, err := os.Stat(pipePath); os.IsNotExist(err) {
if pr.eventLog != nil {
pr.eventLog.Event("PIPE_НЕ_НАЙДЕН")
// Логируем ТОЛЬКО при смене состояния
if pr.pipeState != "not_found" {
pr.pipeState = "not_found"
pr.logStateChange("not_found", "PIPE_НЕ_НАЙДЕН")
}
time.Sleep(2 * time.Second)
continue
@@ -68,27 +86,38 @@ func (pr *PipeReader) Start(ctx context.Context, pipePath string) {
f, err := os.OpenFile(pipePath, os.O_RDONLY, 0)
if err != nil {
if pr.eventLog != nil {
pr.eventLog.Event("ОШИБКА_ОТКРЫТИЯ_PIPE")
if pr.pipeState != "error" {
pr.pipeState = "error"
pr.logStateChange("error", "ОШИБКА_ОТКРЫТИЯ_PIPE")
}
time.Sleep(time.Second)
continue
}
if pr.eventLog != nil {
pr.eventLog.Event("PIPE_ПОДКЛЮЧЕН")
// Pipe успешно открыт
if pr.pipeState != "found" {
pr.pipeState = "found"
pr.logStateChange("found", "PIPE_ПОДКЛЮЧЕН")
}
for {
n, err := f.Read(buffer)
if err != nil {
f.Close()
if pr.eventLog != nil {
pr.eventLog.Event("PIPE_ОТКЛЮЧЕН")
// Отключаемся только если были подключены
if pr.pipeState == "found" {
pr.pipeState = "disconnected"
pr.logStateChange("disconnected", "PIPE_ОТКЛЮЧЕН")
}
break
}
// Если были в состоянии ошибки/отключения, восстанавливаемся
if pr.pipeState != "found" {
pr.pipeState = "found"
pr.logStateChange("found", "PIPE_ПОДКЛЮЧЕН")
}
for i := 0; i < n; i++ {
rawByte := buffer[i]
now := time.Now()

View File

@@ -1,8 +1,11 @@
"""
# Создание сокета mkfifo /tmp/gpio_pipe
# Необходим файл с данными, например array.txt. Формат записи данных [0xE0, 0x01, 0x4C, 0x88]
# Запуск python3 emulator.py array.txt /tmp/gpio_pipe
Эмулятор чтения из файла и отправки в pipe.
Создание сокета: mkfifo /tmp/gpio_pipe
Формат файла: [0xE0, 0x01, 0x4C, 0x88]
Запуск: python3 emulator.py array.txt /tmp/gpio_pipe
"""
import sys
import time
import ast
@@ -49,6 +52,7 @@ def status_clear():
# ================= DATA =================
def load_array(path):
"""Загружает данные из файла."""
while True:
try:
with open(path, "r") as f:
@@ -73,6 +77,7 @@ def load_array(path):
# ================= PIPE =================
def open_pipe(path):
"""Открывает pipe для записи."""
while not stop_flag:
try:
fd = os.open(path, os.O_WRONLY | os.O_NONBLOCK)