feat: автосинхронизация компонентов для новых пользователей и Support Bundle

- Воркер теперь запускает SyncComponents при пустой local_components,
  чтобы новый пользователь получил каталог компонентов без ручного действия
- Результат синхронизации компонентов персистируется в app_settings
  (last_component_sync_status/error/attempt_at) по аналогии с прайслистами
- Добавлен эндпоинт GET /api/support-bundle: скачивает ZIP с диагностикой
  (app_info, local_db_stats, db_connection с TCP-пингом, sync_readiness,
  system_metrics с памятью и диском, schema_migrations, app.log)
- Кнопка-иконка в шапке рядом с юзернеймом для скачивания бандла

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:50:41 +03:00
parent c951ceb44b
commit 84cab011d3
9 changed files with 294 additions and 0 deletions

View File

@@ -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) return nil, nil, fmt.Errorf("creating sync handler: %w", err)
} }
supportBundleHandler := handlers.NewSupportBundleHandler(local, connMgr, syncService, cfg.Logging.FilePath)
// Setup handler (for reconfiguration) // Setup handler (for reconfiguration)
setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, restartSig) setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, restartSig)
if err != nil { 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"}) c.JSON(http.StatusOK, gin.H{"message": "pong"})
}) })
api.GET("/support-bundle", supportBundleHandler.DownloadBundle)
// Components (public read) // Components (public read)
components := api.Group("/components") components := api.Group("/components")
{ {

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -0,0 +1,7 @@
//go:build windows
package handlers
func diskUsage(_ string) map[string]any {
return nil
}

View File

@@ -187,8 +187,10 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
return return
} }
now := time.Now()
result, err := h.localDB.SyncComponents(mariaDB) result, err := h.localDB.SyncComponents(mariaDB)
if err != nil { if err != nil {
_ = h.localDB.SetComponentSyncResult("error", err.Error(), now)
slog.Error("component sync failed", "error", err) slog.Error("component sync failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
@@ -197,6 +199,7 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
_ = c.Error(err) _ = c.Error(err)
return return
} }
_ = h.localDB.SetComponentSyncResult("ok", "", now)
c.JSON(http.StatusOK, SyncResultResponse{ c.JSON(http.StatusOK, SyncResultResponse{
Success: true, Success: true,
@@ -313,8 +316,10 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
return return
} }
compNow := time.Now()
compResult, err := h.localDB.SyncComponents(mariaDB) compResult, err := h.localDB.SyncComponents(mariaDB)
if err != nil { if err != nil {
_ = h.localDB.SetComponentSyncResult("error", err.Error(), compNow)
slog.Error("component sync failed during full sync", "error", err) slog.Error("component sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
@@ -323,6 +328,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
_ = c.Error(err) _ = c.Error(err)
return return
} }
_ = h.localDB.SetComponentSyncResult("ok", "", compNow)
componentsSynced = compResult.TotalSynced componentsSynced = compResult.TotalSynced
// Sync pricelists // Sync pricelists

View File

@@ -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 // CountLocalPricelists returns the number of local pricelists
func (l *LocalDB) CountLocalPricelists() int64 { func (l *LocalDB) CountLocalPricelists() int64 {
var count int64 var count int64

View File

@@ -1623,3 +1623,25 @@ func (s *Service) getConnectionStatus() db.ConnectionStatus {
} }
return s.connMgr.GetStatus() 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
}

View File

@@ -80,6 +80,11 @@ func (w *Worker) runSync() {
return 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 // Push pending changes first
pushed, err := w.service.PushPendingChanges() pushed, err := w.service.PushPendingChanges()
if err != nil { if err != nil {

View File

@@ -35,6 +35,16 @@
hx-swap="innerHTML"> hx-swap="innerHTML">
</div> </div>
<span id="db-user" class="text-sm text-gray-600"></span> <span id="db-user" class="text-sm text-gray-600"></span>
<a href="/api/support-bundle"
title="Скачать Support Bundle"
class="text-gray-400 hover:text-gray-600 transition-colors"
download>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</a>
</div> </div>
</div> </div>
</div> </div>