Introduction

Dans les applications web modernes, de nombreuses opérations nécessitent un temps de traitement significatif : import de fichiers volumineux, génération de rapports complexes, traitement par lots, migrations de données, etc. L'expérience utilisateur lors de ces opérations longues est cruciale : un utilisateur ne sachant pas ce qui se passe côté serveur aura l'impression que l'application est figée ou cassée.

Le système LongTaskNotifier pour Blazor Server répond à ce besoin en fournissant un mécanisme de notification en temps réel, permettant à l'utilisateur de suivre précisément l'avancement d'une tâche longue avec :

  • Affichage en temps réel de la progression
  • Multiples barres de progression simultanées
  • Messages informatifs, warnings et erreurs
  • Possibilité d'annuler une tâche en cours
  • Historique détaillé des événements

Pourquoi la notification temps réel est essentielle ?

1. Transparence et confiance

Lorsqu'un utilisateur lance une opération qui prend plusieurs minutes, le silence de l'application génère de l'anxiété. Il se pose des questions :

  • Est-ce que ça fonctionne vraiment ?
  • Combien de temps cela va-t-il prendre ?
  • L'application a-t-elle planté ?
  • Puis-je fermer l'onglet ?

Une notification en temps réel répond à toutes ces questions en affichant clairement :

  • Le statut actuel ("Traitement de l'élément 45/200")
  • L'étape en cours ("Validation des données")
  • Le temps écoulé
  • La possibilité d'annuler

2. Productivité de l'utilisateur

Sans feedback visuel, l'utilisateur est bloqué à attendre devant son écran, ne sachant pas combien de temps l'opération prendra. Avec une notification claire :

  • Il peut estimer le temps restant grâce aux barres de progression
  • Il peut continuer à travailler sur d'autres tâches si l'opération est longue
  • Il peut identifier et corriger rapidement les problèmes (via les warnings/erreurs)
  • Il peut annuler une opération qui prend trop de temps

3. Détection précoce des problèmes

Les notifications en temps réel permettent de détecter les anomalies pendant l'exécution :

  • Warnings sur des données incohérentes
  • Erreurs non bloquantes à corriger
  • Ralentissements inattendus
  • Échecs partiels nécessitant une intervention

4. Expérience utilisateur professionnelle

Une application qui communique clairement avec l'utilisateur pendant les traitements longs donne une impression de professionnalisme et de fiabilité. C'est la différence entre une application "amateur" et une application "production-ready".

Architecture du système

Diagramme de classes

