Add offline RefreshPrices, fix sync bugs, implement auto-restart

- Implement RefreshPrices for local-first mode
  - Update prices from local_components.current_price cache
  - Graceful degradation when component not found
  - Add PriceUpdatedAt timestamp to LocalConfiguration model
  - Support both authenticated and no-auth price refresh

- Fix sync duplicate entry bug
  - pushConfigurationUpdate now ensures server_id exists before update
  - Fetch from LocalConfiguration.ServerID or search on server if missing
  - Update local config with server_id after finding

- Add application auto-restart after settings save
  - Implement restartProcess() using syscall.Exec
  - Setup handler signals restart via channel
  - Setup page polls /health endpoint and redirects when ready
  - Add "Back" button on setup page when settings exist

- Fix setup handler password handling
  - Use PasswordEncrypted field consistently
  - Support empty password by using saved value

- Improve sync status handling
  - Add fallback for is_offline check in SyncStatusPartial
  - Enhance background sync logging with prefixes

- Update CLAUDE.md documentation
  - Mark Phase 2.5 tasks as complete
  - Add UI Improvements section with future tasks
  - Update SQLite tables documentation

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-02-02 11:03:41 +03:00
parent ec3c16f3fc
commit 9bd2acd4f7
11 changed files with 330 additions and 48 deletions

View File

