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:
@@ -1653,6 +1653,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
syncAPI.POST("/push", syncHandler.PushPendingChanges)
|
syncAPI.POST("/push", syncHandler.PushPendingChanges)
|
||||||
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
|
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
|
||||||
syncAPI.GET("/pending", syncHandler.GetPendingChanges)
|
syncAPI.GET("/pending", syncHandler.GetPendingChanges)
|
||||||
|
syncAPI.POST("/repair", syncHandler.RepairPendingChanges)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// SyncInfoResponse represents sync information for the modal
|
||||||
type SyncInfoResponse struct {
|
type SyncInfoResponse struct {
|
||||||
// Connection
|
// Connection
|
||||||
|
|||||||
@@ -1041,6 +1041,129 @@ func (l *LocalDB) GetPendingCount() int64 {
|
|||||||
return l.CountPendingChanges()
|
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.
|
// GetRemoteMigrationApplied returns a locally applied remote migration by ID.
|
||||||
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
|
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
|
||||||
var migration LocalRemoteMigrationApplied
|
var migration LocalRemoteMigrationApplied
|
||||||
|
|||||||
@@ -123,6 +123,26 @@
|
|||||||
<h4 class="font-medium text-gray-900 mb-2">Ошибки синхронизации</h4>
|
<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 id="modal-errors-list" class="text-sm max-h-40 overflow-y-auto space-y-1"></div>
|
||||||
</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>
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
<div class="mt-6 flex justify-end">
|
||||||
@@ -235,7 +255,8 @@
|
|||||||
// Section 4: Errors
|
// Section 4: Errors
|
||||||
const errorsSection = document.getElementById('modal-errors-section');
|
const errorsSection = document.getElementById('modal-errors-section');
|
||||||
const errorsList = document.getElementById('modal-errors-list');
|
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');
|
errorsSection.classList.remove('hidden');
|
||||||
errorsList.innerHTML = data.errors.map(error => {
|
errorsList.innerHTML = data.errors.map(error => {
|
||||||
const time = new Date(error.timestamp).toLocaleString('ru-RU');
|
const time = new Date(error.timestamp).toLocaleString('ru-RU');
|
||||||
@@ -246,12 +267,65 @@
|
|||||||
} else {
|
} else {
|
||||||
errorsSection.classList.add('hidden');
|
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) {
|
} catch(e) {
|
||||||
console.error('Failed to load sync info:', e);
|
console.error('Failed to load sync info:', e);
|
||||||
document.getElementById('modal-db-status').textContent = 'Ошибка загрузки';
|
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
|
// Event delegation for sync dropdown and actions
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadDBUser();
|
loadDBUser();
|
||||||
|
|||||||
Reference in New Issue
Block a user