classDiagram
    class ILongTaskNotifier~TCategory~ {
        <<interface>>
        +Notify(LongTaskNotification) Task
        +RegisterCancellationTokenSource(Guid, CancellationTokenSource) void
    }

    class LongTaskNotifier~TCategory~ {
        -LongTaskContainerService _containerService
        -ILogger _logger
        -Dictionary~Guid, CancellationTokenSource~ _ctsList
        +Notify(LongTaskNotification) Task
        +RegisterCancellationTokenSource(Guid, CancellationTokenSource) void
    }

    class LongTaskNotification {
        +Guid TaskId
        +DateTime CreationDate
        +LongTaskNotificationType Type
        +string Subject
        +string Body
        +int? TotalCount
        +int? Index
        +string? ProgressBarCode
        +string? ErrorMessage
        +List~string~ BrokenRuleList
    }

    class LongTaskNotificationType {
        <<enumeration>>
        LongTaskStart
        LongTaskWrite
        LongTaskWarning
        LongTaskError
        LongTaskStartProgress
        LongTaskProgress
        LongTaskEndProgress
        LongTaskCompleted
        LongTaskFailed
        LongTaskCanceled
    }

    class LongTaskContainerService {
        -ConcurrentDictionary~Guid, LongTaskDisplayItem~ _activeTasks
        +event Func~Task~ OnChange
        +IReadOnlyList~LongTaskDisplayItem~ ActiveTasks
        +ProcessNotificationAsync(LongTaskNotification, CancellationTokenSource?) Task
        +RemoveTaskAsync(Guid) Task
        +CancelTaskAsync(Guid) Task
    }

    class LongTaskDisplayItem {
        +Guid TaskId
        +string Title
        +LongTaskStatus Status
        +DateTime StartedAt
        +DateTime? CompletedAt
        +Dictionary~string, ProgressBarItem~ ProgressBars
        +List~LongTaskLogEntry~ LogEntries
        +bool IsCancellable
        +CancellationTokenSource CancellationTokenSource
    }

    class LongTaskContainer {
        <<Blazor Component>>
        +LongTaskPosition Position
        +int MaxLogEntries
        -LongTaskContainerService LongTaskService
        +OnAfterRender(bool) void
        +CloseTask(Guid) Task
        +CancelTask(Guid) Task
    }

    class ProgressBarItem {
        +string Code
        +string Label
        +bool IsContinuous
        +int Value
        +int Max
        +int Percentage
        +string? CurrentMessage
    }

    ILongTaskNotifier~TCategory~ <|.. LongTaskNotifier~TCategory~
    LongTaskNotifier~TCategory~ --> LongTaskContainerService : utilise
    LongTaskNotifier~TCategory~ --> LongTaskNotification : crée
    LongTaskNotification --> LongTaskNotificationType : référence
    LongTaskContainerService --> LongTaskDisplayItem : gère
    LongTaskDisplayItem --> ProgressBarItem : contient
    LongTaskContainer --> LongTaskContainerService : injecte
    LongTaskContainer ..> LongTaskDisplayItem : affiche

Diagramme de séquence - Cycle de vie complet d'une tâche longue

sequenceDiagram
    actor User as Utilisateur
    participant UI as Blazor UI
    participant Notifier as LongTaskNotifier
    participant Service as LongTaskContainerService
    participant Container as LongTaskContainer
    participant BG as Tâche Background

    User->>UI: Clic sur "Lancer le traitement"
    UI->>UI: Créer CancellationTokenSource
    UI->>Notifier: RegisterCancellationTokenSource(taskId, cts)
    UI->>BG: Task.Run(() => ExecuteTask(taskId, token))
    
    activate BG
    BG->>Notifier: Start(taskId, "Import de données")
    Notifier->>Service: ProcessNotificationAsync(notification, cts)
    Service->>Service: Créer LongTaskDisplayItem
    Service->>Container: OnChange Event
    Container->>Container: StateHasChanged()
    Container-->>User: 📦 Affichage "Import de données - En cours"

    Note over BG: Phase 1 : Chargement
    BG->>Notifier: StartContinuousProgress(taskId, "load", "Chargement du fichier")
    Notifier->>Service: ProcessNotificationAsync(...)
    Service->>Container: OnChange Event
    Container-->>User: 🔄 Barre de progression continue

    BG->>BG: await Task.Delay(5000, token)
    BG->>Notifier: EndContinuousProgress(taskId, "load")
    
    Note over BG: Phase 2 : Traitement
    BG->>Notifier: StartProgress(taskId, "process", "Traitement", 100)
    Notifier->>Service: ProcessNotificationAsync(...)
    Service->>Container: OnChange Event
    Container-->>User: 📊 Barre 0/100 (0%)

    loop Pour chaque élément
        BG->>BG: Traiter l'élément
        BG->>Notifier: Progress(taskId, "process", "Élément 25", 25)
        Notifier->>Service: ProcessNotificationAsync(...)
        Service->>Container: OnChange Event
        Container-->>User: 📊 Barre 25/100 (25%)
        
        alt Élément avec warning
            BG->>Notifier: Warning(taskId, "Anomalie", "Données incomplètes")
            Notifier->>Service: ProcessNotificationAsync(...)
            Service->>Container: OnChange Event
            Container-->>User: ⚠️ Warning affiché
        end
    end

    BG->>Notifier: EndProgress(taskId, "process")
    
    Note over BG: Finalisation
    BG->>Notifier: Completed(taskId, "Terminé", "100 éléments traités")
    Notifier->>Service: ProcessNotificationAsync(...)
    Service->>Service: task.Status = Completed
    Service->>Container: OnChange Event
    Container-->>User: ✅ "Terminé - 100 éléments traités"
    deactivate BG

    User->>Container: Clic sur "Fermer"
    Container->>Service: RemoveTaskAsync(taskId)
    Service->>Container: OnChange Event
    Container-->>User: Notification disparue

