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">
+
+
+