Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 11 additions & 12 deletions src/EventLogExpert.Components/Modals/DebugLogModal.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
@using EventLogExpert.UI
@using Microsoft.Extensions.Logging
@inherits ModalBase<bool>

Expand All @@ -19,9 +18,9 @@
<ValueSelect AriaLabel="Level operator"
CssClass="input filter-dropdown"
T="FilterEvaluator"
ToStringFunc="@(x => x.ToFullString())"
Value="_levelOperator"
ValueChanged="HandleLevelOperatorChanged"
ToStringFunc="@(x => x.ToFullString())">
ValueChanged="HandleLevelOperatorChanged">
<ValueSelectItem T="FilterEvaluator" Value="FilterEvaluator.Equals" />
<ValueSelectItem T="FilterEvaluator" Value="FilterEvaluator.NotEqual" />
<ValueSelectItem T="FilterEvaluator" Value="FilterEvaluator.MultiSelect" />
Expand All @@ -33,10 +32,10 @@
EmptyText="All"
IsMultiSelect
T="LogLevel"
ToStringFunc="@(x => x.ToString())"
Values="_multiLevels"
ValuesChanged="HandleMultiLevelsChanged"
ToStringFunc="@(x => x.ToString())">
<ValueSelectItem T="LogLevel" ClearItem>All</ValueSelectItem>
ValuesChanged="HandleMultiLevelsChanged">
<ValueSelectItem ClearItem T="LogLevel">All</ValueSelectItem>
@foreach (var level in s_logLevels)
{
<ValueSelectItem T="LogLevel" Value="level" />
Expand All @@ -48,27 +47,27 @@
<ValueSelect AriaLabel="Level"
CssClass="input filter-dropdown"
T="LogLevel?"
ToStringFunc="@(x => x?.ToString() ?? "All")"
Value="_singleLevel"
ValueChanged="HandleSingleLevelChanged"
ToStringFunc="@(x => x?.ToString() ?? "All")">
<ValueSelectItem T="LogLevel?" ClearItem>All</ValueSelectItem>
ValueChanged="HandleSingleLevelChanged">
<ValueSelectItem ClearItem T="LogLevel?">All</ValueSelectItem>
@foreach (var level in s_logLevels)
{
<ValueSelectItem T="LogLevel?" Value="level" />
}
</ValueSelect>
}
<input aria-label="Filter messages"
class="input debug-log-text-filter"
class="debug-log-text-filter input"
@oninput="HandleStringFilterInput"
placeholder="Filter messages..."
type="text"
value="@_pendingStringFilter" />
</div>
<div aria-busy="@(_hasLoaded ? "false" : "true")" aria-label="Debug log entries" class="debug-log-viewport" role="region" tabindex="0">
<Virtualize Items="_displayedView" Context="line" ItemSize="@RowHeightPx" OverscanCount="20">
<Virtualize Context="line" Items="_displayedView" ItemSize="@RowHeightPx" OverscanCount="20">
<ItemContent>
<div class="debug-log-row">@line</div>
<div class="debug-log-row" title="@line">@line</div>
</ItemContent>
<EmptyContent>
@if (_hasLoaded)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@
min-width: 0;
flex: 1 1 auto;
min-height: 0;
overflow-x: auto;
overflow-x: hidden;
overflow-y: auto;
}

