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) {
|
configs.POST("", func(c *gin.Context) {
|
||||||
var req services.CreateConfigRequest
|
var req services.CreateConfigRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LocalConfigurationService handles configurations in local-first mode
|
// LocalConfigurationService handles configurations in local-first mode
|
||||||
@@ -621,3 +621,8 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
|
|
||||||
return cfg, nil
|
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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
@@ -10,8 +11,11 @@ import (
|
|||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"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
|
// Service handles synchronization between MariaDB and local SQLite
|
||||||
type Service struct {
|
type Service struct {
|
||||||
connMgr *db.ConnectionManager
|
connMgr *db.ConnectionManager
|
||||||
@@ -34,6 +38,71 @@ type SyncStatus struct {
|
|||||||
NeedsSync bool `json:"needs_sync"`
|
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
|
// GetStatus returns the current sync status
|
||||||
func (s *Service) GetStatus() (*SyncStatus, error) {
|
func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||||
lastSync := s.localDB.GetLastSyncTime()
|
lastSync := s.localDB.GetLastSyncTime()
|
||||||
|
|||||||
@@ -4,10 +4,13 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
|
<h1 class="text-2xl font-bold">Мои конфигурации</h1>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<button onclick="openCreateModal()" class="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
<button onclick="openCreateModal()" class="py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||||
+ Создать новую конфигурацию
|
+ Создать новую конфигурацию
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div id="pricelist-badge" class="mt-4 text-sm text-gray-600 hidden">
|
<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() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadConfigs();
|
loadConfigs();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user