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:
@@ -53,6 +53,9 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Skip auth if no password is configured
|
||||
if !s.authEnabled() {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] auth: no password configured, passing through %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -77,6 +80,13 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||||
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil || !s.isValidSession(cookie.Value) {
|
||||
if s.isDebug() {
|
||||
reason := "no cookie"
|
||||
if err == nil {
|
||||
reason = "invalid/expired session"
|
||||
}
|
||||
s.logger.Printf("[DEBUG] [web] auth: rejected %s %s from %s (%s)", r.Method, r.URL.Path, r.RemoteAddr, reason)
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
@@ -92,6 +102,9 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] auth: valid session for %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -101,6 +114,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
password := r.FormValue("password")
|
||||
nextURL := r.FormValue("next")
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] login attempt from %s (X-Forwarded-For: %s)", r.RemoteAddr, r.Header.Get("X-Forwarded-For"))
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
s.renderLogin(w, "Kérjük adja meg a jelszót", "")
|
||||
return
|
||||
@@ -147,6 +164,10 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
delete(s.loginAttempts, ip)
|
||||
s.loginAttemptMu.Unlock()
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] login successful from %s, creating session", ip)
|
||||
}
|
||||
|
||||
token := s.createSession()
|
||||
isSecure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
@@ -174,6 +195,9 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] logout from %s", r.RemoteAddr)
|
||||
}
|
||||
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||
s.sessionsMu.Lock()
|
||||
delete(s.sessions, cookie.Value)
|
||||
@@ -197,8 +221,13 @@ func (s *Server) createSession() string {
|
||||
expiresAt: time.Now().Add(sessionMaxAge),
|
||||
csrfToken: csrfToken,
|
||||
}
|
||||
sessionCount := len(s.sessions)
|
||||
s.sessionsMu.Unlock()
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] session created, expires=%s, active_sessions=%d", time.Now().Add(sessionMaxAge).Format(time.RFC3339), sessionCount)
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
@@ -225,8 +254,12 @@ func (s *Server) isValidSession(token string) bool {
|
||||
// Used after password change.
|
||||
func (s *Server) invalidateAllSessions() {
|
||||
s.sessionsMu.Lock()
|
||||
count := len(s.sessions)
|
||||
s.sessions = make(map[string]*session)
|
||||
s.sessionsMu.Unlock()
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] invalidated all sessions (cleared %d)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) cleanupSessions() {
|
||||
@@ -239,12 +272,18 @@ func (s *Server) cleanupSessions() {
|
||||
case <-ticker.C:
|
||||
s.sessionsMu.Lock()
|
||||
now := time.Now()
|
||||
expired := 0
|
||||
for t, sess := range s.sessions {
|
||||
if now.After(sess.expiresAt) {
|
||||
delete(s.sessions, t)
|
||||
expired++
|
||||
}
|
||||
}
|
||||
remaining := len(s.sessions)
|
||||
s.sessionsMu.Unlock()
|
||||
if s.isDebug() && expired > 0 {
|
||||
s.logger.Printf("[DEBUG] [web] session cleanup: expired=%d remaining=%d", expired, remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
||||
@@ -100,6 +101,14 @@ func (s *Server) handleDebugAPI(w http.ResponseWriter, r *http.Request) {
|
||||
case subpath == "logs" && r.Method == http.MethodGet:
|
||||
s.debugLogBuffer(w, r)
|
||||
|
||||
// Section 9: App Export/Import
|
||||
case subpath == "appexport/status" && r.Method == http.MethodGet:
|
||||
s.debugAppExportStatus(w, r)
|
||||
case subpath == "appexport/bundles" && r.Method == http.MethodGet:
|
||||
s.debugAppExportBundles(w, r)
|
||||
case subpath == "appexport/cleanup" && r.Method == http.MethodPost:
|
||||
s.debugAppExportCleanup(w, r)
|
||||
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -725,3 +734,90 @@ func (s *Server) debugLogBuffer(w http.ResponseWriter, r *http.Request) {
|
||||
"total": total,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Section 9: App Export/Import ─────────────────────────────────────
|
||||
|
||||
func (s *Server) debugAppExportStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if s.appExporter == nil {
|
||||
writeDebugJSON(w, http.StatusOK, true, "", map[string]interface{}{
|
||||
"available": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
info := s.appExporter.GetDebugInfo()
|
||||
|
||||
// Scan for bundles
|
||||
drives := s.storageDriveList()
|
||||
bundles := appexport.ScanForBundles(drives)
|
||||
|
||||
// Scan for stale temp files
|
||||
staleFiles := appexport.ScanForStaleTempFiles(drives)
|
||||
|
||||
info["bundle_count"] = len(bundles)
|
||||
info["stale_temp_files"] = staleFiles
|
||||
info["stale_temp_count"] = len(staleFiles)
|
||||
info["available"] = true
|
||||
|
||||
// Export dirs
|
||||
exportDirs := make([]map[string]interface{}, 0, len(drives))
|
||||
for _, d := range drives {
|
||||
dir := appexport.ExportDir(d.Path)
|
||||
dirInfo := map[string]interface{}{
|
||||
"path": dir,
|
||||
"label": d.Label,
|
||||
}
|
||||
if stat, err := os.Stat(dir); err == nil {
|
||||
dirInfo["exists"] = true
|
||||
dirInfo["modified"] = stat.ModTime()
|
||||
} else {
|
||||
dirInfo["exists"] = false
|
||||
}
|
||||
exportDirs = append(exportDirs, dirInfo)
|
||||
}
|
||||
info["export_dirs"] = exportDirs
|
||||
|
||||
writeDebugJSON(w, http.StatusOK, true, "", info)
|
||||
}
|
||||
|
||||
func (s *Server) debugAppExportBundles(w http.ResponseWriter, r *http.Request) {
|
||||
if s.appExporter == nil {
|
||||
writeDebugJSON(w, http.StatusBadRequest, false, "App export not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
drives := s.storageDriveList()
|
||||
bundles := appexport.ScanForBundles(drives)
|
||||
|
||||
writeDebugJSON(w, http.StatusOK, true,
|
||||
fmt.Sprintf("%d csomag található", len(bundles)),
|
||||
map[string]interface{}{"bundles": bundles})
|
||||
}
|
||||
|
||||
func (s *Server) debugAppExportCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
if s.appExporter == nil {
|
||||
writeDebugJSON(w, http.StatusBadRequest, false, "App export not available", nil)
|
||||
return
|
||||
}
|
||||
|
||||
drives := s.storageDriveList()
|
||||
staleFiles := appexport.ScanForStaleTempFiles(drives)
|
||||
|
||||
if len(staleFiles) == 0 {
|
||||
writeDebugJSON(w, http.StatusOK, true, "Nincs eltávolítandó temp fájl", nil)
|
||||
return
|
||||
}
|
||||
|
||||
removed := 0
|
||||
for _, f := range staleFiles {
|
||||
if err := os.Remove(f); err != nil {
|
||||
s.logger.Printf("[WARN] Failed to remove stale temp file %s: %v", f, err)
|
||||
} else {
|
||||
s.logger.Printf("[INFO] Removed stale temp file: %s", f)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
|
||||
writeDebugJSON(w, http.StatusOK, true,
|
||||
fmt.Sprintf("%d/%d temp fájl eltávolítva", removed, len(staleFiles)), nil)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -11,10 +11,16 @@ import (
|
||||
|
||||
// restorePageHandler renders the full-page DR restore UI.
|
||||
func (s *Server) restorePageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] restorePageHandler: rendering restore page")
|
||||
}
|
||||
s.restoreMu.RLock()
|
||||
plan := s.restorePlan
|
||||
if plan == nil {
|
||||
s.restoreMu.RUnlock()
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] restorePageHandler: no restore plan, redirecting to /")
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
@@ -26,6 +32,9 @@ func (s *Server) restorePageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
copy(drives, plan.Drives)
|
||||
status := plan.GetStatus()
|
||||
s.restoreMu.RUnlock()
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] restorePageHandler: customer=%s apps=%d drives=%d status=%s", customerID, len(apps), len(drives), status)
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Title": "Katasztrófa utáni visszaállítás",
|
||||
@@ -44,6 +53,9 @@ func (s *Server) restorePageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// apiRestoreStatus returns the current restore plan status as JSON.
|
||||
func (s *Server) apiRestoreStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] apiRestoreStatus: status poll from %s", r.RemoteAddr)
|
||||
}
|
||||
s.restoreMu.RLock()
|
||||
plan := s.restorePlan
|
||||
if plan == nil {
|
||||
@@ -60,6 +72,9 @@ func (s *Server) apiRestoreStatus(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// apiRestoreAll starts restoring all pending apps sequentially.
|
||||
func (s *Server) apiRestoreAll(w http.ResponseWriter, r *http.Request) {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] apiRestoreAll: restore-all requested from %s", r.RemoteAddr)
|
||||
}
|
||||
s.restoreMu.RLock()
|
||||
plan := s.restorePlan
|
||||
s.restoreMu.RUnlock()
|
||||
@@ -68,6 +83,9 @@ func (s *Server) apiRestoreAll(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !plan.TryStartRestore() {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] apiRestoreAll: restore already in progress, rejecting")
|
||||
}
|
||||
jsonError(w, "restore already in progress", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
@@ -81,6 +99,9 @@ func (s *Server) apiRestoreAll(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// apiRestoreSkip exits restore mode without restoring.
|
||||
func (s *Server) apiRestoreSkip(w http.ResponseWriter, r *http.Request) {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] apiRestoreSkip: skip requested from %s", r.RemoteAddr)
|
||||
}
|
||||
s.restoreMu.RLock()
|
||||
plan := s.restorePlan
|
||||
s.restoreMu.RUnlock()
|
||||
@@ -101,6 +122,7 @@ func (s *Server) apiRestoreSkip(w http.ResponseWriter, r *http.Request) {
|
||||
// executeAllRestores runs the restore for each pending app sequentially.
|
||||
func (s *Server) executeAllRestores() {
|
||||
s.logger.Println("[INFO] Starting DR restore for all apps")
|
||||
restoreStart := time.Now()
|
||||
|
||||
s.restoreMu.RLock()
|
||||
plan := s.restorePlan
|
||||
@@ -117,6 +139,9 @@ func (s *Server) executeAllRestores() {
|
||||
pendingCount++
|
||||
}
|
||||
}
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] executeAllRestores: %d pending apps to restore", pendingCount)
|
||||
}
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyDRStarted(pendingCount)
|
||||
}
|
||||
@@ -130,6 +155,7 @@ func (s *Server) executeAllRestores() {
|
||||
|
||||
plan.UpdateApp(app.Name, "restoring", "")
|
||||
s.logger.Printf("[INFO] Restoring app %s (%s)", app.Name, app.DisplayName)
|
||||
appStart := time.Now()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
err := backup.RestoreAppFromBackup(ctx, app, s.cfg.Paths.StacksDir, s.logger)
|
||||
@@ -138,16 +164,25 @@ func (s *Server) executeAllRestores() {
|
||||
if err != nil {
|
||||
plan.UpdateApp(app.Name, "failed", err.Error())
|
||||
s.logger.Printf("[ERROR] Restore failed for %s: %v", app.Name, err)
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] executeAllRestores: app=%s failed after %s", app.Name, time.Since(appStart))
|
||||
}
|
||||
failCount++
|
||||
} else {
|
||||
plan.UpdateApp(app.Name, "done", "")
|
||||
s.logger.Printf("[INFO] Restore completed for %s", app.Name)
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] executeAllRestores: app=%s completed in %s", app.Name, time.Since(appStart))
|
||||
}
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
plan.SetStatus("done")
|
||||
s.logger.Println("[INFO] All app restores completed")
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] executeAllRestores: total=%d success=%d fail=%d elapsed=%s", pendingCount, successCount, failCount, time.Since(restoreStart))
|
||||
}
|
||||
|
||||
// Push DR completion event
|
||||
if s.notifier != nil {
|
||||
|
||||
@@ -276,8 +276,14 @@ func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string
|
||||
}
|
||||
|
||||
func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s method=%s", name, r.Method)
|
||||
}
|
||||
meta, appCfg, err := s.stackMgr.GetDeployFields(name)
|
||||
if err != nil {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s not found: %v", name, err)
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
@@ -953,6 +959,10 @@ func (s *Server) buildAppBackupRows(
|
||||
func (s *Server) settingsCrossBackupHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
_ = r.ParseForm()
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] settingsCrossBackupHandler: stack=%s from %s", name, r.RemoteAddr)
|
||||
}
|
||||
|
||||
enabled := r.FormValue("cross_drive_enabled") == "on"
|
||||
|
||||
// Preserve existing runtime status fields and config when disabling
|
||||
@@ -1023,6 +1033,10 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
stackName := r.FormValue("stack_name")
|
||||
snapshotID := r.FormValue("snapshot_id")
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
|
||||
}
|
||||
|
||||
if stackName == "" || snapshotID == "" {
|
||||
http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
|
||||
return
|
||||
@@ -1035,13 +1049,21 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
s.logger.Printf("[WARN] Restore requested: stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
|
||||
|
||||
start := time.Now()
|
||||
if err := s.backupMgr.RestoreApp(stackName, snapshotID); err != nil {
|
||||
s.logger.Printf("[ERROR] Restore failed: %v", err)
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s failed after %s", stackName, time.Since(start))
|
||||
}
|
||||
errMsg := url.QueryEscape("Visszaállítás sikertelen: " + err.Error())
|
||||
http.Redirect(w, r, "/backups?flash_error="+errMsg, http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s completed in %s", stackName, time.Since(start))
|
||||
}
|
||||
|
||||
msg := url.QueryEscape(stackName + " visszaállítva (" + snapshotID + ").")
|
||||
http.Redirect(w, r, "/backups?flash="+msg, http.StatusFound)
|
||||
}
|
||||
@@ -1167,11 +1189,18 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
|
||||
newPassword := r.FormValue("new_password")
|
||||
confirmPassword := r.FormValue("confirm_password")
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: password change attempt from %s", r.RemoteAddr)
|
||||
}
|
||||
|
||||
data := s.settingsData()
|
||||
|
||||
// Validate current password
|
||||
effectiveHash := s.effectivePasswordHash()
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: current password mismatch from %s", r.RemoteAddr)
|
||||
}
|
||||
data["PasswordError"] = "Hibás jelenlegi jelszó"
|
||||
s.executeTemplate(w, r, "settings", data)
|
||||
return
|
||||
@@ -1221,6 +1250,10 @@ func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request)
|
||||
func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] settingsNotificationsHandler: updating notification prefs from %s", r.RemoteAddr)
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(r.FormValue("notification_email"))
|
||||
cooldownStr := r.FormValue("cooldown_hours")
|
||||
cooldownHours := 6
|
||||
@@ -1410,6 +1443,10 @@ func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Reques
|
||||
label := strings.TrimSpace(r.FormValue("storage_label"))
|
||||
isDefault := r.FormValue("storage_default") == "true"
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] settingsStorageAddHandler: path=%s label=%q default=%v from %s", path, label, isDefault, r.RemoteAddr)
|
||||
}
|
||||
|
||||
if label == "" {
|
||||
label = settings.InferStorageLabel(path)
|
||||
}
|
||||
@@ -1476,6 +1513,10 @@ func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Req
|
||||
_ = r.ParseForm()
|
||||
path := r.FormValue("storage_path")
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] settingsStorageRemoveHandler: path=%s from %s", path, r.RemoteAddr)
|
||||
}
|
||||
|
||||
data := s.settingsData()
|
||||
|
||||
// Check: apps using this path
|
||||
@@ -1518,6 +1559,10 @@ func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Re
|
||||
_ = r.ParseForm()
|
||||
path := r.FormValue("storage_path")
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] settingsStorageDefaultHandler: path=%s from %s", path, r.RemoteAddr)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -1531,6 +1576,10 @@ func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *htt
|
||||
path := r.FormValue("storage_path")
|
||||
schedulable := r.FormValue("schedulable") == "true"
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] settingsStorageSchedulableHandler: path=%s schedulable=%v from %s", path, schedulable, r.RemoteAddr)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -1544,6 +1593,10 @@ func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Requ
|
||||
path := r.FormValue("storage_path")
|
||||
label := strings.TrimSpace(r.FormValue("storage_label"))
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] settingsStorageLabelHandler: path=%s label=%q from %s", path, label, r.RemoteAddr)
|
||||
}
|
||||
|
||||
if label == "" || len(label) > 50 {
|
||||
data := s.settingsData()
|
||||
data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter."
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/assets"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
@@ -78,6 +79,9 @@ type Server struct {
|
||||
// App-to-app integration manager (optional)
|
||||
integrationMgr *integrations.Manager
|
||||
|
||||
// App export/import engine (optional)
|
||||
appExporter *appexport.Exporter
|
||||
|
||||
// Debug mode support
|
||||
logBuffer *LogBuffer
|
||||
debugCallbacks *DebugCallbacks
|
||||
@@ -102,6 +106,13 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste
|
||||
loginAttempts: make(map[string]*loginAttempt),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
if cfg.Logging.Level == "debug" {
|
||||
logger.Printf("[DEBUG] [web] NewServer: initializing web server v%s", version)
|
||||
logger.Printf("[DEBUG] [web] NewServer: backup=%v crossDrive=%v scheduler=%v alertMgr=%v notifier=%v updater=%v",
|
||||
backupMgr != nil, crossDrive != nil, sched != nil, alertMgr != nil, notif != nil, updater != nil)
|
||||
}
|
||||
|
||||
s.loadTemplates()
|
||||
go s.cleanupSessions()
|
||||
|
||||
@@ -138,6 +149,10 @@ func (s *Server) loadTemplates() {
|
||||
s.tmpl = template.Must(
|
||||
template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"),
|
||||
)
|
||||
if s.isDebug() {
|
||||
names := s.tmpl.Templates()
|
||||
s.logger.Printf("[DEBUG] [web] loadTemplates: loaded %d templates", len(names))
|
||||
}
|
||||
}
|
||||
|
||||
// SetRestoreState puts the server into DR restore mode with the given plan.
|
||||
@@ -190,6 +205,11 @@ func (s *Server) SetDebugCallbacks(dc *DebugCallbacks) {
|
||||
s.debugCallbacks = dc
|
||||
}
|
||||
|
||||
// SetAppExporter sets the app export/import engine.
|
||||
func (s *Server) SetAppExporter(e *appexport.Exporter) {
|
||||
s.appExporter = e
|
||||
}
|
||||
|
||||
// SetStartTime records the controller start time for uptime calculation.
|
||||
func (s *Server) SetStartTime(t time.Time) {
|
||||
s.startTime = t
|
||||
@@ -221,6 +241,10 @@ func (s *Server) InRestoreMode() bool {
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] ServeHTTP: %s %s from %s", r.Method, path, r.RemoteAddr)
|
||||
}
|
||||
|
||||
// DR restore mode: intercept all routes except restore page, static, and restore API
|
||||
if s.InRestoreMode() {
|
||||
switch {
|
||||
@@ -283,6 +307,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.storageAttachHandler(w, r)
|
||||
case path == "/settings/storage/migrate-drive":
|
||||
s.migrateDrivePageHandler(w, r)
|
||||
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/export"):
|
||||
name := strings.TrimPrefix(path, "/stacks/")
|
||||
name = strings.TrimSuffix(name, "/export")
|
||||
s.exportPageHandler(w, r, name)
|
||||
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/migrate"):
|
||||
name := strings.TrimPrefix(path, "/stacks/")
|
||||
name = strings.TrimSuffix(name, "/migrate")
|
||||
@@ -295,6 +323,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(path, "/stacks/")
|
||||
name = strings.TrimSuffix(name, "/deploy")
|
||||
s.deployHandler(w, r, name)
|
||||
case path == "/import":
|
||||
s.importPageHandler(w, r)
|
||||
case path == "/static/style.css":
|
||||
s.serveCSSHandler(w, r)
|
||||
case path == "/static/chart.min.js":
|
||||
@@ -324,6 +354,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// the controller host (felhom.DOMAIN) pass through normally.
|
||||
func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
|
||||
controllerHost := "felhom." + s.cfg.Customer.Domain
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] CatchAllMiddleware: controller host=%s", controllerHost)
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Host
|
||||
if idx := strings.LastIndex(host, ":"); idx != -1 {
|
||||
@@ -335,6 +368,9 @@ func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] CatchAllMiddleware: non-controller host=%s, serving catch-all page", host)
|
||||
}
|
||||
s.serveCatchAll(w, r, host)
|
||||
})
|
||||
}
|
||||
@@ -405,6 +441,9 @@ func (s *Server) findStackBySubdomain(subdomain string) (*stacks.Stack, bool) {
|
||||
|
||||
// ServeStorageAPI handles /api/storage/* routes (JSON API for disk operations).
|
||||
func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] ServeStorageAPI: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
|
||||
}
|
||||
s.storageAPIHandler(w, r)
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,10 @@ func (s *Server) storageInitHandler(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) storageAPIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] storageAPI: %s %s from %s", r.Method, path, r.RemoteAddr)
|
||||
}
|
||||
|
||||
switch {
|
||||
case path == "/api/storage/scan" && r.Method == http.MethodPost:
|
||||
s.storageScanAPIHandler(w, r)
|
||||
@@ -197,12 +201,18 @@ func (s *Server) storageAPIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// storageScanAPIHandler handles POST /api/storage/scan.
|
||||
func (s *Server) storageScanAPIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] storageScan: scanning disks")
|
||||
}
|
||||
result, err := storage.ScanDisks(s.logger, s.cfg.Logging.Level == "debug")
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] storageScan: %v", err)
|
||||
jsonError(w, "Meghajtók keresése sikertelen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] storageScan: found %d available disks, %d system disks", len(result.AvailableDisks), len(result.SystemDisks))
|
||||
}
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"available": result.AvailableDisks,
|
||||
@@ -226,6 +236,11 @@ func (s *Server) storageInitAPIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] storageInit: device=%s mountName=%s label=%q partition=%v default=%v from %s",
|
||||
req.DevicePath, req.MountName, req.Label, req.CreatePartition, req.SetDefault, r.RemoteAddr)
|
||||
}
|
||||
|
||||
if req.Confirm != "FORMÁZÁS" {
|
||||
jsonError(w, "Megerősítés szükséges: írja be 'FORMÁZÁS'", http.StatusBadRequest)
|
||||
return
|
||||
@@ -432,6 +447,10 @@ func (s *Server) storageMigrateAPIHandler(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] storageMigrate: stack=%s target=%s from %s", req.StackName, req.TargetPath, r.RemoteAddr)
|
||||
}
|
||||
|
||||
if req.StackName == "" || req.TargetPath == "" {
|
||||
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
|
||||
return
|
||||
@@ -794,6 +813,10 @@ func (s *Server) staleDataCleanupHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] staleDataCleanup: stack=%s stalePath=%s from %s", req.StackName, req.StalePath, r.RemoteAddr)
|
||||
}
|
||||
|
||||
if req.StackName == "" || req.StalePath == "" {
|
||||
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
|
||||
return
|
||||
@@ -915,6 +938,10 @@ func (s *Server) storageAttachMountRawHandler(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] storageAttachMountRaw: device=%s from %s", req.DevicePath, r.RemoteAddr)
|
||||
}
|
||||
|
||||
// Hold lock across entire cleanup+mount+set to prevent races
|
||||
s.diskJobMu.Lock()
|
||||
if s.activeRawMount != "" {
|
||||
@@ -1021,6 +1048,11 @@ func (s *Server) storageAttachAPIHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] storageAttach: device=%s mountName=%s subPath=%s label=%q default=%v from %s",
|
||||
req.DevicePath, req.MountName, req.SubPath, req.Label, req.SetDefault, r.RemoteAddr)
|
||||
}
|
||||
|
||||
if req.DevicePath == "" || req.MountName == "" || req.SubPath == "" {
|
||||
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
|
||||
return
|
||||
@@ -1164,6 +1196,10 @@ func (s *Server) storageDisconnectHandler(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] storageDisconnect: path=%s from %s", req.Path, r.RemoteAddr)
|
||||
}
|
||||
|
||||
if s.storageWatchdog == nil {
|
||||
jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable)
|
||||
return
|
||||
@@ -1172,6 +1208,9 @@ func (s *Server) storageDisconnectHandler(w http.ResponseWriter, r *http.Request
|
||||
// Check if USB device (only USB drives can be safely disconnected)
|
||||
fsInfo := system.GetFSInfo(req.Path)
|
||||
if fsInfo != nil && fsInfo.Device != "" && !system.IsUSBDevice(fsInfo.Device) {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] storageDisconnect: path=%s device=%s is not USB, rejecting", req.Path, fsInfo.Device)
|
||||
}
|
||||
jsonError(w, "Csak USB meghajtó választható le biztonságosan", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -1183,6 +1222,10 @@ func (s *Server) storageDisconnectHandler(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] storageDisconnect: path=%s success, stopped %d stacks", req.Path, len(stoppedStacks))
|
||||
}
|
||||
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": "A meghajtó biztonságosan eltávolítható.",
|
||||
@@ -1205,6 +1248,10 @@ func (s *Server) storageReconnectHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] storageReconnect: path=%s from %s", req.Path, r.RemoteAddr)
|
||||
}
|
||||
|
||||
if s.storageWatchdog == nil {
|
||||
jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable)
|
||||
return
|
||||
@@ -1217,6 +1264,10 @@ func (s *Server) storageReconnectHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] storageReconnect: path=%s success, previously stopped stacks=%v", req.Path, stoppedStacks)
|
||||
}
|
||||
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": "Meghajtó sikeresen csatlakoztatva.",
|
||||
@@ -1239,6 +1290,10 @@ func (s *Server) storageRestartAppsHandler(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] storageRestartApps: path=%s from %s", req.Path, r.RemoteAddr)
|
||||
}
|
||||
|
||||
if s.storageWatchdog == nil {
|
||||
jsonError(w, "Szolgáltatás nem elérhető", http.StatusServiceUnavailable)
|
||||
return
|
||||
@@ -1246,11 +1301,17 @@ func (s *Server) storageRestartAppsHandler(w http.ResponseWriter, r *http.Reques
|
||||
|
||||
// Validate drive is connected
|
||||
if s.settings.IsDisconnected(req.Path) {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] storageRestartApps: path=%s is disconnected, rejecting", req.Path)
|
||||
}
|
||||
jsonError(w, "A meghajtó jelenleg leválasztva — először csatlakoztassa", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
started, failed := s.storageWatchdog.RestartStoppedApps(req.Path)
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] storageRestartApps: path=%s started=%v failed=%v", req.Path, started, failed)
|
||||
}
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"started": started,
|
||||
@@ -1387,6 +1448,10 @@ func (s *Server) driveMigrateAPIHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] driveMigrate: source=%s dest=%s from %s", req.SourcePath, req.DestPath, r.RemoteAddr)
|
||||
}
|
||||
|
||||
if req.SourcePath == "" || req.DestPath == "" {
|
||||
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
|
||||
return
|
||||
@@ -1497,6 +1562,10 @@ func (s *Server) decommissionRemoveHandler(w http.ResponseWriter, r *http.Reques
|
||||
req.Path = r.FormValue("storage_path")
|
||||
}
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] decommissionRemove: path=%s from %s", req.Path, r.RemoteAddr)
|
||||
}
|
||||
|
||||
if req.Path == "" {
|
||||
jsonError(w, "Hiányzó útvonal", http.StatusBadRequest)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
{{define "app_export"}}
|
||||
{{template "layout_start" .}}
|
||||
|
||||
<div class="page-header">
|
||||
<div style="display:flex;align-items:center;gap:1rem">
|
||||
<a href="/stacks" class="btn btn-sm btn-outline">← Alkalmazások</a>
|
||||
<h2>{{.Stack.Meta.DisplayName}} — Exportálás</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="max-width:700px">
|
||||
<h3>Mentés helye</h3>
|
||||
<select id="destDrive" onchange="loadEstimate()" style="width:100%;padding:.5rem;margin-bottom:1rem">
|
||||
<option value="">Válassz tárolót...</option>
|
||||
{{range .Drives}}
|
||||
<option value="{{.Path}}">{{if .Label}}{{.Label}} ({{.Path}}){{else}}{{.Path}}{{end}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
|
||||
<div id="estimateBox" style="display:none;background:var(--bg-secondary);border-radius:8px;padding:1rem;margin-bottom:1.5rem">
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
|
||||
<span>Konfiguráció:</span>
|
||||
<span id="estConfig">-</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
|
||||
<span>Felhasználói adatok:</span>
|
||||
<span id="estData">-</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-weight:600;margin-bottom:.25rem;border-top:1px solid var(--border);padding-top:.5rem">
|
||||
<span>Összesen:</span>
|
||||
<span><span id="estTotal">-</span> <span id="estFree" style="color:var(--text-muted)">(szabad: -)</span></span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<span>Becsült idő:</span>
|
||||
<span id="estTime">-</span>
|
||||
</div>
|
||||
<div id="estWarning" style="display:none;color:var(--danger);margin-top:.5rem;font-weight:600"></div>
|
||||
</div>
|
||||
|
||||
<h3>Jelszó (opcionális)</h3>
|
||||
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
|
||||
<input type="password" id="exportPassword" placeholder="Titkosítási jelszó" style="flex:1;padding:.5rem">
|
||||
<button class="btn btn-sm btn-outline" onclick="togglePw()" type="button" title="Jelszó mutatása">👁</button>
|
||||
</div>
|
||||
|
||||
<label style="display:flex;align-items:flex-start;gap:.5rem;margin-bottom:.5rem;cursor:pointer">
|
||||
<input type="checkbox" id="stopApp" checked style="margin-top:3px">
|
||||
<div>
|
||||
<strong>Alkalmazás leállítása mentés előtt (ajánlott)</strong>
|
||||
<div style="color:var(--text-muted);font-size:.85rem">Az adatok konzisztenciája érdekében javasolt leállítani az alkalmazást mentés közben.</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button id="startBtn" class="btn btn-primary" onclick="startExport()" style="margin-top:1rem;width:100%" disabled>
|
||||
Exportálás indítása
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="progressCard" class="card" style="max-width:700px;display:none">
|
||||
<h3>Folyamat</h3>
|
||||
<div id="progressSteps"></div>
|
||||
<div id="progressError" style="display:none;color:var(--danger);margin-top:1rem;font-weight:600"></div>
|
||||
</div>
|
||||
|
||||
<div id="doneCard" class="card" style="max-width:700px;display:none">
|
||||
<h3 style="color:var(--success)">Kész!</h3>
|
||||
<div style="margin-bottom:1rem">
|
||||
<span id="doneFile" style="font-weight:600"></span>
|
||||
<span id="doneSize" style="color:var(--text-muted)"></span>
|
||||
</div>
|
||||
<a id="doneFBLink" href="#" target="_blank" class="btn btn-outline">Megnyitás FileBrowser-ben ↗</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var stackName = '{{.Stack.Name}}';
|
||||
var domain = '{{.Stack.Meta.Subdomain}}' ? '{{.Stack.Meta.Subdomain}}.{{$.CSRFToken}}' : '';
|
||||
var pollTimer = null;
|
||||
|
||||
function csrfH() {
|
||||
var el = document.querySelector('meta[name="csrf-token"]');
|
||||
return el ? {'X-CSRF-Token': el.content, 'Content-Type': 'application/json'} : {'Content-Type': 'application/json'};
|
||||
}
|
||||
|
||||
function togglePw() {
|
||||
var inp = document.getElementById('exportPassword');
|
||||
inp.type = inp.type === 'password' ? 'text' : 'password';
|
||||
}
|
||||
|
||||
async function loadEstimate() {
|
||||
var drive = document.getElementById('destDrive').value;
|
||||
var box = document.getElementById('estimateBox');
|
||||
var btn = document.getElementById('startBtn');
|
||||
|
||||
if (!drive) {
|
||||
box.style.display = 'none';
|
||||
btn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var resp = await fetch('/api/export/estimate?stack=' + encodeURIComponent(stackName) + '&drive=' + encodeURIComponent(drive));
|
||||
var data = await resp.json();
|
||||
if (!data.ok) {
|
||||
box.style.display = 'none';
|
||||
btn.disabled = true;
|
||||
return;
|
||||
}
|
||||
var est = data.data;
|
||||
document.getElementById('estConfig').textContent = est.config_size_human;
|
||||
document.getElementById('estData').textContent = est.data_size_human;
|
||||
document.getElementById('estTotal').textContent = '~' + est.total_size_human;
|
||||
document.getElementById('estFree').textContent = '(szabad: ' + est.dest_free_human + ')';
|
||||
document.getElementById('estTime').textContent = '~' + est.estimated_minutes + ' perc';
|
||||
|
||||
var warn = document.getElementById('estWarning');
|
||||
if (!est.fits_on_dest) {
|
||||
warn.textContent = 'Nincs elég szabad hely a kiválasztott tárolón!';
|
||||
warn.style.display = 'block';
|
||||
btn.disabled = true;
|
||||
} else {
|
||||
warn.style.display = 'none';
|
||||
btn.disabled = false;
|
||||
}
|
||||
box.style.display = 'block';
|
||||
} catch(e) {
|
||||
console.error('Estimate error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function startExport() {
|
||||
var drive = document.getElementById('destDrive').value;
|
||||
var password = document.getElementById('exportPassword').value;
|
||||
var stopApp = document.getElementById('stopApp').checked;
|
||||
|
||||
document.getElementById('startBtn').disabled = true;
|
||||
document.getElementById('progressCard').style.display = 'block';
|
||||
document.getElementById('doneCard').style.display = 'none';
|
||||
|
||||
try {
|
||||
var resp = await fetch('/api/export/start', {
|
||||
method: 'POST',
|
||||
headers: csrfH(),
|
||||
body: JSON.stringify({
|
||||
stack_name: stackName,
|
||||
dest_drive: drive,
|
||||
password: password,
|
||||
stop_app: stopApp
|
||||
})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (!data.ok) {
|
||||
showError(data.error || 'Hiba történt');
|
||||
return;
|
||||
}
|
||||
pollStatus();
|
||||
} catch(e) {
|
||||
showError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function pollStatus() {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(async function() {
|
||||
try {
|
||||
var resp = await fetch('/api/export/status');
|
||||
var data = await resp.json();
|
||||
renderSteps(data.steps || []);
|
||||
|
||||
if (data.error) {
|
||||
showError(data.error);
|
||||
clearInterval(pollTimer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.done && !data.error) {
|
||||
clearInterval(pollTimer);
|
||||
showDone(data);
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Poll error:', e);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function renderSteps(steps) {
|
||||
var html = '';
|
||||
for (var i = 0; i < steps.length; i++) {
|
||||
var s = steps[i];
|
||||
var icon = '○'; // pending
|
||||
if (s.status === 'running') icon = '⟳';
|
||||
if (s.status === 'done') icon = '✓';
|
||||
if (s.status === 'failed') icon = '✗';
|
||||
var cls = s.status === 'failed' ? 'color:var(--danger)' : s.status === 'done' ? 'color:var(--success)' : s.status === 'running' ? 'color:var(--primary)' : '';
|
||||
html += '<div style="padding:.25rem 0;' + cls + '">' + icon + ' ' + s.label;
|
||||
if (s.error) html += ' <span style="font-size:.85rem">(' + s.error + ')</span>';
|
||||
html += '</div>';
|
||||
}
|
||||
document.getElementById('progressSteps').innerHTML = html;
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
var el = document.getElementById('progressError');
|
||||
el.textContent = msg;
|
||||
el.style.display = 'block';
|
||||
document.getElementById('startBtn').disabled = false;
|
||||
}
|
||||
|
||||
function showDone(data) {
|
||||
document.getElementById('progressCard').style.display = 'none';
|
||||
document.getElementById('doneCard').style.display = 'block';
|
||||
var fileName = (data.output_path || '').split('/').pop();
|
||||
document.getElementById('doneFile').textContent = fileName;
|
||||
document.getElementById('doneSize').textContent = data.output_size ? '(' + data.output_size + ')' : '';
|
||||
|
||||
// Build FileBrowser link to the exports directory
|
||||
var drive = document.getElementById('destDrive').value;
|
||||
var fbPath = drive + '/felhom-data/exports/';
|
||||
document.getElementById('doneFBLink').href = 'https://files.' + location.hostname.split('.').slice(-2).join('.') + '/files' + fbPath;
|
||||
}
|
||||
</script>
|
||||
|
||||
{{template "layout_end" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,287 @@
|
||||
{{define "app_import"}}
|
||||
{{template "layout_start" .}}
|
||||
|
||||
<div class="page-header">
|
||||
<div style="display:flex;align-items:center;gap:1rem">
|
||||
<a href="/stacks" class="btn btn-sm btn-outline">← Alkalmazások</a>
|
||||
<h2>Alkalmazás importálás</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if not .Bundles}}
|
||||
<div class="card" style="max-width:700px">
|
||||
<p style="color:var(--text-muted)">Nem található .fab csomag a regisztrált tárolókon.</p>
|
||||
<p style="color:var(--text-muted);font-size:.85rem">Exportálj egy alkalmazást az alkalmazás oldaláról, vagy másolj egy .fab fájlt a <code>{tároló}/felhom-data/exports/</code> könyvtárba.</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="card" style="max-width:900px">
|
||||
<table class="table" style="width:100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Alkalmazás</th>
|
||||
<th>Dátum</th>
|
||||
<th>Méret</th>
|
||||
<th>Tároló</th>
|
||||
<th>Titkos</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Bundles}}
|
||||
<tr>
|
||||
<td><strong>{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}}</strong></td>
|
||||
<td>{{.ExportedAt}}</td>
|
||||
<td>{{.SizeHuman}}</td>
|
||||
<td>{{if .DriveLabel}}{{.DriveLabel}}{{else}}{{.DrivePath}}{{end}}</td>
|
||||
<td>{{if .Encrypted}}🔒{{end}}</td>
|
||||
<td><button class="btn btn-sm btn-outline" onclick="showPreview('{{.Path}}', {{.Encrypted}})">Részletek »</button></td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Preview / password modal -->
|
||||
<div id="previewCard" class="card" style="max-width:700px;display:none">
|
||||
<h3 id="previewTitle">Csomag részletei</h3>
|
||||
|
||||
<div id="passwordPrompt" style="display:none;margin-bottom:1rem">
|
||||
<p>Ez a csomag jelszóval védett. Kérlek add meg a jelszót:</p>
|
||||
<div style="display:flex;gap:.5rem">
|
||||
<input type="password" id="importPassword" placeholder="Jelszó" style="flex:1;padding:.5rem">
|
||||
<button class="btn btn-primary" onclick="loadManifest()">Megnyitás</button>
|
||||
</div>
|
||||
<div id="passwordError" style="display:none;color:var(--danger);margin-top:.5rem"></div>
|
||||
</div>
|
||||
|
||||
<div id="manifestInfo" style="display:none">
|
||||
<div style="background:var(--bg-secondary);border-radius:8px;padding:1rem;margin-bottom:1rem">
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
|
||||
<span>Alkalmazás:</span>
|
||||
<strong id="mfName">-</strong>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
|
||||
<span>Exportálva:</span>
|
||||
<span id="mfDate">-</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
|
||||
<span>Controller verzió:</span>
|
||||
<span id="mfVersion">-</span>
|
||||
</div>
|
||||
<div id="mfDBRow" style="display:flex;justify-content:space-between;margin-bottom:.25rem">
|
||||
<span>Adatbázis:</span>
|
||||
<span id="mfDB">-</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
|
||||
<span>Adatok:</span>
|
||||
<span id="mfDataType">-</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between">
|
||||
<span>Méret:</span>
|
||||
<span id="mfSize">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="overwriteWarning" style="display:none;background:var(--warning-bg, #fff3cd);border:1px solid var(--warning-border, #ffc107);border-radius:8px;padding:1rem;margin-bottom:1rem">
|
||||
<strong>⚠ FIGYELEM:</strong> A meglévő <span id="overwriteAppName"></span> alkalmazás konfigurációja és összes adata felül lesz írva!
|
||||
</div>
|
||||
|
||||
<button id="importBtn" class="btn btn-primary" onclick="startImport()" style="width:100%">
|
||||
Visszaállítás indítása
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress -->
|
||||
<div id="progressCard" class="card" style="max-width:700px;display:none">
|
||||
<h3>Importálás folyamata</h3>
|
||||
<div id="progressSteps"></div>
|
||||
<div id="progressError" style="display:none;color:var(--danger);margin-top:1rem;font-weight:600"></div>
|
||||
</div>
|
||||
|
||||
<div id="doneCard" class="card" style="max-width:700px;display:none">
|
||||
<h3 style="color:var(--success)">Importálás kész!</h3>
|
||||
<p>Az alkalmazás sikeresen visszaállítva.</p>
|
||||
<a id="doneLink" href="/stacks" class="btn btn-primary">Alkalmazások megtekintése</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var selectedPath = '';
|
||||
var selectedEncrypted = false;
|
||||
var selectedManifest = null;
|
||||
var pollTimer = null;
|
||||
|
||||
function csrfH() {
|
||||
var el = document.querySelector('meta[name="csrf-token"]');
|
||||
return el ? {'X-CSRF-Token': el.content, 'Content-Type': 'application/json'} : {'Content-Type': 'application/json'};
|
||||
}
|
||||
|
||||
function showPreview(path, encrypted) {
|
||||
selectedPath = path;
|
||||
selectedEncrypted = encrypted;
|
||||
selectedManifest = null;
|
||||
|
||||
document.getElementById('previewCard').style.display = 'block';
|
||||
document.getElementById('manifestInfo').style.display = 'none';
|
||||
document.getElementById('passwordPrompt').style.display = 'none';
|
||||
document.getElementById('passwordError').style.display = 'none';
|
||||
document.getElementById('progressCard').style.display = 'none';
|
||||
document.getElementById('doneCard').style.display = 'none';
|
||||
|
||||
if (encrypted) {
|
||||
document.getElementById('passwordPrompt').style.display = 'block';
|
||||
document.getElementById('importPassword').value = '';
|
||||
document.getElementById('importPassword').focus();
|
||||
} else {
|
||||
loadManifest();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadManifest() {
|
||||
var password = selectedEncrypted ? document.getElementById('importPassword').value : '';
|
||||
|
||||
try {
|
||||
var resp = await fetch('/api/export/manifest', {
|
||||
method: 'POST',
|
||||
headers: csrfH(),
|
||||
body: JSON.stringify({path: selectedPath, password: password})
|
||||
});
|
||||
var data = await resp.json();
|
||||
|
||||
if (!data.ok) {
|
||||
if (selectedEncrypted) {
|
||||
document.getElementById('passwordError').textContent = data.error || 'Hibás jelszó';
|
||||
document.getElementById('passwordError').style.display = 'block';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.needs_password) {
|
||||
document.getElementById('passwordPrompt').style.display = 'block';
|
||||
document.getElementById('importPassword').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedManifest = data.manifest;
|
||||
showManifest(data.manifest);
|
||||
} catch(e) {
|
||||
console.error('Manifest error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function showManifest(m) {
|
||||
document.getElementById('passwordPrompt').style.display = 'none';
|
||||
document.getElementById('manifestInfo').style.display = 'block';
|
||||
|
||||
document.getElementById('previewTitle').textContent = (m.display_name || m.app_name) + ' — Csomag részletei';
|
||||
document.getElementById('mfName').textContent = m.display_name || m.app_name;
|
||||
document.getElementById('mfDate').textContent = m.exported_at ? new Date(m.exported_at).toLocaleString('hu-HU') : '-';
|
||||
document.getElementById('mfVersion').textContent = m.controller_version || '-';
|
||||
|
||||
if (m.has_database && m.db_type) {
|
||||
document.getElementById('mfDB').textContent = m.db_type;
|
||||
document.getElementById('mfDBRow').style.display = 'flex';
|
||||
} else {
|
||||
document.getElementById('mfDBRow').style.display = 'none';
|
||||
}
|
||||
|
||||
var dataType = [];
|
||||
if (m.has_hdd_data) dataType.push('HDD');
|
||||
if (m.has_volume_data) dataType.push('Docker volume');
|
||||
document.getElementById('mfDataType').textContent = dataType.length ? dataType.join(', ') : 'Nincs';
|
||||
|
||||
// Format size
|
||||
var bytes = m.total_size_bytes || 0;
|
||||
var sizeStr = bytes > 1073741824 ? (bytes / 1073741824).toFixed(1) + ' GB' :
|
||||
bytes > 1048576 ? (bytes / 1048576).toFixed(1) + ' MB' :
|
||||
bytes > 1024 ? (bytes / 1024).toFixed(1) + ' KB' : bytes + ' B';
|
||||
document.getElementById('mfSize').textContent = sizeStr;
|
||||
|
||||
// Check if app already exists — show overwrite warning
|
||||
// We detect this by checking if the app link exists in the nav
|
||||
var warn = document.getElementById('overwriteWarning');
|
||||
// Simple approach: always show warning for existing app names
|
||||
document.getElementById('overwriteAppName').textContent = m.display_name || m.app_name;
|
||||
warn.style.display = 'block';
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
document.getElementById('importBtn').disabled = true;
|
||||
document.getElementById('progressCard').style.display = 'block';
|
||||
document.getElementById('doneCard').style.display = 'none';
|
||||
|
||||
var password = selectedEncrypted ? document.getElementById('importPassword').value : '';
|
||||
|
||||
try {
|
||||
var resp = await fetch('/api/export/import', {
|
||||
method: 'POST',
|
||||
headers: csrfH(),
|
||||
body: JSON.stringify({path: selectedPath, password: password})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (!data.ok) {
|
||||
showError(data.error || 'Hiba történt');
|
||||
return;
|
||||
}
|
||||
pollStatus();
|
||||
} catch(e) {
|
||||
showError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function pollStatus() {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(async function() {
|
||||
try {
|
||||
var resp = await fetch('/api/export/import/status');
|
||||
var data = await resp.json();
|
||||
renderSteps(data.steps || []);
|
||||
|
||||
if (data.error) {
|
||||
showError(data.error);
|
||||
clearInterval(pollTimer);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.done && !data.error) {
|
||||
clearInterval(pollTimer);
|
||||
document.getElementById('progressCard').style.display = 'none';
|
||||
document.getElementById('doneCard').style.display = 'block';
|
||||
if (data.stack_name) {
|
||||
document.getElementById('doneLink').href = '/stacks/' + data.stack_name + '/deploy';
|
||||
document.getElementById('doneLink').textContent = 'Alkalmazás megtekintése';
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Poll error:', e);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function renderSteps(steps) {
|
||||
var html = '';
|
||||
for (var i = 0; i < steps.length; i++) {
|
||||
var s = steps[i];
|
||||
var icon = '○';
|
||||
if (s.status === 'running') icon = '⟳';
|
||||
if (s.status === 'done') icon = '✓';
|
||||
if (s.status === 'failed') icon = '✗';
|
||||
var cls = s.status === 'failed' ? 'color:var(--danger)' : s.status === 'done' ? 'color:var(--success)' : s.status === 'running' ? 'color:var(--primary)' : '';
|
||||
html += '<div style="padding:.25rem 0;' + cls + '">' + icon + ' ' + s.label;
|
||||
if (s.error) html += ' <span style="font-size:.85rem">(' + s.error + ')</span>';
|
||||
html += '</div>';
|
||||
}
|
||||
document.getElementById('progressSteps').innerHTML = html;
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
var el = document.getElementById('progressError');
|
||||
el.textContent = msg;
|
||||
el.style.display = 'block';
|
||||
document.getElementById('importBtn').disabled = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{{template "layout_end" .}}
|
||||
{{end}}
|
||||
@@ -15,6 +15,7 @@
|
||||
{{if .Stack.Orphaned}}
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Stack.Name}}')">Törlés</button>
|
||||
{{else}}
|
||||
<a href="/stacks/{{.Stack.Name}}/export" class="btn btn-sm btn-outline">Exportálás</a>
|
||||
<a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-outline">Beállítások</a>
|
||||
{{end}}
|
||||
{{else}}
|
||||
|
||||
@@ -184,6 +184,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 9: App Export/Import -->
|
||||
<div class="card debug-section" id="section-appexport">
|
||||
<div class="card-header debug-section-header" onclick="toggleSection('appexport')">
|
||||
<h3>Alkalmazás Export/Import</h3>
|
||||
<span class="section-toggle">▶</span>
|
||||
</div>
|
||||
<div class="card-body debug-section-body" style="display:none">
|
||||
<div id="appexport-status"><span class="text-muted">Betöltés...</span></div>
|
||||
<div class="debug-actions" style="margin-top:.75rem">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-appexport-scan" data-label="Csomagok keresése" onclick="scanAppBundles()">Csomagok keresése</button>
|
||||
<span class="debug-result" id="btn-appexport-scan-result"></span>
|
||||
|
||||
<button class="btn btn-secondary btn-sm" id="btn-appexport-cleanup" data-label="Temp fájlok törlése" onclick="triggerAction('btn-appexport-cleanup','/api/debug/appexport/cleanup','POST')">Temp fájlok törlése</button>
|
||||
<span class="debug-result" id="btn-appexport-cleanup-result"></span>
|
||||
|
||||
<button class="btn btn-secondary btn-sm" id="btn-appexport-refresh" data-label="Frissítés" onclick="loadSectionData('appexport')">Frissítés</button>
|
||||
</div>
|
||||
<div id="appexport-bundles" style="display:none;margin-top:1rem"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 8: Log Viewer -->
|
||||
<div class="card debug-section" id="section-logs">
|
||||
<div class="card-header debug-section-header" onclick="toggleSection('logs')">
|
||||
@@ -285,6 +306,7 @@ function loadSectionData(id) {
|
||||
case 'telemetry': break; // no auto-load, user triggers manually
|
||||
case 'selfupdate': loadSelfUpdateStatus(); break;
|
||||
case 'dr': loadDRStatus(); break;
|
||||
case 'appexport': loadAppExportStatus(); break;
|
||||
case 'logs': initLogViewer(); break;
|
||||
}
|
||||
}
|
||||
@@ -693,6 +715,137 @@ function renderTelemetryDetail(data) {
|
||||
detail.style.display = 'block';
|
||||
}
|
||||
|
||||
// ── Section 9: App Export/Import ──
|
||||
function loadAppExportStatus() {
|
||||
document.getElementById('appexport-status').innerHTML = '<span class="text-muted">Betöltés...</span>';
|
||||
fetch('/api/debug/appexport/status', {headers: csrfHeaders()}).then(function(r){return r.json()}).then(function(data) {
|
||||
if (!data.ok) { document.getElementById('appexport-status').innerHTML = '<span class="text-muted">Nem elérhető</span>'; return; }
|
||||
renderAppExportStatus(data.data);
|
||||
}).catch(function(e) {
|
||||
document.getElementById('appexport-status').innerHTML = '<span class="debug-result-error">Hiba: ' + e.message + '</span>';
|
||||
});
|
||||
}
|
||||
function renderAppExportStatus(d) {
|
||||
if (!d.available) {
|
||||
document.getElementById('appexport-status').innerHTML = '<span class="text-muted">App export modul nem elérhető</span>';
|
||||
return;
|
||||
}
|
||||
var html = '<div class="debug-kv-grid">';
|
||||
html += '<dt>Debug mód</dt><dd>' + (d.debug_enabled ? '<span class="state-text-green">Aktív</span>' : 'Inaktív') + '</dd>';
|
||||
html += '<dt>Verzió</dt><dd class="mono">' + (d.version||'-') + '</dd>';
|
||||
html += '<dt>Csomagok</dt><dd>' + (d.bundle_count||0) + ' db</dd>';
|
||||
html += '<dt>Temp fájlok</dt><dd>' + (d.stale_temp_count||0) + ' db';
|
||||
if (d.stale_temp_count > 0) html += ' <span style="color:var(--warning)">⚠</span>';
|
||||
html += '</dd>';
|
||||
html += '</div>';
|
||||
|
||||
// Active job
|
||||
if (d.has_active_job && d.active_job) {
|
||||
var j = d.active_job;
|
||||
html += '<h4 style="margin-top:.75rem">Aktív feladat</h4>';
|
||||
html += '<div class="debug-kv-grid">';
|
||||
html += '<dt>Típus</dt><dd>' + (j.job_type||'-') + '</dd>';
|
||||
html += '<dt>Stack</dt><dd>' + (j.display_name || j.stack_name || '-') + '</dd>';
|
||||
html += '<dt>Állapot</dt><dd>' + (j.running ? '🔄 Fut' : j.done ? '✅ Kész' : '⏸ Várakozik') + '</dd>';
|
||||
if (j.error) html += '<dt>Hiba</dt><dd class="debug-result-error">' + escapeHtml(j.error) + '</dd>';
|
||||
if (j.output_path) html += '<dt>Kimenet</dt><dd class="mono" style="font-size:.75rem">' + escapeHtml(j.output_path) + '</dd>';
|
||||
if (j.output_size) html += '<dt>Méret</dt><dd>' + j.output_size + '</dd>';
|
||||
html += '</div>';
|
||||
if (j.steps && j.steps.length > 0) {
|
||||
html += '<div style="margin-top:.5rem">';
|
||||
j.steps.forEach(function(s) {
|
||||
var icon = s.status === 'done' ? '✓' : s.status === 'running' ? '⟳' : s.status === 'failed' ? '✗' : '○';
|
||||
var cls = s.status === 'failed' ? 'color:var(--danger)' : s.status === 'done' ? 'color:var(--success)' : s.status === 'running' ? 'color:var(--primary)' : 'color:var(--text-muted)';
|
||||
html += '<div style="font-size:.85rem;padding:.1rem 0;' + cls + '">' + icon + ' ' + escapeHtml(s.label);
|
||||
if (s.error) html += ' <span style="font-size:.8rem">(' + escapeHtml(s.error) + ')</span>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Export dirs
|
||||
if (d.export_dirs && d.export_dirs.length > 0) {
|
||||
html += '<h4 style="margin-top:.75rem">Export könyvtárak</h4>';
|
||||
html += '<table class="info-table debug-table"><tr><th>Útvonal</th><th>Cimke</th><th>Létezik</th></tr>';
|
||||
d.export_dirs.forEach(function(dir) {
|
||||
html += '<tr><td class="mono" style="font-size:.75rem">' + escapeHtml(dir.path) + '</td><td>' + (dir.label||'-') + '</td><td>' + (dir.exists ? '✅' : '❌') + '</td></tr>';
|
||||
});
|
||||
html += '</table>';
|
||||
}
|
||||
|
||||
// Stale temp files
|
||||
if (d.stale_temp_files && d.stale_temp_files.length > 0) {
|
||||
html += '<h4 style="margin-top:.75rem;color:var(--warning)">Elavult temp fájlok</h4>';
|
||||
html += '<ul style="font-size:.85rem;margin:0;padding-left:1.5rem">';
|
||||
d.stale_temp_files.forEach(function(f) {
|
||||
html += '<li class="mono" style="font-size:.75rem">' + escapeHtml(f) + '</li>';
|
||||
});
|
||||
html += '</ul>';
|
||||
}
|
||||
|
||||
document.getElementById('appexport-status').innerHTML = html;
|
||||
|
||||
// Auto-refresh if a job is running
|
||||
if (d.has_active_job && d.active_job && d.active_job.running) {
|
||||
startPolling('appexport', 2000, function() {
|
||||
fetch('/api/debug/appexport/status', {headers: csrfHeaders()}).then(function(r){return r.json()}).then(function(data) {
|
||||
if (data.ok) renderAppExportStatus(data.data);
|
||||
}).catch(function(){});
|
||||
});
|
||||
}
|
||||
}
|
||||
function scanAppBundles() {
|
||||
var btn = document.getElementById('btn-appexport-scan');
|
||||
var result = document.getElementById('btn-appexport-scan-result');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Keresés...';
|
||||
result.className = 'debug-result';
|
||||
result.textContent = '';
|
||||
fetch('/api/debug/appexport/bundles', {headers: csrfHeaders()}).then(function(r){return r.json()}).then(function(data) {
|
||||
if (data.ok) {
|
||||
result.className = 'debug-result debug-result-ok';
|
||||
result.textContent = data.message;
|
||||
if (data.data && data.data.bundles) {
|
||||
renderAppBundles(data.data.bundles);
|
||||
}
|
||||
} else {
|
||||
result.className = 'debug-result debug-result-error';
|
||||
result.textContent = data.error || 'Hiba';
|
||||
}
|
||||
}).catch(function(e) {
|
||||
result.className = 'debug-result debug-result-error';
|
||||
result.textContent = 'Hálózati hiba: ' + e.message;
|
||||
}).finally(function() {
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn.dataset.label;
|
||||
});
|
||||
}
|
||||
function renderAppBundles(bundles) {
|
||||
var container = document.getElementById('appexport-bundles');
|
||||
if (!bundles || bundles.length === 0) {
|
||||
container.innerHTML = '<span class="text-muted">Nem található .fab csomag.</span>';
|
||||
container.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
var html = '<table class="info-table debug-table"><tr><th>Alkalmazás</th><th>Dátum</th><th>Méret</th><th>Tároló</th><th>Titkos</th><th>DB</th><th>HDD</th><th>Elérés</th></tr>';
|
||||
bundles.forEach(function(b) {
|
||||
html += '<tr>';
|
||||
html += '<td><strong>' + escapeHtml(b.display_name || b.app_name) + '</strong></td>';
|
||||
html += '<td>' + (b.exported_at || '-') + '</td>';
|
||||
html += '<td>' + (b.size_human || '-') + '</td>';
|
||||
html += '<td>' + escapeHtml(b.drive_label || b.drive_path) + '</td>';
|
||||
html += '<td>' + (b.encrypted ? '🔒' : '-') + '</td>';
|
||||
html += '<td>' + (b.has_db ? '✅' : '-') + '</td>';
|
||||
html += '<td>' + (b.needs_hdd ? '✅' : '-') + '</td>';
|
||||
html += '<td class="mono" style="font-size:.7rem;max-width:200px;overflow:hidden;text-overflow:ellipsis">' + escapeHtml(b.path) + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</table>';
|
||||
container.innerHTML = html;
|
||||
container.style.display = 'block';
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
function fmtTime(ts) {
|
||||
if (!ts) return '-';
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<div class="page-header">
|
||||
<h2>Alkalmazások</h2>
|
||||
<span class="domain-badge">{{.Domain}}</span>
|
||||
<a href="/import" class="btn btn-sm btn-outline" title="Alkalmazás visszaállítása exportált csomagból">Importálás</a>
|
||||
<button class="btn btn-sm btn-outline" id="sync-btn" onclick="syncTemplates()" title="Sablonok frissítése a központi katalógusból">↻ Sablonok frissítése</button>
|
||||
</div>
|
||||
<div id="sync-toast" class="sync-toast" style="display:none"></div>
|
||||
|
||||
Reference in New Issue
Block a user