.debug-log-row {
box-sizing: border-box;
width: 100%;
height: 18px;
padding: 0 0.25rem;
overflow: hidden;
text-overflow: ellipsis;

font-family: monospace;
line-height: 18px;
Expand Down
14 changes: 5 additions & 9 deletions src/EventLogExpert.UI/EventLog/Effects.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ public sealed class Effects(
private readonly IFilterService _filterService = filterService;
private readonly Lock _globalCtsLock = new();
private readonly ConcurrentDictionary<EventLogId, TaskCompletionSource> _logCloseCompletions = new();

/// <summary>
/// Serializes <see cref="HandleSetFilters" />'s XML-reload path with
/// <see cref="PrepareForDatabaseRemovalAsync" />. Both write into <see cref="_logCloseCompletions" /> with raw
Expand All @@ -63,26 +62,22 @@ public sealed class Effects(
/// it.
/// </summary>
private readonly SemaphoreSlim _logCloseCoordinatorLock = new(1, 1);

private readonly ConcurrentDictionary<EventLogId, CancellationTokenSource> _logCts = new();

private readonly ITraceLogger _logger = logger;
/// <summary>
/// Tracks per-log load completion so <see cref="HandleCloseLog" /> can wait for the in-flight
/// <see cref="LoadLogAsync" /> service scope to dispose before draining the watcher. Without this, cts.Cancel() merely
/// requests cancellation — LoadLogAsync's service scope (which owns the IEventResolver and its SQLite handles) only
/// disposes once HandleOpenLog's outer using/finally runs.
/// </summary>
private readonly ConcurrentDictionary<EventLogId, TaskCompletionSource> _logLoadCompletions = new();
private readonly ILogWatcherService _logWatcherService = logWatcherService;
private readonly ITraceLogger _logger = logger;

/// <summary>
/// Tracks which currently-open logs (by <see cref="EventLogData.Id" />) were loaded with renderXml=true. A
/// reload-on-transition only re-opens logs that lack XML; logs that already have it are left alone. Removing or
/// disabling an XML filter never triggers a reload because the XML data is already in memory and harmless to keep.
/// </summary>
private readonly ConcurrentDictionary<EventLogId, byte> _logsLoadedWithXml = new();

private readonly ILogWatcherService _logWatcherService = logWatcherService;
/// <summary>
/// Pending selection restore per log name, populated when a filter transition forces a reload. Consumed by
/// <see cref="HandleLoadEvents" /> when the reloaded log finishes loading. Carries both the selected record-ids and
Expand Down Expand Up @@ -146,15 +141,16 @@ public async Task HandleCloseAll(IDispatcher dispatcher)
{
CancelAllLoads();

await _logWatcherService.RemoveAllAsync();

// Sync prefix: subsequent OpenLogAction must see cleared LogTable.
dispatcher.Dispatch(new LogTable.CloseAllAction());
dispatcher.Dispatch(new StatusBar.CloseAllAction());

_resolverCache.ClearAll();
_xmlResolver.ClearAll();
_logsLoadedWithXml.Clear();
_pendingSelectionRestore.Clear();

await _logWatcherService.RemoveAllAsync();
}

[EffectMethod]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,26 @@ await component.WaitForAssertionAsync(() =>
Assert.Equal("true", counter.GetAttribute("aria-atomic"));
}

[Fact]
public async Task DebugLogModal_AfterLoad_RowsCarryTitleAttributeMirroringText()
{
var firstHeader = DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogFirstMessage);
var secondHeader = DebugLogUtils.BuildLine(LogLevel.Information, Constants.DebugLogSecondMessage);

_fileLogger.LoadAsync(Arg.Any<CancellationToken>()).Returns(
DebugLogUtils.ToAsyncEnumerable([firstHeader, secondHeader]));

var component = Render<DebugLogModal>();

await component.WaitForAssertionAsync(() =>
Assert.Equal("2 of 2 entries", component.Find(".debug-log-footer-counter").TextContent.Trim()));

var rows = component.FindAll(".debug-log-row");
Assert.Equal(
rows.Select(row => row.TextContent).ToArray(),
rows.Select(row => row.GetAttribute("title")).ToArray());
}

[Fact]
public async Task DebugLogModal_AfterLoad_ViewportHasRegionRoleWithoutAriaLiveAndIsKeyboardFocusable()
{
Expand Down
124 changes: 73 additions & 51 deletions tests/Unit/EventLogExpert.UI.Tests/EventLog/EffectsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,28 @@ public async Task HandleAddEvent_WhenLogNotActive_ShouldNotDispatchActions()
mockDispatcher.DidNotReceive().Dispatch(Arg.Any<object>());
}

[Fact]
public async Task HandleCloseAll_DispatchesStateClearsBeforeWatcherDrain()
{
var (effects, mockDispatcher, mockLogWatcher, mockResolverCache, _) = CreateEffectsWithServices();

var watcherTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
mockLogWatcher.RemoveAllAsync().Returns(watcherTcs.Task);

var closeTask = effects.HandleCloseAll(mockDispatcher);

Assert.False(closeTask.IsCompleted, "HandleCloseAll must still be awaiting RemoveAllAsync.");
mockDispatcher.Received(1).Dispatch(Arg.Any<CloseAllAction>());
mockDispatcher.Received(1).Dispatch(Arg.Any<UI.StatusBar.CloseAllAction>());
mockResolverCache.Received(1).ClearAll();

watcherTcs.SetResult();
await closeTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);

Assert.True(closeTask.IsCompletedSuccessfully);
await mockLogWatcher.Received(1).RemoveAllAsync();
}

[Fact]
public async Task HandleCloseAll_ShouldClearAllResolvedXml()
{
Expand Down Expand Up @@ -688,7 +710,7 @@ public async Task HandleSetFilters_FilterBranch_ShouldDispatchSetIsLoadingTrueTh

var (effects, mockDispatcher, _, _, _) = CreateEffectsWithServices(activeLogs: activeLogs);

var filter = FilterUtils.CreateTestFilter(Constants.FilterIdEquals100, isEnabled: true);
var filter = FilterUtils.CreateTestFilter(isEnabled: true);
var action = new SetFiltersAction(new EventFilter(null, [filter]));

await effects.HandleSetFilters(action, mockDispatcher);
Expand All @@ -714,7 +736,7 @@ public async Task HandleSetFilters_FilterBranch_WhenFilterServiceThrows_ShouldSt
.When(x => x.FilterActiveLogs(Arg.Any<IEnumerable<EventLogData>>(), Arg.Any<EventFilter>()))
.Do(_ => throw new InvalidOperationException("boom"));

var filter = FilterUtils.CreateTestFilter(Constants.FilterIdEquals100, isEnabled: true);
var filter = FilterUtils.CreateTestFilter(isEnabled: true);
var action = new SetFiltersAction(new EventFilter(null, [filter]));

await Assert.ThrowsAsync<InvalidOperationException>(
Expand Down Expand Up @@ -763,7 +785,7 @@ public async Task HandleSetFilters_FilterBranch_WhenLogClosedDuringFilter_Should
return filterResult;
});

var nonXmlFilter = FilterUtils.CreateTestFilter(Constants.FilterIdEquals100, isEnabled: true);
var nonXmlFilter = FilterUtils.CreateTestFilter(isEnabled: true);
var action = new SetFiltersAction(new EventFilter(null, [nonXmlFilter]));

// Act
Expand Down Expand Up @@ -827,7 +849,7 @@ public async Task HandleSetFilters_FilterBranch_WhenLogEventsChangeDuringFilter_
},
_ => pass2Result);

