diff --git a/api.go b/api.go index 19ce04f..11b28bc 100644 --- a/api.go +++ b/api.go @@ -364,9 +364,30 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { "cameras_active": 0, // TODO: real counts in M2 "uptime": "n/a", } + if s.storage != nil { + st := s.storage.Status() + status["storage_ok"] = st.OK + status["storage"] = st + } jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: status}) } +// handleStorage returns recordings-storage health. +// GET /api/storage +func (s *Server) handleStorage(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + jsonResponse(w, http.StatusMethodNotAllowed, APIResponse{Error: "method not allowed"}) + return + } + if s.storage == nil { + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: StorageStatus{ + OK: true, Message: "storage monitor disabled", + }}) + return + } + jsonResponse(w, http.StatusOK, APIResponse{Success: true, Data: s.storage.Status()}) +} + // handleRecordings returns available recordings for playback. // GET /api/recordings?cam={id}&preset={today|yesterday|week}&from={date}&to={date} // diff --git a/main.go b/main.go index 0f8749a..6d0696f 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "os" "os/signal" "syscall" + "time" ) var ( @@ -76,8 +77,14 @@ func main() { go cln.Start() app.cleaner = cln + // Start storage monitor (alert-only; never stops recording or live view). + storeMon := NewStorageMonitor(cfg.Storage.RecordingsPath, 30*time.Second) + storeMon.Start() + app.storage = storeMon + // Start the HTTP server (main port, with auth). app.server, _ = NewServer(cfg) + app.server.storage = storeMon app.server.onReload = func() { log.Println("Config reload — restarting services...") if app.go2rtc != nil { app.go2rtc.Stop() } @@ -100,6 +107,16 @@ func main() { rec := NewRecorderManager(liveCfg.Storage) rec.StartAll(liveCfg.Cameras) app.recorder = rec + + // Re-target the storage monitor if the recordings path changed. + if app.storage == nil || app.storage.Path() != liveCfg.Storage.RecordingsPath { + if app.storage != nil { + app.storage.Stop() + } + app.storage = NewStorageMonitor(liveCfg.Storage.RecordingsPath, 30*time.Second) + app.storage.Start() + app.server.storage = app.storage + } log.Println("Config reload — services restarted.") } errCh := make(chan error, 1) @@ -159,6 +176,7 @@ type App struct { snapshots *SnapshotEngine recorder *RecorderManager cleaner *Cleaner + storage *StorageMonitor viewerSrv *http.Server } diff --git a/public/app.js b/public/app.js index 1512aa3..cce5b27 100644 --- a/public/app.js +++ b/public/app.js @@ -17,6 +17,10 @@ document.addEventListener('DOMContentLoaded', async () => { renderPlaybackCameras(); renderCameraCards(); + // Storage health alert (never stops recording; just warns the operator). + checkStorage(); + setInterval(checkStorage, 15000); + // Read role from meta tag (set by server). const role = document.querySelector('meta[name="nextnvr-role"]')?.content || ''; applyRole(role); @@ -66,6 +70,26 @@ async function loadStatus() { } } +// ── Storage Health Alert ── +async function checkStorage() { + const banner = document.getElementById('storage-alert'); + if (!banner) return; + try { + const r = await fetch(API + '/storage'); + const j = await r.json(); + const st = j.data || {}; + if (j.success && st.ok === false) { + banner.textContent = '⚠ RECORDING STORAGE PROBLEM — ' + (st.message || 'storage unavailable') + + ' — live view keeps running, but recordings are NOT being saved.'; + banner.classList.remove('hidden'); + } else { + banner.classList.add('hidden'); + } + } catch(e) { + // Server unreachable — the status dot already reflects that. + } +} + async function loadConfig() { try { const r = await fetch(API + '/config'); diff --git a/public/index.html b/public/index.html index 5032a8e..73ed87d 100644 --- a/public/index.html +++ b/public/index.html @@ -18,6 +18,8 @@ ● + +
diff --git a/public/style.css b/public/style.css index 5492637..4c6b237 100644 --- a/public/style.css +++ b/public/style.css @@ -48,6 +48,20 @@ body { .tab-panel { display: none; padding: 16px; } .tab-panel.active { display: block; } +/* ── Storage Alert Banner ── */ +.storage-alert { + background: var(--red); + color: #fff; + padding: 10px 20px; + font-size: 14px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid rgba(0,0,0,.3); +} +.storage-alert.hidden { display: none; } + /* ── Live Grid ── */ .grid-4x2 { display: grid; diff --git a/server.go b/server.go index fd39bc8..c88dc4f 100644 --- a/server.go +++ b/server.go @@ -28,6 +28,7 @@ type Server struct { appConfig *Config mu sync.RWMutex sessions *SessionStore + storage *StorageMonitor onReload func() // called when config is saved via API } @@ -121,6 +122,7 @@ func (s *Server) registerRoutes() { s.mux.HandleFunc("/api/config/reload", s.handleConfigReload) s.mux.HandleFunc("/api/scan", s.handleScan) s.mux.HandleFunc("/api/status", s.handleStatus) + s.mux.HandleFunc("/api/storage", s.handleStorage) s.mux.HandleFunc("/api/recordings", s.handleRecordings) // Auth endpoints. diff --git a/storage.go b/storage.go new file mode 100644 index 0000000..9a6021a --- /dev/null +++ b/storage.go @@ -0,0 +1,176 @@ +// 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 +}