diff --git a/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs b/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs index 8840eba8..f6f177fc 100644 --- a/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs +++ b/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs @@ -7,6 +7,7 @@ using EventLogExpert.Eventing.Resolvers; using EventLogExpert.Eventing.Structured; using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; using System.Security.Principal; @@ -232,6 +233,8 @@ public bool TryGetTimeRange(out long minTicks, out long maxTicks) return Count > 0; } + internal static bool IsUsableRawValue([NotNullWhen(true)] string? value) => !string.IsNullOrWhiteSpace(value) && value != "-"; + internal void BucketTimeTicksByEventData( ReadOnlySpan rankByPhysical, long minTicks, @@ -350,6 +353,275 @@ internal void BucketTimeTicksByEventDataHResult( } } + internal void BucketTimeTicksByEventDataHResultWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, + long[] targetCodes, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + int otherSlot = targetCodes.Length; + Span eligibleBuffer = eligibleProviders.Count <= MaxStackProviderNames + ? stackalloc int[eligibleProviders.Count] + : new int[eligibleProviders.Count]; + ReadOnlySpan eligible = ResolveEligibleSourceIndices(eligibleProviders, eligibleBuffer); + Span userDataPathBuffer = userDataErrorCodePaths.Count <= MaxStackProviderNames + ? stackalloc int[userDataErrorCodePaths.Count] + : new int[userDataErrorCodePaths.Count]; + ReadOnlySpan userDataPathIndices = ResolveUserDataPathIndices(userDataErrorCodePaths, userDataPathBuffer); + int[] fieldIndexBySchema = NewSchemaFieldMemo(); + int offset = 0; + + foreach (EventColumnChunk chunk in _sealedChunks) + { + cancellationToken.ThrowIfCancellationRequested(); + + ReadOnlySpan timeColumn = chunk.TimeTicksColumn; + ReadOnlySpan sourceColumn = chunk.PoolIndexColumn(EventColumnField.Source); + + for (int row = 0; row < timeColumn.Length; row++) + { + int physical = offset + row; + + if (rankByPhysical[physical] < 0) { continue; } + + if (eligible.BinarySearch(sourceColumn[row]) < 0) { continue; } + + if (!TryGetSealedEventDataHResult(chunk, row, fieldName, fieldIndexBySchema, out long code) + && !TryGetSealedUserDataHResult(chunk, row, userDataPathIndices, out code)) { continue; } + + int bucket = ToBucket(timeColumn[row], minTicks, bucketSpanTicks, bucketCount); + int slot = SlotForCode(code, targetCodes, otherSlot); + AddTieSlot(slotCounts, slotColorMask, highlightWinners, physical, bucket, slotCount, slot); + } + + offset += chunk.RowCount; + } + + if (_sealedCount >= Count) { return; } + + IReadOnlySet eligibleNames = AsOrdinalSet(eligibleProviders); + + for (int index = _sealedCount; index < Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + ResolvedEvent pending = Pending(index); + + if (!eligibleNames.Contains(pending.Source)) { continue; } + + if (!TryGetPendingEventDataHResult(pending, fieldName, out long code) + && !TryGetPendingUserDataHResult(pending, userDataErrorCodePaths, out code)) { continue; } + + int bucket = ToBucket(pending.TimeCreated.Ticks, minTicks, bucketSpanTicks, bucketCount); + int slot = SlotForCode(code, targetCodes, otherSlot); + AddTieSlot(slotCounts, slotColorMask, highlightWinners, index, bucket, slotCount, slot); + } + } + + internal void BucketTimeTicksByEventDataString( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + int otherSlot = slotCount - 1; + int[][] fieldIndexBySchemaPerCandidate = new int[candidateFields.Length][]; + + for (int candidate = 0; candidate < candidateFields.Length; candidate++) { fieldIndexBySchemaPerCandidate[candidate] = 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 = otherSlot; + + for (int candidate = 0; candidate < candidateFields.Length; candidate++) + { + if (TryGetSealedEventDataString(chunk, row, candidateFields[candidate], fieldIndexBySchemaPerCandidate[candidate], out string? raw) + && IsUsableRawValue(raw)) + { + slot = rawValueToSlot.GetValueOrDefault(raw, otherSlot); + + break; + } + } + + 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 = otherSlot; + + for (int candidate = 0; candidate < candidateFields.Length; candidate++) + { + if (TryGetPendingEventDataString(pending, candidateFields[candidate], out string? raw) && IsUsableRawValue(raw)) + { + slot = rawValueToSlot.GetValueOrDefault(raw, otherSlot); + + break; + } + } + + int bucket = ToBucket(pending.TimeCreated.Ticks, minTicks, bucketSpanTicks, bucketCount); + slotCounts[(bucket * slotCount) + slot]++; + } + } + + internal void BucketTimeTicksByEventDataStringWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + int otherSlot = slotCount - 1; + int[][] fieldIndexBySchemaPerCandidate = new int[candidateFields.Length][]; + + for (int candidate = 0; candidate < candidateFields.Length; candidate++) { fieldIndexBySchemaPerCandidate[candidate] = NewSchemaFieldMemo(); } + + int offset = 0; + + foreach (EventColumnChunk chunk in _sealedChunks) + { + cancellationToken.ThrowIfCancellationRequested(); + + ReadOnlySpan timeColumn = chunk.TimeTicksColumn; + + for (int row = 0; row < timeColumn.Length; row++) + { + int physical = offset + row; + + if (rankByPhysical[physical] < 0) { continue; } + + int slot = otherSlot; + + for (int candidate = 0; candidate < candidateFields.Length; candidate++) + { + if (TryGetSealedEventDataString(chunk, row, candidateFields[candidate], fieldIndexBySchemaPerCandidate[candidate], out string? raw) + && IsUsableRawValue(raw)) + { + slot = rawValueToSlot.GetValueOrDefault(raw, otherSlot); + + break; + } + } + + int bucket = ToBucket(timeColumn[row], minTicks, bucketSpanTicks, bucketCount); + AddTieSlot(slotCounts, slotColorMask, highlightWinners, physical, bucket, slotCount, slot); + } + + offset += chunk.RowCount; + } + + for (int index = _sealedCount; index < Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + ResolvedEvent pending = Pending(index); + int slot = otherSlot; + + for (int candidate = 0; candidate < candidateFields.Length; candidate++) + { + if (TryGetPendingEventDataString(pending, candidateFields[candidate], out string? raw) && IsUsableRawValue(raw)) + { + slot = rawValueToSlot.GetValueOrDefault(raw, otherSlot); + break; + } + } + + int bucket = ToBucket(pending.TimeCreated.Ticks, minTicks, bucketSpanTicks, bucketCount); + AddTieSlot(slotCounts, slotColorMask, highlightWinners, index, bucket, slotCount, slot); + } + } + + internal void BucketTimeTicksByEventDataWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + 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++) + { + int physical = offset + row; + + if (rankByPhysical[physical] < 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); + AddTieSlot(slotCounts, slotColorMask, highlightWinners, physical, 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); + AddTieSlot(slotCounts, slotColorMask, highlightWinners, index, bucket, slotCount, slot); + } + } + internal void BucketTimeTicksByEventId( ReadOnlySpan rankByPhysical, long minTicks, @@ -391,6 +663,53 @@ internal void BucketTimeTicksByEventId( } } + internal void BucketTimeTicksByEventIdWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] targetIds, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + int otherSlot = targetIds.Length; + int offset = 0; + + foreach (EventColumnChunk chunk in _sealedChunks) + { + cancellationToken.ThrowIfCancellationRequested(); + + ReadOnlySpan timeColumn = chunk.TimeTicksColumn; + ReadOnlySpan idColumn = chunk.IdColumn; + + for (int row = 0; row < timeColumn.Length; row++) + { + int physical = offset + row; + + if (rankByPhysical[physical] < 0) { continue; } + + int bucket = ToBucket(timeColumn[row], minTicks, bucketSpanTicks, bucketCount); + int slot = SlotForIndex(idColumn[row], targetIds, otherSlot); + AddTieSlot(slotCounts, slotColorMask, highlightWinners, physical, bucket, slotCount, slot); + } + + offset += chunk.RowCount; + } + + for (int index = _sealedCount; index < Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + ResolvedEvent pending = Pending(index); + int bucket = ToBucket(pending.TimeCreated.Ticks, minTicks, bucketSpanTicks, bucketCount); + int slot = SlotForIndex(pending.Id, targetIds, otherSlot); + AddTieSlot(slotCounts, slotColorMask, highlightWinners, index, bucket, slotCount, slot); + } + } + internal void BucketTimeTicksByField( ReadOnlySpan rankByPhysical, long minTicks, @@ -443,6 +762,61 @@ internal void BucketTimeTicksByField( } } + internal void BucketTimeTicksByFieldWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + EventColumnField field, + string[] targetValues, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + int otherSlot = targetValues.Length; + int[] targetIndices = new int[targetValues.Length]; + + for (int slot = 0; slot < targetValues.Length; slot++) + { + targetIndices[slot] = _pool.TryGetIndex(targetValues[slot], out int index) ? index : int.MinValue; + } + + int offset = 0; + + foreach (EventColumnChunk chunk in _sealedChunks) + { + cancellationToken.ThrowIfCancellationRequested(); + + ReadOnlySpan timeColumn = chunk.TimeTicksColumn; + ReadOnlySpan valueColumn = chunk.PoolIndexColumn(field); + + for (int row = 0; row < timeColumn.Length; row++) + { + int physical = offset + row; + + if (rankByPhysical[physical] < 0) { continue; } + + int bucket = ToBucket(timeColumn[row], minTicks, bucketSpanTicks, bucketCount); + int slot = SlotForIndex(valueColumn[row], targetIndices, otherSlot); + AddTieSlot(slotCounts, slotColorMask, highlightWinners, physical, bucket, slotCount, slot); + } + + offset += chunk.RowCount; + } + + for (int index = _sealedCount; index < Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + ResolvedEvent pending = Pending(index); + int bucket = ToBucket(pending.TimeCreated.Ticks, minTicks, bucketSpanTicks, bucketCount); + int slot = SlotForString(PendingFieldValue(pending, field), targetValues, otherSlot); + AddTieSlot(slotCounts, slotColorMask, highlightWinners, index, bucket, slotCount, slot); + } + } + internal void BucketTimeTicksBySeverity( ReadOnlySpan rankByPhysical, long minTicks, @@ -498,6 +872,65 @@ int SlotOf(int levelPoolIndex) => levelPoolIndex == verboseIndex ? (int)SeverityLevel.Verbose : 0; } + internal void BucketTimeTicksBySeverityWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + int criticalIndex = _pool.TryGetIndex(nameof(SeverityLevel.Critical), out int critical) ? critical : int.MinValue; + int errorIndex = _pool.TryGetIndex(nameof(SeverityLevel.Error), out int error) ? error : int.MinValue; + int warningIndex = _pool.TryGetIndex(nameof(SeverityLevel.Warning), out int warning) ? warning : int.MinValue; + int informationIndex = _pool.TryGetIndex(nameof(SeverityLevel.Information), out int information) ? information : int.MinValue; + int verboseIndex = _pool.TryGetIndex(nameof(SeverityLevel.Verbose), out int verbose) ? verbose : int.MinValue; + + const int SlotCount = LevelSeverity.SlotCount; + int offset = 0; + + foreach (EventColumnChunk chunk in _sealedChunks) + { + cancellationToken.ThrowIfCancellationRequested(); + + ReadOnlySpan timeColumn = chunk.TimeTicksColumn; + ReadOnlySpan levelColumn = chunk.PoolIndexColumn(EventColumnField.Level); + + for (int row = 0; row < timeColumn.Length; row++) + { + int physical = offset + row; + + if (rankByPhysical[physical] < 0) { continue; } + + int bucket = ToBucket(timeColumn[row], minTicks, bucketSpanTicks, bucketCount); + AddTieSlot(slotCounts, slotColorMask, highlightWinners, physical, bucket, SlotCount, SlotOf(levelColumn[row])); + } + + offset += chunk.RowCount; + } + + for (int index = _sealedCount; index < Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + ResolvedEvent pending = Pending(index); + int bucket = ToBucket(pending.TimeCreated.Ticks, minTicks, bucketSpanTicks, bucketCount); + int slot = LevelSeverity.Slot(LevelSeverity.FromLevelName(pending.Level)); + AddTieSlot(slotCounts, slotColorMask, highlightWinners, index, bucket, SlotCount, slot); + } + + return; + + int SlotOf(int levelPoolIndex) => + levelPoolIndex == criticalIndex ? (int)SeverityLevel.Critical : + levelPoolIndex == errorIndex ? (int)SeverityLevel.Error : + levelPoolIndex == warningIndex ? (int)SeverityLevel.Warning : + levelPoolIndex == informationIndex ? (int)SeverityLevel.Information : + levelPoolIndex == verboseIndex ? (int)SeverityLevel.Verbose : 0; + } + /// /// Bulk-copies the column into caller-owned flat arrays over the whole /// physical range [0, ): sealed rows are copied a chunk at a time (no per-row search), the bounded @@ -731,6 +1164,54 @@ internal void CountEventDataHResults( } } + internal void CountEventDataStringValues(ReadOnlySpan rankByPhysical, string[] candidateFields, IDictionary counts, CancellationToken cancellationToken) + { + int[][] fieldIndexBySchemaPerCandidate = new int[candidateFields.Length][]; + for (int candidate = 0; candidate < candidateFields.Length; candidate++) { fieldIndexBySchemaPerCandidate[candidate] = 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; } + + for (int candidate = 0; candidate < candidateFields.Length; candidate++) + { + if (TryGetSealedEventDataString(chunk, row, candidateFields[candidate], fieldIndexBySchemaPerCandidate[candidate], out string? raw) + && IsUsableRawValue(raw)) + { + counts[raw] = counts.TryGetValue(raw, out int existing) ? existing + 1 : 1; + break; + } + } + } + + offset += chunk.RowCount; + } + + for (int index = _sealedCount; index < Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + ResolvedEvent pending = Pending(index); + + for (int candidate = 0; candidate < candidateFields.Length; candidate++) + { + if (TryGetPendingEventDataString(pending, candidateFields[candidate], out string? raw) && IsUsableRawValue(raw)) + { + counts[raw] = counts.TryGetValue(raw, out int existing) ? existing + 1 : 1; + break; + } + } + } + } + internal void CountEventDataValues(ReadOnlySpan rankByPhysical, string fieldName, IDictionary counts, CancellationToken cancellationToken) { int[] fieldIndexBySchema = NewSchemaFieldMemo(); @@ -1194,6 +1675,19 @@ internal bool TryGetTimeTicksRange( internal EventColumnStore WithReloadGeneration(IReadOnlyList batch) => Build(batch, Generation + 1, ContentVersion + 1); + private static void AddTieSlot( + int[] slotCounts, + uint[] slotColorMask, + ReadOnlySpan highlightWinners, + int physical, + int bucket, + int slotCount, + int slot) + { + slotCounts[(bucket * slotCount) + slot]++; + slotColorMask[slot] |= 1u << highlightWinners[physical]; + } + // A pending row's Source is a raw string (not yet pooled), so match it against a scan-local set built once per scan. // Always normalize to Ordinal so pending matching stays byte-identical to the sealed pool's ordinal lookup, regardless // of the caller collection's own comparer. @@ -1355,6 +1849,24 @@ private static bool TryGetPendingEventDataHResult(ResolvedEvent pending, string return false; } + private static bool TryGetPendingEventDataString(ResolvedEvent pending, string fieldName, out string? value) + { + // Count only genuine native-string values: a property whose raw reference is actually a string. This mirrors the + // sealed gate (StoredFieldKind.String only). An exotic reference - e.g. an EvtHandle - renders as + // EventFieldValueKind.String via FromProperty's stringify fallback yet seals as StringForm, so keying on the raw + // reference (not the rendered EventFieldValue kind) keeps the live and sealed groupings in exact lockstep. + if (pending.EventData.TryGetRawValue(fieldName, out EventProperty property) && property.Reference is string text) + { + value = text; + + return true; + } + + value = null; + + return false; + } + private static bool TryGetPendingUserDataHResult(ResolvedEvent pending, IReadOnlyList userDataErrorCodePaths, out long code) { if (!pending.UserData.IsDefaultOrEmpty) @@ -1670,6 +2182,45 @@ private bool TryGetSealedEventDataHResult(EventColumnChunk chunk, int row, strin return TryGetHResult32FromRawField(chunk.RowEventDataField(row, fieldIndex), out code) && code != 0; } + private bool TryGetSealedEventDataString(EventColumnChunk chunk, int row, string fieldName, int[] fieldIndexBySchema, out string? value) + { + int schemaId = chunk.RowEventDataSchemaId(row); + + if ((uint)schemaId >= (uint)fieldIndexBySchema.Length) { value = null; return false; } + + int fieldIndex = fieldIndexBySchema[schemaId]; + + if (fieldIndex == SchemaFieldUnresolved) + { + fieldIndex = ResolveSchemaFieldIndex(schemaId, fieldName); + fieldIndexBySchema[schemaId] = fieldIndex; + } + + if (fieldIndex < 0 || fieldIndex >= chunk.RowEventDataCount(row)) + { + value = null; + + return false; + } + + RawEventDataField field = chunk.RowEventDataField(row, fieldIndex); + + if (field.Kind == StoredFieldKind.String) + { + // Only a genuine native-string property seals as StoredFieldKind.String (EventColumnChunk's `case string`). + // The pending gate mirrors this by requiring the raw property reference to be a string, so a non-string + // reference - which renders as EventFieldValueKind.String via FromProperty's stringify fallback yet seals as + // StringForm - is excluded on BOTH sides and the live and sealed groupings stay identical. + value = PoolGet(field.RefIndex); + + return true; + } + + value = null; + + return false; + } + private bool TryGetSealedUserDataHResult(EventColumnChunk chunk, int row, ReadOnlySpan targetPathIndices, out long code) { if (!targetPathIndices.IsEmpty) diff --git a/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs b/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs index 14d269d0..93a9139d 100644 --- a/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs +++ b/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs @@ -86,6 +86,149 @@ public void BucketTimeTicksByEventDataHResult( _store.BucketTimeTicksByEventDataHResult(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, fieldName, eligibleProviders, userDataErrorCodePaths, targetCodes, slotCount, slotCounts, cancellationToken); } + public void BucketTimeTicksByEventDataHResultWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(fieldName); + ArgumentNullException.ThrowIfNull(eligibleProviders); + ArgumentNullException.ThrowIfNull(userDataErrorCodePaths); + ArgumentNullException.ThrowIfNull(targetCodes); + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentNullException.ThrowIfNull(slotColorMask); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + ArgumentOutOfRangeException.ThrowIfNotEqual(highlightWinners.Length, Count); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketSpanTicks, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketCount, 1); + + int slotCount = targetCodes.Length + 1; + ArgumentOutOfRangeException.ThrowIfLessThan(slotCounts.Length, bucketCount * slotCount); + ArgumentOutOfRangeException.ThrowIfLessThan(slotColorMask.Length, slotCount); + + _store.BucketTimeTicksByEventDataHResultWithTie(rankByPhysical, + highlightWinners, + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + eligibleProviders, + userDataErrorCodePaths, + targetCodes, + slotCount, + slotCounts, + cancellationToken); + } + + public void BucketTimeTicksByEventDataString( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(candidateFields); + ArgumentNullException.ThrowIfNull(rawValueToSlot); + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketSpanTicks, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketCount, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(slotCount, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(slotCounts.Length, bucketCount * slotCount); + + _store.BucketTimeTicksByEventDataString(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, candidateFields, rawValueToSlot, slotCount, slotCounts, cancellationToken); + } + + public void BucketTimeTicksByEventDataStringWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(candidateFields); + ArgumentNullException.ThrowIfNull(rawValueToSlot); + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentNullException.ThrowIfNull(slotColorMask); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + ArgumentOutOfRangeException.ThrowIfNotEqual(highlightWinners.Length, Count); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketSpanTicks, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketCount, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(slotCount, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(slotCounts.Length, bucketCount * slotCount); + ArgumentOutOfRangeException.ThrowIfLessThan(slotColorMask.Length, slotCount); + + _store.BucketTimeTicksByEventDataStringWithTie(rankByPhysical, + highlightWinners, + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + candidateFields, + rawValueToSlot, + slotCount, + slotCounts, + cancellationToken); + } + + public void BucketTimeTicksByEventDataWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(fieldName); + ArgumentNullException.ThrowIfNull(targetCodes); + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentNullException.ThrowIfNull(slotColorMask); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + ArgumentOutOfRangeException.ThrowIfNotEqual(highlightWinners.Length, Count); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketSpanTicks, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketCount, 1); + + int slotCount = targetCodes.Length + 1; + ArgumentOutOfRangeException.ThrowIfLessThan(slotCounts.Length, bucketCount * slotCount); + ArgumentOutOfRangeException.ThrowIfLessThan(slotColorMask.Length, slotCount); + + _store.BucketTimeTicksByEventDataWithTie(rankByPhysical, + highlightWinners, + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + targetCodes, + slotCount, + slotCounts, + cancellationToken); + } + public void BucketTimeTicksByEventId( ReadOnlySpan rankByPhysical, long minTicks, @@ -107,6 +250,41 @@ public void BucketTimeTicksByEventId( _store.BucketTimeTicksByEventId(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, targetIds, slotCount, slotCounts, cancellationToken); } + public void BucketTimeTicksByEventIdWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] targetIds, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(targetIds); + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentNullException.ThrowIfNull(slotColorMask); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + ArgumentOutOfRangeException.ThrowIfNotEqual(highlightWinners.Length, Count); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketSpanTicks, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketCount, 1); + + int slotCount = targetIds.Length + 1; + ArgumentOutOfRangeException.ThrowIfLessThan(slotCounts.Length, bucketCount * slotCount); + ArgumentOutOfRangeException.ThrowIfLessThan(slotColorMask.Length, slotCount); + + _store.BucketTimeTicksByEventIdWithTie(rankByPhysical, + highlightWinners, + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + targetIds, + slotCount, + slotCounts, + cancellationToken); + } + public void BucketTimeTicksByField( ReadOnlySpan rankByPhysical, long minTicks, @@ -129,6 +307,43 @@ public void BucketTimeTicksByField( _store.BucketTimeTicksByField(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, ToColumnField(field), targetValues, slotCount, slotCounts, cancellationToken); } + public void BucketTimeTicksByFieldWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + EventFieldId field, + string[] targetValues, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(targetValues); + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentNullException.ThrowIfNull(slotColorMask); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + ArgumentOutOfRangeException.ThrowIfNotEqual(highlightWinners.Length, Count); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketSpanTicks, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketCount, 1); + + int slotCount = targetValues.Length + 1; + ArgumentOutOfRangeException.ThrowIfLessThan(slotCounts.Length, bucketCount * slotCount); + ArgumentOutOfRangeException.ThrowIfLessThan(slotColorMask.Length, slotCount); + + _store.BucketTimeTicksByFieldWithTie(rankByPhysical, + highlightWinners, + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + ToColumnField(field), + targetValues, + slotCount, + slotCounts, + cancellationToken); + } + public void BucketTimeTicksBySeverity( ReadOnlySpan rankByPhysical, long minTicks, @@ -146,6 +361,28 @@ public void BucketTimeTicksBySeverity( _store.BucketTimeTicksBySeverity(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, slotCounts, cancellationToken); } + public void BucketTimeTicksBySeverityWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentNullException.ThrowIfNull(slotColorMask); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + ArgumentOutOfRangeException.ThrowIfNotEqual(highlightWinners.Length, Count); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketSpanTicks, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketCount, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(slotCounts.Length, bucketCount * LevelSeverity.SlotCount); + ArgumentOutOfRangeException.ThrowIfLessThan(slotColorMask.Length, LevelSeverity.SlotCount); + + _store.BucketTimeTicksBySeverityWithTie(rankByPhysical, highlightWinners, slotColorMask, minTicks, bucketSpanTicks, bucketCount, slotCounts, cancellationToken); + } + public void CopyGuidColumn(EventFieldId field, Guid[] values, bool[] hasValue) { ArgumentNullException.ThrowIfNull(values); @@ -226,6 +463,15 @@ public void CountEventDataHResults( _store.CountEventDataHResults(rankByPhysical, fieldName, eligibleProviders, userDataErrorCodePaths, counts, cancellationToken); } + public void CountEventDataStringValues(ReadOnlySpan rankByPhysical, string[] candidateFields, IDictionary counts, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(candidateFields); + ArgumentNullException.ThrowIfNull(counts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + _store.CountEventDataStringValues(rankByPhysical, candidateFields, counts, cancellationToken); + } + public void CountEventDataValues(ReadOnlySpan rankByPhysical, string fieldName, IDictionary counts, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(fieldName); diff --git a/src/EventLogExpert.Eventing/Common/Events/EventDataView.cs b/src/EventLogExpert.Eventing/Common/Events/EventDataView.cs index 03fd1318..5fd53274 100644 --- a/src/EventLogExpert.Eventing/Common/Events/EventDataView.cs +++ b/src/EventLogExpert.Eventing/Common/Events/EventDataView.cs @@ -4,6 +4,7 @@ using EventLogExpert.Eventing.Readers; using EventLogExpert.Eventing.Resolvers; using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; namespace EventLogExpert.Eventing.Common.Events; @@ -40,7 +41,7 @@ public bool TryGetValue(string? fieldName, out EventFieldValue value) { if (fieldName is not null && TryGetOrdering(out FieldNameOrdering ordering) - && _schema!.TryGetIndex(ordering, fieldName, out int index) + && _schema.TryGetIndex(ordering, fieldName, out int index) && (uint)index < (uint)_values.Length) { value = EventFieldValue.FromProperty(_values[index]); @@ -53,6 +54,23 @@ public bool TryGetValue(string? fieldName, out EventFieldValue value) return false; } + internal bool TryGetRawValue(string? fieldName, out EventProperty property) + { + if (fieldName is not null + && TryGetOrdering(out FieldNameOrdering ordering) + && _schema.TryGetIndex(ordering, fieldName, out int index) + && (uint)index < (uint)_values.Length) + { + property = _values[index]; + + return true; + } + + property = default; + + return false; + } + public bool TryGetName(int index, out string name) { if (TryGetOrdering(out FieldNameOrdering ordering)) @@ -77,6 +95,7 @@ public bool TryGetName(int index, out string name) private ImmutableArray OrderingNames(FieldNameOrdering ordering) => ordering == FieldNameOrdering.Visible ? _schema!.VisibleNames : _schema!.AllNames; + [MemberNotNullWhen(true, nameof(_schema))] private bool TryGetOrdering(out FieldNameOrdering ordering) { if (_schema is not null && !_values.IsDefaultOrEmpty) diff --git a/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs b/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs index 360fd039..5d41cf0b 100644 --- a/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs +++ b/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs @@ -57,6 +57,57 @@ void BucketTimeTicksByEventDataHResult( int[] slotCounts, CancellationToken cancellationToken); + void BucketTimeTicksByEventDataHResultWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken); + + /// Bucket-by-EventData string variant keyed on the first usable named candidate field value. + void BucketTimeTicksByEventDataString( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken); + + void BucketTimeTicksByEventDataStringWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken); + + void BucketTimeTicksByEventDataWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + 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, @@ -67,6 +118,17 @@ void BucketTimeTicksByEventId( int[] slotCounts, CancellationToken cancellationToken); + void BucketTimeTicksByEventIdWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] targetIds, + int[] slotCounts, + CancellationToken cancellationToken); + /// /// Group-by variant for a pooled string field: each survivor lands in its targetValues slot (resolved per store, /// so it sums across differently-pooled logs) else the trailing "other" slot; (targetValues length + 1) slots per bin. @@ -81,6 +143,18 @@ void BucketTimeTicksByField( int[] slotCounts, CancellationToken cancellationToken); + void BucketTimeTicksByFieldWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + EventFieldId field, + string[] targetValues, + int[] slotCounts, + CancellationToken cancellationToken); + /// /// Additively buckets survivors (rank >= 0) by UTC tick and severity slot; bucket-major /// slotCounts[i*LevelSeverity.SlotCount + slot], out-of-domain ticks clamp to the end buckets. @@ -93,6 +167,16 @@ void BucketTimeTicksBySeverity( int[] slotCounts, CancellationToken cancellationToken); + void BucketTimeTicksBySeverityWithTie( + ReadOnlySpan rankByPhysical, + ReadOnlySpan highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken); + /// /// Bulk-copies the column into caller-owned arrays over the physical range /// [0, ). [i] is false for an absent (null) value. @@ -123,6 +207,9 @@ void BucketTimeTicksBySeverity( /// void CountEventDataHResults(ReadOnlySpan rankByPhysical, string fieldName, IReadOnlyCollection eligibleProviders, IReadOnlyList userDataErrorCodePaths, IDictionary counts, CancellationToken cancellationToken); + /// Tallies survivors by the first usable string value from the named EventData candidate fields. + void CountEventDataStringValues(ReadOnlySpan rankByPhysical, string[] candidateFields, IDictionary counts, CancellationToken cancellationToken); + /// /// 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 diff --git a/src/EventLogExpert.Filtering/Compilation/FilterService.cs b/src/EventLogExpert.Filtering/Compilation/FilterService.cs index a4b040b3..783576d4 100644 --- a/src/EventLogExpert.Filtering/Compilation/FilterService.cs +++ b/src/EventLogExpert.Filtering/Compilation/FilterService.cs @@ -16,6 +16,39 @@ public sealed class FilterService : IFilterService /// Outer parallelism only kicks in when the combined work justifies the scheduling overhead. private const int OuterParallelTotalEventThreshold = 10_000; + public static byte[] ClassifyHighlightWinners( + IEventColumnReader reader, + IReadOnlyList survivingOrder, + IReadOnlyList orderedColoredFilters, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(reader); + ArgumentNullException.ThrowIfNull(survivingOrder); + ArgumentNullException.ThrowIfNull(orderedColoredFilters); + ArgumentOutOfRangeException.ThrowIfGreaterThan(orderedColoredFilters.Count, 31); + + ColumnCompiledFilter[] compiledFilters = CompileHighlightColumnFilters(orderedColoredFilters); + byte[] winners = new byte[reader.Count]; + + foreach (int index in survivingOrder) + { + cancellationToken.ThrowIfCancellationRequested(); + + EventLocator locator = reader.LocatorAt(index); + + for (int ordinal = 0; ordinal < compiledFilters.Length; ordinal++) + { + if (compiledFilters[ordinal].Evaluate(reader, locator) != FilterMatch.Match) { continue; } + + winners[index] = (byte)(ordinal + 1); + + break; + } + } + + return winners; + } + public static IReadOnlyList GetSurvivingOrder(IEventColumnReader reader, Filter filter) { ArgumentNullException.ThrowIfNull(reader); @@ -143,6 +176,32 @@ public IReadOnlyList GetFilteredEvents( return compiledFilters; } + private static ColumnCompiledFilter[] CompileHighlightColumnFilters(IReadOnlyList filters) + { + var compiledFilters = new ColumnCompiledFilter[filters.Count]; + + for (int index = 0; index < filters.Count; index++) + { + SavedFilter savedFilter = filters[index]; + + if (savedFilter.Compiled is null) + { + throw new InvalidOperationException("Highlight classification requires a non-null compiled filter."); + } + + if (!FilterCompiler.TryCompileColumn(savedFilter.ComparisonText, out var columnCompiled, out var error)) + { + throw new InvalidOperationException( + $"Filter '{savedFilter.ComparisonText}' has a non-null AoS {nameof(SavedFilter.Compiled)} but failed " + + $"column-direct compilation: {error}"); + } + + compiledFilters[index] = columnCompiled; + } + + return compiledFilters; + } + private static IReadOnlyList FilterEventsSequential( IEnumerable events, Filter filter) => diff --git a/src/EventLogExpert.Runtime/FilterPane/HighlightSelector.cs b/src/EventLogExpert.Runtime/FilterPane/HighlightSelector.cs index 62a3466c..28761dd0 100644 --- a/src/EventLogExpert.Runtime/FilterPane/HighlightSelector.cs +++ b/src/EventLogExpert.Runtime/FilterPane/HighlightSelector.cs @@ -32,6 +32,29 @@ public int ComputeHighlightKey(ImmutableList filters) return hash.ToHashCode(); } + public int ComputePredicatePlanKey(ImmutableList filters) + { + var hash = new HashCode(); + int eligibleCount = 0; + + foreach (var filter in filters) + { + if (filter is not { IsEnabled: true, IsExcluded: false }) { continue; } + + if (filter.Compiled is null) { continue; } + + if (!Enum.IsDefined(filter.Color)) { continue; } + + hash.Add(eligibleCount); + hash.Add(filter.ComparisonText, StringComparer.Ordinal); + eligibleCount++; + } + + hash.Add(eligibleCount); + + return hash.ToHashCode(); + } + public SavedFilter[] Select(ImmutableList filters) { if (filters.IsEmpty) { return []; } diff --git a/src/EventLogExpert.Runtime/FilterPane/IHighlightSelector.cs b/src/EventLogExpert.Runtime/FilterPane/IHighlightSelector.cs index ce2ee50c..c432d914 100644 --- a/src/EventLogExpert.Runtime/FilterPane/IHighlightSelector.cs +++ b/src/EventLogExpert.Runtime/FilterPane/IHighlightSelector.cs @@ -10,5 +10,7 @@ public interface IHighlightSelector { int ComputeHighlightKey(ImmutableList filters); + int ComputePredicatePlanKey(ImmutableList filters); + SavedFilter[] Select(ImmutableList filters); } diff --git a/src/EventLogExpert.Runtime/Histogram/ClearHistogramDimensionRequestAction.cs b/src/EventLogExpert.Runtime/Histogram/ClearHistogramDimensionRequestAction.cs new file mode 100644 index 00000000..c8bc0e98 --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/ClearHistogramDimensionRequestAction.cs @@ -0,0 +1,6 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Histogram; + +internal sealed record ClearHistogramDimensionRequestAction; diff --git a/src/EventLogExpert.Runtime/Histogram/Effects.cs b/src/EventLogExpert.Runtime/Histogram/Effects.cs new file mode 100644 index 00000000..724bb359 --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/Effects.cs @@ -0,0 +1,29 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.EventLog; +using Fluxor; + +namespace EventLogExpert.Runtime.Histogram; + +internal sealed class Effects(IState eventLogState, IState histogramState) +{ + private readonly IState _eventLogState = eventLogState; + private readonly IState _histogramState = histogramState; + + [EffectMethod] + public Task HandleCloseAllLogs(CloseAllLogsAction action, IDispatcher dispatcher) => ClearIfNoLogs(dispatcher); + + [EffectMethod] + public Task HandleCloseLog(CloseLogAction action, IDispatcher dispatcher) => ClearIfNoLogs(dispatcher); + + private Task ClearIfNoLogs(IDispatcher dispatcher) + { + if (_eventLogState.Value.OpenLogs.IsEmpty && _histogramState.Value.DimensionRequest is not null) + { + dispatcher.Dispatch(new ClearHistogramDimensionRequestAction()); + } + + return Task.CompletedTask; + } +} diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs b/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs index b32ba85c..7e0b6ce8 100644 --- a/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs +++ b/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs @@ -15,6 +15,10 @@ public static class HistogramBuilder // and servicing providers so a generic errorCode field on an unrelated provider does not pollute the failure view. private const string ErrorCodeFieldName = "errorCode"; + private static readonly string[] s_parentProcessImageFields = ["ParentProcessName", "ParentImage"]; + private static readonly char[] s_pathSeparators = ['\\', '/']; + private static readonly string[] s_processImageFields = ["NewProcessName", "Image"]; + // Microsoft-Windows-Servicing stores its failure HRESULT in a UserData Cbs*ChangeState/ErrorCode leaf (a hex string) // rather than an EventData errorCode field, so the ErrorCode dimension also probes these curated storage-key paths for // an eligible row whose EventData lacks the code. CbsPackageInitiateChanges carries no ErrorCode and is not listed. @@ -26,6 +30,23 @@ public static class HistogramBuilder IEventColumnView view, HistogramDimension dimension, int maxBuckets, + CancellationToken cancellationToken) => + Build(view, dimension, maxBuckets, useHighlightTie: false, highlightWinners: null, cancellationToken); + + public static HistogramData? BuildWithHighlightTie( + IEventColumnView view, + HistogramDimension dimension, + int maxBuckets, + byte[] highlightWinners, + CancellationToken cancellationToken) => + Build(view, dimension, maxBuckets, useHighlightTie: true, highlightWinners, cancellationToken); + + private static HistogramData? Build( + IEventColumnView view, + HistogramDimension dimension, + int maxBuckets, + bool useHighlightTie, + byte[]? highlightWinners, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(view); @@ -40,20 +61,61 @@ public static class HistogramBuilder if (dimension is HistogramDimension.LogonType or HistogramDimension.TicketEncryptionType) { - return BuildByEventData(view, ToEventDataFieldName(dimension), minTicks, maxTicks, bucketSpanTicks, bucketCount, cancellationToken); + return BuildByEventData(view, + ToEventDataFieldName(dimension), + minTicks, + maxTicks, + bucketSpanTicks, + bucketCount, + useHighlightTie, + highlightWinners, + DimensionOtherNoun(dimension), + cancellationToken); } if (dimension is HistogramDimension.ErrorCode) { - return BuildByErrorCode(view, minTicks, maxTicks, bucketSpanTicks, bucketCount, cancellationToken); + return BuildByErrorCode(view, minTicks, maxTicks, bucketSpanTicks, bucketCount, useHighlightTie, highlightWinners, cancellationToken); + } + + if (dimension is HistogramDimension.ProcessImage or HistogramDimension.ParentProcessImage) + { + return BuildByEventDataString(view, + ProcessImageFieldNames(dimension), + minTicks, + maxTicks, + bucketSpanTicks, + bucketCount, + useHighlightTie, + highlightWinners, + DimensionOtherNoun(dimension), + cancellationToken); } - (int[] slotCounts, int slotCount, IReadOnlyList groups) = dimension switch + (int[] slotCounts, int slotCount, IReadOnlyList groups, uint[]? groupHighlightMasks) = dimension switch { - HistogramDimension.Severity => ScanSeverity(view, minTicks, bucketSpanTicks, bucketCount, cancellationToken), - HistogramDimension.EventId => ScanByEventId(view, minTicks, bucketSpanTicks, bucketCount, cancellationToken), - HistogramDimension.Log => ScanByField(view, EventFieldId.OwningLog, OwningLogDisplay.DistinctShortNames, minTicks, bucketSpanTicks, bucketCount, cancellationToken), - _ => ScanByField(view, ToFieldId(dimension), static keys => keys, minTicks, bucketSpanTicks, bucketCount, cancellationToken) + HistogramDimension.Severity => ScanSeverity(view, minTicks, bucketSpanTicks, bucketCount, useHighlightTie, highlightWinners, cancellationToken), + HistogramDimension.EventId => ScanByEventId(view, minTicks, bucketSpanTicks, bucketCount, useHighlightTie, highlightWinners, cancellationToken), + HistogramDimension.Log => ScanByField(view, + EventFieldId.OwningLog, + OwningLogDisplay.DistinctShortNames, + minTicks, + bucketSpanTicks, + bucketCount, + useHighlightTie, + highlightWinners, + DimensionOtherNoun(HistogramDimension.Log), + cancellationToken), + _ => ScanByField(view, + ToFieldId(dimension), + static keys => keys, + minTicks, + bucketSpanTicks, + bucketCount, + useHighlightTie, + highlightWinners, + DimensionOtherNoun(dimension), + cancellationToken) }; int total = 0; @@ -68,7 +130,8 @@ public static class HistogramBuilder new DateTime(maxTicks, DateTimeKind.Utc), total, bucketSpanTicks, - groups); + groups, + groupHighlightMasks); } private static HistogramData BuildByErrorCode( @@ -77,6 +140,8 @@ private static HistogramData BuildByErrorCode( long maxTicks, long bucketSpanTicks, int bucketCount, + bool useHighlightTie, + byte[]? highlightWinners, CancellationToken cancellationToken) { var counts = new Dictionary(); @@ -96,24 +161,54 @@ private static HistogramData BuildByErrorCode( }; } + int keptCount = ResolveKeptCount(counts.Count, useHighlightTie); long[] targetCodes = counts .OrderByDescending(pair => pair.Value) .ThenBy(pair => pair.Key) - .Take(HistogramConstants.MaxGroupByCategories) + .Take(keptCount) .Select(pair => pair.Key) .ToArray(); int slotCount = targetCodes.Length + 1; int[] slotCounts = new int[bucketCount * slotCount]; - view.BucketTimeTicksByEventDataHResult(minTicks, bucketSpanTicks, bucketCount, ErrorCodeFieldName, s_updateProviders, s_updateErrorCodeUserDataPaths, targetCodes, slotCounts, cancellationToken); + uint[]? slotColorMask = useHighlightTie ? new uint[slotCount] : null; + if (slotColorMask is null) + { + view.BucketTimeTicksByEventDataHResult(minTicks, + bucketSpanTicks, + bucketCount, + ErrorCodeFieldName, + s_updateProviders, + s_updateErrorCodeUserDataPaths, + targetCodes, + slotCounts, + cancellationToken); + } + else + { + view.BucketTimeTicksByEventDataHResultWithTie(RequireHighlightWinners(highlightWinners), + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + ErrorCodeFieldName, + s_updateProviders, + s_updateErrorCodeUserDataPaths, + targetCodes, + slotCounts, + cancellationToken); + } int total = 0; foreach (int count in slotCounts) { total += count; } + string? otherLabel = ResolveOtherLabel(slotCounts, bucketCount, slotCount, counts.Count, keptCount, DimensionOtherNoun(HistogramDimension.ErrorCode)); + // Key = the raw invariant code (stable toggle key); Label = the 8-digit hex form plus its curated HRESULT symbol. string[] keys = Array.ConvertAll(targetCodes, code => code.ToString(CultureInfo.InvariantCulture)); string[] labels = Array.ConvertAll(targetCodes, FormatErrorCodeLabel); + IReadOnlyList groups = HistogramGroups.ForCategories(keys, labels, otherLabel); return new HistogramData( slotCounts, @@ -123,7 +218,8 @@ private static HistogramData BuildByErrorCode( maxUtc, total, bucketSpanTicks, - HistogramGroups.ForCategories(keys, labels)) + groups, + FoldGroupMasks(slotColorMask, groups)) { EventNoun = ErrorCodeEventNoun }; @@ -136,6 +232,9 @@ private static HistogramData BuildByEventData( long maxTicks, long bucketSpanTicks, int bucketCount, + bool useHighlightTie, + byte[]? highlightWinners, + (string Singular, string Plural) otherNoun, CancellationToken cancellationToken) { var counts = new Dictionary(); @@ -153,27 +252,49 @@ private static HistogramData BuildByEventData( return new HistogramData([], 0, bucketCount, minUtc, maxUtc, view.Count, bucketSpanTicks, []) { GroupingFieldAbsent = true }; } + int keptCount = ResolveKeptCount(counts.Count, useHighlightTie); long[] targetCodes = counts .OrderByDescending(pair => pair.Value) .ThenBy(pair => pair.Key) - .Take(HistogramConstants.MaxGroupByCategories) + .Take(keptCount) .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); + uint[]? slotColorMask = useHighlightTie ? new uint[slotCount] : null; + + if (slotColorMask is null) + { + view.BucketTimeTicksByEventData(minTicks, bucketSpanTicks, bucketCount, fieldName, targetCodes, slotCounts, cancellationToken); + } + else + { + view.BucketTimeTicksByEventDataWithTie(RequireHighlightWinners(highlightWinners), + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + targetCodes, + slotCounts, + cancellationToken); + } int total = 0; foreach (int count in slotCounts) { total += count; } + string? otherLabel = ResolveOtherLabel(slotCounts, bucketCount, slotCount, counts.Count, keptCount, otherNoun); + // 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)); + IReadOnlyList groups = HistogramGroups.ForCategories(keys, labels, otherLabel); + return new HistogramData( slotCounts, slotCount, @@ -182,7 +303,131 @@ private static HistogramData BuildByEventData( maxUtc, total, bucketSpanTicks, - HistogramGroups.ForCategories(keys, labels)); + groups, + FoldGroupMasks(slotColorMask, groups)); + } + + private static HistogramData BuildByEventDataString( + IEventColumnView view, + string[] candidateFields, + long minTicks, + long maxTicks, + long bucketSpanTicks, + int bucketCount, + bool useHighlightTie, + byte[]? highlightWinners, + (string Singular, string Plural) otherNoun, + CancellationToken cancellationToken) + { + var rawCounts = new Dictionary(StringComparer.Ordinal); + view.CountEventDataStringValues(candidateFields, rawCounts, cancellationToken); + + var minUtc = new DateTime(minTicks, DateTimeKind.Utc); + var maxUtc = new DateTime(maxTicks, DateTimeKind.Utc); + + var byShortName = new Dictionary(StringComparer.Ordinal); + foreach ((string raw, int count) in rawCounts) + { + string? shortName = NormalizeProcessImage(raw); + if (shortName is null) { continue; } + + byShortName[shortName] = byShortName.TryGetValue(shortName, out int existing) ? existing + count : count; + } + + if (byShortName.Count == 0) + { + return new HistogramData([], 0, bucketCount, minUtc, maxUtc, view.Count, bucketSpanTicks, []) { GroupingFieldAbsent = true }; + } + + int keptCount = ResolveKeptCount(byShortName.Count, useHighlightTie); + string[] topShortNames = byShortName + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Take(keptCount) + .Select(pair => pair.Key) + .ToArray(); + + int slotCount = topShortNames.Length + 1; + int otherSlot = topShortNames.Length; + + var shortNameToSlot = new Dictionary(StringComparer.Ordinal); + for (int index = 0; index < topShortNames.Length; index++) { shortNameToSlot[topShortNames[index]] = index; } + + var rawValueToSlot = new Dictionary(StringComparer.Ordinal); + foreach (string raw in rawCounts.Keys) + { + string? shortName = NormalizeProcessImage(raw); + rawValueToSlot[raw] = shortName is not null && shortNameToSlot.TryGetValue(shortName, out int slot) ? slot : otherSlot; + } + + int[] slotCounts = new int[bucketCount * slotCount]; + uint[]? slotColorMask = useHighlightTie ? new uint[slotCount] : null; + + if (slotColorMask is null) + { + view.BucketTimeTicksByEventDataString(minTicks, + bucketSpanTicks, + bucketCount, + candidateFields, + rawValueToSlot, + slotCount, + slotCounts, + cancellationToken); + } + else + { + view.BucketTimeTicksByEventDataStringWithTie(RequireHighlightWinners(highlightWinners), + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + candidateFields, + rawValueToSlot, + slotCount, + slotCounts, + cancellationToken); + } + + int total = 0; + foreach (int count in slotCounts) { total += count; } + + string? otherLabel = ResolveOtherLabel(slotCounts, bucketCount, slotCount, byShortName.Count, keptCount, otherNoun); + IReadOnlyList groups = HistogramGroups.ForCategories(topShortNames, topShortNames, otherLabel); + + return new HistogramData(slotCounts, slotCount, bucketCount, minUtc, maxUtc, total, bucketSpanTicks, groups, FoldGroupMasks(slotColorMask, groups)); + } + + private static (string Singular, string Plural) DimensionOtherNoun(HistogramDimension dimension) => dimension switch + { + HistogramDimension.EventId => ("event ID", "event IDs"), + HistogramDimension.Source => ("source", "sources"), + HistogramDimension.TaskCategory => ("task category", "task categories"), + HistogramDimension.Opcode => ("opcode", "opcodes"), + HistogramDimension.Log => ("log", "logs"), + HistogramDimension.LogonType => ("logon type", "logon types"), + HistogramDimension.TicketEncryptionType => ("ticket encryption type", "ticket encryption types"), + HistogramDimension.ErrorCode => ("error code", "error codes"), + HistogramDimension.ProcessImage => ("process", "processes"), + HistogramDimension.ParentProcessImage => ("parent process", "parent processes"), + _ => throw new ArgumentOutOfRangeException(nameof(dimension), dimension, "Dimension has no categorical Other noun.") + }; + + private static uint[]? FoldGroupMasks(uint[]? slotColorMask, IReadOnlyList groups) + { + if (slotColorMask is null) { return null; } + + uint[] groupMasks = new uint[groups.Count]; + + for (int group = 0; group < groups.Count; group++) + { + uint mask = 0; + + foreach (int slot in groups[group].SlotIndices) { mask |= slotColorMask[slot]; } + + groupMasks[group] = mask; + } + + return groupMasks; } private static string FormatErrorCodeLabel(long code) @@ -193,71 +438,201 @@ private static string FormatErrorCodeLabel(long code) return symbol is null ? hex : $"{hex} {symbol}"; } - private static (int[] SlotCounts, int SlotCount, IReadOnlyList Groups) ScanByEventId( + private static string? NormalizeProcessImage(string raw) + { + string trimmed = raw.Trim(); + + if (trimmed is ['"', _, ..] && trimmed[^1] == '"') { trimmed = trimmed[1..^1].Trim(); } + + int slash = trimmed.LastIndexOfAny(s_pathSeparators); + string tail = slash >= 0 ? trimmed[(slash + 1)..] : trimmed; + + if (tail.Length == 0 || tail == "-") { return null; } + + return tail.ToLowerInvariant(); + } + + private static string[] ProcessImageFieldNames(HistogramDimension dimension) => dimension switch + { + HistogramDimension.ProcessImage => s_processImageFields, + HistogramDimension.ParentProcessImage => s_parentProcessImageFields, + _ => throw new ArgumentOutOfRangeException(nameof(dimension), dimension, "Dimension is not a process image field.") + }; + + private static byte[] RequireHighlightWinners(byte[]? highlightWinners) => + highlightWinners ?? throw new InvalidOperationException("Highlight winners must be captured before tie bucketing."); + + private static int ResolveKeptCount(int distinct, bool useHighlightTie) => + distinct <= HistogramConstants.MaxGroupByCategories + || (useHighlightTie && distinct <= HistogramConstants.GraceGroupByCategories) + ? distinct + : HistogramConstants.MaxGroupByCategories; + + private static string? ResolveOtherLabel( + int[] slotCounts, + int bucketCount, + int slotCount, + int distinct, + int keptCount, + (string Singular, string Plural) otherNoun) + { + int otherTotal = 0; + int otherSlot = slotCount - 1; + + for (int bucket = 0; bucket < bucketCount; bucket++) + { + otherTotal += slotCounts[(bucket * slotCount) + otherSlot]; + } + + int foldedDistinct = distinct - keptCount; + + return (otherTotal, foldedDistinct) switch + { + (0, _) => null, + (_, 0) => "Other", + (_, 1) => $"Other (1 {otherNoun.Singular})", + _ => $"Other ({foldedDistinct} {otherNoun.Plural})" + }; + } + + private static (int[] SlotCounts, int SlotCount, IReadOnlyList Groups, uint[]? GroupHighlightMasks) ScanByEventId( IEventColumnView view, long minTicks, long bucketSpanTicks, int bucketCount, + bool useHighlightTie, + byte[]? highlightWinners, CancellationToken cancellationToken) { var counts = new Dictionary(); view.CountEventIds(counts, cancellationToken); + int keptCount = ResolveKeptCount(counts.Count, useHighlightTie); int[] targetIds = counts .OrderByDescending(pair => pair.Value) .ThenBy(pair => pair.Key) - .Take(HistogramConstants.MaxGroupByCategories) + .Take(keptCount) .Select(pair => pair.Key) .ToArray(); int slotCount = targetIds.Length + 1; int[] slotCounts = new int[bucketCount * slotCount]; - view.BucketTimeTicksByEventId(minTicks, bucketSpanTicks, bucketCount, targetIds, slotCounts, cancellationToken); + uint[]? slotColorMask = useHighlightTie ? new uint[slotCount] : null; + if (slotColorMask is null) + { + view.BucketTimeTicksByEventId(minTicks, + bucketSpanTicks, + bucketCount, + targetIds, + slotCounts, + cancellationToken); + } + else + { + view.BucketTimeTicksByEventIdWithTie(RequireHighlightWinners(highlightWinners), + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + targetIds, + slotCounts, + cancellationToken); + } + + string? otherLabel = ResolveOtherLabel(slotCounts, bucketCount, slotCount, counts.Count, keptCount, DimensionOtherNoun(HistogramDimension.EventId)); string[] keys = Array.ConvertAll(targetIds, id => id.ToString(CultureInfo.InvariantCulture)); string[] labels = Array.ConvertAll(targetIds, id => id.ToString(CultureInfo.CurrentCulture)); + IReadOnlyList groups = HistogramGroups.ForCategories(keys, labels, otherLabel); - return (slotCounts, slotCount, HistogramGroups.ForCategories(keys, labels)); + return (slotCounts, slotCount, groups, FoldGroupMasks(slotColorMask, groups)); } - private static (int[] SlotCounts, int SlotCount, IReadOnlyList Groups) ScanByField( + private static (int[] SlotCounts, int SlotCount, IReadOnlyList Groups, uint[]? GroupHighlightMasks) ScanByField( IEventColumnView view, EventFieldId field, Func, IReadOnlyList> labelMapper, long minTicks, long bucketSpanTicks, int bucketCount, + bool useHighlightTie, + byte[]? highlightWinners, + (string Singular, string Plural) otherNoun, CancellationToken cancellationToken) { var counts = new Dictionary(StringComparer.Ordinal); view.CountFieldValues(field, counts, cancellationToken); + int keptCount = ResolveKeptCount(counts.Count, useHighlightTie); string[] targetValues = counts .OrderByDescending(pair => pair.Value) .ThenBy(pair => pair.Key, StringComparer.Ordinal) - .Take(HistogramConstants.MaxGroupByCategories) + .Take(keptCount) .Select(pair => pair.Key) .ToArray(); int slotCount = targetValues.Length + 1; int[] slotCounts = new int[bucketCount * slotCount]; - view.BucketTimeTicksByField(minTicks, bucketSpanTicks, bucketCount, field, targetValues, slotCounts, cancellationToken); + uint[]? slotColorMask = useHighlightTie ? new uint[slotCount] : null; + + if (slotColorMask is null) + { + view.BucketTimeTicksByField(minTicks, + bucketSpanTicks, + bucketCount, + field, + targetValues, + slotCounts, + cancellationToken); + } + else + { + view.BucketTimeTicksByFieldWithTie(RequireHighlightWinners(highlightWinners), + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + field, + targetValues, + slotCounts, + cancellationToken); + } + + string? otherLabel = ResolveOtherLabel(slotCounts, bucketCount, slotCount, counts.Count, keptCount, otherNoun); + IReadOnlyList groups = HistogramGroups.ForCategories(targetValues, labelMapper(targetValues), otherLabel); - return (slotCounts, slotCount, HistogramGroups.ForCategories(targetValues, labelMapper(targetValues))); + return (slotCounts, slotCount, groups, FoldGroupMasks(slotColorMask, groups)); } - private static (int[] SlotCounts, int SlotCount, IReadOnlyList Groups) ScanSeverity( + private static (int[] SlotCounts, int SlotCount, IReadOnlyList Groups, uint[]? GroupHighlightMasks) ScanSeverity( IEventColumnView view, long minTicks, long bucketSpanTicks, int bucketCount, + bool useHighlightTie, + byte[]? highlightWinners, CancellationToken cancellationToken) { int slotCount = HistogramGroups.SeveritySlotCount; int[] slotCounts = new int[bucketCount * slotCount]; - view.BucketTimeTicksBySeverity(minTicks, bucketSpanTicks, bucketCount, slotCounts, cancellationToken); + uint[]? slotColorMask = useHighlightTie ? new uint[slotCount] : null; + + if (slotColorMask is null) + { + view.BucketTimeTicksBySeverity(minTicks, bucketSpanTicks, bucketCount, slotCounts, cancellationToken); + } + else + { + view.BucketTimeTicksBySeverityWithTie(RequireHighlightWinners(highlightWinners), + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + slotCounts, + cancellationToken); + } - return (slotCounts, slotCount, HistogramGroups.Severity); + return (slotCounts, slotCount, HistogramGroups.Severity, FoldGroupMasks(slotColorMask, HistogramGroups.Severity)); } private static string ToEventDataFieldName(HistogramDimension dimension) => dimension switch diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramConstants.cs b/src/EventLogExpert.Runtime/Histogram/HistogramConstants.cs index bd6b2ea2..6aa259af 100644 --- a/src/EventLogExpert.Runtime/Histogram/HistogramConstants.cs +++ b/src/EventLogExpert.Runtime/Histogram/HistogramConstants.cs @@ -5,7 +5,7 @@ namespace EventLogExpert.Runtime.Histogram; public static class HistogramConstants { + public const int GraceGroupByCategories = 12; public const int MaxBuckets = 20000; - - public const int MaxGroupByCategories = 4; + public const int MaxGroupByCategories = 8; } diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramData.cs b/src/EventLogExpert.Runtime/Histogram/HistogramData.cs index 1835d230..418b7f9b 100644 --- a/src/EventLogExpert.Runtime/Histogram/HistogramData.cs +++ b/src/EventLogExpert.Runtime/Histogram/HistogramData.cs @@ -12,7 +12,8 @@ public sealed record HistogramData( DateTime MaxUtc, int Total, long BucketSpanTicks, - IReadOnlyList Groups) + IReadOnlyList Groups, + uint[]? GroupHighlightMasks = null) { // 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 diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramDimension.cs b/src/EventLogExpert.Runtime/Histogram/HistogramDimension.cs index e50485dd..ce9bb02c 100644 --- a/src/EventLogExpert.Runtime/Histogram/HistogramDimension.cs +++ b/src/EventLogExpert.Runtime/Histogram/HistogramDimension.cs @@ -13,5 +13,7 @@ public enum HistogramDimension Log, LogonType, TicketEncryptionType, - ErrorCode + ErrorCode, + ProcessImage, + ParentProcessImage } diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramDimensionRequest.cs b/src/EventLogExpert.Runtime/Histogram/HistogramDimensionRequest.cs new file mode 100644 index 00000000..e0a1846c --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/HistogramDimensionRequest.cs @@ -0,0 +1,6 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Histogram; + +public sealed record HistogramDimensionRequest(HistogramDimension Dimension, long Token); diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramGroups.cs b/src/EventLogExpert.Runtime/Histogram/HistogramGroups.cs index 0848f1b8..f3d49cf4 100644 --- a/src/EventLogExpert.Runtime/Histogram/HistogramGroups.cs +++ b/src/EventLogExpert.Runtime/Histogram/HistogramGroups.cs @@ -19,20 +19,25 @@ public static class HistogramGroups public static int SeveritySlotCount => LevelSeverity.SlotCount; public static IReadOnlyList ForCategories(IReadOnlyList labels) => - ForCategories(labels, labels); + ForCategories(labels, labels, "Other"); // Keys drive bucketing and the stable toggle Key; labels are the parallel display strings, so distinct keys that resolve to the same display value still stay separate groups. - public static IReadOnlyList ForCategories(IReadOnlyList keys, IReadOnlyList labels) + public static IReadOnlyList ForCategories(IReadOnlyList keys, IReadOnlyList labels) => + ForCategories(keys, labels, "Other"); + + public static IReadOnlyList ForCategories(IReadOnlyList keys, IReadOnlyList labels, string? otherLabel) { ArgumentNullException.ThrowIfNull(keys); ArgumentNullException.ThrowIfNull(labels); ArgumentOutOfRangeException.ThrowIfNotEqual(labels.Count, keys.Count); int otherSlot = keys.Count; - var groups = new List(keys.Count + 1) + var groups = new List(keys.Count + (otherLabel is null ? 0 : 1)); + + if (otherLabel is not null) { - new("Other", "histogram-cat-other", "cat-other", [otherSlot]) - }; + groups.Add(new HistogramGroup(otherLabel, "histogram-cat-other", "cat-other", [otherSlot])); + } for (int index = 0; index < keys.Count; index++) { diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramState.cs b/src/EventLogExpert.Runtime/Histogram/HistogramState.cs index 0c1772a6..043aa367 100644 --- a/src/EventLogExpert.Runtime/Histogram/HistogramState.cs +++ b/src/EventLogExpert.Runtime/Histogram/HistogramState.cs @@ -8,5 +8,9 @@ namespace EventLogExpert.Runtime.Histogram; [FeatureState] public sealed record HistogramState { + public HistogramDimensionRequest? DimensionRequest { get; init; } + public bool IsVisible { get; init; } + + public long NextDimensionToken { get; init; } } diff --git a/src/EventLogExpert.Runtime/Histogram/Reducers.cs b/src/EventLogExpert.Runtime/Histogram/Reducers.cs index 34fe886a..6820f9a3 100644 --- a/src/EventLogExpert.Runtime/Histogram/Reducers.cs +++ b/src/EventLogExpert.Runtime/Histogram/Reducers.cs @@ -7,7 +7,25 @@ namespace EventLogExpert.Runtime.Histogram; internal sealed class Reducers { + [ReducerMethod] + public static HistogramState ReduceClearHistogramDimensionRequest( + HistogramState state, + ClearHistogramDimensionRequestAction action) => + state.DimensionRequest is null ? state : state with { DimensionRequest = null }; + + [ReducerMethod] + public static HistogramState ReduceRequestHistogramDimension(HistogramState state, RequestHistogramDimensionAction action) + { + var token = state.NextDimensionToken + 1; + + return state with + { + DimensionRequest = new HistogramDimensionRequest(action.Dimension, token), + NextDimensionToken = token + }; + } + [ReducerMethod] public static HistogramState ReduceSetHistogramVisible(HistogramState state, SetHistogramVisibleAction action) => - new() { IsVisible = action.IsVisible }; + state with { IsVisible = action.IsVisible, DimensionRequest = action.IsVisible ? state.DimensionRequest : null }; } diff --git a/src/EventLogExpert.Runtime/Histogram/RequestHistogramDimensionAction.cs b/src/EventLogExpert.Runtime/Histogram/RequestHistogramDimensionAction.cs new file mode 100644 index 00000000..1552c43b --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/RequestHistogramDimensionAction.cs @@ -0,0 +1,6 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Histogram; + +internal sealed record RequestHistogramDimensionAction(HistogramDimension Dimension); diff --git a/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs b/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs index 235724ef..91da7746 100644 --- a/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs +++ b/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs @@ -3,8 +3,10 @@ using EventLogExpert.Eventing.Common.EventLogs; using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Filtering.Persistence; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; namespace EventLogExpert.Runtime.LogTable; @@ -24,6 +26,7 @@ internal sealed class CombinedColumnView : IEventColumnView private readonly Dictionary _byLog; private readonly ResolvedEventOrdering.CrossComparison _compare; private readonly int _count; + private readonly ConditionalWeakTable _highlightWinnerHandles = []; private readonly Lock _indexGate = new(); private readonly EventColumnView[] _views; @@ -104,6 +107,116 @@ public void BucketTimeTicksByEventDataHResult( } } + public void BucketTimeTicksByEventDataHResultWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) + { + byte[][] childWinners = ResolveChildHighlightWinners(highlightWinners); + + for (int index = 0; index < _views.Length; index++) + { + _views[index].BucketTimeTicksByEventDataHResultWithTie(childWinners[index], + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + eligibleProviders, + userDataErrorCodePaths, + targetCodes, + slotCounts, + cancellationToken); + } + } + + public void BucketTimeTicksByEventDataString( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + foreach (var view in _views) + { + view.BucketTimeTicksByEventDataString(minTicks, + bucketSpanTicks, + bucketCount, + candidateFields, + rawValueToSlot, + slotCount, + slotCounts, + cancellationToken); + } + } + + public void BucketTimeTicksByEventDataStringWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + byte[][] childWinners = ResolveChildHighlightWinners(highlightWinners); + + for (int index = 0; index < _views.Length; index++) + { + _views[index].BucketTimeTicksByEventDataStringWithTie(childWinners[index], + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + candidateFields, + rawValueToSlot, + slotCount, + slotCounts, + cancellationToken); + } + } + + public void BucketTimeTicksByEventDataWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) + { + byte[][] childWinners = ResolveChildHighlightWinners(highlightWinners); + + for (int index = 0; index < _views.Length; index++) + { + _views[index].BucketTimeTicksByEventDataWithTie(childWinners[index], + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + targetCodes, + slotCounts, + cancellationToken); + } + } + public void BucketTimeTicksByEventId( long minTicks, long bucketSpanTicks, @@ -123,6 +236,31 @@ public void BucketTimeTicksByEventId( } } + public void BucketTimeTicksByEventIdWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] targetIds, + int[] slotCounts, + CancellationToken cancellationToken) + { + byte[][] childWinners = ResolveChildHighlightWinners(highlightWinners); + + for (int index = 0; index < _views.Length; index++) + { + _views[index].BucketTimeTicksByEventIdWithTie(childWinners[index], + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + targetIds, + slotCounts, + cancellationToken); + } + } + public void BucketTimeTicksByField( long minTicks, long bucketSpanTicks, @@ -144,6 +282,33 @@ public void BucketTimeTicksByField( } } + public void BucketTimeTicksByFieldWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + EventFieldId field, + string[] targetValues, + int[] slotCounts, + CancellationToken cancellationToken) + { + byte[][] childWinners = ResolveChildHighlightWinners(highlightWinners); + + for (int index = 0; index < _views.Length; index++) + { + _views[index].BucketTimeTicksByFieldWithTie(childWinners[index], + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + field, + targetValues, + slotCounts, + cancellationToken); + } + } + public void BucketTimeTicksBySeverity( long minTicks, long bucketSpanTicks, @@ -157,6 +322,23 @@ public void BucketTimeTicksBySeverity( } } + public void BucketTimeTicksBySeverityWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + byte[][] childWinners = ResolveChildHighlightWinners(highlightWinners); + + for (int index = 0; index < _views.Length; index++) + { + _views[index].BucketTimeTicksBySeverityWithTie(childWinners[index], slotColorMask, minTicks, bucketSpanTicks, bucketCount, slotCounts, cancellationToken); + } + } + public void CountEventDataHResults( string fieldName, IReadOnlyCollection eligibleProviders, @@ -174,6 +356,14 @@ public void CountEventDataHResults( } } + public void CountEventDataStringValues(string[] candidateFields, IDictionary counts, CancellationToken cancellationToken) + { + foreach (var view in _views) + { + view.CountEventDataStringValues(candidateFields, counts, cancellationToken); + } + } + public void CountEventDataValues(string fieldName, IDictionary counts, CancellationToken cancellationToken) { foreach (var view in _views) @@ -198,6 +388,24 @@ public void CountFieldValues(EventFieldId field, IDictionary counts } } + public byte[] EnsureHighlightWinners( + IReadOnlyList orderedColoredFilters, + int planKey, + CancellationToken cancellationToken) + { + var childWinners = new byte[_views.Length][]; + + for (int index = 0; index < _views.Length; index++) + { + childWinners[index] = _views[index].EnsureHighlightWinners(orderedColoredFilters, planKey, cancellationToken); + } + + byte[] handle = new byte[1]; + _highlightWinnerHandles.Add(handle, new CombinedHighlightWinners(childWinners)); + + return handle; + } + public IEnumerable EnumerateDetail() { var offsets = new int[_views.Length]; @@ -432,6 +640,11 @@ private int PickBest(ReadOnlySpan offsets) return best; } + private byte[][] ResolveChildHighlightWinners(byte[] handle) => + _highlightWinnerHandles.TryGetValue(handle, out CombinedHighlightWinners? winners) + ? winners.ChildWinners + : throw new InvalidOperationException("Combined highlight winners must be captured before tie bucketing."); + private EventColumnView Route(EventLocator locator) => _byLog.TryGetValue(locator.LogId, out var view) ? view @@ -452,4 +665,6 @@ private int SeekTo(int index, Span offsets) return checkpoint * Stride; } + + private sealed record CombinedHighlightWinners(byte[][] ChildWinners); } diff --git a/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs b/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs index 0ab31b3e..8dd80691 100644 --- a/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs +++ b/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs @@ -2,6 +2,8 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Filtering.Compilation; +using EventLogExpert.Filtering.Persistence; using System.Diagnostics.CodeAnalysis; namespace EventLogExpert.Runtime.LogTable; @@ -14,6 +16,7 @@ internal sealed class EventColumnView : IEventColumnView private readonly IEventColumnReader _reader; private Dictionary? _byKey; + private HighlightWinnerCache? _highlightCache; private EventColumnView(IEventColumnReader reader, int[] order, int[] rankByPhysical, SortContext context) { @@ -73,6 +76,98 @@ public void BucketTimeTicksByEventDataHResult( slotCounts, cancellationToken); + public void BucketTimeTicksByEventDataHResultWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventDataHResultWithTie( + _rankByPhysical, + highlightWinners, + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + eligibleProviders, + userDataErrorCodePaths, + targetCodes, + slotCounts, + cancellationToken); + + public void BucketTimeTicksByEventDataString( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventDataString( + _rankByPhysical, + minTicks, + bucketSpanTicks, + bucketCount, + candidateFields, + rawValueToSlot, + slotCount, + slotCounts, + cancellationToken); + + public void BucketTimeTicksByEventDataStringWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventDataStringWithTie( + _rankByPhysical, + highlightWinners, + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + candidateFields, + rawValueToSlot, + slotCount, + slotCounts, + cancellationToken); + + public void BucketTimeTicksByEventDataWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventDataWithTie( + _rankByPhysical, + highlightWinners, + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + fieldName, + targetCodes, + slotCounts, + cancellationToken); + public void BucketTimeTicksByEventId( long minTicks, long bucketSpanTicks, @@ -88,6 +183,25 @@ public void BucketTimeTicksByEventId( slotCounts, cancellationToken); + public void BucketTimeTicksByEventIdWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] targetIds, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventIdWithTie(_rankByPhysical, + highlightWinners, + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + targetIds, + slotCounts, + cancellationToken); + public void BucketTimeTicksByField( long minTicks, long bucketSpanTicks, @@ -105,6 +219,27 @@ public void BucketTimeTicksByField( slotCounts, cancellationToken); + public void BucketTimeTicksByFieldWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + EventFieldId field, + string[] targetValues, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByFieldWithTie(_rankByPhysical, + highlightWinners, + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + field, + targetValues, + slotCounts, + cancellationToken); + public void BucketTimeTicksBySeverity( long minTicks, long bucketSpanTicks, @@ -118,6 +253,23 @@ public void BucketTimeTicksBySeverity( slotCounts, cancellationToken); + public void BucketTimeTicksBySeverityWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksBySeverityWithTie(_rankByPhysical, + highlightWinners, + slotColorMask, + minTicks, + bucketSpanTicks, + bucketCount, + slotCounts, + cancellationToken); + public void CountEventDataHResults( string fieldName, IReadOnlyCollection eligibleProviders, @@ -126,6 +278,12 @@ public void CountEventDataHResults( CancellationToken cancellationToken) => _reader.CountEventDataHResults(_rankByPhysical, fieldName, eligibleProviders, userDataErrorCodePaths, counts, cancellationToken); + public void CountEventDataStringValues( + string[] candidateFields, + IDictionary counts, + CancellationToken cancellationToken) => + _reader.CountEventDataStringValues(_rankByPhysical, candidateFields, counts, cancellationToken); + public void CountEventDataValues( string fieldName, IDictionary counts, @@ -141,6 +299,26 @@ public void CountFieldValues( CancellationToken cancellationToken) => _reader.CountFieldValues(_rankByPhysical, field, counts, cancellationToken); + public byte[] EnsureHighlightWinners( + IReadOnlyList orderedColoredFilters, + int planKey, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(orderedColoredFilters); + + HighlightWinnerCache? cache = _highlightCache; + + if (cache is { PlanKey: var key, Winners: var winners } && key == planKey && winners.Length == _reader.Count) + { + return winners; + } + + byte[] fresh = FilterService.ClassifyHighlightWinners(_reader, _order, orderedColoredFilters, cancellationToken); + _highlightCache = new HighlightWinnerCache(planKey, fresh); + + return fresh; + } + public IEnumerable EnumerateDetail() { foreach (int physical in _order) @@ -272,7 +450,13 @@ internal static EventColumnView Create(IEventColumnReader reader, ReadOnlySpan Create(_reader, _order, context); + internal EventColumnView WithContext(SortContext context) + { + EventColumnView view = Create(_reader, _order, context); + view._highlightCache = _highlightCache; + + return view; + } private Dictionary BuildByKey() { @@ -289,4 +473,6 @@ private Dictionary BuildByKey() return map; } + + private sealed record HighlightWinnerCache(int PlanKey, byte[] Winners); } diff --git a/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs b/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs index 1fdce9f2..8df5566c 100644 --- a/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs +++ b/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs @@ -2,6 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Filtering.Persistence; using System.Diagnostics.CodeAnalysis; namespace EventLogExpert.Runtime.LogTable; @@ -46,6 +47,53 @@ void BucketTimeTicksByEventDataHResult( int[] slotCounts, CancellationToken cancellationToken); + void BucketTimeTicksByEventDataHResultWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string fieldName, + IReadOnlyCollection eligibleProviders, + IReadOnlyList userDataErrorCodePaths, + long[] targetCodes, + int[] slotCounts, + CancellationToken cancellationToken); + + /// Group-by EventData string variant keyed on the first usable named candidate field value. + void BucketTimeTicksByEventDataString( + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken); + + void BucketTimeTicksByEventDataStringWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken); + + void BucketTimeTicksByEventDataWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + 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, @@ -55,6 +103,16 @@ void BucketTimeTicksByEventId( int[] slotCounts, CancellationToken cancellationToken); + void BucketTimeTicksByEventIdWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] targetIds, + int[] slotCounts, + CancellationToken cancellationToken); + /// /// Group-by variant of for a pooled string field; (targetValues length + /// 1) slots per bin. @@ -68,6 +126,17 @@ void BucketTimeTicksByField( int[] slotCounts, CancellationToken cancellationToken); + void BucketTimeTicksByFieldWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + EventFieldId field, + string[] targetValues, + int[] slotCounts, + CancellationToken cancellationToken); + /// /// Additively buckets this view's rows by UTC tick and severity slot; bucket-major /// slotCounts[i*LevelSeverity.SlotCount + slot], out-of-domain ticks clamp to the end buckets. @@ -79,6 +148,15 @@ void BucketTimeTicksBySeverity( int[] slotCounts, CancellationToken cancellationToken); + void BucketTimeTicksBySeverityWithTie( + byte[] highlightWinners, + uint[] slotColorMask, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken); + /// /// HRESULT-code variant of for the ErrorCode dimension: tallies this view's /// survivors from a provider in by the nonzero 32-bit HRESULT in @@ -93,6 +171,9 @@ void CountEventDataHResults( IDictionary counts, CancellationToken cancellationToken); + /// Tallies this view's rows by the first usable string value from the named EventData candidate fields. + void CountEventDataStringValues(string[] candidateFields, IDictionary counts, 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. @@ -111,14 +192,26 @@ void CountEventDataHResults( /// void CountFieldValues(EventFieldId field, IDictionary counts, CancellationToken cancellationToken); + /// + /// Precomputes the highlight "winners" for and returns an OPAQUE, + /// view-specific handle. The result is NOT guaranteed to be a per-row array - a combined view returns a small sentinel + /// handle - so callers MUST NOT index into it or assume its Length equals the row count; it may only be passed + /// back to the *WithTie bucketing APIs on the SAME view instance. lets + /// implementations cache the underlying winner data and skip recomputation while the coloured-filter predicate set is + /// unchanged (the returned handle itself is not guaranteed to be reused). + /// + byte[] EnsureHighlightWinners( + IReadOnlyList orderedColoredFilters, + int planKey, + CancellationToken cancellationToken); + /// Full-detail rehydrate of every display row, in display order, for export and clipboard. IEnumerable EnumerateDetail(); ResolvedEvent GetDetail(EventLocator locator); /// - /// Lean single-row rehydrate (grid scalars plus Description) for the row addressed by - /// . + /// Lean single-row rehydrate (grid scalars plus Description) for the row addressed by . /// ResolvedEvent GetDetailLean(EventLocator locator); diff --git a/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs b/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs index abdf9b8b..e5411a44 100644 --- a/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs +++ b/src/EventLogExpert.Runtime/Scenarios/ScenarioLaunchService.cs @@ -55,9 +55,17 @@ public async Task LaunchAsync( _dispatcher.Dispatch(new CloseAllLogsAction()); _dispatcher.Dispatch(new RestoreFilterPaneStateAction(priorFilterState)); } - else if (scenario.ActivatesTimeline && !_histogramState.Value.IsVisible) + else if (scenario.ActivatesTimeline) { - _menuActionService.SetHistogramVisible(true); + if (scenario.TimelineDimension is { } timelineDimension) + { + _dispatcher.Dispatch(new RequestHistogramDimensionAction(MapTimelineDimension(timelineDimension))); + } + + if (!_histogramState.Value.IsVisible) + { + _menuActionService.SetHistogramVisible(true); + } } return new ScenarioLaunchResult(result.Opened, result.Empty, result.Failed); @@ -134,6 +142,22 @@ private static ImmutableArray AllTargetChannels(ScenarioDefinition scena private static string FilesWord(int count) => count == 1 ? "1 file" : $"{count} files"; + private static HistogramDimension MapTimelineDimension(ScenarioTimelineDimension dimension) => dimension switch + { + ScenarioTimelineDimension.Severity => HistogramDimension.Severity, + ScenarioTimelineDimension.Source => HistogramDimension.Source, + ScenarioTimelineDimension.EventId => HistogramDimension.EventId, + ScenarioTimelineDimension.TaskCategory => HistogramDimension.TaskCategory, + ScenarioTimelineDimension.Opcode => HistogramDimension.Opcode, + ScenarioTimelineDimension.Log => HistogramDimension.Log, + ScenarioTimelineDimension.LogonType => HistogramDimension.LogonType, + ScenarioTimelineDimension.TicketEncryptionType => HistogramDimension.TicketEncryptionType, + ScenarioTimelineDimension.ErrorCode => HistogramDimension.ErrorCode, + ScenarioTimelineDimension.ProcessImage => HistogramDimension.ProcessImage, + ScenarioTimelineDimension.ParentProcessImage => HistogramDimension.ParentProcessImage, + _ => throw new ArgumentOutOfRangeException(nameof(dimension), dimension, null) + }; + private static ImmutableArray MissingChannels(ScenarioDefinition scenario, ImmutableArray matchedChannels) { var matched = new HashSet(matchedChannels, StringComparer.OrdinalIgnoreCase); @@ -198,9 +222,17 @@ private async Task OpenMatchedLogsAsync( }; } - if (scenario.ActivatesTimeline && !_histogramState.Value.IsVisible) + if (scenario.ActivatesTimeline) { - _menuActionService.SetHistogramVisible(true); + if (scenario.TimelineDimension is { } timelineDimension) + { + _dispatcher.Dispatch(new RequestHistogramDimensionAction(MapTimelineDimension(timelineDimension))); + } + + if (!_histogramState.Value.IsVisible) + { + _menuActionService.SetHistogramVisible(true); + } } return new ScenarioFolderLaunchResult diff --git a/src/EventLogExpert.Scenarios/Catalog/ScenarioDefinition.cs b/src/EventLogExpert.Scenarios/Catalog/ScenarioDefinition.cs index abec3bbf..e49fe7f6 100644 --- a/src/EventLogExpert.Scenarios/Catalog/ScenarioDefinition.cs +++ b/src/EventLogExpert.Scenarios/Catalog/ScenarioDefinition.cs @@ -35,6 +35,9 @@ public sealed record ScenarioDefinition /// True when launching this scenario should reveal the event-rate timeline for rate-over-time triage. public bool ActivatesTimeline { get; init; } + /// The histogram group-by dimension auto-selected when this scenario activates the timeline. + public ScenarioTimelineDimension? TimelineDimension { get; init; } + /// The ordered filter rows, each materialising to one Basic filter. public required ImmutableArray Filters { get; init; } diff --git a/src/EventLogExpert.Scenarios/Catalog/ScenarioTimelineDimension.cs b/src/EventLogExpert.Scenarios/Catalog/ScenarioTimelineDimension.cs new file mode 100644 index 00000000..acd8faa4 --- /dev/null +++ b/src/EventLogExpert.Scenarios/Catalog/ScenarioTimelineDimension.cs @@ -0,0 +1,19 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Scenarios.Catalog; + +public enum ScenarioTimelineDimension +{ + Severity, + Source, + EventId, + TaskCategory, + Opcode, + Log, + LogonType, + TicketEncryptionType, + ErrorCode, + ProcessImage, + ParentProcessImage +} diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/active-directory.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/active-directory.json index 3d884298..aa8e5981 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/active-directory.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/active-directory.json @@ -8,6 +8,8 @@ "group": "ActiveDirectory", "channels": [ "Directory Service" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 0, "version": 1, @@ -38,6 +40,8 @@ "group": "ActiveDirectory", "channels": [ "DFS Replication" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 2, "version": 1, @@ -54,13 +58,15 @@ "group": "ActiveDirectory", "channels": [ "System" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 3, "version": 1, "filters": [ - { "color": "Blue", "comparison": { "property": "Source", "value": "Netlogon" }, "predicates": [ { "comparison": { "property": "Id", "value": "5823" } } ] }, - { "color": "Orange", "comparison": { "property": "Source", "value": "Netlogon" }, "predicates": [ { "comparison": { "property": "Id", "value": "5719" } } ] }, - { "color": "Red", "comparison": { "property": "Source", "value": "Netlogon" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "5722", "5723" ] } } ] } + { "color": "LightBlue", "comparison": { "property": "Source", "value": "Netlogon" }, "predicates": [ { "comparison": { "property": "Id", "value": "5823" } } ] }, + { "color": "LightOrange", "comparison": { "property": "Source", "value": "Netlogon" }, "predicates": [ { "comparison": { "property": "Id", "value": "5719" } } ] }, + { "color": "LightRed", "comparison": { "property": "Source", "value": "Netlogon" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "5722", "5723" ] } } ] } ] }, { diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/applications.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/applications.json index 4c579a7f..e1bd10ed 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/applications.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/applications.json @@ -65,6 +65,8 @@ "group": "Applications", "channels": [ "Application" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 3, "version": 1, diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/capi2.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/capi2.json index 75a6f22f..faab039f 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/capi2.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/capi2.json @@ -4,7 +4,7 @@ { "id": "capi2-pki-chain-failure-triage", "name": "PKI chain-build failure triage", - "purpose": "All failed certificate chain builds (Event ID 11) color-coded by root cause: untrusted root (Red), expired cert (Orange), revoked cert (Purple), CRL/OCSP server offline (DarkYellow), revocation status indeterminate (Yellow), partial chain / missing issuer (Blue), wrong EKU (Magenta), and invalid signature (DarkRed). Requires CAPI2 Operational logging enabled by an administrator (disabled by default).", + "purpose": "All failed certificate chain builds (Event ID 11) color-coded by root cause: untrusted root (LightRed), expired cert (LightOrange), revoked cert (LightPurple), CRL/OCSP server offline (LightTeal), revocation status indeterminate (LightYellow), partial chain / missing issuer (LightBlue), wrong EKU (LightMagenta), and invalid signature (LightPink). Requires CAPI2 Operational logging enabled by an administrator (disabled by default).", "group": "Security", "channels": [ "Microsoft-Windows-CAPI2/Operational" ], "requiresAdmin": true, @@ -12,14 +12,14 @@ "order": 0, "version": 1, "filters": [ - { "color": "Red", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_UNTRUSTED_ROOT", "value": "true" } } ] }, - { "color": "Orange", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_NOT_TIME_VALID", "value": "true" } } ] }, - { "color": "Purple", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_REVOKED", "value": "true" } } ] }, - { "color": "DarkYellow", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_OFFLINE_REVOCATION", "value": "true" } } ] }, - { "color": "Yellow", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_REVOCATION_STATUS_UNKNOWN", "value": "true" } } ] }, - { "color": "Blue", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_PARTIAL_CHAIN", "value": "true" } } ] }, - { "color": "Magenta", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_NOT_VALID_FOR_USAGE", "value": "true" } } ] }, - { "color": "DarkRed", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_NOT_SIGNATURE_VALID", "value": "true" } } ] } + { "color": "LightRed", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_UNTRUSTED_ROOT", "value": "true" } } ] }, + { "color": "LightOrange", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_NOT_TIME_VALID", "value": "true" } } ] }, + { "color": "LightPurple", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_REVOKED", "value": "true" } } ] }, + { "color": "LightTeal", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_OFFLINE_REVOCATION", "value": "true" } } ] }, + { "color": "LightYellow", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_REVOCATION_STATUS_UNKNOWN", "value": "true" } } ] }, + { "color": "LightBlue", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_PARTIAL_CHAIN", "value": "true" } } ] }, + { "color": "LightMagenta", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_NOT_VALID_FOR_USAGE", "value": "true" } } ] }, + { "color": "LightPink", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_NOT_SIGNATURE_VALID", "value": "true" } } ] } ] }, { @@ -53,7 +53,7 @@ { "id": "capi2-revocation-offline", "name": "Revocation service offline / unreachable", - "purpose": "Chain builds where revocation checking failed due to an unreachable CRL or OCSP responder, color-coded by sub-cause. DarkYellow: server definitively offline or response stale (CERT_TRUST_IS_OFFLINE_REVOCATION, 0x01000000), from a broken CDP URL, a firewall blocking port 80/443 to the CDP host, or an air-gapped machine. Yellow: revocation status indeterminate (CERT_TRUST_REVOCATION_STATUS_UNKNOWN, 0x00000040), which also fires when the certificate carries no CDP extension. Requires CAPI2 Operational logging enabled by an administrator.", + "purpose": "Chain builds where revocation checking failed due to an unreachable CRL or OCSP responder, color-coded by sub-cause. LightOrange: server definitively offline or response stale (CERT_TRUST_IS_OFFLINE_REVOCATION, 0x01000000), from a broken CDP URL, a firewall blocking port 80/443 to the CDP host, or an air-gapped machine. LightYellow: revocation status indeterminate (CERT_TRUST_REVOCATION_STATUS_UNKNOWN, 0x00000040), which also fires when the certificate carries no CDP extension. Requires CAPI2 Operational logging enabled by an administrator.", "group": "Security", "channels": [ "Microsoft-Windows-CAPI2/Operational" ], "requiresAdmin": true, @@ -61,8 +61,8 @@ "order": 3, "version": 1, "filters": [ - { "color": "DarkYellow", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_OFFLINE_REVOCATION", "value": "true" } } ] }, - { "color": "Yellow", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_REVOCATION_STATUS_UNKNOWN", "value": "true" } } ] } + { "color": "LightOrange", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_OFFLINE_REVOCATION", "value": "true" } } ] }, + { "color": "LightYellow", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_REVOCATION_STATUS_UNKNOWN", "value": "true" } } ] } ] }, { @@ -110,7 +110,7 @@ { "id": "capi2-signature-failures", "name": "Weak / invalid certificate signature", - "purpose": "Chain builds that failed due to signature issues, color-coded by sub-cause. DarkRed: signature cryptographically invalid (CERT_TRUST_IS_NOT_SIGNATURE_VALID, 0x00000008), from a tampered or corrupted cert or an issuer public-key mismatch. Pink: weak hash algorithm deprecated by Windows policy (CERT_TRUST_HAS_WEAK_SIGNATURE, 0x00100000), where SHA-1 or MD2/MD5 certs are now rejected; surfaces SHA-1 migration gaps in internal PKI. Requires CAPI2 Operational logging enabled by an administrator.", + "purpose": "Chain builds that failed due to signature issues, color-coded by sub-cause. LightRed: signature cryptographically invalid (CERT_TRUST_IS_NOT_SIGNATURE_VALID, 0x00000008), from a tampered or corrupted cert or an issuer public-key mismatch. LightPink: weak hash algorithm deprecated by Windows policy (CERT_TRUST_HAS_WEAK_SIGNATURE, 0x00100000), where SHA-1 or MD2/MD5 certs are now rejected; surfaces SHA-1 migration gaps in internal PKI. Requires CAPI2 Operational logging enabled by an administrator.", "group": "Security", "channels": [ "Microsoft-Windows-CAPI2/Operational" ], "requiresAdmin": true, @@ -118,8 +118,8 @@ "order": 7, "version": 1, "filters": [ - { "color": "DarkRed", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_NOT_SIGNATURE_VALID", "value": "true" } } ] }, - { "color": "Pink", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_HAS_WEAK_SIGNATURE", "value": "true" } } ] } + { "color": "LightRed", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_IS_NOT_SIGNATURE_VALID", "value": "true" } } ] }, + { "color": "LightPink", "comparison": { "property": "Id", "value": "11" }, "predicates": [ { "comparison": { "property": "UserData", "userDataFieldName": "CertGetCertificateChain/CertificateChain/TrustStatus/ErrorStatus/@CERT_TRUST_HAS_WEAK_SIGNATURE", "value": "true" } } ] } ] } ] diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/defender-for-endpoint.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/defender-for-endpoint.json index d9221a68..2d98b096 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/defender-for-endpoint.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/defender-for-endpoint.json @@ -181,15 +181,17 @@ "group": "DefenderForEndpoint", "channels": [ "Microsoft-Windows-Windows Defender/Operational" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 0, "order": 12, "version": 1, "filters": [ - { "color": "Green", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "5000", "1001", "1117", "2000" ] } }, - { "color": "Blue", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "1000", "5007" ] } }, - { "color": "Orange", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "1002", "2001" ] } }, - { "color": "Red", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "1116", "5001" ] } }, - { "color": "DarkRed", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "1118", "1119" ] } } + { "color": "LightGreen", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "5000", "1001", "1117", "2000" ] } }, + { "color": "LightBlue", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "1000", "5007" ] } }, + { "color": "LightYellow", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "1002", "2001" ] } }, + { "color": "LightOrange", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "1116", "5001" ] } }, + { "color": "LightRed", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "1118", "1119" ] } } ] } ] diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/dns-server.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/dns-server.json index c6de16f0..55961753 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/dns-server.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/dns-server.json @@ -111,6 +111,8 @@ "group": "DnsServer", "channels": [ "Microsoft-Windows-DNS-Server/Analytical" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 7, "version": 1, @@ -125,13 +127,15 @@ "group": "DnsServer", "channels": [ "DNS Server" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 8, "version": 1, "filters": [ - { "color": "Green", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "6001", "6002" ] } }, - { "color": "Orange", "comparison": { "property": "Id", "value": "6004" } }, - { "color": "Red", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "150", "408", "4015" ] } } + { "color": "LightGreen", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "6001", "6002" ] } }, + { "color": "LightOrange", "comparison": { "property": "Id", "value": "6004" } }, + { "color": "LightRed", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "150", "408", "4015" ] } } ] } ] diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/exchange.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/exchange.json index 8ba69a1c..9c3f5a34 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/exchange.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/exchange.json @@ -8,13 +8,15 @@ "group": "Exchange", "channels": [ "Application" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 0, "version": 1, "filters": [ - { "color": "Yellow", "comparison": { "property": "Source", "value": "MSExchangeTransport" }, "predicates": [ { "comparison": { "property": "Id", "value": "15004" } } ] }, - { "color": "Red", "comparison": { "property": "Source", "value": "MSExchangeTransport" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "15006", "15007" ] } } ] }, - { "color": "Orange", "comparison": { "property": "Source", "value": "MSExchangeTransport" }, "predicates": [ { "comparison": { "property": "Id", "value": "1009" } } ] } + { "color": "LightYellow", "comparison": { "property": "Source", "value": "MSExchangeTransport" }, "predicates": [ { "comparison": { "property": "Id", "value": "15004" } } ] }, + { "color": "LightRed", "comparison": { "property": "Source", "value": "MSExchangeTransport" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "15006", "15007" ] } } ] }, + { "color": "LightOrange", "comparison": { "property": "Source", "value": "MSExchangeTransport" }, "predicates": [ { "comparison": { "property": "Id", "value": "1009" } } ] } ] }, { diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/file-print-and-storage.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/file-print-and-storage.json index f88a82af..d9e1896a 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/file-print-and-storage.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/file-print-and-storage.json @@ -92,6 +92,8 @@ "group": "FilePrintAndStorage", "channels": [ "System" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 6, "version": 1, @@ -255,39 +257,43 @@ "order": 20, "version": 1, "filters": [ - { "color": "Green", "comparison": { "property": "Id", "value": "5142" } }, - { "color": "Blue", "comparison": { "property": "Id", "value": "5143" } }, - { "color": "Red", "comparison": { "property": "Id", "value": "5144" } } + { "color": "LightGreen", "comparison": { "property": "Id", "value": "5142" } }, + { "color": "LightBlue", "comparison": { "property": "Id", "value": "5143" } }, + { "color": "LightRed", "comparison": { "property": "Id", "value": "5144" } } ] }, { "id": "print-jobs-by-printer", "name": "Print job story - one printer queue", - "purpose": "Successful print jobs (307, Green) and print failures (372, Red) for one specific printer queue. Replace 'PRINTER-NAME' with the printer name as it appears in the event (partial match). Event 307 EventData field param5 = printer; event 372 EventData field param3 = printer (classic spooler positional fields).", + "purpose": "Successful print jobs (307, LightGreen) and print failures (372, LightRed) for one specific printer queue. Replace 'PRINTER-NAME' with the printer name as it appears in the event (partial match). Event 307 EventData field param5 = printer; event 372 EventData field param3 = printer (classic spooler positional fields).", "group": "FilePrintAndStorage", "channels": [ "Microsoft-Windows-PrintService/Operational", "Microsoft-Windows-PrintService/Admin" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 21, "version": 1, "filters": [ - { "color": "Green", "comparison": { "property": "LogName", "value": "Microsoft-Windows-PrintService/Operational" }, "predicates": [ { "comparison": { "property": "Id", "value": "307" } }, { "comparison": { "property": "EventData", "eventDataFieldName": "param5", "operator": "Contains", "value": "PRINTER-NAME" } } ] }, - { "color": "Red", "comparison": { "property": "LogName", "value": "Microsoft-Windows-PrintService/Admin" }, "predicates": [ { "comparison": { "property": "Id", "value": "372" } }, { "comparison": { "property": "EventData", "eventDataFieldName": "param3", "operator": "Contains", "value": "PRINTER-NAME" } } ] } + { "color": "LightGreen", "comparison": { "property": "LogName", "value": "Microsoft-Windows-PrintService/Operational" }, "predicates": [ { "comparison": { "property": "Id", "value": "307" } }, { "comparison": { "property": "EventData", "eventDataFieldName": "param5", "operator": "Contains", "value": "PRINTER-NAME" } } ] }, + { "color": "LightRed", "comparison": { "property": "LogName", "value": "Microsoft-Windows-PrintService/Admin" }, "predicates": [ { "comparison": { "property": "Id", "value": "372" } }, { "comparison": { "property": "EventData", "eventDataFieldName": "param3", "operator": "Contains", "value": "PRINTER-NAME" } } ] } ] }, { "id": "print-jobs-by-user", "name": "Print job story - one user", - "purpose": "Successful print jobs (307, Green) and print failures (372, Red) submitted by one user. Replace 'USERNAME' with the owner name as it appears in the event (partial match). Event 307 EventData field param3 = owner; event 372 EventData field param2 = owner (classic spooler positional fields).", + "purpose": "Successful print jobs (307, LightGreen) and print failures (372, LightRed) submitted by one user. Replace 'USERNAME' with the owner name as it appears in the event (partial match). Event 307 EventData field param3 = owner; event 372 EventData field param2 = owner (classic spooler positional fields).", "group": "FilePrintAndStorage", "channels": [ "Microsoft-Windows-PrintService/Operational", "Microsoft-Windows-PrintService/Admin" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 22, "version": 1, "filters": [ - { "color": "Green", "comparison": { "property": "LogName", "value": "Microsoft-Windows-PrintService/Operational" }, "predicates": [ { "comparison": { "property": "Id", "value": "307" } }, { "comparison": { "property": "EventData", "eventDataFieldName": "param3", "operator": "Contains", "value": "USERNAME" } } ] }, - { "color": "Red", "comparison": { "property": "LogName", "value": "Microsoft-Windows-PrintService/Admin" }, "predicates": [ { "comparison": { "property": "Id", "value": "372" } }, { "comparison": { "property": "EventData", "eventDataFieldName": "param2", "operator": "Contains", "value": "USERNAME" } } ] } + { "color": "LightGreen", "comparison": { "property": "LogName", "value": "Microsoft-Windows-PrintService/Operational" }, "predicates": [ { "comparison": { "property": "Id", "value": "307" } }, { "comparison": { "property": "EventData", "eventDataFieldName": "param3", "operator": "Contains", "value": "USERNAME" } } ] }, + { "color": "LightRed", "comparison": { "property": "LogName", "value": "Microsoft-Windows-PrintService/Admin" }, "predicates": [ { "comparison": { "property": "Id", "value": "372" } }, { "comparison": { "property": "EventData", "eventDataFieldName": "param2", "operator": "Contains", "value": "USERNAME" } } ] } ] } ] diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/network.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/network.json index 859e8f17..d52c34c6 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/network.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/network.json @@ -8,6 +8,8 @@ "group": "Network", "channels": [ "Microsoft-Windows-DNS-Client/Operational" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 0, "version": 1, @@ -43,6 +45,8 @@ "group": "Network", "channels": [ "Microsoft-Windows-Dhcp-Client/Admin" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 2, "version": 1, @@ -113,6 +117,8 @@ "group": "Network", "channels": [ "Microsoft-Windows-SMBClient/Operational" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 6, "version": 1, @@ -129,6 +135,8 @@ "group": "Network", "channels": [ "Microsoft-Windows-WLAN-AutoConfig/Operational" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 7, "version": 1, diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/nps-and-rras.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/nps-and-rras.json index cab5c794..87e36da1 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/nps-and-rras.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/nps-and-rras.json @@ -84,73 +84,83 @@ "group": "NpsAndRras", "channels": [ "Security" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 0, "order": 2, "version": 1, "filters": [ - { "color": "Green", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "6272", "6278" ] } }, - { "color": "Red", "comparison": { "property": "Id", "value": "6273" } }, - { "color": "Orange", "comparison": { "property": "Id", "value": "6274" } } + { "color": "LightGreen", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "6272", "6278" ] } }, + { "color": "LightRed", "comparison": { "property": "Id", "value": "6273" } }, + { "color": "LightOrange", "comparison": { "property": "Id", "value": "6274" } } ] }, { "id": "nps-per-policy-access-triage", "name": "NPS access triage - one network policy", - "purpose": "Color-coded NPS grants (6272, Green) and denials (6273, Red) scoped to a single NetworkPolicyName. Replace 'POLICY-NAME' with a partial policy name (Contains match). Isolates VPN, Wi-Fi, or NAP policy decisions from a busy Security log.", + "purpose": "Color-coded NPS grants (6272, LightGreen) and denials (6273, LightRed) scoped to a single NetworkPolicyName. Replace 'POLICY-NAME' with a partial policy name (Contains match). Isolates VPN, Wi-Fi, or NAP policy decisions from a busy Security log.", "group": "NpsAndRras", "channels": [ "Security" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 10, "version": 1, "filters": [ - { "color": "Green", "comparison": { "property": "Id", "value": "6272" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "NetworkPolicyName", "operator": "Contains", "value": "POLICY-NAME" } } ] }, - { "color": "Red", "comparison": { "property": "Id", "value": "6273" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "NetworkPolicyName", "operator": "Contains", "value": "POLICY-NAME" } } ] } + { "color": "LightGreen", "comparison": { "property": "Id", "value": "6272" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "NetworkPolicyName", "operator": "Contains", "value": "POLICY-NAME" } } ] }, + { "color": "LightRed", "comparison": { "property": "Id", "value": "6273" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "NetworkPolicyName", "operator": "Contains", "value": "POLICY-NAME" } } ] } ] }, { "id": "nps-per-nas-device-triage", "name": "NPS access triage - one NAS device", - "purpose": "Color-coded NPS grants (6272, Green) and denials (6273, Red) for a single NAS device identified by NASIdentifier. Replace 'NAS-HOSTNAME' with the device hostname or IP. Isolates one switch, wireless controller, or VPN gateway's RADIUS traffic.", + "purpose": "Color-coded NPS grants (6272, LightGreen) and denials (6273, LightRed) for a single NAS device identified by NASIdentifier. Replace 'NAS-HOSTNAME' with the device hostname or IP. Isolates one switch, wireless controller, or VPN gateway's RADIUS traffic.", "group": "NpsAndRras", "channels": [ "Security" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 11, "version": 1, "filters": [ - { "color": "Green", "comparison": { "property": "Id", "value": "6272" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "NASIdentifier", "operator": "Contains", "value": "NAS-HOSTNAME" } } ] }, - { "color": "Red", "comparison": { "property": "Id", "value": "6273" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "NASIdentifier", "operator": "Contains", "value": "NAS-HOSTNAME" } } ] } + { "color": "LightGreen", "comparison": { "property": "Id", "value": "6272" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "NASIdentifier", "operator": "Contains", "value": "NAS-HOSTNAME" } } ] }, + { "color": "LightRed", "comparison": { "property": "Id", "value": "6273" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "NASIdentifier", "operator": "Contains", "value": "NAS-HOSTNAME" } } ] } ] }, { "id": "nps-user-auth-story", "name": "NPS authentication story - one user", - "purpose": "All NPS access decisions for one user: granted (6272, Green) and denied (6273, Red). Replace 'USERNAME' with the SAMAccountName (partial match via Contains). Answers 'why can't this user connect?' without sifting through all accounts.", + "purpose": "All NPS access decisions for one user: granted (6272, LightGreen) and denied (6273, LightRed). Replace 'USERNAME' with the SAMAccountName (partial match via Contains). Answers 'why can't this user connect?' without sifting through all accounts.", "group": "NpsAndRras", "channels": [ "Security" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 12, "version": 1, "filters": [ - { "color": "Green", "comparison": { "property": "Id", "value": "6272" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "SubjectUserName", "operator": "Contains", "value": "USERNAME" } } ] }, - { "color": "Red", "comparison": { "property": "Id", "value": "6273" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "SubjectUserName", "operator": "Contains", "value": "USERNAME" } } ] } + { "color": "LightGreen", "comparison": { "property": "Id", "value": "6272" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "SubjectUserName", "operator": "Contains", "value": "USERNAME" } } ] }, + { "color": "LightRed", "comparison": { "property": "Id", "value": "6273" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "SubjectUserName", "operator": "Contains", "value": "USERNAME" } } ] } ] }, { "id": "nps-per-endpoint-triage", "name": "NPS access triage - one endpoint (802.1x / CallingStationID)", - "purpose": "Color-coded NPS grants (6272, Green) and denials (6273, Red) for a specific client endpoint by CallingStationID (MAC address for 802.1x/Wi-Fi; IP or phone number for VPN). Replace 'MAC-ADDRESS' with a partial identifier. Answers 'why won't this device connect?' independently of user account or NAS.", + "purpose": "Color-coded NPS grants (6272, LightGreen) and denials (6273, LightRed) for a specific client endpoint by CallingStationID (MAC address for 802.1x/Wi-Fi; IP or phone number for VPN). Replace 'MAC-ADDRESS' with a partial identifier. Answers 'why won't this device connect?' independently of user account or NAS.", "group": "NpsAndRras", "channels": [ "Security" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 13, "version": 1, "filters": [ - { "color": "Green", "comparison": { "property": "Id", "value": "6272" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "CallingStationID", "operator": "Contains", "value": "MAC-ADDRESS" } } ] }, - { "color": "Red", "comparison": { "property": "Id", "value": "6273" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "CallingStationID", "operator": "Contains", "value": "MAC-ADDRESS" } } ] } + { "color": "LightGreen", "comparison": { "property": "Id", "value": "6272" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "CallingStationID", "operator": "Contains", "value": "MAC-ADDRESS" } } ] }, + { "color": "LightRed", "comparison": { "property": "Id", "value": "6273" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "CallingStationID", "operator": "Contains", "value": "MAC-ADDRESS" } } ] } ] }, { diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/security.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/security.json index c70bb8c2..be652cbc 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/security.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/security.json @@ -8,6 +8,8 @@ "group": "Security", "channels": [ "Security" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "LogonType", "priority": 1, "order": 0, "version": 1, @@ -184,14 +186,16 @@ "group": "Security", "channels": [ "Security" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 0, "order": 11, "version": 1, "filters": [ - { "color": "Green", "comparison": { "property": "Id", "value": "4624" } }, - { "color": "Red", "comparison": { "property": "Id", "value": "4625" } }, - { "color": "Orange", "comparison": { "property": "Id", "value": "4740" } }, - { "color": "Purple", "comparison": { "property": "Id", "value": "4648" } } + { "color": "LightGreen", "comparison": { "property": "Id", "value": "4624" } }, + { "color": "LightRed", "comparison": { "property": "Id", "value": "4625" } }, + { "color": "LightOrange", "comparison": { "property": "Id", "value": "4740" } }, + { "color": "LightPurple", "comparison": { "property": "Id", "value": "4648" } } ] }, { @@ -201,15 +205,17 @@ "group": "Security", "channels": [ "Security" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 12, "version": 1, "filters": [ - { "color": "Red", "comparison": { "property": "Id", "value": "4625" } }, - { "color": "Orange", "comparison": { "property": "Id", "value": "4624" } }, - { "color": "Purple", "comparison": { "property": "Id", "value": "4672" } }, - { "color": "Magenta", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "4720", "4722", "4724", "4728", "4732", "4756" ] } }, - { "color": "DarkRed", "comparison": { "property": "Id", "value": "1102" } } + { "color": "LightOrange", "comparison": { "property": "Id", "value": "4625" } }, + { "color": "LightYellow", "comparison": { "property": "Id", "value": "4624" } }, + { "color": "LightPurple", "comparison": { "property": "Id", "value": "4672" } }, + { "color": "LightMagenta", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "4720", "4722", "4724", "4728", "4732", "4756" ] } }, + { "color": "LightRed", "comparison": { "property": "Id", "value": "1102" } } ] }, { @@ -219,30 +225,34 @@ "group": "Security", "channels": [ "Security" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 13, "version": 1, "filters": [ - { "color": "Red", "comparison": { "property": "Id", "value": "4625" } }, - { "color": "Orange", "comparison": { "property": "Id", "value": "4740" } }, - { "color": "Green", "comparison": { "property": "Id", "value": "4767" } }, - { "color": "Purple", "comparison": { "property": "Id", "value": "4724" } } + { "color": "LightRed", "comparison": { "property": "Id", "value": "4625" } }, + { "color": "LightOrange", "comparison": { "property": "Id", "value": "4740" } }, + { "color": "LightGreen", "comparison": { "property": "Id", "value": "4767" } }, + { "color": "LightPurple", "comparison": { "property": "Id", "value": "4724" } } ] }, { "id": "rdp-network-failed-logon-triage", "name": "Failed logon - network & RDP triage", - "purpose": "Color-coded 4625 failed logons by access channel: network credential spray (LogonType 3, Red), interactive RDP brute force (LogonType 10, Orange), and workstation-unlock retries (LogonType 7, Blue). Splits a high-volume 4625 stream into distinct attack vectors at a glance. Requires Audit Logon. ATT&CK T1110, T1110.003.", + "purpose": "Color-coded 4625 failed logons by access channel: network credential spray (LogonType 3, LightRed), interactive RDP brute force (LogonType 10, LightOrange), and workstation-unlock retries (LogonType 7, LightBlue). Splits a high-volume 4625 stream into distinct attack vectors at a glance. Requires Audit Logon. ATT&CK T1110, T1110.003.", "group": "Security", "channels": [ "Security" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "LogonType", "priority": 0, "order": 14, "version": 1, "filters": [ - { "color": "Red", "comparison": { "property": "Id", "value": "4625" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "LogonType", "value": "3" } } ] }, - { "color": "Orange", "comparison": { "property": "Id", "value": "4625" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "LogonType", "value": "10" } } ] }, - { "color": "Blue", "comparison": { "property": "Id", "value": "4625" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "LogonType", "value": "7" } } ] } + { "color": "LightRed", "comparison": { "property": "Id", "value": "4625" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "LogonType", "value": "3" } } ] }, + { "color": "LightOrange", "comparison": { "property": "Id", "value": "4625" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "LogonType", "value": "10" } } ] }, + { "color": "LightBlue", "comparison": { "property": "Id", "value": "4625" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "LogonType", "value": "7" } } ] } ] }, { @@ -262,16 +272,18 @@ { "id": "kerberos-ticket-encryption-triage", "name": "Kerberos service-ticket encryption triage", - "purpose": "Color-coded 4769 TGS stream focused on anomalous encryption: RC4-HMAC-NT (decimal 23, hex 0x17; DarkRed, Kerberoasting risk) and DES variants (decimal 1 or 3; Red, obsolete or downgrade). Expected AES traffic is not highlighted so anomalies stand out. Requires Audit Kerberos Service Ticket Operations on domain controllers. ATT&CK T1558.003, T1600.001.", + "purpose": "Color-coded 4769 TGS stream focused on anomalous encryption: RC4-HMAC-NT (decimal 23, hex 0x17; LightRed, Kerberoasting risk) and DES variants (decimal 1 or 3; LightOrange, obsolete or downgrade). Expected AES traffic is not highlighted so anomalies stand out. Requires Audit Kerberos Service Ticket Operations on domain controllers. ATT&CK T1558.003, T1600.001.", "group": "Security", "channels": [ "Security" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "TicketEncryptionType", "priority": 0, "order": 16, "version": 1, "filters": [ - { "color": "DarkRed", "comparison": { "property": "Id", "value": "4769" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "TicketEncryptionType", "value": "23" } } ] }, - { "color": "Red", "comparison": { "property": "Id", "value": "4769" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "TicketEncryptionType", "matchMode": "Many", "values": [ "1", "3" ] } } ] } + { "color": "LightRed", "comparison": { "property": "Id", "value": "4769" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "TicketEncryptionType", "value": "23" } } ] }, + { "color": "LightOrange", "comparison": { "property": "Id", "value": "4769" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "TicketEncryptionType", "matchMode": "Many", "values": [ "1", "3" ] } } ] } ] }, { diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/sql-server.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/sql-server.json index fcded59b..667d891d 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/sql-server.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/sql-server.json @@ -8,6 +8,8 @@ "group": "SqlServer", "channels": [ "Application" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "Source", "priority": 1, "order": 0, "version": 1, @@ -65,6 +67,8 @@ "group": "SqlServer", "channels": [ "Application" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "Source", "priority": 1, "order": 3, "version": 1, @@ -280,15 +284,17 @@ "group": "SqlServer", "channels": [ "Application" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 0, "order": 10, "version": 1, "filters": [ - { "color": "Green", "comparison": { "property": "Source", "operator": "Contains", "value": "MSSQL" }, "predicates": [ { "comparison": { "property": "Id", "value": "17126" } } ] }, - { "color": "Blue", "comparison": { "property": "Source", "operator": "Contains", "value": "MSSQL" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "17147", "17148" ] } } ] }, - { "color": "Orange", "comparison": { "property": "Source", "operator": "Contains", "value": "MSSQL" }, "predicates": [ { "comparison": { "property": "Id", "value": "18456" } } ] }, - { "color": "Yellow", "comparison": { "property": "Source", "operator": "Contains", "value": "MSSQL" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "9002", "825" ] } } ] }, - { "color": "DarkRed", "comparison": { "property": "Source", "operator": "Contains", "value": "MSSQL" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "823", "824" ] } } ] } + { "color": "LightGreen", "comparison": { "property": "Source", "operator": "Contains", "value": "MSSQL" }, "predicates": [ { "comparison": { "property": "Id", "value": "17126" } } ] }, + { "color": "LightBlue", "comparison": { "property": "Source", "operator": "Contains", "value": "MSSQL" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "17147", "17148" ] } } ] }, + { "color": "LightOrange", "comparison": { "property": "Source", "operator": "Contains", "value": "MSSQL" }, "predicates": [ { "comparison": { "property": "Id", "value": "18456" } } ] }, + { "color": "LightYellow", "comparison": { "property": "Source", "operator": "Contains", "value": "MSSQL" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "9002", "825" ] } } ] }, + { "color": "LightRed", "comparison": { "property": "Source", "operator": "Contains", "value": "MSSQL" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "823", "824" ] } } ] } ] }, { @@ -298,13 +304,15 @@ "group": "SqlServer", "channels": [ "Application" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 11, "version": 1, "filters": [ - { "color": "Green", "comparison": { "property": "Source", "operator": "Contains", "value": "MSSQL" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "18264", "18265" ] } } ] }, - { "color": "Blue", "comparison": { "property": "Source", "operator": "Contains", "value": "MSSQL" }, "predicates": [ { "comparison": { "property": "Id", "value": "18267" } } ] }, - { "color": "Red", "comparison": { "property": "Source", "operator": "Contains", "value": "MSSQL" }, "predicates": [ { "comparison": { "property": "Id", "value": "3041" } } ] } + { "color": "LightGreen", "comparison": { "property": "Source", "operator": "Contains", "value": "MSSQL" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "18264", "18265" ] } } ] }, + { "color": "LightBlue", "comparison": { "property": "Source", "operator": "Contains", "value": "MSSQL" }, "predicates": [ { "comparison": { "property": "Id", "value": "18267" } } ] }, + { "color": "LightRed", "comparison": { "property": "Source", "operator": "Contains", "value": "MSSQL" }, "predicates": [ { "comparison": { "property": "Id", "value": "3041" } } ] } ] } ] diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/storage.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/storage.json index 295a5146..395a4967 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/storage.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/storage.json @@ -8,6 +8,8 @@ "group": "Storage", "channels": [ "System" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 0, "order": 0, "version": 1, @@ -67,6 +69,8 @@ "group": "Storage", "channels": [ "System" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "Source", "priority": 1, "order": 3, "version": 1, @@ -108,12 +112,14 @@ "group": "Storage", "channels": [ "System", "Application" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "Log", "priority": 1, "order": 5, "version": 1, "filters": [ { - "color": "Orange", + "color": "LightOrange", "comparison": { "property": "LogName", "value": "System" }, "predicates": [ { "joinWithAny": false, "comparison": { "property": "Source", "value": "Volsnap" } }, @@ -121,7 +127,7 @@ ] }, { - "color": "Red", + "color": "LightRed", "comparison": { "property": "LogName", "value": "Application" }, "predicates": [ { "joinWithAny": false, "comparison": { "property": "Source", "value": "VSS" } }, diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/system-health.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/system-health.json index 55ad954e..516c88f6 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/system-health.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/system-health.json @@ -24,26 +24,28 @@ "group": "SystemHealth", "channels": [ "System" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 0, "order": 1, "version": 1, "filters": [ { - "color": "Red", + "color": "LightOrange", "comparison": { "property": "Source", "value": "Service Control Manager" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "7000", "7001", "7023", "7024" ] } } ] }, { - "color": "Orange", + "color": "LightYellow", "comparison": { "property": "Source", "value": "Service Control Manager" }, "predicates": [ { "comparison": { "property": "Id", "value": "7022" } } ] }, { - "color": "DarkRed", + "color": "LightRed", "comparison": { "property": "Source", "value": "Service Control Manager" }, "predicates": [ { "comparison": { "property": "Id", "value": "7026" } } @@ -58,19 +60,21 @@ "group": "SystemHealth", "channels": [ "System" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 2, "version": 1, "filters": [ { - "color": "Red", + "color": "LightOrange", "comparison": { "property": "Source", "value": "Service Control Manager" }, "predicates": [ { "comparison": { "property": "Id", "value": "7031" } } ] }, { - "color": "DarkRed", + "color": "LightRed", "comparison": { "property": "Source", "value": "Service Control Manager" }, "predicates": [ { "comparison": { "property": "Id", "value": "7034" } } @@ -165,19 +169,21 @@ "group": "SystemHealth", "channels": [ "System" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 0, "order": 7, "version": 1, "filters": [ { - "color": "DarkRed", + "color": "LightPink", "comparison": { "property": "Source", "matchMode": "Many", "values": [ "Microsoft-Windows-WER-SystemErrorReporting", "BugCheck" ] }, "predicates": [ { "comparison": { "property": "Id", "value": "1001" } } ] }, { - "color": "Yellow", + "color": "LightYellow", "comparison": { "property": "Source", "value": "Microsoft-Windows-Kernel-Power" }, "predicates": [ { "comparison": { "property": "Id", "value": "41" } } @@ -198,7 +204,7 @@ ] }, { - "color": "Green", + "color": "LightGreen", "comparison": { "property": "Source", "value": "Microsoft-Windows-Kernel-General" }, "predicates": [ { "comparison": { "property": "Id", "value": "12" } } @@ -213,12 +219,14 @@ "group": "SystemHealth", "channels": [ "System" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 8, "version": 1, "filters": [ { - "color": "Green", + "color": "LightGreen", "comparison": { "property": "Source", "value": "Microsoft-Windows-Kernel-General" }, "predicates": [ { "joinWithAny": false, "comparison": { "property": "Id", "value": "12" } }, @@ -227,7 +235,7 @@ ] }, { - "color": "Blue", + "color": "LightBlue", "comparison": { "property": "Source", "value": "Microsoft-Windows-Kernel-General" }, "predicates": [ { "joinWithAny": false, "comparison": { "property": "Id", "value": "13" } }, @@ -236,14 +244,14 @@ ] }, { - "color": "Teal", + "color": "LightTeal", "comparison": { "property": "Source", "value": "User32" }, "predicates": [ { "comparison": { "property": "Id", "value": "1074" } } ] }, { - "color": "Orange", + "color": "LightOrange", "comparison": { "property": "Source", "value": "EventLog" }, "predicates": [ { "comparison": { "property": "Id", "value": "6008" } } @@ -313,6 +321,8 @@ "group": "SystemHealth", "channels": [ "System" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 12, "version": 1, @@ -332,12 +342,14 @@ "group": "SystemHealth", "channels": [ "System", "Application" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 13, "version": 1, "filters": [ { - "color": "Yellow", + "color": "LightYellow", "comparison": { "property": "LogName", "value": "System" }, "predicates": [ { "joinWithAny": false, "comparison": { "property": "Source", "value": "Microsoft-Windows-Resource-Exhaustion-Detector" } }, @@ -345,7 +357,7 @@ ] }, { - "color": "DarkRed", + "color": "LightRed", "comparison": { "property": "LogName", "value": "Application" }, "predicates": [ { "joinWithAny": false, "comparison": { "property": "Source", "value": "Application Error" } }, @@ -353,7 +365,7 @@ ] }, { - "color": "Orange", + "color": "LightOrange", "comparison": { "property": "LogName", "value": "Application" }, "predicates": [ { "joinWithAny": false, "comparison": { "property": "Source", "value": "Application Hang" } }, @@ -361,7 +373,7 @@ ] }, { - "color": "Purple", + "color": "LightPurple", "comparison": { "property": "LogName", "value": "Application" }, "predicates": [ { "joinWithAny": false, "comparison": { "property": "Source", "value": "Windows Error Reporting" } }, diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/threats-and-incident-response.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/threats-and-incident-response.json index eb76a6d5..1705ab37 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/threats-and-incident-response.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/threats-and-incident-response.json @@ -305,6 +305,8 @@ "group": "ThreatsAndIncidentResponse", "channels": [ "Microsoft-Windows-Windows Defender/Operational" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 23, "version": 1, @@ -432,6 +434,8 @@ "group": "ThreatsAndIncidentResponse", "channels": [ "Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational", "Microsoft-Windows-TerminalServices-LocalSessionManager/Operational" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 32, "version": 1, @@ -475,6 +479,8 @@ "group": "ThreatsAndIncidentResponse", "channels": [ "Microsoft-Windows-WinRM/Operational", "Microsoft-Windows-PowerShell/Operational", "Microsoft-Windows-WMI-Activity/Operational", "Microsoft-Windows-TaskScheduler/Operational" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "Log", "priority": 1, "order": 35, "version": 1, @@ -492,6 +498,8 @@ "group": "ThreatsAndIncidentResponse", "channels": [ "Security", "System", "Microsoft-Windows-TaskScheduler/Operational", "Microsoft-Windows-PowerShell/Operational" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "Log", "priority": 1, "order": 36, "version": 1, @@ -505,17 +513,19 @@ { "id": "lolbin-process-creation-by-name", "name": "LOLBin process creation (NewProcessName field)", - "purpose": "4688 process-creation events filtered on the NewProcessName EventData field, eliminating false positives from description-text matching. Color-coded by risk tier: scripting hosts with no legitimate standalone-execution use (DarkRed), binary-proxy loaders (Red), and download or indirect-execution utilities (Orange). Requires Audit Process Creation. ATT&CK T1218, T1202.", + "purpose": "4688 process-creation events filtered on the NewProcessName EventData field, eliminating false positives from description-text matching. Color-coded by risk tier: scripting hosts with no legitimate standalone-execution use (LightRed), binary-proxy loaders (LightOrange), and download or indirect-execution utilities (LightYellow). Requires Audit Process Creation. ATT&CK T1218, T1202.", "group": "ThreatsAndIncidentResponse", "channels": [ "Security" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "ProcessImage", "priority": 1, "order": 37, "version": 1, "filters": [ - { "color": "DarkRed", "comparison": { "property": "Id", "value": "4688" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "NewProcessName", "operator": "Contains", "matchMode": "Many", "values": [ "mshta.exe", "wscript.exe", "cscript.exe" ] } } ] }, - { "color": "Red", "comparison": { "property": "Id", "value": "4688" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "NewProcessName", "operator": "Contains", "matchMode": "Many", "values": [ "rundll32.exe", "regsvr32.exe", "installutil.exe" ] } } ] }, - { "color": "Orange", "comparison": { "property": "Id", "value": "4688" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "NewProcessName", "operator": "Contains", "matchMode": "Many", "values": [ "certutil.exe", "bitsadmin.exe", "wmic.exe" ] } } ] } + { "color": "LightRed", "comparison": { "property": "Id", "value": "4688" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "NewProcessName", "operator": "Contains", "matchMode": "Many", "values": [ "mshta.exe", "wscript.exe", "cscript.exe" ] } } ] }, + { "color": "LightOrange", "comparison": { "property": "Id", "value": "4688" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "NewProcessName", "operator": "Contains", "matchMode": "Many", "values": [ "rundll32.exe", "regsvr32.exe", "installutil.exe" ] } } ] }, + { "color": "LightYellow", "comparison": { "property": "Id", "value": "4688" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "NewProcessName", "operator": "Contains", "matchMode": "Many", "values": [ "certutil.exe", "bitsadmin.exe", "wmic.exe" ] } } ] } ] }, { @@ -525,15 +535,17 @@ "group": "ThreatsAndIncidentResponse", "channels": [ "Security" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "ParentProcessImage", "priority": 1, "order": 38, "version": 1, "filters": [ - { "color": "Red", "comparison": { "property": "Id", "value": "4688" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "ParentProcessName", "operator": "Contains", "value": "winword.exe" } } ] }, - { "color": "Red", "comparison": { "property": "Id", "value": "4688" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "ParentProcessName", "operator": "Contains", "value": "excel.exe" } } ] }, - { "color": "Red", "comparison": { "property": "Id", "value": "4688" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "ParentProcessName", "operator": "Contains", "value": "powerpnt.exe" } } ] }, - { "color": "Red", "comparison": { "property": "Id", "value": "4688" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "ParentProcessName", "operator": "Contains", "value": "outlook.exe" } } ] }, - { "color": "Red", "comparison": { "property": "Id", "value": "4688" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "ParentProcessName", "operator": "Contains", "value": "onenote.exe" } } ] } + { "color": "LightRed", "comparison": { "property": "Id", "value": "4688" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "ParentProcessName", "operator": "Contains", "value": "winword.exe" } } ] }, + { "color": "LightRed", "comparison": { "property": "Id", "value": "4688" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "ParentProcessName", "operator": "Contains", "value": "excel.exe" } } ] }, + { "color": "LightRed", "comparison": { "property": "Id", "value": "4688" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "ParentProcessName", "operator": "Contains", "value": "powerpnt.exe" } } ] }, + { "color": "LightRed", "comparison": { "property": "Id", "value": "4688" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "ParentProcessName", "operator": "Contains", "value": "outlook.exe" } } ] }, + { "color": "LightRed", "comparison": { "property": "Id", "value": "4688" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "ParentProcessName", "operator": "Contains", "value": "onenote.exe" } } ] } ] }, { @@ -553,7 +565,7 @@ { "id": "suspicious-ps-scriptblock-ioc-triage", "name": "PowerShell script block - IOC content triage", - "purpose": "Color-coded 4104 script-block triage: Red rows match ScriptBlockText containing in-memory decode-and-execute constructs (FromBase64String, Invoke-Expression); Orange rows match download cradles and in-memory assembly loading; the Blue row catches blocks flagged at Warning level by PowerShell's AMSI integration. Combines content-based field filtering with AMSI auto-flagging in one view. Requires PowerShell Script Block Logging. ATT&CK T1059.001, T1027.010, T1140, T1071.001.", + "purpose": "Color-coded 4104 script-block triage: LightRed rows match ScriptBlockText containing in-memory decode-and-execute constructs (FromBase64String, Invoke-Expression); LightOrange rows match download cradles and in-memory assembly loading; the LightBlue row catches blocks flagged at Warning level by PowerShell's AMSI integration. Combines content-based field filtering with AMSI auto-flagging in one view. Requires PowerShell Script Block Logging. ATT&CK T1059.001, T1027.010, T1140, T1071.001.", "group": "ThreatsAndIncidentResponse", "channels": [ "Microsoft-Windows-PowerShell/Operational" ], "requiresAdmin": false, @@ -561,25 +573,27 @@ "order": 40, "version": 1, "filters": [ - { "color": "Red", "comparison": { "property": "Id", "value": "4104" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "ScriptBlockText", "operator": "Contains", "matchMode": "Many", "values": [ "FromBase64String", "Invoke-Expression" ] } } ] }, - { "color": "Orange", "comparison": { "property": "Id", "value": "4104" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "ScriptBlockText", "operator": "Contains", "matchMode": "Many", "values": [ "DownloadString", "New-Object Net.WebClient", "Reflection.Assembly]::Load" ] } } ] }, - { "color": "Blue", "comparison": { "property": "Level", "value": "Warning" }, "predicates": [ { "comparison": { "property": "Id", "value": "4104" } } ] } + { "color": "LightRed", "comparison": { "property": "Id", "value": "4104" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "ScriptBlockText", "operator": "Contains", "matchMode": "Many", "values": [ "FromBase64String", "Invoke-Expression" ] } } ] }, + { "color": "LightOrange", "comparison": { "property": "Id", "value": "4104" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "ScriptBlockText", "operator": "Contains", "matchMode": "Many", "values": [ "DownloadString", "New-Object Net.WebClient", "Reflection.Assembly]::Load" ] } } ] }, + { "color": "LightBlue", "comparison": { "property": "Level", "value": "Warning" }, "predicates": [ { "comparison": { "property": "Id", "value": "4104" } } ] } ] }, { "id": "sysmon-lolbin-initiated-network", "name": "Sysmon - LOLBin & script engine network connection", - "purpose": "Sysmon Event 3 network-connect events filtered to connections initiated by scripting hosts and Living-off-the-Land binaries. Color-coded by tier: scripting hosts with no legitimate outbound use (DarkRed), PowerShell variants (Red), and binary-proxy loaders (Orange). Requires Sysmon with NetworkConnect logging enabled. ATT&CK T1071.001, T1218, T1059.", + "purpose": "Sysmon Event 3 network-connect events filtered to connections initiated by scripting hosts and Living-off-the-Land binaries. Color-coded by tier: scripting hosts with no legitimate outbound use (LightRed), PowerShell variants (LightOrange), and binary-proxy loaders (LightYellow). Requires Sysmon with NetworkConnect logging enabled. ATT&CK T1071.001, T1218, T1059.", "group": "ThreatsAndIncidentResponse", "channels": [ "Microsoft-Windows-Sysmon/Operational" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "ProcessImage", "priority": 1, "order": 41, "version": 1, "filters": [ - { "color": "DarkRed", "comparison": { "property": "Id", "value": "3" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "Image", "operator": "Contains", "matchMode": "Many", "values": [ "mshta.exe", "wscript.exe", "cscript.exe" ] } } ] }, - { "color": "Red", "comparison": { "property": "Id", "value": "3" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "Image", "operator": "Contains", "matchMode": "Many", "values": [ "powershell.exe", "pwsh.exe" ] } } ] }, - { "color": "Orange", "comparison": { "property": "Id", "value": "3" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "Image", "operator": "Contains", "matchMode": "Many", "values": [ "rundll32.exe", "regsvr32.exe", "certutil.exe" ] } } ] } + { "color": "LightRed", "comparison": { "property": "Id", "value": "3" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "Image", "operator": "Contains", "matchMode": "Many", "values": [ "mshta.exe", "wscript.exe", "cscript.exe" ] } } ] }, + { "color": "LightOrange", "comparison": { "property": "Id", "value": "3" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "Image", "operator": "Contains", "matchMode": "Many", "values": [ "powershell.exe", "pwsh.exe" ] } } ] }, + { "color": "LightYellow", "comparison": { "property": "Id", "value": "3" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "Image", "operator": "Contains", "matchMode": "Many", "values": [ "rundll32.exe", "regsvr32.exe", "certutil.exe" ] } } ] } ] }, { diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/updates-and-policy.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/updates-and-policy.json index b3f95386..724d217e 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/updates-and-policy.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/updates-and-policy.json @@ -8,6 +8,8 @@ "group": "UpdatesAndPolicy", "channels": [ "Microsoft-Windows-GroupPolicy/Operational", "System" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "Log", "priority": 0, "order": 0, "version": 1, @@ -35,26 +37,27 @@ "channels": [ "System" ], "requiresAdmin": false, "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 0, "order": 1, "version": 1, "filters": [ { - "color": "Green", + "color": "LightGreen", "comparison": { "property": "Source", "value": "Microsoft-Windows-WindowsUpdateClient" }, "predicates": [ { "comparison": { "property": "Id", "value": "19" } } ] }, { - "color": "Yellow", + "color": "LightYellow", "comparison": { "property": "Source", "value": "Microsoft-Windows-WindowsUpdateClient" }, "predicates": [ { "comparison": { "property": "Level", "value": "Warning" } } ] }, { - "color": "Red", + "color": "LightRed", "comparison": { "property": "Source", "value": "Microsoft-Windows-WindowsUpdateClient" }, "predicates": [ { "comparison": { "property": "Level", "value": "Error" } } @@ -70,26 +73,27 @@ "channels": [ "Setup" ], "requiresAdmin": false, "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 2, "version": 1, "filters": [ { - "color": "Red", + "color": "LightRed", "comparison": { "property": "Source", "value": "Microsoft-Windows-Servicing" }, "predicates": [ { "comparison": { "property": "Id", "value": "3" } } ] }, { - "color": "Yellow", + "color": "LightYellow", "comparison": { "property": "Source", "value": "Microsoft-Windows-Servicing" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "4", "13" ] } } ] }, { - "color": "Green", + "color": "LightGreen", "comparison": { "property": "Source", "value": "Microsoft-Windows-Servicing" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "2", "9" ] } } @@ -105,54 +109,55 @@ "channels": [ "System" ], "requiresAdmin": false, "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 0, "order": 3, "version": 1, "filters": [ { - "color": "Red", + "color": "LightRed", "comparison": { "property": "Source", "value": "EventLog" }, "predicates": [ { "comparison": { "property": "Id", "value": "6008" } } ] }, { - "color": "Red", + "color": "LightRed", "comparison": { "property": "Source", "value": "Microsoft-Windows-Kernel-Power" }, "predicates": [ { "comparison": { "property": "Id", "value": "41" } } ] }, { - "color": "Orange", + "color": "LightOrange", "comparison": { "property": "Source", "value": "User32" }, "predicates": [ { "comparison": { "property": "Id", "value": "1074" } } ] }, { - "color": "Yellow", + "color": "LightYellow", "comparison": { "property": "Source", "value": "EventLog" }, "predicates": [ { "comparison": { "property": "Id", "value": "6006" } } ] }, { - "color": "Green", + "color": "LightGreen", "comparison": { "property": "Source", "value": "EventLog" }, "predicates": [ { "comparison": { "property": "Id", "value": "6005" } } ] }, { - "color": "Green", + "color": "LightGreen", "comparison": { "property": "Source", "value": "Microsoft-Windows-WindowsUpdateClient" }, "predicates": [ { "comparison": { "property": "Id", "value": "19" } } ] }, { - "color": "Teal", + "color": "LightTeal", "comparison": { "property": "Source", "value": "Microsoft-Windows-WindowsUpdateClient" }, "predicates": [ { "comparison": { "property": "Id", "value": "43" } } @@ -168,6 +173,8 @@ "channels": [ "System" ], "optionalChannels": [ "Microsoft-Windows-Time-Service/Operational" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 3, "version": 1, diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/virtualization-and-clustering.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/virtualization-and-clustering.json index f8de9529..b96f6b00 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/virtualization-and-clustering.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/virtualization-and-clustering.json @@ -8,6 +8,8 @@ "group": "VirtualizationAndClustering", "channels": [ "Microsoft-Windows-Hyper-V-VMMS/Admin", "Microsoft-Windows-Hyper-V-Worker/Admin", "Microsoft-Windows-Hyper-V-Hypervisor/Admin" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "Log", "priority": 1, "order": 0, "version": 1, @@ -24,6 +26,8 @@ "group": "VirtualizationAndClustering", "channels": [ "Microsoft-Windows-Hyper-V-VMMS/Admin" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 1, "version": 1, @@ -38,6 +42,8 @@ "group": "VirtualizationAndClustering", "channels": [ "Microsoft-Windows-Hyper-V-VMMS/Admin" ], "requiresAdmin": true, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 2, "version": 1, @@ -66,33 +72,35 @@ "group": "VirtualizationAndClustering", "channels": [ "System" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 4, "version": 1, "filters": [ { - "color": "Orange", + "color": "LightYellow", "comparison": { "property": "Source", "value": "Microsoft-Windows-FailoverClustering" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "1672", "1676" ] } } ] }, { - "color": "Yellow", + "color": "LightTeal", "comparison": { "property": "Source", "value": "Microsoft-Windows-FailoverClustering" }, "predicates": [ { "comparison": { "property": "Id", "value": "5120" } } ] }, { - "color": "Red", + "color": "LightOrange", "comparison": { "property": "Source", "value": "Microsoft-Windows-FailoverClustering" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "1069", "1205" ] } } ] }, { - "color": "DarkRed", + "color": "LightRed", "comparison": { "property": "Source", "value": "Microsoft-Windows-FailoverClustering" }, "predicates": [ { "comparison": { "property": "Id", "value": "1135" } } @@ -126,6 +134,8 @@ "group": "VirtualizationAndClustering", "channels": [ "System" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 6, "version": 1, @@ -226,18 +236,20 @@ { "id": "s2d-physical-disk-story", "name": "Storage Spaces physical disk story (by serial number)", - "purpose": "Full event history for one physical disk in a Storage Spaces / S2D pool by DriveSerial: IO failure (203, Red), failed while arriving (208, Red), SMART / impending failure (204, Orange), lost communication (205, Yellow), and disk arrival baseline (207, Blue). Replace 'DISK-SERIAL' with the value from Get-PhysicalDisk SerialNumber. Complements s2d-disk-volume-health which covers virtual disk issues.", + "purpose": "Full event history for one physical disk in a Storage Spaces / S2D pool by DriveSerial: IO failure (203, LightRed), failed while arriving (208, LightRed), SMART / impending failure (204, LightOrange), lost communication (205, LightYellow), and disk arrival baseline (207, LightBlue). Replace 'DISK-SERIAL' with the value from Get-PhysicalDisk SerialNumber. Complements s2d-disk-volume-health which covers virtual disk issues.", "group": "VirtualizationAndClustering", "channels": [ "Microsoft-Windows-StorageSpaces-Driver/Operational" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 12, "version": 1, "filters": [ - { "color": "Red", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "203", "208" ] }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "DriveSerial", "operator": "Contains", "value": "DISK-SERIAL" } } ] }, - { "color": "Orange", "comparison": { "property": "Id", "value": "204" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "DriveSerial", "operator": "Contains", "value": "DISK-SERIAL" } } ] }, - { "color": "Yellow", "comparison": { "property": "Id", "value": "205" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "DriveSerial", "operator": "Contains", "value": "DISK-SERIAL" } } ] }, - { "color": "Blue", "comparison": { "property": "Id", "value": "207" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "DriveSerial", "operator": "Contains", "value": "DISK-SERIAL" } } ] } + { "color": "LightRed", "comparison": { "property": "Id", "matchMode": "Many", "values": [ "203", "208" ] }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "DriveSerial", "operator": "Contains", "value": "DISK-SERIAL" } } ] }, + { "color": "LightOrange", "comparison": { "property": "Id", "value": "204" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "DriveSerial", "operator": "Contains", "value": "DISK-SERIAL" } } ] }, + { "color": "LightYellow", "comparison": { "property": "Id", "value": "205" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "DriveSerial", "operator": "Contains", "value": "DISK-SERIAL" } } ] }, + { "color": "LightBlue", "comparison": { "property": "Id", "value": "207" }, "predicates": [ { "comparison": { "property": "EventData", "eventDataFieldName": "DriveSerial", "operator": "Contains", "value": "DISK-SERIAL" } } ] } ] } ] diff --git a/src/EventLogExpert.Scenarios/Scenarios/built-in/web-and-iis.json b/src/EventLogExpert.Scenarios/Scenarios/built-in/web-and-iis.json index e3f7e20d..0d7bb773 100644 --- a/src/EventLogExpert.Scenarios/Scenarios/built-in/web-and-iis.json +++ b/src/EventLogExpert.Scenarios/Scenarios/built-in/web-and-iis.json @@ -8,26 +8,28 @@ "group": "WebAndIis", "channels": [ "System" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 0, "version": 1, "filters": [ { - "color": "Red", + "color": "LightOrange", "comparison": { "property": "Source", "value": "Microsoft-Windows-WAS" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "5009", "5011", "5012" ] } } ] }, { - "color": "DarkRed", + "color": "LightRed", "comparison": { "property": "Source", "value": "Microsoft-Windows-WAS" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "5002", "5057" ] } } ] }, { - "color": "Orange", + "color": "LightYellow", "comparison": { "property": "Source", "value": "Microsoft-Windows-WAS" }, "predicates": [ { "comparison": { "property": "Id", "matchMode": "Many", "values": [ "5021", "5186" ] } } @@ -42,6 +44,8 @@ "group": "WebAndIis", "channels": [ "Application" ], "requiresAdmin": false, + "activatesTimeline": true, + "timelineDimension": "EventId", "priority": 1, "order": 1, "version": 1, diff --git a/src/EventLogExpert.Scenarios/Serialization/ScenarioCatalogLoader.cs b/src/EventLogExpert.Scenarios/Serialization/ScenarioCatalogLoader.cs index 1017762d..8a52da1f 100644 --- a/src/EventLogExpert.Scenarios/Serialization/ScenarioCatalogLoader.cs +++ b/src/EventLogExpert.Scenarios/Serialization/ScenarioCatalogLoader.cs @@ -246,6 +246,12 @@ private static void ProcessScenario( var origin = ParseEnum(dto.Origin, $"scenario {label}", "origin", required: false, local, ScenarioOrigin.BuiltIn); var gating = ParseEnum(dto.Gating, $"scenario {label}", "gating", required: false, local, ScenarioGating.ChannelPresence); + ScenarioTimelineDimension? timelineDimension = + !string.IsNullOrWhiteSpace(dto.TimelineDimension) && + Enum.IsDefined(typeof(ScenarioTimelineDimension), dto.TimelineDimension) + ? Enum.Parse(dto.TimelineDimension) + : null; + if (gating == ScenarioGating.SourceRegistration) { local.Add($"scenario {label}: gating 'SourceRegistration' is not supported in a built-in scenario yet."); @@ -275,6 +281,7 @@ private static void ProcessScenario( SourceGates = [.. dto.SourceGates ?? []], RequiresAdmin = dto.RequiresAdmin, ActivatesTimeline = dto.ActivatesTimeline, + TimelineDimension = timelineDimension, Filters = [.. rows], Priority = dto.Priority, Order = dto.Order, diff --git a/src/EventLogExpert.Scenarios/Serialization/ScenarioDto.cs b/src/EventLogExpert.Scenarios/Serialization/ScenarioDto.cs index 27bf6f18..d0c35fff 100644 --- a/src/EventLogExpert.Scenarios/Serialization/ScenarioDto.cs +++ b/src/EventLogExpert.Scenarios/Serialization/ScenarioDto.cs @@ -33,5 +33,7 @@ internal sealed class ScenarioDto public List? SourceGates { get; set; } + public string? TimelineDimension { get; set; } + public int Version { get; set; } } diff --git a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor index 726eb4b2..8a826035 100644 --- a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor +++ b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor @@ -13,6 +13,8 @@ Value="_dimension" ValueChanged="OnDimensionSelected"> + + @@ -31,19 +33,22 @@ var info = legendData.Groups[group]; string key = info.Key; bool shown = !_hiddenGroups.Contains(key); + string highlightText = GroupHighlightText(group); + string accessibleLabel = string.IsNullOrEmpty(highlightText) ? info.Label : $"{info.Label}, {highlightText}"; - } @@ -119,6 +124,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @for (int index = 0; index < barCount; index++) @@ -142,7 +191,9 @@ segmentTop -= height; - ActiveView { get; init; } = null!; + [Inject] private IStateSelection DimensionRequest { get; init; } = null!; + [Inject] private IFilterLensCommands FilterLensCommands { get; init; } = null!; + [Inject] private IStateSelection> Filters { get; init; } = null!; + [Inject] private IFindMarkerSource FindMarkers { get; init; } = null!; [Inject] private IStateSelection Focus { get; init; } = null!; + [Inject] private IHighlightSelector HighlightSelector { get; init; } = null!; + [Inject] private IJSRuntime JSRuntime { get; init; } = null!; [Inject] private ISettingsService Settings { get; init; } = null!; @@ -166,6 +178,52 @@ public void OnHistogramZoomed(bool zoomIn, double cursorFraction, int navToken) ApplyZoom(zoomIn ? ZoomInFactor : ZoomOutFactor, Math.Clamp(cursorFraction, 0, 1)); } + internal static (string? CssName, string Description) ResolveGroupHighlight( + uint mask, + IReadOnlyList tieHighlightFilters) + { + bool hasUncolored = mask == 0 || (mask & 1u) != 0; + HighlightColor? winner = null; + int distinctColors = 0; + + for (int bit = 1; bit <= 31; bit++) + { + if ((mask & (1u << bit)) == 0) { continue; } + + if (bit - 1 >= tieHighlightFilters.Count) { return (null, "Mixed highlights"); } + + HighlightColor color = tieHighlightFilters[bit - 1].Color; + + if (color.ToCssName() is null) + { + hasUncolored = true; + + continue; + } + + if (winner is null) + { + winner = color; + distinctColors = 1; + + continue; + } + + if (winner.Value != color) + { + distinctColors++; + + return (null, "Mixed highlights"); + } + } + + if (hasUncolored && distinctColors > 0) { return (null, "Mixed highlights"); } + + return winner is { } highlight && distinctColors == 1 + ? (highlight.ToCssName(), $"{HighlightColorDisplayName(highlight)} highlight") + : (null, "Uncolored"); + } + protected override async ValueTask DisposeAsyncCore(bool disposing) { if (disposing) @@ -174,6 +232,8 @@ protected override async ValueTask DisposeAsyncCore(bool disposing) ActiveView.SelectedValueChanged -= OnActiveViewChanged; ActiveEventLogId.SelectedValueChanged -= OnActiveEventLogIdChanged; + DimensionRequest.SelectedValueChanged -= OnDimensionRequestChanged; + Filters.SelectedValueChanged -= OnFiltersChanged; Focus.SelectedValueChanged -= OnFocusChanged; Settings.TimeZoneChanged -= OnTimeZoneChanged; FindMarkers.MarksChanged -= OnFindMarksChanged; @@ -229,13 +289,26 @@ protected override void OnInitialized() ActiveOriginLog.Select(SelectActiveOriginLog); ActiveEventLogId.Select(state => state.ActiveEventLogId); Focus.Select(state => state.Focus); + Filters.Select(state => state.Filters); + DimensionRequest.Select(state => state.DimensionRequest); ActiveView.SelectedValueChanged += OnActiveViewChanged; ActiveEventLogId.SelectedValueChanged += OnActiveEventLogIdChanged; Focus.SelectedValueChanged += OnFocusChanged; + Filters.SelectedValueChanged += OnFiltersChanged; + DimensionRequest.SelectedValueChanged += OnDimensionRequestChanged; Settings.TimeZoneChanged += OnTimeZoneChanged; FindMarkers.MarksChanged += OnFindMarksChanged; + var initialRequest = DimensionRequest.Value; + + if (initialRequest is not null && initialRequest.Token > _appliedDimensionToken) + { + _dimension = initialRequest.Dimension; + _appliedDimensionToken = initialRequest.Token; + } + RefreshFindTicks(); + RefreshTieFilters(rescanOnPredicateChange: false); base.OnInitialized(); } @@ -247,6 +320,8 @@ protected override void OnInitialized() HistogramDimension.TaskCategory => "Task Category", HistogramDimension.TicketEncryptionType => "Ticket Encryption Type", HistogramDimension.ErrorCode => "Error Code", + HistogramDimension.ProcessImage => "Process Image", + HistogramDimension.ParentProcessImage => "Parent Process Image", _ => dimension.ToString() }; @@ -257,6 +332,12 @@ protected override void OnInitialized() HistogramDimension.ErrorCode => visibleRange ? "No update error codes in the visible range." : "No update error codes in this view.", + HistogramDimension.ProcessImage => visibleRange + ? "No process image names in the visible range." + : "No process image names in this view.", + HistogramDimension.ParentProcessImage => visibleRange + ? "No parent process image names in the visible range." + : "No parent process image names in this view.", _ => visibleRange ? "No events to chart in the current view." : $"No {DimensionLabel(dimension)} values in this view." @@ -267,6 +348,27 @@ private static string FindMarkerPoints(double centerX) => private static string FormatCoordinate(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); + private static string HighlightColorDisplayName(HighlightColor color) + { + string name = color.ToString(); + var parts = new List(); + int start = 0; + + for (int index = 1; index < name.Length; index++) + { + if (!char.IsUpper(name[index])) { continue; } + + parts.Add(name[start..index]); + start = index; + } + + parts.Add(name[start..]); + + string joined = string.Join(" ", parts).ToLowerInvariant(); + + return joined.Length == 0 ? joined : char.ToUpperInvariant(joined[0]) + joined[1..]; + } + private static string? SelectActiveOriginLog(LogTableState state) { var activeTab = state.EventTables.FirstOrDefault(tab => tab.Id == state.ActiveEventLogId); @@ -274,6 +376,18 @@ private static string FindMarkerPoints(double centerX) => return activeTab is { IsCombined: false } ? activeTab.LogName : null; } + private static bool ShouldArmTie(SavedFilter[] filters) + { + if (filters.Length is 0 or > 31) { return false; } + + foreach (SavedFilter filter in filters) + { + if (filter.Color != HighlightColor.None) { return true; } + } + + return false; + } + private void AggregateAndRender(bool syncScrollbar = true) { _binCursor = null; @@ -321,6 +435,29 @@ await InvokeAsync(() => catch (ObjectDisposedException) { /* Component torn down mid-announce; nothing to update. */ } } + private void ApplyDimension(HistogramDimension dimension, bool force) + { + if (!force && dimension == _dimension) { return; } + + _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; 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(); + + StartScan(); + } + private void ApplyPublishedWindow() { if (_baseData is not { } data) { return; } @@ -508,14 +645,43 @@ private string GroupBreakdown(HistogramRenderBin bin) { int count = bin.GroupCounts[group]; - if (count > 0) { parts.Add($"{count} {data.Groups[group].Label}"); } + if (count > 0) + { + string highlight = GroupHighlightText(group); + parts.Add(string.IsNullOrEmpty(highlight) + ? $"{count} {data.Groups[group].Label}" + : $"{count} {data.Groups[group].Label}, {highlight}"); + } } return parts.Count == 0 ? string.Empty : $" ({string.Join(", ", parts)})"; } - private string GroupColorClass(int group) => - _baseData is { } data && group < data.Groups.Count ? data.Groups[group].ColorClass : string.Empty; + private string GroupColorClass(int group) + { + if (_baseData is not { } data || group >= data.Groups.Count) { return string.Empty; } + + if (IsCategoricalOther(data, group)) { return "histogram-cat-other"; } + + return data.GroupHighlightMasks is not null ? "histogram-cat-hl" : data.Groups[group].ColorClass; + } + + private string? GroupHighlightCssName(int group) + { + if (_baseData is not { } data || IsCategoricalOther(data, group) || data.GroupHighlightMasks is not { } masks || group >= masks.Length) { return null; } + + return ResolveGroupHighlight(masks[group]).CssName; + } + + private string GroupHighlightText(int group) + { + if (_baseData is not { } data || IsCategoricalOther(data, group) || data.GroupHighlightMasks is not { } masks || group >= masks.Length) { return string.Empty; } + + return ResolveGroupHighlight(masks[group]).Description; + } + + private static bool IsCategoricalOther(HistogramData data, int group) => + group < data.Groups.Count && data.Groups[group].ColorClass == "histogram-cat-other"; private void HandleKeyDown(KeyboardEventArgs args) { @@ -578,29 +744,25 @@ private void OnActiveEventLogIdChanged(object? sender, EventLogId? logId) => _ = private void OnActiveViewChanged(object? sender, IEventColumnView view) => _ = InvokeAsync(ScheduleRecompute); - private void OnDimensionSelected(HistogramDimension dimension) + private void OnDimensionRequestChanged(object? sender, HistogramDimensionRequest? request) => _ = InvokeAsync(() => { - if (dimension == _dimension) { return; } + if (_disposed) { return; } - _dimension = dimension; - _hiddenGroups.Clear(); + var current = DimensionRequest.Value; + if (current is null || current.Token <= _appliedDimensionToken) { return; } - // 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(); + _appliedDimensionToken = current.Token; + ApplyDimension(current.Dimension, force: true); + }); - StartScan(); + private void OnDimensionSelected(HistogramDimension dimension) + { + ApplyDimension(dimension, force: false); } + private void OnFiltersChanged(object? sender, ImmutableList filters) => + _ = InvokeAsync(() => RefreshTieFilters(rescanOnPredicateChange: true)); + private void OnFindMarksChanged(object? sender, EventArgs args) => _ = InvokeAsync(() => { if (_disposed) { return; } @@ -670,6 +832,34 @@ private void RefreshFindTicks() => ? [.. ticks] : []; + private void RefreshTieFilters(bool rescanOnPredicateChange) + { + ImmutableList filters = Filters.Value; + SavedFilter[] selected = HighlightSelector.Select(filters); + int planKey = HighlightSelector.ComputePredicatePlanKey(filters); + + bool wasArmed = ShouldArmTie(_tieHighlightFilters); + bool willArm = ShouldArmTie(selected); + bool rescanNeeded = wasArmed != willArm || (planKey != _tiePlanKey && (wasArmed || willArm)); + + _tieHighlightFilters = selected; + _tiePlanKey = planKey; + + if (rescanNeeded && rescanOnPredicateChange) + { + StartScan(); + + return; + } + + if (_binCursor is { } cursor && _render is { } render && cursor < render.Bins.Count) + { + _binAnnouncement = BinCursorAnnouncement(render.Bins[cursor]); + } + + StateHasChanged(); + } + private string RegionAria() => _baseData is { } data ? HistogramSummary.RegionLabel(data, _timeZone) : "Timeline"; @@ -682,14 +872,36 @@ private void ResolveFocusedTicks() : null; } - private async Task RunScanAsync(IEventColumnView view, HistogramDimension dimension, int epoch, CancellationToken token) + private (string? CssName, string Description) ResolveGroupHighlight(uint mask) => + ResolveGroupHighlight(mask, _tieHighlightFilters); + + private async Task RunScanAsync( + IEventColumnView view, + HistogramDimension dimension, + int epoch, + SavedFilter[] tieHighlightFilters, + int tiePlanKey, + bool useHighlightTie, + CancellationToken token) { HistogramData? data; try { // Domain is the view's own survivor span, so the bucketer's edge clamp can't pile off-window rows into false spikes. - data = await Task.Run(() => HistogramBuilder.Build(view, dimension, HistogramConstants.MaxBuckets, token), token); + data = await Task.Run(() => + { + token.ThrowIfCancellationRequested(); + + if (useHighlightTie) + { + byte[] highlightWinners = view.EnsureHighlightWinners(tieHighlightFilters, tiePlanKey, token); + + return HistogramBuilder.BuildWithHighlightTie(view, dimension, HistogramConstants.MaxBuckets, highlightWinners, token); + } + + return HistogramBuilder.Build(view, dimension, HistogramConstants.MaxBuckets, token); + }, token); } catch (OperationCanceledException) { return; } catch (Exception e) @@ -705,6 +917,8 @@ await InvokeAsync(() => { if (_disposed || epoch != _scanEpoch || !ReferenceEquals(view, ActiveView.Value)) { return; } + if (useHighlightTie && tiePlanKey != _tiePlanKey) { return; } + _baseData = data; ResolveFocusedTicks(); ApplyPublishedWindow(); @@ -821,10 +1035,13 @@ private void StartScan() IEventColumnView view = ActiveView.Value; int epoch = ++_scanEpoch; HistogramDimension dimension = _dimension; + SavedFilter[] tieHighlightFilters = _tieHighlightFilters; + int tiePlanKey = _tiePlanKey; + bool useHighlightTie = ShouldArmTie(tieHighlightFilters); var cts = new CancellationTokenSource(); _scanCts = cts; - _ = RunScanAsync(view, dimension, epoch, cts.Token); + _ = RunScanAsync(view, dimension, epoch, tieHighlightFilters, tiePlanKey, useHighlightTie, cts.Token); } // Invalidate any pan/zoom queued before an explicit navigation reset (undo, Fit, drag-select, tab switch): the higher generation makes their stale, schedule-time-stamped OnHistogramZoomed/OnHistogramPanned invocations no-op. Every caller is followed by a render (immediate sync-scroll render, or the rescan's after a tab switch) that publishes the new token to JS via applyView. diff --git a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.css b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.css index 5fe6aef1..6d70fb45 100644 --- a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.css +++ b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.css @@ -55,6 +55,7 @@ display: inline-flex; align-items: center; gap: 3px; + min-width: 0; padding: 0 2px; border: 1px solid transparent; border-radius: 2px; @@ -64,6 +65,14 @@ cursor: pointer; } +.histogram-legend-label { + max-width: 13rem; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .histogram-legend-item:hover { border-color: var(--background-darkgray); } .histogram-legend-item:focus-visible { @@ -218,16 +227,42 @@ .histogram-bar-error { fill: var(--clr-red); } -.histogram-cat-0 { fill: #56b4e9; } +.histogram-bar-segment { + stroke: var(--background-dark); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.histogram-cat-0 { fill: light-dark(#0072b2, #56b4e9); } -.histogram-cat-1 { fill: #e69f00; } +.histogram-cat-1 { fill: light-dark(#b66d00, #e69f00); } -.histogram-cat-2 { fill: #009e73; } +.histogram-cat-2 { fill: light-dark(#007a5a, #009e73); } -.histogram-cat-3 { fill: #cc79a7; } +.histogram-cat-3 { fill: light-dark(#a64f82, #cc79a7); } + +.histogram-cat-4 { fill: light-dark(#6f5aa8, #8e7cc3); } + +.histogram-cat-5 { fill: light-dark(#8f6a00, #f0e442); } + +.histogram-cat-6 { fill: light-dark(#8a3f00, #d55e00); } + +.histogram-cat-7 { fill: light-dark(#007983, #00a6b8); } .histogram-cat-other { fill: var(--text-secondary); } +.histogram-cat-hl { + fill: var(--text-secondary); + stroke: var(--background-darkgray); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.histogram-cat-hl[data-highlight] { + fill: var(--hl-bg); + stroke: var(--hl-fg); +} + .histogram-bin-cursor { fill: none; stroke: var(--toggle-accent); @@ -285,9 +320,10 @@ .histogram-bar-error { fill: url(#histogram-hatch-error); } - /* Forced colors drop the palette, so hatch each capped top-four category distinctly; Other stays a solid fill. */ .histogram-cat-other { fill: CanvasText; } + .histogram-bar-segment { stroke: Canvas; } + .histogram-cat-0 { fill: url(#histogram-hatch-cat-0); } .histogram-cat-1 { fill: url(#histogram-hatch-cat-1); } @@ -296,5 +332,44 @@ .histogram-cat-3 { fill: url(#histogram-hatch-cat-3); } + .histogram-cat-4 { fill: url(#histogram-hatch-cat-4); } + + .histogram-cat-5 { fill: url(#histogram-hatch-cat-5); } + + .histogram-cat-6 { fill: url(#histogram-hatch-cat-6); } + + .histogram-cat-7 { fill: url(#histogram-hatch-cat-7); } + + .histogram-cat-hl { + fill: CanvasText; + stroke: Canvas; + } + + .histogram-cat-hl[data-stackpos="0"] { fill: url(#histogram-hatch-cat-0); } + + .histogram-cat-hl[data-stackpos="1"] { fill: url(#histogram-hatch-cat-1); } + + .histogram-cat-hl[data-stackpos="2"] { fill: url(#histogram-hatch-cat-2); } + + .histogram-cat-hl[data-stackpos="3"] { fill: url(#histogram-hatch-cat-3); } + + .histogram-cat-hl[data-stackpos="4"] { fill: url(#histogram-hatch-cat-4); } + + .histogram-cat-hl[data-stackpos="5"] { fill: url(#histogram-hatch-cat-5); } + + .histogram-cat-hl[data-stackpos="6"] { fill: url(#histogram-hatch-cat-6); } + + .histogram-cat-hl[data-stackpos="7"] { fill: url(#histogram-hatch-cat-7); } + + .histogram-cat-hl[data-stackpos="8"] { fill: url(#histogram-hatch-cat-8); } + + .histogram-cat-hl[data-stackpos="9"] { fill: url(#histogram-hatch-cat-9); } + + .histogram-cat-hl[data-stackpos="10"] { fill: url(#histogram-hatch-cat-10); } + + .histogram-cat-hl[data-stackpos="11"] { fill: url(#histogram-hatch-cat-11); } + + .histogram-cat-hl[data-stackpos="12"] { fill: url(#histogram-hatch-cat-12); } + .histogram-find-marker { fill: Highlight; stroke: CanvasText; } } diff --git a/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs b/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs index 0a6f3536..7f5b7342 100644 --- a/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs +++ b/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs @@ -2,6 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Readers; using EventLogExpert.Eventing.Structured; namespace EventLogExpert.Eventing.Common.Events; @@ -66,7 +67,12 @@ public void BucketTimeTicksByEventData( { for (int target = 0; target < targetCodes.Length; target++) { - if (targetCodes[target] == code) { slot = target; break; } + if (targetCodes[target] == code) + { + slot = target; + + break; + } } } @@ -120,6 +126,125 @@ public void BucketTimeTicksByEventDataHResult( } } + public void BucketTimeTicksByEventDataHResultWithTie(ReadOnlySpan rankByPhysical, ReadOnlySpan highlightWinners, uint[] slotColorMask, long minTicks, long bucketSpanTicks, int bucketCount, string fieldName, IReadOnlyCollection eligibleProviders, IReadOnlyList userDataErrorCodePaths, long[] targetCodes, int[] slotCounts, CancellationToken cancellationToken) + { + BucketTimeTicksByEventDataHResult(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, fieldName, eligibleProviders, userDataErrorCodePaths, targetCodes, slotCounts, cancellationToken); + + int otherSlot = targetCodes.Length; + IReadOnlySet eligibleNames = AsOrdinalSet(eligibleProviders); + + for (int index = 0; index < _events.Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + ResolvedEvent resolved = _events[index]; + + if (!eligibleNames.Contains(resolved.Source)) { continue; } + if (!TryGetHResult(resolved, fieldName, userDataErrorCodePaths, out long code)) { continue; } + + int slot = otherSlot; + for (int target = 0; target < targetCodes.Length; target++) + { + if (targetCodes[target] == code) { slot = target; break; } + } + + slotColorMask[slot] |= 1u << highlightWinners[index]; + } + } + + public void BucketTimeTicksByEventDataString( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + string[] candidateFields, + IReadOnlyDictionary rawValueToSlot, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(candidateFields); + ArgumentNullException.ThrowIfNull(rawValueToSlot); + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + int otherSlot = slotCount - 1; + + 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; + + for (int candidate = 0; candidate < candidateFields.Length; candidate++) + { + if (_events[index].EventData.TryGetRawValue(candidateFields[candidate], out EventProperty property) + && property.Reference is string raw + && EventColumnStore.IsUsableRawValue(raw)) + { + slot = rawValueToSlot.TryGetValue(raw, out int mapped) ? mapped : otherSlot; + + break; + } + } + + slotCounts[(clamped * slotCount) + slot]++; + } + } + + public void BucketTimeTicksByEventDataStringWithTie(ReadOnlySpan rankByPhysical, ReadOnlySpan highlightWinners, uint[] slotColorMask, long minTicks, long bucketSpanTicks, int bucketCount, string[] candidateFields, IReadOnlyDictionary rawValueToSlot, int slotCount, int[] slotCounts, CancellationToken cancellationToken) + { + BucketTimeTicksByEventDataString(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, candidateFields, rawValueToSlot, slotCount, slotCounts, cancellationToken); + + int otherSlot = slotCount - 1; + + for (int index = 0; index < _events.Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + int slot = otherSlot; + + for (int candidate = 0; candidate < candidateFields.Length; candidate++) + { + if (_events[index].EventData.TryGetRawValue(candidateFields[candidate], out EventProperty property) + && property.Reference is string raw + && EventColumnStore.IsUsableRawValue(raw)) + { + slot = rawValueToSlot.TryGetValue(raw, out int mapped) ? mapped : otherSlot; + break; + } + } + + slotColorMask[slot] |= 1u << highlightWinners[index]; + } + } + + public void BucketTimeTicksByEventDataWithTie(ReadOnlySpan rankByPhysical, ReadOnlySpan highlightWinners, uint[] slotColorMask, long minTicks, long bucketSpanTicks, int bucketCount, string fieldName, long[] targetCodes, int[] slotCounts, CancellationToken cancellationToken) + { + BucketTimeTicksByEventData(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, fieldName, targetCodes, slotCounts, cancellationToken); + + int otherSlot = targetCodes.Length; + + for (int index = 0; index < _events.Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + 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; } + } + } + + slotColorMask[slot] |= 1u << highlightWinners[index]; + } + } + public void BucketTimeTicksByEventId( ReadOnlySpan rankByPhysical, long minTicks, @@ -153,6 +278,27 @@ public void BucketTimeTicksByEventId( } } + public void BucketTimeTicksByEventIdWithTie(ReadOnlySpan rankByPhysical, ReadOnlySpan highlightWinners, uint[] slotColorMask, long minTicks, long bucketSpanTicks, int bucketCount, int[] targetIds, int[] slotCounts, CancellationToken cancellationToken) + { + BucketTimeTicksByEventId(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, targetIds, slotCounts, cancellationToken); + + int otherSlot = targetIds.Length; + + for (int index = 0; index < _events.Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + int slot = otherSlot; + + for (int target = 0; target < targetIds.Length; target++) + { + if (_events[index].Id == targetIds[target]) { slot = target; break; } + } + + slotColorMask[slot] |= 1u << highlightWinners[index]; + } + } + public void BucketTimeTicksByField( ReadOnlySpan rankByPhysical, long minTicks, @@ -181,6 +327,21 @@ public void BucketTimeTicksByField( } } + public void BucketTimeTicksByFieldWithTie(ReadOnlySpan rankByPhysical, ReadOnlySpan highlightWinners, uint[] slotColorMask, long minTicks, long bucketSpanTicks, int bucketCount, EventFieldId field, string[] targetValues, int[] slotCounts, CancellationToken cancellationToken) + { + BucketTimeTicksByField(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, field, targetValues, slotCounts, cancellationToken); + + int otherSlot = targetValues.Length; + + for (int index = 0; index < _events.Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + int slot = SlotForString(FieldValue(_events[index], field), targetValues, otherSlot); + slotColorMask[slot] |= 1u << highlightWinners[index]; + } + } + public void BucketTimeTicksBySeverity( ReadOnlySpan rankByPhysical, long minTicks, @@ -205,6 +366,19 @@ public void BucketTimeTicksBySeverity( } } + public void BucketTimeTicksBySeverityWithTie(ReadOnlySpan rankByPhysical, ReadOnlySpan highlightWinners, uint[] slotColorMask, long minTicks, long bucketSpanTicks, int bucketCount, int[] slotCounts, CancellationToken cancellationToken) + { + BucketTimeTicksBySeverity(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, slotCounts, cancellationToken); + + for (int index = 0; index < _events.Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + int slot = LevelSeverity.Slot(LevelSeverity.FromLevelName(_events[index].Level)); + slotColorMask[slot] |= 1u << highlightWinners[index]; + } + } + public void CopyGuidColumn(EventFieldId field, Guid[] values, bool[] hasValue) { ArgumentNullException.ThrowIfNull(values); @@ -310,6 +484,30 @@ public void CountEventDataHResults(ReadOnlySpan rankByPhysical, string fiel } } + public void CountEventDataStringValues(ReadOnlySpan rankByPhysical, string[] candidateFields, IDictionary counts, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(candidateFields); + ArgumentNullException.ThrowIfNull(counts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + for (int index = 0; index < _events.Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + for (int candidate = 0; candidate < candidateFields.Length; candidate++) + { + if (_events[index].EventData.TryGetRawValue(candidateFields[candidate], out EventProperty property) + && property.Reference is string raw + && EventColumnStore.IsUsableRawValue(raw)) + { + counts[raw] = counts.TryGetValue(raw, out int existing) ? existing + 1 : 1; + + break; + } + } + } + } + public void CountEventDataValues(ReadOnlySpan rankByPhysical, string fieldName, IDictionary counts, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrEmpty(fieldName); diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs index 6c3bf5ae..ea9cddc8 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreEventDataTests.cs @@ -4,6 +4,7 @@ using EventLogExpert.Eventing.Common.Channels; using EventLogExpert.Eventing.Common.EventLogs; using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Eventing.Readers; using EventLogExpert.Eventing.TestUtils; namespace EventLogExpert.Eventing.Tests.Common.Events; @@ -11,7 +12,6 @@ namespace EventLogExpert.Eventing.Tests.Common.Events; public sealed class EventColumnStoreEventDataTests { private const string WuClient = "Microsoft-Windows-WindowsUpdateClient"; - private static readonly EventLogId s_logId = EventLogId.Create(); private static readonly string[] s_updateProviders = [WuClient, "Microsoft-Windows-Servicing"]; private static readonly string[] s_errorCodeUserDataPaths = ["CbsPackageChangeState/ErrorCode", "CbsUpdateChangeState/ErrorCode"]; @@ -127,6 +127,33 @@ public void BucketTimeTicksByEventDataHResult_IsAllocationFreeOnSealedRows() Assert.True(delta < 512, $"Per-row allocation detected: {delta} bytes over {events.Length} sealed rows."); } + [Fact] + public void BucketTimeTicksByEventDataString_UsesSameCandidateSelectionAsCounts() + { + IEventColumnReader reader = ReaderFor( + ProcessEvent(("NewProcessName", @"C:\Windows\System32\rundll32.exe")), + ProcessEvent(("NewProcessName", "-"), ("Image", @"C:\temp\evil.exe")), + ProcessEvent(("NewProcessName", @"C:\tools\rare.exe")), + ProcessEvent(("NewProcessName", "-"))); + string[] candidateFields = ["NewProcessName", "Image"]; + + var counts = new Dictionary(StringComparer.Ordinal); + reader.CountEventDataStringValues(AllSurvive(reader.Count), candidateFields, counts, CancellationToken.None); + + var rawValueToSlot = new Dictionary(StringComparer.Ordinal) + { + [@"C:\Windows\System32\rundll32.exe"] = 0, + [@"C:\temp\evil.exe"] = 1 + }; + int[] slotCounts = new int[3]; + reader.BucketTimeTicksByEventDataString(AllSurvive(reader.Count), 0, long.MaxValue, 1, candidateFields, rawValueToSlot, slotCount: 3, slotCounts, CancellationToken.None); + + Assert.Equal(3, counts.Values.Sum()); + Assert.Equal(1, slotCounts[0]); + Assert.Equal(1, slotCounts[1]); + Assert.Equal(2, slotCounts[2]); + } + [Fact] public void CountEventDataHResults_CaseInsensitiveAllowlist_IsNormalizedToOrdinal() { @@ -310,6 +337,71 @@ public void CountEventDataHResults_ServicingUserData_SealedAndPending_ChartFailu } } + [Fact] + public void CountEventDataStringValues_FallsThroughUnusableOrNonStringCandidates() + { + IEventColumnReader reader = ReaderFor( + ProcessEvent(("NewProcessName", "-"), ("Image", @"C:\x\evil.exe")), + ProcessEvent(("NewProcessName", @"C:\x\good.exe"), ("Image", @"C:\x\ignored.exe")), + ProcessEvent(("NewProcessName", " "), ("Image", "-")), + ProcessEvent(("NewProcessName", 42L), ("Image", @"C:\x\numeric-fallback.exe"))); + + var counts = new Dictionary(StringComparer.Ordinal); + reader.CountEventDataStringValues(AllSurvive(reader.Count), ["NewProcessName", "Image"], counts, CancellationToken.None); + + Assert.Equal(3, counts.Count); + Assert.Equal(1, counts[@"C:\x\evil.exe"]); + Assert.Equal(1, counts[@"C:\x\good.exe"]); + Assert.Equal(1, counts[@"C:\x\numeric-fallback.exe"]); + Assert.DoesNotContain("-", counts.Keys); + Assert.DoesNotContain(@"C:\x\ignored.exe", counts.Keys); + } + + [Fact] + public void CountEventDataStringValues_NonStringReferenceRejectedConsistently() + { + // An exotic EventData reference (a boxed Int64 here; EvtVariantType.Handle -> EvtHandle in the wild) renders as + // EventFieldValueKind.String while the row is pending (FromProperty stringifies unrecognized references) but seals + // as StoredFieldKind.StringForm. The dimension groups only genuine native strings, so such a value must be rejected + // on BOTH paths - never grouped in the live view and then dropped (or the reverse) once its chunk seals. + ResolvedEvent schemaCarrier = new ResolvedEvent("TestLog", LogPathType.Channel) { Id = 4688, TimeCreated = new DateTime(0, DateTimeKind.Utc) } + .WithEventData(("NewProcessName", "placeholder")); + ResolvedEvent exoticReferenceEvent = schemaCarrier with { EventDataValues = [EventProperty.FromReference(42L)] }; + + foreach (bool sealRows in new[] { true, false }) + { + EventColumnStore store = sealRows + ? EventColumnStore.Build([exoticReferenceEvent], generation: 0, contentVersion: 0) + : EventColumnStore.Build([], generation: 0, contentVersion: 0).Append([exoticReferenceEvent]); + IEventColumnReader reader = store.CreateReader(s_logId); + + var counts = new Dictionary(StringComparer.Ordinal); + reader.CountEventDataStringValues(AllSurvive(reader.Count), ["NewProcessName", "Image"], counts, CancellationToken.None); + + Assert.Empty(counts); + } + } + + [Fact] + public void CountEventDataStringValues_SealedAndPendingNativeStringsMatch() + { + ResolvedEvent[] events = [ProcessEvent(("NewProcessName", @"C:\Windows\System32\cmd.exe"))]; + + foreach (bool sealRows in new[] { true, false }) + { + EventColumnStore store = sealRows + ? EventColumnStore.Build(events, generation: 0, contentVersion: 0) + : EventColumnStore.Build([], generation: 0, contentVersion: 0).Append(events); + IEventColumnReader reader = store.CreateReader(s_logId); + + var counts = new Dictionary(StringComparer.Ordinal); + reader.CountEventDataStringValues(AllSurvive(reader.Count), ["NewProcessName", "Image"], counts, CancellationToken.None); + + Assert.Single(counts); + Assert.Equal(1, counts[@"C:\Windows\System32\cmd.exe"]); + } + } + [Fact] public void CountEventDataValues_FoldsDecimalAndHexSpellingsOfOneCode() { @@ -406,6 +498,10 @@ private static ResolvedEvent Event(string fieldName, object value, int tick = 0) private static ResolvedEvent EventWithoutData() => new("TestLog", LogPathType.Channel) { Id = 4624, TimeCreated = new DateTime(0, DateTimeKind.Utc) }; + private static ResolvedEvent ProcessEvent(params (string Name, object? Value)[] fields) => + new ResolvedEvent("TestLog", LogPathType.Channel) { Id = 4688, TimeCreated = new DateTime(0, DateTimeKind.Utc) } + .WithEventData(fields); + private static IEventColumnReader ReaderFor(params ResolvedEvent[] events) => EventColumnStore.Build(events, generation: 0, contentVersion: 0).CreateReader(s_logId); diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreHighlightTieBucketTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreHighlightTieBucketTests.cs new file mode 100644 index 00000000..8676e20c --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/EventColumnStoreHighlightTieBucketTests.cs @@ -0,0 +1,183 @@ +// // 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.Readers; +using EventLogExpert.Eventing.Resolvers; +using EventLogExpert.Eventing.Structured; +using System.Collections.Immutable; +using System.Security; + +namespace EventLogExpert.Eventing.Tests.Common.Events; + +public sealed class EventColumnStoreHighlightTieBucketTests +{ + private static readonly DateTime s_time = new(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void BucketTimeTicksByEventDataHResultWithTie_OmitsSuccessAndReadsUserData() + { + IEventColumnReader reader = Reader( + WithNamedProperties( + Event(20, "Microsoft-Windows-WindowsUpdateClient"), + ("errorCode", unchecked((int)0x80070005))), + WithNamedProperties( + Event(21, "Microsoft-Windows-WindowsUpdateClient"), + ("errorCode", 0)), + Event(22, "Microsoft-Windows-Servicing") with + { + UserData = ImmutableArray.Create( + new UserDataField("CbsPackageChangeState/ErrorCode", ImmutableArray.Create("0x80070002"), false)) + }); + int[] slotCounts = new int[3]; + uint[] masks = new uint[3]; + + reader.BucketTimeTicksByEventDataHResultWithTie( + AllSurvive(reader.Count), + [1, 2, 3], + masks, + s_time.Ticks, + TimeSpan.TicksPerMinute, + 1, + "errorCode", + ["Microsoft-Windows-WindowsUpdateClient", "Microsoft-Windows-Servicing"], + ["CbsPackageChangeState/ErrorCode"], + [0x80070005L, 0x80070002L], + slotCounts, + TestContext.Current.CancellationToken); + + Assert.Equal([1, 1, 0], slotCounts); + Assert.Equal(1u << 1, masks[0]); + Assert.Equal(1u << 3, masks[1]); + Assert.Equal(0u, masks[2]); + } + + [Fact] + public void BucketTimeTicksByEventDataStringWithTie_FoldsAliasesAndOtherSlot() + { + IEventColumnReader reader = Reader( + WithNamedProperties(Event(20), ("NewProcessName", (EventProperty)@"C:\Windows\System32\cmd.exe")), + WithNamedProperties(Event(21), ("Image", (EventProperty)@"C:\Windows\System32\notepad.exe"))); + int[] slotCounts = new int[2]; + uint[] masks = new uint[2]; + + reader.BucketTimeTicksByEventDataStringWithTie( + AllSurvive(reader.Count), + [1, 2], + masks, + s_time.Ticks, + TimeSpan.TicksPerMinute, + 1, + ["NewProcessName", "Image"], + new Dictionary(StringComparer.OrdinalIgnoreCase) { [@"C:\Windows\System32\cmd.exe"] = 0 }, + 2, + slotCounts, + TestContext.Current.CancellationToken); + + Assert.Equal([1, 1], slotCounts); + Assert.Equal(1u << 1, masks[0]); + Assert.Equal(1u << 2, masks[1]); + } + + [Fact] + public void BucketTimeTicksByEventDataWithTie_SetsMaskForCountedRows() + { + IEventColumnReader reader = Reader( + WithNamedProperties(Event(20), ("LogonType", 2)), + Event(21)); + int[] slotCounts = new int[2]; + uint[] masks = new uint[2]; + + reader.BucketTimeTicksByEventDataWithTie( + AllSurvive(reader.Count), + [1, 2], + masks, + s_time.Ticks, + TimeSpan.TicksPerMinute, + 1, + "LogonType", + [2], + slotCounts, + TestContext.Current.CancellationToken); + + Assert.Equal([1, 1], slotCounts); + Assert.Equal(1u << 1, masks[0]); + Assert.Equal(1u << 2, masks[1]); + } + + [Fact] + public void BucketTimeTicksByFieldWithTie_SetsMaskForLogAndSource() + { + IEventColumnReader reader = Reader( + Event(20, source: "Alpha", owningLog: @"C:\Logs\a.evtx"), + Event(21, source: "Beta", owningLog: @"C:\Logs\b.evtx")); + int[] sourceSlotCounts = new int[2]; + uint[] sourceMasks = new uint[2]; + int[] logSlotCounts = new int[2]; + uint[] logMasks = new uint[2]; + + reader.BucketTimeTicksByFieldWithTie( + AllSurvive(reader.Count), + [1, 2], + sourceMasks, + s_time.Ticks, + TimeSpan.TicksPerMinute, + 1, + EventFieldId.Source, + ["Alpha"], + sourceSlotCounts, + TestContext.Current.CancellationToken); + reader.BucketTimeTicksByFieldWithTie( + AllSurvive(reader.Count), + [1, 2], + logMasks, + s_time.Ticks, + TimeSpan.TicksPerMinute, + 1, + EventFieldId.OwningLog, + [@"C:\Logs\a.evtx"], + logSlotCounts, + TestContext.Current.CancellationToken); + + Assert.Equal([1, 1], sourceSlotCounts); + Assert.Equal([1, 1], logSlotCounts); + Assert.Equal(1u << 1, sourceMasks[0]); + Assert.Equal(1u << 2, sourceMasks[1]); + Assert.Equal(1u << 1, logMasks[0]); + Assert.Equal(1u << 2, logMasks[1]); + } + + private static int[] AllSurvive(int count) => Enumerable.Range(0, count).ToArray(); + + private static ResolvedEvent Event( + int id, + string source = "TestSource", + string owningLog = "TestLog") => + new(owningLog, LogPathType.Channel) + { + Id = id, + Source = source, + TimeCreated = s_time, + Level = "Information" + }; + + private static IEventColumnReader Reader(params ResolvedEvent[] events) => + EventColumnStore.Build(events, generation: 0, contentVersion: 0).CreateReader(EventLogId.Create()); + + private static ResolvedEvent WithNamedProperties( + ResolvedEvent source, + params (string Name, EventProperty Value)[] fields) + { + string template = ""; + TemplateFieldSchema schema = new TemplateAnalyzer().GetTemplateInfo(template).Schema; + ImmutableArray.Builder values = ImmutableArray.CreateBuilder(fields.Length); + + foreach ((string _, EventProperty value) in fields) { values.Add(value); } + + return source with { EventDataValues = values.MoveToImmutable(), EventDataSchema = schema }; + } +} diff --git a/tests/Unit/EventLogExpert.Filtering.Tests/Compilation/FilterServiceHighlightWinnerTests.cs b/tests/Unit/EventLogExpert.Filtering.Tests/Compilation/FilterServiceHighlightWinnerTests.cs new file mode 100644 index 00000000..9999a6c2 --- /dev/null +++ b/tests/Unit/EventLogExpert.Filtering.Tests/Compilation/FilterServiceHighlightWinnerTests.cs @@ -0,0 +1,138 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Eventing.Structured; +using EventLogExpert.Filtering.Compilation; +using EventLogExpert.Filtering.Persistence; +using EventLogExpert.Filtering.TestUtils; +using EventLogExpert.Filtering.TestUtils.Constants; +using System.Collections.Immutable; + +namespace EventLogExpert.Filtering.Tests.Compilation; + +public sealed class FilterServiceHighlightWinnerTests +{ + [Fact] + public void ClassifyHighlightWinners_MatchesResolvedEventFirstMatchOracle() + { + ResolvedEvent[] events = + [ + FilterEventBuilder.CreateTestEvent(100), + FilterEventBuilder.CreateTestEvent(200, source: FilterTestConstants.EventSourceOtherSource), + FilterEventBuilder.CreateTestEvent(300) with + { + UserDataIncomplete = true, + UserData = ImmutableArray.Empty + } + ]; + IEventColumnReader reader = ReaderFor(events); + SavedFilter disabled = SavedFilter.TryCreate("Id == 300", color: HighlightColor.LightGreen, isEnabled: false) + ?? throw new InvalidOperationException("Failed to compile disabled test filter."); + SavedFilter excluded = (SavedFilter.TryCreate("Id == 300", color: HighlightColor.LightOrange, isEnabled: true) + ?? throw new InvalidOperationException("Failed to compile excluded test filter.")) with + { + IsExcluded = true + }; + SavedFilter[] orderedEligibleFilters = + [ + CreateFilter("UserData[\"Missing\"] == \"x\"", HighlightColor.LightRed), + CreateFilter(FilterTestConstants.FilterIdEquals100, HighlightColor.LightBlue), + CreateFilter("Source == \"" + FilterTestConstants.EventSourceOtherSource + "\"", HighlightColor.LightYellow) + ]; + _ = disabled; + _ = excluded; + + byte[] winners = FilterService.ClassifyHighlightWinners( + reader, + [0, 1, 2], + orderedEligibleFilters, + TestContext.Current.CancellationToken); + + for (int index = 0; index < events.Length; index++) + { + Assert.Equal(OracleWinner(events[index], orderedEligibleFilters), winners[index]); + } + } + + [Fact] + public void ClassifyHighlightWinners_WhenFilterColorIsNone_StillBlocksLaterColor() + { + IEventColumnReader reader = ReaderFor( + FilterEventBuilder.CreateTestEvent(100, level: FilterTestConstants.EventLevelError)); + SavedFilter[] filters = + [ + CreateFilter(FilterTestConstants.FilterIdEquals100, HighlightColor.None), + CreateFilter(FilterTestConstants.FilterLevelEqualsError, HighlightColor.LightBlue) + ]; + + byte[] winners = FilterService.ClassifyHighlightWinners(reader, [0], filters, TestContext.Current.CancellationToken); + + Assert.Equal(1, winners[0]); + } + + [Fact] + public void ClassifyHighlightWinners_WhenFirstFilterDoesNotMatch_FallsThrough() + { + IEventColumnReader reader = ReaderFor( + FilterEventBuilder.CreateTestEvent(200, level: FilterTestConstants.EventLevelError)); + SavedFilter[] filters = + [ + CreateFilter(FilterTestConstants.FilterIdEquals100, HighlightColor.LightRed), + CreateFilter(FilterTestConstants.FilterLevelEqualsError, HighlightColor.LightBlue) + ]; + + byte[] winners = FilterService.ClassifyHighlightWinners(reader, [0], filters, TestContext.Current.CancellationToken); + + Assert.Equal(2, winners[0]); + } + + [Fact] + public void ClassifyHighlightWinners_WhenRowIsNotSurviving_LeavesZero() + { + IEventColumnReader reader = ReaderFor( + FilterEventBuilder.CreateTestEvent(100), + FilterEventBuilder.CreateTestEvent(200)); + SavedFilter[] filters = [CreateFilter(FilterTestConstants.FilterIdEquals100, HighlightColor.LightRed)]; + + byte[] winners = FilterService.ClassifyHighlightWinners(reader, [0], filters, TestContext.Current.CancellationToken); + + Assert.Equal(1, winners[0]); + Assert.Equal(0, winners[1]); + } + + [Fact] + public void ClassifyHighlightWinners_WhenTwoFiltersMatch_UsesFirstMatch() + { + IEventColumnReader reader = ReaderFor( + FilterEventBuilder.CreateTestEvent(100, level: FilterTestConstants.EventLevelError)); + SavedFilter[] filters = + [ + CreateFilter(FilterTestConstants.FilterIdEquals100, HighlightColor.LightRed), + CreateFilter(FilterTestConstants.FilterLevelEqualsError, HighlightColor.LightBlue) + ]; + + byte[] winners = FilterService.ClassifyHighlightWinners(reader, [0], filters, TestContext.Current.CancellationToken); + + Assert.Equal(1, winners[0]); + } + + private static SavedFilter CreateFilter(string text, HighlightColor color) => + SavedFilter.TryCreate(text, color: color, isEnabled: true) + ?? throw new InvalidOperationException($"Failed to compile test filter '{text}'."); + + private static byte OracleWinner(ResolvedEvent detail, IReadOnlyList filters) + { + for (int index = 0; index < filters.Count; index++) + { + if (filters[index].Compiled!.Predicate(detail)) { return (byte)(index + 1); } + } + + return 0; + } + + private static IEventColumnReader ReaderFor(params ResolvedEvent[] events) => + EventColumnStore.Build(events, generation: 0, contentVersion: 0) + .CreateReader(EventLogId.Create()); +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/FilterPane/HighlightSelectorTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/FilterPane/HighlightSelectorTests.cs index 590c6ac9..b31b7260 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/FilterPane/HighlightSelectorTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/FilterPane/HighlightSelectorTests.cs @@ -56,7 +56,7 @@ public void ComputeHighlightKey_WhenCandidateAdded_ReturnsDifferentKey() [Fact] public void ComputeHighlightKey_WhenCandidatesReordered_ReturnsDifferentKey() { - // Arrange — GetHighlight is first-match-wins; ordering is load-bearing. + // Arrange - GetHighlight is first-match-wins; ordering is load-bearing. var first = FilterBuilder.CreateTestFilter( FilterTestConstants.FilterIdEquals100, HighlightColor.Red, @@ -110,7 +110,7 @@ public void ComputeHighlightKey_WhenColorMovesOutOfDefinedRange_ReturnsDifferent [Fact] public void ComputeHighlightKey_WhenCompiledReferenceDiffers_ReturnsDifferentKey() { - // Arrange — key hashes Compiled by reference identity (RuntimeHelpers.GetHashCode). + // Arrange - key hashes Compiled by reference identity (RuntimeHelpers.GetHashCode). var first = FilterBuilder.CreateTestFilter( FilterTestConstants.FilterIdEquals100, HighlightColor.Red, @@ -249,6 +249,94 @@ public void ComputeHighlightKey_WhenSkippedFiltersReorderedAroundCandidate_Retur s_selector.ComputeHighlightKey(ImmutableList.Create(disabledB, keeper, disabledA))); } + [Fact] + public void ComputePredicatePlanKey_WhenEligibleFiltersReordered_ReturnsDifferentKey() + { + // Arrange + var eligibleFirst = FilterBuilder.CreateTestFilter( + FilterTestConstants.FilterIdEquals100, + HighlightColor.Red, + true); + + var eligibleSecond = FilterBuilder.CreateTestFilter( + FilterTestConstants.FilterIdEquals200, + HighlightColor.Blue, + true); + + // Act + Assert - winner resolution is order-sensitive, so swapping the relative order of two + // eligible predicates must change the plan key even though the eligible set is identical. + Assert.NotEqual( + s_selector.ComputePredicatePlanKey(ImmutableList.Create(eligibleFirst, eligibleSecond)), + s_selector.ComputePredicatePlanKey(ImmutableList.Create(eligibleSecond, eligibleFirst))); + } + + [Fact] + public void ComputePredicatePlanKey_WhenIneligibleFiltersShiftEligiblePositions_ReturnsSameKey() + { + // Arrange + var eligibleFirst = FilterBuilder.CreateTestFilter( + FilterTestConstants.FilterIdEquals100, + HighlightColor.Red, + true); + + var eligibleSecond = FilterBuilder.CreateTestFilter( + FilterTestConstants.FilterIdEquals200, + HighlightColor.Blue, + true); + + var disabled = FilterBuilder.CreateTestFilter( + FilterTestConstants.FilterIdEquals999, + HighlightColor.Green); + + int baselineKey = s_selector.ComputePredicatePlanKey( + ImmutableList.Create(eligibleFirst, eligibleSecond)); + + // Act + Assert - inserting an ineligible (disabled) filter before or between the eligible + // filters shifts their absolute indexes but not their relative order, so the plan key must not change. + Assert.Equal( + baselineKey, + s_selector.ComputePredicatePlanKey(ImmutableList.Create(disabled, eligibleFirst, eligibleSecond))); + + Assert.Equal( + baselineKey, + s_selector.ComputePredicatePlanKey(ImmutableList.Create(eligibleFirst, disabled, eligibleSecond))); + } + + [Fact] + public void ComputePredicatePlanKey_WhenOnlyColorChanges_ReturnsSameKey() + { + var red = FilterBuilder.CreateTestFilter( + FilterTestConstants.FilterIdEquals100, + HighlightColor.Red, + true); + var blue = red with { Color = HighlightColor.Blue }; + + Assert.Equal( + s_selector.ComputePredicatePlanKey(ImmutableList.Create(red)), + s_selector.ComputePredicatePlanKey(ImmutableList.Create(blue))); + } + + [Fact] + public void ComputePredicatePlanKey_WhenPredicateOrStateChanges_ReturnsDifferentKey() + { + var original = FilterBuilder.CreateTestFilter( + FilterTestConstants.FilterIdEquals100, + HighlightColor.Red, + true); + var predicateChanged = FilterBuilder.CreateTestFilter( + FilterTestConstants.FilterIdEquals200, + HighlightColor.Red, + true); + var disabled = original with { IsEnabled = false }; + var excluded = original with { IsExcluded = true }; + + int key = s_selector.ComputePredicatePlanKey(ImmutableList.Create(original)); + + Assert.NotEqual(key, s_selector.ComputePredicatePlanKey(ImmutableList.Create(predicateChanged))); + Assert.NotEqual(key, s_selector.ComputePredicatePlanKey(ImmutableList.Create(disabled))); + Assert.NotEqual(key, s_selector.ComputePredicatePlanKey(ImmutableList.Create(excluded))); + } + [Fact] public void Select_ResultIsIdenticalAcrossIsEnabledToggle() { @@ -308,7 +396,7 @@ public void Select_ShouldIncludeUserDataFilters() [Fact] public void Select_ShouldPreserveHighlightColorNoneCandidates() { - // Arrange — None is enum-defined; the downstream GetHighlight loop no-ops it. + // Arrange - None is enum-defined; the downstream GetHighlight loop no-ops it. var noneColored = FilterBuilder.CreateTestFilter( FilterTestConstants.FilterIdEquals100, HighlightColor.None, diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramBuilderTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramBuilderTests.cs index 6f6ac681..8a2f15a6 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramBuilderTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramBuilderTests.cs @@ -5,6 +5,7 @@ using EventLogExpert.Eventing.Common.EventLogs; using EventLogExpert.Eventing.Common.Events; using EventLogExpert.Eventing.TestUtils; +using EventLogExpert.Filtering.Persistence; using EventLogExpert.Runtime.Histogram; using EventLogExpert.Runtime.LogTable; using EventLogExpert.Runtime.Tests.TestUtils; @@ -23,7 +24,7 @@ public void Build_AllEventsAtSameTime_ProducesASingleBucket() HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.Severity, maxBuckets: 100, CancellationToken.None); Assert.NotNull(data); - Assert.Equal(1, data!.BinCount); + Assert.Equal(1, data.BinCount); Assert.Equal(LevelSeverity.SlotCount, data.SlotCount); Assert.Equal(LevelSeverity.SlotCount, data.SlotCounts.Length); Assert.Equal(3, data.SlotCounts[0]); @@ -57,8 +58,8 @@ public void Build_CombinedView_GroupBySource_SumsByLogicalValueAcrossStores() HistogramData? data = HistogramBuilder.Build(combined, HistogramDimension.Source, maxBuckets: 100, CancellationToken.None); Assert.NotNull(data); - Assert.Equal("shared", data!.Groups[1].Label); // most frequent across both stores (5) - Assert.Equal(5, GroupTotal(data, 1)); + Assert.Equal("shared", data!.Groups[0].Label); // most frequent across both stores (5) + Assert.Equal(5, GroupTotal(data, 0)); Assert.Equal(7, data.Total); } @@ -150,7 +151,7 @@ public void Build_GroupByErrorCode_OmitsSuccessesAndIneligibleProviders() Assert.NotNull(data); Assert.Equal(2, data!.Total); // only the eligible failures contribute; successes and other providers are omitted - Assert.Equal(2, GroupTotal(data, 1)); + Assert.Equal(2, GroupTotal(data, 0)); } [Fact] @@ -165,11 +166,11 @@ public void Build_GroupByErrorCode_SplitsByHexLabelWithCuratedSymbol() Assert.NotNull(data); Assert.False(data!.GroupingFieldAbsent); Assert.Equal("error-code events", data.EventNoun); - Assert.Equal("0x800F081F CBS_E_SOURCE_MISSING", data.Groups[1].Label); - Assert.Equal("cat:2148468767", data.Groups[1].Key); - Assert.Equal(2, GroupTotal(data, 1)); - Assert.Equal("0x800F0823 CBS_E_NEW_SERVICING_STACK_REQUIRED", data.Groups[2].Label); - Assert.Equal(1, GroupTotal(data, 2)); + Assert.Equal("0x800F081F CBS_E_SOURCE_MISSING", data.Groups[0].Label); + Assert.Equal("cat:2148468767", data.Groups[0].Key); + Assert.Equal(2, GroupTotal(data, 0)); + Assert.Equal("0x800F0823 CBS_E_NEW_SERVICING_STACK_REQUIRED", data.Groups[1].Label); + Assert.Equal(1, GroupTotal(data, 1)); Assert.Equal(3, data.Total); } @@ -182,7 +183,7 @@ public void Build_GroupByErrorCode_UnrecognizedCode_UsesHexLabelWithoutSymbol() HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.ErrorCode, maxBuckets: 100, CancellationToken.None); Assert.NotNull(data); - Assert.Equal("0x80070005", data!.Groups[1].Label); + Assert.Equal("0x80070005", data!.Groups[0].Label); } [Fact] @@ -194,10 +195,10 @@ public void Build_GroupByEventId_LabelsCategoriesWithTheNumericIds() HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.EventId, maxBuckets: 100, CancellationToken.None); Assert.NotNull(data); - Assert.Equal("1000", data!.Groups[1].Label); - Assert.Equal("2000", data.Groups[2].Label); - Assert.Equal("3000", data.Groups[3].Label); - Assert.Equal(3, GroupTotal(data, 1)); + Assert.Equal("1000", data!.Groups[0].Label); + Assert.Equal("2000", data.Groups[1].Label); + Assert.Equal("3000", data.Groups[2].Label); + Assert.Equal(3, GroupTotal(data, 0)); Assert.Equal(6, data.Total); } @@ -213,9 +214,9 @@ public void Build_GroupByLog_EscalatesToFullPathWhenFileNameAndParentBothCollide Assert.NotNull(data); // Same file name AND parent folder ("logs"), so the parenthetical form also collides and both escalate to the full owning-log path. - Assert.Equal(@"C:\logs\Security.evtx", data!.Groups[1].Label); - Assert.Equal(@"D:\logs\Security.evtx", data.Groups[2].Label); - Assert.NotEqual(data.Groups[1].Label, data.Groups[2].Label); + Assert.Equal(@"C:\logs\Security.evtx", data!.Groups[0].Label); + Assert.Equal(@"D:\logs\Security.evtx", data.Groups[1].Label); + Assert.NotEqual(data.Groups[0].Label, data.Groups[1].Label); } [Fact] @@ -230,15 +231,15 @@ public void Build_GroupByLog_LabelsWithShortNameAndKeepsSameShortNameLogsSeparat HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.Log, maxBuckets: 100, CancellationToken.None); Assert.NotNull(data); - Assert.Equal(4, data!.Groups.Count); // Other + 3 distinct logs - Assert.Equal("Security.evtx (logsA)", data.Groups[1].Label); // colliding file names disambiguated by parent folder... - Assert.Equal("Security.evtx (logsB)", data.Groups[2].Label); - Assert.Equal("System", data.Groups[3].Label); - Assert.Equal(@"cat:C:\logsA\Security.evtx", data.Groups[1].Key); // ...while the toggle Key stays the raw owning-log path - Assert.NotEqual(data.Groups[1].Key, data.Groups[2].Key); - Assert.Equal(3, GroupTotal(data, 1)); - Assert.Equal(2, GroupTotal(data, 2)); - Assert.Equal(1, GroupTotal(data, 3)); + Assert.Equal(3, data!.Groups.Count); + Assert.Equal("Security.evtx (logsA)", data.Groups[0].Label); // colliding file names disambiguated by parent folder... + Assert.Equal("Security.evtx (logsB)", data.Groups[1].Label); + Assert.Equal("System", data.Groups[2].Label); + Assert.Equal(@"cat:C:\logsA\Security.evtx", data.Groups[0].Key); // ...while the toggle Key stays the raw owning-log path + Assert.NotEqual(data.Groups[0].Key, data.Groups[1].Key); + Assert.Equal(3, GroupTotal(data, 0)); + Assert.Equal(2, GroupTotal(data, 1)); + Assert.Equal(1, GroupTotal(data, 2)); Assert.Equal(6, data.Total); } @@ -278,11 +279,11 @@ public void Build_GroupByLogonType_SplitsByDecodedLabel() 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)); + Assert.Equal("Network", data.Groups[0].Label); + Assert.Equal("cat:3", data.Groups[0].Key); + Assert.Equal(3, GroupTotal(data, 0)); + Assert.Equal("RemoteInteractive", data.Groups[1].Label); + Assert.Equal(2, GroupTotal(data, 1)); } [Fact] @@ -293,23 +294,173 @@ public void Build_GroupByLogonType_UnrecognizedCode_KeepsTheRawCodeAsLabel() 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); + Assert.Equal("99", data!.Groups[0].Label); + Assert.Equal("cat:99", data.Groups[0].Key); + } + + [Fact] + public void Build_GroupByParentProcessImage_UsesParentImageCandidates() + { + var view = DisplayViewTestFactory.Build( + s_logId, + [ + ProcessImageEvent(0, ("ParentProcessName", "-"), ("ParentImage", @"C:\Office\WINWORD.EXE")), + ProcessImageEvent(1, ("ParentProcessName", @"C:\Office\EXCEL.EXE"), ("ParentImage", @"C:\ignored.exe")) + ]); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.ParentProcessImage, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal("excel.exe", data.Groups[0].Label); + Assert.Equal("cat:excel.exe", data.Groups[0].Key); + Assert.Equal("winword.exe", data.Groups[1].Label); + Assert.Equal(2, data.Total); + } + + [Fact] + public void Build_GroupByProcessImage_FieldAbsentFromView_SignalsEmptyState() + { + var view = DisplayViewTestFactory.Build( + s_logId, + [ + ProcessImageEvent(0, ("NewProcessName", "-"), ("Image", " ")), + ProcessImageEvent(1, ("NewProcessName", " ")) + ]); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.ProcessImage, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.True(data.GroupingFieldAbsent); + Assert.Empty(data.Groups); + Assert.Equal(2, data.Total); + } + + [Fact] + public void Build_GroupByProcessImage_FoldsRawPathsByShortName() + { + var view = DisplayViewTestFactory.Build( + s_logId, + [ + ProcessImageEvent(0, ("NewProcessName", @"C:\Windows\System32\RUNDLL32.EXE")), + ProcessImageEvent(1, ("NewProcessName", @"C:\temp\rundll32.exe")) + ]); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.ProcessImage, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.Single(data.Groups); + Assert.Equal("rundll32.exe", data.Groups[0].Label); + Assert.Equal("cat:rundll32.exe", data.Groups[0].Key); + Assert.Equal(2, GroupTotal(data, 0)); + } + + [Fact] + public void Build_GroupByProcessImage_HandlesSlashesQuotesAndTrailingSeparators() + { + var view = DisplayViewTestFactory.Build( + s_logId, + [ + ProcessImageEvent(0, ("NewProcessName", "/opt/tools/CMD.EXE")), + ProcessImageEvent(1, ("NewProcessName", "\"C:\\Program Files\\PowerShell\\PowerShell.EXE\"")), + ProcessImageEvent(2, ("NewProcessName", @"C:\")) + ]); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.ProcessImage, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.Contains(data.Groups, group => group.Label == "cmd.exe"); + Assert.Contains(data.Groups, group => group.Label == "powershell.exe"); + Assert.Equal(1, GroupTotal(data, 0)); + Assert.Equal(3, data.Total); + } + + [Fact] + public void Build_GroupByProcessImage_RoutesToNewProcessNameThenImage() + { + var view = DisplayViewTestFactory.Build( + s_logId, + [ + ProcessImageEvent(0, ("NewProcessName", "-"), ("Image", @"C:\Windows\System32\NOTEPAD.EXE")) + ]); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.ProcessImage, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.False(data.GroupingFieldAbsent); + Assert.Equal("notepad.exe", data.Groups[0].Label); + Assert.Equal(1, data.Total); + } + + [Fact] + public void Build_GroupByProcessImage_TopNamesKeepOtherBand() + { + var view = DisplayViewTestFactory.Build( + s_logId, + ProcessImageEvents( + (@"C:\a\alpha.exe", 5), + (@"C:\b\bravo.exe", 4), + (@"C:\c\charlie.exe", 3), + (@"C:\d\delta.exe", 2), + (@"C:\e\echo.exe", 1), + (@"C:\f\foxtrot.exe", 1), + (@"C:\g\golf.exe", 1), + (@"C:\h\hotel.exe", 1), + (@"C:\i\india.exe", 1))); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.ProcessImage, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal(HistogramConstants.MaxGroupByCategories + 1, data.Groups.Count); + Assert.Equal("Other (1 process)", data.Groups[0].Label); + Assert.DoesNotContain(data.Groups, group => group.Label == "india.exe"); + Assert.Equal(1, GroupTotal(data, 0)); + Assert.Equal(19, data.Total); + } + + [Fact] + public void Build_GroupByProcessImage_WhenAllProcessesShownAndRowsAreMissing_LabelsOtherPlainly() + { + var view = DisplayViewTestFactory.Build( + s_logId, + [ + ProcessImageEvent(0, ("NewProcessName", @"C:\tools\cmd.exe")), + EventAt(1, nameof(SeverityLevel.Information)) + ]); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.ProcessImage, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal("Other", data!.Groups[0].Label); + Assert.Equal("cmd.exe", data.Groups[1].Label); + Assert.Equal(1, GroupTotal(data, 0)); + Assert.Equal(1, GroupTotal(data, 1)); + Assert.Equal(2, data.Total); } [Fact] public void Build_GroupBySource_FoldsValuesBeyondTheTopCapIntoOther() { - var events = SourceEvents(("s1", 5), ("s2", 4), ("s3", 3), ("s4", 2), ("s5", 1), ("s6", 1)); + var events = SourceEvents( + ("s01", 9), + ("s02", 8), + ("s03", 7), + ("s04", 6), + ("s05", 5), + ("s06", 4), + ("s07", 3), + ("s08", 2), + ("s09", 1), + ("s10", 1)); var view = DisplayViewTestFactory.Build(s_logId, events); HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.Source, maxBuckets: 100, CancellationToken.None); Assert.NotNull(data); - Assert.Equal(HistogramConstants.MaxGroupByCategories + 1, data!.Groups.Count); // Other + top 4 - Assert.DoesNotContain(data.Groups, group => group.Label is "s5" or "s6"); - Assert.Equal(2, GroupTotal(data, 0)); // s5 + s6 fell into Other - Assert.Equal(16, data.Total); + Assert.Equal(HistogramConstants.MaxGroupByCategories + 1, data!.Groups.Count); + Assert.Equal("Other (2 sources)", data.Groups[0].Label); + Assert.DoesNotContain(data.Groups, group => group.Label is "s09" or "s10"); + Assert.Equal(2, GroupTotal(data, 0)); + Assert.Equal(46, data.Total); } [Fact] @@ -321,18 +472,99 @@ public void Build_GroupBySource_RanksTopCategoriesByCountDescending() HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.Source, maxBuckets: 100, CancellationToken.None); Assert.NotNull(data); - Assert.Equal(4, data!.Groups.Count); // Other + 3 categories - Assert.Equal("Other", data.Groups[0].Label); - Assert.Equal("apache", data.Groups[1].Label); - Assert.Equal("nginx", data.Groups[2].Label); - Assert.Equal("caddy", data.Groups[3].Label); - Assert.Equal(3, GroupTotal(data, 1)); - Assert.Equal(2, GroupTotal(data, 2)); - Assert.Equal(1, GroupTotal(data, 3)); - Assert.Equal(0, GroupTotal(data, 0)); + Assert.Equal(3, data!.Groups.Count); + Assert.Equal("apache", data.Groups[0].Label); + Assert.Equal("nginx", data.Groups[1].Label); + Assert.Equal("caddy", data.Groups[2].Label); + Assert.Equal(3, GroupTotal(data, 0)); + Assert.Equal(2, GroupTotal(data, 1)); + Assert.Equal(1, GroupTotal(data, 2)); Assert.Equal(6, data.Total); } + [Fact] + public void Build_GroupBySource_WhenEightDistinctValues_EmitsNoOtherGroup() + { + var events = SourceEvents(("s01", 8), ("s02", 7), ("s03", 6), ("s04", 5), ("s05", 4), ("s06", 3), ("s07", 2), ("s08", 1)); + var view = DisplayViewTestFactory.Build(s_logId, events); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.Source, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal(8, data!.Groups.Count); + Assert.DoesNotContain(data.Groups, group => group.Key == "cat-other"); + Assert.Equal(9, data.SlotCount); + } + + [Fact] + public void Build_GroupBySource_WhenNineDistinctValuesAndTieInactive_KeepsTopEightWithOther() + { + var events = SourceEvents(("s01", 9), ("s02", 8), ("s03", 7), ("s04", 6), ("s05", 5), ("s06", 4), ("s07", 3), ("s08", 2), ("s09", 1)); + var view = DisplayViewTestFactory.Build(s_logId, events); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.Source, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal(HistogramConstants.MaxGroupByCategories + 1, data!.Groups.Count); + Assert.Equal("Other (1 source)", data.Groups[0].Label); + Assert.DoesNotContain(data.Groups, group => group.Label == "s09"); + Assert.Equal(1, GroupTotal(data, 0)); + } + + [Fact] + public void Build_GroupBySource_WhenThirteenDistinctValues_KeepsTopEightWithOther() + { + var events = SourceEvents( + ("s01", 13), + ("s02", 12), + ("s03", 11), + ("s04", 10), + ("s05", 9), + ("s06", 8), + ("s07", 7), + ("s08", 6), + ("s09", 5), + ("s10", 4), + ("s11", 3), + ("s12", 2), + ("s13", 1)); + var view = DisplayViewTestFactory.Build(s_logId, events); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.Source, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal(HistogramConstants.MaxGroupByCategories + 1, data!.Groups.Count); + Assert.Equal("Other (5 sources)", data.Groups[0].Label); + Assert.Equal(15, GroupTotal(data, 0)); + } + + [Fact] + public void Build_GroupBySource_WhenTwelveDistinctValuesAndTieInactive_KeepsTopEightWithOther() + { + var events = SourceEvents( + ("s01", 12), + ("s02", 11), + ("s03", 10), + ("s04", 9), + ("s05", 8), + ("s06", 7), + ("s07", 6), + ("s08", 5), + ("s09", 4), + ("s10", 3), + ("s11", 2), + ("s12", 1)); + var view = DisplayViewTestFactory.Build(s_logId, events); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.Source, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal(HistogramConstants.MaxGroupByCategories + 1, data!.Groups.Count); + Assert.Equal("Other (4 sources)", data.Groups[0].Label); + Assert.DoesNotContain(data.Groups, group => group.Label is "s09" or "s10" or "s11" or "s12"); + Assert.Equal(10, GroupTotal(data, 0)); + } + [Fact] public void Build_GroupByTicketEncryptionType_DecimalAndHexCodesFoldIntoOneBand() { @@ -342,10 +574,10 @@ public void Build_GroupByTicketEncryptionType_DecimalAndHexCodesFoldIntoOneBand( 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)); + Assert.Single(data!.Groups); + Assert.Equal("RC4", data.Groups[0].Label); + Assert.Equal("cat:23", data.Groups[0].Key); + Assert.Equal(3, GroupTotal(data, 0)); } [Fact] @@ -371,6 +603,83 @@ public void Build_RecordsPerSeverityCountsInTheBaseBuffer() Assert.Equal(4, data.Total); } + [Fact] + public void BuildWithHighlightTie_GroupBySource_WhenNineDistinctValues_EmitsNoOtherGroup() + { + var events = SourceEvents(("s01", 9), ("s02", 8), ("s03", 7), ("s04", 6), ("s05", 5), ("s06", 4), ("s07", 3), ("s08", 2), ("s09", 1)); + var view = DisplayViewTestFactory.Build(s_logId, events); + SavedFilter filter = CreateFilter("Id == 0"); + byte[] highlightWinners = view.EnsureHighlightWinners([filter], planKey: 1, CancellationToken.None); + + HistogramData? data = HistogramBuilder.BuildWithHighlightTie(view, HistogramDimension.Source, maxBuckets: 100, highlightWinners, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal(9, data!.Groups.Count); + Assert.DoesNotContain(data.Groups, group => group.Key == "cat-other"); + Assert.Equal(10, data.SlotCount); + } + + [Fact] + public void BuildWithHighlightTie_GroupBySource_WhenThirteenDistinctValues_KeepsTopEightWithOther() + { + var events = SourceEvents( + ("s01", 13), + ("s02", 12), + ("s03", 11), + ("s04", 10), + ("s05", 9), + ("s06", 8), + ("s07", 7), + ("s08", 6), + ("s09", 5), + ("s10", 4), + ("s11", 3), + ("s12", 2), + ("s13", 1)); + var view = DisplayViewTestFactory.Build(s_logId, events); + SavedFilter filter = CreateFilter("Id == 0"); + byte[] highlightWinners = view.EnsureHighlightWinners([filter], planKey: 1, CancellationToken.None); + + HistogramData? data = HistogramBuilder.BuildWithHighlightTie(view, HistogramDimension.Source, maxBuckets: 100, highlightWinners, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal(HistogramConstants.MaxGroupByCategories + 1, data!.Groups.Count); + Assert.Equal("Other (5 sources)", data.Groups[0].Label); + Assert.Equal(15, GroupTotal(data, 0)); + } + + [Fact] + public void BuildWithHighlightTie_GroupBySource_WhenTwelveDistinctValues_EmitsNoOtherGroup() + { + var events = SourceEvents( + ("s01", 12), + ("s02", 11), + ("s03", 10), + ("s04", 9), + ("s05", 8), + ("s06", 7), + ("s07", 6), + ("s08", 5), + ("s09", 4), + ("s10", 3), + ("s11", 2), + ("s12", 1)); + var view = DisplayViewTestFactory.Build(s_logId, events); + SavedFilter filter = CreateFilter("Id == 0"); + byte[] highlightWinners = view.EnsureHighlightWinners([filter], planKey: 1, CancellationToken.None); + + HistogramData? data = HistogramBuilder.BuildWithHighlightTie(view, HistogramDimension.Source, maxBuckets: 100, highlightWinners, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal(HistogramConstants.GraceGroupByCategories, data!.Groups.Count); + Assert.DoesNotContain(data.Groups, group => group.Key == "cat-other"); + Assert.Equal(13, data.SlotCount); + } + + private static SavedFilter CreateFilter(string text) => + SavedFilter.TryCreate(text, color: HighlightColor.LightRed, isEnabled: true) + ?? throw new InvalidOperationException($"Failed to compile test filter '{text}'."); + private static ResolvedEvent[] ErrorCodeEvents(params (string Source, object? ErrorCode, int Count)[] groups) { var events = new List(); @@ -494,6 +803,29 @@ private static ResolvedEvent[] LogEvents(params (string OwningLog, int Count)[] return [.. events]; } + private static ResolvedEvent ProcessImageEvent(long ticks, params (string Name, object? Value)[] fields) => + new ResolvedEvent("TestLog", LogPathType.Channel) + { + Id = 4688, + TimeCreated = new DateTime(ticks, DateTimeKind.Utc) + }.WithEventData(fields); + + private static ResolvedEvent[] ProcessImageEvents(params (string NewProcessName, int Count)[] groups) + { + var events = new List(); + long ticks = 0; + + foreach ((string newProcessName, int count) in groups) + { + for (int index = 0; index < count; index++) + { + events.Add(ProcessImageEvent(ticks++, ("NewProcessName", newProcessName))); + } + } + + return [.. events]; + } + private static ResolvedEvent ServicingUserDataEvent(string userDataPath, string errorCode, long ticks) => new ResolvedEvent("TestLog", LogPathType.Channel) { Id = 3, Source = "Microsoft-Windows-Servicing", TimeCreated = new DateTime(ticks, DateTimeKind.Utc) } .WithUserData((userDataPath, errorCode)); diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramEffectsTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramEffectsTests.cs new file mode 100644 index 00000000..9a54aa3d --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramEffectsTests.cs @@ -0,0 +1,84 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Runtime.EventLog; +using EventLogExpert.Runtime.Histogram; +using Fluxor; +using NSubstitute; +using System.Collections.Immutable; + +namespace EventLogExpert.Runtime.Tests.Histogram; + +public sealed class HistogramEffectsTests +{ + [Fact] + public async Task HandleCloseAllLogs_WhenNoLogsAndRequestExists_ClearsRequest() + { + var (effects, dispatcher) = CreateEffects( + new EventLogState(), + new HistogramState { DimensionRequest = new HistogramDimensionRequest(HistogramDimension.EventId, 1) }); + + await effects.HandleCloseAllLogs(new CloseAllLogsAction(), dispatcher); + + dispatcher.Received(1).Dispatch(Arg.Any()); + } + + [Fact] + public async Task HandleCloseAllLogs_WhenNoRequest_DoesNotClearRequest() + { + var (effects, dispatcher) = CreateEffects(new EventLogState(), new HistogramState()); + + await effects.HandleCloseAllLogs(new CloseAllLogsAction(), dispatcher); + + dispatcher.DidNotReceive().Dispatch(Arg.Any()); + } + + [Fact] + public async Task HandleCloseLog_WhenLogsRemain_DoesNotClearRequest() + { + EventLogId remainingLogId = EventLogId.Create(); + EventLogState eventLogState = new() + { + OpenLogs = ImmutableDictionary.Empty.Add( + "Application", + new OpenLogInfo(remainingLogId, LogPathType.Channel)) + }; + var (effects, dispatcher) = CreateEffects( + eventLogState, + new HistogramState { DimensionRequest = new HistogramDimensionRequest(HistogramDimension.EventId, 1) }); + + await effects.HandleCloseLog(new CloseLogAction(EventLogId.Create(), "System"), dispatcher); + + dispatcher.DidNotReceive().Dispatch(Arg.Any()); + } + + [Fact] + public async Task HandleCloseLog_WhenNoLogsAndRequestExists_ClearsRequest() + { + var (effects, dispatcher) = CreateEffects( + new EventLogState(), + new HistogramState { DimensionRequest = new HistogramDimensionRequest(HistogramDimension.EventId, 1) }); + + await effects.HandleCloseLog(new CloseLogAction(EventLogId.Create(), "System"), dispatcher); + + dispatcher.Received(1).Dispatch(Arg.Any()); + } + + private static (Effects Effects, IDispatcher Dispatcher) CreateEffects( + EventLogState eventLogStateValue, + HistogramState histogramStateValue) + { + var eventLogState = Substitute.For>(); + eventLogState.Value.Returns(eventLogStateValue); + + var histogramState = Substitute.For>(); + histogramState.Value.Returns(histogramStateValue); + + var dispatcher = Substitute.For(); + Effects effects = new(eventLogState, histogramState); + + return (effects, dispatcher); + } +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramGroupsTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramGroupsTests.cs index 352a3ce4..72ad056d 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramGroupsTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramGroupsTests.cs @@ -33,6 +33,24 @@ public void ForCategories_SyntheticOtherKeyIsDistinctFromACategoryNamedOther() Assert.NotEqual(groups[0].Key, groups[1].Key); } + [Fact] + public void ForCategories_WhenOtherLabelIsNull_OmitsTheOtherGroup() + { + var groups = HistogramGroups.ForCategories(["a", "b"], ["A", "B"], otherLabel: null); + + Assert.Equal(["A", "B"], groups.Select(group => group.Label)); + Assert.DoesNotContain(groups, group => group.Key == "cat-other"); + } + + [Fact] + public void ForCategories_WhenOtherLabelIsSupplied_UsesItForTheOtherGroup() + { + var groups = HistogramGroups.ForCategories(["a"], ["A"], "Other (1 source)"); + + Assert.Equal("Other (1 source)", groups[0].Label); + Assert.Equal("cat-other", groups[0].Key); + } + [Fact] public void Severity_GroupKeysAreDistinct() { diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramHighlightTieTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramHighlightTieTests.cs new file mode 100644 index 00000000..7333f7b2 --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramHighlightTieTests.cs @@ -0,0 +1,263 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Filtering.Persistence; +using EventLogExpert.Filtering.TestUtils; +using EventLogExpert.Filtering.TestUtils.Constants; +using EventLogExpert.Runtime.Histogram; +using EventLogExpert.Runtime.LogTable; +using EventLogExpert.Runtime.Tests.TestUtils; + +namespace EventLogExpert.Runtime.Tests.Histogram; + +public sealed class HistogramHighlightTieTests +{ + [Fact] + public void Build_WhenHighlightTieIsNotRequested_LeavesGroupMasksNull() + { + EventColumnView view = DisplayViewTestFactory.Build( + EventLogId.Create(), + [FilterEventBuilder.CreateTestEvent(20)]); + + HistogramData data = HistogramBuilder.Build( + view, + HistogramDimension.EventId, + HistogramConstants.MaxBuckets, + TestContext.Current.CancellationToken)!; + + Assert.Null(data.GroupHighlightMasks); + } + + [Fact] + public void BuildWithHighlightTie_FieldDimension_SetsMasksForCountedRows() + { + EventColumnView view = DisplayViewTestFactory.Build( + EventLogId.Create(), + [FilterEventBuilder.CreateTestEvent(20, source: "Alpha")]); + SavedFilter[] filters = [CreateFilter("Id == 20", HighlightColor.LightRed)]; + byte[] highlightWinners = view.EnsureHighlightWinners(filters, planKey: 1, TestContext.Current.CancellationToken); + + HistogramData data = BuildTie(view, HistogramDimension.Source, highlightWinners); + + int group = GroupIndex(data, "Alpha"); + Assert.Equal(1u << 1, data.GroupHighlightMasks![group]); + } + + [Theory] + [InlineData(HistogramDimension.Severity)] + [InlineData(HistogramDimension.Source)] + [InlineData(HistogramDimension.EventId)] + [InlineData(HistogramDimension.Log)] + [InlineData(HistogramDimension.LogonType)] + [InlineData(HistogramDimension.TicketEncryptionType)] + [InlineData(HistogramDimension.ErrorCode)] + [InlineData(HistogramDimension.ProcessImage)] + public void BuildWithHighlightTie_PreservesSlotCountsAcrossDimensions(HistogramDimension dimension) + { + EventColumnView view = DisplayViewTestFactory.Build( + EventLogId.Create(), + [ + FilterEventBuilder.CreateTestEvent(20, source: "Alpha", level: FilterTestConstants.EventLevelError), + FilterEventBuilder.CreateTestEvent(21, source: "Beta", level: "Information") + ]); + SavedFilter[] filters = [CreateFilter("Id == 20 || Id == 21", HighlightColor.LightRed)]; + byte[] highlightWinners = view.EnsureHighlightWinners(filters, planKey: 1, TestContext.Current.CancellationToken); + + HistogramData plain = HistogramBuilder.Build( + view, + dimension, + HistogramConstants.MaxBuckets, + TestContext.Current.CancellationToken)!; + HistogramData tied = BuildTie(view, dimension, highlightWinners); + + Assert.Equal(plain.SlotCounts, tied.SlotCounts); + } + + [Fact] + public void BuildWithHighlightTie_UsesCapturedWinnerArrayWhenViewCacheChanges() + { + EventColumnView view = DisplayViewTestFactory.Build( + EventLogId.Create(), + [FilterEventBuilder.CreateTestEvent(20)]); + SavedFilter[] firstFilters = [CreateFilter("Id == 20", HighlightColor.LightRed)]; + SavedFilter[] secondFilters = [CreateFilter("Id == 21", HighlightColor.LightBlue)]; + byte[] capturedWinners = view.EnsureHighlightWinners(firstFilters, planKey: 1, TestContext.Current.CancellationToken); + _ = view.EnsureHighlightWinners(secondFilters, planKey: 2, TestContext.Current.CancellationToken); + + HistogramData data = BuildTie(view, HistogramDimension.EventId, capturedWinners); + + int group = GroupIndex(data, "20"); + Assert.Equal(1u << 1, data.GroupHighlightMasks![group]); + } + + [Fact] + public void BuildWithHighlightTie_WhenGroupHasOneWinner_StoresThatWinnerMask() + { + EventColumnView view = DisplayViewTestFactory.Build( + EventLogId.Create(), + [FilterEventBuilder.CreateTestEvent(20), FilterEventBuilder.CreateTestEvent(20)]); + SavedFilter[] filters = [CreateFilter("Id == 20", HighlightColor.LightRed)]; + byte[] highlightWinners = view.EnsureHighlightWinners(filters, planKey: 1, TestContext.Current.CancellationToken); + + HistogramData data = HistogramBuilder.BuildWithHighlightTie( + view, + HistogramDimension.EventId, + HistogramConstants.MaxBuckets, + highlightWinners, + TestContext.Current.CancellationToken)!; + + int group = GroupIndex(data, "20"); + Assert.Equal(1u << 1, data.GroupHighlightMasks![group]); + } + + [Fact] + public void BuildWithHighlightTie_WhenGroupHasUncoloredRow_IncludesBitZero() + { + EventColumnView view = DisplayViewTestFactory.Build( + EventLogId.Create(), + [ + FilterEventBuilder.CreateTestEvent(20, source: FilterTestConstants.EventSourceTestSource), + FilterEventBuilder.CreateTestEvent(20, source: FilterTestConstants.EventSourceOtherSource) + ]); + SavedFilter[] filters = [CreateFilter(FilterTestConstants.FilterSourceEqualsTestSource, HighlightColor.LightRed)]; + byte[] highlightWinners = view.EnsureHighlightWinners(filters, planKey: 1, TestContext.Current.CancellationToken); + + HistogramData data = HistogramBuilder.BuildWithHighlightTie( + view, + HistogramDimension.EventId, + HistogramConstants.MaxBuckets, + highlightWinners, + TestContext.Current.CancellationToken)!; + + int group = GroupIndex(data, "20"); + Assert.Equal((1u << 0) | (1u << 1), data.GroupHighlightMasks![group]); + } + + [Fact] + public void BuildWithHighlightTie_WhenSeverityGroupFoldsSlots_OrsSlotMasks() + { + EventColumnView view = DisplayViewTestFactory.Build( + EventLogId.Create(), + [ + FilterEventBuilder.CreateTestEvent(20, level: FilterTestConstants.EventLevelError), + FilterEventBuilder.CreateTestEvent(21, level: "Critical") + ]); + SavedFilter[] filters = + [ + CreateFilter("Id == 20", HighlightColor.LightRed), + CreateFilter("Id == 21", HighlightColor.LightRed) + ]; + byte[] highlightWinners = view.EnsureHighlightWinners(filters, planKey: 1, TestContext.Current.CancellationToken); + + HistogramData data = HistogramBuilder.BuildWithHighlightTie( + view, + HistogramDimension.Severity, + HistogramConstants.MaxBuckets, + highlightWinners, + TestContext.Current.CancellationToken)!; + + int group = GroupIndex(data, "Errors"); + Assert.Equal((1u << 1) | (1u << 2), data.GroupHighlightMasks![group]); + } + + [Fact] + public void CombinedColumnView_BuildWithHighlightTie_OrsPerChildMasks() + { + EventColumnView first = DisplayViewTestFactory.Build( + EventLogId.Create(), + [FilterEventBuilder.CreateTestEvent(20, source: "Alpha")]); + EventColumnView second = DisplayViewTestFactory.Build( + EventLogId.Create(), + [FilterEventBuilder.CreateTestEvent(20, source: "Beta")]); + var combined = new CombinedColumnView( + [first, second], + new SortContext(orderBy: null, isDescending: false, groupBy: null, isGroupDescending: false)); + SavedFilter[] filters = + [ + CreateFilter("Source == \"Alpha\"", HighlightColor.LightRed), + CreateFilter("Source == \"Beta\"", HighlightColor.LightBlue) + ]; + byte[] highlightWinners = combined.EnsureHighlightWinners(filters, planKey: 1, TestContext.Current.CancellationToken); + + HistogramData data = BuildTie(combined, HistogramDimension.EventId, highlightWinners); + + int group = GroupIndex(data, "20"); + Assert.Equal((1u << 1) | (1u << 2), data.GroupHighlightMasks![group]); + } + + [Fact] + public void CombinedColumnView_BuildWithHighlightTie_RejectsForeignWinnerHandle() + { + EventColumnView first = DisplayViewTestFactory.Build( + EventLogId.Create(), + [FilterEventBuilder.CreateTestEvent(20)]); + var combined = new CombinedColumnView( + [first], + new SortContext(orderBy: null, isDescending: false, groupBy: null, isGroupDescending: false)); + + Assert.Throws(() => + BuildTie(combined, HistogramDimension.EventId, [1])); + } + + [Fact] + public void EnsureHighlightWinners_AfterPlanChange_ReturnsPlanConsistentSnapshot() + { + EventColumnView view = DisplayViewTestFactory.Build( + EventLogId.Create(), + [FilterEventBuilder.CreateTestEvent(20), FilterEventBuilder.CreateTestEvent(21)]); + SavedFilter[] firstFilters = [CreateFilter("Id == 20", HighlightColor.LightRed)]; + SavedFilter[] secondFilters = [CreateFilter("Id == 21", HighlightColor.LightBlue)]; + byte[] firstWinners = view.EnsureHighlightWinners(firstFilters, planKey: 1, TestContext.Current.CancellationToken); + byte[] secondWinners = view.EnsureHighlightWinners(secondFilters, planKey: 2, TestContext.Current.CancellationToken); + + byte[] firstAgainWinners = view.EnsureHighlightWinners(firstFilters, planKey: 1, TestContext.Current.CancellationToken); + + Assert.Equal(new byte[] { 1, 0 }, firstWinners); + Assert.Equal(new byte[] { 0, 1 }, secondWinners); + Assert.Equal(new byte[] { 1, 0 }, firstAgainWinners); + Assert.NotSame(secondWinners, firstAgainWinners); + } + + [Fact] + public void EventColumnView_WithContext_PreservesCapturedHighlightWinners() + { + EventColumnView view = DisplayViewTestFactory.Build( + EventLogId.Create(), + [ + FilterEventBuilder.CreateTestEvent(20, timeCreated: DateTime.UtcNow.AddMinutes(1)), + FilterEventBuilder.CreateTestEvent(21, timeCreated: DateTime.UtcNow) + ]); + SavedFilter[] filters = [CreateFilter("Id == 20", HighlightColor.LightRed)]; + byte[] winners = view.EnsureHighlightWinners(filters, planKey: 1, TestContext.Current.CancellationToken); + IEventColumnView sorted = view.WithContext(new SortContext(ColumnName.DateAndTime, true, null, false)); + byte[] sortedWinners = sorted.EnsureHighlightWinners(filters, planKey: 1, TestContext.Current.CancellationToken); + + Assert.Same(winners, sortedWinners); + } + + private static HistogramData BuildTie( + IEventColumnView view, + HistogramDimension dimension, + byte[] highlightWinners) => + HistogramBuilder.BuildWithHighlightTie( + view, + dimension, + HistogramConstants.MaxBuckets, + highlightWinners, + TestContext.Current.CancellationToken)!; + + private static SavedFilter CreateFilter(string text, HighlightColor color) => + SavedFilter.TryCreate(text, color: color, isEnabled: true) + ?? throw new InvalidOperationException($"Failed to compile test filter '{text}'."); + + private static int GroupIndex(HistogramData data, string label) + { + for (int index = 0; index < data.Groups.Count; index++) + { + if (data.Groups[index].Label == label) { return index; } + } + + throw new InvalidOperationException($"Group '{label}' was not created."); + } +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramStateTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramStateTests.cs index dcd0c0d3..7d50512b 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramStateTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramStateTests.cs @@ -13,4 +13,79 @@ public void IsVisible_DefaultsToHidden() // The timeline is off on every launch - there is no persisted preference; a scenario or the View menu reveals it. Assert.False(new HistogramState().IsVisible); } + + [Fact] + public void ReduceClearHistogramDimensionRequest_WhenRequestExists_ClearsRequest() + { + HistogramState state = new() + { + DimensionRequest = new HistogramDimensionRequest(HistogramDimension.EventId, 1), + NextDimensionToken = 1 + }; + + var reduced = Reducers.ReduceClearHistogramDimensionRequest(state, new ClearHistogramDimensionRequestAction()); + + Assert.Null(reduced.DimensionRequest); + Assert.Equal(1, reduced.NextDimensionToken); + } + + [Fact] + public void ReduceRequestHistogramDimension_AfterClear_UsesNewToken() + { + var first = Reducers.ReduceRequestHistogramDimension( + new HistogramState(), + new RequestHistogramDimensionAction(HistogramDimension.EventId)); + var cleared = Reducers.ReduceClearHistogramDimensionRequest(first, new ClearHistogramDimensionRequestAction()); + + var second = Reducers.ReduceRequestHistogramDimension( + cleared, + new RequestHistogramDimensionAction(HistogramDimension.Source)); + + Assert.Equal(2, second.NextDimensionToken); + Assert.Equal(new HistogramDimensionRequest(HistogramDimension.Source, 2), second.DimensionRequest); + } + + [Fact] + public void ReduceRequestHistogramDimension_IncrementsNextDimensionToken() + { + HistogramState state = new() { NextDimensionToken = 7 }; + + var reduced = Reducers.ReduceRequestHistogramDimension( + state, + new RequestHistogramDimensionAction(HistogramDimension.EventId)); + + Assert.Equal(8, reduced.NextDimensionToken); + Assert.Equal(new HistogramDimensionRequest(HistogramDimension.EventId, 8), reduced.DimensionRequest); + } + + [Fact] + public void ReduceSetHistogramVisible_WhenHidden_ClearsRequest() + { + HistogramState state = new() + { + DimensionRequest = new HistogramDimensionRequest(HistogramDimension.EventId, 1), + IsVisible = true, + NextDimensionToken = 1 + }; + + var reduced = Reducers.ReduceSetHistogramVisible(state, new SetHistogramVisibleAction(false)); + + Assert.False(reduced.IsVisible); + Assert.Null(reduced.DimensionRequest); + } + + [Fact] + public void ReduceSetHistogramVisible_WhenShown_PreservesRequest() + { + HistogramState state = new() + { + DimensionRequest = new HistogramDimensionRequest(HistogramDimension.EventId, 1), + NextDimensionToken = 1 + }; + + var reduced = Reducers.ReduceSetHistogramVisible(state, new SetHistogramVisibleAction(true)); + + Assert.True(reduced.IsVisible); + Assert.Equal(state.DimensionRequest, reduced.DimensionRequest); + } } diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TestSupport/LegacyEventColumnView.cs b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TestSupport/LegacyEventColumnView.cs index bcd53110..92037c3a 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TestSupport/LegacyEventColumnView.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TestSupport/LegacyEventColumnView.cs @@ -3,6 +3,8 @@ using EventLogExpert.Eventing.Common.EventLogs; using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Filtering.Compilation; +using EventLogExpert.Filtering.Persistence; using EventLogExpert.Runtime.LogTable; using System.Diagnostics.CodeAnalysis; @@ -16,6 +18,8 @@ internal sealed class LegacyEventColumnView( { private readonly LegacyEventColumnReader _reader = new LegacyEventColumnReader(logId, generation, contentVersion, events); + private byte[]? _highlightWinners; + private int _highlightWinnersPlanKey; public int Count => _reader.Count; @@ -27,18 +31,42 @@ public void BucketTimeTicksByEventData(long minTicks, long bucketSpanTicks, int public void BucketTimeTicksByEventDataHResult(long minTicks, long bucketSpanTicks, int bucketCount, string fieldName, IReadOnlyCollection eligibleProviders, IReadOnlyList userDataErrorCodePaths, long[] targetCodes, int[] slotCounts, CancellationToken cancellationToken) => _reader.BucketTimeTicksByEventDataHResult(AllSurvive(), minTicks, bucketSpanTicks, bucketCount, fieldName, eligibleProviders, userDataErrorCodePaths, targetCodes, slotCounts, cancellationToken); + public void BucketTimeTicksByEventDataHResultWithTie(byte[] highlightWinners, uint[] slotColorMask, long minTicks, long bucketSpanTicks, int bucketCount, string fieldName, IReadOnlyCollection eligibleProviders, IReadOnlyList userDataErrorCodePaths, long[] targetCodes, int[] slotCounts, CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventDataHResultWithTie(AllSurvive(), highlightWinners, slotColorMask, minTicks, bucketSpanTicks, bucketCount, fieldName, eligibleProviders, userDataErrorCodePaths, targetCodes, slotCounts, cancellationToken); + + public void BucketTimeTicksByEventDataString(long minTicks, long bucketSpanTicks, int bucketCount, string[] candidateFields, IReadOnlyDictionary rawValueToSlot, int slotCount, int[] slotCounts, CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventDataString(AllSurvive(), minTicks, bucketSpanTicks, bucketCount, candidateFields, rawValueToSlot, slotCount, slotCounts, cancellationToken); + + public void BucketTimeTicksByEventDataStringWithTie(byte[] highlightWinners, uint[] slotColorMask, long minTicks, long bucketSpanTicks, int bucketCount, string[] candidateFields, IReadOnlyDictionary rawValueToSlot, int slotCount, int[] slotCounts, CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventDataStringWithTie(AllSurvive(), highlightWinners, slotColorMask, minTicks, bucketSpanTicks, bucketCount, candidateFields, rawValueToSlot, slotCount, slotCounts, cancellationToken); + + public void BucketTimeTicksByEventDataWithTie(byte[] highlightWinners, uint[] slotColorMask, long minTicks, long bucketSpanTicks, int bucketCount, string fieldName, long[] targetCodes, int[] slotCounts, CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventDataWithTie(AllSurvive(), highlightWinners, slotColorMask, 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); + public void BucketTimeTicksByEventIdWithTie(byte[] highlightWinners, uint[] slotColorMask, long minTicks, long bucketSpanTicks, int bucketCount, int[] targetIds, int[] slotCounts, CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventIdWithTie(AllSurvive(), highlightWinners, slotColorMask, minTicks, bucketSpanTicks, bucketCount, targetIds, slotCounts, cancellationToken); + public void BucketTimeTicksByField(long minTicks, long bucketSpanTicks, int bucketCount, EventFieldId field, string[] targetValues, int[] slotCounts, CancellationToken cancellationToken) => _reader.BucketTimeTicksByField(AllSurvive(), minTicks, bucketSpanTicks, bucketCount, field, targetValues, slotCounts, cancellationToken); + public void BucketTimeTicksByFieldWithTie(byte[] highlightWinners, uint[] slotColorMask, long minTicks, long bucketSpanTicks, int bucketCount, EventFieldId field, string[] targetValues, int[] slotCounts, CancellationToken cancellationToken) => + _reader.BucketTimeTicksByFieldWithTie(AllSurvive(), highlightWinners, slotColorMask, minTicks, bucketSpanTicks, bucketCount, field, targetValues, slotCounts, cancellationToken); + public void BucketTimeTicksBySeverity(long minTicks, long bucketSpanTicks, int bucketCount, int[] slotCounts, CancellationToken cancellationToken) => _reader.BucketTimeTicksBySeverity(AllSurvive(), minTicks, bucketSpanTicks, bucketCount, slotCounts, cancellationToken); + public void BucketTimeTicksBySeverityWithTie(byte[] highlightWinners, uint[] slotColorMask, long minTicks, long bucketSpanTicks, int bucketCount, int[] slotCounts, CancellationToken cancellationToken) => + _reader.BucketTimeTicksBySeverityWithTie(AllSurvive(), highlightWinners, slotColorMask, minTicks, bucketSpanTicks, bucketCount, slotCounts, cancellationToken); + public void CountEventDataHResults(string fieldName, IReadOnlyCollection eligibleProviders, IReadOnlyList userDataErrorCodePaths, IDictionary counts, CancellationToken cancellationToken) => _reader.CountEventDataHResults(AllSurvive(), fieldName, eligibleProviders, userDataErrorCodePaths, counts, cancellationToken); + public void CountEventDataStringValues(string[] candidateFields, IDictionary counts, CancellationToken cancellationToken) => + _reader.CountEventDataStringValues(AllSurvive(), candidateFields, counts, cancellationToken); + public void CountEventDataValues(string fieldName, IDictionary counts, CancellationToken cancellationToken) => _reader.CountEventDataValues(AllSurvive(), fieldName, counts, cancellationToken); @@ -48,6 +76,19 @@ public void CountEventIds(IDictionary counts, CancellationToken cancel public void CountFieldValues(EventFieldId field, IDictionary counts, CancellationToken cancellationToken) => _reader.CountFieldValues(AllSurvive(), field, counts, cancellationToken); + public byte[] EnsureHighlightWinners(IReadOnlyList orderedColoredFilters, int planKey, CancellationToken cancellationToken) + { + if (_highlightWinnersPlanKey == planKey && _highlightWinners is { Length: var length } && length == _reader.Count) + { + return _highlightWinners; + } + + _highlightWinners = FilterService.ClassifyHighlightWinners(_reader, AllSurvive(), orderedColoredFilters, cancellationToken); + _highlightWinnersPlanKey = planKey; + + return _highlightWinners; + } + public IEnumerable EnumerateDetail() { for (int physical = 0; physical < _reader.Count; physical++) diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs index 7db02717..a0eea26f 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Scenarios/ScenarioLaunchServiceTests.cs @@ -12,6 +12,7 @@ using Fluxor; using NSubstitute; using NSubstitute.ExceptionExtensions; +using System.Reflection; namespace EventLogExpert.Runtime.Tests.Scenarios; @@ -59,6 +60,46 @@ public async Task LaunchAsync_ActivatingScenario_ZeroOpenFreshView_DoesNotShowTi menu.DidNotReceive().SetHistogramVisible(Arg.Any()); } + [Fact] + public async Task LaunchAsync_ActivatingScenarioWithoutTimelineDimension_DoesNotDispatchDimensionRequest() + { + var (service, dispatcher, _, scenario) = + Create(new OpenLogsBatchResult(1, 0, 0, 0, []), activatesTimeline: true); + + await service.LaunchAsync(scenario, dateWindow: null); + + dispatcher.DidNotReceive().Dispatch(Arg.Any()); + } + + [Fact] + public async Task LaunchAsync_ActivatingScenarioWithTimelineDimension_DispatchesDimensionRequest() + { + var (service, dispatcher, _, scenario) = Create( + new OpenLogsBatchResult(1, 0, 0, 0, []), + activatesTimeline: true, + timelineDimension: ScenarioTimelineDimension.ErrorCode); + + await service.LaunchAsync(scenario, dateWindow: null); + + dispatcher.Received(1).Dispatch( + Arg.Is(action => action != null && action.Dimension == HistogramDimension.ErrorCode)); + } + + [Fact] + public async Task LaunchAsync_ActivatingScenarioWithTimelineDimension_WhenAlreadyVisible_DispatchesDimensionRequest() + { + var (service, dispatcher, _, scenario) = Create( + new OpenLogsBatchResult(1, 0, 0, 0, []), + activatesTimeline: true, + timelineVisible: true, + timelineDimension: ScenarioTimelineDimension.EventId); + + await service.LaunchAsync(scenario, dateWindow: null); + + dispatcher.Received(1).Dispatch( + Arg.Is(action => action != null && action.Dimension == HistogramDimension.EventId)); + } + [Fact] public async Task LaunchAsync_AppliesFiltersThenDate_BeforeOpening() { @@ -288,6 +329,63 @@ public async Task LaunchFromFolderAsync_CancelledToken_ReturnsCancelledWithoutOp dispatcher.DidNotReceive().Dispatch(Arg.Any()); } + [Fact] + public async Task LaunchFromFolderAsync_ActivatingScenarioWithoutTimelineDimension_DoesNotDispatchDimensionRequest() + { + var (service, dispatcher, menu, picker, enumerator, reader, scenario) = + CreateFolder(FolderScenario() with { ActivatesTimeline = true }); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); + reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); + menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); + + await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); + + dispatcher.DidNotReceive().Dispatch(Arg.Any()); + } + + [Fact] + public async Task LaunchFromFolderAsync_ActivatingScenarioWithTimelineDimension_DispatchesDimensionRequest() + { + var (service, dispatcher, menu, picker, enumerator, reader, scenario) = + CreateFolder(FolderScenario() with + { + ActivatesTimeline = true, + TimelineDimension = ScenarioTimelineDimension.Log + }); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); + reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); + menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); + + await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); + + dispatcher.Received(1).Dispatch( + Arg.Is(action => action != null && action.Dimension == HistogramDimension.Log)); + } + + [Fact] + public async Task LaunchFromFolderAsync_ActivatingScenarioWithTimelineDimension_WhenAlreadyVisible_DispatchesDimensionRequest() + { + var (service, dispatcher, menu, picker, enumerator, reader, scenario) = + CreateFolder( + FolderScenario() with + { + ActivatesTimeline = true, + TimelineDimension = ScenarioTimelineDimension.Source + }, + timelineVisible: true); + picker.PickFolderAsync().Returns("C:\\bundle"); + enumerator.EnumerateTopLevel("C:\\bundle", Arg.Any()).Returns(new EvtxFolderScanResult.Files(["C:\\bundle\\System.evtx"])); + reader.ReadChannel("C:\\bundle\\System.evtx").Returns(EvtxChannelReadResult.FromChannel("System")); + menu.OpenLogFilesAsync(Arg.Any>(), false).Returns(new OpenLogsBatchResult(1, 0, 0, 0, [])); + + await service.LaunchFromFolderAsync(scenario, dateWindow: null, TestContext.Current.CancellationToken); + + dispatcher.Received(1).Dispatch( + Arg.Is(action => action != null && action.Dimension == HistogramDimension.Source)); + } + [Fact] public async Task LaunchFromFolderAsync_CompletedWithUnreadableFile_PlumbsUnreadableCount() { @@ -428,10 +526,48 @@ public async Task LaunchFromFolderAsync_PickerThrows_ReturnsError() Assert.Equal("picker boom", result.Message); } + [Theory] + [InlineData(ScenarioTimelineDimension.Severity, HistogramDimension.Severity)] + [InlineData(ScenarioTimelineDimension.Source, HistogramDimension.Source)] + [InlineData(ScenarioTimelineDimension.EventId, HistogramDimension.EventId)] + [InlineData(ScenarioTimelineDimension.TaskCategory, HistogramDimension.TaskCategory)] + [InlineData(ScenarioTimelineDimension.Opcode, HistogramDimension.Opcode)] + [InlineData(ScenarioTimelineDimension.Log, HistogramDimension.Log)] + [InlineData(ScenarioTimelineDimension.LogonType, HistogramDimension.LogonType)] + [InlineData(ScenarioTimelineDimension.TicketEncryptionType, HistogramDimension.TicketEncryptionType)] + [InlineData(ScenarioTimelineDimension.ErrorCode, HistogramDimension.ErrorCode)] + [InlineData(ScenarioTimelineDimension.ProcessImage, HistogramDimension.ProcessImage)] + [InlineData(ScenarioTimelineDimension.ParentProcessImage, HistogramDimension.ParentProcessImage)] + public void MapTimelineDimension_MapsEveryScenarioDimension( + ScenarioTimelineDimension dimension, + HistogramDimension expected) + { + var method = typeof(ScenarioLaunchService).GetMethod( + "MapTimelineDimension", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + Assert.Equal(expected, method.Invoke(null, [dimension])); + } + + [Fact] + public void ScenarioTimelineDimension_MatchesHistogramDimensionNames() + { + Assert.Equal(Enum.GetNames(), Enum.GetNames()); + } + private static (IScenarioLaunchService Service, IDispatcher Dispatcher, IMenuActionService Menu, ScenarioDefinition Scenario) - Create(OpenLogsBatchResult openResult, bool activatesTimeline = false, bool timelineVisible = false) + Create( + OpenLogsBatchResult openResult, + bool activatesTimeline = false, + bool timelineVisible = false, + ScenarioTimelineDimension? timelineDimension = null) { - var scenario = ScenarioTestData.Single("a", "System", 1000) with { ActivatesTimeline = activatesTimeline }; + var scenario = ScenarioTestData.Single("a", "System", 1000) with + { + ActivatesTimeline = activatesTimeline, + TimelineDimension = timelineDimension + }; var registry = ScenarioTestData.Registry(scenario); var menu = Substitute.For(); diff --git a/tests/Unit/EventLogExpert.Scenarios.Tests/BuiltInCatalogValidationTests.cs b/tests/Unit/EventLogExpert.Scenarios.Tests/BuiltInCatalogValidationTests.cs index 29a9bc54..99509e15 100644 --- a/tests/Unit/EventLogExpert.Scenarios.Tests/BuiltInCatalogValidationTests.cs +++ b/tests/Unit/EventLogExpert.Scenarios.Tests/BuiltInCatalogValidationTests.cs @@ -49,6 +49,83 @@ public void Catalog_StableGuidsAreUnique() Assert.DoesNotContain(Guid.Empty, guids); } + [Fact] + public void Catalog_TimelineScenariosMatchExpectedDimensions() + { + Dictionary expected = new(StringComparer.Ordinal) + { + ["account-compromise-timeline"] = ScenarioTimelineDimension.EventId, + ["account-lockout-investigation"] = ScenarioTimelineDimension.EventId, + ["ad-replication-failures"] = ScenarioTimelineDimension.EventId, + ["boot-shutdown-restart-timeline"] = ScenarioTimelineDimension.EventId, + ["cluster-network-partition"] = ScenarioTimelineDimension.EventId, + ["cluster-node-quarantine"] = ScenarioTimelineDimension.EventId, + ["corrected-hardware-errors-whea"] = ScenarioTimelineDimension.EventId, + ["crashing-services"] = ScenarioTimelineDimension.EventId, + ["defender-malware-detected"] = ScenarioTimelineDimension.EventId, + ["defender-protection-timeline"] = ScenarioTimelineDimension.EventId, + ["dfsr-sysvol-failures"] = ScenarioTimelineDimension.EventId, + ["dhcp-lease-failures-apipa"] = ScenarioTimelineDimension.EventId, + ["disk-io-errors-bad-blocks"] = ScenarioTimelineDimension.EventId, + ["dns-analytic-query-failures"] = ScenarioTimelineDimension.EventId, + ["dns-resolution-timeouts"] = ScenarioTimelineDimension.EventId, + ["dns-zone-transfer-and-errors"] = ScenarioTimelineDimension.EventId, + ["endpoint-ir-triage-persistence-evasion"] = ScenarioTimelineDimension.Log, + ["exchange-transport-backpressure"] = ScenarioTimelineDimension.EventId, + ["failed-services-at-boot"] = ScenarioTimelineDimension.EventId, + ["failed-signins-brute-force"] = ScenarioTimelineDimension.LogonType, + ["faults-triage"] = ScenarioTimelineDimension.EventId, + ["group-policy-processing-errors"] = ScenarioTimelineDimension.Log, + ["hyperv-live-migration-failures"] = ScenarioTimelineDimension.EventId, + ["hyperv-replica-failures"] = ScenarioTimelineDimension.EventId, + ["hyperv-vm-health"] = ScenarioTimelineDimension.Log, + ["iis-app-pool-crashes"] = ScenarioTimelineDimension.EventId, + ["iscsi-connection-drops"] = ScenarioTimelineDimension.EventId, + ["kerberos-ticket-encryption-triage"] = ScenarioTimelineDimension.TicketEncryptionType, + ["logon-activity-triage"] = ScenarioTimelineDimension.EventId, + ["lolbin-process-creation-by-name"] = ScenarioTimelineDimension.ProcessImage, + ["netlogon-secure-channel-failures"] = ScenarioTimelineDimension.EventId, + ["nps-per-endpoint-triage"] = ScenarioTimelineDimension.EventId, + ["nps-per-nas-device-triage"] = ScenarioTimelineDimension.EventId, + ["nps-per-policy-access-triage"] = ScenarioTimelineDimension.EventId, + ["nps-radius-triage"] = ScenarioTimelineDimension.EventId, + ["nps-user-auth-story"] = ScenarioTimelineDimension.EventId, + ["office-spawned-process"] = ScenarioTimelineDimension.ParentProcessImage, + ["out-of-memory-resource-exhaustion"] = ScenarioTimelineDimension.EventId, + ["print-jobs-by-printer"] = ScenarioTimelineDimension.EventId, + ["print-jobs-by-user"] = ScenarioTimelineDimension.EventId, + ["rdp-activity-combined"] = ScenarioTimelineDimension.EventId, + ["rdp-network-failed-logon-triage"] = ScenarioTimelineDimension.LogonType, + ["remote-execution-lateral-movement-combined"] = ScenarioTimelineDimension.Log, + ["s2d-physical-disk-story"] = ScenarioTimelineDimension.EventId, + ["smb-client-connectivity-failures"] = ScenarioTimelineDimension.EventId, + ["sql-backup-restore-timeline"] = ScenarioTimelineDimension.EventId, + ["sql-deadlocks"] = ScenarioTimelineDimension.Source, + ["sql-health-triage"] = ScenarioTimelineDimension.EventId, + ["sql-login-failures"] = ScenarioTimelineDimension.Source, + ["storage-controller-driver-resets"] = ScenarioTimelineDimension.Source, + ["sysmon-lolbin-initiated-network"] = ScenarioTimelineDimension.ProcessImage, + ["time-synchronization-failures"] = ScenarioTimelineDimension.EventId, + ["unexpected-restart-power-loss-bsod"] = ScenarioTimelineDimension.EventId, + ["update-reboot-crash-correlation"] = ScenarioTimelineDimension.EventId, + ["vss-shadow-copy-backup-failures"] = ScenarioTimelineDimension.Log, + ["w3wp-dotnet-crashes"] = ScenarioTimelineDimension.EventId, + ["wifi-connect-disconnect-auth"] = ScenarioTimelineDimension.EventId, + ["windows-setup-servicing-errors"] = ScenarioTimelineDimension.EventId, + ["windows-update-diagnostics"] = ScenarioTimelineDimension.EventId + }; + var actual = s_registry.Scenarios + .Where(scenario => scenario.ActivatesTimeline) + .ToDictionary(scenario => scenario.Id, scenario => scenario.TimelineDimension, StringComparer.Ordinal); + + Assert.Equal(expected.Count, actual.Count); + foreach (var (scenarioId, dimension) in expected) + { + Assert.True(actual.TryGetValue(scenarioId, out var actualDimension), $"Missing timeline scenario '{scenarioId}'."); + Assert.Equal(dimension, actualDimension); + } + } + // The companion negative: a benign line-of-business process/command matches no row of any collapsed scenario, proving // the Contains-Any needle lists did not widen into a catch-all during the collapse. [Theory] @@ -86,32 +163,32 @@ public void CollapsedContainsAnyScenario_HasExpectedRowCount(string scenarioId, // needle). A misspelled needle in the refactored JSON fails here because the correctly-spelled payload cannot match a // mistyped filter needle - a defect that catalog compile/round-trip validation cannot detect. [Theory] - // lolbin-process-creation-by-name (Security 4688, NewProcessName): DarkRed / Red / Orange tiers. - [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "mshta.exe", "DarkRed")] - [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "wscript.exe", "DarkRed")] - [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "cscript.exe", "DarkRed")] - [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "rundll32.exe", "Red")] - [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "regsvr32.exe", "Red")] - [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "installutil.exe", "Red")] - [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "certutil.exe", "Orange")] - [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "bitsadmin.exe", "Orange")] - [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "wmic.exe", "Orange")] - // sysmon-lolbin-initiated-network (Sysmon 3, Image): DarkRed / Red / Orange tiers. - [InlineData("sysmon-lolbin-initiated-network", 3, "Image", "mshta.exe", "DarkRed")] - [InlineData("sysmon-lolbin-initiated-network", 3, "Image", "wscript.exe", "DarkRed")] - [InlineData("sysmon-lolbin-initiated-network", 3, "Image", "cscript.exe", "DarkRed")] - [InlineData("sysmon-lolbin-initiated-network", 3, "Image", "powershell.exe", "Red")] - [InlineData("sysmon-lolbin-initiated-network", 3, "Image", "pwsh.exe", "Red")] - [InlineData("sysmon-lolbin-initiated-network", 3, "Image", "rundll32.exe", "Orange")] - [InlineData("sysmon-lolbin-initiated-network", 3, "Image", "regsvr32.exe", "Orange")] - [InlineData("sysmon-lolbin-initiated-network", 3, "Image", "certutil.exe", "Orange")] - // suspicious-ps-scriptblock-ioc-triage (PowerShell 4104, ScriptBlockText): Red / Orange content tiers (the Blue row + // lolbin-process-creation-by-name (Security 4688, NewProcessName): LightRed / LightOrange / LightYellow tiers. + [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "mshta.exe", "LightRed")] + [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "wscript.exe", "LightRed")] + [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "cscript.exe", "LightRed")] + [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "rundll32.exe", "LightOrange")] + [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "regsvr32.exe", "LightOrange")] + [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "installutil.exe", "LightOrange")] + [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "certutil.exe", "LightYellow")] + [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "bitsadmin.exe", "LightYellow")] + [InlineData("lolbin-process-creation-by-name", 4688, "NewProcessName", "wmic.exe", "LightYellow")] + // sysmon-lolbin-initiated-network (Sysmon 3, Image): LightRed / LightOrange / LightYellow tiers. + [InlineData("sysmon-lolbin-initiated-network", 3, "Image", "mshta.exe", "LightRed")] + [InlineData("sysmon-lolbin-initiated-network", 3, "Image", "wscript.exe", "LightRed")] + [InlineData("sysmon-lolbin-initiated-network", 3, "Image", "cscript.exe", "LightRed")] + [InlineData("sysmon-lolbin-initiated-network", 3, "Image", "powershell.exe", "LightOrange")] + [InlineData("sysmon-lolbin-initiated-network", 3, "Image", "pwsh.exe", "LightOrange")] + [InlineData("sysmon-lolbin-initiated-network", 3, "Image", "rundll32.exe", "LightYellow")] + [InlineData("sysmon-lolbin-initiated-network", 3, "Image", "regsvr32.exe", "LightYellow")] + [InlineData("sysmon-lolbin-initiated-network", 3, "Image", "certutil.exe", "LightYellow")] + // suspicious-ps-scriptblock-ioc-triage (PowerShell 4104, ScriptBlockText): LightRed / LightOrange content tiers (the Blue row // keys on Level, not a needle, so it is covered by the Level-defaulting-empty non-match implicitly). - [InlineData("suspicious-ps-scriptblock-ioc-triage", 4104, "ScriptBlockText", "FromBase64String", "Red")] - [InlineData("suspicious-ps-scriptblock-ioc-triage", 4104, "ScriptBlockText", "Invoke-Expression", "Red")] - [InlineData("suspicious-ps-scriptblock-ioc-triage", 4104, "ScriptBlockText", "DownloadString", "Orange")] - [InlineData("suspicious-ps-scriptblock-ioc-triage", 4104, "ScriptBlockText", "New-Object Net.WebClient", "Orange")] - [InlineData("suspicious-ps-scriptblock-ioc-triage", 4104, "ScriptBlockText", "Reflection.Assembly]::Load", "Orange")] + [InlineData("suspicious-ps-scriptblock-ioc-triage", 4104, "ScriptBlockText", "FromBase64String", "LightRed")] + [InlineData("suspicious-ps-scriptblock-ioc-triage", 4104, "ScriptBlockText", "Invoke-Expression", "LightRed")] + [InlineData("suspicious-ps-scriptblock-ioc-triage", 4104, "ScriptBlockText", "DownloadString", "LightOrange")] + [InlineData("suspicious-ps-scriptblock-ioc-triage", 4104, "ScriptBlockText", "New-Object Net.WebClient", "LightOrange")] + [InlineData("suspicious-ps-scriptblock-ioc-triage", 4104, "ScriptBlockText", "Reflection.Assembly]::Load", "LightOrange")] // encoded-powershell-commandline-field (Security 4688, CommandLine): single uncolored (None) row. [InlineData("encoded-powershell-commandline-field", 4688, "CommandLine", "-EncodedCommand", "None")] [InlineData("encoded-powershell-commandline-field", 4688, "CommandLine", "-enc ", "None")] @@ -153,16 +230,16 @@ public void CollapsedContainsAnyScenario_MatchesEveryNeedleWithTierColor( // mshta.ex) or a tier re-expanded into same-colored single rows; this test rejects both by pinning Values + shape. [Theory] // lolbin-process-creation-by-name (Security 4688, NewProcessName) - [InlineData("lolbin-process-creation-by-name", 0, "DarkRed", "4688", "NewProcessName", new[] { "mshta.exe", "wscript.exe", "cscript.exe" })] - [InlineData("lolbin-process-creation-by-name", 1, "Red", "4688", "NewProcessName", new[] { "rundll32.exe", "regsvr32.exe", "installutil.exe" })] - [InlineData("lolbin-process-creation-by-name", 2, "Orange", "4688", "NewProcessName", new[] { "certutil.exe", "bitsadmin.exe", "wmic.exe" })] + [InlineData("lolbin-process-creation-by-name", 0, "LightRed", "4688", "NewProcessName", new[] { "mshta.exe", "wscript.exe", "cscript.exe" })] + [InlineData("lolbin-process-creation-by-name", 1, "LightOrange", "4688", "NewProcessName", new[] { "rundll32.exe", "regsvr32.exe", "installutil.exe" })] + [InlineData("lolbin-process-creation-by-name", 2, "LightYellow", "4688", "NewProcessName", new[] { "certutil.exe", "bitsadmin.exe", "wmic.exe" })] // sysmon-lolbin-initiated-network (Sysmon 3, Image) - [InlineData("sysmon-lolbin-initiated-network", 0, "DarkRed", "3", "Image", new[] { "mshta.exe", "wscript.exe", "cscript.exe" })] - [InlineData("sysmon-lolbin-initiated-network", 1, "Red", "3", "Image", new[] { "powershell.exe", "pwsh.exe" })] - [InlineData("sysmon-lolbin-initiated-network", 2, "Orange", "3", "Image", new[] { "rundll32.exe", "regsvr32.exe", "certutil.exe" })] + [InlineData("sysmon-lolbin-initiated-network", 0, "LightRed", "3", "Image", new[] { "mshta.exe", "wscript.exe", "cscript.exe" })] + [InlineData("sysmon-lolbin-initiated-network", 1, "LightOrange", "3", "Image", new[] { "powershell.exe", "pwsh.exe" })] + [InlineData("sysmon-lolbin-initiated-network", 2, "LightYellow", "3", "Image", new[] { "rundll32.exe", "regsvr32.exe", "certutil.exe" })] // suspicious-ps-scriptblock-ioc-triage (PowerShell 4104, ScriptBlockText) - rows 0/1 are Contains-Any; row 2 is the Level=Warning Blue row. - [InlineData("suspicious-ps-scriptblock-ioc-triage", 0, "Red", "4104", "ScriptBlockText", new[] { "FromBase64String", "Invoke-Expression" })] - [InlineData("suspicious-ps-scriptblock-ioc-triage", 1, "Orange", "4104", "ScriptBlockText", new[] { "DownloadString", "New-Object Net.WebClient", "Reflection.Assembly]::Load" })] + [InlineData("suspicious-ps-scriptblock-ioc-triage", 0, "LightRed", "4104", "ScriptBlockText", new[] { "FromBase64String", "Invoke-Expression" })] + [InlineData("suspicious-ps-scriptblock-ioc-triage", 1, "LightOrange", "4104", "ScriptBlockText", new[] { "DownloadString", "New-Object Net.WebClient", "Reflection.Assembly]::Load" })] // encoded-powershell-commandline-field (Security 4688, CommandLine) - single uncolored row [InlineData("encoded-powershell-commandline-field", 0, "None", "4688", "CommandLine", new[] { "-EncodedCommand", "-enc ", "FromBase64String", "IEX", "DownloadString", "-w hidden", "-nop" })] // lolbin-in-process-command-line (Security 4688, CommandLine) - single uncolored row, proxied-execution companion @@ -265,22 +342,21 @@ static ResolvedEvent Event(string source, int id) => } [Fact] - public void NonUpdateScenario_DoesNotActivateTimeline() + public void NonTimelineScenario_DoesNotActivateTimeline() { - // Activation is per-scenario, not catalog-wide: a neighbouring policy scenario must stay opted out. - var scenario = s_registry.Scenarios.Single(scenario => scenario.Id == "group-policy-processing-errors"); + var scenario = s_registry.Scenarios.Single(scenario => scenario.Id == "directory-service-errors"); Assert.False(scenario.ActivatesTimeline); } [Theory] - [InlineData("EventLog", 6008, "Red")] - [InlineData("Microsoft-Windows-Kernel-Power", 41, "Red")] - [InlineData("User32", 1074, "Orange")] - [InlineData("EventLog", 6006, "Yellow")] - [InlineData("EventLog", 6005, "Green")] - [InlineData("Microsoft-Windows-WindowsUpdateClient", 19, "Green")] - [InlineData("Microsoft-Windows-WindowsUpdateClient", 43, "Teal")] + [InlineData("EventLog", 6008, "LightRed")] + [InlineData("Microsoft-Windows-Kernel-Power", 41, "LightRed")] + [InlineData("User32", 1074, "LightOrange")] + [InlineData("EventLog", 6006, "LightYellow")] + [InlineData("EventLog", 6005, "LightGreen")] + [InlineData("Microsoft-Windows-WindowsUpdateClient", 19, "LightGreen")] + [InlineData("Microsoft-Windows-WindowsUpdateClient", 43, "LightTeal")] public void RebootCrashCorrelation_MatchesEachProviderIdWithItsColor(string source, int id, string expectedColor) { var filterSet = s_registry.BuildFilterSet(s_registry.Scenarios.Single(scenario => scenario.Id == "update-reboot-crash-correlation")); @@ -339,11 +415,11 @@ public void ServicingOutcomes_DoesNotMatchWusaSource() // Servicing events - including the failure event 3 - are logged at Level=Information, so the prior Level=Error // filter matched nothing. See files/real-log-validation-2026-07-17.md. [Theory] - [InlineData(3, "Red")] - [InlineData(4, "Yellow")] - [InlineData(13, "Yellow")] - [InlineData(2, "Green")] - [InlineData(9, "Green")] + [InlineData(3, "LightRed")] + [InlineData(4, "LightYellow")] + [InlineData(13, "LightYellow")] + [InlineData(2, "LightGreen")] + [InlineData(9, "LightGreen")] public void ServicingOutcomes_MatchesServicingEventByIdAtInformationLevel(int id, string expectedColor) { var filterSet = s_registry.BuildFilterSet(s_registry.Scenarios.Single(scenario => scenario.Id == "windows-setup-servicing-errors")); @@ -389,9 +465,9 @@ public void WindowsUpdateDiagnostics_IsSystemOnly() // WUClient failures log at Level=Error and warnings at Level=Warning (manifest), so those buckets are level-keyed; // the success event 19 is Level=Information and so is keyed by id. [Theory] - [InlineData(19, "Information", "Green")] - [InlineData(20, "Error", "Red")] - [InlineData(16, "Warning", "Yellow")] + [InlineData(19, "Information", "LightGreen")] + [InlineData(20, "Error", "LightRed")] + [InlineData(16, "Warning", "LightYellow")] public void WindowsUpdateDiagnostics_KeysSuccessByIdFailuresAndWarningsByLevel(int id, string level, string expectedColor) { var filterSet = s_registry.BuildFilterSet(s_registry.Scenarios.Single(scenario => scenario.Id == "windows-update-diagnostics")); diff --git a/tests/Unit/EventLogExpert.Scenarios.Tests/ScenarioCatalogLoaderTests.cs b/tests/Unit/EventLogExpert.Scenarios.Tests/ScenarioCatalogLoaderTests.cs index 7225000d..b3096031 100644 --- a/tests/Unit/EventLogExpert.Scenarios.Tests/ScenarioCatalogLoaderTests.cs +++ b/tests/Unit/EventLogExpert.Scenarios.Tests/ScenarioCatalogLoaderTests.cs @@ -1,6 +1,7 @@ // // Copyright (c) Microsoft Corporation. // // Licensed under the MIT License. +using EventLogExpert.Scenarios.Catalog; using EventLogExpert.Scenarios.Serialization; using System.Text; @@ -393,6 +394,24 @@ public void Load_SourceRegistrationGating_IsRejected() Assert.Contains(result.Errors, error => error.Contains("SourceRegistration")); } + [Theory] + [InlineData(null, null)] + [InlineData("EventId", ScenarioTimelineDimension.EventId)] + [InlineData("NotARealDimension", null)] + public void Load_TimelineDimension_WhenInvalid_IsIgnored(string? dimension, ScenarioTimelineDimension? expected) + { + string dimensionJson = dimension is null ? string.Empty : $", \"timelineDimension\": \"{dimension}\""; + var result = Load(Wrap($$""" + { "id": "x", "name": "X", "purpose": "p", "group": "SystemHealth", + "channels": [ "System" ], "activatesTimeline": true{{dimensionJson}}, + "filters": [ { "comparison": { "property": "Id", "value": "1000" } } ] } + """)); + + var scenario = Assert.Single(result.Scenarios); + Assert.Empty(result.Errors); + Assert.Equal(expected, scenario.TimelineDimension); + } + [Fact] public void Load_UnknownMembers_AreIgnored() { diff --git a/tests/Unit/EventLogExpert.UI.Tests/LogTable/Histogram/HistogramPaneTests.cs b/tests/Unit/EventLogExpert.UI.Tests/LogTable/Histogram/HistogramPaneTests.cs new file mode 100644 index 00000000..ac4fabd8 --- /dev/null +++ b/tests/Unit/EventLogExpert.UI.Tests/LogTable/Histogram/HistogramPaneTests.cs @@ -0,0 +1,354 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Bunit; +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Filtering.Persistence; +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.EventLog; +using EventLogExpert.Runtime.FilterLenses; +using EventLogExpert.Runtime.FilterPane; +using EventLogExpert.Runtime.Histogram; +using EventLogExpert.Runtime.LogTable; +using EventLogExpert.Runtime.Settings; +using EventLogExpert.UI.Inputs; +using EventLogExpert.UI.LogTable.Find; +using EventLogExpert.UI.LogTable.Histogram; +using Fluxor; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using System.Collections.Immutable; +using System.Reflection; + +namespace EventLogExpert.UI.Tests.LogTable.Histogram; + +public sealed class HistogramPaneTests : BunitContext +{ + private readonly IStateSelection _activeEventLogId = + Substitute.For>(); + + private readonly IStateSelection _activeOriginLog = + Substitute.For>(); + + private readonly IStateSelection _activeView = + Substitute.For>(); + + private readonly IStateSelection _dimensionRequest = + Substitute.For>(); + + private readonly IFilterLensCommands _filterLensCommands = Substitute.For(); + private readonly IStateSelection> _filters = + Substitute.For>>(); + private readonly IFindMarkerSource _findMarkers = new FindMarkerSource(); + private readonly IStateSelection _focus = + Substitute.For>(); + private readonly IHighlightSelector _highlightSelector = Substitute.For(); + + private readonly ISettingsService _settings = Substitute.For(); + private readonly ITraceLogger _traceLogger = Substitute.For(); + + private HistogramDimensionRequest? _dimensionRequestValue; + + public HistogramPaneTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + JSInterop.SetupModule("./_content/EventLogExpert.UI/Inputs/ValueSelect.razor.js"); + JSInterop.SetupModule("./_content/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.js"); + + _activeEventLogId.Value.Returns((EventLogId?)null); + _activeOriginLog.Value.Returns((string?)null); + _activeView.Value.Returns(LogTableState.EmptyView); + _dimensionRequest.Value.Returns(_ => _dimensionRequestValue); + _filters.Value.Returns(ImmutableList.Empty); + _focus.Value.Returns((SelectionEntry?)null); + _highlightSelector.Select(Arg.Any>()).Returns([]); + _highlightSelector.ComputePredicatePlanKey(Arg.Any>()).Returns(0); + _settings.TimeZoneInfo.Returns(TimeZoneInfo.Utc); + + Services.AddSingleton(_activeEventLogId); + Services.AddSingleton(_activeOriginLog); + Services.AddSingleton(_activeView); + Services.AddSingleton(_dimensionRequest); + Services.AddSingleton(_filterLensCommands); + Services.AddSingleton(_filters); + Services.AddSingleton(_findMarkers); + Services.AddSingleton(_focus); + Services.AddSingleton(_highlightSelector); + Services.AddSingleton(_settings); + Services.AddSingleton(_traceLogger); + Services.AddFluxor(options => options.ScanAssemblies(typeof(HistogramPane).Assembly)); + } + + public static TheoryData GroupHighlightCases() + { + var lightRed = Filter(HighlightColor.LightRed); + var anotherLightRed = Filter(HighlightColor.LightRed); + var lightBlue = Filter(HighlightColor.LightBlue); + var none = Filter(HighlightColor.None); + + return new TheoryData + { + { (1u << 1) | (1u << 2), [lightRed, anotherLightRed], "lightred", "Light red highlight" }, + { (1u << 1) | (1u << 2), [lightRed, lightBlue], null, "Mixed highlights" }, + { 1u | (1u << 1), [lightRed], null, "Mixed highlights" }, + { (1u << 1) | (1u << 2), [none, lightRed], null, "Mixed highlights" }, + { 0u, [lightRed], null, "Uncolored" }, + { 1u, [lightRed], null, "Uncolored" }, + { 1u << 1, [none], null, "Uncolored" }, + { 1u << 3, [lightRed], null, "Mixed highlights" } + }; + } + + [Fact] + public async Task FilterRefresh_WhenOnlyHighlightColorChanges_DoesNotReadActiveViewForScan() + { + SavedFilter red = Filter(HighlightColor.LightRed); + SavedFilter blue = red with { Color = HighlightColor.LightBlue }; + _highlightSelector.Select(Arg.Any>()).Returns([red], [blue]); + _highlightSelector.ComputePredicatePlanKey(Arg.Any>()).Returns(7); + var cut = Render(); + await cut.InvokeAsync(() => cut.Instance.OnHistogramResized(500, 100)); + + // The resize starts a background scan that reads ActiveView.Value twice: once synchronously at StartScan (before + // this await returns) and once in its post-scan continuation (the "did the view change mid-scan?" check). Wait for + // that second read so the continuation's read is not miscounted against the colour-only change under test (it + // otherwise races ClearReceivedCalls under load). The markup never reads ActiveView.Value, so the count is exact. + cut.WaitForAssertion(() => _ = _activeView.Received(2).Value); + _activeView.ClearReceivedCalls(); + + _filters.SelectedValueChanged += + Raise.Event>>(_filters, ImmutableList.Create(blue)); + await cut.InvokeAsync(() => { }); + + _ = _activeView.DidNotReceive().Value; + } + + [Fact] + public async Task FilterRefresh_WhenPredicatePlanChanges_ReadsActiveViewForScan() + { + SavedFilter red = Filter(HighlightColor.LightRed); + SavedFilter blue = Filter(HighlightColor.LightBlue); + _highlightSelector.Select(Arg.Any>()).Returns([red], [blue]); + _highlightSelector.ComputePredicatePlanKey(Arg.Any>()).Returns(7, 8); + var cut = Render(); + await cut.InvokeAsync(() => cut.Instance.OnHistogramResized(500, 100)); + _activeView.ClearReceivedCalls(); + + _filters.SelectedValueChanged += + Raise.Event>>(_filters, ImmutableList.Create(blue)); + + _ = _activeView.Received().Value; + } + + [Fact] + public async Task FilterRefresh_WhenPredicatePlanChangesButTieStaysUnarmed_DoesNotReadActiveViewForScan() + { + // Both filter sets are eligible but uncoloured (HighlightColor.None), so highlight-tie is never armed + // and the histogram output is independent of the predicates. A plan-key change (7 -> 8) while tie stays + // unarmed must update UI state only - it must not trigger a background rescan (which would read ActiveView). + SavedFilter firstUncoloured = Filter(HighlightColor.None); + SavedFilter secondUncoloured = Filter(HighlightColor.None); + _highlightSelector.Select(Arg.Any>()).Returns([firstUncoloured], [secondUncoloured]); + _highlightSelector.ComputePredicatePlanKey(Arg.Any>()).Returns(7, 8); + var cut = Render(); + await cut.InvokeAsync(() => cut.Instance.OnHistogramResized(500, 100)); + + // Wait for the resize scan's two ActiveView.Value reads (sync StartScan + post-scan continuation) to + // settle before clearing, so a late continuation read is not miscounted against the change under test. + cut.WaitForAssertion(() => _ = _activeView.Received(2).Value); + _activeView.ClearReceivedCalls(); + + _filters.SelectedValueChanged += + Raise.Event>>(_filters, ImmutableList.Create(secondUncoloured)); + await cut.InvokeAsync(() => { }); + + _ = _activeView.DidNotReceive().Value; + } + + [Fact] + public void Render_WhenDimensionRequestExists_AppliesRequestedDimensionOnMount() + { + _dimensionRequestValue = new HistogramDimensionRequest(HistogramDimension.Log, 1); + + var cut = Render(); + + Assert.Equal("Log", cut.Find(".histogram-dimension-select").GetAttribute("value")); + Assert.Equal(HistogramDimension.Log, GetDimension(cut)); + } + + [Fact] + public void Render_WhenLaterDimensionRequestHasHigherToken_AppliesRequestedDimension() + { + _dimensionRequestValue = new HistogramDimensionRequest(HistogramDimension.Log, 1); + var cut = Render(); + + _dimensionRequestValue = new HistogramDimensionRequest(HistogramDimension.EventId, 2); + _dimensionRequest.SelectedValueChanged += + Raise.Event>(_dimensionRequest, _dimensionRequestValue); + + cut.WaitForAssertion(() => Assert.Equal(HistogramDimension.EventId, GetDimension(cut))); + } + + [Fact] + public async Task Render_WhenLegendHasLongLabel_WrapsVisibleValueInTitledSpan() + { + const string longLabel = "Microsoft-Windows-Servicing-CbsPackageChangeState"; + IReadOnlyList groups = HistogramGroups.ForCategories([longLabel], [longLabel], otherLabel: null); + var data = new HistogramData( + [1, 0], + 2, + 1, + new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2024, 1, 1, 1, 0, 0, DateTimeKind.Utc), + 1, + TimeSpan.FromHours(1).Ticks, + groups); + var cut = Render(); + + await PublishBaseDataAsync(cut, data); + + var label = cut.Find(".histogram-legend-label"); + Assert.Equal(longLabel, label.TextContent); + Assert.Equal(longLabel, label.GetAttribute("title")); + Assert.Contains($"Hide {longLabel}", cut.Find(".histogram-legend-item").GetAttribute("aria-label")); + } + + [Fact] + public async Task Render_WhenStaleDimensionRequestArrivesAfterManualChange_DoesNotOverrideManualDimension() + { + _dimensionRequestValue = new HistogramDimensionRequest(HistogramDimension.EventId, 3); + var cut = Render(); + await SelectDimensionAsync(cut, HistogramDimension.Source); + + _dimensionRequest.SelectedValueChanged += + Raise.Event>(_dimensionRequest, _dimensionRequestValue); + + cut.WaitForAssertion(() => Assert.Equal(HistogramDimension.Source, GetDimension(cut))); + } + + [Fact] + public async Task Render_WhenTieModeIncludesCategoricalOther_RendersOtherAsRecessiveGray() + { + IReadOnlyList groups = HistogramGroups.ForCategories(["alpha"], ["Alpha"], "Other"); + var data = new HistogramData( + [1, 1], + 2, + 1, + new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2024, 1, 1, 1, 0, 0, DateTimeKind.Utc), + 2, + TimeSpan.FromHours(1).Ticks, + groups, + [1u << 1, 1u << 1]); + var cut = Render(); + + await PublishBaseDataAsync(cut, data); + + var otherButton = cut.FindAll(".histogram-legend-item").Single(button => button.TextContent == "Other"); + var otherSwatch = otherButton.QuerySelector("rect"); + Assert.Equal("Hide Other", otherButton.GetAttribute("aria-label")); + Assert.Equal("histogram-cat-other", otherSwatch?.GetAttribute("class")); + Assert.Null(otherSwatch?.GetAttribute("data-highlight")); + } + + [Theory] + [MemberData(nameof(GroupHighlightCases))] + public void ResolveGroupHighlight_MapsMasksToCssAndText( + uint mask, + SavedFilter[] filters, + string? expectedCssName, + string expectedDescription) + { + (string? cssName, string description) = HistogramPane.ResolveGroupHighlight(mask, filters); + + Assert.Equal(expectedCssName, cssName); + Assert.Equal(expectedDescription, description); + } + + [Fact] + public void ShouldArmTie_WhenMoreThanThirtyOneFilters_ReturnsFalse() + { + SavedFilter[] filters = Enumerable.Range(0, 32) + .Select(_ => Filter(HighlightColor.LightRed)) + .ToArray(); + var method = typeof(HistogramPane).GetMethod( + "ShouldArmTie", + BindingFlags.Static | BindingFlags.NonPublic); + + Assert.NotNull(method); + bool shouldArmTie = Assert.IsType(method.Invoke(null, [filters])); + + Assert.False(shouldArmTie); + } + + [Fact] + public void SourceFiles_DefineExtendedHistogramPaletteAndForcedColorHatches() + { + string razor = File.ReadAllText(ResolveRepoPath("src", "EventLogExpert.UI", "LogTable", "Histogram", "HistogramPane.razor")); + string css = File.ReadAllText(ResolveRepoPath("src", "EventLogExpert.UI", "LogTable", "Histogram", "HistogramPane.razor.css")); + + for (int index = 4; index <= 7; index++) + { + Assert.Contains($".histogram-cat-{index}", css); + } + + for (int index = 0; index <= 12; index++) + { + Assert.Contains($"id=\"histogram-hatch-cat-{index}\"", razor); + Assert.Contains($"data-stackpos=\"{index}\"", css); + } + } + + private static SavedFilter Filter(HighlightColor color) => + new() { Color = color, IsEnabled = true, ComparisonText = "Id == 1", Compiled = null! }; + + private static HistogramDimension GetDimension(IRenderedComponent cut) + { + var field = typeof(HistogramPane).GetField( + "_dimension", + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(field); + return Assert.IsType(field.GetValue(cut.Instance)); + } + + private static Task PublishBaseDataAsync(IRenderedComponent cut, HistogramData data) + { + var field = typeof(HistogramPane).GetField( + "_baseData", + BindingFlags.Instance | BindingFlags.NonPublic); + var stateHasChanged = typeof(ComponentBase).GetMethod( + "StateHasChanged", + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(field); + Assert.NotNull(stateHasChanged); + field.SetValue(cut.Instance, data); + return cut.InvokeAsync(() => stateHasChanged.Invoke(cut.Instance, null)); + } + + private static string ResolveRepoPath(params string[] segments) + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "EventLogExpert.slnx"))) + { + directory = directory.Parent; + } + + Assert.NotNull(directory); + + string path = Path.Combine([directory.FullName, .. segments]); + Assert.True(File.Exists(path), $"Expected source file at {path} to exist."); + + return path; + } + + private static Task SelectDimensionAsync(IRenderedComponent cut, HistogramDimension dimension) + { + var select = cut.FindComponent>(); + + return cut.InvokeAsync(() => select.Instance.UpdateValue(dimension)); + } +}