var nonXmlFilter = FilterUtils.CreateTestFilter(Constants.FilterIdEquals100, isEnabled: true);
var nonXmlFilter = FilterUtils.CreateTestFilter(isEnabled: true);
var action = new SetFiltersAction(new EventFilter(null, [nonXmlFilter]));

// Act
Expand Down Expand Up @@ -914,7 +936,7 @@ public async Task HandleSetFilters_FilterBranch_WhenLogStillStaleAfterRetry_Shou
return pass2Result;
});

var nonXmlFilter = FilterUtils.CreateTestFilter(Constants.FilterIdEquals100, isEnabled: true);
var nonXmlFilter = FilterUtils.CreateTestFilter(isEnabled: true);
var action = new SetFiltersAction(new EventFilter(null, [nonXmlFilter]));

// Act
Expand Down Expand Up @@ -952,7 +974,7 @@ public async Task HandleSetFilters_FilterBranch_WhenSupersededByNewerFilter_Shou
var staleStarted = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);

var staleFilterModel = FilterUtils.CreateTestFilter(Constants.FilterIdEquals999, isEnabled: true);
var freshFilterModel = FilterUtils.CreateTestFilter(Constants.FilterIdEquals100, isEnabled: true);
var freshFilterModel = FilterUtils.CreateTestFilter(isEnabled: true);

var staleFilter = new EventFilter(null, [staleFilterModel]);
var freshFilter = new EventFilter(null, [freshFilterModel]);
Expand Down Expand Up @@ -1053,7 +1075,7 @@ public async Task HandleSetFilters_WhenFilterDoesNotRequireXml_ShouldNotReloadLo

var (effects, mockDispatcher, _, _, _) = CreateEffectsWithServices(activeLogs: activeLogs);

var nonXmlFilter = FilterUtils.CreateTestFilter(Constants.FilterIdEquals100, isEnabled: true);
var nonXmlFilter = FilterUtils.CreateTestFilter(isEnabled: true);
var eventFilter = new EventFilter(null, [nonXmlFilter]);
var action = new SetFiltersAction(eventFilter);

Expand All @@ -1067,50 +1089,6 @@ public async Task HandleSetFilters_WhenFilterDoesNotRequireXml_ShouldNotReloadLo
mockDispatcher.Received(1).Dispatch(Arg.Any<UpdateDisplayedEventsAction>());
}