@@ -29,12 +29,48 @@
- ✅ Add routes for new sync endpoints (`/api/sync/push`, `/pending/count`, `/pending`) - ✅ Add routes for new sync endpoints (`/api/sync/push`, `/pending/count`, `/pending`)
- ✅ ConfigurationGetter interface for handler compatibility - ✅ ConfigurationGetter interface for handler compatibility
- ✅ Background sync worker: auto-sync every 5min (push + pull) - `internal/services/sync/worker.go` - ✅ Background sync worker: auto-sync every 5min (push + pull) - `internal/services/sync/worker.go`
- ✅ UI: sync status indicator (pending badge + sync button + offline/online dot) - `web/templates/partials/sync_status.html`
- ✅ RefreshPrices for local mode:
- `RefreshPrices()` / `RefreshPricesNoAuth()` в `local_configuration.go`
- Берёт цены из `local_components.current_price`
- Graceful degradation при отсутствии компонента
- Добавлено поле `price_updated_at` в `LocalConfiguration` (models.go:72)
- Обновлены converters для PriceUpdatedAt
- UI кнопка "Пересчитать цену" работает offline/online
- ✅ Fixed sync bugs:
- Duplicate entry error при update конфигураций (`sync/service.go:334-365`)
- pushConfigurationUpdate теперь проверяет наличие server_id перед update
- Если нет ID → получает из LocalConfiguration.ServerID или ищет на сервере
- Fixed setup.go: `settings.Password``settings.PasswordEncrypted`
**TODO:** **TODO:**
- ❌ UI: sync status partial (pending badge + sync button + offline indicator)
- ❌ RefreshPrices for local mode (via local_components)
- ❌ Conflict resolution (Phase 4, last-write-wins default) - ❌ Conflict resolution (Phase 4, last-write-wins default)
### UI Improvements 🔶 IN PROGRESS
**1. Sync icon + pricelist badge в header (tasks 4+2):**
-`sync_status.html`: заменить текст Online/Offline на SVG иконку
- ❌ Кнопка sync → иконка (circular arrows) вместо текста
- ❌ Dropdown при клике: Push changes, Full sync, статус последней синхронизации
-`configs.html`: рядом с кнопкой "Создать" показать badge с версией активного прайслиста
- ❌ Загружать через `/api/pricelists/latest` при DOMContentLoaded
**2. Прайслисты → вкладка в "Администратор цен" (task 1):**
-`base.html`: убрать отдельную ссылку "Прайслисты" из навигации
-`admin_pricing.html`: добавить 4-ю вкладку "Прайслисты"
- ❌ Перенести логику из `pricelists.html` (table, create modal, CRUD) в эту вкладку
- ❌ Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists` или удалить
**3. Страница настроек: расширить + синхронизация (task 3):**
-`setup.html`: переделать на `{{template "base" .}}` структуру
- ❌ Увеличить до `max-w-4xl`, разделить на 2 секции
- ❌ Секция A: Подключение к БД (текущая форма)
- ❌ Секция B: Синхронизация данных:
- Статус Online/Offline
- Кнопки: "Синхронизировать всё", "Обновить компоненты", "Обновить прайслисты"
- Журнал синхронизации (последние N операций)
- ❌ Возможно: новый API endpoint для sync log
### Phase 3: Projects and Specifications ### Phase 3: Projects and Specifications
- qt_projects, qt_specifications tables (MariaDB) - qt_projects, qt_specifications tables (MariaDB)
- Replace qt_configurations → Project/Specification hierarchy - Replace qt_configurations → Project/Specification hierarchy
@@ -65,12 +101,12 @@ Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind
- `qt_specifications` - project_id, pricelist_id, variant, rev, qty, items JSON (Phase 3) - `qt_specifications` - project_id, pricelist_id, variant, rev, qty, items JSON (Phase 3)
### SQLite (data/quoteforge.db) ### SQLite (data/quoteforge.db)
- `connection_settings` - encrypted DB credentials - `connection_settings` - encrypted DB credentials (PasswordEncrypted field)
- `local_pricelists/items` - cached from server - `local_pricelists/items` - cached from server
- `local_components` - lot cache for offline search - `local_components` - lot cache for offline search (with current_price)
- `local_configurations` - with sync_status (pending/synced/conflict) - `local_configurations` - UUID, items, price_updated_at, sync_status (pending/synced/conflict), server_id
- `local_projects/specifications` - Phase 3 - `local_projects/specifications` - Phase 3
- `pending_changes` - sync queue (entity_type, uuid, op, payload, created_at) - `pending_changes` - sync queue (entity_type, uuid, op, payload, created_at, attempts, last_error)
## Business Logic ## Business Logic
@@ -91,6 +127,7 @@ Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind
| Pricelists | CRUD /api/pricelists, GET /latest, POST /compare | | Pricelists | CRUD /api/pricelists, GET /latest, POST /compare |
| Projects | CRUD /api/projects/:uuid (Phase 3) | | Projects | CRUD /api/projects/:uuid (Phase 3) |
| Specs | CRUD /api/specs/:uuid, POST /upgrade, GET /diff (Phase 3) | | Specs | CRUD /api/specs/:uuid, POST /upgrade, GET /diff (Phase 3) |
| Configs | POST /:uuid/refresh-prices (обновить цены из local_components) |
| Sync | GET /status, POST /components, /pricelists, /push, /pull, /resolve-conflict | | Sync | GET /status, POST /components, /pricelists, /push, /pull, /resolve-conflict |
| Export | GET /api/specs/:uuid/export, /api/projects/:uuid/export | | Export | GET /api/specs/:uuid/export, /api/projects/:uuid/export |

View File

@@ -195,7 +195,9 @@ func setConfigDefaults(cfg *config.Config) {
// runSetupMode starts a minimal server that only serves the setup page // runSetupMode starts a minimal server that only serves the setup page
func runSetupMode(local *localdb.LocalDB) { func runSetupMode(local *localdb.LocalDB) {
setupHandler, err := handlers.NewSetupHandler(local, "web/templates") restartSig := make(chan struct{}, 1)
setupHandler, err := handlers.NewSetupHandler(local, "web/templates", restartSig)
if err != nil { if err != nil {
slog.Error("failed to create setup handler", "error", err) slog.Error("failed to create setup handler", "error", err)
os.Exit(1) os.Exit(1)
@@ -242,9 +244,21 @@ func runSetupMode(local *localdb.LocalDB) {
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
slog.Info("setup mode server stopped") select {
case <-quit:
slog.Info("setup mode server stopped")
case <-restartSig:
slog.Info("restarting application with saved settings...")
// Graceful shutdown
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
srv.Shutdown(ctx)
// Restart process with same arguments
restartProcess()
}
} }
func setupLogger(cfg config.LoggingConfig) { func setupLogger(cfg config.LoggingConfig) {
@@ -336,8 +350,8 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
return nil, nil, fmt.Errorf("creating sync handler: %w", err) return nil, nil, fmt.Errorf("creating sync handler: %w", err)
} }
// Setup handler (for reconfiguration) // Setup handler (for reconfiguration) - no restart signal in normal mode
setupHandler, err := handlers.NewSetupHandler(local, "web/templates") setupHandler, err := handlers.NewSetupHandler(local, "web/templates", nil)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("creating setup handler: %w", err) return nil, nil, fmt.Errorf("creating setup handler: %w", err)
} }
@@ -620,6 +634,26 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
return router, syncService, nil return router, syncService, nil
} }
// restartProcess restarts the current process with the same arguments
func restartProcess() {
executable, err := os.Executable()
if err != nil {
slog.Error("failed to get executable path", "error", err)
os.Exit(1)
}
args := os.Args
env := os.Environ()
slog.Info("executing restart", "executable", executable, "args", args)
err = syscall.Exec(executable, args, env)
if err != nil {
slog.Error("failed to restart process", "error", err)
os.Exit(1)
}
}
func requestLogger() gin.HandlerFunc { func requestLogger() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
start := time.Now() start := time.Now()

View File

@@ -16,11 +16,12 @@ import (
) )
type SetupHandler struct { type SetupHandler struct {
localDB *localdb.LocalDB localDB *localdb.LocalDB
templates map[string]*template.Template templates map[string]*template.Template
restartSig chan struct{}
} }
func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string) (*SetupHandler, error) { func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"sub": func(a, b int) int { return a - b }, "sub": func(a, b int) int { return a - b },
"add": func(a, b int) int { return a + b }, "add": func(a, b int) int { return a + b },
@@ -37,8 +38,9 @@ func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string) (*SetupHand
templates["setup.html"] = tmpl templates["setup.html"] = tmpl
return &SetupHandler{ return &SetupHandler{
localDB: localDB, localDB: localDB,
templates: templates, templates: templates,
restartSig: restartSig,
}, nil }, nil
} }
@@ -72,6 +74,13 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
port = p port = p
} }
// If password is empty, try to use saved password
if password == "" {
if settings, err := h.localDB.GetSettings(); err == nil && settings != nil {
password = settings.PasswordEncrypted // GetSettings returns decrypted password in this field
}
}
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s", dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
user, password, host, port, database) user, password, host, port, database)
@@ -138,6 +147,13 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
port = p port = p
} }
// If password is empty, use saved password
if password == "" {
if settings, err := h.localDB.GetSettings(); err == nil && settings != nil {
password = settings.PasswordEncrypted // GetSettings returns decrypted password in this field
}
}
// Test connection first // Test connection first
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s", dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s",
user, password, host, port, database) user, password, host, port, database)
@@ -167,8 +183,16 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Settings saved. Please restart the application.", "message": "Settings saved. Restarting application...",
}) })
// Signal restart after response is sent
if h.restartSig != nil {
go func() {
time.Sleep(500 * time.Millisecond) // Give time for response to be sent
h.restartSig <- struct{}{}
}()
}
} }
// GetStatus returns the current setup status // GetStatus returns the current setup status

View File

@@ -285,20 +285,30 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
// SyncStatusPartial renders the sync status partial for htmx // SyncStatusPartial renders the sync status partial for htmx
// GET /partials/sync-status // GET /partials/sync-status
func (h *SyncHandler) SyncStatusPartial(c *gin.Context) { func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
// Check online status // Check online status from middleware
isOffline, _ := c.Get("is_offline") isOfflineValue, exists := c.Get("is_offline")
isOffline := false
if exists {
isOffline = isOfflineValue.(bool)
} else {
// Fallback: check directly if middleware didn't set it
isOffline = !h.checkOnline()
slog.Warn("is_offline not found in context, checking directly")
}
// Get pending count // Get pending count
pendingCount := h.localDB.GetPendingCount() pendingCount := h.localDB.GetPendingCount()
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount)
data := gin.H{ data := gin.H{
"IsOffline": isOffline.(bool), "IsOffline": isOffline,
"PendingCount": pendingCount, "PendingCount": pendingCount,
} }
c.Header("Content-Type", "text/html; charset=utf-8") c.Header("Content-Type", "text/html; charset=utf-8")
if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil { if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil {
slog.Error("failed to render sync_status template", "error", err) slog.Error("failed to render sync_status template", "error", err)
c.String(http.StatusInternalServerError, "Template error") c.String(http.StatusInternalServerError, "Template error: "+err.Error())
} }
} }

View File

@@ -26,6 +26,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
Notes: cfg.Notes, Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate, IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount, ServerCount: cfg.ServerCount,
PriceUpdatedAt: cfg.PriceUpdatedAt,
CreatedAt: cfg.CreatedAt, CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
SyncStatus: "pending", SyncStatus: "pending",
@@ -52,16 +53,17 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
} }
cfg := &models.Configuration{ cfg := &models.Configuration{
UUID: local.UUID, UUID: local.UUID,
UserID: local.OriginalUserID, UserID: local.OriginalUserID,
Name: local.Name, Name: local.Name,
Items: items, Items: items,
TotalPrice: local.TotalPrice, TotalPrice: local.TotalPrice,
CustomPrice: local.CustomPrice, CustomPrice: local.CustomPrice,
Notes: local.Notes, Notes: local.Notes,
IsTemplate: local.IsTemplate, IsTemplate: local.IsTemplate,
ServerCount: local.ServerCount, ServerCount: local.ServerCount,
CreatedAt: local.CreatedAt, PriceUpdatedAt: local.PriceUpdatedAt,
CreatedAt: local.CreatedAt,
} }
if local.ServerID != nil { if local.ServerID != nil {

View File

@@ -69,6 +69,7 @@ type LocalConfiguration struct {
Notes string `json:"notes"` Notes string `json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"` IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"` ServerCount int `gorm:"default:1" json:"server_count"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
SyncedAt *time.Time `json:"synced_at"` SyncedAt *time.Time `json:"synced_at"`

