Compare commits
7 Commits
87ab5d48de
...
5d2d37fc7b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d2d37fc7b | ||
|
|
5938d94a8f | ||
|
|
4180353b65 | ||
|
|
bc40c1437a | ||
|
|
875aa8a054 | ||
|
|
9525097af6 | ||
|
|
9a17569ac0 |
372
README.md
372
README.md
@@ -2,56 +2,125 @@
|
||||
|
||||
Реалтайм мониторинг параллельной GPIO-шины Raspberry Pi через C + Go + Web Dashboard.
|
||||
|
||||
Проект читает входящие сигналы с GPIO-пинов Raspberry Pi по прерыванию, передаёт данные через FIFO pipe в Go-сервер и отображает всё в браузере в реальном времени.
|
||||
Проект читает входящие сигналы с GPIO-пинов Raspberry Pi по прерыванию, передаёт данные через FIFO pipe в Go-сервер и отображает всё в браузере в реальном времени с возможностью просмотра исторических данных.
|
||||
|
||||
---
|
||||
|
||||
# Возможности
|
||||
## Возможности
|
||||
|
||||
✅ Захват данных по GPIO interrupt
|
||||
✅ Работа через WiringPi
|
||||
✅ FIFO pipe между C и Go
|
||||
✅ Высокоскоростной RingBuffer
|
||||
✅ HTTP API
|
||||
✅ Web Dashboard
|
||||
✅ Работа через Wi-Fi Access Point Raspberry Pi
|
||||
✅ Подходит для ПК и мобильных устройств
|
||||
✅ Захват данных по GPIO interrupt (C + WiringPi)
|
||||
✅ FIFO pipe, а также stdin/stdout между C + WiringPi и Go Service
|
||||
✅ Реализация кольцевого буфера чтения
|
||||
✅ HTTP API + Web Dashboard
|
||||
✅ **Автоматическая ротация** лог-файлов
|
||||
✅ **Retention** (автоматическая очистка старых логов)
|
||||
✅ **Watchdog** мониторинг активности
|
||||
✅ **MJPEG камера** через go2rtc
|
||||
✅ TypeScript фронтенд
|
||||
✅ Работа через Wi-Fi Access Point
|
||||
|
||||
---
|
||||
|
||||
# Архитектура
|
||||
## Архитектура
|
||||
|
||||
```text
|
||||
GPIO BUS (8 bit)
|
||||
↓
|
||||
gpio-interrupt.c
|
||||
gpio-interrupt.c (C + WiringPi)
|
||||
↓ stdout
|
||||
systemd socket FIFO (/tmp/gpio_pipe)
|
||||
FIFO pipe (/tmp/gpio_pipe)
|
||||
↓
|
||||
Go Server
|
||||
Go Server (gpio-monitor-server)
|
||||
├── RingBuffer (RAM)
|
||||
├── Data Logger (бинарные .bin файлы)
|
||||
├── Human Logger (текстовые логи)
|
||||
├── Event Logger (события системы)
|
||||
├── Watchdog (мониторинг)
|
||||
└── Retention (очистка старых логов)
|
||||
↓
|
||||
HTTP API (:8080)
|
||||
├── /api/health - статус сервера
|
||||
├── /api/latest - последние данные
|
||||
├── /api/history - история (последние 100)
|
||||
├── /api/stream - статус камеры
|
||||
├── /api/cam - MJPEG поток
|
||||
├── /api/log/files - список лог-файлов
|
||||
├── /api/log/data - данные из лог-файла
|
||||
└── /api/log/events - события системы
|
||||
↓
|
||||
Dashboard (Browser)
|
||||
Web Dashboard (Embedded)
|
||||
├── index.html
|
||||
├── dashboard.html (главная)
|
||||
├── logs.html (просмотр логов)
|
||||
├── css/style.css
|
||||
├── js/*.ts (TypeScript исходники)
|
||||
└── dist/*.js (скомпилированный JS)
|
||||
```
|
||||
|
||||
# Структура проекта
|
||||
---
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
.
|
||||
├── cmd/server/main.go # Go HTTP сервер
|
||||
├── internal/adapter/ # RingBuffer
|
||||
├── web/
|
||||
│ ├── index.html
|
||||
│ ├── dashboard.html
|
||||
│ ├── css/style.css
|
||||
│ ├── js/app.js
|
||||
│ └── favicon.png
|
||||
├── build/
|
||||
│ └── gpio-monitor-server # Скомпилированный бинарник
|
||||
├── build.sh # Скрипт сборки (Go + TypeScript)
|
||||
├── cmd/server/
|
||||
│ ├── main.go # Go HTTP сервер
|
||||
│ └── web/ # Веб-интерфейс
|
||||
│ ├── css/style.css
|
||||
│ ├── dist/ # Скомпилированный JS
|
||||
│ │ ├── app.js
|
||||
│ │ ├── chart.js
|
||||
│ │ ├── data.js
|
||||
│ │ └── ...
|
||||
│ ├── js/ # TypeScript исходники
|
||||
│ │ ├── app.ts
|
||||
│ │ ├── chart.ts
|
||||
│ │ ├── data.ts
|
||||
│ │ └── ...
|
||||
│ ├── dashboard.html
|
||||
│ ├── index.html
|
||||
│ ├── logs.html
|
||||
│ ├── favicon.png
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── README.md
|
||||
├── internal/
|
||||
│ ├── adapter/buffer.go # RingBuffer
|
||||
│ ├── logger/ # Система логирования
|
||||
│ │ ├── config.go
|
||||
│ │ ├── data_logger.go # Бинарные логи
|
||||
│ │ ├── event_logger.go # События
|
||||
│ │ ├── human_logger.go # Человекочитаемые логи
|
||||
│ │ ├── monitor.go # Watchdog
|
||||
│ │ ├── parser.go # Парсер данных GPIO
|
||||
│ │ ├── paths.go # Пути к файлам
|
||||
│ │ ├── retention.go # Очистка старых логов
|
||||
│ │ └── rotation.go # Ротация файлов
|
||||
│ └── pipe/reader.go # Чтение из FIFO pipe
|
||||
├── scripts/
|
||||
│ ├── monitor-gpio.service # Systemd сервис
|
||||
│ ├── monitor-gpio.socket # Systemd socket
|
||||
│ ├── go2rtc # Бинарник go2rtc
|
||||
│ ├── go2rtc.yaml # Конфиг go2rtc
|
||||
│ ├── view_logs.sh # Скрипт просмотра логов
|
||||
│ ├── emulator.py # Эмулятор GPIO
|
||||
│ └── imi_wire.py # IMI Wire эмулятор
|
||||
├── SDR/ # SDR компоненты
|
||||
│ ├── common.c/h
|
||||
│ ├── reciever.c
|
||||
│ ├── transmitter.c
|
||||
│ └── makefile
|
||||
├── go.mod
|
||||
├── start-server.sh
|
||||
└── README.md
|
||||
```
|
||||
|
||||
# GPIO pin
|
||||
## GPIO пины
|
||||
|
||||
```
|
||||
WR/STROBE = GPIO27
|
||||
|
||||
DATA BUS:
|
||||
@@ -63,39 +132,262 @@ D4 = GPIO25
|
||||
D5 = GPIO24
|
||||
D6 = GPIO23
|
||||
D7 = GPIO22
|
||||
```
|
||||
|
||||
# Установка
|
||||
---
|
||||
|
||||
## Установка
|
||||
|
||||
### 1. Установка зависимостей
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install golang git build-essential
|
||||
sudo apt install -y golang git build-essential wiringpi nodejs npm
|
||||
```
|
||||
|
||||
# Сборка
|
||||
### 2. Клонирование и сборка
|
||||
|
||||
go build -o gpio-monitor-server ./cmd/server
|
||||
```bash
|
||||
git clone <your-repo>
|
||||
cd ~/work/golang
|
||||
|
||||
# Запуск сервера
|
||||
# Сборка TypeScript + Go
|
||||
./build.sh
|
||||
```
|
||||
|
||||
./start-server.sh
|
||||
### 3. Настройка systemd сервиса
|
||||
|
||||
```bash
|
||||
# Копировать сервисные файлы
|
||||
sudo cp scripts/monitor-gpio.service /etc/systemd/system/
|
||||
sudo cp scripts/monitor-gpio.socket /etc/systemd/system/
|
||||
|
||||
# Перезагрузить systemd
|
||||
sudo systemctl daemon-reload
|
||||
|
||||
# Включить автозапуск
|
||||
sudo systemctl enable monitor-gpio.socket
|
||||
sudo systemctl enable monitor-gpio.service
|
||||
|
||||
# Запустить сервис
|
||||
sudo systemctl start monitor-gpio.service
|
||||
```
|
||||
|
||||
# Dashboard
|
||||
### 4. Проверка статуса
|
||||
|
||||
Открывать на http://192.168.77.1:8080
|
||||
```bash
|
||||
sudo systemctl status monitor-gpio.service
|
||||
journalctl -u monitor-gpio.service -f
|
||||
```
|
||||
|
||||
# API
|
||||
---
|
||||
|
||||
## Настройка firewall (nftables)
|
||||
|
||||
**Важно:** На Raspberry Pi OS Bookworm по умолчанию активен nftables, который блокирует все входящие порты, кроме SSH (22).
|
||||
|
||||
### Разрешить порт 8080:
|
||||
|
||||
```bash
|
||||
# Добавить правило для порта 8080
|
||||
sudo nft add rule inet filter input tcp dport 8080 accept
|
||||
|
||||
# Проверить правила
|
||||
sudo nft list ruleset
|
||||
|
||||
# Сохранить правила (для сохранения после перезагрузки)
|
||||
sudo nft list ruleset | sudo tee /etc/nftables.conf > /dev/null
|
||||
sudo systemctl enable nftables
|
||||
sudo systemctl restart nftables
|
||||
```
|
||||
|
||||
### Для доступа только с локальной сети:
|
||||
|
||||
```bash
|
||||
# Разрешить только с сети 10.1.1.0/24
|
||||
sudo nft add rule inet filter input ip saddr 10.1.1.0/24 tcp dport 8080 accept
|
||||
|
||||
# Или только с конкретного IP
|
||||
sudo nft add rule inet filter input ip saddr 192.168.1.100 tcp dport 8080 accept
|
||||
```
|
||||
|
||||
### Для доступа через Wi-Fi AP:
|
||||
|
||||
```bash
|
||||
# Разрешить с Wi-Fi сети (192.168.77.0/24)
|
||||
sudo nft add rule inet filter input iifname "wlan0" tcp dport 8080 accept
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Запуск
|
||||
|
||||
### Ручной запуск
|
||||
|
||||
```bash
|
||||
./build/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
|
||||
```
|
||||
|
||||
### Параметры командной строки
|
||||
|
||||
| Параметр | По умолчанию | Описание |
|
||||
|----------|-------------|----------|
|
||||
| `-pipe` | `/tmp/gpio_pipe` | Путь к FIFO pipe |
|
||||
| `-port` | `:8080` | Порт веб-сервера |
|
||||
| `-retention-hours` | `48` | Часы хранения логов (0 = отключено) |
|
||||
| `-retention-mb` | `5000` | Максимальный размер логов в MB |
|
||||
| `-retention-interval` | `15` | Интервал проверки retention (мин) |
|
||||
| `-rotation-check-interval` | `1` | Интервал проверки ротации (мин) |
|
||||
| `-buffer-size` | `10240` | Размер кольцевого буфера |
|
||||
| `-human-log-interval` | `5` | Интервал записи human-логов (сек) |
|
||||
| `-alert-cooldown` | `10` | Задержка между алертами (сек) |
|
||||
| `-camera-url` | `http://127.0.0.1:1984/api/stream.mjpeg?src=cam_mjpeg` | URL MJPEG камеры |
|
||||
|
||||
---
|
||||
|
||||
## Dashboard
|
||||
|
||||
Открывайте в браузере:
|
||||
|
||||
- **Через Ethernet:** `http://10.1.1.33:8080` (или ваш IP)
|
||||
- **Через Wi-Fi AP:** `http://192.168.77.1:8080`
|
||||
- **Локально:** `http://localhost:8080`
|
||||
|
||||
### Страницы
|
||||
|
||||
- `http://<IP>:8080/` - Перенаправление на dashboard
|
||||
- `http://<IP>:8080/dashboard.html` - Главная панель мониторинга
|
||||
- `http://<IP>:8080/logs.html` - Просмотр исторических логов
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### Основные эндпоинты
|
||||
|
||||
```bash
|
||||
GET /api/health
|
||||
# Статус сервера, uptime, статистика
|
||||
|
||||
GET /api/latest
|
||||
# Последние данные и история (10 записей)
|
||||
|
||||
GET /api/history
|
||||
# Последние 100 записей
|
||||
|
||||
# Wi-Fi режим Raspberry Pi
|
||||
GET /api/stream
|
||||
# Статус камеры
|
||||
|
||||
SSID: fix_me
|
||||
IP: 192.168.77.1
|
||||
GET /api/cam
|
||||
# MJPEG поток камеры (прокси на go2rtc)
|
||||
```
|
||||
|
||||
Подключение: ssh user@192.168.77.1
|
||||
### Работа с логами
|
||||
|
||||
# License
|
||||
```bash
|
||||
GET /api/log/files
|
||||
# Список доступных лог-файлов
|
||||
|
||||
MIT
|
||||
GET /api/log/data?file=gpio-2026-06-17-11.bin
|
||||
# Данные из конкретного бинарного лога
|
||||
|
||||
GET /api/log/events?limit=100
|
||||
# Последние события системы
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Диагностика проблем
|
||||
|
||||
### Если Dashboard не открывается:
|
||||
|
||||
1. **Проверьте, что сервер запущен:**
|
||||
```bash
|
||||
ps aux | grep gpio-monitor-server
|
||||
sudo systemctl status monitor-gpio.service
|
||||
```
|
||||
|
||||
2. **Проверьте firewall:**
|
||||
```bash
|
||||
sudo nft list ruleset | grep 8080
|
||||
```
|
||||
|
||||
3. **Проверьте, на каком интерфейсе слушает сервер:**
|
||||
```bash
|
||||
sudo ss -tlnp | grep 8080
|
||||
# Должно быть *:8080 или 0.0.0.0:8080
|
||||
```
|
||||
|
||||
4. **Проверьте маршрутизацию:**
|
||||
```bash
|
||||
ip route show
|
||||
```
|
||||
|
||||
5. **Посмотрите логи:**
|
||||
```bash
|
||||
sudo journalctl -u monitor-gpio.service -f
|
||||
tail -f /home/user/logs/gpio-events.log
|
||||
tail -f /home/user/logs/gpio-human.log
|
||||
```
|
||||
|
||||
6. **Проверьте наличие pipe:**
|
||||
```bash
|
||||
ls -la /tmp/gpio_pipe
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Wi-Fi режим Raspberry Pi
|
||||
|
||||
SSID: `fix_me`
|
||||
IP: `192.168.77.1`
|
||||
|
||||
Подключение: `ssh user@192.168.77.1`
|
||||
|
||||
---
|
||||
|
||||
|
||||
### Запуск Go сервера в режиме разработки:
|
||||
|
||||
```bash
|
||||
go run cmd/server/main.go \
|
||||
-pipe /tmp/gpio_pipe \
|
||||
-retention-hours=1 \
|
||||
-human-log-interval=1
|
||||
```
|
||||
|
||||
### Эмуляция GPIO сигналов:
|
||||
|
||||
```bash
|
||||
python3 scripts/emulator.py
|
||||
```
|
||||
|
||||
### Просмотр логов:
|
||||
|
||||
```bash
|
||||
./scripts/view_logs.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Камера (go2rtc)
|
||||
|
||||
Для работы камеры установите go2rtc:
|
||||
|
||||
```bash
|
||||
# Запуск go2rtc
|
||||
./scripts/go2rtc -config scripts/go2rtc.yaml
|
||||
```
|
||||
|
||||
Камера будет доступна по адресу:
|
||||
- MJPEG поток: `http://<IP>:1984/api/stream.mjpeg?src=cam_mjpeg`
|
||||
- Встроенный интерфейс: `http://<IP>:1984`
|
||||
|
||||
---
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -10,6 +11,10 @@ import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -33,6 +38,215 @@ func writeJSON(w http.ResponseWriter, v any) {
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// HandleLogFiles - список доступных лог-файлов
|
||||
func (a *API) HandleLogFiles(w http.ResponseWriter, r *http.Request) {
|
||||
dataDir, err := logger.GetDataLogsDir()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(dataDir, "gpio-*.bin"))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
type LogFileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime time.Time `json:"mod_time"`
|
||||
Time string `json:"time"`
|
||||
Date string `json:"date"`
|
||||
Hour int `json:"hour"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
var result []LogFileInfo
|
||||
for _, file := range files {
|
||||
info, err := os.Stat(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Парсим имя файла: gpio-2026-06-16-11.bin
|
||||
base := filepath.Base(file)
|
||||
parts := strings.Split(strings.TrimSuffix(base, ".bin"), "-")
|
||||
|
||||
var fileTime time.Time
|
||||
if len(parts) == 5 {
|
||||
year, _ := strconv.Atoi(parts[1])
|
||||
month, _ := strconv.Atoi(parts[2])
|
||||
day, _ := strconv.Atoi(parts[3])
|
||||
hour, _ := strconv.Atoi(parts[4])
|
||||
fileTime = time.Date(year, time.Month(month), day, hour, 0, 0, 0, time.Local)
|
||||
} else {
|
||||
fileTime = info.ModTime()
|
||||
}
|
||||
|
||||
result = append(result, LogFileInfo{
|
||||
Name: base,
|
||||
Path: file,
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
Time: fileTime.Format("02.01.2006 15:00"),
|
||||
Date: fileTime.Format("2006-01-02"),
|
||||
Hour: fileTime.Hour(),
|
||||
IsActive: false,
|
||||
})
|
||||
}
|
||||
|
||||
// Сортируем по времени (новые сверху)
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].ModTime.After(result[j].ModTime)
|
||||
})
|
||||
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
// HandleLogData - чтение данных из конкретного bin файла
|
||||
func (a *API) HandleLogData(w http.ResponseWriter, r *http.Request) {
|
||||
filename := r.URL.Query().Get("file")
|
||||
if filename == "" {
|
||||
http.Error(w, "missing file parameter", 400)
|
||||
return
|
||||
}
|
||||
|
||||
// Защита от path traversal
|
||||
filename = filepath.Base(filename)
|
||||
dataDir, err := logger.GetDataLogsDir()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
filePath := filepath.Join(dataDir, filename)
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
http.Error(w, "file not found", 404)
|
||||
return
|
||||
}
|
||||
|
||||
// Читаем бинарный файл
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
type Sample struct {
|
||||
Timestamp int64 `json:"ts"`
|
||||
Time string `json:"time"`
|
||||
Value byte `json:"value"`
|
||||
Count byte `json:"count"`
|
||||
Strength byte `json:"strength"`
|
||||
StrengthName string `json:"strength_name"`
|
||||
}
|
||||
|
||||
var samples []Sample
|
||||
buf := make([]byte, 9) // 8 байт timestamp + 1 байт value
|
||||
|
||||
for {
|
||||
n, err := f.Read(buf)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
if n < 9 {
|
||||
continue
|
||||
}
|
||||
|
||||
ts := int64(binary.LittleEndian.Uint64(buf[0:8]))
|
||||
value := buf[8]
|
||||
|
||||
data := logger.ParseGPIO(value)
|
||||
|
||||
samples = append(samples, Sample{
|
||||
Timestamp: ts,
|
||||
Time: time.UnixMicro(ts).Format("2006-01-02 15:04:05.000"),
|
||||
Value: value,
|
||||
Count: data.Count,
|
||||
Strength: data.Strength,
|
||||
StrengthName: logger.GetStrengthName(data.Strength),
|
||||
})
|
||||
}
|
||||
|
||||
// Ограничиваем количество точек для производительности
|
||||
if len(samples) > 10000 {
|
||||
// Берем каждую N-ю точку
|
||||
step := len(samples) / 10000
|
||||
var filtered []Sample
|
||||
for i := 0; i < len(samples); i += step {
|
||||
filtered = append(filtered, samples[i])
|
||||
}
|
||||
samples = filtered
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{
|
||||
"filename": filename,
|
||||
"total": len(samples),
|
||||
"samples": samples,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleLogEvents - чтение событий
|
||||
func (a *API) HandleLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||
limit := 100
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
eventsPath, err := logger.GetEventHumanLogPath()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(eventsPath)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
// Берем последние N строк
|
||||
if len(lines) > limit+1 {
|
||||
lines = lines[len(lines)-limit-1:]
|
||||
}
|
||||
|
||||
// Парсим строки для JSON
|
||||
type Event struct {
|
||||
Time string `json:"time"`
|
||||
Event string `json:"event"`
|
||||
}
|
||||
|
||||
var events []Event
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Формат: [2026-06-16 11:23:45.123] EVENT: СЕРВЕР_ЗАПУЩЕН
|
||||
parts := strings.SplitN(line, "] EVENT: ", 2)
|
||||
if len(parts) == 2 {
|
||||
events = append(events, Event{
|
||||
Time: strings.TrimPrefix(parts[0], "["),
|
||||
Event: parts[1],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, map[string]any{
|
||||
"events": events,
|
||||
"total": len(events),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *API) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
latest, ok := a.buf.GetLatest()
|
||||
idle := time.Since(a.buf.LastWriteTime())
|
||||
@@ -42,6 +256,8 @@ func (a *API) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
status = "Нет связи"
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
writeJSON(w, map[string]any{
|
||||
"status": status,
|
||||
"uptime_sec": time.Since(a.startTime).Seconds(),
|
||||
@@ -50,6 +266,7 @@ func (a *API) HandleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
"has_data": ok,
|
||||
"latest": latest,
|
||||
"stats": a.buf.Stats(),
|
||||
"server_time": now.Format("02.01.2006 15:04"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -159,9 +376,69 @@ func cors(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Параметры командной строки
|
||||
pipePath := flag.String("pipe", "/tmp/gpio_pipe", "путь к pipe")
|
||||
|
||||
// Параметры retention (хранения)
|
||||
retentionHours := flag.Int("retention-hours", 48, "количество часов хранения логов (0 = отключено)")
|
||||
retentionMB := flag.Int("retention-mb", 5000, "максимальный размер логов в MB (0 = отключено)")
|
||||
retentionIntervalMin := flag.Int("retention-interval", 15, "интервал проверки retention в минутах")
|
||||
|
||||
// Параметры ротации файлов
|
||||
rotationCheckIntervalMin := flag.Int("rotation-check-interval", 1, "интервал проверки ротации в минутах")
|
||||
|
||||
// Параметры буфера
|
||||
bufferSize := flag.Int("buffer-size", 10240, "размер кольцевого буфера (количество элементов)")
|
||||
|
||||
// Параметры мониторинга
|
||||
silence5minAlert := flag.Int("silence-5min", 5, "время тишины для алерта 5 минут (в минутах)")
|
||||
silence10minAlert := flag.Int("silence-10min", 10, "время тишины для алерта 10 минут (в минутах)")
|
||||
watchdogIntervalMin := flag.Int("watchdog-interval", 1, "интервал проверки watchdog в минутах")
|
||||
|
||||
// Параметры анти-спама
|
||||
alertCooldownSec := flag.Int("alert-cooldown", 10, "задержка между одинаковыми алертами в секундах")
|
||||
humanLogIntervalSec := flag.Int("human-log-interval", 5, "интервал записи human-readable логов в секундах")
|
||||
|
||||
// Параметры сервера
|
||||
serverPort := flag.String("port", ":8080", "порт для веб-сервера")
|
||||
cameraURL := flag.String("camera-url", "http://127.0.0.1:1984/api/stream.mjpeg?src=cam_mjpeg", "URL камеры для прокси")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// Конвертируем в time.Duration
|
||||
retentionInterval := time.Duration(*retentionIntervalMin) * time.Minute
|
||||
rotationCheckInterval := time.Duration(*rotationCheckIntervalMin) * time.Minute
|
||||
silence5min := time.Duration(*silence5minAlert) * time.Minute
|
||||
silence10min := time.Duration(*silence10minAlert) * time.Minute
|
||||
watchdogInterval := time.Duration(*watchdogIntervalMin) * time.Minute
|
||||
alertCooldown := time.Duration(*alertCooldownSec) * time.Second
|
||||
humanLogInterval := time.Duration(*humanLogIntervalSec) * time.Second
|
||||
|
||||
// Сохраняем параметры для передачи в логгеры
|
||||
config := &logger.Config{
|
||||
RetentionHours: *retentionHours,
|
||||
RetentionMB: *retentionMB,
|
||||
RetentionIntervalMin: retentionInterval,
|
||||
Silence5Min: silence5min,
|
||||
Silence10Min: silence10min,
|
||||
WatchdogIntervalMin: watchdogInterval,
|
||||
AlertCooldownSec: alertCooldown,
|
||||
HumanLogIntervalSec: humanLogInterval,
|
||||
BufferSize: *bufferSize,
|
||||
}
|
||||
|
||||
// Логируем конфигурацию
|
||||
log.Printf("=== КОНФИГУРАЦИЯ ===")
|
||||
log.Printf("Retention: %d часов, %d MB (проверка каждые %d мин)",
|
||||
*retentionHours, *retentionMB, *retentionIntervalMin)
|
||||
log.Printf("Ротация: проверка каждые %d мин", *rotationCheckIntervalMin)
|
||||
log.Printf("Буфер: %d элементов", *bufferSize)
|
||||
log.Printf("Алерты тишины: %d мин и %d мин (проверка каждые %d мин)",
|
||||
*silence5minAlert, *silence10minAlert, *watchdogIntervalMin)
|
||||
log.Printf("Анти-спам: %d сек, Human-лог: %d сек", *alertCooldownSec, *humanLogIntervalSec)
|
||||
log.Printf("Сервер: %s, Камера: %s", *serverPort, *cameraURL)
|
||||
log.Printf("====================")
|
||||
|
||||
// ===== ИНИЦИАЛИЗАЦИЯ ЛОГГЕРОВ =====
|
||||
|
||||
// 1. EventLogger (события системы)
|
||||
@@ -185,11 +462,12 @@ func main() {
|
||||
defer humanLogger.Close()
|
||||
eventLogger.Event("HUMAN_ЛОГЕР_ГОТОВ")
|
||||
|
||||
// 2. Monitor (watchdog)
|
||||
monitor := logger.NewMonitor(eventLogger)
|
||||
// 2. Monitor (watchdog) с настройками
|
||||
monitor := logger.NewMonitor(eventLogger, config)
|
||||
monitor.SetSilenceThresholds(config.Silence5Min, config.Silence10Min)
|
||||
|
||||
// 3. DataLogger с ротацией
|
||||
dataLogger, err := logger.NewRotatingLogger(eventLogger)
|
||||
// 3. DataLogger с ротацией (с интервалом проверки)
|
||||
dataLogger, err := logger.NewRotatingLogger(eventLogger, rotationCheckInterval)
|
||||
if err != nil {
|
||||
eventLogger.Event("ОШИБКА_ИНИЦИАЛИЗАЦИИ_DATA_ЛОГЕРА")
|
||||
log.Fatal("Ошибка инициализации DataLogger:", err)
|
||||
@@ -197,16 +475,20 @@ func main() {
|
||||
defer dataLogger.Close()
|
||||
eventLogger.Event("DATA_ЛОГЕР_ГОТОВ")
|
||||
|
||||
// 3.5 Retention (автоматическая очистка)
|
||||
retention := logger.NewRetention(48, 5000, eventLogger)
|
||||
retention.Start()
|
||||
eventLogger.Event("RETENTION_ГОТОВ")
|
||||
// 3.5 Retention (автоматическая очистка) с настройками
|
||||
if *retentionHours > 0 || *retentionMB > 0 {
|
||||
retention := logger.NewRetention(*retentionHours, *retentionMB, eventLogger)
|
||||
retention.StartWithInterval(retentionInterval)
|
||||
eventLogger.Event("RETENTION_ГОТОВ")
|
||||
} else {
|
||||
log.Println("Retention отключен (часы и MB = 0)")
|
||||
}
|
||||
|
||||
// 4. RingBuffer
|
||||
buf := adapter.NewRingBuffer(1024 * 10)
|
||||
// 4. RingBuffer с настройкой размера
|
||||
buf := adapter.NewRingBuffer(*bufferSize)
|
||||
|
||||
// 5. PipeReader
|
||||
pipeReader := pipe.NewPipeReader(buf, dataLogger, humanLogger, monitor, eventLogger)
|
||||
// 5. PipeReader с настройками
|
||||
pipeReader := pipe.NewPipeReader(buf, dataLogger, humanLogger, monitor, eventLogger, config)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
pipeReader.Start(ctx, *pipePath)
|
||||
@@ -229,7 +511,14 @@ func main() {
|
||||
case "/stream":
|
||||
cors(api.HandleStream)(w, r)
|
||||
case "/cam":
|
||||
handleCamProxy(w, r)
|
||||
// Передаем URL камеры из конфигурации
|
||||
handleCamProxyWithURL(w, r, *cameraURL)
|
||||
case "/log/files":
|
||||
cors(api.HandleLogFiles)(w, r)
|
||||
case "/log/data":
|
||||
cors(api.HandleLogData)(w, r)
|
||||
case "/log/events":
|
||||
cors(api.HandleLogEvents)(w, r)
|
||||
default:
|
||||
http.Error(w, "not found", 404)
|
||||
}
|
||||
@@ -242,7 +531,56 @@ func main() {
|
||||
}
|
||||
http.Handle("/", http.FileServer(http.FS(webFS)))
|
||||
|
||||
log.Println("Server started on :8080")
|
||||
log.Println("Camera proxy available at /api/cam")
|
||||
log.Fatal(http.ListenAndServe(":8080", nil))
|
||||
log.Printf("Server started on %s", *serverPort)
|
||||
log.Printf("Camera proxy available at /api/cam (source: %s)", *cameraURL)
|
||||
log.Fatal(http.ListenAndServe(*serverPort, nil))
|
||||
}
|
||||
|
||||
// handleCamProxyWithURL проксирует MJPEG поток с указанным URL
|
||||
func handleCamProxyWithURL(w http.ResponseWriter, r *http.Request, cameraURL string) {
|
||||
log.Printf("[cam] proxy request from %s to %s", r.RemoteAddr, cameraURL)
|
||||
|
||||
resp, err := http.Get(cameraURL)
|
||||
if err != nil {
|
||||
log.Printf("[cam] camera unavailable: %v", err)
|
||||
http.Error(w, "camera unavailable", 503)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Прокидываем заголовки
|
||||
for k, vv := range resp.Header {
|
||||
for _, v := range vv {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "stream unsupported", 500)
|
||||
return
|
||||
}
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
_, err = w.Write(buf[:n])
|
||||
if err != nil {
|
||||
log.Printf("[cam] client disconnected")
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.Printf("[cam] stream ended: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,3 +31,7 @@
|
||||
|
||||
#### Установка TypeScript и типов Node.js
|
||||
```npm install -D typescript @types/node```
|
||||
|
||||
```bash
|
||||
chmod +x node_modules/.bin/tsc
|
||||
```
|
||||
@@ -12,9 +12,9 @@ body {
|
||||
|
||||
/* ===== HEADER BAR ===== */
|
||||
.header-bar {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 15px;
|
||||
gap: 16px;
|
||||
}
|
||||
@@ -22,7 +22,20 @@ body {
|
||||
h1 {
|
||||
font-size: 22px;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-time {
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid rgba(0, 255, 136, 0.35);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 255, 136, 0.05);
|
||||
}
|
||||
|
||||
.header-bar .cam-btn {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
/* ===== CAMERA BUTTON (теперь в header) ===== */
|
||||
@@ -406,6 +419,53 @@ h1 {
|
||||
inset 0 0 10px rgba(0, 255, 136, 0.1);
|
||||
}
|
||||
|
||||
/* ===== AUDIO INDICATOR ===== */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
#audio-indicator {
|
||||
transition: all 0.3s ease;
|
||||
font-family: monospace;
|
||||
font-size: 16px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid #333;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#audio-indicator.active {
|
||||
border-color: #00ff88;
|
||||
box-shadow: 0 0 15px rgba(0, 255, 136, 0.3);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* ===== ГОРЯЧИЕ КЛАВИШИ (подсказка) ===== */
|
||||
.hotkey-hint {
|
||||
font-size: 11px;
|
||||
opacity: 0.4;
|
||||
text-align: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.hotkey-hint kbd {
|
||||
background: rgba(0, 255, 136, 0.1);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid rgba(0, 255, 136, 0.2);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* ===== MOBILE ===== */
|
||||
@media (max-width: 500px) {
|
||||
.header-bar {
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
|
||||
<div class="header-bar">
|
||||
<h1>GPIO МОНИТОРИНГ В РЕАЛЬНОМ ВРЕМЕНИ</h1>
|
||||
<button id="camToggle" class="cam-btn">
|
||||
<span class="cam-btn-icon">📷</span>
|
||||
<span class="cam-btn-text">Камера</span>
|
||||
</button>
|
||||
<div style="display:flex; gap:12px; align-items:center;">
|
||||
<a href="/logs.html" style="color:#00ff88; text-decoration:none; font-size:14px; border:1px solid #00ff88; padding:6px 14px; border-radius:6px;">📋 Журнал</a>
|
||||
<div id="server-time" class="header-time">--:--:--</div>
|
||||
<div id="audio-indicator" style="font-size:18px; padding:4px 12px; border-radius:6px; background:rgba(0,0,0,0.4); border:1px solid #333; min-width:50px; text-align:center;">🔇</div>
|
||||
<button id="camToggle" class="cam-btn">📷 КАМЕРА</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
@@ -62,11 +64,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card wide">
|
||||
<h3>ГРАФИК КАНАЛА 1</h3>
|
||||
<canvas id="signalChart" height="120"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="card wide">
|
||||
<h3 class="accordion-header" id="history-header">▶ ПРИНЯТЫЕ ДАННЫЕ</h3>
|
||||
<div class="accordion-body" id="history-body">
|
||||
@@ -81,7 +78,7 @@
|
||||
<div class="resizer" id="dragBar"></div>
|
||||
|
||||
<!-- RIGHT: камера -->
|
||||
<div class="panel right" id="rightPanel">
|
||||
<div class="panel right hidden" id="rightPanel">
|
||||
<div class="cam-modal">
|
||||
<div class="cam-header">
|
||||
<span>Видео с камеры</span>
|
||||
|
||||
@@ -1,17 +1,38 @@
|
||||
// app.js (оркестратор)
|
||||
import { fetchHealth, fetchHistory } from "./data.js";
|
||||
import { state, addSignals, resetBufferTracking, updatePeakHolder, forceResetPeak, getPeakInfo } from "./state.js";
|
||||
import { initDOM, setStatus, setMeta, setBars, setSignal, DOM } from "./ui.js";
|
||||
import {
|
||||
fetchHealth,
|
||||
fetchHistory
|
||||
} from "./data.js";
|
||||
import {
|
||||
state,
|
||||
addSignals,
|
||||
resetBufferTracking,
|
||||
updatePeakHolder,
|
||||
updateSignalPeakHolder,
|
||||
setPeakHolderListener
|
||||
} from "./state.js";
|
||||
import {
|
||||
initDOM,
|
||||
setServerTime,
|
||||
setStatus,
|
||||
setMeta,
|
||||
setBars,
|
||||
setSignal,
|
||||
setAudioIndicator,
|
||||
DOM
|
||||
} from "./ui.js";
|
||||
import { initAccordion } from "./accordion.js";
|
||||
import { drawChart } from "./chart.js";
|
||||
import { initResize } from "./layout.js";
|
||||
import { initCamera } from "./camera.js";
|
||||
import { audioEngine } from "./audio.js";
|
||||
|
||||
let lastChart: number = 0;
|
||||
let connectionLost: boolean = false;
|
||||
let consecutiveErrors: number = 0;
|
||||
const MAX_ERRORS: number = 3;
|
||||
let pollTimer: number | null = null;
|
||||
let lastSignalLevel: number = 0;
|
||||
|
||||
// ================= INIT =================
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
@@ -19,6 +40,28 @@ window.addEventListener("DOMContentLoaded", () => {
|
||||
initAccordion();
|
||||
initResize();
|
||||
initCamera();
|
||||
|
||||
// Инициализация звука при первом клике
|
||||
document.addEventListener('click', () => {
|
||||
audioEngine.updateLevel(0);
|
||||
}, { once: true });
|
||||
|
||||
setPeakHolderListener(() => {
|
||||
if (!connectionLost) {
|
||||
setBars(state.peakValue);
|
||||
setSignal(state.peakSignalLevel);
|
||||
|
||||
const signalLevel = state.peakSignalLevel;
|
||||
if (signalLevel !== lastSignalLevel) {
|
||||
lastSignalLevel = signalLevel;
|
||||
audioEngine.updateLevel(signalLevel);
|
||||
|
||||
// Обновляем индикатор звука
|
||||
const isActive = signalLevel > 0;
|
||||
setAudioIndicator(isActive, signalLevel);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
@@ -45,6 +88,27 @@ document.addEventListener("keydown", (e: KeyboardEvent) => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -58,6 +122,10 @@ async function update(): Promise<void> {
|
||||
|
||||
consecutiveErrors = 0;
|
||||
|
||||
if (health.server_time) {
|
||||
setServerTime(health.server_time);
|
||||
}
|
||||
|
||||
if (connectionLost) {
|
||||
console.log("[app] Соединение восстановлено");
|
||||
}
|
||||
@@ -71,6 +139,11 @@ async function update(): Promise<void> {
|
||||
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 : [];
|
||||
@@ -88,11 +161,24 @@ async function update(): Promise<void> {
|
||||
if (isOnline) {
|
||||
const displayValue = updatePeakHolder(percent);
|
||||
setBars(displayValue);
|
||||
setSignal(level);
|
||||
|
||||
const displaySignalLevel = updateSignalPeakHolder(level);
|
||||
setSignal(displaySignalLevel);
|
||||
|
||||
const currentSignalLevel = state.peakSignalLevel;
|
||||
if (currentSignalLevel !== lastSignalLevel) {
|
||||
lastSignalLevel = currentSignalLevel;
|
||||
audioEngine.updateLevel(currentSignalLevel);
|
||||
setAudioIndicator(currentSignalLevel > 0, currentSignalLevel);
|
||||
}
|
||||
} else {
|
||||
// Сервер НЕ на связи - сбрасываем индикаторы в 0 и делаем серыми
|
||||
setBars(0, true); // true = режим "нет соединения"
|
||||
setSignal(0, true); // true = режим "нет соединения"
|
||||
setBars(0, true);
|
||||
setSignal(0, true);
|
||||
if (lastSignalLevel > 0) {
|
||||
lastSignalLevel = 0;
|
||||
audioEngine.updateLevel(0);
|
||||
setAudioIndicator(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (DOM.lastByte) {
|
||||
@@ -122,10 +208,17 @@ async function update(): Promise<void> {
|
||||
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) {
|
||||
@@ -150,5 +243,15 @@ window.addEventListener("focus", () => {
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("beforeunload", () => {
|
||||
audioEngine.dispose();
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
_mutedLevel?: number;
|
||||
}
|
||||
}
|
||||
|
||||
startPolling();
|
||||
update();
|
||||
304
cmd/server/web/js/audio.ts
Normal file
304
cmd/server/web/js/audio.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
// audio.ts - Звуковой движок для сигнала
|
||||
|
||||
export class AudioEngine {
|
||||
private audioContext: AudioContext | null = null;
|
||||
private oscillator: OscillatorNode | null = null;
|
||||
private gainNode: GainNode | null = null;
|
||||
private isPlaying: boolean = false;
|
||||
private currentLevel: number = 0;
|
||||
private currentFrequency: number = 800;
|
||||
private fadeTimeout: number | null = null;
|
||||
private readonly FADE_TIME: number = 0.05; // 50 мс для плавного старта/остановки
|
||||
private readonly FREQUENCY_MAP: Record<number, number> = {
|
||||
1: 800, // Уровень 1 → 800 Гц
|
||||
2: 1000, // Уровень 2 → 1000 Гц
|
||||
3: 1200 // Уровень 3 → 1200 Гц
|
||||
};
|
||||
|
||||
constructor() {
|
||||
// Инициализация при первом использовании
|
||||
}
|
||||
|
||||
/**
|
||||
* Инициализация AudioContext (требует взаимодействия пользователя)
|
||||
*/
|
||||
private initAudio(): boolean {
|
||||
try {
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
}
|
||||
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
this.audioContext.resume();
|
||||
}
|
||||
|
||||
return this.audioContext.state === 'running';
|
||||
} catch (error) {
|
||||
console.warn('[Audio] Ошибка инициализации:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение частоты для уровня
|
||||
*/
|
||||
private getFrequencyForLevel(level: number): number {
|
||||
return this.FREQUENCY_MAP[level] || 800;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление частоты осциллятора
|
||||
*/
|
||||
private updateFrequency(frequency: number): void {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание звукового графа
|
||||
*/
|
||||
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.oscillator.connect(this.gainNode);
|
||||
this.gainNode.connect(this.audioContext.destination);
|
||||
|
||||
// Запускаем осциллятор
|
||||
this.oscillator.start();
|
||||
|
||||
this.isPlaying = true;
|
||||
console.log(`[Audio] Звук создан, частота: ${frequency} Гц, уровень: ${level}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление уровня сигнала
|
||||
* @param level - уровень сигнала (0-3)
|
||||
*/
|
||||
public updateLevel(level: number): void {
|
||||
// Инициализация при первом вызове
|
||||
if (!this.audioContext) {
|
||||
if (!this.initAudio()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Если AudioContext не готов, пробуем восстановить
|
||||
if (this.audioContext?.state !== 'running') {
|
||||
this.audioContext?.resume();
|
||||
return;
|
||||
}
|
||||
|
||||
// Приводим уровень к числу
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Плавное затухание и остановка
|
||||
*/
|
||||
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] Затухание...');
|
||||
}
|
||||
|
||||
/**
|
||||
* Немедленная остановка звука
|
||||
*/
|
||||
private stopSound(): void {
|
||||
if (this.oscillator) {
|
||||
try {
|
||||
this.oscillator.stop();
|
||||
this.oscillator.disconnect();
|
||||
} catch (e) {
|
||||
// Игнорируем ошибки при остановке
|
||||
}
|
||||
this.oscillator = null;
|
||||
}
|
||||
|
||||
if (this.gainNode) {
|
||||
try {
|
||||
this.gainNode.disconnect();
|
||||
} catch (e) {
|
||||
// Игнорируем ошибки
|
||||
}
|
||||
this.gainNode = null;
|
||||
}
|
||||
|
||||
this.isPlaying = false;
|
||||
|
||||
if (this.fadeTimeout) {
|
||||
clearTimeout(this.fadeTimeout);
|
||||
this.fadeTimeout = null;
|
||||
}
|
||||
|
||||
console.log('[Audio] Звук остановлен');
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверка, активен ли звук
|
||||
*/
|
||||
public isActive(): boolean {
|
||||
return this.isPlaying && this.currentLevel > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение текущей частоты
|
||||
*/
|
||||
public getCurrentFrequency(): number {
|
||||
return this.currentFrequency;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение текущего уровня
|
||||
*/
|
||||
public getCurrentLevel(): number {
|
||||
return this.currentLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Полная остановка и освобождение ресурсов
|
||||
*/
|
||||
public dispose(): void {
|
||||
this.stopSound();
|
||||
if (this.audioContext) {
|
||||
try {
|
||||
this.audioContext.close();
|
||||
} catch (e) {
|
||||
// Игнорируем
|
||||
}
|
||||
this.audioContext = null;
|
||||
}
|
||||
this.currentLevel = 0;
|
||||
this.currentFrequency = 800;
|
||||
console.log('[Audio] Ресурсы освобождены');
|
||||
}
|
||||
}
|
||||
|
||||
// Экспортируем одиночный экземпляр
|
||||
export const audioEngine = new AudioEngine();
|
||||
@@ -18,8 +18,6 @@ export function initCamera(): void {
|
||||
camImage = document.getElementById("camImage") as HTMLImageElement;
|
||||
crosshair = document.getElementById("crosshair");
|
||||
|
||||
// Проверяем доступность потока
|
||||
checkStreamAvailability();
|
||||
|
||||
const btn = document.getElementById("camToggle");
|
||||
|
||||
@@ -37,6 +35,7 @@ export function initCamera(): void {
|
||||
// Показываем перекрестие
|
||||
if (crosshair) {
|
||||
crosshair.style.display = "block";
|
||||
crosshair.style.opacity = "0.5";
|
||||
}
|
||||
|
||||
if (camImage) {
|
||||
@@ -114,29 +113,6 @@ export function initCamera(): void {
|
||||
|
||||
btn?.addEventListener("click", toggle);
|
||||
|
||||
// Проверка доступности камеры
|
||||
async function checkStreamAvailability(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch('/api/stream');
|
||||
const data: StreamResponse = await response.json();
|
||||
|
||||
if (data.available && data.cam) {
|
||||
console.log("[camera] stream available:", data.source);
|
||||
// Автозапуск если камера доступна
|
||||
if (!isOpen) {
|
||||
open();
|
||||
}
|
||||
} else {
|
||||
console.log("[camera] stream not available");
|
||||
if (crosshair) {
|
||||
crosshair.style.display = 'none';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("[camera] check failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Мониторинг состояния потока
|
||||
function startStreamMonitoring(): void {
|
||||
stopStreamMonitoring();
|
||||
|
||||
@@ -7,6 +7,7 @@ interface HealthResponse {
|
||||
pipe_alive: boolean;
|
||||
has_data: boolean;
|
||||
latest: number;
|
||||
server_time?: string;
|
||||
stats: {
|
||||
filled: number;
|
||||
bytes_per_sec: number;
|
||||
|
||||
@@ -5,13 +5,23 @@ interface AppState {
|
||||
signalHistory: number[];
|
||||
lastBufferSize: number;
|
||||
|
||||
// Peak Holder параметры
|
||||
peakValue: number; // Текущее удерживаемое пиковое значение
|
||||
currentValue: number; // Текущее реальное значение (без сглаживания)
|
||||
peakTimer: number | null; // Таймер для сброса пика
|
||||
// Peak Holder
|
||||
peakValue: number; // Текущее удерживаемое пиковое значение амплитуды
|
||||
currentValue: number; // Текущее реальное значение амплитуды
|
||||
peakTimer: number | null; // Таймер для сброса пика амплитуды
|
||||
peakHoldTime: number; // Время удержания пика в миллисекундах
|
||||
|
||||
// Peak Holder для уровня сигнала
|
||||
peakSignalLevel: number;
|
||||
currentSignalLevel: number;
|
||||
signalPeakTimer: number | null;
|
||||
signalPeakHoldTime: number;
|
||||
|
||||
hasPeak: boolean;
|
||||
}
|
||||
|
||||
type PeakHolderListener = () => void;
|
||||
|
||||
export const state: AppState = {
|
||||
maxPoints: 1000,
|
||||
signalHistory: [],
|
||||
@@ -20,14 +30,66 @@ export const state: AppState = {
|
||||
peakValue: 0,
|
||||
currentValue: 0,
|
||||
peakTimer: null,
|
||||
peakHoldTime: 3000, // миллисекунды удержания пика
|
||||
peakHoldTime: 3000, // 3 секунды
|
||||
|
||||
peakSignalLevel: 0,
|
||||
currentSignalLevel: 0,
|
||||
signalPeakTimer: null,
|
||||
signalPeakHoldTime: 3000,
|
||||
|
||||
hasPeak: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Основная функция peak holder
|
||||
* @param target - новое значение (0-63)
|
||||
* @returns значение для отображения (пик, если таймер активен)
|
||||
*/
|
||||
let peakHolderListener: PeakHolderListener | null = null;
|
||||
|
||||
/** Обновление UI при сбросе peak holder по таймеру (между опросами API). */
|
||||
export function setPeakHolderListener(listener: PeakHolderListener | null): void {
|
||||
peakHolderListener = listener;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ========== ФУНКЦИИ ДЛЯ АМПЛИТУДЫ ==========
|
||||
|
||||
export function updatePeakHolder(target: number): number {
|
||||
// Обновляем текущее значение
|
||||
state.currentValue = target;
|
||||
@@ -36,80 +98,69 @@ export function updatePeakHolder(target: number): number {
|
||||
if (target > state.peakValue) {
|
||||
// Устанавливаем новый пик
|
||||
state.peakValue = target;
|
||||
|
||||
// Сбрасываем и перезапускаем таймер
|
||||
resetPeakTimer();
|
||||
startPeakTimer();
|
||||
}
|
||||
|
||||
// Возвращаем текущий пик для отображения
|
||||
state.hasPeak = true;
|
||||
scheduleAmplitudePeakRelease();
|
||||
} else if (target < state.peakValue && state.peakTimer === null) {
|
||||
// Пик выше текущего значения, но таймер пропал — перезапускаем удержание
|
||||
scheduleAmplitudePeakRelease();
|
||||
} // Возвращаем текущий пик
|
||||
return state.peakValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Запуск таймера сброса пика
|
||||
*/
|
||||
function startPeakTimer(): void {
|
||||
if (state.peakTimer !== null) {
|
||||
clearTimeout(state.peakTimer);
|
||||
// ========== ФУНКЦИИ ДЛЯ УРОВНЯ СИГНАЛА ==========
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
state.peakTimer = window.setTimeout(() => {
|
||||
resetPeak();
|
||||
}, state.peakHoldTime);
|
||||
// Возвращаем текущий пик
|
||||
return state.peakSignalLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сброс таймера пика
|
||||
*/
|
||||
function resetPeakTimer(): void {
|
||||
// ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ==========
|
||||
|
||||
export function forceResetSignalPeak(): void {
|
||||
if (state.signalPeakTimer !== null) {
|
||||
clearTimeout(state.signalPeakTimer);
|
||||
state.signalPeakTimer = null;
|
||||
}
|
||||
state.peakSignalLevel = 0;
|
||||
state.currentSignalLevel = 0;
|
||||
state.hasPeak = false;
|
||||
}
|
||||
|
||||
export function setSignalPeakHoldTime(ms: number): void {
|
||||
state.signalPeakHoldTime = ms;
|
||||
}
|
||||
|
||||
export function getSignalPeakInfo(): { peak: number; current: number; timerActive: boolean } {
|
||||
return {
|
||||
peak: state.peakSignalLevel,
|
||||
current: state.currentSignalLevel,
|
||||
timerActive: state.signalPeakTimer !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export function forceResetPeak(): void {
|
||||
if (state.peakTimer !== null) {
|
||||
clearTimeout(state.peakTimer);
|
||||
state.peakTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сброс пикового значения до текущего уровня
|
||||
*/
|
||||
function resetPeak(): void {
|
||||
state.peakValue = state.currentValue;
|
||||
state.peakTimer = null;
|
||||
|
||||
// Опционально: лог для отладки
|
||||
// console.log(`[PeakHolder] Reset to: ${state.peakValue}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Принудительный сброс пика (например, при потере связи)
|
||||
*/
|
||||
export function forceResetPeak(): void {
|
||||
resetPeakTimer();
|
||||
state.peakValue = 0;
|
||||
state.currentValue = 0;
|
||||
state.hasPeak = false;
|
||||
forceResetSignalPeak();
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновление времени удержания пика
|
||||
* @param ms - время в миллисекундах
|
||||
*/
|
||||
export function setPeakHoldTime(ms: number): void {
|
||||
state.peakHoldTime = ms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение текущего состояния пика (для отладки)
|
||||
*/
|
||||
export function getPeakInfo(): { peak: number; current: number; timerActive: boolean } {
|
||||
return {
|
||||
peak: state.peakValue,
|
||||
current: state.currentValue,
|
||||
timerActive: state.peakTimer !== null,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== ОСТАВЛЯЕМ СУЩЕСТВУЮЩИЕ ФУНКЦИИ БЕЗ ИЗМЕНЕНИЙ =====
|
||||
|
||||
export function addSignal(value: number): void {
|
||||
state.signalHistory.push(value);
|
||||
if (state.signalHistory.length > state.maxPoints) {
|
||||
@@ -137,5 +188,5 @@ export function addSignals(values: number[], bufferSize: number): void {
|
||||
|
||||
export function resetBufferTracking(): void {
|
||||
state.lastBufferSize = 0;
|
||||
forceResetPeak(); // Также сбрасываем пик при потере связи
|
||||
}
|
||||
forceResetPeak();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// ui.ts (DOM слой)
|
||||
export interface DOMElements {
|
||||
serverTime: HTMLElement | null;
|
||||
status: HTMLElement | null;
|
||||
meta: HTMLElement | null;
|
||||
latest: HTMLElement | null;
|
||||
@@ -10,9 +11,11 @@ export interface DOMElements {
|
||||
barFill: HTMLElement | null;
|
||||
historyHeader: HTMLElement | null;
|
||||
historyBody: HTMLElement | null;
|
||||
audioIndicator: HTMLElement | null;
|
||||
}
|
||||
|
||||
export const DOM: DOMElements = {
|
||||
serverTime: null,
|
||||
status: null,
|
||||
meta: null,
|
||||
latest: null,
|
||||
@@ -23,9 +26,11 @@ export const DOM: DOMElements = {
|
||||
barFill: null,
|
||||
historyHeader: null,
|
||||
historyBody: null,
|
||||
audioIndicator: null,
|
||||
};
|
||||
|
||||
export function initDOM(): void {
|
||||
DOM.serverTime = document.getElementById("server-time");
|
||||
DOM.status = document.getElementById("status");
|
||||
DOM.meta = document.getElementById("meta");
|
||||
DOM.latest = document.getElementById("latest");
|
||||
@@ -36,6 +41,11 @@ export function initDOM(): void {
|
||||
DOM.historyHeader = document.getElementById("history-header");
|
||||
DOM.historyBody = document.getElementById("history-body");
|
||||
DOM.bars = document.querySelectorAll(".sig-bar");
|
||||
DOM.audioIndicator = document.getElementById("audio-indicator");
|
||||
}
|
||||
|
||||
export function setServerTime(v: string): void {
|
||||
if (DOM.serverTime) DOM.serverTime.textContent = v;
|
||||
}
|
||||
|
||||
export function setStatus(v: string, isError: boolean = false): void {
|
||||
@@ -100,3 +110,22 @@ export function setSignal(level: number, noConnection: boolean = false): void {
|
||||
bars[2].classList.remove("dim");
|
||||
}
|
||||
}
|
||||
|
||||
// ФУНКЦИЮ ДЛЯ ИНДИКАТОРА ЗВУКА
|
||||
export function setAudioIndicator(active: boolean, level: number = 0): void {
|
||||
const el = DOM.audioIndicator;
|
||||
if (!el) return;
|
||||
|
||||
if (active && level > 0) {
|
||||
el.textContent = `🔊 ${level}`;
|
||||
el.style.color = '#00ff88';
|
||||
el.style.opacity = '1';
|
||||
// Анимация пульсации
|
||||
el.style.animation = 'pulse 1s ease-in-out infinite';
|
||||
} else {
|
||||
el.textContent = '🔇';
|
||||
el.style.color = '#666';
|
||||
el.style.opacity = '0.5';
|
||||
el.style.animation = 'none';
|
||||
}
|
||||
}
|
||||
373
cmd/server/web/logs.html
Normal file
373
cmd/server/web/logs.html
Normal file
@@ -0,0 +1,373 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Журнал логов</title>
|
||||
<link rel="icon" type="image/png" href="/favicon.png">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
/* Дополнительные стили для журнала */
|
||||
.logs-container {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.logs-sidebar {
|
||||
flex: 0 0 280px;
|
||||
border-right: 1px solid #00ff88;
|
||||
padding-right: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.logs-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-file-item {
|
||||
padding: 8px 12px;
|
||||
margin: 4px 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.log-file-item:hover {
|
||||
background: rgba(0,255,136,0.1);
|
||||
border-color: #00ff88;
|
||||
}
|
||||
|
||||
.log-file-item.active {
|
||||
background: rgba(0,255,136,0.2);
|
||||
border-color: #00ff88;
|
||||
}
|
||||
|
||||
.log-file-item .file-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.log-file-item .file-info {
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.log-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.log-stat-card {
|
||||
padding: 12px;
|
||||
border: 1px solid #00ff88;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.log-stat-card .value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.log-stat-card .label {
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
text-align: left;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid #00ff88;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #050805;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid rgba(0,255,136,0.1);
|
||||
}
|
||||
|
||||
.data-table tr:hover {
|
||||
background: rgba(0,255,136,0.05);
|
||||
}
|
||||
|
||||
.strength-0 { color: #666; }
|
||||
.strength-1 { color: #88ff88; }
|
||||
.strength-2 { color: #ffaa44; }
|
||||
.strength-3 { color: #ff4444; }
|
||||
|
||||
.events-list {
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.events-list .event-time {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 6px 16px;
|
||||
background: transparent;
|
||||
color: #00ff88;
|
||||
border: 1px solid #00ff88;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: rgba(0,255,136,0.2);
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
background: rgba(0,255,136,0.1);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header-bar">
|
||||
<h1>📋 ЖУРНАЛ ЛОГОВ</h1>
|
||||
<div id="server-time" class="header-time">--:--:--</div>
|
||||
<a href="/" style="color:#00ff88; text-decoration:none; font-size:14px;">← На главную</a>
|
||||
</div>
|
||||
|
||||
<div class="logs-container">
|
||||
<div class="logs-sidebar">
|
||||
<h3 style="margin-top:0;">📁 Файлы данных</h3>
|
||||
<div id="fileList"></div>
|
||||
</div>
|
||||
|
||||
<div class="logs-content">
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="data">📊 Данные</button>
|
||||
<button class="tab-btn" data-tab="stats">📈 Статистика</button>
|
||||
<button class="tab-btn" data-tab="events">📋 События</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-data" class="tab-content active">
|
||||
<div id="logStats" class="log-stats"></div>
|
||||
<div id="chartContainer" style="margin-bottom:16px;">
|
||||
<canvas id="logChart" height="200"></canvas>
|
||||
</div>
|
||||
<div id="dataTableContainer" style="max-height:400px; overflow-y:auto;"></div>
|
||||
</div>
|
||||
|
||||
<div id="tab-stats" class="tab-content">
|
||||
<div id="statsContent">Выберите файл для просмотра статистики</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-events" class="tab-content">
|
||||
<div id="eventsContent" class="events-list">Загрузка...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { drawChart } from '/dist/chart.js';
|
||||
|
||||
// Состояние
|
||||
let currentFile = null;
|
||||
let currentData = null;
|
||||
let currentEvents = [];
|
||||
|
||||
// DOM элементы
|
||||
const fileList = document.getElementById('fileList');
|
||||
const logStats = document.getElementById('logStats');
|
||||
const dataTableContainer = document.getElementById('dataTableContainer');
|
||||
const logChart = document.getElementById('logChart');
|
||||
const eventsContent = document.getElementById('eventsContent');
|
||||
|
||||
// Загрузка списка файлов
|
||||
async function loadFileList() {
|
||||
try {
|
||||
const response = await fetch('/api/log/files');
|
||||
const files = await response.json();
|
||||
|
||||
fileList.innerHTML = '';
|
||||
files.forEach(file => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'log-file-item';
|
||||
if (currentFile === file.name) div.classList.add('active');
|
||||
div.innerHTML = `
|
||||
<div class="file-name">${file.name}</div>
|
||||
<div class="file-info">${file.time} | ${(file.size/1024).toFixed(1)} KB</div>
|
||||
`;
|
||||
div.addEventListener('click', () => loadFileData(file.name));
|
||||
fileList.appendChild(div);
|
||||
});
|
||||
|
||||
// Автоматически загружаем первый файл
|
||||
if (files.length > 0 && !currentFile) {
|
||||
loadFileData(files[0].name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading file list:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка данных файла
|
||||
async function loadFileData(filename) {
|
||||
currentFile = filename;
|
||||
|
||||
// Обновляем активный элемент в списке
|
||||
document.querySelectorAll('.log-file-item').forEach(el => el.classList.remove('active'));
|
||||
const items = document.querySelectorAll('.log-file-item');
|
||||
for (const item of items) {
|
||||
if (item.textContent.includes(filename)) {
|
||||
item.classList.add('active');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/log/data?file=${encodeURIComponent(filename)}`);
|
||||
const data = await response.json();
|
||||
currentData = data;
|
||||
|
||||
renderData(data);
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Отрисовка данных
|
||||
function renderData(data) {
|
||||
const samples = data.samples || [];
|
||||
|
||||
// Статистика
|
||||
if (samples.length > 0) {
|
||||
const values = samples.map(s => s.value);
|
||||
const counts = samples.map(s => s.count);
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const avg = values.reduce((a,b) => a + b, 0) / values.length;
|
||||
const totalObjects = counts.reduce((a,b) => a + b, 0);
|
||||
|
||||
logStats.innerHTML = `
|
||||
<div class="log-stat-card">
|
||||
<div class="value">${samples.length}</div>
|
||||
<div class="label">Всего точек</div>
|
||||
</div>
|
||||
<div class="log-stat-card">
|
||||
<div class="value">${max}</div>
|
||||
<div class="label">Максимум</div>
|
||||
</div>
|
||||
<div class="log-stat-card">
|
||||
<div class="value">${avg.toFixed(1)}</div>
|
||||
<div class="label">Среднее</div>
|
||||
</div>
|
||||
<div class="log-stat-card">
|
||||
<div class="value">${totalObjects}</div>
|
||||
<div class="label">Объектов всего</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// График
|
||||
if (logChart && samples.length > 0) {
|
||||
const history = samples.map(s => s.count);
|
||||
drawChart(logChart, history);
|
||||
}
|
||||
|
||||
// Таблица — только Время, Объектов, Сила
|
||||
if (samples.length > 0) {
|
||||
let html = `<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Время</th>
|
||||
<th>Объектов</th>
|
||||
<th>Сила</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>`;
|
||||
|
||||
const displaySamples = samples.slice(-200);
|
||||
displaySamples.forEach(s => {
|
||||
const strengthClass = `strength-${s.strength}`;
|
||||
html += `<tr>
|
||||
<td>${s.time}</td>
|
||||
<td>${s.count}</td>
|
||||
<td class="${strengthClass}">${s.strength_name}</td>
|
||||
</tr>`;
|
||||
});
|
||||
|
||||
html += '</tbody></table>';
|
||||
dataTableContainer.innerHTML = html;
|
||||
} else {
|
||||
dataTableContainer.innerHTML = '<p>Нет данных</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка событий
|
||||
async function loadEvents() {
|
||||
try {
|
||||
const response = await fetch('/api/log/events?limit=200');
|
||||
const data = await response.json();
|
||||
currentEvents = data.events || [];
|
||||
|
||||
let html = '';
|
||||
currentEvents.forEach(e => {
|
||||
html += `<div><span class="event-time">[${e.time}]</span> ${e.event}</div>`;
|
||||
});
|
||||
eventsContent.innerHTML = html || 'Нет событий';
|
||||
} catch (error) {
|
||||
eventsContent.innerHTML = 'Ошибка загрузки событий';
|
||||
}
|
||||
}
|
||||
|
||||
// Переключение табов
|
||||
document.querySelectorAll('.tab-btn[data-tab]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab-btn[data-tab]').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
const tabName = btn.dataset.tab;
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
|
||||
document.getElementById(`tab-${tabName}`).classList.add('active');
|
||||
|
||||
if (tabName === 'events') {
|
||||
loadEvents();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Инициализация
|
||||
loadFileList();
|
||||
|
||||
// Обновление времени
|
||||
function updateTime() {
|
||||
const now = new Date();
|
||||
document.getElementById('server-time').textContent = now.toLocaleTimeString();
|
||||
}
|
||||
setInterval(updateTime, 1000);
|
||||
updateTime();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
38
internal/logger/config.go
Normal file
38
internal/logger/config.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package logger
|
||||
|
||||
import "time"
|
||||
|
||||
// Config содержит настройки для всех компонентов логирования
|
||||
type Config struct {
|
||||
// Retention настройки
|
||||
RetentionHours int
|
||||
RetentionMB int
|
||||
RetentionIntervalMin time.Duration
|
||||
|
||||
// Мониторинг тишины
|
||||
Silence5Min time.Duration
|
||||
Silence10Min time.Duration
|
||||
WatchdogIntervalMin time.Duration
|
||||
|
||||
// Анти-спам и интервалы
|
||||
AlertCooldownSec time.Duration
|
||||
HumanLogIntervalSec time.Duration
|
||||
|
||||
// Буфер
|
||||
BufferSize int
|
||||
}
|
||||
|
||||
// DefaultConfig возвращает конфигурацию по умолчанию
|
||||
func DefaultConfig() *Config {
|
||||
return &Config{
|
||||
RetentionHours: 48,
|
||||
RetentionMB: 5000,
|
||||
RetentionIntervalMin: 15 * time.Minute,
|
||||
Silence5Min: 5 * time.Minute,
|
||||
Silence10Min: 10 * time.Minute,
|
||||
WatchdogIntervalMin: 1 * time.Minute,
|
||||
AlertCooldownSec: 10 * time.Second,
|
||||
HumanLogIntervalSec: 5 * time.Second,
|
||||
BufferSize: 10240,
|
||||
}
|
||||
}
|
||||
@@ -6,20 +6,34 @@ import (
|
||||
)
|
||||
|
||||
type Monitor struct {
|
||||
eventLogger *EventLogger
|
||||
lastWrite time.Time
|
||||
mu sync.RWMutex
|
||||
eventLogger *EventLogger
|
||||
lastWrite time.Time
|
||||
mu sync.RWMutex
|
||||
silence5Min time.Duration
|
||||
silence10Min time.Duration
|
||||
watchdogInterval time.Duration
|
||||
}
|
||||
|
||||
func NewMonitor(eventLogger *EventLogger) *Monitor {
|
||||
func NewMonitor(eventLogger *EventLogger, config *Config) *Monitor {
|
||||
m := &Monitor{
|
||||
eventLogger: eventLogger,
|
||||
lastWrite: time.Now(),
|
||||
eventLogger: eventLogger,
|
||||
lastWrite: time.Now(),
|
||||
silence5Min: config.Silence5Min,
|
||||
silence10Min: config.Silence10Min,
|
||||
watchdogInterval: config.WatchdogIntervalMin,
|
||||
}
|
||||
go m.watchdogLoop()
|
||||
return m
|
||||
}
|
||||
|
||||
// SetSilenceThresholds позволяет изменить пороги тишины после создания
|
||||
func (m *Monitor) SetSilenceThresholds(silence5Min, silence10Min time.Duration) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.silence5Min = silence5Min
|
||||
m.silence10Min = silence10Min
|
||||
}
|
||||
|
||||
func (m *Monitor) RecordWrite() {
|
||||
m.mu.Lock()
|
||||
m.lastWrite = time.Now()
|
||||
@@ -27,15 +41,19 @@ func (m *Monitor) RecordWrite() {
|
||||
}
|
||||
|
||||
func (m *Monitor) watchdogLoop() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
ticker := time.NewTicker(m.watchdogInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
m.mu.RLock()
|
||||
silence := time.Since(m.lastWrite)
|
||||
silence5Min := m.silence5Min
|
||||
silence10Min := m.silence10Min
|
||||
m.mu.RUnlock()
|
||||
|
||||
if silence > 10*time.Minute {
|
||||
if silence > silence10Min {
|
||||
m.eventLogger.Event("ТИШИНА_10МИН")
|
||||
} else if silence > 5*time.Minute {
|
||||
} else if silence > silence5Min {
|
||||
m.eventLogger.Event("ТИШИНА_5МИН")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ import (
|
||||
)
|
||||
|
||||
type Retention struct {
|
||||
maxAgeHours int
|
||||
maxSizeBytes int64
|
||||
eventLogger *EventLogger
|
||||
maxAgeHours int
|
||||
maxSizeBytes int64
|
||||
eventLogger *EventLogger
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewRetention(maxAgeHours int, maxSizeMB int, eventLogger *EventLogger) *Retention {
|
||||
@@ -20,15 +21,27 @@ func NewRetention(maxAgeHours int, maxSizeMB int, eventLogger *EventLogger) *Ret
|
||||
maxAgeHours: maxAgeHours,
|
||||
maxSizeBytes: int64(maxSizeMB) * 1024 * 1024,
|
||||
eventLogger: eventLogger,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Retention) Start() {
|
||||
// Запускаем проверку каждые 15 минут
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
r.StartWithInterval(15 * time.Minute)
|
||||
}
|
||||
|
||||
func (r *Retention) StartWithInterval(interval time.Duration) {
|
||||
// Запускаем проверку с указанным интервалом
|
||||
ticker := time.NewTicker(interval)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
r.Cleanup()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
r.Cleanup()
|
||||
case <-r.stopCh:
|
||||
ticker.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -38,7 +51,16 @@ func (r *Retention) Start() {
|
||||
})
|
||||
}
|
||||
|
||||
func (r *Retention) Stop() {
|
||||
close(r.stopCh)
|
||||
}
|
||||
|
||||
func (r *Retention) Cleanup() {
|
||||
// Если оба лимита отключены, ничего не делаем
|
||||
if r.maxAgeHours <= 0 && r.maxSizeBytes <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Получаем директорию с данными
|
||||
dataDir, err := GetDataLogsDir()
|
||||
if err != nil {
|
||||
@@ -52,11 +74,17 @@ func (r *Retention) Cleanup() {
|
||||
r.eventLogger.Event("ЗАПУЩЕНА_ОЧИСТКА_ЛОГОВ")
|
||||
}
|
||||
|
||||
// 1. Удаляем старые файлы
|
||||
deletedByAge := r.cleanByAge(dataDir)
|
||||
// 1. Удаляем старые файлы (если включено)
|
||||
deletedByAge := 0
|
||||
if r.maxAgeHours > 0 {
|
||||
deletedByAge = r.cleanByAge(dataDir)
|
||||
}
|
||||
|
||||
// 2. Проверяем общий размер и удаляем самые старые если превышен лимит
|
||||
deletedBySize := r.cleanBySize(dataDir)
|
||||
// 2. Проверяем общий размер и удаляем самые старые если превышен лимит (если включено)
|
||||
deletedBySize := 0
|
||||
if r.maxSizeBytes > 0 {
|
||||
deletedBySize = r.cleanBySize(dataDir)
|
||||
}
|
||||
|
||||
if r.eventLogger != nil && (deletedByAge > 0 || deletedBySize > 0) {
|
||||
r.eventLogger.Event("УДАЛЕНО_ФАЙЛОВ")
|
||||
|
||||
@@ -8,23 +8,30 @@ import (
|
||||
)
|
||||
|
||||
type RotatingLogger struct {
|
||||
dataLogger *DataLogger
|
||||
currentHour int
|
||||
baseDir string
|
||||
mu sync.Mutex
|
||||
eventLogger *EventLogger
|
||||
dataLogger *DataLogger
|
||||
currentHour int
|
||||
baseDir string
|
||||
mu sync.Mutex
|
||||
eventLogger *EventLogger
|
||||
checkInterval time.Duration
|
||||
}
|
||||
|
||||
func NewRotatingLogger(eventLogger *EventLogger) (*RotatingLogger, error) {
|
||||
func NewRotatingLogger(eventLogger *EventLogger, checkInterval time.Duration) (*RotatingLogger, error) {
|
||||
// Получаем директорию для бинарных данных
|
||||
dataDir, err := GetDataLogsDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Интервал проверки по умолчанию
|
||||
if checkInterval == 0 {
|
||||
checkInterval = 1 * time.Minute
|
||||
}
|
||||
|
||||
r := &RotatingLogger{
|
||||
baseDir: dataDir,
|
||||
eventLogger: eventLogger,
|
||||
baseDir: dataDir,
|
||||
eventLogger: eventLogger,
|
||||
checkInterval: checkInterval,
|
||||
}
|
||||
|
||||
if err := r.rotate(); err != nil {
|
||||
@@ -87,7 +94,9 @@ func (r *RotatingLogger) Write(s Sample) {
|
||||
}
|
||||
|
||||
func (r *RotatingLogger) rotationLoop() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
ticker := time.NewTicker(r.checkInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
r.rotate()
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ type PipeReader struct {
|
||||
lastData logger.GPIOData
|
||||
lastLogTime time.Time
|
||||
lastAlertTime map[byte]time.Time
|
||||
|
||||
// Настройки
|
||||
alertCooldownSec time.Duration
|
||||
humanLogIntervalSec time.Duration
|
||||
}
|
||||
|
||||
func NewPipeReader(
|
||||
@@ -29,14 +33,17 @@ func NewPipeReader(
|
||||
humanLogger *logger.HumanLogger,
|
||||
monitor *logger.Monitor,
|
||||
eventLog *logger.EventLogger,
|
||||
config *logger.Config,
|
||||
) *PipeReader {
|
||||
return &PipeReader{
|
||||
buf: buf,
|
||||
dataLogger: dataLogger,
|
||||
humanLogger: humanLogger,
|
||||
monitor: monitor,
|
||||
eventLog: eventLog,
|
||||
lastAlertTime: make(map[byte]time.Time),
|
||||
buf: buf,
|
||||
dataLogger: dataLogger,
|
||||
humanLogger: humanLogger,
|
||||
monitor: monitor,
|
||||
eventLog: eventLog,
|
||||
lastAlertTime: make(map[byte]time.Time),
|
||||
alertCooldownSec: config.AlertCooldownSec,
|
||||
humanLogIntervalSec: config.HumanLogIntervalSec,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +107,7 @@ func (pr *PipeReader) Start(ctx context.Context, pipePath string) {
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Human-readable лог (при изменении состояния или раз в 5 секунд)
|
||||
// 3. Human-readable лог (при изменении состояния или раз в N секунд)
|
||||
if pr.humanLogger != nil {
|
||||
shouldLog := false
|
||||
|
||||
@@ -109,8 +116,8 @@ func (pr *PipeReader) Start(ctx context.Context, pipePath string) {
|
||||
shouldLog = true
|
||||
}
|
||||
|
||||
// Или если прошло больше 5 секунд с последнего лога
|
||||
if now.Sub(pr.lastLogTime) >= 5*time.Second {
|
||||
// Или если прошло больше N секунд с последнего лога
|
||||
if now.Sub(pr.lastLogTime) >= pr.humanLogIntervalSec {
|
||||
shouldLog = true
|
||||
}
|
||||
|
||||
@@ -149,10 +156,10 @@ func (pr *PipeReader) Start(ctx context.Context, pipePath string) {
|
||||
}
|
||||
|
||||
func (pr *PipeReader) handleAlert(data logger.GPIOData, timestamp time.Time) {
|
||||
// Anti-spam: не чаще 1 алерта в 10 секунд для одинакового количества
|
||||
// Anti-spam: не чаще 1 алерта в N секунд для одинакового количества
|
||||
key := data.Count
|
||||
if last, exists := pr.lastAlertTime[key]; exists {
|
||||
if timestamp.Sub(last) < 10*time.Second {
|
||||
if timestamp.Sub(last) < pr.alertCooldownSec {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,15 @@ User=user
|
||||
Group=user
|
||||
WorkingDirectory=/home/user/temp/golang
|
||||
|
||||
# Запуск сервера
|
||||
ExecStart=/home/user/temp/golang/build/gpio-monitor-server -pipe /tmp/gpio_pipe
|
||||
# Запуск сервера с параметрами для 72-часового хранения
|
||||
ExecStart=/home/user/temp/golang/build/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
|
||||
@@ -26,4 +33,4 @@ StandardError=journal
|
||||
SyslogIdentifier=gpio-monitor
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user