Add server-to-local configuration import in web UI
This commit is contained in:
@@ -652,6 +652,19 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
})
|
||||
})
|
||||
|
||||
configs.POST("/import", func(c *gin.Context) {
|
||||
result, err := configService.ImportFromServer()
|
||||
if err != nil {
|
||||
if errors.Is(err, sync.ErrOffline) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database is offline"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
})
|
||||
|
||||
configs.POST("", func(c *gin.Context) {
|
||||
var req services.CreateConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// LocalConfigurationService handles configurations in local-first mode
|
||||
@@ -621,3 +621,8 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
|
||||
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
|
||||
return s.syncService.ImportConfigurationsToLocal()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -10,8 +11,11 @@ import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var ErrOffline = errors.New("database is offline")
|
||||
|
||||
// Service handles synchronization between MariaDB and local SQLite
|
||||
type Service struct {
|
||||
connMgr *db.ConnectionManager
|
||||
@@ -34,6 +38,71 @@ type SyncStatus struct {
|
||||
NeedsSync bool `json:"needs_sync"`
|
||||
}
|
||||
|
||||
// ConfigImportResult represents server->local configuration import stats.
|
||||
type ConfigImportResult struct {
|
||||
Imported int `json:"imported"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
}
|
||||
|
||||
// ImportConfigurationsToLocal imports configurations from MariaDB into local SQLite.
|
||||
// Existing local configs with pending local changes are skipped to avoid data loss.
|
||||
func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
if err != nil {
|
||||
return nil, ErrOffline
|
||||
}
|
||||
|
||||
configRepo := repository.NewConfigurationRepository(mariaDB)
|
||||
result := &ConfigImportResult{}
|
||||
|
||||
offset := 0
|
||||
const limit = 200
|
||||
for {
|
||||
serverConfigs, _, err := configRepo.ListAll(offset, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing server configurations: %w", err)
|
||||
}
|
||||
if len(serverConfigs) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for i := range serverConfigs {
|
||||
cfg := serverConfigs[i]
|
||||
existing, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("getting local configuration %s: %w", cfg.UUID, err)
|
||||
}
|
||||
|
||||
if existing != nil && err == nil && existing.SyncStatus == "pending" {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
localCfg := localdb.ConfigurationToLocal(&cfg)
|
||||
now := time.Now()
|
||||
localCfg.SyncedAt = &now
|
||||
localCfg.SyncStatus = "synced"
|
||||
localCfg.UpdatedAt = now
|
||||
|
||||
if existing != nil && err == nil {
|
||||
localCfg.ID = existing.ID
|
||||
result.Updated++
|
||||
} else {
|
||||
result.Imported++
|
||||
}
|
||||
|
||||
if err := s.localDB.SaveConfiguration(localCfg); err != nil {
|
||||
return nil, fmt.Errorf("saving local configuration %s: %w", cfg.UUID, err)
|
||||
}
|
||||
}
|
||||
|
||||
offset += len(serverConfigs)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetStatus returns the current sync status
|
||||
func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||
lastSync := s.localDB.GetLastSyncTime()
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
|
||||
|
||||
<div class="mt-4">
|
||||
<button onclick="openCreateModal()" class="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
<div class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||
+ Создать новую конфигурацию
|
||||
</button>
|
||||
<button id="import-configs-btn" onclick="importConfigsFromServer()" class="py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium">
|
||||
Импорт с сервера
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden">
|
||||
@@ -407,6 +410,38 @@ async function loadConfigs() {
|
||||
}
|
||||
}
|
||||
|
||||
async function importConfigsFromServer() {
|
||||
const button = document.getElementById('import-configs-btn');
|
||||
const originalText = button.textContent;
|
||||
button.disabled = true;
|
||||
button.textContent = 'Импорт...';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/import', { method: 'POST' });
|
||||
const data = await resp.json();
|
||||
|
||||
if (!resp.ok) {
|
||||
alert('Ошибка импорта: ' + (data.error || 'неизвестная ошибка'));
|
||||
return;
|
||||
}
|
||||
|
||||
alert(
|
||||
'Импорт завершен:\n' +
|
||||
'- Новых: ' + (data.imported || 0) + '\n' +
|
||||
'- Обновлено: ' + (data.updated || 0) + '\n' +
|
||||
'- Пропущено (локальные изменения): ' + (data.skipped || 0)
|
||||
);
|
||||
|
||||
currentPage = 1;
|
||||
await loadConfigs();
|
||||
} catch (e) {
|
||||
alert('Ошибка импорта с сервера');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadConfigs();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user