View File

@@ -2,7 +2,6 @@ package services
import ( import (
"encoding/json" "encoding/json"
"errors"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -292,11 +291,71 @@ func (s *LocalConfigurationService) ListByUser(userID uint, page, perPage int) (
return userConfigs[start:end], total, nil return userConfigs[start:end], total, nil
} }
// RefreshPrices updates all component prices in the configuration // RefreshPrices updates all component prices in the configuration from local cache
func (s *LocalConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) { func (s *LocalConfigurationService) RefreshPrices(uuid string, userID uint) (*models.Configuration, error) {
// This requires access to component prices from local cache // Get configuration from local SQLite
// For now, return error as we need to implement component price lookup from local cache localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
return nil, errors.New("refresh prices not yet implemented for local-first mode") if err != nil {
return nil, ErrConfigNotFound
}
// Check ownership
if localCfg.OriginalUserID != userID {
return nil, ErrConfigForbidden
}
// Update prices for all items
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items {
// Get current component price from local cache
component, err := s.localDB.GetLocalComponent(item.LotName)
if err != nil || component.CurrentPrice == nil {
// Keep original item if component not found or no price available
updatedItems[i] = item
continue
}
// Update item with current price from local cache
updatedItems[i] = localdb.LocalConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: *component.CurrentPrice,
}
}
// Update configuration
localCfg.Items = updatedItems
total := updatedItems.Total()
// If server count is greater than 1, multiply the total by server count
if localCfg.ServerCount > 1 {
total *= float64(localCfg.ServerCount)
}
localCfg.TotalPrice = &total
// Set price update timestamp and mark for sync
now := time.Now()
localCfg.PriceUpdatedAt = &now
localCfg.UpdatedAt = now
localCfg.SyncStatus = "pending"
// Save to local SQLite
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
// Add to pending sync queue
cfg := localdb.LocalToConfiguration(localCfg)
payload, err := json.Marshal(cfg)
if err != nil {
return nil, err
}
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
return nil, err
}
return cfg, nil
} }
// GetByUUIDNoAuth returns configuration without ownership check // GetByUUIDNoAuth returns configuration without ownership check
@@ -503,7 +562,62 @@ func (s *LocalConfigurationService) ListTemplates(page, perPage int) ([]models.C
// RefreshPricesNoAuth updates all component prices in the configuration without ownership check // RefreshPricesNoAuth updates all component prices in the configuration without ownership check
func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) { func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
// This requires access to component prices from local cache // Get configuration from local SQLite
// For now, return error as we need to implement component price lookup from local cache localCfg, err := s.localDB.GetConfigurationByUUID(uuid)
return nil, errors.New("refresh prices not yet implemented for local-first mode") if err != nil {
return nil, ErrConfigNotFound
}
// Update prices for all items
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items {
// Get current component price from local cache
component, err := s.localDB.GetLocalComponent(item.LotName)
if err != nil || component.CurrentPrice == nil {
// Keep original item if component not found or no price available
updatedItems[i] = item
continue
}
// Update item with current price from local cache
updatedItems[i] = localdb.LocalConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: *component.CurrentPrice,
}
}
// Update configuration
localCfg.Items = updatedItems
total := updatedItems.Total()
// If server count is greater than 1, multiply the total by server count
if localCfg.ServerCount > 1 {
total *= float64(localCfg.ServerCount)
}
localCfg.TotalPrice = &total
// Set price update timestamp and mark for sync
now := time.Now()
localCfg.PriceUpdatedAt = &now
localCfg.UpdatedAt = now
localCfg.SyncStatus = "pending"
// Save to local SQLite
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
return nil, err
}
// Add to pending sync queue
cfg := localdb.LocalToConfiguration(localCfg)
payload, err := json.Marshal(cfg)
if err != nil {
return nil, err
}
if err := s.localDB.AddPendingChange("configuration", uuid, "update", string(payload)); err != nil {
return nil, err
}
return cfg, nil
} }

