controller v0.92.0: NAS network storage Part A2 (registry + UI + per-share health)

Controller-side of NAS network storage, proxying to agent A1 /netstorage/*. Distinct
'network' storage kind (no drive lifecycle); add/list/remove + per-share health UI;
unreachable NAS is a recoverable warning, never the drive missing/stop cascade; SMB
creds pass through to the agent, never persisted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs
This commit is contained in:
2026-06-30 14:40:47 +02:00
parent 9391f4ea71
commit 364dc50794
13 changed files with 837 additions and 1 deletions
+75
View File
@@ -155,6 +155,7 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
data["Stacks"] = deployedStacks
data["MissingStorage"] = s.missingStorageMap(deployedStacks)
data["NetworkWarnings"] = s.networkStorageWarnings(deployedStacks) // NAS unreachable — recoverable warning, not "missing"
data["RunningCount"] = running
data["StoppedCount"] = stopped
data["TotalCount"] = len(stackList)
@@ -202,6 +203,7 @@ func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
allStacks := s.stackMgr.GetStacks()
data["Stacks"] = allStacks
data["MissingStorage"] = s.missingStorageMap(allStacks)
data["NetworkWarnings"] = s.networkStorageWarnings(allStacks) // NAS unreachable — recoverable warning, not "missing"
// Build storage label lookup for deployed apps
storageLabels := make(map[string]string) // stack name → storage label
@@ -915,6 +917,11 @@ func (s *Server) settingsData() map[string]interface{} {
}
var storageViews []StoragePathView
for _, sp := range storagePaths {
// NAS network shares are rendered in their OWN section (NetworkStoragePaths) with the agent's
// per-share health — never in the physical-drive list (which offers eject/decommission/wipe).
if sp.IsNetwork() {
continue
}
view := StoragePathView{
StoragePath: sp,
StoppedApps: sp.StoppedStacks,
@@ -942,6 +949,9 @@ func (s *Server) settingsData() map[string]interface{} {
storageViews = append(storageViews, view)
}
data["StoragePaths"] = storageViews
// NAS network storage (Part A2) — separate section with the agent's per-share health (ok/idle/
// unreachable/unknown). Distinct from the physical-drive list above; no drive lifecycle actions.
data["NetworkStoragePaths"] = s.networkStorageItems(context.Background())
// Recovery info for emergency section
data["RetrievalPassword"] = s.settings.GetRetrievalPassword()
@@ -1210,6 +1220,12 @@ func (s *Server) missingStorageLabel(hddPath string) (string, bool) {
}
for _, sp := range s.settings.GetStoragePaths() {
if sp.Path == hddPath {
// A NAS network path is NEVER "missing": its availability is the agent's per-share liveness
// (surfaced as a recoverable warning by networkStorageWarnings), NOT the drive missing/stop
// cascade. An `unreachable` NAS must not look like a removed drive.
if sp.IsNetwork() {
return "", false
}
if sp.Decommissioned || sp.Disconnected {
return s.settings.GetStorageLabel(hddPath), true
}
@@ -1219,6 +1235,65 @@ func (s *Server) missingStorageLabel(hddPath string) (string, bool) {
return s.settings.GetStorageLabel(hddPath), true // not in registry → its drive is gone
}
// networkStorageWarnings returns stack-name → share label for every deployed app whose HDD_PATH is a NAS
// network path the agent currently reports `unreachable`. This is a RECOVERABLE warning ("hálózati
// tárhely nem elérhető"), explicitly NOT the drive missing/stop-cascade — the app keeps running and the
// badge clears when the NAS returns. Best-effort: an agent error or no network paths → no warnings.
func (s *Server) networkStorageWarnings(list []stacks.Stack) map[string]string {
out := map[string]string{}
if s.settings == nil || s.stackMgr == nil {
return out
}
netPaths := map[string]settings.StoragePath{}
for _, sp := range s.settings.GetStoragePaths() {
if sp.IsNetwork() {
netPaths[sp.Path] = sp
}
}
if len(netPaths) == 0 {
return out
}
agent, err := s.agentClient()
if err != nil {
return out
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
mounts, err := agent.ListNetStorage(ctx)
if err != nil {
s.logger.Printf("[WARN] [web] network storage health unavailable for warnings: %v", err)
return out
}
unreachable := map[string]string{} // path → label
for _, m := range mounts {
if !m.Unreachable() { // only `unreachable` is degraded; `idle`/`ok` are benign
continue
}
p := settings.NetworkMountRoot + "/" + m.Name
if sp, ok := netPaths[p]; ok {
lbl := sp.Label
if lbl == "" {
lbl = m.Name
}
unreachable[p] = lbl
}
}
if len(unreachable) == 0 {
return out
}
for _, st := range list {
if !st.Deployed {
continue
}
if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil {
if lbl, bad := unreachable[cfg.Env["HDD_PATH"]]; bad {
out[st.Name] = lbl
}
}
}
return out
}
// missingStorageMap returns stack-name → storage label for every deployed app whose data drive is
// currently unavailable (drives the "Hiányzó tárhely" dashboard/stacks/app-card indicator).
func (s *Server) missingStorageMap(list []stacks.Stack) map[string]string {