Compare commits

...

4 Commits

11 changed files with 932 additions and 44 deletions

View File

@@ -10,11 +10,12 @@ import (
"io/fs"
"log"
"net/http"
"os"
"strings"
"time"
"gpio-monitor/internal/adapter"
"gpio-monitor/internal/logger"
"gpio-monitor/internal/pipe"
)
// ===== EMBED WEB =====
@@ -125,7 +126,6 @@ func handleCamProxy(w http.ResponseWriter, r *http.Request) {
for {
n, err := resp.Body.Read(buf)
if n > 0 {
_, err = w.Write(buf[:n])
if err != nil {
@@ -134,7 +134,6 @@ func handleCamProxy(w http.ResponseWriter, r *http.Request) {
}
flusher.Flush()
}
if err != nil {
if err != io.EOF {
log.Printf("[cam] stream ended: %v", err)
@@ -144,43 +143,6 @@ func handleCamProxy(w http.ResponseWriter, r *http.Request) {
}
}
// ===== PIPE READER =====
func startPipeReader(ctx context.Context, buf *adapter.RingBuffer, path string) {
go func() {
buffer := make([]byte, 64)
for {
select {
case <-ctx.Done():
return
default:
}
f, err := os.OpenFile(path, os.O_RDONLY, 0)
if err != nil {
time.Sleep(time.Second)
continue
}
log.Println("pipe connected")
for {
n, err := f.Read(buffer)
if err != nil {
f.Close()
break
}
for i := 0; i < n; i++ {
buf.Write(buffer[i])
}
}
log.Println("pipe disconnected, retry...")
}
}()
}
func cors(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
@@ -197,15 +159,59 @@ func cors(next http.HandlerFunc) http.HandlerFunc {
}
func main() {
pipePath := flag.String("pipe", "/tmp/gpio_pipe", "pipe path")
pipePath := flag.String("pipe", "/tmp/gpio_pipe", "путь к pipe")
flag.Parse()
// ===== ИНИЦИАЛИЗАЦИЯ ЛОГГЕРОВ =====
// 1. EventLogger (события системы)
eventLogger, err := logger.NewEventLogger()
if err != nil {
log.Fatal("Ошибка инициализации EventLogger:", err)
}
defer eventLogger.Close()
eventLogger.Event("СЕРВЕР_ЗАПУЩЕН")
// Выводим пути для информации
if logsDir, err := logger.GetLogsDir(); err == nil {
log.Printf("Логи сохраняются в: %s", logsDir)
}
// 1.5 Human-readable логгер
humanLogger, err := logger.NewHumanLogger()
if err != nil {
log.Fatal("Ошибка инициализации HumanLogger:", err)
}
defer humanLogger.Close()
eventLogger.Event("HUMAN_ЛОГЕРОТОВ")
// 2. Monitor (watchdog)
monitor := logger.NewMonitor(eventLogger)
// 3. DataLogger с ротацией
dataLogger, err := logger.NewRotatingLogger(eventLogger)
if err != nil {
eventLogger.Event("ОШИБКАНИЦИАЛИЗАЦИИ_DATA_ЛОГЕРА")
log.Fatal("Ошибка инициализации DataLogger:", err)
}
defer dataLogger.Close()
eventLogger.Event("DATA_ЛОГЕРОТОВ")
// 3.5 Retention (автоматическая очистка)
retention := logger.NewRetention(48, 5000, eventLogger)
retention.Start()
eventLogger.Event("RETENTION_ГОТОВ")
// 4. RingBuffer
buf := adapter.NewRingBuffer(1024 * 10)
// 5. PipeReader
pipeReader := pipe.NewPipeReader(buf, dataLogger, humanLogger, monitor, eventLogger)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pipeReader.Start(ctx, *pipePath)
startPipeReader(ctx, buf, *pipePath)
// ===== API И WEB СЕРВЕР =====
api := &API{
buf: buf,
startTime: time.Now(),
@@ -234,7 +240,6 @@ func main() {
if err != nil {
log.Fatal(err)
}
http.Handle("/", http.FileServer(http.FS(webFS)))
log.Println("Server started on :8080")

View File

@@ -0,0 +1,98 @@
package logger
import (
"encoding/binary"
"os"
"sync"
"time"
)
type DataLogger struct {
file *os.File
mu sync.Mutex
buffer []byte
bufferSize int
flushTick *time.Ticker
closeCh chan struct{}
}
type Sample struct {
Timestamp int64 // microseconds
Value byte
}
func NewDataLogger(basePath string) (*DataLogger, error) {
// путь будет формироваться через rotation
f, err := os.OpenFile(basePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return nil, err
}
d := &DataLogger{
file: f,
buffer: make([]byte, 0, 64*1024), // 64KB буфер
bufferSize: 0,
flushTick: time.NewTicker(1 * time.Second),
closeCh: make(chan struct{}),
}
go d.flushLoop()
return d, nil
}
func (d *DataLogger) Write(s Sample) {
d.mu.Lock()
defer d.mu.Unlock()
// Формат: [timestamp uint64][value byte]
tsBuf := make([]byte, 8)
binary.LittleEndian.PutUint64(tsBuf, uint64(s.Timestamp))
d.buffer = append(d.buffer, tsBuf...)
d.buffer = append(d.buffer, s.Value)
d.bufferSize += 9
// Если буфер переполнен - сбрасываем немедленно
if d.bufferSize >= 64*1024 {
d.flush()
}
}
func (d *DataLogger) flush() {
if d.bufferSize == 0 {
return
}
_, err := d.file.Write(d.buffer[:d.bufferSize])
if err != nil {
// тут должен быть event
return
}
d.file.Sync() // важно для durability
d.buffer = d.buffer[:0]
d.bufferSize = 0
}
func (d *DataLogger) flushLoop() {
for {
select {
case <-d.flushTick.C:
d.mu.Lock()
d.flush()
d.mu.Unlock()
case <-d.closeCh:
d.mu.Lock()
d.flush()
d.mu.Unlock()
return
}
}
}
func (d *DataLogger) Close() error {
close(d.closeCh)
d.flushTick.Stop()
return d.file.Close()
}

View File

@@ -0,0 +1,81 @@
package logger
import (
"encoding/json"
"fmt"
"os"
"sync"
"time"
)
type EventLogger struct {
file *os.File
humanFile *os.File
mu sync.Mutex
}
func NewEventLogger() (*EventLogger, error) {
// Получаем пути к файлам
jsonPath, err := GetEventLogPath()
if err != nil {
return nil, fmt.Errorf("ошибка получения пути для JSON логов: %w", err)
}
humanPath, err := GetEventHumanLogPath()
if err != nil {
return nil, fmt.Errorf("ошибка получения пути для Human логов: %w", err)
}
// Открываем JSON файл
f, err := os.OpenFile(jsonPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return nil, fmt.Errorf("ошибка открытия JSON лога: %w", err)
}
// Открываем Human-readable файл
humanF, err := os.OpenFile(humanPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
f.Close()
return nil, fmt.Errorf("ошибка открытия Human лога: %w", err)
}
return &EventLogger{
file: f,
humanFile: humanF,
}, nil
}
func (l *EventLogger) Event(name string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
// JSON формат (для машин)
ev := struct {
Timestamp int64 `json:"ts"`
Time string `json:"time"`
Event string `json:"event"`
}{
Timestamp: now.Unix(),
Time: now.Format("2006-01-02 15:04:05"),
Event: name,
}
data, _ := json.Marshal(ev)
data = append(data, '\n')
l.file.Write(data)
// Human-readable формат
humanLine := fmt.Sprintf("[%s] EVENT: %s\n", now.Format("2006-01-02 15:04:05.000"), name)
l.humanFile.WriteString(humanLine)
l.file.Sync()
l.humanFile.Sync()
}
func (l *EventLogger) Close() error {
l.file.Close()
l.humanFile.Close()
return nil
}

View File

@@ -0,0 +1,37 @@
package logger
import (
"os"
"sync"
)
type HumanLogger struct {
file *os.File
mu sync.Mutex
}
func NewHumanLogger() (*HumanLogger, error) {
path, err := GetGPIOLogPath()
if err != nil {
return nil, err
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return nil, err
}
return &HumanLogger{file: f}, nil
}
func (l *HumanLogger) Write(data string) {
l.mu.Lock()
defer l.mu.Unlock()
l.file.WriteString(data)
l.file.Sync()
}
func (l *HumanLogger) Close() error {
return l.file.Close()
}

View File

@@ -0,0 +1,42 @@
package logger
import (
"sync"
"time"
)
type Monitor struct {
eventLogger *EventLogger
lastWrite time.Time
mu sync.RWMutex
}
func NewMonitor(eventLogger *EventLogger) *Monitor {
m := &Monitor{
eventLogger: eventLogger,
lastWrite: time.Now(),
}
go m.watchdogLoop()
return m
}
func (m *Monitor) RecordWrite() {
m.mu.Lock()
m.lastWrite = time.Now()
m.mu.Unlock()
}
func (m *Monitor) watchdogLoop() {
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
m.mu.RLock()
silence := time.Since(m.lastWrite)
m.mu.RUnlock()
if silence > 10*time.Minute {
m.eventLogger.Event("ТИШИНА_10МИН")
} else if silence > 5*time.Minute {
m.eventLogger.Event("ТИШИНА_5МИН")
}
}
}

40
internal/logger/parser.go Normal file
View File

@@ -0,0 +1,40 @@
package logger
type GPIOData struct {
RawValue byte // сырое значение
Count byte // количество обнаружений (биты 0-5)
Strength byte // сила сигнала (биты 6-7)
IsHighCount bool // превышает ли количество 10
}
// ParseGPIO парсит сырой байт на составляющие
func ParseGPIO(raw byte) GPIOData {
// Биты 0-5: количество (0-63)
count := raw & 0x3F // маска 00111111
// Биты 6-7: сила (0-3)
strength := (raw >> 6) & 0x03 // сдвиг на 6 бит, маска 00000011
return GPIOData{
RawValue: raw,
Count: count,
Strength: strength,
IsHighCount: count > 10,
}
}
// GetStrengthName возвращает текстовое описание силы
func GetStrengthName(strength byte) string {
switch strength {
case 0:
return "Слабый"
case 1:
return "Средний"
case 2:
return "Сильный"
case 3:
return "Максимальный"
default:
return "Неизвестно"
}
}

85
internal/logger/paths.go Normal file
View File

@@ -0,0 +1,85 @@
package logger
import (
"os"
"path/filepath"
)
// GetDataDir возвращает стандартную директорию для данных приложения
func GetDataDir() (string, error) {
// Используем XDG Base Directory Specification
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
// ~/.local/share/gpio-monitoring
dataDir := filepath.Join(homeDir, ".local", "share", "gpio-monitoring")
// Создаём директорию если её нет
if err := os.MkdirAll(dataDir, 0755); err != nil {
return "", err
}
return dataDir, nil
}
// GetLogsDir возвращает директорию для логов
func GetLogsDir() (string, error) {
dataDir, err := GetDataDir()
if err != nil {
return "", err
}
logsDir := filepath.Join(dataDir, "logs")
if err := os.MkdirAll(logsDir, 0755); err != nil {
return "", err
}
return logsDir, nil
}
// GetDataLogsDir возвращает директорию для бинарных данных
func GetDataLogsDir() (string, error) {
logsDir, err := GetLogsDir()
if err != nil {
return "", err
}
dataDir := filepath.Join(logsDir, "data")
if err := os.MkdirAll(dataDir, 0755); err != nil {
return "", err
}
return dataDir, nil
}
// GetEventLogPath возвращает путь к файлу событий
func GetEventLogPath() (string, error) {
logsDir, err := GetLogsDir()
if err != nil {
return "", err
}
return filepath.Join(logsDir, "events.log"), nil
}
// GetEventHumanLogPath возвращает путь к человеко-читаемому файлу событий
func GetEventHumanLogPath() (string, error) {
logsDir, err := GetLogsDir()
if err != nil {
return "", err
}
return filepath.Join(logsDir, "events_human.log"), nil
}
// GetGPIOLogPath возвращает путь к человеко-читаемому GPIO логу
func GetGPIOLogPath() (string, error) {
logsDir, err := GetLogsDir()
if err != nil {
return "", err
}
return filepath.Join(logsDir, "gpio_human.log"), nil
}

View File

@@ -0,0 +1,182 @@
package logger
import (
"log"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type Retention struct {
maxAgeHours int
maxSizeBytes int64
eventLogger *EventLogger
}
func NewRetention(maxAgeHours int, maxSizeMB int, eventLogger *EventLogger) *Retention {
return &Retention{
maxAgeHours: maxAgeHours,
maxSizeBytes: int64(maxSizeMB) * 1024 * 1024,
eventLogger: eventLogger,
}
}
func (r *Retention) Start() {
// Запускаем проверку каждые 15 минут
ticker := time.NewTicker(15 * time.Minute)
go func() {
for range ticker.C {
r.Cleanup()
}
}()
// Первая очистка через 1 минуту после запуска
time.AfterFunc(1*time.Minute, func() {
r.Cleanup()
})
}
func (r *Retention) Cleanup() {
// Получаем директорию с данными
dataDir, err := GetDataLogsDir()
if err != nil {
if r.eventLogger != nil {
r.eventLogger.Event("ОШИБКАОЛУЧЕНИЯ_ДИРЕКТОРИИ")
}
return
}
if r.eventLogger != nil {
r.eventLogger.Event("ЗАПУЩЕНА_ОЧИСТКАОГОВ")
}
// 1. Удаляем старые файлы
deletedByAge := r.cleanByAge(dataDir)
// 2. Проверяем общий размер и удаляем самые старые если превышен лимит
deletedBySize := r.cleanBySize(dataDir)
if r.eventLogger != nil && (deletedByAge > 0 || deletedBySize > 0) {
r.eventLogger.Event("УДАЛЕНОАЙЛОВ")
log.Printf("Очистка логов: удалено %d файлов (по возрасту: %d, по размеру: %d)",
deletedByAge+deletedBySize, deletedByAge, deletedBySize)
}
}
func (r *Retention) cleanByAge(dataDir string) int {
cutoff := time.Now().Add(-time.Duration(r.maxAgeHours) * time.Hour)
deleted := 0
// Получаем все .bin файлы
files, err := filepath.Glob(filepath.Join(dataDir, "gpio-*.bin"))
if err != nil {
return 0
}
for _, file := range files {
info, err := os.Stat(file)
if err != nil {
continue
}
// Извлекаем время из имени файла
fileTime := r.parseFilenameTime(file)
if fileTime.IsZero() {
fileTime = info.ModTime()
}
if fileTime.Before(cutoff) {
if err := os.Remove(file); err == nil {
deleted++
if r.eventLogger != nil {
r.eventLogger.Event("УДАЛЕНАЙЛ_ПО_ВОЗРАСТУ")
}
}
}
}
return deleted
}
func (r *Retention) cleanBySize(dataDir string) int {
// Получаем все файлы
files, err := filepath.Glob(filepath.Join(dataDir, "gpio-*.bin"))
if err != nil {
return 0
}
if len(files) == 0 {
return 0
}
// Сортируем по времени (старыми первыми)
type fileInfo struct {
path string
time time.Time
size int64
}
var fileList []fileInfo
var totalSize int64
for _, file := range files {
info, err := os.Stat(file)
if err != nil {
continue
}
fileTime := r.parseFilenameTime(file)
if fileTime.IsZero() {
fileTime = info.ModTime()
}
fileList = append(fileList, fileInfo{
path: file,
time: fileTime,
size: info.Size(),
})
totalSize += info.Size()
}
// Сортируем по времени (старые первые)
sort.Slice(fileList, func(i, j int) bool {
return fileList[i].time.Before(fileList[j].time)
})
// Удаляем самые старые пока не уложимся в лимит
deleted := 0
for totalSize > r.maxSizeBytes && len(fileList) > 1 {
// Удаляем самый старый файл
oldest := fileList[0]
if err := os.Remove(oldest.path); err == nil {
totalSize -= oldest.size
deleted++
fileList = fileList[1:]
if r.eventLogger != nil {
r.eventLogger.Event("УДАЛЕНАЙЛ_ПО_РАЗМЕРУ")
}
} else {
break
}
}
return deleted
}
func (r *Retention) parseFilenameTime(filename string) time.Time {
base := filepath.Base(filename)
// Формат: gpio-2026-06-09-11.bin
parts := strings.Split(strings.TrimSuffix(base, ".bin"), "-")
if len(parts) != 5 {
return time.Time{}
}
year, _ := time.Parse("2006", parts[1])
month, _ := time.Parse("01", parts[2])
day, _ := time.Parse("02", parts[3])
hour, _ := time.Parse("15", parts[4])
return time.Date(year.Year(), month.Month(), day.Day(), hour.Hour(), 0, 0, 0, time.Local)
}

103
internal/logger/rotation.go Normal file
View File

@@ -0,0 +1,103 @@
package logger
import (
"fmt"
"path/filepath"
"sync"
"time"
)
type RotatingLogger struct {
dataLogger *DataLogger
currentHour int
baseDir string
mu sync.Mutex
eventLogger *EventLogger
}
func NewRotatingLogger(eventLogger *EventLogger) (*RotatingLogger, error) {
// Получаем директорию для бинарных данных
dataDir, err := GetDataLogsDir()
if err != nil {
return nil, err
}
r := &RotatingLogger{
baseDir: dataDir,
eventLogger: eventLogger,
}
if err := r.rotate(); err != nil {
return nil, err
}
go r.rotationLoop()
return r, nil
}
func (r *RotatingLogger) getFilename() string {
now := time.Now()
return filepath.Join(r.baseDir, fmt.Sprintf("gpio-%04d-%02d-%02d-%02d.bin",
now.Year(), now.Month(), now.Day(), now.Hour()))
}
func (r *RotatingLogger) rotate() error {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
newHour := now.Hour()
// Если уже правильный час и логгер существует - ок
if r.dataLogger != nil && r.currentHour == newHour {
return nil
}
// Закрываем старый
if r.dataLogger != nil {
r.dataLogger.Close()
}
// Открываем новый
filename := r.getFilename()
dataLogger, err := NewDataLogger(filename)
if err != nil {
if r.eventLogger != nil {
r.eventLogger.Event("ROTATION_FAILED")
}
return err
}
r.dataLogger = dataLogger
r.currentHour = newHour
if r.eventLogger != nil {
r.eventLogger.Event("ROTATION_COMPLETE")
}
return nil
}
func (r *RotatingLogger) Write(s Sample) {
r.mu.Lock()
logger := r.dataLogger
r.mu.Unlock()
if logger != nil {
logger.Write(s)
}
}
func (r *RotatingLogger) rotationLoop() {
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
r.rotate()
}
}
func (r *RotatingLogger) Close() error {
r.mu.Lock()
defer r.mu.Unlock()
if r.dataLogger != nil {
return r.dataLogger.Close()
}
return nil
}

179
internal/pipe/reader.go Normal file
View File

@@ -0,0 +1,179 @@
package pipe
import (
"context"
"fmt"
"os"
"time"
"gpio-monitor/internal/adapter"
"gpio-monitor/internal/logger"
)
type PipeReader struct {
buf *adapter.RingBuffer
dataLogger *logger.RotatingLogger
humanLogger *logger.HumanLogger
monitor *logger.Monitor
eventLog *logger.EventLogger
// Для дедупликации логов
lastData logger.GPIOData
lastLogTime time.Time
lastAlertTime map[byte]time.Time
}
func NewPipeReader(
buf *adapter.RingBuffer,
dataLogger *logger.RotatingLogger,
humanLogger *logger.HumanLogger,
monitor *logger.Monitor,
eventLog *logger.EventLogger,
) *PipeReader {
return &PipeReader{
buf: buf,
dataLogger: dataLogger,
humanLogger: humanLogger,
monitor: monitor,
eventLog: eventLog,
lastAlertTime: make(map[byte]time.Time),
}
}
func (pr *PipeReader) Start(ctx context.Context, pipePath string) {
go func() {
buffer := make([]byte, 4096)
for {
select {
case <-ctx.Done():
return
default:
}
if _, err := os.Stat(pipePath); os.IsNotExist(err) {
if pr.eventLog != nil {
pr.eventLog.Event("PIPE_НЕ_НАЙДЕН")
}
time.Sleep(2 * time.Second)
continue
}
f, err := os.OpenFile(pipePath, os.O_RDONLY, 0)
if err != nil {
if pr.eventLog != nil {
pr.eventLog.Event("ОШИБКА_ОТКРЫТИЯ_PIPE")
}
time.Sleep(time.Second)
continue
}
if pr.eventLog != nil {
pr.eventLog.Event("PIPE_ПОДКЛЮЧЕН")
}
for {
n, err := f.Read(buffer)
if err != nil {
f.Close()
if pr.eventLog != nil {
pr.eventLog.Event("PIPE_ОТКЛЮЧЕН")
}
break
}
for i := 0; i < n; i++ {
rawByte := buffer[i]
now := time.Now()
// Парсим данные
data := logger.ParseGPIO(rawByte)
// 1. В RAM для UI (сырое значение)
pr.buf.Write(rawByte)
// 2. На диск для архива (бинарный)
if pr.dataLogger != nil {
pr.dataLogger.Write(logger.Sample{
Timestamp: now.UnixMicro(),
Value: rawByte,
})
}
// 3. Human-readable лог (при изменении состояния или раз в 5 секунд)
if pr.humanLogger != nil {
shouldLog := false
// Логируем если изменилось количество или сила
if data.Count != pr.lastData.Count || data.Strength != pr.lastData.Strength {
shouldLog = true
}
// Или если прошло больше 5 секунд с последнего лога
if now.Sub(pr.lastLogTime) >= 5*time.Second {
shouldLog = true
}
if shouldLog {
strengthName := logger.GetStrengthName(data.Strength)
humanLine := fmt.Sprintf(
"[%s] Обнаружение: %d объектов | Сила: %s (уровень %d) | Сырое: 0x%02X (%d)\n",
now.Format("2006-01-02 15:04:05.000"),
data.Count,
strengthName,
data.Strength,
rawByte,
rawByte,
)
pr.humanLogger.Write(humanLine)
pr.lastData = data
pr.lastLogTime = now
}
}
// 4. Алерт если количество обнаружений > 10
if data.IsHighCount {
pr.handleAlert(data, now)
}
// 5. Обновляем watchdog
if pr.monitor != nil {
pr.monitor.RecordWrite()
}
}
}
time.Sleep(1 * time.Second)
}
}()
}
func (pr *PipeReader) handleAlert(data logger.GPIOData, timestamp time.Time) {
// Anti-spam: не чаще 1 алерта в 10 секунд для одинакового количества
key := data.Count
if last, exists := pr.lastAlertTime[key]; exists {
if timestamp.Sub(last) < 10*time.Second {
return
}
}
pr.lastAlertTime[key] = timestamp
strengthName := logger.GetStrengthName(data.Strength)
// 1. В event log (JSON)
if pr.eventLog != nil {
pr.eventLog.Event(fmt.Sprintf("ОБНАРУЖЕНО_%d_ОБЪЕКТОВ_СИЛА_%d", data.Count, data.Strength))
}
// 2. В human-readable лог с предупреждением
if pr.humanLogger != nil {
alertLine := fmt.Sprintf(
"[%s] ВНИМАНИЕ: Обнаружено превышение! %d объектов | Сила: %s (уровень %d)\n",
timestamp.Format("2006-01-02 15:04:05.000"),
data.Count,
strengthName,
data.Strength,
)
pr.humanLogger.Write(alertLine)
}
}

36
scripts/view_logs.sh Normal file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
LOG_DIR="$HOME/.local/share/gpio-monitoring/logs"
echo "=== СТАТИСТИКА ЛОГОВ ==="
echo ""
if [ -d "$LOG_DIR" ]; then
echo "Логи в: $LOG_DIR"
echo ""
echo "Общая статистика:"
echo " Событий (JSON): $(wc -l < $LOG_DIR/events.log 2>/dev/null || echo 0)"
echo " Событий (Human): $(wc -l < $LOG_DIR/events_human.log 2>/dev/null || echo 0)"
echo " Записей GPIO: $(wc -l < $LOG_DIR/gpio_human.log 2>/dev/null || echo 0)"
echo ""
echo "оследние 5 предупреждений:"
grep "ВНИМАНИЕ" $LOG_DIR/gpio_human.log 2>/dev/null | tail -5 || echo " Нет предупреждений"
echo ""
echo "Статистика по значениям:"
grep "Значение GPIO:" $LOG_DIR/gpio_human.log 2>/dev/null | awk -F': ' '{print $2}' | awk '{print $1}' | sort | uniq -c | sort -rn | head -10
echo ""
echo "💾 Использование диска:"
du -sh $LOG_DIR 2>/dev/null
echo ""
if ls $LOG_DIR/data/*.bin 1> /dev/null 2>&1; then
echo "📁 Файлы данных:"
ls -lh $LOG_DIR/data/*.bin 2>/dev/null | awk '{print " " $9 ": " $5}'
fi
else
echo "❌ Директория логов не найдена: $LOG_DIR"
echo "Запустите сервер для создания логов"
fi