View File

@@ -337,6 +337,31 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
return fmt.Errorf("unmarshaling configuration: %w", err) return fmt.Errorf("unmarshaling configuration: %w", err)
} }
// Ensure we have a server ID before updating
// If the payload doesn't have ID, get it from local configuration
if cfg.ID == 0 {
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
if err != nil {
return fmt.Errorf("getting local configuration: %w", err)
}
if localCfg.ServerID == nil {
// Configuration hasn't been synced yet, try to find it on server by UUID
serverCfg, err := s.configRepo.GetByUUID(cfg.UUID)
if err != nil {
return fmt.Errorf("configuration not yet synced to server: %w", err)
}
cfg.ID = serverCfg.ID
// Update local with server ID
serverID := serverCfg.ID
localCfg.ServerID = &serverID
s.localDB.SaveConfiguration(localCfg)
} else {
cfg.ID = *localCfg.ServerID
}
}
// Update on server // Update on server
if err := s.configRepo.Update(&cfg); err != nil { if err := s.configRepo.Update(&cfg); err != nil {
return fmt.Errorf("updating configuration on server: %w", err) return fmt.Errorf("updating configuration on server: %w", err)

View File

@@ -75,21 +75,19 @@ func (w *Worker) runSync() {
return return
} }
w.logger.Debug("running background sync")
// Push pending changes first // Push pending changes first
pushed, err := w.service.PushPendingChanges() pushed, err := w.service.PushPendingChanges()
if err != nil { if err != nil {
w.logger.Warn("failed to push pending changes", "error", err) w.logger.Warn("background sync: failed to push pending changes", "error", err)
} else if pushed > 0 { } else if pushed > 0 {
w.logger.Info("pushed pending changes", "count", pushed) w.logger.Info("background sync: pushed pending changes", "count", pushed)
} }
// Then check for new pricelists // Then check for new pricelists
err = w.service.SyncPricelistsIfNeeded() err = w.service.SyncPricelistsIfNeeded()
if err != nil { if err != nil {
w.logger.Warn("failed to sync pricelists", "error", err) w.logger.Warn("background sync: failed to sync pricelists", "error", err)
} }
w.logger.Debug("background sync completed") w.logger.Info("background sync cycle completed")
} }

