feat: comprehensive debug logging across all controller modules
Add detailed [DEBUG] logging to every controller module when logging.level is set to "debug". Each module with stateful debug uses SetDebug(bool) wired from main.go. Covers stacks, backup, cloudflare, integrations, system, monitor, settings, scheduler, web handlers, storage, metrics, API, selfupdate, and assets. Also includes the app export/import (.fab bundles) feature from v0.32.0 and its debug page integration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
|
||||
)
|
||||
|
||||
// ServeExportAPI dispatches /api/export/* endpoints.
|
||||
func (s *Server) ServeExportAPI(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
|
||||
switch {
|
||||
// GET /api/export/estimate?stack=X&drive=Y
|
||||
case path == "/api/export/estimate" && r.Method == http.MethodGet:
|
||||
s.apiExportEstimate(w, r)
|
||||
|
||||
// POST /api/export/start
|
||||
case path == "/api/export/start" && r.Method == http.MethodPost:
|
||||
s.apiExportStart(w, r)
|
||||
|
||||
// GET /api/export/status
|
||||
case path == "/api/export/status" && r.Method == http.MethodGet:
|
||||
s.apiExportStatus(w, r)
|
||||
|
||||
// GET /api/export/bundles — scan for .fab files on all drives
|
||||
case path == "/api/export/bundles" && r.Method == http.MethodGet:
|
||||
s.apiExportBundles(w, r)
|
||||
|
||||
// POST /api/export/manifest — read manifest from a .fab file
|
||||
case path == "/api/export/manifest" && r.Method == http.MethodPost:
|
||||
s.apiExportManifest(w, r)
|
||||
|
||||
// POST /api/export/import — start async import
|
||||
case path == "/api/export/import" && r.Method == http.MethodPost:
|
||||
s.apiImportStart(w, r)
|
||||
|
||||
// GET /api/export/import/status — poll import progress
|
||||
case path == "/api/export/import/status" && r.Method == http.MethodGet:
|
||||
s.apiImportStatus(w, r)
|
||||
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// exportPageHandler renders the export form for a specific app.
|
||||
func (s *Server) exportPageHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
if s.appExporter == nil {
|
||||
http.Error(w, "App export not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
stack, ok := s.stackMgr.GetStack(name)
|
||||
if !ok || !stack.Deployed {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Build drive list for the dropdown
|
||||
drives := s.storageDriveList()
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Stack": stack,
|
||||
"Drives": drives,
|
||||
}
|
||||
s.executeTemplate(w, r, "app_export", data)
|
||||
}
|
||||
|
||||
// importPageHandler renders the import page (standalone, not tied to a stack).
|
||||
func (s *Server) importPageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if s.appExporter == nil {
|
||||
http.Error(w, "App import not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
drives := s.storageDriveList()
|
||||
bundles := appexport.ScanForBundles(drives)
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Bundles": bundles,
|
||||
}
|
||||
s.executeTemplate(w, r, "app_import", data)
|
||||
}
|
||||
|
||||
// apiExportEstimate returns size estimation for an export.
|
||||
func (s *Server) apiExportEstimate(w http.ResponseWriter, r *http.Request) {
|
||||
if s.appExporter == nil {
|
||||
jsonError(w, "App export not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
stackName := r.URL.Query().Get("stack")
|
||||
drive := r.URL.Query().Get("drive")
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiExportEstimate: stack=%q drive=%q", stackName, drive)
|
||||
if stackName == "" || drive == "" {
|
||||
jsonError(w, "Missing stack or drive parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.isValidDrivePath(drive) {
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiExportEstimate: invalid drive path %q", drive)
|
||||
jsonError(w, "Invalid drive path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
est, err := s.appExporter.EstimateExport(stackName, drive)
|
||||
if err != nil {
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiExportEstimate error: %v", err)
|
||||
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiExportEstimate: total=%s free=%s fits=%v",
|
||||
est.TotalSizeHuman, est.DestFreeHuman, est.FitsOnDest)
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"data": est,
|
||||
})
|
||||
}
|
||||
|
||||
// apiExportStart starts an async export.
|
||||
func (s *Server) apiExportStart(w http.ResponseWriter, r *http.Request) {
|
||||
if s.appExporter == nil {
|
||||
jsonError(w, "App export not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
StackName string `json:"stack_name"`
|
||||
DestDrive string `json:"dest_drive"`
|
||||
Password string `json:"password"`
|
||||
StopApp bool `json:"stop_app"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiExportStart: invalid body: %v", err)
|
||||
jsonError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiExportStart: stack=%q drive=%q encrypted=%v stopApp=%v",
|
||||
req.StackName, req.DestDrive, req.Password != "", req.StopApp)
|
||||
|
||||
if req.StackName == "" || req.DestDrive == "" {
|
||||
jsonError(w, "Missing stack_name or dest_drive", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.isValidDrivePath(req.DestDrive) {
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiExportStart: invalid drive path %q", req.DestDrive)
|
||||
jsonError(w, "Invalid drive path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := s.appExporter.StartExport(appexport.ExportRequest{
|
||||
StackName: req.StackName,
|
||||
DestDrive: req.DestDrive,
|
||||
Password: req.Password,
|
||||
StopApp: req.StopApp,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiExportStart error: %v", err)
|
||||
jsonError(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Export started for %s to %s", req.StackName, req.DestDrive)
|
||||
jsonResponse(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
// apiExportStatus returns current export/import job status.
|
||||
func (s *Server) apiExportStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if s.appExporter == nil {
|
||||
jsonError(w, "App export not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
job := s.appExporter.GetActiveJob()
|
||||
if job == nil {
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"running": false,
|
||||
"done": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
jsonResponse(w, job.Snapshot())
|
||||
}
|
||||
|
||||
// apiExportBundles scans all drives for .fab bundles.
|
||||
func (s *Server) apiExportBundles(w http.ResponseWriter, r *http.Request) {
|
||||
if s.appExporter == nil {
|
||||
jsonError(w, "App export not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
drives := s.storageDriveList()
|
||||
bundles := appexport.ScanForBundles(drives)
|
||||
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"bundles": bundles,
|
||||
})
|
||||
}
|
||||
|
||||
// apiExportManifest reads and returns the manifest from a .fab file.
|
||||
func (s *Server) apiExportManifest(w http.ResponseWriter, r *http.Request) {
|
||||
if s.appExporter == nil {
|
||||
jsonError(w, "App export not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Path == "" {
|
||||
jsonError(w, "Missing path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiExportManifest: path=%q hasPassword=%v", req.Path, req.Password != "")
|
||||
|
||||
// Security: validate path is within a registered exports directory
|
||||
if !s.isValidExportPath(req.Path) {
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiExportManifest: invalid path %q", req.Path)
|
||||
jsonError(w, "Invalid bundle path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
encrypted, _ := appexport.IsEncryptedFAB(req.Path)
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiExportManifest: encrypted=%v", encrypted)
|
||||
|
||||
var manifest *appexport.Manifest
|
||||
var err error
|
||||
if encrypted {
|
||||
if req.Password == "" {
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiExportManifest: encrypted, needs password")
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"encrypted": true,
|
||||
"needs_password": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
manifest, err = appexport.ReadManifestFromEncryptedFAB(req.Path, req.Password)
|
||||
} else {
|
||||
manifest, err = appexport.ReadManifestFromFAB(req.Path)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiExportManifest: error: %v", err)
|
||||
jsonError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiExportManifest: app=%s display=%s size=%d",
|
||||
manifest.AppName, manifest.DisplayName, manifest.TotalSizeBytes)
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"manifest": manifest,
|
||||
})
|
||||
}
|
||||
|
||||
// apiImportStart starts an async import.
|
||||
func (s *Server) apiImportStart(w http.ResponseWriter, r *http.Request) {
|
||||
if s.appExporter == nil {
|
||||
jsonError(w, "App import not available", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiImportStart: invalid body: %v", err)
|
||||
jsonError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiImportStart: path=%q hasPassword=%v", req.Path, req.Password != "")
|
||||
|
||||
if req.Path == "" {
|
||||
jsonError(w, "Missing path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.isValidExportPath(req.Path) {
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiImportStart: invalid path %q", req.Path)
|
||||
jsonError(w, "Invalid bundle path", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
err := s.appExporter.StartImport(appexport.ImportRequest{
|
||||
FABPath: req.Path,
|
||||
Password: req.Password,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Printf("[DEBUG] [handler_export] apiImportStart error: %v", err)
|
||||
jsonError(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Import started from %s", req.Path)
|
||||
jsonResponse(w, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
// apiImportStatus returns current import job status (same as export status).
|
||||
func (s *Server) apiImportStatus(w http.ResponseWriter, r *http.Request) {
|
||||
s.apiExportStatus(w, r)
|
||||
}
|
||||
|
||||
// storageDriveList converts settings StoragePaths to appexport DrivePathInfo.
|
||||
func (s *Server) storageDriveList() []appexport.DrivePathInfo {
|
||||
paths := s.settings.GetStoragePaths()
|
||||
drives := make([]appexport.DrivePathInfo, 0, len(paths))
|
||||
for _, sp := range paths {
|
||||
drives = append(drives, appexport.DrivePathInfo{
|
||||
Path: sp.Path,
|
||||
Label: sp.Label,
|
||||
})
|
||||
}
|
||||
return drives
|
||||
}
|
||||
|
||||
// isValidDrivePath checks if a path is a registered storage path.
|
||||
func (s *Server) isValidDrivePath(path string) bool {
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.Path == path {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isValidExportPath checks if a file path is within a registered exports directory.
|
||||
func (s *Server) isValidExportPath(filePath string) bool {
|
||||
cleanPath := filepath.Clean(filePath)
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
exportDir := appexport.ExportDir(sp.Path)
|
||||
if strings.HasPrefix(cleanPath, filepath.Clean(exportDir)+string(filepath.Separator)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user