From b153afbf510f29d378e74f345dc03fe8ab0f9db3 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Mon, 16 Feb 2026 19:00:03 +0300 Subject: [PATCH] Add smart self-healing for sync errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements automatic repair mechanism for pending changes with sync errors: - Projects: validates and fixes empty name/code fields - Configurations: ensures project references exist or assigns system project - Clears errors and resets attempts to give changes another sync chance Backend: - LocalDB.RepairPendingChanges() with smart validation logic - POST /api/sync/repair endpoint - Detailed repair results with remaining errors Frontend: - Auto-repair section in sync modal shown when errors exist - "ИСПРАВИТЬ" button with clear explanation of actions - Real-time feedback with result messages Co-Authored-By: Claude Opus 4.6 --- cmd/qfs/main.go | 1 + internal/handlers/sync.go | 20 ++++++ internal/localdb/localdb.go | 123 ++++++++++++++++++++++++++++++++++++ web/templates/base.html | 76 +++++++++++++++++++++- 4 files changed, 219 insertions(+), 1 deletion(-) diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index bd0c46c..b22f986 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -1653,6 +1653,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect syncAPI.POST("/push", syncHandler.PushPendingChanges) syncAPI.GET("/pending/count", syncHandler.GetPendingCount) syncAPI.GET("/pending", syncHandler.GetPendingChanges) + syncAPI.POST("/repair", syncHandler.RepairPendingChanges) } } diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index 26f897b..4116f9f 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -419,6 +419,26 @@ func (h *SyncHandler) GetPendingChanges(c *gin.Context) { }) } +// RepairPendingChanges attempts to repair errored pending changes +// POST /api/sync/repair +func (h *SyncHandler) RepairPendingChanges(c *gin.Context) { + repaired, remainingErrors, err := h.localDB.RepairPendingChanges() + if err != nil { + slog.Error("repair pending changes failed", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "repaired": repaired, + "remaining_errors": remainingErrors, + }) +} + // SyncInfoResponse represents sync information for the modal type SyncInfoResponse struct { // Connection diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 0d1b611..11b2c87 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -1041,6 +1041,129 @@ func (l *LocalDB) GetPendingCount() int64 { return l.CountPendingChanges() } +// RepairPendingChanges attempts to fix errored pending changes by validating and correcting data. +// Returns the number of changes repaired and a list of errors that couldn't be fixed. +func (l *LocalDB) RepairPendingChanges() (int, []string, error) { + var erroredChanges []PendingChange + if err := l.db.Where("last_error != ?", "").Find(&erroredChanges).Error; err != nil { + return 0, nil, fmt.Errorf("fetching errored changes: %w", err) + } + + if len(erroredChanges) == 0 { + return 0, nil, nil + } + + repaired := 0 + var remainingErrors []string + + for _, change := range erroredChanges { + var repairErr error + switch change.EntityType { + case "project": + repairErr = l.repairProjectChange(&change) + case "configuration": + repairErr = l.repairConfigurationChange(&change) + default: + repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType) + } + + if repairErr != nil { + remainingErrors = append(remainingErrors, fmt.Sprintf("%s %s %s: %v", + change.Operation, change.EntityType, change.EntityUUID[:8], repairErr)) + continue + } + + // Clear error and reset attempts + if err := l.db.Model(&PendingChange{}).Where("id = ?", change.ID).Updates(map[string]interface{}{ + "last_error": "", + "attempts": 0, + }).Error; err != nil { + remainingErrors = append(remainingErrors, fmt.Sprintf("clearing error for %s: %v", change.EntityUUID[:8], err)) + continue + } + + repaired++ + } + + return repaired, remainingErrors, nil +} + +// repairProjectChange validates and fixes project data +func (l *LocalDB) repairProjectChange(change *PendingChange) error { + project, err := l.GetProjectByUUID(change.EntityUUID) + if err != nil { + return fmt.Errorf("project not found locally: %w", err) + } + + modified := false + + // Fix Code: must be non-empty + if strings.TrimSpace(project.Code) == "" { + if project.Name != nil && strings.TrimSpace(*project.Name) != "" { + project.Code = strings.TrimSpace(*project.Name) + } else { + project.Code = project.UUID[:8] + } + modified = true + } + + // Fix Name: use Code if empty + if project.Name == nil || strings.TrimSpace(*project.Name) == "" { + name := project.Code + project.Name = &name + modified = true + } + + // Fix OwnerUsername: must be non-empty + if strings.TrimSpace(project.OwnerUsername) == "" { + project.OwnerUsername = l.GetDBUser() + if project.OwnerUsername == "" { + return fmt.Errorf("cannot determine owner username") + } + modified = true + } + + if modified { + if err := l.SaveProject(project); err != nil { + return fmt.Errorf("saving repaired project: %w", err) + } + } + + return nil +} + +// repairConfigurationChange validates and fixes configuration data +func (l *LocalDB) repairConfigurationChange(change *PendingChange) error { + config, err := l.GetConfigurationByUUID(change.EntityUUID) + if err != nil { + return fmt.Errorf("configuration not found locally: %w", err) + } + + modified := false + + // Check if referenced project exists + if config.ProjectUUID != nil && *config.ProjectUUID != "" { + _, err := l.GetProjectByUUID(*config.ProjectUUID) + if err != nil { + // Project doesn't exist locally - use default system project + systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername) + if sysErr != nil { + return fmt.Errorf("getting system project: %w", sysErr) + } + config.ProjectUUID = &systemProject.UUID + modified = true + } + } + + if modified { + if err := l.SaveConfiguration(config); err != nil { + return fmt.Errorf("saving repaired configuration: %w", err) + } + } + + return nil +} + // GetRemoteMigrationApplied returns a locally applied remote migration by ID. func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) { var migration LocalRemoteMigrationApplied diff --git a/web/templates/base.html b/web/templates/base.html index 9946ae2..2140686 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -123,6 +123,26 @@