Ce composant doit etre séparé en 2 assembiles

Une assembly UI qui permet réellement les notifications pour l'utilisateur, et une assembly Abstraction qui n'expose que l'interface ILongTaskNotification<T>

Le backend doit reférencer l'abstraction tandis que l'UI reférence le composant graphique.

2. Ajout du composant dans le layout

Dans MainLayout.razor, il faut ajoutez le composant container :

@using LongTaskNotifier.Components
@inherits LayoutComponentBase

<div class="page">
    <main>
        <article class="content">
            @Body
        </article>
    </main>
</div>

<!-- Container de notifications en bas à droite -->
<LongTaskContainer Position="LongTaskPosition.BottomRight" MaxLogEntries="100" />

Par exemple dans un service on lance une longue tache :

public class MyService(ILongTaskNotification<MyService> notifier)
{
    public async Task MyLongProcess()
    {
        var taskId = Guid.NewGuid(); // <- peut etre utilisé pour savoir s'il n'y a pas déjà un traitement en cours
        // Créer un CancellationTokenSource pour permettre l'annulation
        var cts = new CancellationTokenSource();
        notifier.RegisterCancellationTokenSource(taskId, cts);
        
        // Lancer la tâche en arrière-plan
        _ = Task.Run(async () => await ExecuteInternal(taskId, cts.Token));
    }

    public async Task ExecuteInternal(Guid taskId, CancellationToken cancellationToken = default)
    {
        try
        {
            // 1. Démarrage
            await notifier.Start(
                taskId, 
                "Import de données", 
                "Début du traitement"
            );

            // 2. Phase de chargement (barre continue)
            await notifier.StartContinuousProgress(
                taskId, 
                "loading", 
                "Chargement du fichier"
            );
            
            // Simuler le chargement
            await Task.Delay(3000, cancellationToken);
            
            await notifier.EndContinuousProgress(taskId, "loading");

            // 3. Phase de traitement (barre déterminée)
            var items = GetItemsToProcess(); // 500 éléments
            await notifier.StartProgress(
                taskId, 
                "processing", 
                "Traitement des données", 
                items.Count
            );

            for (int i = 0; i < items.Count; i++)
            {
                // Vérifier l'annulation
                cancellationToken.ThrowIfCancellationRequested();
                
                // Traiter l'élément
                var result = await ProcessItemAsync(items[i]);
                
                // Mettre à jour la progression
                await notifier.Progress(
                    taskId, 
                    "processing", 
                    $"Traitement de {items[i].Name}", 
                    i + 1
                );

                // Gérer les cas spéciaux
                if (result.HasWarning)
                {
                    await notifier.Warning(
                        taskId, 
                        "Données incomplètes", 
                        $"L'élément {items[i].Name} contient des données manquantes"
                    );
                }

                if (result.HasError)
                {
                    await LongTaskNotifier.Error(
                        taskId, 
                        "Erreur de validation", 
                        $"L'élément {items[i].Name} ne respecte pas les règles de validation"
                    );
                }

                // Petite pause pour éviter de surcharger le système
                await Task.Delay(50, cancellationToken);
            }

            await notifier.EndProgress(taskId, "processing");

            // 4. Phase de finalisation
            await notifier.StartProgress(
                taskId, 
                "finalize", 
                "Finalisation", 
                3
            );

            await notifier.Progress(taskId, "finalize", "Sauvegarde en base", 1);
            await SaveToDatabase();
            await Task.Delay(1000, cancellationToken);

            await notifier.Progress(taskId, "finalize", "Mise à jour des index", 2);
            await UpdateIndexes();
            await Task.Delay(1000, cancellationToken);

            await notifier.Progress(taskId, "finalize", "Notification des utilisateurs", 3);
            await NotifyUsers();
            await Task.Delay(1000, cancellationToken);

            await notifier.EndProgress(taskId, "finalize");

            // 5. Complétion
            await notifier.Completed(
                taskId, 
                "Import terminé avec succès", 
                $"{items.Count} éléments importés"
            );
        }
        catch (OperationCanceledException)
        {
            // L'utilisateur a annulé la tâche
            await notifier.Canceled(taskId);
        }
        catch (Exception ex)
        {
            // Erreur fatale
            await notifier.Failed(
                taskId, 
                $"Erreur lors de l'import : {ex.Message}"
            );
        }
        finally
        {
            _isRunning = false;
            await InvokeAsync(StateHasChanged);
        }
    }
}

