diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index af0c6e5..c147c53 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -786,6 +786,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect return nil, nil, fmt.Errorf("creating sync handler: %w", err) } + supportBundleHandler := handlers.NewSupportBundleHandler(local, connMgr, syncService, cfg.Logging.FilePath) + // Setup handler (for reconfiguration) setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, restartSig) if err != nil { @@ -905,6 +907,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect c.JSON(http.StatusOK, gin.H{"message": "pong"}) }) + api.GET("/support-bundle", supportBundleHandler.DownloadBundle) + // Components (public read) components := api.Group("/components") { diff --git a/internal/handlers/support_bundle.go b/internal/handlers/support_bundle.go new file mode 100644 index 0000000..ce4026c --- /dev/null +++ b/internal/handlers/support_bundle.go @@ -0,0 +1,172 @@ +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()) + + // 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 +} diff --git a/internal/handlers/support_bundle_disk_unix.go b/internal/handlers/support_bundle_disk_unix.go new file mode 100644 index 0000000..a7194ca --- /dev/null +++ b/internal/handlers/support_bundle_disk_unix.go @@ -0,0 +1,20 @@ +//go:build linux || darwin + +package handlers + +import "syscall" + +func diskUsage(path string) map[string]any { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return nil + } + total := stat.Blocks * uint64(stat.Bsize) + free := stat.Bfree * uint64(stat.Bsize) + return map[string]any{ + "total_bytes": total, + "free_bytes": free, + "used_bytes": total - free, + "path": path, + } +} diff --git a/internal/handlers/support_bundle_disk_windows.go b/internal/handlers/support_bundle_disk_windows.go new file mode 100644 index 0000000..6d8ba05 --- /dev/null +++ b/internal/handlers/support_bundle_disk_windows.go @@ -0,0 +1,7 @@ +//go:build windows + +package handlers + +func diskUsage(_ string) map[string]any { + return nil +} diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index ee68dcf..f4a6a7e 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -187,8 +187,10 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) { return } + now := time.Now() result, err := h.localDB.SyncComponents(mariaDB) if err != nil { + _ = h.localDB.SetComponentSyncResult("error", err.Error(), now) slog.Error("component sync failed", "error", err) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, @@ -197,6 +199,7 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) { _ = c.Error(err) return } + _ = h.localDB.SetComponentSyncResult("ok", "", now) c.JSON(http.StatusOK, SyncResultResponse{ Success: true, @@ -313,8 +316,10 @@ func (h *SyncHandler) SyncAll(c *gin.Context) { return } + compNow := time.Now() compResult, err := h.localDB.SyncComponents(mariaDB) if err != nil { + _ = h.localDB.SetComponentSyncResult("error", err.Error(), compNow) slog.Error("component sync failed during full sync", "error", err) c.JSON(http.StatusInternalServerError, gin.H{ "success": false, @@ -323,6 +328,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) { _ = c.Error(err) return } + _ = h.localDB.SetComponentSyncResult("ok", "", compNow) componentsSynced = compResult.TotalSynced // Sync pricelists diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 19aacc3..1209aa8 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -1153,6 +1153,54 @@ func (l *LocalDB) SetPricelistSyncResult(status, errorText string, attemptedAt t }) } +func (l *LocalDB) GetLastComponentSyncAttemptAt() *time.Time { + value, ok := l.getAppSettingValue("last_component_sync_attempt_at") + if !ok { + return nil + } + t, err := time.Parse(time.RFC3339, value) + if err != nil { + return nil + } + return &t +} + +func (l *LocalDB) GetLastComponentSyncStatus() string { + value, ok := l.getAppSettingValue("last_component_sync_status") + if !ok { + return "" + } + return strings.TrimSpace(value) +} + +func (l *LocalDB) GetLastComponentSyncError() string { + value, ok := l.getAppSettingValue("last_component_sync_error") + if !ok { + return "" + } + return strings.TrimSpace(value) +} + +func (l *LocalDB) SetComponentSyncResult(status, errorText string, attemptedAt time.Time) error { + status = strings.TrimSpace(status) + errorText = strings.TrimSpace(errorText) + if status == "" { + status = "unknown" + } + return l.db.Transaction(func(tx *gorm.DB) error { + if err := l.upsertAppSetting(tx, "last_component_sync_status", status, attemptedAt); err != nil { + return err + } + if err := l.upsertAppSetting(tx, "last_component_sync_error", errorText, attemptedAt); err != nil { + return err + } + if err := l.upsertAppSetting(tx, "last_component_sync_attempt_at", attemptedAt.Format(time.RFC3339), attemptedAt); err != nil { + return err + } + return nil + }) +} + // CountLocalPricelists returns the number of local pricelists func (l *LocalDB) CountLocalPricelists() int64 { var count int64 diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 1b03795..76210c8 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -1623,3 +1623,25 @@ func (s *Service) getConnectionStatus() db.ConnectionStatus { } return s.connMgr.GetStatus() } + +// SyncComponentsIfEmpty syncs components from MariaDB when local_components is empty. +// Used by the background worker on first run to populate the catalog for new users. +func (s *Service) SyncComponentsIfEmpty() error { + if s.localDB.CountComponents() > 0 { + return nil + } + mariaDB, err := s.getDB() + if err != nil { + _ = s.localDB.SetComponentSyncResult("error", err.Error(), time.Now()) + return err + } + result, err := s.localDB.SyncComponents(mariaDB) + now := time.Now() + if err != nil { + _ = s.localDB.SetComponentSyncResult("error", err.Error(), now) + return err + } + _ = s.localDB.SetComponentSyncResult("ok", "", now) + slog.Info("background sync: initial component sync completed", "synced", result.TotalSynced) + return nil +} diff --git a/internal/services/sync/worker.go b/internal/services/sync/worker.go index 599659b..c887b61 100644 --- a/internal/services/sync/worker.go +++ b/internal/services/sync/worker.go @@ -80,6 +80,11 @@ func (w *Worker) runSync() { return } + // Populate component catalog on first run (empty local_components) + if err := w.service.SyncComponentsIfEmpty(); err != nil { + w.logger.Warn("background sync: initial component sync failed", "error", err) + } + // Push pending changes first pushed, err := w.service.PushPendingChanges() if err != nil { diff --git a/web/templates/base.html b/web/templates/base.html index cf9c753..5e4b886 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -35,6 +35,16 @@ hx-swap="innerHTML"> + + + + + + +