View File

@@ -28,10 +28,10 @@
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<!-- Sync Status Indicator (htmx-powered) --> <!-- Sync Status Indicator (htmx-powered) -->
<div id="sync-status" <div id="sync-status"
class="flex items-center gap-3 text-sm"
hx-get="/partials/sync-status" hx-get="/partials/sync-status"
hx-trigger="load, refresh from:body, every 30s" hx-trigger="load, refresh from:body, every 30s"
hx-swap="innerHTML"> hx-swap="innerHTML">
<span class="animate-pulse text-gray-400 text-xs">Загрузка...</span>
</div> </div>
<span id="db-user" class="text-sm text-gray-600"></span> <span id="db-user" class="text-sm text-gray-600"></span>
</div> </div>

View File

@@ -61,6 +61,12 @@
<div id="status" class="hidden p-3 rounded-md text-sm"></div> <div id="status" class="hidden p-3 rounded-md text-sm"></div>
<div class="flex space-x-3 pt-4"> <div class="flex space-x-3 pt-4">
{{if .Settings}}
<a href="/"
class="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition text-center">
Назад
</a>
{{end}}
<button type="button" onclick="testConnection()" <button type="button" onclick="testConnection()"
class="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition"> class="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition">
Проверить Проверить
@@ -122,6 +128,34 @@
} }
} }
async function checkServerReady() {
let attempts = 0;
const maxAttempts = 30; // 30 seconds max
const checkInterval = setInterval(async () => {
attempts++;
try {
const resp = await fetch('/health', { method: 'GET' });
const data = await resp.json();
// Check if we're out of setup mode
if (data.status === 'ok') {
clearInterval(checkInterval);
showStatus('✓ Приложение запущено! Перенаправление...', 'success');
setTimeout(() => {
window.location.href = '/';
}, 1000);
}
} catch (e) {
// Server still restarting, continue polling
if (attempts >= maxAttempts) {
clearInterval(checkInterval);
showStatus('Сервер не отвечает. Обновите страницу вручную.', 'error');
}
}
}, 1000); // Check every second
}
document.getElementById('setup-form').addEventListener('submit', async function(e) { document.getElementById('setup-form').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
showStatus('Сохранение настроек...', 'info'); showStatus('Сохранение настроек...', 'info');
@@ -136,9 +170,12 @@
const data = await resp.json(); const data = await resp.json();
if (data.success) { if (data.success) {
showStatus(data.message + ' Перенаправление...', 'success'); showStatus('✓ ' + data.message, 'success');
// Wait for restart and redirect to home
setTimeout(() => { setTimeout(() => {
window.location.href = '/'; showStatus('✓ Настройки сохранены. Проверка подключения...', 'success');
// Poll until server is back
checkServerReady();
}, 2000); }, 2000);
} else { } else {
showStatus(data.error, 'error'); showStatus(data.error, 'error');