Skip to content

Commit 504a51d

Browse files
committed
Add Avalonia in-app log viewer & mediator workflow refactor
Introduce in-app log viewer for Avalonia UI using a custom logger provider and observable log store, with UI integration and clear functionality. Refactor WorkflowActionExecutor to use mediator-based dispatch by default, supporting handler scoping and execution time logging. Improve error handling in workflow actions. Update DI registrations, tests, and solution launch profile to support these changes. Add Avalonia log viewer UI and mediator workflow refactor Introduce AvaloniaLogStore and LogViewerControl for real-time log viewing in the mobile app UI. Refactor workflow action execution to use a mediator-based pattern with improved logging, error handling, and handler scope options. Update service registration and workflow tests for mediator compatibility. Add unit tests for new logging components. Update solution launch config for Android and app host.
1 parent ec65255 commit 504a51d

25 files changed

+530
-45
lines changed

FunWasHad.slnLaunch

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
"Name": "Aspire + Android",
44
"Projects": [
55
{
6-
"Name": "src/FWH.AppHost/FWH.AppHost.csproj",
6+
"Path": "src\\FWH.Mobile\\FWH.Mobile.Android\\FWH.Mobile.Android.csproj",
77
"Action": "Start",
8-
"DebugTarget": "http"
8+
"DebugTarget": "7.6 Fold-in with outer display API 33 (Android 13.0 - API 33)"
99
},
1010
{
11-
"Name": "src/FWH.Mobile/FWH.Mobile.Android/FWH.Mobile.Android.csproj",
12-
"Action": "Start"
11+
"Path": "src\\FWH.AppHost\\FWH.AppHost.csproj",
12+
"Action": "Start",
13+
"DebugTarget": "http"
1314
}
1415
]
1516
}
16-
]
17+
]

src/FWH.Common.Workflow/Actions/WorkflowActionExecutor.cs

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,23 @@ public class WorkflowActionExecutor : IWorkflowActionExecutor
3131
private readonly IMediatorSender? _mediator;
3232
private readonly WorkflowActionExecutorOptions _options;
3333

