Add smart self-healing for sync errors

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 19:00:03 +03:00
parent 9b5d57902d
commit b153afbf51
4 changed files with 219 additions and 1 deletions

View File

@@ -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

View File

@@ -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