Compare commits

...

6 Commits

Author SHA1 Message Date
mchus 498cbf5490 chore: обновить submodule bible до последнего коммита
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 07:33:38 +03:00
mchus 80eab1db93 fix: UI виснет на секунды при недоступном MySQL-хосте
OfflineDetector дёргал connMgr.IsOnline() на каждый HTTP-запрос, а тот
при офлайне синхронно лез в сеть и держал дайл/пинг с таймаутом 3с под
общей блокировкой состояния — из-за этого /health и другие запросы
блокировались на секунды прямо в обработчике.

IsOnline() теперь чистое чтение кэша. Реальный сетевой опрос вынесен в
фоновый цикл (ConnectionManager.Start/Stop), а сами попытки dial/ping
сериализуются через отдельный connMu и никогда не держат блокировку
состояния во время сетевого I/O — поэтому конкурентные читатели статуса
больше не ждут таймаут MySQL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 07:31:08 +03:00
Mikhail Chusavitin 5067670294 fix: SQLITE_BUSY при клонировании конфигурации вариантов
SetMaxOpenConns(1) сериализует запись через единственное соединение,
busy_timeout=5000 добавляет ожидание до 5с при внешних блокировках.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-01 12:02:08 +03:00
Mikhail Chusavitin ea98eef5de docs: release notes v2.27
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 00:57:17 +03:00
Mikhail Chusavitin 50f0e4f76f feat: индикатор присутствия в конфигурациях (иконка глаза)
Открытые конфигурации фиксируются в локальном SQLite (app_settings) и
передаются на сервер через qt_client_schema_state.open_config_uuids при
каждом цикле синхронизации. Списки конфигураций обогащаются полем viewers,
в таблицах отображается иконка глаза с подсказкой при наличии других
пользователей, открывших эту конфигурацию.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 00:56:06 +03:00
Mikhail Chusavitin 9601619d1b docs: release notes v2.26
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 16:22:54 +03:00
11 changed files with 423 additions and 82 deletions
+1 -1
Submodule bible updated: 52444350c1...1977730d93
+64 -2
View File
@@ -168,6 +168,13 @@ func main() {
// Create connection manager. Runtime stays local-first; MariaDB is used on demand by sync/setup only.
connMgr := db.NewConnectionManager(local)
// Keep the online-status cache fresh in the background so request-handling
// goroutines (health checks, middleware, etc.) never block on the MySQL
// dial/read timeout themselves.
connMgrCtx, connMgrCancel := context.WithCancel(context.Background())
defer connMgrCancel()
connMgr.Start(connMgrCtx)
dbUser := local.GetDBUser()
slog.Info("starting QuoteForge server",
@@ -272,6 +279,8 @@ func main() {
syncWorker.Stop()
workerCancel()
backupCancel()
connMgr.Stop()
connMgrCancel()
// Then shutdown HTTP server
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -996,8 +1005,27 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
return
}
uuids := make([]string, len(cfgs))
for i, cfg := range cfgs {
uuids[i] = cfg.UUID
}
viewers, _ := syncService.ListActiveViewersByConfigUUIDs(uuids)
type cfgRow struct {
models.Configuration
Viewers []string `json:"viewers"`
}
rows := make([]cfgRow, len(cfgs))
for i, cfg := range cfgs {
v := viewers[cfg.UUID]
if v == nil {
v = []string{}
}
rows[i] = cfgRow{Configuration: cfg, Viewers: v}
}
c.JSON(http.StatusOK, gin.H{
"configurations": cfgs,
"configurations": rows,
"total": total,
"page": page,
"per_page": perPage,
@@ -1332,6 +1360,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}
c.JSON(http.StatusOK, config)
})
configs.POST("/:uuid/presence", func(c *gin.Context) {
_ = local.AddOpenConfigUUID(c.Param("uuid"))
c.JSON(http.StatusOK, gin.H{"ok": true})
})
configs.DELETE("/:uuid/presence", func(c *gin.Context) {
_ = local.RemoveOpenConfigUUID(c.Param("uuid"))
c.JSON(http.StatusOK, gin.H{"ok": true})
})
}
projects := api.Group("/projects")
@@ -1672,8 +1710,32 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}
return
}
projUUIDs := make([]string, len(result.Configs))
for i, cfg := range result.Configs {
projUUIDs[i] = cfg.UUID
}
projViewers, _ := syncService.ListActiveViewersByConfigUUIDs(projUUIDs)
type projCfgRow struct {
models.Configuration
Viewers []string `json:"viewers"`
}
projRows := make([]projCfgRow, len(result.Configs))
for i, cfg := range result.Configs {
v := projViewers[cfg.UUID]
if v == nil {
v = []string{}
}
projRows[i] = projCfgRow{Configuration: cfg, Viewers: v}
}
c.Header("X-Config-Status", status)
c.JSON(http.StatusOK, result)
c.JSON(http.StatusOK, gin.H{
"project_uuid": result.ProjectUUID,
"configurations": projRows,
"total": result.Total,
})
})
projects.PATCH("/:uuid/configs/reorder", func(c *gin.Context) {
+162 -77
View File
@@ -17,6 +17,11 @@ const (
defaultPingInterval = 30 * time.Second
defaultReconnectCooldown = 10 * time.Second
// defaultStatusCheckInterval controls how often the background prober
// re-checks connectivity to keep IsOnline() cheap. Request-handling
// goroutines must never pay the MySQL dial/read timeout themselves.
defaultStatusCheckInterval = 15 * time.Second
maxOpenConns = 10
maxIdleConns = 2
connMaxLifetime = 5 * time.Minute
@@ -33,86 +38,148 @@ type ConnectionStatus struct {
// ConnectionManager manages database connections with thread-safety and connection pooling
type ConnectionManager struct {
localDB *localdb.LocalDB // for getting DSN from settings
mu sync.RWMutex // protects db and state
mu sync.RWMutex // protects db/lastError/lastCheck only — never held during network I/O
connMu sync.Mutex // serializes actual dial/ping attempts; held *instead of* mu during network I/O
db *gorm.DB // current connection (nil if not connected)
lastError error // last connection error
lastCheck time.Time // time of last check/attempt
connectTimeout time.Duration // timeout for connection (default: 5s)
pingInterval time.Duration // minimum interval between pings (default: 30s)
reconnectCooldown time.Duration // pause after failed attempt (default: 10s)
statusCheckInterval time.Duration // background prober cadence (default: 15s)
stopStatusLoop chan struct{} // closed by Stop() to end the background loop
}
// NewConnectionManager creates a new ConnectionManager instance
func NewConnectionManager(localDB *localdb.LocalDB) *ConnectionManager {
return &ConnectionManager{
localDB: localDB,
connectTimeout: defaultConnectTimeout,
pingInterval: defaultPingInterval,
reconnectCooldown: defaultReconnectCooldown,
db: nil,
lastError: nil,
lastCheck: time.Time{},
localDB: localDB,
connectTimeout: defaultConnectTimeout,
pingInterval: defaultPingInterval,
reconnectCooldown: defaultReconnectCooldown,
statusCheckInterval: defaultStatusCheckInterval,
db: nil,
lastError: nil,
lastCheck: time.Time{},
}
}
// GetDB returns the current database connection, establishing it if needed
// Thread-safe and respects connection cooldowns
// Start launches a background goroutine that keeps the online-status cache
// fresh, so that IsOnline() (called from request-handling middleware) never
// blocks on network I/O itself. Returns immediately — the app must be able
// to serve the local-first UI right away, before connectivity is even known.
// Until the first background check completes, IsOnline() reports offline
// (the safe default). Stop via ctx cancellation or Stop().
func (cm *ConnectionManager) Start(ctx context.Context) {
cm.mu.Lock()
if cm.stopStatusLoop == nil {
cm.stopStatusLoop = make(chan struct{})
}
stopCh := cm.stopStatusLoop
cm.mu.Unlock()
go func() {
// Prime the cache in the background; the dial/read timeout must not
// delay server startup.
cm.checkOnlineNow()
ticker := time.NewTicker(cm.statusCheckInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-stopCh:
return
case <-ticker.C:
cm.checkOnlineNow()
}
}
}()
}
// Stop ends the background status-refresh loop started by Start.
func (cm *ConnectionManager) Stop() {
cm.mu.Lock()
defer cm.mu.Unlock()
if cm.stopStatusLoop != nil {
close(cm.stopStatusLoop)
cm.stopStatusLoop = nil
}
}
// GetDB returns the current database connection, establishing it if needed.
// Thread-safe and respects connection cooldowns. The actual network I/O
// (dial/ping) never runs while holding cm.mu, so concurrent readers of the
// cached status (IsOnline, GetStatus) are never blocked by an in-flight
// connection attempt — only concurrent GetDB/checkOnlineNow callers are
// serialized against each other, via connMu.
func (cm *ConnectionManager) GetDB() (*gorm.DB, error) {
// Handle case where localDB is nil
if cm.localDB == nil {
return nil, fmt.Errorf("local database not initialized")
}
// First check if we already have a valid connection
cm.mu.RLock()
if cm.db != nil {
// Check if connection is still valid and within ping interval
if time.Since(cm.lastCheck) < cm.pingInterval {
cm.mu.RUnlock()
return cm.db, nil
}
if db, err, ok := cm.cachedResult(); ok {
return db, err
}
cm.mu.RUnlock()
// Upgrade to write lock
// Serialize actual connection attempts so concurrent callers don't dial
// in parallel. This may block the calling goroutine on network I/O, but
// never blocks other goroutines that only read the cached status.
cm.connMu.Lock()
defer cm.connMu.Unlock()
// Re-check: another goroutine may have just finished connecting while we
// were waiting for connMu.
if db, err, ok := cm.cachedResult(); ok {
return db, err
}
newDB, err := cm.dial()
cm.mu.Lock()
defer cm.mu.Unlock()
// Double-check: someone else might have connected while we were waiting for the write lock
if cm.db != nil {
// Check if connection is still valid and within ping interval
if time.Since(cm.lastCheck) < cm.pingInterval {
return cm.db, nil
}
}
// Check if we're in cooldown period after a failed attempt
if cm.lastError != nil && time.Since(cm.lastCheck) < cm.reconnectCooldown {
return nil, cm.lastError
}
// Attempt to connect
err := cm.connect()
if err != nil {
// Drop stale handle so callers don't treat it as an active connection.
cm.db = nil
cm.lastError = err
cm.lastCheck = time.Now()
cm.mu.Unlock()
return nil, err
}
// Update last check time and return success
cm.lastCheck = time.Now()
cm.db = newDB
cm.lastError = nil
return cm.db, nil
cm.lastCheck = time.Now()
cm.mu.Unlock()
return newDB, nil
}
// connect establishes a new database connection
func (cm *ConnectionManager) connect() error {
// cachedResult returns (db, err, true) if the cached state is still fresh
// enough to answer without a new network round-trip: either a live
// connection within pingInterval, or a recent failure still within
// reconnectCooldown. Returns ok=false if a fresh connection attempt is needed.
func (cm *ConnectionManager) cachedResult() (*gorm.DB, error, bool) {
cm.mu.RLock()
defer cm.mu.RUnlock()
if cm.db != nil && time.Since(cm.lastCheck) < cm.pingInterval {
return cm.db, nil, true
}
if cm.db == nil && cm.lastError != nil && time.Since(cm.lastCheck) < cm.reconnectCooldown {
return nil, cm.lastError, true
}
return nil, nil, false
}
// dial establishes a new database connection. Pure network I/O — must not be
// called while holding cm.mu.
func (cm *ConnectionManager) dial() (*gorm.DB, error) {
// Get DSN from local settings
dsn, err := cm.localDB.GetDSN()
if err != nil {
return fmt.Errorf("getting DSN: %w", err)
return nil, fmt.Errorf("getting DSN: %w", err)
}
// Create context with timeout
@@ -124,18 +191,18 @@ func (cm *ConnectionManager) connect() error {
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return fmt.Errorf("opening database connection: %w", err)
return nil, fmt.Errorf("opening database connection: %w", err)
}
// Test the connection
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("getting sql.DB: %w", err)
return nil, fmt.Errorf("getting sql.DB: %w", err)
}
// Ping with timeout
if err = sqlDB.PingContext(ctx); err != nil {
return fmt.Errorf("pinging database: %w", err)
return nil, fmt.Errorf("pinging database: %w", err)
}
// Set connection pool settings
@@ -143,15 +210,25 @@ func (cm *ConnectionManager) connect() error {
sqlDB.SetMaxIdleConns(maxIdleConns)
sqlDB.SetConnMaxLifetime(connMaxLifetime)
// Store the connection
cm.db = db
return nil
return db, nil
}
// IsOnline checks if the database is currently connected and responsive.
// If disconnected, it tries to reconnect (respecting cooldowns in GetDB).
// IsOnline returns the cached connectivity status. It never performs network
// I/O itself so it is safe to call from request-handling middleware; the
// background loop started by Start() (or an explicit TryConnect) is
// responsible for keeping the cache fresh.
func (cm *ConnectionManager) IsOnline() bool {
cm.mu.RLock()
defer cm.mu.RUnlock()
return cm.db != nil && cm.lastError == nil
}
// checkOnlineNow checks if the database is currently connected and
// responsive, performing real network I/O (dial/ping) as needed. If
// disconnected, it tries to reconnect (respecting cooldowns in GetDB). This
// must only be called from the background status loop or explicit
// user-triggered reconnects, never from request-handling goroutines.
func (cm *ConnectionManager) checkOnlineNow() bool {
cm.mu.RLock()
isDisconnected := cm.db == nil
lastErr := cm.lastError
@@ -169,57 +246,65 @@ func (cm *ConnectionManager) IsOnline() bool {
return lastErr == nil
}
// Need to perform actual ping.
cm.mu.Lock()
defer cm.mu.Unlock()
// Serialize actual ping attempts (network I/O) against other
// connect/ping attempts, without ever holding cm.mu during the I/O.
cm.connMu.Lock()
defer cm.connMu.Unlock()
// Double-check after acquiring write lock
if cm.db == nil {
cm.mu.RLock()
db := cm.db
checkedRecently = time.Since(cm.lastCheck) < cm.pingInterval
cm.mu.RUnlock()
if db == nil {
return false
}
if checkedRecently {
return true
}
// Perform ping with timeout
// Perform ping with timeout — no locks held here.
ctx, cancel := context.WithTimeout(context.Background(), cm.connectTimeout)
defer cancel()
sqlDB, err := cm.db.DB()
sqlDB, err := db.DB()
if err == nil {
err = sqlDB.PingContext(ctx)
}
cm.mu.Lock()
defer cm.mu.Unlock()
if err != nil {
cm.lastError = err
cm.lastCheck = time.Now()
cm.db = nil
return false
}
if err = sqlDB.PingContext(ctx); err != nil {
cm.lastError = err
cm.lastCheck = time.Now()
cm.db = nil
return false
}
// Update last check time and return success
cm.lastCheck = time.Now()
cm.lastError = nil
return true
}
// TryConnect forces a new connection attempt (for UI "Reconnect" button)
// Ignores cooldown period
// TryConnect forces a new connection attempt (for UI "Reconnect" button).
// Ignores the reconnect cooldown, but still serializes against other
// dial attempts via connMu and never holds cm.mu during network I/O.
func (cm *ConnectionManager) TryConnect() error {
cm.connMu.Lock()
defer cm.connMu.Unlock()
newDB, err := cm.dial()
cm.mu.Lock()
defer cm.mu.Unlock()
// Attempt to connect
err := cm.connect()
if err != nil {
cm.db = nil
cm.lastError = err
cm.lastCheck = time.Now()
return err
}
// Update last check time and clear error
cm.lastCheck = time.Now()
cm.db = newDB
cm.lastError = nil
cm.lastCheck = time.Now()
return nil
}
+72
View File
@@ -114,6 +114,13 @@ func New(dbPath string) (*LocalDB, error) {
return nil, fmt.Errorf("opening sqlite database: %w", err)
}
// SQLite requires a single writer connection to avoid SQLITE_BUSY under concurrent requests.
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("get sql.DB from gorm: %w", err)
}
sqlDB.SetMaxOpenConns(1)
// Enable WAL mode so background sync writes never block UI reads.
if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
slog.Warn("failed to enable WAL mode", "error", err)
@@ -121,6 +128,10 @@ func New(dbPath string) (*LocalDB, error) {
if err := db.Exec("PRAGMA synchronous=NORMAL").Error; err != nil {
slog.Warn("failed to set synchronous=NORMAL", "error", err)
}
// Wait up to 5 s before returning SQLITE_BUSY (guards against WAL checkpoints and external locks).
if err := db.Exec("PRAGMA busy_timeout = 5000").Error; err != nil {
slog.Warn("failed to set busy_timeout", "error", err)
}
if err := ensureLocalProjectsTable(db); err != nil {
return nil, fmt.Errorf("ensure local_projects table: %w", err)
@@ -1235,6 +1246,67 @@ func (l *LocalDB) GetLastComponentSyncError() string {
}
const openConfigUUIDsKey = "open_config_uuids"
// GetOpenConfigUUIDs returns UUIDs of all configurations currently open in the configurator.
func (l *LocalDB) GetOpenConfigUUIDs() []string {
value, ok := l.getAppSettingValue(openConfigUUIDsKey)
if !ok || value == "" {
return nil
}
var uuids []string
if err := json.Unmarshal([]byte(value), &uuids); err != nil {
return nil
}
return uuids
}
// AddOpenConfigUUID records that a configuration is open in the configurator.
func (l *LocalDB) AddOpenConfigUUID(uuid string) error {
uuids := l.GetOpenConfigUUIDs()
for _, u := range uuids {
if u == uuid {
return nil
}
}
uuids = append(uuids, uuid)
raw, err := json.Marshal(uuids)
if err != nil {
return err
}
return l.db.Exec(`
INSERT INTO app_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, openConfigUUIDsKey, string(raw), time.Now().Format(time.RFC3339)).Error
}
// RemoveOpenConfigUUID records that a configuration is no longer open in the configurator.
func (l *LocalDB) RemoveOpenConfigUUID(uuid string) error {
uuids := l.GetOpenConfigUUIDs()
filtered := uuids[:0]
for _, u := range uuids {
if u != uuid {
filtered = append(filtered, u)
}
}
var raw []byte
var err error
if len(filtered) == 0 {
raw = []byte("[]")
} else {
raw, err = json.Marshal(filtered)
if err != nil {
return err
}
}
return l.db.Exec(`
INSERT INTO app_settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`, openConfigUUIDsKey, string(raw), time.Now().Format(time.RFC3339)).Error
}
// CountLocalPricelists returns the number of local pricelists
func (l *LocalDB) CountLocalPricelists() int64 {
var count int64
+11 -1
View File
@@ -249,6 +249,13 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
pricelistItemsCount := s.localDB.CountAllPricelistItems()
componentsCount := s.localDB.CountComponents()
dbSizeBytes := s.localDB.DBFileSizeBytes()
openConfigUUIDs := s.localDB.GetOpenConfigUUIDs()
var openConfigUUIDsJSON *string
if len(openConfigUUIDs) > 0 {
raw, _ := json.Marshal(openConfigUUIDs)
s := string(raw)
openConfigUUIDsJSON = &s
}
return mariaDB.Exec(`
INSERT INTO qt_client_schema_state (
username, hostname, app_version,
@@ -257,9 +264,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version,
last_sync_error_code, last_sync_error_text,
local_pricelist_count, pricelist_items_count, components_count, db_size_bytes,
open_config_uuids,
last_checked_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
app_version = VALUES(app_version),
last_sync_at = VALUES(last_sync_at),
@@ -277,6 +285,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
pricelist_items_count = VALUES(pricelist_items_count),
components_count = VALUES(components_count),
db_size_bytes = VALUES(db_size_bytes),
open_config_uuids = VALUES(open_config_uuids),
last_checked_at = VALUES(last_checked_at),
updated_at = VALUES(updated_at)
`, username, hostname, appmeta.Version(),
@@ -285,6 +294,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
estimateVersion, warehouseVersion, competitorVersion,
lastSyncErrorCode, lastSyncErrorText,
localPricelistCount, pricelistItemsCount, componentsCount, dbSizeBytes,
openConfigUUIDsJSON,
checkedAt, checkedAt).Error
}
+50
View File
@@ -630,6 +630,56 @@ func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.
}
}
// ListActiveViewersByConfigUUIDs returns a map of configUUID → []username for users
// who currently have those configs open (based on the last two sync cycles).
func (s *Service) ListActiveViewersByConfigUUIDs(uuids []string) (map[string][]string, error) {
if len(uuids) == 0 {
return map[string][]string{}, nil
}
mariaDB, err := s.getDB()
if err != nil || mariaDB == nil {
return map[string][]string{}, nil
}
selfUsername := strings.ToLower(strings.TrimSpace(s.localDB.GetDBUser()))
type row struct {
Username string `gorm:"column:username"`
OpenConfigJSON string `gorm:"column:open_config_uuids"`
}
var rows []row
if err := mariaDB.Raw(`
SELECT username, open_config_uuids
FROM qt_client_schema_state
WHERE open_config_uuids IS NOT NULL
AND open_config_uuids != '[]'
AND last_checked_at > NOW() - INTERVAL 10 MINUTE
`).Scan(&rows).Error; err != nil {
return map[string][]string{}, nil
}
wantSet := make(map[string]struct{}, len(uuids))
for _, u := range uuids {
wantSet[u] = struct{}{}
}
result := make(map[string][]string)
for _, r := range rows {
if strings.ToLower(strings.TrimSpace(r.Username)) == selfUsername {
continue
}
var openUUIDs []string
if err := json.Unmarshal([]byte(r.OpenConfigJSON), &openUUIDs); err != nil {
continue
}
for _, ou := range openUUIDs {
if _, ok := wantSet[ou]; ok {
result[ou] = append(result[ou], r.Username)
}
}
}
return result, nil
}
// ListUserSyncStatuses returns users who have recorded a client schema state check.
func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) {
mariaDB, err := s.getDB()
+17
View File
@@ -0,0 +1,17 @@
# QuoteForge v2.26
Дата релиза: 2026-06-29
Тег: `v2.26`
Предыдущий релиз: `v2.25`
## Ключевые изменения
- fix: лоты, отсутствующие в текущем прайслисте, больше не блокируют сохранение конфига и генерацию артикула — такие лоты просто пропускаются;
- fix: если прайслист конфига удалён с сервера, автоматически выбирается последний активный;
- refactor: удалён мёртвый код qt_lot_metadata;
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.
+15
View File
@@ -0,0 +1,15 @@
# QuoteForge v2.27
Дата релиза: 2026-06-30
Тег: `v2.27`
Предыдущий релиз: `v2.26`
## Ключевые изменения
- в таблицах конфигураций появилась иконка глаза — показывает, что конфигурацию сейчас открыл другой пользователь; при наведении виден список имён;
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.
+11
View File
@@ -242,6 +242,7 @@ function renderConfigs(configs) {
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Кол-во</th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Сумма</th>';
html += '<th class="px-2 py-3 w-8"></th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '</tr></thead><tbody class="divide-y">';
@@ -298,6 +299,16 @@ function renderConfigs(configs) {
html += '<td class="px-4 py-3 text-sm text-gray-500">' + pricePerUnit + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
html += '<td class="px-4 py-3 text-sm text-right">' + total + '</td>';
const viewers = c.viewers || [];
if (viewers.length > 0) {
const names = viewers.map(escapeHtml).join(', ');
html += '<td class="px-2 py-3 text-center w-8">';
html += '<span title="Открыта: ' + names + '" class="inline-flex items-center justify-center text-blue-500 cursor-default">';
html += '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>';
html += '</span></td>';
} else {
html += '<td class="px-2 py-3 w-8"></td>';
}
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
if (configStatusMode === 'archived') {
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
+10
View File
@@ -985,6 +985,16 @@ document.addEventListener('DOMContentLoaded', async function() {
if (configUUID) {
loadVendorSpec(configUUID);
}
// Presence: announce that this config is open and keep renewing every 4 min
if (configUUID) {
const sendPresence = () => fetch('/api/configs/' + configUUID + '/presence', {method: 'POST'}).catch(() => {});
sendPresence();
setInterval(sendPresence, 4 * 60 * 1000);
window.addEventListener('beforeunload', () => {
fetch('/api/configs/' + configUUID + '/presence', {method: 'DELETE', keepalive: true}).catch(() => {});
});
}
});
async function loadAllComponents() {
+10 -1
View File
@@ -518,7 +518,16 @@ function renderConfigs(configs) {
html += '<td class="px-4 py-3 text-sm text-gray-500"><input type="number" min="1" value="' + serverCount + '" class="w-16 px-1 py-0.5 border rounded text-center text-sm" data-uuid="' + c.uuid + '" data-prev="' + serverCount + '" onchange="updateConfigServerCount(this)"></td>';
}
html += '<td class="px-4 py-3 text-sm text-right" data-total-uuid="' + c.uuid + '">' + formatMoneyNoDecimals(total) + '</td>';
html += '<td class="px-2 py-3 text-sm text-center text-gray-500 w-12">main</td>';
const projViewers = c.viewers || [];
if (projViewers.length > 0) {
const projNames = projViewers.map(escapeHtml).join(', ');
html += '<td class="px-2 py-3 text-center w-12">';
html += '<span title="Открыта: ' + projNames + '" class="inline-flex items-center justify-center text-blue-500 cursor-default">';
html += '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>';
html += '</span></td>';
} else {
html += '<td class="px-2 py-3 w-12"></td>';
}
html += '<td class="px-4 py-3 text-sm text-right whitespace-nowrap"><div class="inline-flex items-center justify-end gap-2">';
if (configStatusMode === 'archived') {
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';