34-
public WorkflowActionExecutor(IServiceProvider serviceProvider, IWorkflowActionHandlerRegistry registry, IOptions<WorkflowActionExecutorOptions>? options = null, ILogger<WorkflowActionExecutor>? logger = null)
35-
{
36-
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
37-
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
38-
_mediator = null;
39-
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<WorkflowActionExecutor>.Instance;
40-
_options = options?.Value ?? new WorkflowActionExecutorOptions();
41-
42-
// Eagerly trigger registrar if available so handlers registered as singletons get wired when DI builds
43-
var registrar = _serviceProvider.GetService<WorkflowActionHandlerRegistrar>();
44-
// registrar's constructor performs registration
45-
}
46-
47-
public WorkflowActionExecutor(IServiceProvider serviceProvider, IMediatorSender mediator, IOptions<WorkflowActionExecutorOptions>? options = null, ILogger<WorkflowActionExecutor>? logger = null)
34+
//public WorkflowActionExecutor(IServiceProvider serviceProvider, IWorkflowActionHandlerRegistry registry, IOptions<WorkflowActionExecutorOptions>? options = null, ILogger<WorkflowActionExecutor>? logger = null)
35+
//{
36+
// _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
37+
// _registry = registry ?? throw new ArgumentNullException(nameof(registry));
38+
// _mediator = null;
39+
// _logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<WorkflowActionExecutor>.Instance;
40+
// _options = options?.Value ?? new WorkflowActionExecutorOptions();
41+
42+
// // Eagerly trigger registrar if available so handlers registered as singletons get wired when DI builds
43+
// var registrar = _serviceProvider.GetService<WorkflowActionHandlerRegistrar>();
44+
// // registrar's constructor performs registration
45+
//}
46+
47+
public WorkflowActionExecutor(IServiceProvider serviceProvider,
48+
IMediatorSender mediator,
49+
IOptions<WorkflowActionExecutorOptions>? options = null,
50+
ILogger<WorkflowActionExecutor>? logger = null)
4851
{
4952
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
5053
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
@@ -115,15 +118,24 @@ public async Task<bool> ExecuteAsync(string workflowId, WorkflowNode node, Workf
115118
{
116119
try
117120
{
121+
var sw = System.Diagnostics.Stopwatch.StartNew();
118122
var response = await _mediator.SendAsync(new WorkflowActionRequest
119123
{
120124
WorkflowId = workflowId,
121125
Node = node,
122126
Definition = definition,
123127
ActionName = actionName,
124-
Parameters = resolved
128+
Parameters = resolved,
129+
CreateScopeForHandlers = _options.CreateScopeForHandlers,
130+
LogExecutionTime = _options.LogExecutionTime
125131
}, cancellationToken).ConfigureAwait(false);
126132

133+
sw.Stop();
134+
if (_options.LogExecutionTime)
135+
{
136+
_logger.LogInformation("Action {ActionName} handled by mediator in {ElapsedMs}ms", actionName, sw.ElapsedMilliseconds);
137+
}
138+
127139
if (response.Success && response.VariableUpdates != null && rootInstanceManager != null)
128140
{
129141
foreach (var update in response.VariableUpdates)
@@ -138,6 +150,12 @@ public async Task<bool> ExecuteAsync(string workflowId, WorkflowNode node, Workf
138150
{
139151
return false;
140152
}
153+
catch (Exception ex)
154+
{
155+
_logger.LogError(ex, "Action {ActionName} execution failed via mediator", actionName);
156+
// Return false but don't throw - allows workflow to continue
157+
return false;
158+
}
141159
}
142160

143161
// Prefer any singleton handlers registered directly in DI (common for delegate adapters)

src/FWH.Common.Workflow/Actions/WorkflowActionRequest.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ public sealed class WorkflowActionRequest : IMediatorRequest<WorkflowActionRespo
1010
public WorkflowDefinition Definition { get; set; } = null!;
1111
public string ActionName { get; set; } = string.Empty;
1212
public IDictionary<string, string> Parameters { get; set; } = new Dictionary<string, string>();
13+
public bool CreateScopeForHandlers { get; set; } = true;
14+
public bool LogExecutionTime { get; set; } = false;
1315
}
1416

1517
public sealed class WorkflowActionResponse

src/FWH.Common.Workflow/Actions/WorkflowActionRequestHandler.cs

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,33 +42,75 @@ public async Task<WorkflowActionResponse> HandleAsync(WorkflowActionRequest requ
4242

4343
try
4444
{
45-
using var scope = _serviceProvider.CreateScope();
46-
var handler = factory(scope.ServiceProvider);
47-
if (handler is null)
45+
IDictionary<string, string>? updates;
46+
System.Diagnostics.Stopwatch? sw = null;
47+
48+
if (request.LogExecutionTime)
4849
{
49-
_logger.LogWarning("Handler factory returned null for action {ActionName}", request.ActionName);
50-
return new WorkflowActionResponse { Success = false, ErrorMessage = $"Handler factory returned null for {request.ActionName}" };
50+
sw = System.Diagnostics.Stopwatch.StartNew();
5151
}
5252

53-
var instanceManager = scope.ServiceProvider.GetService<IWorkflowInstanceManager>();
54-
if (instanceManager is null)
53+
if (request.CreateScopeForHandlers)
5554
{
56-
throw new InvalidOperationException("InstanceManager required in workflow action scope.");
55+
using var scope = _serviceProvider.CreateScope();
56+
var handler = factory(scope.ServiceProvider);
57+
if (handler is null)
58+
{
59+
_logger.LogWarning("Handler factory returned null for action {ActionName}", request.ActionName);
60+
return new WorkflowActionResponse { Success = false, ErrorMessage = $"Handler factory returned null for {request.ActionName}" };
61+
}
62+
63+
var instanceManager = scope.ServiceProvider.GetService<IWorkflowInstanceManager>();
64+
if (instanceManager is null)
65+
{
66+
throw new InvalidOperationException("InstanceManager required in workflow action scope.");
67+
}
68+
69+
var ctx = new ActionHandlerContext(request.WorkflowId, request.Node, request.Definition, instanceManager);
70+
updates = await handler.HandleAsync(ctx, request.Parameters, cancellationToken).ConfigureAwait(false);
5771
}
72+
else
73+
{
74+
// Use root service provider without creating a scope
75+
var handler = factory(_serviceProvider);
76+
if (handler is null)
77+
{
78+
_logger.LogWarning("Handler factory returned null for action {ActionName}", request.ActionName);
79+
return new WorkflowActionResponse { Success = false, ErrorMessage = $"Handler factory returned null for {request.ActionName}" };
80+
}
81+
82+
var instanceManager = _serviceProvider.GetService<IWorkflowInstanceManager>();
83+
if (instanceManager is null)
84+
{
85+
throw new InvalidOperationException("InstanceManager required in workflow action scope.");
86+
}
5887

59-
var ctx = new ActionHandlerContext(request.WorkflowId, request.Node, request.Definition, instanceManager);
60-
var updates = await handler.HandleAsync(ctx, request.Parameters, cancellationToken).ConfigureAwait(false);
88+
var ctx = new ActionHandlerContext(request.WorkflowId, request.Node, request.Definition, instanceManager);
89+
updates = await handler.HandleAsync(ctx, request.Parameters, cancellationToken).ConfigureAwait(false);
90+
}
91+
92+
if (sw != null)
93+
{
94+
sw.Stop();
95+
_logger.LogInformation("Action {ActionName} handled by handler in {ElapsedMs}ms", request.ActionName, sw.ElapsedMilliseconds);
96+
}
6197

6298
return new WorkflowActionResponse
6399
{
64100
Success = true,
65101
VariableUpdates = updates
66102
};
67103
}
68-
catch (InvalidOperationException ex)
104+
catch (OperationCanceledException)
105+
{
106+
_logger.LogInformation("Action {ActionName} execution cancelled", request.ActionName);
107+
return new WorkflowActionResponse { Success = false, ErrorMessage = "Action execution was cancelled" };
108+
}
109+
catch (Exception ex)
69110
{
70-
_logger.LogError(ex, "Invalid operation executing action {ActionName}", request.ActionName);
71-
return new WorkflowActionResponse { Success = false, ErrorMessage = ex.Message };
111+
_logger.LogError(ex, "Action handler for {ActionName} threw an exception", request.ActionName);
112+
// Return success=true even on exception - allows workflow to continue (matches direct handler path behavior)
113+
return new WorkflowActionResponse { Success = true, VariableUpdates = null };
72114
}
73115
}
74116
}

src/FWH.Common.Workflow/Extensions/WorkflowServiceCollectionExtensions.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ public static IServiceCollection AddWorkflowServices(this IServiceCollection ser
4444
services.AddSingleton<WorkflowActionHandlerRegistrar>();
4545

4646
var opts = executorOptions ?? new WorkflowActionExecutorOptions();
47-
services.AddSingleton<IWorkflowActionExecutor>(sp => new WorkflowActionExecutor(sp, sp.GetRequiredService<IWorkflowActionHandlerRegistry>(), Options.Create(opts), sp.GetService<Microsoft.Extensions.Logging.ILogger<WorkflowActionExecutor>>()));
47+
services.AddSingleton<IWorkflowActionExecutor>(sp => new WorkflowActionExecutor(sp,
48+
sp.GetRequiredService<IMediatorSender>(),
49+
Options.Create(opts),
50+
sp.GetService<Microsoft.Extensions.Logging.ILogger<WorkflowActionExecutor>>()));
4851

4952
// Controller and service
5053
services.AddSingleton<IWorkflowController, WorkflowController>();

src/FWH.Mobile/FWH.Mobile/App.axaml.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,15 @@ public partial class App : Application
3939
static App()
4040
{
4141
var services = new ServiceCollection();
42-
services.AddLogging();
42+
43+
// Register log store and configure logging to use Avalonia logger
44+
var logStore = new FWH.Mobile.Logging.AvaloniaLogStore(maxEntries: 1000);
45+
services.AddSingleton(logStore);
46+
47+
services.AddLogging(builder =>
48+
{
49+
builder.AddProvider(new FWH.Mobile.Logging.AvaloniaLoggerProvider(logStore));
50+
});
4351

4452
// Note: Service discovery is available when Microsoft.Extensions.ServiceDiscovery package is added
4553
// For now, using direct URL configuration for mobile app
@@ -59,6 +67,7 @@ static App()
5967
// Requires IPlatformService from AddChatServices() to be registered first
6068
services.AddLocationServices();
6169

70+
6271
// Register MediatR handlers for remote API calls
6372
services.AddRemoteMediatorHandlers();
6473

@@ -136,10 +145,14 @@ static App()
136145
// Register camera capture ViewModel
137146
services.AddSingleton<CameraCaptureViewModel>();
138147

148+
// Register log viewer ViewModel
149+
services.AddSingleton<LogViewerViewModel>();
150+
139151
ServiceProvider = services.BuildServiceProvider();
140152

141153
// Database initialization deferred to OnFrameworkInitializationCompleted
142154
// to avoid blocking the UI thread during app startup
155+
143156
}
144157

145158
public static IServiceProvider ServiceProvider { get; }
@@ -266,10 +279,12 @@ public override async void OnFrameworkInitializationCompleted()
266279
await InitializeWorkflowAsync();
267280

268281
var chatViewModel = ServiceProvider.GetRequiredService<ChatViewModel>();
282+
var logViewerViewModel = ServiceProvider.GetRequiredService<LogViewerViewModel>();
269283

270284
var mainWindow = new MainWindow
271285
{
272286
DataContext = chatViewModel,
287+
Tag = logViewerViewModel, // Pass log viewer ViewModel via Tag
273288
Width = 800,
274289
Height = 600,
275290
Title = "Fun Was Had"
@@ -280,6 +295,7 @@ public override async void OnFrameworkInitializationCompleted()
280295

281296
// Start location tracking on desktop
282297
await StartLocationTrackingAsync();
298+
283299
}
284300
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform)
285301
{
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using System;
2+
using Microsoft.Extensions.Logging;
3+
4+
namespace FWH.Mobile.Logging;
5+
6+
public sealed record AvaloniaLogEntry(
7+
DateTimeOffset TimestampUtc,
8+
LogLevel Level,
9+
string Category,
10+
EventId EventId,
11+
string Message,
12+
Exception? Exception)
13+
{
14+
public string LevelShort => Level switch
15+
{
16+
LogLevel.Trace => "TRC",
17+
LogLevel.Debug => "DBG",
18+
LogLevel.Information => "INF",
19+
LogLevel.Warning => "WRN",
20+
LogLevel.Error => "ERR",
21+
LogLevel.Critical => "CRT",
22+
_ => Level.ToString()
23+
};
24+
}
25+
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using System;
2+
using System.Collections.ObjectModel;
3+
using Avalonia.Threading;
4+
5+
namespace FWH.Mobile.Logging;
6+
7+
public sealed class AvaloniaLogStore
8+
{
9+
private readonly int _maxEntries;
10+
11+
public AvaloniaLogStore(int maxEntries = 1000)
12+
{
13+
if (maxEntries <= 0)
14+
throw new ArgumentOutOfRangeException(nameof(maxEntries));
15+
16+
_maxEntries = maxEntries;
17+
}
18+
19+
public ObservableCollection<AvaloniaLogEntry> Entries { get; } = new();
20+
21+
public void Add(AvaloniaLogEntry entry)
22+
{
23+
ArgumentNullException.ThrowIfNull(entry);
24+
25+
if (Dispatcher.UIThread.CheckAccess())
26+
{
27+
AddOnUiThread(entry);
28+
return;
29+
}
30+
31+
Dispatcher.UIThread.Post(() => AddOnUiThread(entry));
32+
}
33+
34+
private void AddOnUiThread(AvaloniaLogEntry entry)
35+
{
36+
Entries.Add(entry);
37+
while (Entries.Count > _maxEntries)
38+
Entries.RemoveAt(0);
39+
}
40+
41+
public void Clear()
42+
{
43+
if (Dispatcher.UIThread.CheckAccess())
44+
{
45+
Entries.Clear();
46+
return;
47+
}
48+
49+
Dispatcher.UIThread.Post(() => Entries.Clear());
50+
}
51+
}
52+

0 commit comments

Comments
 (0)