package handlers import ( "archive/zip" "encoding/json" "fmt" "io" "net" "net/http" "os" "runtime" "strconv" "time" "git.mchus.pro/mchus/quoteforge/internal/appmeta" "git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/localdb" syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync" "github.com/gin-gonic/gin" ) type SupportBundleHandler struct { localDB *localdb.LocalDB connMgr *db.ConnectionManager syncService *syncsvc.Service logFilePath string } func NewSupportBundleHandler(local *localdb.LocalDB, connMgr *db.ConnectionManager, svc *syncsvc.Service, logFilePath string) *SupportBundleHandler { return &SupportBundleHandler{ localDB: local, connMgr: connMgr, syncService: svc, logFilePath: logFilePath, } } // DownloadBundle collects diagnostic data and streams a ZIP archive. // GET /api/support-bundle func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) { now := time.Now().UTC() hostname, _ := os.Hostname() c.Header("Content-Type", "application/zip") c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="qfs-bundle-%s.zip"`, now.Format("20060102-150405"))) zw := zip.NewWriter(c.Writer) defer zw.Close() writeJSON := func(name string, v any) { w, err := zw.Create(name) if err != nil { return } enc := json.NewEncoder(w) enc.SetIndent("", " ") _ = enc.Encode(v) } // app_info.json writeJSON("app_info.json", map[string]any{ "app_version": appmeta.Version(), "go_version": runtime.Version(), "os": runtime.GOOS, "arch": runtime.GOARCH, "hostname": hostname, "db_user": h.localDB.GetDBUser(), "collected_at": now.Format(time.RFC3339), }) // local_db_stats.json writeJSON("local_db_stats.json", map[string]any{ "components": h.localDB.CountLocalComponents(), "configurations": h.localDB.CountConfigurations(), "projects": h.localDB.CountProjects(), "pricelists": h.localDB.CountLocalPricelists(), "pending_changes": h.localDB.GetPendingCount(), "db_size_bytes": h.localDB.DBFileSizeBytes(), "last_pricelist_sync_time": h.localDB.GetLastSyncTime(), "last_pricelist_attempt": h.localDB.GetLastPricelistSyncAttemptAt(), "last_pricelist_status": h.localDB.GetLastPricelistSyncStatus(), "last_pricelist_error": h.localDB.GetLastPricelistSyncError(), "last_component_sync_attempt": h.localDB.GetLastComponentSyncAttemptAt(), "last_component_sync_status": h.localDB.GetLastComponentSyncStatus(), "last_component_sync_error": h.localDB.GetLastComponentSyncError(), }) // db_connection.json — includes TCP ping to DB host connStatus := h.connMgr.GetStatus() dbConnDoc := map[string]any{ "is_connected": connStatus.IsConnected, "last_error": connStatus.LastError, } if settings, err := h.localDB.GetSettings(); err == nil && settings.Host != "" { addr := net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port)) start := time.Now() conn, dialErr := net.DialTimeout("tcp", addr, 3*time.Second) pingMs := time.Since(start).Milliseconds() if dialErr == nil { conn.Close() dbConnDoc["tcp_ping_ms"] = pingMs dbConnDoc["tcp_ping_addr"] = addr } else { dbConnDoc["tcp_ping_error"] = dialErr.Error() dbConnDoc["tcp_ping_addr"] = addr } } writeJSON("db_connection.json", dbConnDoc) // sync_readiness.json if h.syncService != nil { readiness, err := h.syncService.GetReadiness() if err != nil { writeJSON("sync_readiness.json", map[string]any{"error": err.Error()}) } else { writeJSON("sync_readiness.json", readiness) } } // system_metrics.json writeJSON("system_metrics.json", collectSystemMetrics()) // sync_log.json — history of sync operations if entries, err := h.localDB.GetSyncLog(200); err == nil { writeJSON("sync_log.json", entries) } // pricelists.json — downloaded pricelists grouped by source if pricelists, err := h.localDB.GetLocalPricelists(); err == nil { type plEntry struct { ServerID uint `json:"server_id"` Source string `json:"source"` Version string `json:"version"` Name string `json:"name,omitempty"` CreatedAt time.Time `json:"created_at"` SyncedAt time.Time `json:"synced_at"` IsUsed bool `json:"is_used"` } bySource := map[string][]plEntry{} for _, pl := range pricelists { e := plEntry{ ServerID: pl.ServerID, Source: pl.Source, Version: pl.Version, Name: pl.Name, CreatedAt: pl.CreatedAt, SyncedAt: pl.SyncedAt, IsUsed: pl.IsUsed, } bySource[pl.Source] = append(bySource[pl.Source], e) } writeJSON("pricelists.json", bySource) } // schema_migrations.json var migrations []localdb.LocalSchemaMigration _ = h.localDB.DB().Order("applied_at ASC").Find(&migrations).Error writeJSON("schema_migrations.json", migrations) // app.log (tail 5 MiB) if h.logFilePath != "" { if f, err := os.Open(h.logFilePath); err == nil { defer f.Close() if info, err := f.Stat(); err == nil { const maxLog = 5 << 20 offset := int64(0) if info.Size() > maxLog { offset = info.Size() - maxLog } if _, err := f.Seek(offset, io.SeekStart); err == nil { if w, err := zw.Create("app.log"); err == nil { _, _ = io.Copy(w, f) } } } } } c.Status(http.StatusOK) } func collectSystemMetrics() map[string]any { var ms runtime.MemStats runtime.ReadMemStats(&ms) m := map[string]any{ "goroutines": runtime.NumGoroutine(), "cpu_count": runtime.NumCPU(), "heap_alloc_bytes": ms.HeapAlloc, "heap_sys_bytes": ms.HeapSys, "heap_inuse_bytes": ms.HeapInuse, "stack_inuse_bytes": ms.StackInuse, "gc_cycles": ms.NumGC, "next_gc_bytes": ms.NextGC, } if wd, err := os.Getwd(); err == nil { if info := diskUsage(wd); info != nil { m["disk"] = info } } return m }