Ошибки синхронизации

+ + +
@@ -235,7 +255,8 @@ // Section 4: Errors const errorsSection = document.getElementById('modal-errors-section'); const errorsList = document.getElementById('modal-errors-list'); - if (data.errors && data.errors.length > 0) { + const hasErrors = data.errors && data.errors.length > 0; + if (hasErrors) { errorsSection.classList.remove('hidden'); errorsList.innerHTML = data.errors.map(error => { const time = new Date(error.timestamp).toLocaleString('ru-RU'); @@ -246,12 +267,65 @@ } else { errorsSection.classList.add('hidden'); } + + // Section 5: Repair (show only if errors exist) + const repairSection = document.getElementById('modal-repair-section'); + const repairResult = document.getElementById('repair-result'); + if (hasErrors) { + repairSection.classList.remove('hidden'); + repairResult.classList.add('hidden'); + repairResult.innerHTML = ''; + } else { + repairSection.classList.add('hidden'); + } } catch(e) { console.error('Failed to load sync info:', e); document.getElementById('modal-db-status').textContent = 'Ошибка загрузки'; } } + // Repair pending changes + async function repairPendingChanges() { + const button = document.getElementById('repair-button'); + const resultDiv = document.getElementById('repair-result'); + + button.disabled = true; + button.textContent = 'Исправление...'; + resultDiv.classList.add('hidden'); + + try { + const resp = await fetch('/api/sync/repair', { method: 'POST' }); + const data = await resp.json(); + + if (data.success) { + resultDiv.classList.remove('hidden'); + if (data.repaired > 0) { + resultDiv.className = 'mt-2 text-sm text-green-700 bg-green-50 rounded px-3 py-2'; + resultDiv.textContent = `✓ Исправлено: ${data.repaired}`; + // Reload sync info after repair + setTimeout(() => loadSyncInfo(), 1000); + } else { + resultDiv.className = 'mt-2 text-sm text-yellow-700 bg-yellow-50 rounded px-3 py-2'; + resultDiv.textContent = 'Нечего исправлять или проблемы остались'; + if (data.remaining_errors && data.remaining_errors.length > 0) { + resultDiv.innerHTML += '
' + data.remaining_errors.join('
') + '
'; + } + } + } else { + resultDiv.classList.remove('hidden'); + resultDiv.className = 'mt-2 text-sm text-red-700 bg-red-50 rounded px-3 py-2'; + resultDiv.textContent = 'Ошибка: ' + (data.error || 'неизвестная ошибка'); + } + } catch (e) { + resultDiv.classList.remove('hidden'); + resultDiv.className = 'mt-2 text-sm text-red-700 bg-red-50 rounded px-3 py-2'; + resultDiv.textContent = 'Ошибка запроса: ' + e.message; + } finally { + button.disabled = false; + button.textContent = 'ИСПРАВИТЬ'; + } + } + // Event delegation for sync dropdown and actions document.addEventListener('DOMContentLoaded', function() { loadDBUser();