package handlers import ( "archive/zip" "encoding/json" "fmt" "io" "log/slog" "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, err := os.Hostname() if err != nil { slog.Warn("support bundle: could not get hostname", "err", err) } 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.CountComponents(), "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"` IsActive bool `json:"is_active"` } 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, IsActive: pl.IsActive, } bySource[pl.Source] = append(bySource[pl.Source], e) } writeJSON("pricelists.json", bySource) } // pricelist_coverage.json — for each local estimate pricelist: item count by lot_category if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil { type catRow struct { Category string `json:"category"` Count int64 `json:"count"` } type plCoverage struct { Version string `json:"version"` ServerID uint `json:"server_id"` TotalItems int64 `json:"total_items"` Categories []catRow `json:"categories"` } rows, total, catErr := h.localDB.GetLocalPricelistCoverageByCategory(pl.ID) if catErr == nil { cats := make([]catRow, 0, len(rows)) for cat, cnt := range rows { cats = append(cats, catRow{Category: cat, Count: cnt}) } writeJSON("pricelist_coverage.json", plCoverage{ Version: pl.Version, ServerID: pl.ServerID, TotalItems: total, Categories: cats, }) } } // configurator_settings.json — what /api/configurator-settings actually returns if cfgSettings, err := h.localDB.GetConfiguratorSettings(); err == nil { writeJSON("configurator_settings.json", cfgSettings) } else { writeJSON("configurator_settings.json", map[string]any{"error": err.Error()}) } // component_categories.json — distinct categories in active estimate pricelist if cats, err := h.localDB.GetLocalComponentCategories(); err == nil { writeJSON("component_categories.json", cats) } // autocomplete_lots.json — per-category breakdown of lots with their prices // Mirrors what filterAutocomplete() works with: lot_name + estimate_price per category. if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil { if items, err := h.localDB.GetLocalPricelistItems(pl.ID); err == nil { type lotEntry struct { LotName string `json:"lot_name"` Price float64 `json:"price"` HasPrice bool `json:"has_price"` } byCategory := map[string][]lotEntry{} for _, it := range items { entry := lotEntry{ LotName: it.LotName, Price: it.Price, HasPrice: it.Price > 0, } byCategory[it.LotCategory] = append(byCategory[it.LotCategory], entry) } writeJSON("autocomplete_lots.json", map[string]any{ "pricelist_version": pl.Version, "pricelist_id": pl.ServerID, "by_category": byCategory, }) } } // schema_migrations.json migrations, err := h.localDB.GetSchemaMigrations() if err != nil { slog.Warn("support bundle: could not load schema migrations", "err", err) } writeJSON("schema_migrations.json", migrations) // latest_pricelist_items.json — all items from the most recent active estimate pricelist if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil { if items, err := h.localDB.GetLocalPricelistItems(pl.ID); err == nil { type plItem struct { LotName string `json:"lot_name"` LotCategory string `json:"lot_category"` Price float64 `json:"price"` } out := make([]plItem, len(items)) for i, it := range items { out[i] = plItem{ LotName: it.LotName, LotCategory: it.LotCategory, Price: it.Price, } } writeJSON("latest_pricelist_items.json", map[string]any{ "pricelist_version": pl.Version, "pricelist_id": pl.ServerID, "source": pl.Source, "item_count": len(out), "items": out, }) } } // local.db — full SQLite database file (for deep diagnostics) if dbPath := h.localDB.DBFilePath(); dbPath != "" { if f, err := os.Open(dbPath); err == nil { defer f.Close() if w, err := zw.Create("local.db"); err == nil { if _, err := io.Copy(w, f); err != nil { slog.Warn("support bundle: error copying local.db", "err", err) } } } } // 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 { if _, err := io.Copy(w, f); err != nil { slog.Warn("support bundle: error copying log file", "err", err) } } } } } } 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 }