Files
QuoteForge/internal/handlers/support_bundle.go
Michael Chus 452811f393 feat: sync_log таблица и список прайслистов в Support Bundle
- Добавлена таблица sync_log (до 100 записей на тип): фиксирует каждый
  запуск синхронизации с типом, статусом, ошибкой, кол-вом и временем
- AppendSyncLog вызывается из SyncComponents, SyncPricelists (service и
  handler), SyncAll и SyncComponentsIfEmpty
- Bundle теперь включает sync_log.json (200 последних записей) и
  pricelists.json (все скачанные прайслисты, сгруппированные по source)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:57:28 +03:00

205 lines
5.9 KiB
Go

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())
// 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"`
}
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,
}
bySource[pl.Source] = append(bySource[pl.Source], e)
}
writeJSON("pricelists.json", bySource)
}
// 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
}