Voici un exemple de ce que ça donne comme affichage :

Image

Annulation de tâches longues avec CancellationTokenSource

Pourquoi l'annulation est cruciale ?

L'annulation d'une tâche longue est une fonctionnalité essentielle pour plusieurs raisons :

  1. Correction d'erreur : L'utilisateur réalise qu'il a lancé la mauvaise opération
  2. Attente trop longue : L'opération prend plus de temps que prévu
  3. Modification des priorités : Une tâche plus urgente doit être effectuée
  4. Économie de ressources : Libérer des ressources serveur pour d'autres traitements

Diagramme de séquence - Annulation d'une tâche

sequenceDiagram
    actor User as Utilisateur
    participant Container as LongTaskContainer
    participant Service as LongTaskContainerService
    participant BG as Tâche Background
    participant Notifier as LongTaskNotifier

    Note over User,Notifier: Tâche en cours d'exécution...
    
    User->>Container: Clic sur bouton "Annuler"
    Container->>Service: CancelTaskAsync(taskId)
    activate Service
    Service->>Service: task.CancellationTokenSource.CancelAsync()
    Service->>Service: task.Status = Canceled
    Service->>Service: AddLogEntry("Arrêt demandé")
    Service->>Container: OnChange Event
    Container-->>User: 🚫 "Arrêt en cours..."
    deactivate Service

    Note over BG: Dans la boucle de traitement
    BG->>BG: cancellationToken.ThrowIfCancellationRequested()
    BG->>BG: OperationCanceledException lancée
    
    activate BG
    BG->>Notifier: Canceled(taskId)
    Notifier->>Service: ProcessNotificationAsync(LongTaskCanceled)
    Service->>Service: task.Status = Canceled
    Service->>Container: OnChange Event
    Container-->>User: 🚫 "Tâche annulée"
    deactivate BG

    Note over User: L'utilisateur peut fermer la notification

Conclusion

Avoir un système d'affichage de la progression des très longues tâches comme LongTaskNotifier pour Blazor Server offre une solution complète et professionnelle pour gérer les notifications de tâches longues. Ses points forts :

Transparence totale : L'utilisateur sait toujours ce qui se passe
Expérience utilisateur optimale : Barres de progression, messages, warnings
Contrôle : Annulation possible à tout moment
Performance : Optimisé pour les tâches très longues
Flexibilité : Multiples barres de progression, positionnement configurable
Production-ready : Gestion complète des erreurs et des cas limites

En implémentant ce système dans vos applications Blazor, vous transformerez l'expérience utilisateur lors d'opérations longues, passant d'un état d'incertitude et d'attente anxieuse à une expérience transparente, informative et contrôlable. N'hésitez pas pour nous contacter

Ressources