From 39de0d1cd555c761dadcb828f30ff961f06d586f Mon Sep 17 00:00:00 2001 From: jschick04 Date: Fri, 17 Jul 2026 16:59:14 +0000 Subject: [PATCH 1/6] Default single-log tables to time order when the timeline is shown --- .../EventLog/FilteringEffects.cs | 16 ++ .../LogTable/LogTableState.cs | 11 +- .../LogTable/Reducers.cs | 38 ++++- .../LogTable/ResolvedEventOrdering.cs | 8 +- .../LogTable/LogTabGroupTests.cs | 2 +- .../LogTableReducerDifferentialTests.cs | 2 +- .../LogTable/LogTableReducerInvariantTests.cs | 4 +- .../LogTable/LogTableStoreTests.cs | 6 +- .../LogTable/TimelineDefaultSortTests.cs | 144 ++++++++++++++++++ 9 files changed, 211 insertions(+), 20 deletions(-) create mode 100644 tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TimelineDefaultSortTests.cs diff --git a/src/EventLogExpert.Runtime/EventLog/FilteringEffects.cs b/src/EventLogExpert.Runtime/EventLog/FilteringEffects.cs index c5cd9652..7a092984 100644 --- a/src/EventLogExpert.Runtime/EventLog/FilteringEffects.cs +++ b/src/EventLogExpert.Runtime/EventLog/FilteringEffects.cs @@ -8,6 +8,7 @@ using EventLogExpert.Filtering.Evaluation; using EventLogExpert.Logging.Abstractions; using EventLogExpert.Runtime.FilterProgress; +using EventLogExpert.Runtime.Histogram; using EventLogExpert.Runtime.LogTable; using Fluxor; using Microsoft.Extensions.DependencyInjection; @@ -208,6 +209,21 @@ public Task HandleSetContinuouslyUpdate(SetContinuouslyUpdateAction action, IDis public async Task HandleSetGroupBy(SetGroupByAction action, IDispatcher dispatcher) => await RepublishForSortAsync(dispatcher); + [EffectMethod] + public async Task HandleSetHistogramVisible(SetHistogramVisibleAction action, IDispatcher dispatcher) + { + // Timeline visibility only changes the default order of a single log with no explicit sort or grouping; every other + // view keeps its order, so skip the rebuild. This predicate matches the reducer's conditional DisplayListVersion bump. + var logTable = _logTableState.Value; + + if (logTable.PerLogEvents.Count != 1 || logTable.RequestedOrderBy is not null || logTable.RequestedGroupBy is not null) + { + return; + } + + await RepublishForSortAsync(dispatcher); + } + [EffectMethod] public async Task HandleSetOrderBy(SetOrderByAction action, IDispatcher dispatcher) => await RepublishForSortAsync(dispatcher); diff --git a/src/EventLogExpert.Runtime/LogTable/LogTableState.cs b/src/EventLogExpert.Runtime/LogTable/LogTableState.cs index 7c041138..fadfb91a 100644 --- a/src/EventLogExpert.Runtime/LogTable/LogTableState.cs +++ b/src/EventLogExpert.Runtime/LogTable/LogTableState.cs @@ -50,6 +50,13 @@ public sealed record LogTableState public bool IsGroupDescending { get; init; } + /// + /// Mirrors the timeline pane's visibility (kept in sync through SetHistogramVisibleAction). A single log + /// with no explicit sort defaults to Date/Time order while the timeline is shown, so the table reads in the same order + /// as the time axis, and to Record ID order while it is hidden. Combined views are unaffected (always Date/Time). + /// + public bool TimelineVisible { get; init; } + internal ColumnName? RequestedOrderBy { get; init; } internal bool RequestedIsDescending { get; init; } = true; @@ -66,7 +73,7 @@ public sealed record LogTableState ImmutableHashSet.Create(StringComparer.Ordinal); internal SortContext SortContext => - new(ResolvedEventOrdering.ResolveDefaultOrderBy(RequestedOrderBy, RequestedGroupBy, PerLogEvents.Count), + new(ResolvedEventOrdering.ResolveDefaultOrderBy(RequestedOrderBy, RequestedGroupBy, PerLogEvents.Count, TimelineVisible), RequestedIsDescending, RequestedGroupBy, RequestedIsGroupDescending); @@ -219,7 +226,7 @@ internal LogTableState WithLogEvents(EventLogId logId, params ResolvedEvent[] ev int newCount = PerLogEvents.ContainsKey(logId) ? PerLogEvents.Count : PerLogEvents.Count + 1; var context = new SortContext( - ResolvedEventOrdering.ResolveDefaultOrderBy(OrderBy, GroupBy, newCount), + ResolvedEventOrdering.ResolveDefaultOrderBy(OrderBy, GroupBy, newCount, TimelineVisible), IsDescending, GroupBy, IsGroupDescending); diff --git a/src/EventLogExpert.Runtime/LogTable/Reducers.cs b/src/EventLogExpert.Runtime/LogTable/Reducers.cs index f2fb5180..e03ae8f6 100644 --- a/src/EventLogExpert.Runtime/LogTable/Reducers.cs +++ b/src/EventLogExpert.Runtime/LogTable/Reducers.cs @@ -4,6 +4,7 @@ using EventLogExpert.Eventing.Common.Channels; using EventLogExpert.Eventing.Common.EventLogs; using EventLogExpert.Runtime.EventLog; +using EventLogExpert.Runtime.Histogram; using Fluxor; using System.Collections.Immutable; @@ -75,7 +76,7 @@ public static LogTableState ReduceAppendTableEvents(LogTableState state, AppendT state.PerLogEvents.Count + 1; var context = EffectiveSortContext( - state.OrderBy, state.IsDescending, state.GroupBy, state.IsGroupDescending, postCount); + state.OrderBy, state.IsDescending, state.GroupBy, state.IsGroupDescending, postCount, state.TimelineVisible); var perLog = SetLog(state.PerLogEvents, table.Id, view, context); perLog = ReconcileToLogCount(perLog, state); @@ -119,7 +120,7 @@ public static LogTableState ReduceAppendTableEventsBatch( } var context = EffectiveSortContext( - state.OrderBy, state.IsDescending, state.GroupBy, state.IsGroupDescending, perLog.Count + newLogs); + state.OrderBy, state.IsDescending, state.GroupBy, state.IsGroupDescending, perLog.Count + newLogs, state.TimelineVisible); foreach (var (logId, view) in action.ViewsByLog) { @@ -255,7 +256,7 @@ public static LogTableState ReduceDisplayReady( } var context = EffectiveSortContext( - flipped.OrderBy, flipped.IsDescending, flipped.GroupBy, flipped.IsGroupDescending, postCount); + flipped.OrderBy, flipped.IsDescending, flipped.GroupBy, flipped.IsGroupDescending, postCount, flipped.TimelineVisible); var perLogBuilder = ImmutableDictionary.CreateBuilder(); var perLogVersion = state.PerLogListVersion; var counts = state.EventCountByLog; @@ -326,7 +327,7 @@ public static LogTableState ReduceLoadColumnsCompleted( GroupCollapseOverrides = ImmutableHashSet.Create(StringComparer.Ordinal), PerLogEvents = ResortAllLogs( updated.PerLogEvents, - EffectiveSortContext(updated.OrderBy, updated.IsDescending, null, false, updated.PerLogEvents.Count)) + EffectiveSortContext(updated.OrderBy, updated.IsDescending, null, false, updated.PerLogEvents.Count, updated.TimelineVisible)) }; } @@ -480,6 +481,25 @@ public static LogTableState ReduceSetGroupBy(LogTableState state, SetGroupByActi }; } + [ReducerMethod] + public static LogTableState ReduceSetHistogramVisible(LogTableState state, SetHistogramVisibleAction action) + { + if (state.TimelineVisible == action.IsVisible) { return state; } + + // A single log with no explicit sort takes its default order from timeline visibility, so bump the display version + // only when that republish will actually follow (see FilteringEffects.HandleSetHistogramVisible). Bumping on a + // combined or explicitly sorted toggle would reject an in-flight republish carrying the pre-bump version with no replacement. + bool willResort = state.PerLogEvents.Count == 1 && + state.RequestedOrderBy is null && + state.RequestedGroupBy is null; + + return state with + { + TimelineVisible = action.IsVisible, + DisplayListVersion = willResort ? state.DisplayListVersion + 1 : state.DisplayListVersion + }; + } + [ReducerMethod] public static LogTableState ReduceSetOrderBy(LogTableState state, SetOrderByAction action) => state.RequestedOrderBy.Equals(action.OrderBy) ? @@ -573,7 +593,7 @@ public static LogTableState ReduceUpdateTable(LogTableState state, UpdateTableAc state.PerLogEvents.Count + 1; var context = EffectiveSortContext( - state.OrderBy, state.IsDescending, state.GroupBy, state.IsGroupDescending, postCount); + state.OrderBy, state.IsDescending, state.GroupBy, state.IsGroupDescending, postCount, state.TimelineVisible); // Always store the finalize view: built over the just-rebuilt raw store, its reader (and every locator it hands // out) addresses the current generation, so a pre-finalize view would strand selection. @@ -598,8 +618,9 @@ private static SortContext EffectiveSortContext( bool isDescending, ColumnName? groupBy, bool isGroupDescending, - int logCount) => - new(ResolvedEventOrdering.ResolveDefaultOrderBy(orderBy, groupBy, logCount), + int logCount, + bool timelineVisible) => + new(ResolvedEventOrdering.ResolveDefaultOrderBy(orderBy, groupBy, logCount, timelineVisible), isDescending, groupBy, isGroupDescending); @@ -613,7 +634,8 @@ private static ImmutableDictionary ReconcileToLogCo state.IsDescending, state.GroupBy, state.IsGroupDescending, - perLog.Count)); + perLog.Count, + state.TimelineVisible)); private static LogTableState RedirectActiveToGroupIfHidden(LogTableState state) { diff --git a/src/EventLogExpert.Runtime/LogTable/ResolvedEventOrdering.cs b/src/EventLogExpert.Runtime/LogTable/ResolvedEventOrdering.cs index 45464db2..88dcbdd1 100644 --- a/src/EventLogExpert.Runtime/LogTable/ResolvedEventOrdering.cs +++ b/src/EventLogExpert.Runtime/LogTable/ResolvedEventOrdering.cs @@ -7,12 +7,14 @@ namespace EventLogExpert.Runtime.LogTable; internal static partial class ResolvedEventOrdering { - // RecordId for one log; timestamp for a combined view of several. - internal static ColumnName? ResolveDefaultOrderBy(ColumnName? orderBy, ColumnName? groupBy, int logCount) + // RecordId for one log while the timeline is hidden; timestamp for a combined view of several, or for a single log + // while the timeline is shown (so the table reads in the same order as the time axis). An explicit sort or grouping + // always wins. + internal static ColumnName? ResolveDefaultOrderBy(ColumnName? orderBy, ColumnName? groupBy, int logCount, bool timelineVisible) { if (orderBy is not null || groupBy is not null) { return orderBy; } - return logCount > 1 ? ColumnName.DateAndTime : null; + return logCount > 1 || timelineVisible ? ColumnName.DateAndTime : null; } private static int CompareDateTime(EventFieldValue left, EventFieldValue right) diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/LogTabGroupTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/LogTabGroupTests.cs index 15950309..62535221 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/LogTabGroupTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/LogTabGroupTests.cs @@ -516,7 +516,7 @@ private static void AssertViewExactly( { var oracle = AosReferenceOrdering.OrderedEvents( expected, - ResolvedEventOrdering.ResolveDefaultOrderBy(state.OrderBy, state.GroupBy, state.PerLogEvents.Count), + ResolvedEventOrdering.ResolveDefaultOrderBy(state.OrderBy, state.GroupBy, state.PerLogEvents.Count, state.TimelineVisible), state.IsDescending, state.GroupBy, state.IsGroupDescending); diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/LogTableReducerDifferentialTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/LogTableReducerDifferentialTests.cs index 14c2f88e..cb97fbaa 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/LogTableReducerDifferentialTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/LogTableReducerDifferentialTests.cs @@ -92,7 +92,7 @@ private sealed class Harness public LogTableState State { get; private set; } = new(); private ColumnName? EffectiveOrderBy => - ResolvedEventOrdering.ResolveDefaultOrderBy(State.OrderBy, State.GroupBy, OpenLogs().Count); + ResolvedEventOrdering.ResolveDefaultOrderBy(State.OrderBy, State.GroupBy, OpenLogs().Count, State.TimelineVisible); public void AppendToAnyLog() { diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/LogTableReducerInvariantTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/LogTableReducerInvariantTests.cs index ac85952e..fe2fd99c 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/LogTableReducerInvariantTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/LogTableReducerInvariantTests.cs @@ -210,7 +210,7 @@ private static void AssertDisplayedExactly(LogTableState state, IReadOnlyList BuildPerLog( { var byLog = events.GroupBy(resolved => resolved.OwningLog).ToList(); - // Match the reducers' default: RecordId for one log, else DateAndTime. + // Match the reducers' default: RecordId for one log while the timeline is hidden, else DateAndTime. var context = new SortContext( - ResolvedEventOrdering.ResolveDefaultOrderBy(orderBy, groupBy, byLog.Count), + ResolvedEventOrdering.ResolveDefaultOrderBy(orderBy, groupBy, byLog.Count, timelineVisible: false), isDescending, groupBy, isGroupDescending); diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TimelineDefaultSortTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TimelineDefaultSortTests.cs new file mode 100644 index 00000000..653f9772 --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TimelineDefaultSortTests.cs @@ -0,0 +1,144 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Runtime.Histogram; +using EventLogExpert.Runtime.LogTable; +using Reducers = EventLogExpert.Runtime.LogTable.Reducers; + +namespace EventLogExpert.Runtime.Tests.LogTable; + +public sealed class TimelineDefaultSortTests +{ + private static readonly DateTime s_baseTime = new(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + [Fact] + public void ResolveDefaultOrderBy_WhenGrouped_IgnoresTimeline() + { + // Grouping short-circuits the default, so the timeline flag cannot force a column sort. + Assert.Null( + ResolvedEventOrdering.ResolveDefaultOrderBy(orderBy: null, ColumnName.Source, logCount: 1, timelineVisible: true)); + } + + [Fact] + public void ResolveDefaultOrderBy_WithExplicitSort_IgnoresTimeline() + { + // An explicit column sort always wins over the timeline-driven default, even for a single log. + Assert.Equal( + ColumnName.EventId, + ResolvedEventOrdering.ResolveDefaultOrderBy(ColumnName.EventId, groupBy: null, logCount: 1, timelineVisible: true)); + } + + [Theory] + [InlineData(1, false, null)] // one log, timeline hidden -> Record ID (null) + [InlineData(1, true, ColumnName.DateAndTime)] // one log, timeline shown -> Date/Time + [InlineData(2, false, ColumnName.DateAndTime)] // combined view -> Date/Time regardless of the timeline + [InlineData(2, true, ColumnName.DateAndTime)] + public void ResolveDefaultOrderBy_WithoutExplicitSort_FollowsLogCountAndTimeline( + int logCount, + bool timelineVisible, + ColumnName? expected) + { + var resolved = ResolvedEventOrdering.ResolveDefaultOrderBy( + orderBy: null, + groupBy: null, + logCount, + timelineVisible); + + Assert.Equal(expected, resolved); + } + + [Fact] + public void SetHistogramVisible_OnCombinedView_FlipsFlagWithoutBumpingDisplayVersion() + { + var log1 = EventLogId.Create(); + var log2 = EventLogId.Create(); + var state = new LogTableState() + .WithLogEvents(log1, LogEvents("Log1")) + .WithLogEvents(log2, LogEvents("Log2")); + int before = state.DisplayListVersion; + + var shown = Reducers.ReduceSetHistogramVisible(state, new SetHistogramVisibleAction(true)); + + // A combined view is Date/Time either way, so no republish follows and the version must not move (it would strand + // an in-flight republish carrying the pre-bump version). + Assert.True(shown.TimelineVisible); + Assert.Equal(before, shown.DisplayListVersion); + } + + [Fact] + public void SetHistogramVisible_OnSingleDefaultLog_FlipsFlagAndBumpsDisplayVersion() + { + var logId = EventLogId.Create(); + var state = new LogTableState().WithLogEvents(logId, OutOfRecordOrderEvents()); + int before = state.DisplayListVersion; + + var shown = Reducers.ReduceSetHistogramVisible(state, new SetHistogramVisibleAction(true)); + + Assert.True(shown.TimelineVisible); + Assert.Equal(before + 1, shown.DisplayListVersion); + } + + [Fact] + public void SetHistogramVisible_WhenUnchanged_ReturnsSameState() + { + var logId = EventLogId.Create(); + var state = new LogTableState { TimelineVisible = true }.WithLogEvents(logId, OutOfRecordOrderEvents()); + + var result = Reducers.ReduceSetHistogramVisible(state, new SetHistogramVisibleAction(true)); + + Assert.Same(state, result); + } + + [Fact] + public void SetHistogramVisible_WithExplicitSort_FlipsFlagWithoutBumpingDisplayVersion() + { + var logId = EventLogId.Create(); + var state = new LogTableState { RequestedOrderBy = ColumnName.EventId } + .WithLogEvents(logId, OutOfRecordOrderEvents()); + int before = state.DisplayListVersion; + + var shown = Reducers.ReduceSetHistogramVisible(state, new SetHistogramVisibleAction(true)); + + Assert.True(shown.TimelineVisible); + Assert.Equal(before, shown.DisplayListVersion); + } + + [Fact] + public void SingleLog_WithTimelineHidden_OrdersByRecordId() + { + var logId = EventLogId.Create(); + var state = new LogTableState { ActiveEventLogId = logId, TimelineVisible = false } + .WithLogEvents(logId, OutOfRecordOrderEvents()); + + Assert.Null(state.SortContext.OrderBy); + Assert.Equal(new long?[] { 3, 2, 1 }, DisplayedRecordIds(state)); + } + + [Fact] + public void SingleLog_WithTimelineVisible_OrdersByDateAndTime() + { + var logId = EventLogId.Create(); + var state = new LogTableState { ActiveEventLogId = logId, TimelineVisible = true } + .WithLogEvents(logId, OutOfRecordOrderEvents()); + + Assert.Equal(ColumnName.DateAndTime, state.SortContext.OrderBy); + + // Record IDs 1/2/3 carry times +3/+1/+2, so Date/Time descending is 1, 3, 2 (not the Record ID order 3, 2, 1). + Assert.Equal(new long?[] { 1, 3, 2 }, DisplayedRecordIds(state)); + } + + private static long?[] DisplayedRecordIds(LogTableState state) => + [.. state.DisplayedEvents.EnumerateDetail().Select(resolved => resolved.RecordId)]; + + private static ResolvedEvent[] LogEvents(string owningLog) => + [ + new(owningLog, LogPathType.Channel) { Id = 10, RecordId = 1, TimeCreated = s_baseTime.AddMinutes(3) }, + new(owningLog, LogPathType.Channel) { Id = 20, RecordId = 2, TimeCreated = s_baseTime.AddMinutes(1) }, + new(owningLog, LogPathType.Channel) { Id = 30, RecordId = 3, TimeCreated = s_baseTime.AddMinutes(2) } + ]; + + private static ResolvedEvent[] OutOfRecordOrderEvents() => LogEvents("TestLog"); +} From b2dddc9394b2f0eca5ac0c7d69d9874aaac768d5 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Fri, 17 Jul 2026 17:00:56 +0000 Subject: [PATCH 2/6] Remove the histogram spike outline --- .../LogTable/Histogram/HistogramPane.razor | 6 ------ .../LogTable/Histogram/HistogramPane.razor.css | 8 -------- 2 files changed, 14 deletions(-) diff --git a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor index 49b8b8fc..318e0bf3 100644 --- a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor +++ b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor @@ -127,7 +127,6 @@ int slotWidth = Math.Max(1, barRight - barLeft); int barDrawWidth = Math.Max(1, slotWidth - 1); double segmentTop = barsHeight; - int visiblePx = 0; int baseSlot = index * groupCount; @@ -139,7 +138,6 @@ if (height <= 0 || IsGroupHidden(group)) { continue; } segmentTop -= height; - visiblePx += height; } - @if (bin.IsAnomaly && visiblePx > 0) - { - - } @if (HasFindHit(bin.StartTicks, bin.EndTicks)) { diff --git a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.css b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.css index 4c862319..761680f9 100644 --- a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.css +++ b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.css @@ -243,14 +243,6 @@ pointer-events: none; } -.histogram-anomaly { - fill: none; - stroke: var(--clr-statusbar); - stroke-width: 1.5; - vector-effect: non-scaling-stroke; - pointer-events: none; -} - .histogram-marker-selected { stroke: var(--toggle-accent); stroke-width: 1.5; From 2445ebddfdf9966522142f24198b400d95f8ce5a Mon Sep 17 00:00:00 2001 From: jschick04 Date: Fri, 17 Jul 2026 17:01:45 +0000 Subject: [PATCH 3/6] Add Logon Type and Ticket Encryption Type histogram grouping --- .../Common/Events/EventColumnStore.cs | 186 ++++++++++++++++++ .../Common/Events/EventColumnStoreReader.cs | 32 +++ .../Common/Events/EventFieldValue.cs | 48 +++++ .../Common/Events/IEventColumnReader.cs | 22 +++ .../Common/Display/EventDataValueDecoder.cs | 55 ++++++ .../DetailsPane/EventFieldExplainer.cs | 57 +----- .../Histogram/HistogramBuilder.cs | 68 +++++++ .../Histogram/HistogramData.cs | 7 +- .../Histogram/HistogramDimension.cs | 4 +- .../LogTable/CombinedColumnView.cs | 68 ++++++- .../LogTable/EventColumnView.cs | 24 +++ .../LogTable/IEventColumnView.cs | 19 ++ .../LogTable/Histogram/HistogramPane.razor | 6 + .../LogTable/Histogram/HistogramPane.razor.cs | 16 +- .../Histogram/HistogramPane.razor.css | 2 +- .../Common/Events/LegacyEventColumnReader.cs | 55 ++++++ .../Events/EventColumnStoreEventDataTests.cs | 135 +++++++++++++ .../Display/EventDataValueDecoderTests.cs | 33 ++++ .../Histogram/HistogramBuilderTests.cs | 93 +++++++++ .../TestSupport/LegacyEventColumnView.cs | 6 + 20 files changed, 872 insertions(+), 64 deletions(-) create mode 100644 src/EventLogExpert.Runtime/Common/Display/EventDataValueDecoder.cs create mode 100644 tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs create mode 100644 tests/Unit/EventLogExpert.Runtime.Tests/Common/Display/EventDataValueDecoderTests.cs diff --git a/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs b/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs index 94372c72..742d8722 100644 --- a/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs +++ b/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs @@ -75,6 +75,9 @@ public int GetHashCode(int[] obj) /// public sealed class EventColumnStore { + // Per-scan EventData field-index memo sentinels: a schema not yet looked up, and a schema that lacks the field. + private const int SchemaFieldAbsent = -1; + private const int SchemaFieldUnresolved = -2; // Target sealed-chunk size. The pending tail columnarizes into one new chunk each time it reaches this many rows, // giving amortized O(1) append with no partially filled columnar chunks. private const int TargetChunkSize = 4096; @@ -227,6 +230,54 @@ public bool TryGetTimeRange(out long minTicks, out long maxTicks) return Count > 0; } + internal void BucketTimeTicksByEventData( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + long[] targetCodes, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + int otherSlot = targetCodes.Length; + int[] fieldIndexBySchema = NewSchemaFieldMemo(); + int offset = 0; + + foreach (EventColumnChunk chunk in _sealedChunks) + { + cancellationToken.ThrowIfCancellationRequested(); + + ReadOnlySpan timeColumn = chunk.TimeTicksColumn; + + for (int row = 0; row < timeColumn.Length; row++) + { + if (rankByPhysical[offset + row] < 0) { continue; } + + int slot = TryGetSealedEventDataCode(chunk, row, fieldName, fieldIndexBySchema, out long code) + ? SlotForCode(code, targetCodes, otherSlot) + : otherSlot; + int bucket = ToBucket(timeColumn[row], minTicks, bucketSpanTicks, bucketCount); + slotCounts[(bucket * slotCount) + slot]++; + } + + offset += chunk.RowCount; + } + + for (int index = _sealedCount; index < Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + ResolvedEvent pending = Pending(index); + int slot = TryGetPendingEventDataCode(pending, fieldName, out long code) + ? SlotForCode(code, targetCodes, otherSlot) + : otherSlot; + int bucket = ToBucket(pending.TimeCreated.Ticks, minTicks, bucketSpanTicks, bucketCount); + slotCounts[(bucket * slotCount) + slot]++; + } + } + internal void BucketTimeTicksByEventId( ReadOnlySpan rankByPhysical, long minTicks, @@ -546,6 +597,41 @@ internal void CopyTimeTicks(long[] values, bool[] hasValue) } } + internal void CountEventDataValues(ReadOnlySpan rankByPhysical, string fieldName, IDictionary counts, CancellationToken cancellationToken) + { + int[] fieldIndexBySchema = NewSchemaFieldMemo(); + int offset = 0; + + foreach (EventColumnChunk chunk in _sealedChunks) + { + cancellationToken.ThrowIfCancellationRequested(); + + ReadOnlySpan timeColumn = chunk.TimeTicksColumn; + + for (int row = 0; row < timeColumn.Length; row++) + { + if (rankByPhysical[offset + row] < 0) { continue; } + + if (TryGetSealedEventDataCode(chunk, row, fieldName, fieldIndexBySchema, out long code)) + { + counts[code] = counts.TryGetValue(code, out int existing) ? existing + 1 : 1; + } + } + + offset += chunk.RowCount; + } + + for (int index = _sealedCount; index < Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + if (TryGetPendingEventDataCode(Pending(index), fieldName, out long code)) + { + counts[code] = counts.TryGetValue(code, out int existing) ? existing + 1 : 1; + } + } + } + internal void CountEventIds(ReadOnlySpan rankByPhysical, IDictionary counts, CancellationToken cancellationToken) { int offset = 0; @@ -1052,6 +1138,16 @@ private static int FindChunk(int[] prefix, int index) _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Field is not a supported group-by dimension.") }; + private static int SlotForCode(long value, long[] targets, int otherSlot) + { + for (int slot = 0; slot < targets.Length; slot++) + { + if (value == targets[slot]) { return slot; } + } + + return otherSlot; + } + // Shared by the pooled-field and event-id group-by scans: a pure integer match, so a negative event id still matches its own target; callers resolve absent pooled targets to int.MinValue (not -1) so a null-field row can't collide. private static int SlotForIndex(int value, int[] targets, int otherSlot) { @@ -1087,6 +1183,15 @@ private static int ToBucket(long ticks, long minTicks, long bucketSpanTicks, int return bucket < 0 ? 0 : bucket >= bucketCount ? bucketCount - 1 : (int)bucket; } + private static bool TryGetPendingEventDataCode(ResolvedEvent pending, string fieldName, out long code) + { + if (pending.EventData.TryGetValue(fieldName, out EventFieldValue value)) { return value.TryGetWholeNumber(out code); } + + code = 0; + + return false; + } + private (EventColumnChunk Chunk, int Row) Locate(int index) { int[] prefix = SealedPrefix(); @@ -1100,6 +1205,16 @@ private static int ToBucket(long ticks, long minTicks, long bucketSpanTicks, int "The columnar representation exists only for sealed rows; check IsPending first.") : Locate(index); + // A fresh per-scan memo (schemaId -> field index, or Unresolved until first hit), so each distinct schema's field + // position is resolved exactly once per scan rather than once per row. + private int[] NewSchemaFieldMemo() + { + int[] memo = new int[_schemas.Length]; + Array.Fill(memo, SchemaFieldUnresolved); + + return memo; + } + private ResolvedEvent Pending(int index) => _pendingTail[index - _sealedCount]; private ImmutableArray ReconstructEventDataValues(int index) @@ -1208,6 +1323,20 @@ private ImmutableArray ReconstructUserData(int index) return fields.MoveToImmutable(); } + private int ResolveSchemaFieldIndex(int schemaId, string fieldName) + { + int[] nameIndices = _schemas[schemaId]; + + for (int i = 0; i < nameIndices.Length; i++) + { + string? name = PoolGet(nameIndices[i]); + + if (!string.IsNullOrEmpty(name) && string.Equals(name, fieldName, StringComparison.Ordinal)) { return i; } + } + + return SchemaFieldAbsent; + } + private int[] SealedPrefix() { int[]? prefix = Volatile.Read(ref _sealedPrefix); @@ -1222,4 +1351,61 @@ private int[] SealedPrefix() return prefix; } + + private bool TryGetSealedEventDataCode(EventColumnChunk chunk, int row, string fieldName, int[] fieldIndexBySchema, out long code) + { + int schemaId = chunk.RowEventDataSchemaId(row); + + if ((uint)schemaId >= (uint)fieldIndexBySchema.Length) { code = 0; return false; } + + int fieldIndex = fieldIndexBySchema[schemaId]; + + if (fieldIndex == SchemaFieldUnresolved) + { + fieldIndex = ResolveSchemaFieldIndex(schemaId, fieldName); + fieldIndexBySchema[schemaId] = fieldIndex; + } + + if (fieldIndex < 0 || fieldIndex >= chunk.RowEventDataCount(row)) + { + code = 0; + + return false; + } + + return TryGetWholeNumberFromRawField(chunk.RowEventDataField(row, fieldIndex), out code); + } + + private bool TryGetWholeNumberFromRawField(in RawEventDataField field, out long code) + { + switch (field.Kind) + { + case StoredFieldKind.SByte: + case StoredFieldKind.Int16: + case StoredFieldKind.Int32: + case StoredFieldKind.Int64: + code = field.Bits; + + return code >= 0; + case StoredFieldKind.Byte: + case StoredFieldKind.UInt16: + case StoredFieldKind.UInt32: + case StoredFieldKind.UInt64: + case StoredFieldKind.SizeT: + ulong unsigned = unchecked((ulong)field.Bits); + + if (unsigned <= long.MaxValue) { code = (long)unsigned; return true; } + + code = 0; + + return false; + case StoredFieldKind.String: + case StoredFieldKind.StringForm: + return EventFieldValue.TryParseWholeNumber(PoolGet(field.RefIndex), out code); + default: + code = 0; + + return false; + } + } } diff --git a/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs b/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs index c2a2bceb..853af847 100644 --- a/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs +++ b/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs @@ -36,6 +36,29 @@ internal EventColumnStoreReader(EventLogId logId, EventColumnStore store) public IReadOnlyList Pool => new PoolView(_store, PendingPool()); + public void BucketTimeTicksByEventData( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(fieldName); + ArgumentNullException.ThrowIfNull(targetCodes); + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketSpanTicks, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketCount, 1); + + int slotCount = targetCodes.Length + 1; + ArgumentOutOfRangeException.ThrowIfLessThan(slotCounts.Length, bucketCount * slotCount); + + _store.BucketTimeTicksByEventData(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, fieldName, targetCodes, slotCount, slotCounts, cancellationToken); + } + public void BucketTimeTicksByEventId( ReadOnlySpan rankByPhysical, long minTicks, @@ -159,6 +182,15 @@ public void CopyPoolIndexColumn(EventFieldId field, int[] poolIndices) } } + public void CountEventDataValues(ReadOnlySpan rankByPhysical, string fieldName, IDictionary counts, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(fieldName); + ArgumentNullException.ThrowIfNull(counts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + _store.CountEventDataValues(rankByPhysical, fieldName, counts, cancellationToken); + } + public void CountEventIds(ReadOnlySpan rankByPhysical, IDictionary counts, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(counts); diff --git a/src/EventLogExpert.Eventing/Common/Events/EventFieldValue.cs b/src/EventLogExpert.Eventing/Common/Events/EventFieldValue.cs index 9e6b75b6..2b861970 100644 --- a/src/EventLogExpert.Eventing/Common/Events/EventFieldValue.cs +++ b/src/EventLogExpert.Eventing/Common/Events/EventFieldValue.cs @@ -63,6 +63,54 @@ public bool TryGetUInt64(out ulong value) return false; } + /// + /// Reads this value as a non-negative whole number when it is one: a typed integral kind, or a + /// holding a plain decimal (e.g. "23") or a 0x-prefixed hex form + /// (e.g. "0x17"). Decimal and hex spellings of the same code canonicalize to the same value. Allocation-free. + /// + public bool TryGetWholeNumber(out long value) + { + switch (_kind) + { + case EventFieldValueKind.Int64 when _bits >= 0: + case EventFieldValueKind.UInt64 when unchecked((ulong)_bits) <= long.MaxValue: + value = _bits; + + return true; + case EventFieldValueKind.String: + return TryParseWholeNumber(_reference as string, out value); + default: + value = 0; + + return false; + } + } + + internal static bool TryParseWholeNumber(string? text, out long value) + { + value = 0; + + if (string.IsNullOrEmpty(text)) { return false; } + + ReadOnlySpan span = text; + + if (!span.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + { + return long.TryParse(span, NumberStyles.None, CultureInfo.InvariantCulture, out value); + } + + // Parse hex as unsigned so a high-bit form (e.g. 0xFFFF...) can't wrap to a negative code, then keep only codes that fit a non-negative long. + if (!ulong.TryParse(span[2..], NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out ulong hex) || + hex > long.MaxValue) + { + return false; + } + + value = (long)hex; + + return true; + } + public bool TryGetDouble(out double value) { if (_kind == EventFieldValueKind.Double) { value = BitConverter.Int64BitsToDouble(_bits); return true; } diff --git a/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs b/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs index 6309d23b..b7a9ebce 100644 --- a/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs +++ b/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs @@ -22,6 +22,21 @@ public interface IEventColumnReader /// IReadOnlyList Pool { get; } + /// + /// Bucket-by-EventData variant of : each survivor lands in its + /// slot (matched on the field's whole-number code) else the trailing "other" slot, + /// which also absorbs rows that lack the field; ( length + 1) slots per bin. + /// + void BucketTimeTicksByEventData( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken); + /// Group-by variant keyed on the numeric event id; ( length + 1) slots per bin. void BucketTimeTicksByEventId( ReadOnlySpan rankByPhysical, @@ -78,6 +93,13 @@ void BucketTimeTicksBySeverity( /// void CopyPoolIndexColumn(EventFieldId field, int[] poolIndices); + /// + /// Group-by variant for a named EventData field of allowlisted numeric codes: tallies survivors by the field's + /// whole-number code (decimal and 0x-hex spellings canonicalize to one code), so the histogram can split, for + /// example, by LogonType or TicketEncryptionType. Rows that lack the field are omitted. + /// + void CountEventDataValues(ReadOnlySpan rankByPhysical, string fieldName, IDictionary counts, CancellationToken cancellationToken); + /// /// Tallies each surviving row by its numeric event id into (accumulating across a /// combined view). diff --git a/src/EventLogExpert.Runtime/Common/Display/EventDataValueDecoder.cs b/src/EventLogExpert.Runtime/Common/Display/EventDataValueDecoder.cs new file mode 100644 index 00000000..a555d52d --- /dev/null +++ b/src/EventLogExpert.Runtime/Common/Display/EventDataValueDecoder.cs @@ -0,0 +1,55 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Common.Display; + +/// +/// Decodes the small set of well-known numeric EventData codes the app surfaces in more than one place - the +/// details-pane field explanation and the timeline group-by labels - to human-readable text, so both stay in sync. +/// Fail-open: an unrecognized field or code returns and each caller supplies its own fallback +/// (the details pane shows nothing; the histogram keeps the raw code as a distinct band). +/// +public static class EventDataValueDecoder +{ + /// + /// The friendly label for under (e.g. LogonType 3 => + /// "Network", TicketEncryptionType 23 => "RC4"), or when the field is not decoded or the + /// code is unrecognized. + /// + public static string? TryDecodeLabel(string fieldName, long code) + { + if (string.Equals(fieldName, "LogonType", StringComparison.OrdinalIgnoreCase)) { return DecodeLogonType(code); } + + return string.Equals(fieldName, "TicketEncryptionType", StringComparison.OrdinalIgnoreCase) ? DecodeTicketEncryptionType(code) : null; + } + + private static string? DecodeLogonType(long code) => code switch + { + 0 => "System", + 2 => "Interactive", + 3 => "Network", + 4 => "Batch", + 5 => "Service", + 7 => "Unlock", + 8 => "NetworkCleartext", + 9 => "NewCredentials", + 10 => "RemoteInteractive", + 11 => "CachedInteractive", + 12 => "CachedRemoteInteractive", + 13 => "CachedUnlock", + _ => null + }; + + // Kerberos RFC 3961 encryption types as carried by Security 4768/4769/4770 TicketEncryptionType; the triage split is + // the weak legacy RC4 (0x17) against modern AES (0x11/0x12). + private static string? DecodeTicketEncryptionType(long code) => code switch + { + 1 => "DES-CBC-CRC", + 3 => "DES-CBC-MD5", + 17 => "AES128", + 18 => "AES256", + 23 => "RC4", + 24 => "RC4-EXP", + _ => null + }; +} diff --git a/src/EventLogExpert.Runtime/DetailsPane/EventFieldExplainer.cs b/src/EventLogExpert.Runtime/DetailsPane/EventFieldExplainer.cs index 06b45a39..97314c91 100644 --- a/src/EventLogExpert.Runtime/DetailsPane/EventFieldExplainer.cs +++ b/src/EventLogExpert.Runtime/DetailsPane/EventFieldExplainer.cs @@ -2,8 +2,8 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Runtime.Common.Display; using System.Collections.Frozen; -using System.Globalization; namespace EventLogExpert.Runtime.DetailsPane; @@ -44,12 +44,6 @@ public static class EventFieldExplainer new(null, null, "WorkstationName", "The name of the workstation from which the action originated.") ]; - private static readonly FrozenDictionary> s_valueDecoders = - new Dictionary> - { - ["LogonType"] = DecodeLogonType - }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); - /// /// Produces an for the given field, resolving a value decode and a glossary /// description independently. Returns false (and an empty explanation) when neither applies. @@ -69,28 +63,6 @@ public static bool TryExplain( return explanation.HasValue; } - private static string? DecodeLogonType(EventFieldValue value) - { - if (!TryReadWholeNumber(value, out ulong logonType)) { return null; } - - return logonType switch - { - 0 => "System", - 2 => "Interactive", - 3 => "Network", - 4 => "Batch", - 5 => "Service", - 7 => "Unlock", - 8 => "NetworkCleartext", - 9 => "NewCredentials", - 10 => "RemoteInteractive", - 11 => "CachedInteractive", - 12 => "CachedRemoteInteractive", - 13 => "CachedUnlock", - _ => null - }; - } - private static string? ResolveDescription(string providerName, int eventId, string fieldName) { // Most specific: provider + event id + field. @@ -133,32 +105,7 @@ public static bool TryExplain( } private static string? TryDecodeValue(string fieldName, in EventFieldValue value) => - s_valueDecoders.TryGetValue(fieldName, out Func? decoder) - ? decoder(value) - : null; - - private static bool TryReadWholeNumber(in EventFieldValue value, out ulong number) - { - if (value.TryGetUInt64(out number)) { return true; } - - if (value.TryGetInt64(out long signed) && signed >= 0) - { - number = (ulong)signed; - - return true; - } - - // Strict parse only for a string-typed value: no sign, whitespace, or fractional part. - if (value.Kind == EventFieldValueKind.String - && ulong.TryParse(value.AsString(), NumberStyles.None, CultureInfo.InvariantCulture, out number)) - { - return true; - } - - number = 0; - - return false; - } + value.TryGetWholeNumber(out long code) ? EventDataValueDecoder.TryDecodeLabel(fieldName, code) : null; private sealed record GlossaryEntry(string? Provider, int? EventId, string Field, string Description); } diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs b/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs index 21a63920..4f9876bb 100644 --- a/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs +++ b/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs @@ -2,6 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Runtime.Common.Display; using EventLogExpert.Runtime.LogTable; using System.Globalization; @@ -25,6 +26,11 @@ public static class HistogramBuilder long bucketSpanTicks = Math.Max(1, (spanTicks + maxBuckets - 1) / maxBuckets); int bucketCount = (int)Math.Min((spanTicks + bucketSpanTicks - 1) / bucketSpanTicks, maxBuckets); + if (dimension is HistogramDimension.LogonType or HistogramDimension.TicketEncryptionType) + { + return BuildByEventData(view, ToEventDataFieldName(dimension), minTicks, maxTicks, bucketSpanTicks, bucketCount, cancellationToken); + } + (int[] slotCounts, int slotCount, IReadOnlyList groups) = dimension switch { HistogramDimension.Severity => ScanSeverity(view, minTicks, bucketSpanTicks, bucketCount, cancellationToken), @@ -48,6 +54,61 @@ public static class HistogramBuilder groups); } + private static HistogramData BuildByEventData( + IEventColumnView view, + string fieldName, + long minTicks, + long maxTicks, + long bucketSpanTicks, + int bucketCount, + CancellationToken cancellationToken) + { + var counts = new Dictionary(); + view.CountEventDataValues(fieldName, counts, cancellationToken); + + var minUtc = new DateTime(minTicks, DateTimeKind.Utc); + var maxUtc = new DateTime(maxTicks, DateTimeKind.Utc); + + if (counts.Count == 0) + { + // No row in the view carries this field. Report the true survivor count (view.Count - every survivor falls within + // the view's own min/max span) so the accessible region label isn't "0 events" over a non-empty span, and flag the + // empty-state so the pane shows a message rather than a lone "Other" band. + return new HistogramData([], 0, bucketCount, minUtc, maxUtc, view.Count, bucketSpanTicks, []) { GroupingFieldAbsent = true }; + } + + long[] targetCodes = counts + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key) + .Take(HistogramConstants.MaxGroupByCategories) + .Select(pair => pair.Key) + .ToArray(); + + int slotCount = targetCodes.Length + 1; + int[] slotCounts = new int[bucketCount * slotCount]; + view.BucketTimeTicksByEventData(minTicks, bucketSpanTicks, bucketCount, fieldName, targetCodes, slotCounts, cancellationToken); + + int total = 0; + + foreach (int count in slotCounts) { total += count; } + + // Key = the raw invariant code (stable toggle key); Label = the friendly decode, or the raw code when unrecognized. + string[] keys = Array.ConvertAll(targetCodes, code => code.ToString(CultureInfo.InvariantCulture)); + string[] labels = Array.ConvertAll( + targetCodes, + code => EventDataValueDecoder.TryDecodeLabel(fieldName, code) ?? code.ToString(CultureInfo.InvariantCulture)); + + return new HistogramData( + slotCounts, + slotCount, + bucketCount, + minUtc, + maxUtc, + total, + bucketSpanTicks, + HistogramGroups.ForCategories(keys, labels)); + } + private static (int[] SlotCounts, int SlotCount, IReadOnlyList Groups) ScanByEventId( IEventColumnView view, long minTicks, @@ -115,6 +176,13 @@ private static (int[] SlotCounts, int SlotCount, IReadOnlyList G return (slotCounts, slotCount, HistogramGroups.Severity); } + private static string ToEventDataFieldName(HistogramDimension dimension) => dimension switch + { + HistogramDimension.LogonType => "LogonType", + HistogramDimension.TicketEncryptionType => "TicketEncryptionType", + _ => throw new ArgumentOutOfRangeException(nameof(dimension), dimension, "Dimension is not an EventData field.") + }; + private static EventFieldId ToFieldId(HistogramDimension dimension) => dimension switch { HistogramDimension.Source => EventFieldId.Source, diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramData.cs b/src/EventLogExpert.Runtime/Histogram/HistogramData.cs index eebb92ab..814fffea 100644 --- a/src/EventLogExpert.Runtime/Histogram/HistogramData.cs +++ b/src/EventLogExpert.Runtime/Histogram/HistogramData.cs @@ -12,4 +12,9 @@ public sealed record HistogramData( DateTime MaxUtc, int Total, long BucketSpanTicks, - IReadOnlyList Groups); + IReadOnlyList Groups) +{ + // True when the selected group-by dimension is a named EventData field that no row in the view carries, so the pane + // shows an explicit empty-state rather than a single, meaningless "Other" band. + public bool GroupingFieldAbsent { get; init; } +} diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramDimension.cs b/src/EventLogExpert.Runtime/Histogram/HistogramDimension.cs index 5ab3d034..223917d9 100644 --- a/src/EventLogExpert.Runtime/Histogram/HistogramDimension.cs +++ b/src/EventLogExpert.Runtime/Histogram/HistogramDimension.cs @@ -10,5 +10,7 @@ public enum HistogramDimension EventId, TaskCategory, Opcode, - Log + Log, + LogonType, + TicketEncryptionType } diff --git a/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs b/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs index 19024f3b..143ba14e 100644 --- a/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs +++ b/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs @@ -58,23 +58,73 @@ internal CombinedColumnView(IReadOnlyList views, SortContext co internal static CombinedColumnView Empty { get; } = new([], new SortContext(null, true, null, false)); - public void BucketTimeTicksByEventId(long minTicks, long bucketSpanTicks, int bucketCount, int[] targetIds, int[] slotCounts, CancellationToken cancellationToken) + public void BucketTimeTicksByEventData( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) { foreach (var view in _views) { - view.BucketTimeTicksByEventId(minTicks, bucketSpanTicks, bucketCount, targetIds, slotCounts, cancellationToken); + view.BucketTimeTicksByEventData(minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + targetCodes, + slotCounts, + cancellationToken); } } - public void BucketTimeTicksByField(long minTicks, long bucketSpanTicks, int bucketCount, EventFieldId field, string[] targetValues, int[] slotCounts, CancellationToken cancellationToken) + public void BucketTimeTicksByEventId( + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] targetIds, + int[] slotCounts, + CancellationToken cancellationToken) { foreach (var view in _views) { - view.BucketTimeTicksByField(minTicks, bucketSpanTicks, bucketCount, field, targetValues, slotCounts, cancellationToken); + view.BucketTimeTicksByEventId(minTicks, + bucketSpanTicks, + bucketCount, + targetIds, + slotCounts, + cancellationToken); } } - public void BucketTimeTicksBySeverity(long minTicks, long bucketSpanTicks, int bucketCount, int[] slotCounts, CancellationToken cancellationToken) + public void BucketTimeTicksByField( + long minTicks, + long bucketSpanTicks, + int bucketCount, + EventFieldId field, + string[] targetValues, + int[] slotCounts, + CancellationToken cancellationToken) + { + foreach (var view in _views) + { + view.BucketTimeTicksByField(minTicks, + bucketSpanTicks, + bucketCount, + field, + targetValues, + slotCounts, + cancellationToken); + } + } + + public void BucketTimeTicksBySeverity( + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken) { foreach (var view in _views) { @@ -82,6 +132,14 @@ public void BucketTimeTicksBySeverity(long minTicks, long bucketSpanTicks, int b } } + public void CountEventDataValues(string fieldName, IDictionary counts, CancellationToken cancellationToken) + { + foreach (var view in _views) + { + view.CountEventDataValues(fieldName, counts, cancellationToken); + } + } + public void CountEventIds(IDictionary counts, CancellationToken cancellationToken) { foreach (var view in _views) diff --git a/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs b/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs index e1a9ae76..43a053c8 100644 --- a/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs +++ b/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs @@ -33,6 +33,24 @@ private EventColumnView(IEventColumnReader reader, int[] order, int[] rankByPhys // so the current display order is a valid re-sort input. internal ReadOnlySpan Survivors => _order; + public void BucketTimeTicksByEventData( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventData( + _rankByPhysical, + minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + targetCodes, + slotCounts, + cancellationToken); + public void BucketTimeTicksByEventId( long minTicks, long bucketSpanTicks, @@ -78,6 +96,12 @@ public void BucketTimeTicksBySeverity( slotCounts, cancellationToken); + public void CountEventDataValues( + string fieldName, + IDictionary counts, + CancellationToken cancellationToken) => + _reader.CountEventDataValues(_rankByPhysical, fieldName, counts, cancellationToken); + public void CountEventIds(IDictionary counts, CancellationToken cancellationToken) => _reader.CountEventIds(_rankByPhysical, counts, cancellationToken); diff --git a/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs b/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs index 56b73172..d31e50c1 100644 --- a/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs +++ b/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs @@ -15,6 +15,19 @@ public interface IEventColumnView { int Count { get; } + /// + /// Group-by variant keyed on a named EventData field's whole-number code; (targetCodes length + 1) slots per bin, + /// with the trailing "other" slot also absorbing rows that lack the field. + /// + void BucketTimeTicksByEventData( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken); + /// Group-by variant keyed on the numeric event id; (targetIds length + 1) slots per bin. void BucketTimeTicksByEventId( long minTicks, @@ -48,6 +61,12 @@ void BucketTimeTicksBySeverity( int[] slotCounts, CancellationToken cancellationToken); + /// + /// Tallies this view's rows by the whole-number code of a named EventData field (accumulating across a combined + /// view, since a numeric code is store-independent); resolves the top-N group-by categories for the histogram. + /// + void CountEventDataValues(string fieldName, IDictionary counts, CancellationToken cancellationToken); + /// /// Tallies this view's rows by numeric event id into (accumulating across a combined /// view). diff --git a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor index 318e0bf3..285872ce 100644 --- a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor +++ b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor @@ -14,10 +14,12 @@ ValueChanged="OnDimensionSelected"> + + @if (_baseData is { } legendData) @@ -178,6 +180,10 @@ } + else if (_baseData is { GroupingFieldAbsent: true }) + { +
No @DimensionLabel(_dimension) values in this view.
+ } else {
No events to chart in the current view.
diff --git a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.cs b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.cs index 9f2ee6d7..102d66c5 100644 --- a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.cs +++ b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.cs @@ -243,7 +243,9 @@ protected override void OnInitialized() private static string DimensionLabel(HistogramDimension dimension) => dimension switch { HistogramDimension.EventId => "Event ID", + HistogramDimension.LogonType => "Logon Type", HistogramDimension.TaskCategory => "Task Category", + HistogramDimension.TicketEncryptionType => "Ticket Encryption Type", _ => dimension.ToString() }; @@ -263,9 +265,17 @@ private void AggregateAndRender(bool syncScrollbar = true) { _binCursor = null; - if (_baseData is not { } data) + if (_baseData is not { } data || data.GroupingFieldAbsent) { _render = null; + + if (_baseData is { GroupingFieldAbsent: true }) + { + // Match the visible empty-state so a screen reader is told why the timeline is blank, and drop any stale bin readout. + _binAnnouncement = string.Empty; + _announcement = $"No {DimensionLabel(_dimension)} values in this view."; + } + StateHasChanged(); return; @@ -561,6 +571,10 @@ private void OnDimensionSelected(HistogramDimension dimension) _dimension = dimension; _hiddenGroups.Clear(); + + // A retained absent-field result would otherwise render its empty-state under the newly-selected dimension's name until the rescan lands. + if (_baseData is { GroupingFieldAbsent: true }) { _baseData = null; _render = null; } + RecomputeSegmentHeights(); StartScan(); diff --git a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.css b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.css index 761680f9..5fe6aef1 100644 --- a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.css +++ b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.css @@ -41,7 +41,7 @@ .histogram-groupby ::deep .dropdown-input { margin: 0; } .histogram-groupby ::deep .histogram-dimension-select { - width: 8rem; + width: 13rem; margin: 0; } diff --git a/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs b/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs index 340496d7..fcfe0338 100644 --- a/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs +++ b/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs @@ -36,6 +36,44 @@ public LegacyEventColumnReader(EventLogId logId, int generation, long contentVer public IReadOnlyList Pool => StringPool().Values; + public void BucketTimeTicksByEventData( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(fieldName); + ArgumentNullException.ThrowIfNull(targetCodes); + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + int slotCount = targetCodes.Length + 1; + int otherSlot = targetCodes.Length; + + for (int index = 0; index < _events.Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + long bucket = (_events[index].TimeCreated.Ticks - minTicks) / bucketSpanTicks; + int clamped = bucket < 0 ? 0 : bucket >= bucketCount ? bucketCount - 1 : (int)bucket; + int slot = otherSlot; + + if (_events[index].EventData.TryGetValue(fieldName, out EventFieldValue value) && value.TryGetWholeNumber(out long code)) + { + for (int target = 0; target < targetCodes.Length; target++) + { + if (targetCodes[target] == code) { slot = target; break; } + } + } + + slotCounts[(clamped * slotCount) + slot]++; + } + } + public void BucketTimeTicksByEventId( ReadOnlySpan rankByPhysical, long minTicks, @@ -201,6 +239,23 @@ public void CopyPoolIndexColumn(EventFieldId field, int[] poolIndices) } } + public void CountEventDataValues(ReadOnlySpan rankByPhysical, string fieldName, IDictionary counts, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(fieldName); + ArgumentNullException.ThrowIfNull(counts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + for (int index = 0; index < _events.Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + if (_events[index].EventData.TryGetValue(fieldName, out EventFieldValue value) && value.TryGetWholeNumber(out long code)) + { + counts[code] = counts.TryGetValue(code, out int existing) ? existing + 1 : 1; + } + } + } + public void CountEventIds(ReadOnlySpan rankByPhysical, IDictionary counts, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(counts); diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs new file mode 100644 index 00000000..e92b92f4 --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs @@ -0,0 +1,135 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Eventing.TestUtils; + +namespace EventLogExpert.Eventing.Tests.Common.Events; + +public sealed class EventColumnStoreEventDataTests +{ + private static readonly EventLogId s_logId = EventLogId.Create(); + + [Fact] + public void BucketTimeTicksByEventData_ClassifiesRowsByCodeWithOtherForNonTargets() + { + IEventColumnReader reader = ReaderFor( + Event("LogonType", 3L), + Event("LogonType", 3L), + Event("LogonType", 10L), + Event("LogonType", 7L)); // 7 is not a target -> Other + + long[] targetCodes = [3, 10]; + int slotCount = targetCodes.Length + 1; + int[] slotCounts = new int[slotCount]; + reader.BucketTimeTicksByEventData(AllSurvive(reader.Count), 0, long.MaxValue, 1, "LogonType", targetCodes, slotCounts, CancellationToken.None); + + Assert.Equal(2, slotCounts[0]); // code 3 + Assert.Equal(1, slotCounts[1]); // code 10 + Assert.Equal(1, slotCounts[2]); // Other (code 7) + } + + [Fact] + public void BucketTimeTicksByEventData_IsAllocationFreeOnSealedRows() + { + // EventColumnStore.Build seals the whole batch. One schema means the field-index memo resolves once, so the + // per-row path performs no allocation; only the fixed per-scan memo array (int[schemaCount]) is charged. + var events = new ResolvedEvent[8192]; + + for (int index = 0; index < events.Length; index++) + { + events[index] = Event("LogonType", index % 3 == 0 ? 3L : 10L, index); + } + + IEventColumnReader reader = EventColumnStore.Build(events, generation: 0, contentVersion: 0).CreateReader(s_logId); + int[] rank = AllSurvive(reader.Count); + long[] targetCodes = [3, 10]; + int[] slotCounts = new int[targetCodes.Length + 1]; + + // Warm: first scan resolves the schema and JITs. + reader.BucketTimeTicksByEventData(rank, 0, long.MaxValue, 1, "LogonType", targetCodes, slotCounts, CancellationToken.None); + + long before = GC.GetAllocatedBytesForCurrentThread(); + reader.BucketTimeTicksByEventData(rank, 0, long.MaxValue, 1, "LogonType", targetCodes, slotCounts, CancellationToken.None); + long delta = GC.GetAllocatedBytesForCurrentThread() - before; + + // A single per-row byte would cost 8192; the fixed memo array is a few dozen bytes, so this proves the hot path is allocation-free. + Assert.True(delta < 512, $"Per-row allocation detected: {delta} bytes over {events.Length} sealed rows."); + } + + [Fact] + public void CountEventDataValues_FoldsDecimalAndHexSpellingsOfOneCode() + { + IEventColumnReader reader = ReaderFor( + Event("TicketEncryptionType", 23L), + Event("TicketEncryptionType", "0x17"), + Event("TicketEncryptionType", 18L)); + + var counts = new Dictionary(); + reader.CountEventDataValues(AllSurvive(reader.Count), "TicketEncryptionType", counts, CancellationToken.None); + + Assert.Equal(2, counts.Count); + Assert.Equal(2, counts[23]); // decimal 23 and hex "0x17" fold to the same code + Assert.Equal(1, counts[18]); + } + + [Fact] + public void CountEventDataValues_OmitsRowsThatLackTheField() + { + IEventColumnReader reader = ReaderFor(Event("LogonType", 3L), EventWithoutData()); + + var counts = new Dictionary(); + reader.CountEventDataValues(AllSurvive(reader.Count), "LogonType", counts, CancellationToken.None); + + Assert.Single(counts); + Assert.Equal(1, counts[3]); + } + + [Fact] + public void CountEventDataValues_ReadsUnsignedIntegralCodes() + { + // A UInt32-typed value exercises the unsigned direct-read branch and must fold with the signed Int64 spelling of the same code. + IEventColumnReader reader = ReaderFor(Event("LogonType", (uint)3), Event("LogonType", 3L)); + + var counts = new Dictionary(); + reader.CountEventDataValues(AllSurvive(reader.Count), "LogonType", counts, CancellationToken.None); + + Assert.Single(counts); + Assert.Equal(2, counts[3]); + } + + [Fact] + public void CountEventDataValues_RejectsHexCodeThatOverflowsALong() + { + // A high-bit hex form must not wrap to a negative code (-1) and fold with a real code. + IEventColumnReader reader = ReaderFor(Event("TicketEncryptionType", "0xFFFFFFFFFFFFFFFF"), Event("TicketEncryptionType", 23L)); + + var counts = new Dictionary(); + reader.CountEventDataValues(AllSurvive(reader.Count), "TicketEncryptionType", counts, CancellationToken.None); + + Assert.Single(counts); + Assert.Equal(1, counts[23]); + Assert.DoesNotContain(-1L, counts.Keys); + } + + private static int[] AllSurvive(int count) + { + int[] rank = new int[count]; + + for (int index = 0; index < count; index++) { rank[index] = index; } + + return rank; + } + + private static ResolvedEvent Event(string fieldName, object value, int tick = 0) => + new ResolvedEvent("TestLog", LogPathType.Channel) { Id = 4624, TimeCreated = new DateTime(tick, DateTimeKind.Utc) } + .WithEventData((fieldName, value)); + + private static ResolvedEvent EventWithoutData() => + new("TestLog", LogPathType.Channel) { Id = 4624, TimeCreated = new DateTime(0, DateTimeKind.Utc) }; + + private static IEventColumnReader ReaderFor(params ResolvedEvent[] events) => + EventColumnStore.Build(events, generation: 0, contentVersion: 0).CreateReader(s_logId); +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Common/Display/EventDataValueDecoderTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Common/Display/EventDataValueDecoderTests.cs new file mode 100644 index 00000000..0b8c52c4 --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Common/Display/EventDataValueDecoderTests.cs @@ -0,0 +1,33 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Common.Display; + +namespace EventLogExpert.Runtime.Tests.Common.Display; + +public sealed class EventDataValueDecoderTests +{ + [Fact] + public void TryDecodeLabel_FieldNameIsCaseInsensitive() => + Assert.Equal("Network", EventDataValueDecoder.TryDecodeLabel("logontype", 3)); + + [Theory] + [InlineData("LogonType", 0, "System")] + [InlineData("LogonType", 3, "Network")] + [InlineData("LogonType", 10, "RemoteInteractive")] + [InlineData("TicketEncryptionType", 17, "AES128")] + [InlineData("TicketEncryptionType", 18, "AES256")] + [InlineData("TicketEncryptionType", 23, "RC4")] + public void TryDecodeLabel_KnownCode_ReturnsFriendlyLabel(string fieldName, long code, string expected) => + Assert.Equal(expected, EventDataValueDecoder.TryDecodeLabel(fieldName, code)); + + [Fact] + public void TryDecodeLabel_UndecodedField_ReturnsNull() => + Assert.Null(EventDataValueDecoder.TryDecodeLabel("TargetUserName", 3)); + + [Theory] + [InlineData("LogonType", 99)] + [InlineData("TicketEncryptionType", 99)] + public void TryDecodeLabel_UnrecognizedCode_ReturnsNull(string fieldName, long code) => + Assert.Null(EventDataValueDecoder.TryDecodeLabel(fieldName, code)); +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramBuilderTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramBuilderTests.cs index 58894249..7eba585b 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramBuilderTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramBuilderTests.cs @@ -4,6 +4,7 @@ using EventLogExpert.Eventing.Common.Channels; using EventLogExpert.Eventing.Common.EventLogs; using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Eventing.TestUtils; using EventLogExpert.Runtime.Histogram; using EventLogExpert.Runtime.LogTable; using EventLogExpert.Runtime.Tests.TestUtils; @@ -158,6 +159,61 @@ public void Build_GroupByLog_LabelsWithShortNameAndKeepsSameShortNameLogsSeparat Assert.Equal(6, data.Total); } + [Fact] + public void Build_GroupByLogonType_FieldAbsentFromView_SignalsEmptyState() + { + var view = DisplayViewTestFactory.Build(s_logId, EventsAt(0, 100, 200)); // no EventData at all + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.LogonType, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.True(data!.GroupingFieldAbsent); + Assert.Empty(data.Groups); + Assert.Equal(3, data.Total); // the true survivor count, so the accessible region label isn't "0 events" + } + + [Fact] + public void Build_GroupByLogonType_RowsWithoutTheFieldFallToOther() + { + var view = DisplayViewTestFactory.Build(s_logId, EventDataEvents("LogonType", (3, 2), (null, 3))); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.LogonType, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal(3, GroupTotal(data, 0)); // Other absorbs the 3 field-less rows + Assert.Equal("Network", data!.Groups[1].Label); + Assert.Equal(2, GroupTotal(data, 1)); + } + + [Fact] + public void Build_GroupByLogonType_SplitsByDecodedLabel() + { + // LogonType 3 = Network (x3), 10 = RemoteInteractive (x2). + var view = DisplayViewTestFactory.Build(s_logId, EventDataEvents("LogonType", (3, 3), (10, 2))); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.LogonType, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.False(data!.GroupingFieldAbsent); + Assert.Equal("Network", data.Groups[1].Label); + Assert.Equal("cat:3", data.Groups[1].Key); + Assert.Equal(3, GroupTotal(data, 1)); + Assert.Equal("RemoteInteractive", data.Groups[2].Label); + Assert.Equal(2, GroupTotal(data, 2)); + } + + [Fact] + public void Build_GroupByLogonType_UnrecognizedCode_KeepsTheRawCodeAsLabel() + { + var view = DisplayViewTestFactory.Build(s_logId, EventDataEvents("LogonType", (99, 2))); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.LogonType, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal("99", data!.Groups[1].Label); + Assert.Equal("cat:99", data.Groups[1].Key); + } + [Fact] public void Build_GroupBySource_FoldsValuesBeyondTheTopCapIntoOther() { @@ -194,6 +250,21 @@ public void Build_GroupBySource_RanksTopCategoriesByCountDescending() Assert.Equal(6, data.Total); } + [Fact] + public void Build_GroupByTicketEncryptionType_DecimalAndHexCodesFoldIntoOneBand() + { + // 0x17 and 23 are the same RC4 etype; the numeric-code scan must fold both spellings into one group. + var view = DisplayViewTestFactory.Build(s_logId, EventDataEvents("TicketEncryptionType", (23, 2), ("0x17", 1))); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.TicketEncryptionType, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal(2, data!.Groups.Count); // Other + a single RC4 band + Assert.Equal("RC4", data.Groups[1].Label); + Assert.Equal("cat:23", data.Groups[1].Key); + Assert.Equal(3, GroupTotal(data, 1)); + } + [Fact] public void Build_RecordsPerSeverityCountsInTheBaseBuffer() { @@ -225,6 +296,28 @@ private static ResolvedEvent EventAt(long ticks, string level) => Level = level }; + private static ResolvedEvent[] EventDataEvents(string fieldName, params (object? Value, int Count)[] groups) + { + var events = new List(); + long ticks = 0; + + foreach ((object? value, int count) in groups) + { + for (int index = 0; index < count; index++) + { + var @event = new ResolvedEvent("TestLog", LogPathType.Channel) + { + Id = 4624, + TimeCreated = new DateTime(ticks++, DateTimeKind.Utc) + }; + + events.Add(value is null ? @event : @event.WithEventData((fieldName, value))); + } + } + + return [.. events]; + } + private static ResolvedEvent[] EventsAt(params long[] ticks) { var events = new ResolvedEvent[ticks.Length]; diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TestSupport/LegacyEventColumnView.cs b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TestSupport/LegacyEventColumnView.cs index 9e0cc112..ba3d8683 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TestSupport/LegacyEventColumnView.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TestSupport/LegacyEventColumnView.cs @@ -21,6 +21,9 @@ internal sealed class LegacyEventColumnView( public IEventColumnReader Reader => _reader; + public void BucketTimeTicksByEventData(long minTicks, long bucketSpanTicks, int bucketCount, string fieldName, long[] targetCodes, int[] slotCounts, CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventData(AllSurvive(), minTicks, bucketSpanTicks, bucketCount, fieldName, targetCodes, slotCounts, cancellationToken); + public void BucketTimeTicksByEventId(long minTicks, long bucketSpanTicks, int bucketCount, int[] targetIds, int[] slotCounts, CancellationToken cancellationToken) => _reader.BucketTimeTicksByEventId(AllSurvive(), minTicks, bucketSpanTicks, bucketCount, targetIds, slotCounts, cancellationToken); @@ -30,6 +33,9 @@ public void BucketTimeTicksByField(long minTicks, long bucketSpanTicks, int buck public void BucketTimeTicksBySeverity(long minTicks, long bucketSpanTicks, int bucketCount, int[] slotCounts, CancellationToken cancellationToken) => _reader.BucketTimeTicksBySeverity(AllSurvive(), minTicks, bucketSpanTicks, bucketCount, slotCounts, cancellationToken); + public void CountEventDataValues(string fieldName, IDictionary counts, CancellationToken cancellationToken) => + _reader.CountEventDataValues(AllSurvive(), fieldName, counts, cancellationToken); + public void CountEventIds(IDictionary counts, CancellationToken cancellationToken) => _reader.CountEventIds(AllSurvive(), counts, cancellationToken); From a168395746c23e77ea810d437e424846f963d933 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Fri, 17 Jul 2026 18:29:53 +0000 Subject: [PATCH 4/6] Clear stale histogram status when switching group-by dimension --- .../LogTable/Histogram/HistogramPane.razor.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.cs b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.cs index 102d66c5..692a0483 100644 --- a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.cs +++ b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.cs @@ -572,8 +572,16 @@ private void OnDimensionSelected(HistogramDimension dimension) _dimension = dimension; _hiddenGroups.Clear(); - // A retained absent-field result would otherwise render its empty-state under the newly-selected dimension's name until the rescan lands. - if (_baseData is { GroupingFieldAbsent: true }) { _baseData = null; _render = null; } + // A retained absent-field result would otherwise render its empty-state under the newly-selected dimension's name + // until the rescan lands; clear the live-region status with it so a screen reader isn't left holding the previous + // dimension's "no values" message during the gap. + if (_baseData is { GroupingFieldAbsent: true }) + { + _baseData = null; + _render = null; + _announcement = string.Empty; + _binAnnouncement = string.Empty; + } RecomputeSegmentHeights(); From 1017646f5a5a5ee048a03f2f7cfdeb9ee5adb830 Mon Sep 17 00:00:00 2001 From: jschick04 Date: Fri, 17 Jul 2026 18:47:22 +0000 Subject: [PATCH 5/6] Correct the group-by empty-state comments to match the actual condition --- src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs | 7 ++++--- src/EventLogExpert.Runtime/Histogram/HistogramData.cs | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs b/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs index 4f9876bb..17ac80d4 100644 --- a/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs +++ b/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs @@ -71,9 +71,10 @@ private static HistogramData BuildByEventData( if (counts.Count == 0) { - // No row in the view carries this field. Report the true survivor count (view.Count - every survivor falls within - // the view's own min/max span) so the accessible region label isn't "0 events" over a non-empty span, and flag the - // empty-state so the pane shows a message rather than a lone "Other" band. + // No row in the view yields a decodable whole-number value for this field - the field is absent from every row, + // or every occurrence is non-numeric or out of range. Report the true survivor count (view.Count - every survivor + // falls within the view's own min/max span) so the accessible region label isn't "0 events" over a non-empty span, + // and flag the empty-state so the pane shows a message rather than a lone "Other" band. return new HistogramData([], 0, bucketCount, minUtc, maxUtc, view.Count, bucketSpanTicks, []) { GroupingFieldAbsent = true }; } diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramData.cs b/src/EventLogExpert.Runtime/Histogram/HistogramData.cs index 814fffea..96f62c5a 100644 --- a/src/EventLogExpert.Runtime/Histogram/HistogramData.cs +++ b/src/EventLogExpert.Runtime/Histogram/HistogramData.cs @@ -14,7 +14,8 @@ public sealed record HistogramData( long BucketSpanTicks, IReadOnlyList Groups) { - // True when the selected group-by dimension is a named EventData field that no row in the view carries, so the pane - // shows an explicit empty-state rather than a single, meaningless "Other" band. + // True when the selected group-by dimension is a named EventData field for which no row in the view yields a decodable + // whole-number value (the field is absent from every row, or every occurrence is non-numeric or out of range), so the + // pane shows an explicit empty-state rather than a single, meaningless "Other" band. public bool GroupingFieldAbsent { get; init; } } From 613d7d2ae0a0270736f981032b69789526e6802e Mon Sep 17 00:00:00 2001 From: jschick04 Date: Fri, 17 Jul 2026 19:01:40 +0000 Subject: [PATCH 6/6] Use consistent argument style in the timeline sort resolver tests --- .../LogTable/TimelineDefaultSortTests.cs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TimelineDefaultSortTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TimelineDefaultSortTests.cs index 653f9772..729adafc 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TimelineDefaultSortTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TimelineDefaultSortTests.cs @@ -19,7 +19,7 @@ public void ResolveDefaultOrderBy_WhenGrouped_IgnoresTimeline() { // Grouping short-circuits the default, so the timeline flag cannot force a column sort. Assert.Null( - ResolvedEventOrdering.ResolveDefaultOrderBy(orderBy: null, ColumnName.Source, logCount: 1, timelineVisible: true)); + ResolvedEventOrdering.ResolveDefaultOrderBy(orderBy: null, groupBy: ColumnName.Source, logCount: 1, timelineVisible: true)); } [Fact] @@ -28,7 +28,7 @@ public void ResolveDefaultOrderBy_WithExplicitSort_IgnoresTimeline() // An explicit column sort always wins over the timeline-driven default, even for a single log. Assert.Equal( ColumnName.EventId, - ResolvedEventOrdering.ResolveDefaultOrderBy(ColumnName.EventId, groupBy: null, logCount: 1, timelineVisible: true)); + ResolvedEventOrdering.ResolveDefaultOrderBy(orderBy: ColumnName.EventId, groupBy: null, logCount: 1, timelineVisible: true)); } [Theory] @@ -41,11 +41,7 @@ public void ResolveDefaultOrderBy_WithoutExplicitSort_FollowsLogCountAndTimeline bool timelineVisible, ColumnName? expected) { - var resolved = ResolvedEventOrdering.ResolveDefaultOrderBy( - orderBy: null, - groupBy: null, - logCount, - timelineVisible); + var resolved = ResolvedEventOrdering.ResolveDefaultOrderBy(null, null, logCount, timelineVisible); Assert.Equal(expected, resolved); }