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:
172
internal/handlers/support_bundle.go
Normal file
172
internal/handlers/support_bundle.go
Normal 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
|
||||
}
|
||||
20
internal/handlers/support_bundle_disk_unix.go
Normal file
20
internal/handlers/support_bundle_disk_unix.go
Normal 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,
|
||||
}
|
||||
}
|
||||
7
internal/handlers/support_bundle_disk_windows.go
Normal file
7
internal/handlers/support_bundle_disk_windows.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build windows
|
||||
|
||||
package handlers
|
||||
|
||||
func diskUsage(_ string) map[string]any {
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user