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

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

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

View File

@@ -123,6 +123,26 @@
<h4 class="font-medium text-gray-900 mb-2">Ошибки синхронизации</h4>
<div id="modal-errors-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
</div>
<!-- Section 5: Self-Healing (shown only if errors exist) -->
<div id="modal-repair-section" class="hidden">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 class="font-medium text-blue-900 mb-2">Автоматическое исправление</h4>
<p class="text-sm text-blue-700 mb-3">
Система может исправить данные и очистить ошибки синхронизации:
</p>
<ul class="text-sm text-blue-700 mb-3 ml-4 list-disc space-y-1">
<li>Проверит и исправит названия проектов</li>
<li>Восстановит битые ссылки на проекты</li>
<li>Очистит ошибки и даст pending changes еще шанс</li>
</ul>
<button id="repair-button" onclick="repairPendingChanges()"
class="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
ИСПРАВИТЬ
</button>
<div id="repair-result" class="mt-2 text-sm hidden"></div>
</div>
</div>
</div>
<div class="mt-6 flex justify-end">
@@ -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 += '<div class="mt-1 text-xs">' + data.remaining_errors.join('<br>') + '</div>';
}
}
} 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();