- SQLite-запросы по lot_name теперь используют UPPER(lot_name) IN/= для совместимости с легаси-данными, синхронизированными до нормализации регистра - Удалена таблица local_components и весь связанный код синхронизации; источник данных для компонентов — local_pricelist_items - Удалена функция getCategoryFromLotName из JS: категория берётся только из прайслиста, без инференса из имени лота - Регистронезависимые сравнения lot_name в JS (warehouse stock set, addedLots, cartLots, allComponents.find, _bomLotValid) - В support bundle добавлены: latest_pricelist_items.json, local.db, autocomplete_lots.json для диагностики Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
318 lines
9.6 KiB
Go
318 lines
9.6 KiB
Go
package handlers
|
|
|
|
import (
|
|
"archive/zip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"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, err := os.Hostname()
|
|
if err != nil {
|
|
slog.Warn("support bundle: could not get hostname", "err", err)
|
|
}
|
|
|
|
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.CountComponents(),
|
|
"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"`
|
|
IsActive bool `json:"is_active"`
|
|
}
|
|
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,
|
|
IsActive: pl.IsActive,
|
|
}
|
|
bySource[pl.Source] = append(bySource[pl.Source], e)
|
|
}
|
|
writeJSON("pricelists.json", bySource)
|
|
}
|
|
|
|
// pricelist_coverage.json — for each local estimate pricelist: item count by lot_category
|
|
if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil {
|
|
type catRow struct {
|
|
Category string `json:"category"`
|
|
Count int64 `json:"count"`
|
|
}
|
|
type plCoverage struct {
|
|
Version string `json:"version"`
|
|
ServerID uint `json:"server_id"`
|
|
TotalItems int64 `json:"total_items"`
|
|
Categories []catRow `json:"categories"`
|
|
}
|
|
rows, total, catErr := h.localDB.GetLocalPricelistCoverageByCategory(pl.ID)
|
|
if catErr == nil {
|
|
cats := make([]catRow, 0, len(rows))
|
|
for cat, cnt := range rows {
|
|
cats = append(cats, catRow{Category: cat, Count: cnt})
|
|
}
|
|
writeJSON("pricelist_coverage.json", plCoverage{
|
|
Version: pl.Version,
|
|
ServerID: pl.ServerID,
|
|
TotalItems: total,
|
|
Categories: cats,
|
|
})
|
|
}
|
|
}
|
|
|
|
// configurator_settings.json — what /api/configurator-settings actually returns
|
|
if cfgSettings, err := h.localDB.GetConfiguratorSettings(); err == nil {
|
|
writeJSON("configurator_settings.json", cfgSettings)
|
|
} else {
|
|
writeJSON("configurator_settings.json", map[string]any{"error": err.Error()})
|
|
}
|
|
|
|
// component_categories.json — distinct categories in active estimate pricelist
|
|
if cats, err := h.localDB.GetLocalComponentCategories(); err == nil {
|
|
writeJSON("component_categories.json", cats)
|
|
}
|
|
|
|
// autocomplete_lots.json — per-category breakdown of lots with their prices
|
|
// Mirrors what filterAutocomplete() works with: lot_name + estimate_price per category.
|
|
if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil {
|
|
if items, err := h.localDB.GetLocalPricelistItems(pl.ID); err == nil {
|
|
type lotEntry struct {
|
|
LotName string `json:"lot_name"`
|
|
Price float64 `json:"price"`
|
|
HasPrice bool `json:"has_price"`
|
|
}
|
|
byCategory := map[string][]lotEntry{}
|
|
for _, it := range items {
|
|
entry := lotEntry{
|
|
LotName: it.LotName,
|
|
Price: it.Price,
|
|
HasPrice: it.Price > 0,
|
|
}
|
|
byCategory[it.LotCategory] = append(byCategory[it.LotCategory], entry)
|
|
}
|
|
writeJSON("autocomplete_lots.json", map[string]any{
|
|
"pricelist_version": pl.Version,
|
|
"pricelist_id": pl.ServerID,
|
|
"by_category": byCategory,
|
|
})
|
|
}
|
|
}
|
|
|
|
// schema_migrations.json
|
|
migrations, err := h.localDB.GetSchemaMigrations()
|
|
if err != nil {
|
|
slog.Warn("support bundle: could not load schema migrations", "err", err)
|
|
}
|
|
writeJSON("schema_migrations.json", migrations)
|
|
|
|
// latest_pricelist_items.json — all items from the most recent active estimate pricelist
|
|
if pl, err := h.localDB.GetLatestLocalPricelist(); err == nil {
|
|
if items, err := h.localDB.GetLocalPricelistItems(pl.ID); err == nil {
|
|
type plItem struct {
|
|
LotName string `json:"lot_name"`
|
|
LotCategory string `json:"lot_category"`
|
|
Price float64 `json:"price"`
|
|
}
|
|
out := make([]plItem, len(items))
|
|
for i, it := range items {
|
|
out[i] = plItem{
|
|
LotName: it.LotName,
|
|
LotCategory: it.LotCategory,
|
|
Price: it.Price,
|
|
}
|
|
}
|
|
writeJSON("latest_pricelist_items.json", map[string]any{
|
|
"pricelist_version": pl.Version,
|
|
"pricelist_id": pl.ServerID,
|
|
"source": pl.Source,
|
|
"item_count": len(out),
|
|
"items": out,
|
|
})
|
|
}
|
|
}
|
|
|
|
// local.db — full SQLite database file (for deep diagnostics)
|
|
if dbPath := h.localDB.DBFilePath(); dbPath != "" {
|
|
if f, err := os.Open(dbPath); err == nil {
|
|
defer f.Close()
|
|
if w, err := zw.Create("local.db"); err == nil {
|
|
if _, err := io.Copy(w, f); err != nil {
|
|
slog.Warn("support bundle: error copying local.db", "err", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
if _, err := io.Copy(w, f); err != nil {
|
|
slog.Warn("support bundle: error copying log file", "err", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|