v0.10.0: Phase B — Storage Management UI Polish & Health Severity Fix
- Health severity fix: mount-point check downgraded from issue (FAIL) to warning (WARN) - All storage health messages translated to Hungarian - Success flash messages for all storage operations - Edit storage path labels (inline edit UI + backend) - App details per storage path on settings page (expandable list with names + sizes) - Storage badge on stacks page showing which storage each app uses - Deploy dropdown with free space display and low-space warning (<20%) - Filesystem & disk info on settings page (ext4/btrfs, device, model via findmnt) - Backup page storage context with per-app storage label badges Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,12 +16,28 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// DeployStoragePath extends StoragePath with free space data for the deploy dropdown.
|
||||
type DeployStoragePath struct {
|
||||
settings.StoragePath
|
||||
FreeHuman string // "234.5 GB"
|
||||
FreePercent float64 // 67.5
|
||||
}
|
||||
|
||||
// StorageAppDetail holds info about an app using a specific storage path.
|
||||
type StorageAppDetail struct {
|
||||
Name string // Display name (e.g., "Immich")
|
||||
Stack string // Stack name (for link)
|
||||
SizeHuman string // Data size on this path
|
||||
}
|
||||
|
||||
// StoragePathView extends StoragePath with display data for the settings page.
|
||||
type StoragePathView struct {
|
||||
settings.StoragePath
|
||||
DiskInfo *system.DiskUsageInfo
|
||||
AppCount int
|
||||
IsMounted bool
|
||||
DiskInfo *system.DiskUsageInfo
|
||||
AppCount int
|
||||
IsMounted bool
|
||||
AppDetails []StorageAppDetail
|
||||
FSInfo *system.FSInfo
|
||||
}
|
||||
|
||||
func (s *Server) baseData(page, title string) map[string]interface{} {
|
||||
@@ -88,6 +104,27 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
func (s *Server) stacksHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
data := s.baseData("stacks", "Alkalmazások")
|
||||
data["Stacks"] = s.stackMgr.GetStacks()
|
||||
|
||||
// Build storage label lookup for deployed apps
|
||||
storageLabels := make(map[string]string) // stack name → storage label
|
||||
storagePaths := s.settings.GetStoragePaths()
|
||||
for _, stack := range s.stackMgr.GetStacks() {
|
||||
if !stack.Deployed {
|
||||
continue
|
||||
}
|
||||
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
||||
if hddPath := appCfg.Env["HDD_PATH"]; hddPath != "" {
|
||||
for _, sp := range storagePaths {
|
||||
if sp.Path == hddPath {
|
||||
storageLabels[stack.Name] = sp.Label
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
data["StorageLabels"] = storageLabels
|
||||
|
||||
s.render(w, "stacks", data)
|
||||
}
|
||||
|
||||
@@ -136,7 +173,19 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
||||
data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug)
|
||||
data["UserFields"] = meta.UserFacingFields()
|
||||
data["AutoFields"] = meta.AutoGeneratedFields()
|
||||
data["StoragePaths"] = s.settings.GetSchedulableStoragePaths()
|
||||
// Storage paths with free space info for deploy dropdown
|
||||
var deployPaths []DeployStoragePath
|
||||
for _, sp := range s.settings.GetSchedulableStoragePaths() {
|
||||
dp := DeployStoragePath{StoragePath: sp}
|
||||
if di := system.GetDiskUsage(sp.Path); di != nil {
|
||||
dp.FreeHuman = formatFreeSpace(di.AvailGB)
|
||||
if di.TotalGB > 0 {
|
||||
dp.FreePercent = di.AvailGB / di.TotalGB * 100
|
||||
}
|
||||
}
|
||||
deployPaths = append(deployPaths, dp)
|
||||
}
|
||||
data["StoragePaths"] = deployPaths
|
||||
|
||||
// Memory info for deploy page (only for non-deployed apps)
|
||||
if !alreadyDeployed {
|
||||
@@ -268,6 +317,22 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
fullStatus.FlashError = flashErr
|
||||
}
|
||||
|
||||
// Enrich AppDataInfo with storage labels
|
||||
storagePaths := s.settings.GetStoragePaths()
|
||||
for i := range fullStatus.AppDataInfo {
|
||||
app := &fullStatus.AppDataInfo[i]
|
||||
if len(app.HDDPaths) > 0 {
|
||||
hddPath := app.HDDPaths[0].HostPath
|
||||
// Match HDD path prefix against registered storage paths
|
||||
for _, sp := range storagePaths {
|
||||
if strings.HasPrefix(hddPath, sp.Path) {
|
||||
app.StorageLabel = sp.Label
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data["Backup"] = fullStatus
|
||||
|
||||
// Restic password for display
|
||||
@@ -365,8 +430,10 @@ func (s *Server) settingsData() map[string]interface{} {
|
||||
view := StoragePathView{
|
||||
StoragePath: sp,
|
||||
IsMounted: system.IsMountPoint(sp.Path),
|
||||
AppCount: s.countAppsUsingPath(sp.Path),
|
||||
AppDetails: s.appDetailsForPath(sp.Path),
|
||||
FSInfo: system.GetFSInfo(sp.Path),
|
||||
}
|
||||
view.AppCount = len(view.AppDetails)
|
||||
if di := system.GetDiskUsage(sp.Path); di != nil {
|
||||
view.DiskInfo = di
|
||||
}
|
||||
@@ -377,8 +444,12 @@ func (s *Server) settingsData() map[string]interface{} {
|
||||
return data
|
||||
}
|
||||
|
||||
func (s *Server) settingsHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
s.render(w, "settings", s.settingsData())
|
||||
func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := s.settingsData()
|
||||
if msg := r.URL.Query().Get("storage_msg"); msg == "success" {
|
||||
data["StorageSuccess"] = r.URL.Query().Get("storage_detail")
|
||||
}
|
||||
s.render(w, "settings", data)
|
||||
}
|
||||
|
||||
func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -548,6 +619,68 @@ func (s *Server) appsUsingPath(storagePath string) []string {
|
||||
return names
|
||||
}
|
||||
|
||||
func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail {
|
||||
var details []StorageAppDetail
|
||||
for _, stack := range s.stackMgr.GetStacks() {
|
||||
if !stack.Deployed {
|
||||
continue
|
||||
}
|
||||
appCfg := s.stackMgr.LoadAppConfigByName(stack.Name)
|
||||
if appCfg == nil {
|
||||
continue
|
||||
}
|
||||
hddPath := appCfg.Env["HDD_PATH"]
|
||||
if hddPath != storagePath {
|
||||
continue
|
||||
}
|
||||
detail := StorageAppDetail{
|
||||
Name: stack.Meta.DisplayName,
|
||||
Stack: stack.Meta.Slug,
|
||||
}
|
||||
// Try to get data size from the storage subdirectory
|
||||
appDataDir := filepath.Join(storagePath, "storage", stack.Name)
|
||||
if fi, err := os.Stat(appDataDir); err == nil && fi.IsDir() {
|
||||
detail.SizeHuman = dirSizeHuman(appDataDir)
|
||||
}
|
||||
details = append(details, detail)
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
// dirSizeHuman returns a human-readable size for a directory.
|
||||
func dirSizeHuman(path string) string {
|
||||
var total int64
|
||||
filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
total += info.Size()
|
||||
return nil
|
||||
})
|
||||
const (
|
||||
KB = 1024
|
||||
MB = KB * 1024
|
||||
GB = MB * 1024
|
||||
)
|
||||
switch {
|
||||
case total >= GB:
|
||||
return fmt.Sprintf("%.1f GB", float64(total)/float64(GB))
|
||||
case total >= MB:
|
||||
return fmt.Sprintf("%.1f MB", float64(total)/float64(MB))
|
||||
case total >= KB:
|
||||
return fmt.Sprintf("%.1f KB", float64(total)/float64(KB))
|
||||
default:
|
||||
return fmt.Sprintf("%d B", total)
|
||||
}
|
||||
}
|
||||
|
||||
func formatFreeSpace(gb float64) string {
|
||||
if gb >= 1000 {
|
||||
return fmt.Sprintf("%.1f TB", gb/1024)
|
||||
}
|
||||
return fmt.Sprintf("%.1f GB", gb)
|
||||
}
|
||||
|
||||
func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
|
||||
@@ -613,7 +746,7 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Storage path added: %s (%s)", path, label)
|
||||
http.Redirect(w, r, "/settings", http.StatusFound)
|
||||
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló sikeresen hozzáadva: "+path), http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -653,7 +786,7 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Storage path removed: %s", path)
|
||||
http.Redirect(w, r, "/settings", http.StatusFound)
|
||||
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló eltávolítva: "+path), http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -662,8 +795,10 @@ func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Re
|
||||
|
||||
if err := s.settings.SetDefaultStoragePath(path); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to set default storage path: %v", err)
|
||||
http.Redirect(w, r, "/settings", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusFound)
|
||||
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Alapértelmezett adattároló beállítva: "+path), http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -673,6 +808,32 @@ func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *htt
|
||||
|
||||
if err := s.settings.SetSchedulable(path, schedulable); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to update schedulable: %v", err)
|
||||
http.Redirect(w, r, "/settings", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/settings", http.StatusFound)
|
||||
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló állapot módosítva: "+path), http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
path := r.FormValue("storage_path")
|
||||
label := strings.TrimSpace(r.FormValue("storage_label"))
|
||||
|
||||
if label == "" || len(label) > 50 {
|
||||
data := s.settingsData()
|
||||
data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter."
|
||||
s.render(w, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.settings.SetStorageLabel(path, label); err != nil {
|
||||
s.logger.Printf("[ERROR] Failed to set storage label: %v", err)
|
||||
data := s.settingsData()
|
||||
data["StorageError"] = "Hiba a megnevezés mentésekor."
|
||||
s.render(w, "settings", data)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Storage label updated: %s → %q", path, label)
|
||||
http.Redirect(w, r, "/settings?storage_msg=success&storage_detail="+url.QueryEscape("Megnevezés módosítva: "+label), http.StatusFound)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user