[Fact]
public async Task HandleSetFilters_WhenFilterRequiresXmlAndLogLacksXml_ShouldCloseAndReopenLog()
{
// Arrange — active log has not been loaded with XML, so it must be re-read.
var logData = new EventLogData(Constants.LogNameTestLog, LogPathType.Channel, []);
var activeLogs = ImmutableDictionary<string, EventLogData>.Empty.Add(Constants.LogNameTestLog, logData);

var (effects, mockDispatcher, _, _, _) = CreateEffectsWithServices(activeLogs: activeLogs);

// Route CloseLog → HandleCloseLog so HandleSetFilters' await on the close-completion
// TCS resolves quickly (otherwise it hits LogCloseTimeout, 30s). Capture the routed
// tasks so any fault in HandleCloseLog surfaces at the end of the test instead of
// being swallowed by the discard.
var closeTasks = new List<Task>();
mockDispatcher
.When(d => d.Dispatch(Arg.Any<CloseLogAction>()))
.Do(callInfo =>
{
closeTasks.Add(effects.HandleCloseLog(callInfo.Arg<CloseLogAction>(), mockDispatcher));
});

var xmlFilter = FilterUtils.CreateTestFilter(Constants.FilterXmlContainsData, isEnabled: true);
var eventFilter = new EventFilter(null, [xmlFilter]);
var action = new SetFiltersAction(eventFilter);

// Act
await effects.HandleSetFilters(action, mockDispatcher);

// Assert
Assert.True(eventFilter.RequiresXml);

mockDispatcher.Received(1).Dispatch(Arg.Is<CloseLogAction>(a =>
a.LogName == Constants.LogNameTestLog && a.LogId == logData.Id));

mockDispatcher.Received(1).Dispatch(Arg.Is<OpenLogAction>(a =>
a.LogName == Constants.LogNameTestLog && a.LogPathType == LogPathType.Channel));

// Reload path returns early — no UpdateDisplayedEvents until LoadEvents fires.
mockDispatcher.DidNotReceive().Dispatch(Arg.Any<UpdateDisplayedEventsAction>());

// Surface any HandleCloseLog faults before exiting the test.
await Task.WhenAll(closeTasks);
}

[Fact]
public async Task HandleSetFilters_WhenFilterRequiresXml_AwaitsCloseCompletionBeforeReturning()
{
Expand Down Expand Up @@ -1245,6 +1223,50 @@ public async Task HandleSetFilters_WhenFilterRequiresXml_ShouldRestoreSelectionA
await Task.WhenAll(closeTasks);
}

[Fact]
public async Task HandleSetFilters_WhenFilterRequiresXmlAndLogLacksXml_ShouldCloseAndReopenLog()
{
// Arrange — active log has not been loaded with XML, so it must be re-read.
var logData = new EventLogData(Constants.LogNameTestLog, LogPathType.Channel, []);
var activeLogs = ImmutableDictionary<string, EventLogData>.Empty.Add(Constants.LogNameTestLog, logData);

var (effects, mockDispatcher, _, _, _) = CreateEffectsWithServices(activeLogs: activeLogs);

// Route CloseLog → HandleCloseLog so HandleSetFilters' await on the close-completion
// TCS resolves quickly (otherwise it hits LogCloseTimeout, 30s). Capture the routed
// tasks so any fault in HandleCloseLog surfaces at the end of the test instead of
// being swallowed by the discard.
var closeTasks = new List<Task>();
mockDispatcher
.When(d => d.Dispatch(Arg.Any<CloseLogAction>()))
.Do(callInfo =>
{
closeTasks.Add(effects.HandleCloseLog(callInfo.Arg<CloseLogAction>(), mockDispatcher));
});

var xmlFilter = FilterUtils.CreateTestFilter(Constants.FilterXmlContainsData, isEnabled: true);
var eventFilter = new EventFilter(null, [xmlFilter]);
var action = new SetFiltersAction(eventFilter);

// Act
await effects.HandleSetFilters(action, mockDispatcher);

// Assert
Assert.True(eventFilter.RequiresXml);

mockDispatcher.Received(1).Dispatch(Arg.Is<CloseLogAction>(a =>
a.LogName == Constants.LogNameTestLog && a.LogId == logData.Id));

mockDispatcher.Received(1).Dispatch(Arg.Is<OpenLogAction>(a =>
a.LogName == Constants.LogNameTestLog && a.LogPathType == LogPathType.Channel));

// Reload path returns early — no UpdateDisplayedEvents until LoadEvents fires.
mockDispatcher.DidNotReceive().Dispatch(Arg.Any<UpdateDisplayedEventsAction>());

// Surface any HandleCloseLog faults before exiting the test.
await Task.WhenAll(closeTasks);
}

[Fact]
public void ReopenAfterDatabaseRemoval_DispatchesOpenLogPerSnapshotEntry()
{
Expand Down
Loading