NextNVR/storage.go
cclohmar a54ff08ebb feat: storage health monitor with UI alert on drive loss
Detect a disconnected or read-only recordings drive and surface it
without stopping recording or live view:

- storage.go: probe path existence, mount point, writability and free
  space every 30s; capture mount expectation at startup so a clean
  unmount (writes falling through to root fs) is also caught
- GET /api/storage endpoint; storage fields in /api/status
- red UI banner while storage is unavailable
- log ALERT on loss, OK on recovery
2026-10-10 07:17:28 +01:00

176 lines
4.3 KiB
Go

// NextNVR — MIT License
// Copyright (c) 2026 NextNVR Contributors
// SPDX-License-Identifier: MIT
// storage.go — monitors the recordings storage and raises a visible alert when
// the drive disappears or becomes read-only. Recording and live view are NEVER
// stopped: the goal is to warn the operator, not to go dark.
package main
import (
"log"
"os"
"path/filepath"
"sync"
"syscall"
"time"
)
// StorageStatus is a point-in-time snapshot of recordings-storage health.
type StorageStatus struct {
OK bool `json:"ok"`
Path string `json:"path"`
Exists bool `json:"exists"`
Mounted bool `json:"mounted"`
Writable bool `json:"writable"`
FreeBytes uint64 `json:"free_bytes"`
TotalBytes uint64 `json:"total_bytes"`
Message string `json:"message"`
CheckedAt string `json:"checked_at"`
}
// StorageMonitor periodically probes the recordings path and logs transitions.
type StorageMonitor struct {
path string
interval time.Duration
expectMount bool
mu sync.RWMutex
status StorageStatus
stopCh chan struct{}
started bool
}
// NewStorageMonitor creates a monitor. The mount-point expectation is captured
// now, while storage is assumed healthy, so a later unmount is detected.
func NewStorageMonitor(path string, interval time.Duration) *StorageMonitor {
if interval <= 0 {
interval = 30 * time.Second
}
m := &StorageMonitor{
path: path,
interval: interval,
expectMount: isMountPoint(path),
stopCh: make(chan struct{}),
}
m.status = checkStorage(path, m.expectMount)
return m
}
// Path returns the monitored recordings path.
func (m *StorageMonitor) Path() string { return m.path }
// Start begins periodic checks and logs an alert on state changes.
func (m *StorageMonitor) Start() {
m.started = true
prev := m.Status()
m.logStatus("startup", prev)
go func() {
ticker := time.NewTicker(m.interval)
defer ticker.Stop()
for {
select {
case <-m.stopCh:
return
case <-ticker.C:
st := checkStorage(m.path, m.expectMount)
m.mu.Lock()
m.status = st
m.mu.Unlock()
if st.OK != prev.OK {
m.logStatus("change", st)
}
prev = st
}
}
}()
}
// Stop halts the monitor.
func (m *StorageMonitor) Stop() {
if m.started {
close(m.stopCh)
m.started = false
}
}
// Status returns the latest cached status.
func (m *StorageMonitor) Status() StorageStatus {
m.mu.RLock()
defer m.mu.RUnlock()
return m.status
}
func (m *StorageMonitor) logStatus(tag string, st StorageStatus) {
if st.OK {
log.Printf("storage: OK (%s) — %s, %s free", tag, st.Path, formatBytes(int64(st.FreeBytes)))
return
}
log.Printf("storage: ALERT (%s) — recordings storage problem: %s", tag, st.Message)
}
// checkStorage probes existence, mount status, free space and writability.
func checkStorage(path string, expectMount bool) StorageStatus {
st := StorageStatus{Path: path, CheckedAt: time.Now().Format(time.RFC3339)}
info, err := os.Stat(path)
if err != nil {
st.Message = "storage path not found: " + err.Error()
return st
}
if !info.IsDir() {
st.Message = "storage path is not a directory"
return st
}
st.Exists = true
st.Mounted = isMountPoint(path)
var varfs syscall.Statfs_t
if syscall.Statfs(path, &varfs) == nil {
st.TotalBytes = varfs.Blocks * uint64(varfs.Bsize)
st.FreeBytes = varfs.Bavail * uint64(varfs.Bsize)
}
// Writability probe — catches dead mounts that return EIO on write.
probe := filepath.Join(path, ".nextnvr_write_test")
f, err := os.OpenFile(probe, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
st.Message = "storage is not writable: " + err.Error()
return st
}
f.Close()
os.Remove(probe)
st.Writable = true
if expectMount && !st.Mounted {
st.Message = "recordings drive is not mounted (it may have been disconnected)"
return st
}
st.OK = true
st.Message = "ok"
return st
}
// isMountPoint reports whether path is a mount point by comparing its device
// ID with that of its parent directory.
func isMountPoint(path string) bool {
abs, err := filepath.Abs(path)
if err != nil {
return false
}
child, err := os.Stat(abs)
if err != nil {
return false
}
parent, err := os.Stat(filepath.Dir(abs))
if err != nil {
return false
}
cs, ok1 := child.Sys().(*syscall.Stat_t)
ps, ok2 := parent.Sys().(*syscall.Stat_t)
if !ok1 || !ok2 {
return false
}
return cs.Dev != ps.Dev
}