diff --git a/src/EventLogExpert.Eventing/Common/Events/EventColumnPool.cs b/src/EventLogExpert.Eventing/Common/Events/EventColumnPool.cs index a64e689fb..65b9b5837 100644 --- a/src/EventLogExpert.Eventing/Common/Events/EventColumnPool.cs +++ b/src/EventLogExpert.Eventing/Common/Events/EventColumnPool.cs @@ -50,6 +50,9 @@ private EventColumnPool(ImmutableList segments, ImmutableDictionaryThe stable index interned for , or false when it was never interned. + internal bool TryGetIndex(string value, out int index) => _map.TryGetValue(value, out index); + private static int FindSegment(int[] prefix, int index) { int low = 0; diff --git a/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs b/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs index 2ceaeb3fc..94372c72f 100644 --- a/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs +++ b/src/EventLogExpert.Eventing/Common/Events/EventColumnStore.cs @@ -227,6 +227,154 @@ public bool TryGetTimeRange(out long minTicks, out long maxTicks) return Count > 0; } + internal void BucketTimeTicksByEventId( + ReadOnlySpan rankByPhysical, + 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++) + { + if (rankByPhysical[offset + row] < 0) { continue; } + + int bucket = ToBucket(timeColumn[row], minTicks, bucketSpanTicks, bucketCount); + slotCounts[(bucket * slotCount) + SlotForIndex(idColumn[row], targetIds, otherSlot)]++; + } + + 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); + slotCounts[(bucket * slotCount) + SlotForIndex(pending.Id, targetIds, otherSlot)]++; + } + } + + internal void BucketTimeTicksByField( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + EventColumnField field, + string[] targetValues, + int slotCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + int otherSlot = targetValues.Length; + + // Resolve each target to THIS store's pool index once (miss -> int.MinValue, an impossible index) so sealed rows classify by integer compare; a null-field row (-1) can't collide with an absent target. + 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++) + { + if (rankByPhysical[offset + row] < 0) { continue; } + + int bucket = ToBucket(timeColumn[row], minTicks, bucketSpanTicks, bucketCount); + slotCounts[(bucket * slotCount) + SlotForIndex(valueColumn[row], targetIndices, otherSlot)]++; + } + + 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); + slotCounts[(bucket * slotCount) + slot]++; + } + } + + internal void BucketTimeTicksBySeverity( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + // Resolve the five level names to their pool indices once (missing -> int.MinValue) so sealed rows map level by integer compare; a null-level row (-1) can't collide with an absent name. + 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++) + { + if (rankByPhysical[offset + row] < 0) { continue; } + + int bucket = ToBucket(timeColumn[row], minTicks, bucketSpanTicks, bucketCount); + slotCounts[(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); + slotCounts[(bucket * SlotCount) + LevelSeverity.Slot(LevelSeverity.FromLevelName(pending.Level))]++; + } + + 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 @@ -398,6 +546,81 @@ internal void CopyTimeTicks(long[] values, bool[] hasValue) } } + internal void CountEventIds(ReadOnlySpan rankByPhysical, IDictionary counts, CancellationToken cancellationToken) + { + int offset = 0; + + foreach (EventColumnChunk chunk in _sealedChunks) + { + cancellationToken.ThrowIfCancellationRequested(); + + ReadOnlySpan idColumn = chunk.IdColumn; + + for (int row = 0; row < idColumn.Length; row++) + { + if (rankByPhysical[offset + row] < 0) { continue; } + + int id = idColumn[row]; + counts[id] = counts.TryGetValue(id, out int existing) ? existing + 1 : 1; + } + + offset += chunk.RowCount; + } + + for (int index = _sealedCount; index < Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + int id = Pending(index).Id; + counts[id] = counts.TryGetValue(id, out int existing) ? existing + 1 : 1; + } + } + + internal void CountFieldValues(ReadOnlySpan rankByPhysical, EventColumnField field, IDictionary counts, CancellationToken cancellationToken) + { + // Tally sealed rows by pooled INDEX (int-keyed, no per-row string lookup), then reverse-resolve each distinct index once and merge, so the hot path stays integer-only but still sums by logical value across differently-pooled stores. + var byIndex = new Dictionary(); + int offset = 0; + + foreach (EventColumnChunk chunk in _sealedChunks) + { + cancellationToken.ThrowIfCancellationRequested(); + + ReadOnlySpan valueColumn = chunk.PoolIndexColumn(field); + + for (int row = 0; row < valueColumn.Length; row++) + { + if (rankByPhysical[offset + row] < 0) { continue; } + + int poolIndex = valueColumn[row]; + + if (poolIndex < 0) { continue; } + + byIndex[poolIndex] = byIndex.TryGetValue(poolIndex, out int existing) ? existing + 1 : 1; + } + + offset += chunk.RowCount; + } + + foreach ((int poolIndex, int count) in byIndex) + { + string? value = PoolGet(poolIndex); + + if (!string.IsNullOrEmpty(value)) + { + counts[value] = counts.TryGetValue(value, out int existing) ? existing + count : count; + } + } + + // Pending rows carry string fields (no pool index), so tally them by value directly. + for (int index = _sealedCount; index < Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + Tally(counts, PendingFieldValue(Pending(index), field)); + } + } + /// /// Reconstructs a byte-faithful for the row at , /// reproducing every field's observable value. A pending row is returned as-is (perfect fidelity, zero work); a sealed @@ -698,6 +921,52 @@ internal string[] ReconstructStringArray(ReadOnlySpan valueIndices) /// The field-name pool indices backing the deduped schema at . internal ReadOnlySpan SchemaFieldNameIndices(int schemaId) => _schemas[schemaId]; + internal bool TryGetTimeTicksRange( + ReadOnlySpan rankByPhysical, + out long minTicks, + out long maxTicks, + CancellationToken cancellationToken) + { + long min = long.MaxValue; + long max = long.MinValue; + bool any = false; + int offset = 0; + + foreach (EventColumnChunk chunk in _sealedChunks) + { + cancellationToken.ThrowIfCancellationRequested(); + + ReadOnlySpan column = chunk.TimeTicksColumn; + + for (int row = 0; row < column.Length; row++) + { + if (rankByPhysical[offset + row] < 0) { continue; } + + long ticks = column[row]; + if (ticks < min) { min = ticks; } + if (ticks > max) { max = ticks; } + any = true; + } + + offset += chunk.RowCount; + } + + for (int index = _sealedCount; index < Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + long ticks = Pending(index).TimeCreated.Ticks; + if (ticks < min) { min = ticks; } + if (ticks > max) { max = ticks; } + any = true; + } + + minTicks = any ? min : 0; + maxTicks = any ? max : 0; + + return any; + } + /// /// Builds a fresh snapshot for a reload: bumps and continues (never resets) /// . @@ -772,6 +1041,52 @@ private static int FindChunk(int[] prefix, int index) _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, "Not a packed StoredFieldKind.") }; + private static string? PendingFieldValue(ResolvedEvent pending, EventColumnField field) => field switch + { + EventColumnField.Source => pending.Source, + EventColumnField.TaskCategory => pending.TaskCategory, + EventColumnField.Opcode => pending.Opcode, + EventColumnField.LogName => pending.LogName, + EventColumnField.ComputerName => pending.ComputerName, + EventColumnField.OwningLog => pending.OwningLog, + _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Field is not a supported group-by dimension.") + }; + + // Shared by the pooled-field and event-id group-by scans: a pure integer match, so a negative event id still matches its own target; callers resolve absent pooled targets to int.MinValue (not -1) so a null-field row can't collide. + private static int SlotForIndex(int value, int[] targets, int otherSlot) + { + for (int slot = 0; slot < targets.Length; slot++) + { + if (value == targets[slot]) { return slot; } + } + + return otherSlot; + } + + private static int SlotForString(string? value, string[] targets, int otherSlot) + { + for (int slot = 0; slot < targets.Length; slot++) + { + if (string.Equals(value, targets[slot], StringComparison.Ordinal)) { return slot; } + } + + return otherSlot; + } + + private static void Tally(IDictionary counts, string? value) + { + if (string.IsNullOrEmpty(value)) { return; } + + counts[value] = counts.TryGetValue(value, out int existing) ? existing + 1 : 1; + } + + private static int ToBucket(long ticks, long minTicks, long bucketSpanTicks, int bucketCount) + { + long bucket = (ticks - minTicks) / bucketSpanTicks; + + return bucket < 0 ? 0 : bucket >= bucketCount ? bucketCount - 1 : (int)bucket; + } + private (EventColumnChunk Chunk, int Row) Locate(int index) { int[] prefix = SealedPrefix(); diff --git a/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs b/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs index 953b4da38..c2a2bceb0 100644 --- a/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs +++ b/src/EventLogExpert.Eventing/Common/Events/EventColumnStoreReader.cs @@ -36,6 +36,66 @@ internal EventColumnStoreReader(EventLogId logId, EventColumnStore store) public IReadOnlyList Pool => new PoolView(_store, PendingPool()); + public void BucketTimeTicksByEventId( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] targetIds, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(targetIds); + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketSpanTicks, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketCount, 1); + + int slotCount = targetIds.Length + 1; + ArgumentOutOfRangeException.ThrowIfLessThan(slotCounts.Length, bucketCount * slotCount); + + _store.BucketTimeTicksByEventId(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, targetIds, slotCount, slotCounts, cancellationToken); + } + + public void BucketTimeTicksByField( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + EventFieldId field, + string[] targetValues, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(targetValues); + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketSpanTicks, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketCount, 1); + + int slotCount = targetValues.Length + 1; + ArgumentOutOfRangeException.ThrowIfLessThan(slotCounts.Length, bucketCount * slotCount); + + _store.BucketTimeTicksByField(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, ToColumnField(field), targetValues, slotCount, slotCounts, cancellationToken); + } + + public void BucketTimeTicksBySeverity( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketSpanTicks, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(bucketCount, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(slotCounts.Length, bucketCount * LevelSeverity.SlotCount); + + _store.BucketTimeTicksBySeverity(rankByPhysical, minTicks, bucketSpanTicks, bucketCount, slotCounts, cancellationToken); + } + public void CopyGuidColumn(EventFieldId field, Guid[] values, bool[] hasValue) { ArgumentNullException.ThrowIfNull(values); @@ -99,6 +159,22 @@ public void CopyPoolIndexColumn(EventFieldId field, int[] poolIndices) } } + public void CountEventIds(ReadOnlySpan rankByPhysical, IDictionary counts, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(counts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + _store.CountEventIds(rankByPhysical, counts, cancellationToken); + } + + public void CountFieldValues(ReadOnlySpan rankByPhysical, EventFieldId field, IDictionary counts, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(counts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + _store.CountFieldValues(rankByPhysical, ToColumnField(field), counts, cancellationToken); + } + public EventDataFieldEnumerator EnumerateEventData(EventLocator locator) { int index = Resolve(locator); @@ -208,6 +284,8 @@ public IReadOnlyList GetKeywords(EventLocator locator) : _store.ReconstructStringArray(_store.RawKeywords(index)); } + public long GetTimeTicks(EventLocator locator) => _store.RawTimeTicks(Resolve(locator)); + public StructuredFieldResult GetUserData(EventLocator locator, string storageKey) { int index = Resolve(locator); @@ -280,6 +358,17 @@ public bool TryGetEventData(EventLocator locator, string fieldName, out EventFie return false; } + public bool TryGetTimeTicksRange( + ReadOnlySpan rankByPhysical, + out long minTicks, + out long maxTicks, + CancellationToken cancellationToken) + { + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + return _store.TryGetTimeTicksRange(rankByPhysical, out minTicks, out maxTicks, cancellationToken); + } + private static string? PendingPoolString(ResolvedEvent pending, EventColumnField column) => column switch { EventColumnField.OwningLog => pending.OwningLog, diff --git a/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs b/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs index 268360835..6309d23b1 100644 --- a/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs +++ b/src/EventLogExpert.Eventing/Common/Events/IEventColumnReader.cs @@ -22,6 +22,42 @@ public interface IEventColumnReader /// IReadOnlyList Pool { get; } + /// Group-by variant keyed on the numeric event id; ( length + 1) slots per bin. + void BucketTimeTicksByEventId( + ReadOnlySpan rankByPhysical, + 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. + /// + void BucketTimeTicksByField( + ReadOnlySpan rankByPhysical, + 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. + /// + void BucketTimeTicksBySeverity( + ReadOnlySpan rankByPhysical, + 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. @@ -42,6 +78,18 @@ public interface IEventColumnReader /// void CopyPoolIndexColumn(EventFieldId field, int[] poolIndices); + /// + /// Tallies each surviving row by its numeric event id into (accumulating across a + /// combined view). + /// + void CountEventIds(ReadOnlySpan rankByPhysical, IDictionary counts, CancellationToken cancellationToken); + + /// + /// Tallies survivors by non-empty pooled string value of field (accumulating, so a combined view sums by logical + /// value across pools); resolves the top-N group-by categories. + /// + void CountFieldValues(ReadOnlySpan rankByPhysical, EventFieldId field, IDictionary counts, CancellationToken cancellationToken); + EventDataFieldEnumerator EnumerateEventData(EventLocator locator); UserDataFieldEnumerator EnumerateUserData(EventLocator locator); @@ -67,6 +115,9 @@ public interface IEventColumnReader IReadOnlyList GetKeywords(EventLocator locator); + /// The UTC-tick timestamp of read straight from the tick column, with no rehydrate. + long GetTimeTicks(EventLocator locator); + StructuredFieldResult GetUserData(EventLocator locator, string storageKey); bool GetUserDataIncomplete(EventLocator locator); @@ -74,4 +125,11 @@ public interface IEventColumnReader EventLocator LocatorAt(int index); bool TryGetEventData(EventLocator locator, string fieldName, out EventFieldValue value); + + /// UTC-tick span across survivors (rank >= 0): true with [minTicks, maxTicks], false when none survive. + bool TryGetTimeTicksRange( + ReadOnlySpan rankByPhysical, + out long minTicks, + out long maxTicks, + CancellationToken cancellationToken); } diff --git a/src/EventLogExpert.Eventing/Common/Events/LevelSeverity.cs b/src/EventLogExpert.Eventing/Common/Events/LevelSeverity.cs index 0f7e610f6..a98c11621 100644 --- a/src/EventLogExpert.Eventing/Common/Events/LevelSeverity.cs +++ b/src/EventLogExpert.Eventing/Common/Events/LevelSeverity.cs @@ -5,6 +5,12 @@ namespace EventLogExpert.Eventing.Common.Events; public static class LevelSeverity { + /// + /// Dense severity slots: slot 0 for an unrecognized/absent level plus the five + /// values (1-5). + /// + public const int SlotCount = 6; + public static SeverityLevel? FromLevelName(string? level) => level switch { nameof(SeverityLevel.Critical) => SeverityLevel.Critical, @@ -14,4 +20,7 @@ public static class LevelSeverity nameof(SeverityLevel.Verbose) => SeverityLevel.Verbose, _ => null }; + + /// The dense histogram slot for : 0 when absent, else the level's 1-5 value. + public static int Slot(SeverityLevel? severity) => severity is { } value ? (int)value : 0; } diff --git a/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs b/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs index efad797ad..1a6c87b90 100644 --- a/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs +++ b/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs @@ -22,6 +22,7 @@ using EventLogExpert.Runtime.FilterLenses; using EventLogExpert.Runtime.FilterLibrary; using EventLogExpert.Runtime.FilterPane; +using EventLogExpert.Runtime.Histogram; using EventLogExpert.Runtime.LogTable; using EventLogExpert.Runtime.Menu; using EventLogExpert.Runtime.Modal; @@ -167,6 +168,7 @@ public IServiceCollection AddEventLogRuntime() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/EventLogExpert.Runtime/FilterLenses/FilterLensCommands.cs b/src/EventLogExpert.Runtime/FilterLenses/FilterLensCommands.cs index 8f6c5e95a..0e0568ba2 100644 --- a/src/EventLogExpert.Runtime/FilterLenses/FilterLensCommands.cs +++ b/src/EventLogExpert.Runtime/FilterLenses/FilterLensCommands.cs @@ -34,6 +34,9 @@ public void ShowRelatedByRelatedActivityId(Guid? relatedActivityId, string? orig if (relatedActivityId is { } id) { PushLens(FilterLensFactory.ForRelatedActivityId(id, originLog)); } } + public void ShowTimeRange(DateTime startUtc, DateTime endUtc, TimeZoneInfo displayZone, string? originLog = null) => + PushLens(FilterLensFactory.ForTimeRange(startUtc, endUtc, displayZone, originLog)); + private void PushLens(FilterLens? lens) { if (lens != null) { _dispatcher.Dispatch(new PushFilterLensAction(lens)); } diff --git a/src/EventLogExpert.Runtime/FilterLenses/FilterLensFactory.cs b/src/EventLogExpert.Runtime/FilterLenses/FilterLensFactory.cs index 86875567d..94568a897 100644 --- a/src/EventLogExpert.Runtime/FilterLenses/FilterLensFactory.cs +++ b/src/EventLogExpert.Runtime/FilterLenses/FilterLensFactory.cs @@ -30,6 +30,32 @@ internal static class FilterLensFactory $"Related Activity ID = {relatedActivityId}", originLog); + /// + /// Builds a lens keeping rows whose TimeCreated is in the inclusive UTC range [startUtc, endUtc]; endpoints are + /// normalized so a right-to-left brush stays a valid non-empty window. + /// + public static FilterLens ForTimeRange( + DateTime startUtc, + DateTime endUtc, + TimeZoneInfo displayZone, + string? originLog = null) + { + var (after, before) = startUtc <= endUtc ? (startUtc, endUtc) : (endUtc, startUtc); + var afterLocal = after.ConvertTimeZone(displayZone); + var beforeLocal = before.ConvertTimeZone(displayZone); + + return new FilterLens + { + // Include each endpoint's date when the window spans more than one displayed day, so a midnight-straddling or multi-day range isn't a bare, ambiguous "23:55:00 - 00:05:00". + Label = afterLocal.Date == beforeLocal.Date + ? $"{afterLocal:T} - {beforeLocal:T}" + : $"{afterLocal:d} {afterLocal:T} - {beforeLocal:d} {beforeLocal:T}", + Kind = LensKind.TimeWindow, + Window = new DateFilter { After = after, Before = before, IsEnabled = true }, + OriginLog = originLog + }; + } + /// /// Builds a transient time-window lens centered on (the source event's UTC /// timestamp): the effective view is narrowed to the inclusive range [timeCreatedUtc - radius, timeCreatedUtc + diff --git a/src/EventLogExpert.Runtime/FilterLenses/IFilterLensCommands.cs b/src/EventLogExpert.Runtime/FilterLenses/IFilterLensCommands.cs index 376d2a919..bc3517d90 100644 --- a/src/EventLogExpert.Runtime/FilterLenses/IFilterLensCommands.cs +++ b/src/EventLogExpert.Runtime/FilterLenses/IFilterLensCommands.cs @@ -41,4 +41,10 @@ public interface IFilterLensCommands /// so the lens auto-clears when that log is closed. /// void ShowRelatedByRelatedActivityId(Guid? relatedActivityId, string? originLog = null); + + /// + /// Pushes a reversible lens narrowing the view to the inclusive UTC range [startUtc, endUtc] (histogram brush); + /// originLog auto-clears the lens when that log closes (null for a combined view). + /// + void ShowTimeRange(DateTime startUtc, DateTime endUtc, TimeZoneInfo displayZone, string? originLog = null); } diff --git a/src/EventLogExpert.Runtime/Histogram/Effects.cs b/src/EventLogExpert.Runtime/Histogram/Effects.cs new file mode 100644 index 000000000..45184a2fc --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/Effects.cs @@ -0,0 +1,31 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Fluxor; + +namespace EventLogExpert.Runtime.Histogram; + +internal sealed class Effects(IHistogramPreferencesProvider preferences) +{ + private readonly IHistogramPreferencesProvider _preferences = preferences; + + [EffectMethod] + public Task HandleSetHistogramVisible(SetHistogramVisibleAction action, IDispatcher dispatcher) + { + // Skip the redundant write during startup hydration, which dispatches the persisted value straight back. + if (_preferences.HistogramVisiblePreference != action.IsVisible) + { + _preferences.HistogramVisiblePreference = action.IsVisible; + } + + return Task.CompletedTask; + } + + [EffectMethod] + public Task HandleStoreInitialized(StoreInitializedAction action, IDispatcher dispatcher) + { + dispatcher.Dispatch(new SetHistogramVisibleAction(_preferences.HistogramVisiblePreference)); + + return Task.CompletedTask; + } +} diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramAggregator.cs b/src/EventLogExpert.Runtime/Histogram/HistogramAggregator.cs new file mode 100644 index 000000000..660503ee3 --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/HistogramAggregator.cs @@ -0,0 +1,110 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Histogram; + +public static class HistogramAggregator +{ + private const int MinAnomalyBins = 8; + + public static HistogramRender Aggregate( + HistogramData data, + long windowStartTicks, + long windowEndTicks, + int targetBins) + { + ArgumentNullException.ThrowIfNull(data); + ArgumentOutOfRangeException.ThrowIfLessThan(targetBins, 1); + + long baseMin = data.MinUtc.Ticks; + long baseMax = data.MaxUtc.Ticks; + long span = data.BucketSpanTicks; + int slotCount = data.SlotCount; + var groups = data.Groups; + int groupCount = groups.Count; + + // A stale/zoomed window can outrun a shrunk live-tail domain, so clamp it back into the current domain first. + long startTicks = Math.Clamp(Math.Min(windowStartTicks, windowEndTicks), baseMin, baseMax); + long endTicks = Math.Clamp(Math.Max(windowStartTicks, windowEndTicks), baseMin, baseMax); + + int binLo = (int)Math.Clamp((startTicks - baseMin) / span, 0, data.BinCount - 1); + int binHi = (int)Math.Clamp((endTicks - baseMin) / span, 0, data.BinCount - 1); + int windowBinCount = binHi - binLo + 1; + + int renderBinCount = Math.Min(targetBins, windowBinCount); + var bins = new HistogramRenderBin[renderBinCount]; + var windowGroupTotals = new int[groupCount]; + int maxBinTotal = 0; + int windowTotal = 0; + + for (int rendered = 0; rendered < renderBinCount; rendered++) + { + int lo = binLo + (int)((long)rendered * windowBinCount / renderBinCount); + int hi = binLo + (int)((long)(rendered + 1) * windowBinCount / renderBinCount); + if (hi <= lo) { hi = lo + 1; } + + var groupCounts = new int[groupCount]; + int total = 0; + + for (int baseBin = lo; baseBin < hi; baseBin++) + { + int baseOffset = baseBin * slotCount; + + for (int group = 0; group < groupCount; group++) + { + int sum = 0; + + foreach (int slot in groups[group].SlotIndices) { sum += data.SlotCounts[baseOffset + slot]; } + + groupCounts[group] += sum; + total += sum; + } + } + + long binStartTicks = baseMin + (lo * span); + long binEndTicks = Math.Min((baseMin + (hi * span)) - 1, baseMax); + + bins[rendered] = new HistogramRenderBin(binStartTicks, binEndTicks, total, groupCounts); + + if (total > maxBinTotal) { maxBinTotal = total; } + + windowTotal += total; + + for (int group = 0; group < groupCount; group++) { windowGroupTotals[group] += groupCounts[group]; } + } + + // Report the whole-base-bin bounds as the window so the axis, announcement, and Scope describe exactly the counted span. + long effectiveStartTicks = baseMin + (binLo * span); + long effectiveEndTicks = Math.Min((baseMin + ((binHi + 1) * span)) - 1, baseMax); + + FlagAnomalies(bins); + + return new HistogramRender(bins, effectiveStartTicks, effectiveEndTicks, windowTotal, maxBinTotal, windowGroupTotals); + } + + private static void FlagAnomalies(HistogramRenderBin[] bins) + { + if (bins.Length < MinAnomalyBins) { return; } + + double sum = 0; + double sumOfSquares = 0; + + foreach (HistogramRenderBin bin in bins) + { + sum += bin.Total; + sumOfSquares += (double)bin.Total * bin.Total; + } + + double mean = sum / bins.Length; + double variance = (sumOfSquares / bins.Length) - (mean * mean); + + if (variance <= 0) { return; } + + double threshold = mean + (2 * Math.Sqrt(variance)); + + for (int index = 0; index < bins.Length; index++) + { + if (bins[index].Total > threshold) { bins[index] = bins[index] with { IsAnomaly = true }; } + } + } +} diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs b/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs new file mode 100644 index 000000000..21a639209 --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/HistogramBuilder.cs @@ -0,0 +1,125 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Runtime.LogTable; +using System.Globalization; + +namespace EventLogExpert.Runtime.Histogram; + +public static class HistogramBuilder +{ + public static HistogramData? Build( + IEventColumnView view, + HistogramDimension dimension, + int maxBuckets, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(view); + ArgumentOutOfRangeException.ThrowIfLessThan(maxBuckets, 1); + cancellationToken.ThrowIfCancellationRequested(); + + if (!view.TryGetTimeTicksRange(out long minTicks, out long maxTicks, cancellationToken)) { return null; } + + long spanTicks = maxTicks - minTicks + 1; + long bucketSpanTicks = Math.Max(1, (spanTicks + maxBuckets - 1) / maxBuckets); + int bucketCount = (int)Math.Min((spanTicks + bucketSpanTicks - 1) / bucketSpanTicks, maxBuckets); + + (int[] slotCounts, int slotCount, IReadOnlyList groups) = 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) + }; + + int total = 0; + + foreach (int count in slotCounts) { total += count; } + + return new HistogramData( + slotCounts, + slotCount, + bucketCount, + new DateTime(minTicks, DateTimeKind.Utc), + new DateTime(maxTicks, DateTimeKind.Utc), + total, + bucketSpanTicks, + groups); + } + + private static (int[] SlotCounts, int SlotCount, IReadOnlyList Groups) ScanByEventId( + IEventColumnView view, + long minTicks, + long bucketSpanTicks, + int bucketCount, + CancellationToken cancellationToken) + { + var counts = new Dictionary(); + view.CountEventIds(counts, cancellationToken); + + int[] targetIds = counts + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key) + .Take(HistogramConstants.MaxGroupByCategories) + .Select(pair => pair.Key) + .ToArray(); + + int slotCount = targetIds.Length + 1; + int[] slotCounts = new int[bucketCount * slotCount]; + view.BucketTimeTicksByEventId(minTicks, bucketSpanTicks, bucketCount, targetIds, slotCounts, cancellationToken); + + string[] keys = Array.ConvertAll(targetIds, id => id.ToString(CultureInfo.InvariantCulture)); + string[] labels = Array.ConvertAll(targetIds, id => id.ToString(CultureInfo.CurrentCulture)); + + return (slotCounts, slotCount, HistogramGroups.ForCategories(keys, labels)); + } + + private static (int[] SlotCounts, int SlotCount, IReadOnlyList Groups) ScanByField( + IEventColumnView view, + EventFieldId field, + Func, IReadOnlyList> labelMapper, + long minTicks, + long bucketSpanTicks, + int bucketCount, + CancellationToken cancellationToken) + { + var counts = new Dictionary(StringComparer.Ordinal); + view.CountFieldValues(field, counts, cancellationToken); + + string[] targetValues = counts + .OrderByDescending(pair => pair.Value) + .ThenBy(pair => pair.Key, StringComparer.Ordinal) + .Take(HistogramConstants.MaxGroupByCategories) + .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); + + return (slotCounts, slotCount, HistogramGroups.ForCategories(targetValues, labelMapper(targetValues))); + } + + private static (int[] SlotCounts, int SlotCount, IReadOnlyList Groups) ScanSeverity( + IEventColumnView view, + long minTicks, + long bucketSpanTicks, + int bucketCount, + CancellationToken cancellationToken) + { + int slotCount = HistogramGroups.SeveritySlotCount; + int[] slotCounts = new int[bucketCount * slotCount]; + view.BucketTimeTicksBySeverity(minTicks, bucketSpanTicks, bucketCount, slotCounts, cancellationToken); + + return (slotCounts, slotCount, HistogramGroups.Severity); + } + + private static EventFieldId ToFieldId(HistogramDimension dimension) => dimension switch + { + HistogramDimension.Source => EventFieldId.Source, + HistogramDimension.TaskCategory => EventFieldId.TaskCategory, + HistogramDimension.Opcode => EventFieldId.Opcode, + _ => throw new ArgumentOutOfRangeException(nameof(dimension), dimension, "Dimension is not a pooled string field.") + }; +} diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramCommands.cs b/src/EventLogExpert.Runtime/Histogram/HistogramCommands.cs new file mode 100644 index 000000000..496aa1536 --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/HistogramCommands.cs @@ -0,0 +1,13 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Fluxor; + +namespace EventLogExpert.Runtime.Histogram; + +internal sealed class HistogramCommands(IDispatcher dispatcher) : IHistogramCommands +{ + private readonly IDispatcher _dispatcher = dispatcher; + + public void SetVisible(bool visible) => _dispatcher.Dispatch(new SetHistogramVisibleAction(visible)); +} diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramConstants.cs b/src/EventLogExpert.Runtime/Histogram/HistogramConstants.cs new file mode 100644 index 000000000..bd6b2ea22 --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/HistogramConstants.cs @@ -0,0 +1,11 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Histogram; + +public static class HistogramConstants +{ + public const int MaxBuckets = 20000; + + public const int MaxGroupByCategories = 4; +} diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramData.cs b/src/EventLogExpert.Runtime/Histogram/HistogramData.cs new file mode 100644 index 000000000..eebb92ab5 --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/HistogramData.cs @@ -0,0 +1,15 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Histogram; + +// Bucket-major: SlotCounts[bin*SlotCount + slot]; Groups folds slots into the rendered stacks. +public sealed record HistogramData( + int[] SlotCounts, + int SlotCount, + int BinCount, + DateTime MinUtc, + DateTime MaxUtc, + int Total, + long BucketSpanTicks, + IReadOnlyList Groups); diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramDimension.cs b/src/EventLogExpert.Runtime/Histogram/HistogramDimension.cs new file mode 100644 index 000000000..5ab3d034d --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/HistogramDimension.cs @@ -0,0 +1,14 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Histogram; + +public enum HistogramDimension +{ + Severity, + Source, + EventId, + TaskCategory, + Opcode, + Log +} diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramGroups.cs b/src/EventLogExpert.Runtime/Histogram/HistogramGroups.cs new file mode 100644 index 000000000..0848f1b83 --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/HistogramGroups.cs @@ -0,0 +1,44 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; + +namespace EventLogExpert.Runtime.Histogram; + +public sealed record HistogramGroup(string Label, string ColorClass, string Key, int[] SlotIndices); + +public static class HistogramGroups +{ + public static IReadOnlyList Severity { get; } = + [ + new("Other", "histogram-bar-normal", "sev-other", [0, (int)SeverityLevel.Information, (int)SeverityLevel.Verbose]), + new("Warnings", "histogram-bar-warning", "sev-warning", [(int)SeverityLevel.Warning]), + new("Errors", "histogram-bar-error", "sev-error", [(int)SeverityLevel.Critical, (int)SeverityLevel.Error]) + ]; + + public static int SeveritySlotCount => LevelSeverity.SlotCount; + + public static IReadOnlyList ForCategories(IReadOnlyList labels) => + ForCategories(labels, labels); + + // 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) + { + ArgumentNullException.ThrowIfNull(keys); + ArgumentNullException.ThrowIfNull(labels); + ArgumentOutOfRangeException.ThrowIfNotEqual(labels.Count, keys.Count); + + int otherSlot = keys.Count; + var groups = new List(keys.Count + 1) + { + new("Other", "histogram-cat-other", "cat-other", [otherSlot]) + }; + + for (int index = 0; index < keys.Count; index++) + { + groups.Add(new HistogramGroup(labels[index], $"histogram-cat-{index}", $"cat:{keys[index]}", [index])); + } + + return groups; + } +} diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramRender.cs b/src/EventLogExpert.Runtime/Histogram/HistogramRender.cs new file mode 100644 index 000000000..03edc4221 --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/HistogramRender.cs @@ -0,0 +1,21 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Histogram; + +public readonly record struct HistogramRenderBin( + long StartTicks, + long EndTicks, + int Total, + int[] GroupCounts) +{ + public bool IsAnomaly { get; init; } +} + +public sealed record HistogramRender( + IReadOnlyList Bins, + long WindowStartTicks, + long WindowEndTicks, + int WindowTotal, + int MaxBinTotal, + int[] WindowGroupTotals); diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramScale.cs b/src/EventLogExpert.Runtime/Histogram/HistogramScale.cs new file mode 100644 index 000000000..eb675f324 --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/HistogramScale.cs @@ -0,0 +1,98 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Histogram; + +public static class HistogramScale +{ + private const int StackRemainderThreshold = 64; + + public static double BarHeight(int count, int maxCount, double plotHeightPx) + { + if (count <= 0 || maxCount <= 0 || plotHeightPx <= 0) { return 0; } + + return Math.Max(1, Math.Sqrt(count) / Math.Sqrt(maxCount) * plotHeightPx); + } + + public static int[] StackedGroupHeights(int[] groupCounts, int maxBinTotal, double plotHeightPx) + { + ArgumentNullException.ThrowIfNull(groupCounts); + + var heights = new int[groupCounts.Length]; + WriteStackedGroupHeights(groupCounts, maxBinTotal, plotHeightPx, heights); + + return heights; + } + + public static void WriteStackedGroupHeights(int[] groupCounts, int maxBinTotal, double plotHeightPx, Span heights) + { + ArgumentNullException.ThrowIfNull(groupCounts); + + int groupCount = groupCounts.Length; + + if (heights.Length < groupCount) + { + throw new ArgumentException($"Destination length {heights.Length} is smaller than the group count {groupCount}.", nameof(heights)); + } + + heights[..groupCount].Clear(); + int total = 0; + int nonzero = 0; + + foreach (int count in groupCounts) + { + total += count; + + if (count > 0) { nonzero++; } + } + + if (total <= 0 || maxBinTotal <= 0 || plotHeightPx <= 0) { return; } + + int barTotalPx = Math.Min((int)Math.Round(BarHeight(total, maxBinTotal, plotHeightPx)), (int)plotHeightPx); + barTotalPx = Math.Max(barTotalPx, nonzero); + + Span remainders = groupCount <= StackRemainderThreshold ? stackalloc double[StackRemainderThreshold] : new double[groupCount]; + remainders = remainders[..groupCount]; + int assigned = 0; + + for (int group = 0; group < groupCount; group++) + { + double exact = (double)barTotalPx * groupCounts[group] / total; + heights[group] = (int)Math.Floor(exact); + remainders[group] = exact - heights[group]; + assigned += heights[group]; + } + + for (int leftover = barTotalPx - assigned; leftover > 0; leftover--) + { + int best = 0; + + for (int group = 1; group < groupCount; group++) + { + if (remainders[group] > remainders[best]) { best = group; } + } + + heights[best]++; + remainders[best] = -1; + } + + // A present group the proportional split rounded to zero steals one pixel from the tallest group so it stays visible. + for (int group = 0; group < groupCount; group++) + { + if (groupCounts[group] <= 0 || heights[group] != 0) { continue; } + + int tallest = 0; + + for (int other = 1; other < groupCount; other++) + { + if (heights[other] > heights[tallest]) { tallest = other; } + } + + if (heights[tallest] > 1) + { + heights[tallest]--; + heights[group] = 1; + } + } + } +} diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramState.cs b/src/EventLogExpert.Runtime/Histogram/HistogramState.cs new file mode 100644 index 000000000..ef88314bd --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/HistogramState.cs @@ -0,0 +1,12 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Fluxor; + +namespace EventLogExpert.Runtime.Histogram; + +[FeatureState] +public sealed record HistogramState +{ + public bool IsVisible { get; init; } = true; +} diff --git a/src/EventLogExpert.Runtime/Histogram/HistogramSummary.cs b/src/EventLogExpert.Runtime/Histogram/HistogramSummary.cs new file mode 100644 index 000000000..493e78a44 --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/HistogramSummary.cs @@ -0,0 +1,59 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Histogram; + +public static class HistogramSummary +{ + public static string RegionLabel(HistogramData data, TimeZoneInfo displayZone) + { + ArgumentNullException.ThrowIfNull(data); + ArgumentNullException.ThrowIfNull(displayZone); + + var min = TimeZoneInfo.ConvertTimeFromUtc(data.MinUtc, displayZone); + var max = TimeZoneInfo.ConvertTimeFromUtc(data.MaxUtc, displayZone); + + return $"Timeline: {data.Total} events from {min:g} to {max:g}{GroupBreakdown(GroupTotals(data), data.Groups)}."; + } + + public static string WindowAnnouncement(HistogramRender render, IReadOnlyList groups, TimeZoneInfo displayZone) + { + ArgumentNullException.ThrowIfNull(render); + ArgumentNullException.ThrowIfNull(groups); + ArgumentNullException.ThrowIfNull(displayZone); + + var start = TimeZoneInfo.ConvertTimeFromUtc(new DateTime(render.WindowStartTicks, DateTimeKind.Utc), displayZone); + var end = TimeZoneInfo.ConvertTimeFromUtc(new DateTime(render.WindowEndTicks, DateTimeKind.Utc), displayZone); + + return $"Showing {start:g} to {end:g}: {render.WindowTotal} events{GroupBreakdown(render.WindowGroupTotals, groups)}."; + } + + private static string GroupBreakdown(int[] totals, IReadOnlyList groups) + { + var parts = new List(); + + for (int group = groups.Count - 1; group >= 0; group--) + { + if (totals[group] > 0) { parts.Add($"{totals[group]} {groups[group].Label}"); } + } + + return parts.Count == 0 ? string.Empty : ", " + string.Join(", ", parts); + } + + private static int[] GroupTotals(HistogramData data) + { + var totals = new int[data.Groups.Count]; + + for (int bin = 0; bin < data.BinCount; bin++) + { + int offset = bin * data.SlotCount; + + for (int group = 0; group < data.Groups.Count; group++) + { + foreach (int slot in data.Groups[group].SlotIndices) { totals[group] += data.SlotCounts[offset + slot]; } + } + } + + return totals; + } +} diff --git a/src/EventLogExpert.Runtime/Histogram/IHistogramCommands.cs b/src/EventLogExpert.Runtime/Histogram/IHistogramCommands.cs new file mode 100644 index 000000000..04a5a9f36 --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/IHistogramCommands.cs @@ -0,0 +1,9 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Histogram; + +public interface IHistogramCommands +{ + void SetVisible(bool visible); +} diff --git a/src/EventLogExpert.Runtime/Histogram/IHistogramPreferencesProvider.cs b/src/EventLogExpert.Runtime/Histogram/IHistogramPreferencesProvider.cs new file mode 100644 index 000000000..87485d0e7 --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/IHistogramPreferencesProvider.cs @@ -0,0 +1,9 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Histogram; + +public interface IHistogramPreferencesProvider +{ + bool HistogramVisiblePreference { get; set; } +} diff --git a/src/EventLogExpert.Runtime/Histogram/Reducers.cs b/src/EventLogExpert.Runtime/Histogram/Reducers.cs new file mode 100644 index 000000000..34fe886af --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/Reducers.cs @@ -0,0 +1,13 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using Fluxor; + +namespace EventLogExpert.Runtime.Histogram; + +internal sealed class Reducers +{ + [ReducerMethod] + public static HistogramState ReduceSetHistogramVisible(HistogramState state, SetHistogramVisibleAction action) => + new() { IsVisible = action.IsVisible }; +} diff --git a/src/EventLogExpert.Runtime/Histogram/SetHistogramVisibleAction.cs b/src/EventLogExpert.Runtime/Histogram/SetHistogramVisibleAction.cs new file mode 100644 index 000000000..b29eb5e41 --- /dev/null +++ b/src/EventLogExpert.Runtime/Histogram/SetHistogramVisibleAction.cs @@ -0,0 +1,6 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Histogram; + +internal sealed record SetHistogramVisibleAction(bool IsVisible); diff --git a/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs b/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs index 5e0a60002..19024f3b7 100644 --- a/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs +++ b/src/EventLogExpert.Runtime/LogTable/CombinedColumnView.cs @@ -58,6 +58,46 @@ internal CombinedColumnView(IReadOnlyList views, SortContext co internal static CombinedColumnView Empty { get; } = new([], new SortContext(null, true, null, false)); + public void BucketTimeTicksByEventId(long minTicks, long bucketSpanTicks, int bucketCount, int[] targetIds, int[] slotCounts, CancellationToken cancellationToken) + { + foreach (var view in _views) + { + view.BucketTimeTicksByEventId(minTicks, bucketSpanTicks, bucketCount, targetIds, slotCounts, cancellationToken); + } + } + + public void BucketTimeTicksByField(long minTicks, long bucketSpanTicks, int bucketCount, EventFieldId field, string[] targetValues, int[] slotCounts, CancellationToken cancellationToken) + { + foreach (var view in _views) + { + view.BucketTimeTicksByField(minTicks, bucketSpanTicks, bucketCount, field, targetValues, slotCounts, cancellationToken); + } + } + + public void BucketTimeTicksBySeverity(long minTicks, long bucketSpanTicks, int bucketCount, int[] slotCounts, CancellationToken cancellationToken) + { + foreach (var view in _views) + { + view.BucketTimeTicksBySeverity(minTicks, bucketSpanTicks, bucketCount, slotCounts, cancellationToken); + } + } + + public void CountEventIds(IDictionary counts, CancellationToken cancellationToken) + { + foreach (var view in _views) + { + view.CountEventIds(counts, cancellationToken); + } + } + + public void CountFieldValues(EventFieldId field, IDictionary counts, CancellationToken cancellationToken) + { + foreach (var view in _views) + { + view.CountFieldValues(field, counts, cancellationToken); + } + } + public IEnumerable EnumerateDetail() { var offsets = new int[_views.Length]; @@ -181,6 +221,41 @@ public bool TryGetDetail(EventLocator locator, [NotNullWhen(true)] out ResolvedE return false; } + public bool TryGetTimeTicks(EventLocator locator, out long ticks) + { + if (_byLog.TryGetValue(locator.LogId, out var view)) + { + return view.TryGetTimeTicks(locator, out ticks); + } + + ticks = 0; + + return false; + } + + public bool TryGetTimeTicksRange(out long minTicks, out long maxTicks, CancellationToken cancellationToken) + { + long min = long.MaxValue; + long max = long.MinValue; + bool any = false; + + foreach (var view in _views) + { + if (!view.TryGetTimeTicksRange(out long viewMin, out long viewMax, cancellationToken)) { continue; } + + if (viewMin < min) { min = viewMin; } + + if (viewMax > max) { max = viewMax; } + + any = true; + } + + minTicks = any ? min : 0; + maxTicks = any ? max : 0; + + return any; + } + private int[][] BuildIndex() { var checkpoints = new List(_count / Stride); diff --git a/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs b/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs index da14ff398..e1a9ae76e 100644 --- a/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs +++ b/src/EventLogExpert.Runtime/LogTable/EventColumnView.cs @@ -33,6 +33,60 @@ private EventColumnView(IEventColumnReader reader, int[] order, int[] rankByPhys // so the current display order is a valid re-sort input. internal ReadOnlySpan Survivors => _order; + public void BucketTimeTicksByEventId( + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] targetIds, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksByEventId(_rankByPhysical, + 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(_rankByPhysical, + minTicks, + bucketSpanTicks, + bucketCount, + field, + targetValues, + slotCounts, + cancellationToken); + + public void BucketTimeTicksBySeverity( + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken) => + _reader.BucketTimeTicksBySeverity(_rankByPhysical, + minTicks, + bucketSpanTicks, + bucketCount, + slotCounts, + cancellationToken); + + public void CountEventIds(IDictionary counts, CancellationToken cancellationToken) => + _reader.CountEventIds(_rankByPhysical, counts, cancellationToken); + + public void CountFieldValues( + EventFieldId field, + IDictionary counts, + CancellationToken cancellationToken) => + _reader.CountFieldValues(_rankByPhysical, field, counts, cancellationToken); + public IEnumerable EnumerateDetail() { foreach (int physical in _order) @@ -105,6 +159,26 @@ public bool TryGetDetail(EventLocator locator, [NotNullWhen(true)] out ResolvedE return false; } + public bool TryGetTimeTicks(EventLocator locator, out long ticks) + { + if (locator.LogId == _reader.LogId + && locator.Generation == _reader.Generation + && locator.Index >= 0 + && locator.Index < _reader.Count) + { + ticks = _reader.GetTimeTicks(locator); + + return true; + } + + ticks = 0; + + return false; + } + + public bool TryGetTimeTicksRange(out long minTicks, out long maxTicks, CancellationToken cancellationToken) => + _reader.TryGetTimeTicksRange(_rankByPhysical, out minTicks, out maxTicks, cancellationToken); + /// /// Builds a view over the filter-surviving , sorted into display order, with the /// physical-to-display inverse used by . @@ -119,8 +193,8 @@ internal static EventColumnView Create( Create(reader, survivors, new SortContext(orderBy, isDescending, groupBy, isGroupDescending)); /// - /// Live-path overload: sorts under and retains it so the reducers can detect - /// () and repair () a stale sort without re-reading the raw store. + /// Live-path overload: sorts under and retains it so the reducers can detect ( + /// ) and repair () a stale sort without re-reading the raw store. /// internal static EventColumnView Create(IEventColumnReader reader, ReadOnlySpan survivors, SortContext context) { diff --git a/src/EventLogExpert.Runtime/LogTable/EventTableColumnFormatter.cs b/src/EventLogExpert.Runtime/LogTable/EventTableColumnFormatter.cs index a5f021180..47d584cc2 100644 --- a/src/EventLogExpert.Runtime/LogTable/EventTableColumnFormatter.cs +++ b/src/EventLogExpert.Runtime/LogTable/EventTableColumnFormatter.cs @@ -19,7 +19,7 @@ public static string GetCellText( ColumnName.Level => @event.Level, ColumnName.DateAndTime => FormatTimeCreated(@event.TimeCreated, timeZone, dateTimeFormat), ColumnName.ActivityId => @event.ActivityId?.ToString() ?? string.Empty, - ColumnName.Log => GetLogShortName(@event.OwningLog), + ColumnName.Log => OwningLogDisplay.ShortName(@event.OwningLog), ColumnName.ComputerName => @event.ComputerName, ColumnName.Source => @event.Source, ColumnName.EventId => @event.Id.ToString(), @@ -44,7 +44,4 @@ private static string FormatTimeCreated(DateTime timeCreated, TimeZoneInfo timeZ ? converted.ToString() : converted.ToString(dateTimeFormat, CultureInfo.InvariantCulture); } - - private static string GetLogShortName(string owningLog) => - owningLog[(owningLog.LastIndexOf('\\') + 1)..]; } diff --git a/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs b/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs index 6978e1855..56b73172c 100644 --- a/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs +++ b/src/EventLogExpert.Runtime/LogTable/IEventColumnView.cs @@ -15,6 +15,51 @@ public interface IEventColumnView { int Count { get; } + /// Group-by variant keyed on the numeric event id; (targetIds length + 1) slots per bin. + void BucketTimeTicksByEventId( + 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. + /// + void BucketTimeTicksByField( + 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. + /// + void BucketTimeTicksBySeverity( + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken); + + /// + /// Tallies this view's rows by numeric event id into (accumulating across a combined + /// view). + /// + void CountEventIds(IDictionary counts, CancellationToken cancellationToken); + + /// + /// Tallies this view's rows by non-empty pooled string value of field (accumulating, so a combined view sums by + /// logical value across logs); resolves the top-N group-by categories. + /// + void CountFieldValues(EventFieldId field, IDictionary counts, CancellationToken cancellationToken); + /// Full-detail rehydrate of every display row, in display order, for export and clipboard. IEnumerable EnumerateDetail(); @@ -52,4 +97,14 @@ public interface IEventColumnView /// inspectable after a filter hides it. /// bool TryGetDetail(EventLocator locator, [NotNullWhen(true)] out ResolvedEvent? detail); + + /// + /// Exception-free read of locator's UTC-tick timestamp from the tick column (no rehydrate); false when it no + /// longer addresses a live row. Like TryGetDetail, a filtered-out row still resolves, so in-view callers must also + /// check Rank. + /// + bool TryGetTimeTicks(EventLocator locator, out long ticks); + + /// UTC-tick span across this view's rows: true with [minTicks, maxTicks], false when the view is empty. + bool TryGetTimeTicksRange(out long minTicks, out long maxTicks, CancellationToken cancellationToken); } diff --git a/src/EventLogExpert.Runtime/LogTable/OwningLogDisplay.cs b/src/EventLogExpert.Runtime/LogTable/OwningLogDisplay.cs new file mode 100644 index 000000000..e787a5682 --- /dev/null +++ b/src/EventLogExpert.Runtime/LogTable/OwningLogDisplay.cs @@ -0,0 +1,60 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.LogTable; + +public static class OwningLogDisplay +{ + // Short names for a set of owning logs, disambiguating any that share a file name (e.g. Security.evtx opened from two folders) with the parent folder, then escalating to the full path if even that repeats, so each log stays a distinct legend label and accessible name. + public static IReadOnlyList DistinctShortNames(IReadOnlyList owningLogs) + { + ArgumentNullException.ThrowIfNull(owningLogs); + + var shortNames = new string[owningLogs.Count]; + var shortNameCounts = new Dictionary(StringComparer.Ordinal); + + for (int index = 0; index < shortNames.Length; index++) + { + shortNames[index] = ShortName(owningLogs[index]); + shortNameCounts[shortNames[index]] = shortNameCounts.GetValueOrDefault(shortNames[index]) + 1; + } + + var labels = new string[owningLogs.Count]; + + for (int index = 0; index < labels.Length; index++) + { + string parent = ParentFolder(owningLogs[index]); + labels[index] = shortNameCounts[shortNames[index]] == 1 || parent.Length == 0 + ? shortNames[index] + : $"{shortNames[index]} ({parent})"; + } + + EscalateRepeatsToFullPath(labels, owningLogs); + + return labels; + } + + // The file name for a saved-log path, or the whole value for a live log (no separator). + public static string ShortName(string owningLog) => owningLog[(owningLog.LastIndexOf('\\') + 1)..]; + + private static void EscalateRepeatsToFullPath(string[] labels, IReadOnlyList owningLogs) + { + var labelCounts = new Dictionary(StringComparer.Ordinal); + + foreach (string label in labels) { labelCounts[label] = labelCounts.GetValueOrDefault(label) + 1; } + + for (int index = 0; index < labels.Length; index++) + { + if (labelCounts[labels[index]] > 1) { labels[index] = owningLogs[index]; } + } + } + + private static string ParentFolder(string owningLog) + { + int end = owningLog.LastIndexOf('\\'); + if (end <= 0) { return string.Empty; } + + int start = owningLog.LastIndexOf('\\', end - 1); + return owningLog[(start + 1)..end]; + } +} diff --git a/src/EventLogExpert.Runtime/Menu/IMenuActionService.cs b/src/EventLogExpert.Runtime/Menu/IMenuActionService.cs index 749b9102f..3c19ad279 100644 --- a/src/EventLogExpert.Runtime/Menu/IMenuActionService.cs +++ b/src/EventLogExpert.Runtime/Menu/IMenuActionService.cs @@ -48,6 +48,8 @@ public interface IMenuActionService void SetContinuouslyUpdate(bool value); + void SetHistogramVisible(bool value); + Task ShowDebugLogsAsync(); Task ShowReleaseNotesAsync(); diff --git a/src/EventLogExpert.UI/DependencyInjection/UiServiceCollectionExtensions.cs b/src/EventLogExpert.UI/DependencyInjection/UiServiceCollectionExtensions.cs index e135f5f32..150c7660c 100644 --- a/src/EventLogExpert.UI/DependencyInjection/UiServiceCollectionExtensions.cs +++ b/src/EventLogExpert.UI/DependencyInjection/UiServiceCollectionExtensions.cs @@ -24,6 +24,7 @@ public IServiceCollection AddEventLogUiServices() services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); return services; diff --git a/src/EventLogExpert.UI/Layout/MainContent.razor b/src/EventLogExpert.UI/Layout/MainContent.razor index d5a0004db..2685eebd0 100644 --- a/src/EventLogExpert.UI/Layout/MainContent.razor +++ b/src/EventLogExpert.UI/Layout/MainContent.razor @@ -4,6 +4,10 @@ { + @if (HistogramVisible.Value) + { + + } } diff --git a/src/EventLogExpert.UI/Layout/MainContent.razor.cs b/src/EventLogExpert.UI/Layout/MainContent.razor.cs index 7e0f2d751..1d792ac7c 100644 --- a/src/EventLogExpert.UI/Layout/MainContent.razor.cs +++ b/src/EventLogExpert.UI/Layout/MainContent.razor.cs @@ -2,6 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.Runtime.EventLog; +using EventLogExpert.Runtime.Histogram; using Fluxor; using Fluxor.Blazor.Web.Components; using Microsoft.AspNetCore.Components; @@ -13,9 +14,13 @@ public sealed partial class MainContent : FluxorComponent [Inject] private IStateSelection HasActiveLogs { get; init; } = null!; + [Inject] + private IStateSelection HistogramVisible { get; init; } = null!; + protected override void OnInitialized() { HasActiveLogs.Select(state => state.OpenLogCount > 0); + HistogramVisible.Select(state => state.IsVisible); base.OnInitialized(); } } diff --git a/src/EventLogExpert.UI/LogTable/Find/FindMarkerSource.cs b/src/EventLogExpert.UI/LogTable/Find/FindMarkerSource.cs new file mode 100644 index 000000000..0409c8377 --- /dev/null +++ b/src/EventLogExpert.UI/LogTable/Find/FindMarkerSource.cs @@ -0,0 +1,36 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; + +namespace EventLogExpert.UI.LogTable.Find; + +/// +public sealed class FindMarkerSource : IFindMarkerSource +{ + private IReadOnlyList _ticks = []; + + public event EventHandler? MarksChanged; + + public EventLogId? Owner { get; private set; } + + public IReadOnlyList Ticks => _ticks; + + public void Clear() + { + if (Owner is null && _ticks.Count == 0) { return; } + + Owner = null; + _ticks = []; + MarksChanged?.Invoke(this, EventArgs.Empty); + } + + public void Publish(EventLogId owner, IReadOnlyList sortedTicks) + { + ArgumentNullException.ThrowIfNull(sortedTicks); + + Owner = owner; + _ticks = [.. sortedTicks]; + MarksChanged?.Invoke(this, EventArgs.Empty); + } +} diff --git a/src/EventLogExpert.UI/LogTable/Find/IFindMarkerSource.cs b/src/EventLogExpert.UI/LogTable/Find/IFindMarkerSource.cs new file mode 100644 index 000000000..86f1064b3 --- /dev/null +++ b/src/EventLogExpert.UI/LogTable/Find/IFindMarkerSource.cs @@ -0,0 +1,24 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; + +namespace EventLogExpert.UI.LogTable.Find; + +public interface IFindMarkerSource +{ + event EventHandler? MarksChanged; + + EventLogId? Owner { get; } + + /// The current match timestamps in ascending UTC-tick order; empty when Find is closed or has no matches. + IReadOnlyList Ticks { get; } + + void Clear(); + + /// + /// Publishes owner-tagged, ascending match timestamps; the list is snapshotted so later caller mutations don't + /// change the published marks. + /// + void Publish(EventLogId owner, IReadOnlyList sortedTicks); +} diff --git a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor new file mode 100644 index 000000000..49b8b8fcc --- /dev/null +++ b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor @@ -0,0 +1,196 @@ +@using EventLogExpert.Runtime.Histogram +@inherits FluxorComponent + +
+
+
+
+ Group by + + + + + + + + +
+ @if (_baseData is { } legendData) + { +
+ @for (int group = legendData.Groups.Count - 1; group >= 0; group--) + { + var info = legendData.Groups[group]; + string key = info.Key; + bool shown = !_hiddenGroups.Contains(key); + + + } +
+ } +
+ @if (_isZoomed) + { + Zoom is visual; use Scope to filter the table. + } +
+ + + + + +
+
+ +
+
+ @if (_render is { Bins.Count: > 0, WindowTotal: > 0 } render && _baseData is { } data && _viewportWidthPx > 0 && _plotHeightPx > 0) + { + double plotWidth = _viewportWidthPx; + int barCount = render.Bins.Count; + double barWidth = plotWidth / barCount; + int barsHeight = BarsAreaHeight(); + int groupCount = data.Groups.Count; + var axisLabels = AxisLabels(); + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @for (int index = 0; index < barCount; index++) + { + var bin = render.Bins[index]; + // Integer-align each bar slot to whole pixels so the 1px separator stays uniform; empty bins still hold their slot. + int barLeft = (int)Math.Round(index * barWidth); + int barRight = (int)Math.Round((index + 1) * barWidth); + int slotWidth = Math.Max(1, barRight - barLeft); + int barDrawWidth = Math.Max(1, slotWidth - 1); + double segmentTop = barsHeight; + int visiblePx = 0; + int baseSlot = index * groupCount; + + + + @for (int group = 0; group < groupCount; group++) + { + int height = _segmentHeights[baseSlot + group]; + + if (height <= 0 || IsGroupHidden(group)) { continue; } + + segmentTop -= height; + visiblePx += height; + + + + } + @if (bin.IsAnomaly && visiblePx > 0) + { + + } + @if (HasFindHit(bin.StartTicks, bin.EndTicks)) + { + + } + @if (_binCursor == index) + { + + } + + } + + + @foreach (var label in axisLabels) + { + + } + @if (FocusMarkerX() is { } markerX) + { + + } + + + + +
+ } + else + { +
No events to chart in the current view.
+ } +
+ +
@_announcement
+
@_binAnnouncement
+ +
diff --git a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.cs b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.cs new file mode 100644 index 000000000..9f2ee6d76 --- /dev/null +++ b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.cs @@ -0,0 +1,900 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Runtime.EventLog; +using EventLogExpert.Runtime.FilterLenses; +using EventLogExpert.Runtime.Histogram; +using EventLogExpert.Runtime.LogTable; +using EventLogExpert.Runtime.Settings; +using EventLogExpert.UI.Common.Interop; +using EventLogExpert.UI.LogTable.Find; +using Fluxor; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.JSInterop; +using System.Globalization; + +namespace EventLogExpert.UI.LogTable.Histogram; + +public sealed partial class HistogramPane +{ + private const int AnnounceDelayMs = 500; + private const int AxisReservePx = 16; + private const double KeyboardPanFraction = 0.2; + private const int MaxWindowHistory = 100; + private const int MinBarPx = 14; + private const int MinWindowBaseBins = 4; + private const double MinWindowFraction = (double)MinWindowBaseBins / HistogramConstants.MaxBuckets; + private const int RecomputeThrottleMs = 500; + private const double ZoomInFactor = 0.8; + private const double ZoomOutFactor = 1.25; + + private readonly HashSet _hiddenGroups = []; + private readonly CancellationTokenSource _lifetimeCts = new(); + private readonly List<(long Start, long End, bool Zoomed)> _windowHistory = []; + + private int _announceGeneration; + private string _announcement = string.Empty; + private HistogramData? _baseData; + private string _binAnnouncement = string.Empty; + private int? _binCursor; + private HistogramDimension _dimension = HistogramDimension.Severity; + private bool _disposed; + private DotNetObjectReference? _dotNetRef; + private long[] _findTicks = []; + private long? _focusedTicks; + private bool _isZoomed; + private IJSObjectReference? _module; + // Generation for queued pan/zoom: bumped on undo so a pan/zoom initiated before the undo (its stale token captured at schedule time) no-ops instead of reapplying the pre-undo window. + private int _navToken; + private double? _pendingViewStartFraction; + private int _plotHeightPx; + private bool _recomputePending; + private HistogramRender? _render; + private CancellationTokenSource? _scanCts; + private int _scanEpoch; + private int _segmentGroupCount; + private int[] _segmentHeights = []; + private TimeZoneInfo _timeZone = TimeZoneInfo.Utc; + private int _viewportWidthPx; + private int[] _visibleGroupCounts = []; + private long _windowEndTicks; + private long _windowStartTicks; + + [Inject] private IStateSelection ActiveEventLogId { get; init; } = null!; + + [Inject] private IStateSelection ActiveOriginLog { get; init; } = null!; + + [Inject] private IStateSelection ActiveView { get; init; } = null!; + + [Inject] private IFilterLensCommands FilterLensCommands { get; init; } = null!; + + [Inject] private IFindMarkerSource FindMarkers { get; init; } = null!; + + [Inject] private IStateSelection Focus { get; init; } = null!; + + [Inject] private IJSRuntime JSRuntime { get; init; } = null!; + + [Inject] private ISettingsService Settings { get; init; } = null!; + + [Inject] private ITraceLogger TraceLogger { get; init; } = null!; + + [JSInvokable] + public void OnHistogramDragSelected(double startFraction, double endFraction, bool scope) + { + if (_disposed || _baseData is null) { return; } + + long startTicks = WindowFractionToTicks(startFraction); + long endTicks = WindowFractionToTicks(endFraction); + SetWindow(startTicks, endTicks); + + if (scope) { ScopeToRange(); } + } + + [JSInvokable] + public void OnHistogramPanned(double windowStartFraction, int navToken) + { + if (_disposed || navToken != _navToken || _baseData is not { } data || !_isZoomed) { return; } + + int binCount = WindowBinCount(data); + int newStartBin = (int)Math.Round(Math.Clamp(windowStartFraction, 0, 1) * data.BinCount); + + SetWindowByBins(data, newStartBin, binCount); + + AggregateAndRender(syncScrollbar: false); + } + + [JSInvokable] + public void OnHistogramReset() + { + if (_disposed) { return; } + + Fit(); + } + + [JSInvokable] + public void OnHistogramResized(int widthPx, int heightPx) + { + if (_disposed) { return; } + + bool hadDimensions = _viewportWidthPx > 0 && _plotHeightPx > 0; + _viewportWidthPx = widthPx; + _plotHeightPx = heightPx; + + if (widthPx <= 0 || heightPx <= 0) { return; } + + // A widened or newly-revealed viewport can push the current zoom depth past the track-width cap, so re-clamp the retained window and render immediately: the inline track width is a percentage the browser re-resolves against the new viewport at once, so deferring the render to an async rescan would briefly expose the stale over-cap track. + if (_baseData is { } data) + { + SetWindowByBins(data, WindowStartBin(data), WindowBinCount(data)); + AggregateAndRender(); + } + + if (!hadDimensions) { StartScan(); } + } + + [JSInvokable] + public void OnHistogramScopeBin(double fraction) + { + if (_disposed || _render is not { Bins.Count: > 0 } render) { return; } + + int index = Math.Clamp((int)(Math.Clamp(fraction, 0, 1) * render.Bins.Count), 0, render.Bins.Count - 1); + var bin = render.Bins[index]; + + FilterLensCommands.ShowTimeRange( + new DateTime(bin.StartTicks, DateTimeKind.Utc), + new DateTime(bin.EndTicks, DateTimeKind.Utc), + _timeZone, + ActiveOriginLog.Value); + } + + [JSInvokable] + public void OnHistogramUndo() + { + if (_disposed) { return; } + + UndoZoom(); + } + + [JSInvokable] + public void OnHistogramZoomed(bool zoomIn, double cursorFraction, int navToken) + { + if (_disposed || navToken != _navToken) { return; } + + ApplyZoom(zoomIn ? ZoomInFactor : ZoomOutFactor, Math.Clamp(cursorFraction, 0, 1)); + } + + protected override async ValueTask DisposeAsyncCore(bool disposing) + { + if (disposing) + { + _disposed = true; + + ActiveView.SelectedValueChanged -= OnActiveViewChanged; + ActiveEventLogId.SelectedValueChanged -= OnActiveEventLogIdChanged; + Focus.SelectedValueChanged -= OnFocusChanged; + Settings.TimeZoneChanged -= OnTimeZoneChanged; + FindMarkers.MarksChanged -= OnFindMarksChanged; + + _lifetimeCts.Cancel(); + _lifetimeCts.Dispose(); + + try { _scanCts?.Cancel(); } catch (ObjectDisposedException) { /* Already disposed; cancel is moot. */ } + + _scanCts?.Dispose(); + _scanCts = null; + + await JsModuleInterop.DisposeModuleSafelyAsync( + _module, + static module => module.InvokeVoidAsync("disposeHistogram")); + + _module = null; + + _dotNetRef?.Dispose(); + } + + await base.DisposeAsyncCore(disposing); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _dotNetRef = DotNetObjectReference.Create(this); + + _module = await JSRuntime.InvokeAsync( + "import", + "./_content/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.js"); + + await _module.InvokeVoidAsync("initHistogram", _dotNetRef); + } + + if (_pendingViewStartFraction is { } startFraction && _module is not null) + { + _pendingViewStartFraction = null; + + try { await _module.InvokeVoidAsync("applyView", startFraction, _navToken); } + catch (JSDisconnectedException) { /* Circuit torn down; nothing to sync. */ } + } + + await base.OnAfterRenderAsync(firstRender); + } + + protected override void OnInitialized() + { + _timeZone = Settings.TimeZoneInfo; + ActiveView.Select(state => state.GetActiveDisplayedEvents()); + ActiveOriginLog.Select(SelectActiveOriginLog); + ActiveEventLogId.Select(state => state.ActiveEventLogId); + Focus.Select(state => state.Focus); + ActiveView.SelectedValueChanged += OnActiveViewChanged; + ActiveEventLogId.SelectedValueChanged += OnActiveEventLogIdChanged; + Focus.SelectedValueChanged += OnFocusChanged; + Settings.TimeZoneChanged += OnTimeZoneChanged; + FindMarkers.MarksChanged += OnFindMarksChanged; + + RefreshFindTicks(); + + base.OnInitialized(); + } + + private static string DimensionLabel(HistogramDimension dimension) => dimension switch + { + HistogramDimension.EventId => "Event ID", + HistogramDimension.TaskCategory => "Task Category", + _ => dimension.ToString() + }; + + private static string FindMarkerPoints(double centerX) => + $"{FormatCoordinate(centerX - 3)},0 {FormatCoordinate(centerX + 3)},0 {FormatCoordinate(centerX)},5"; + + private static string FormatCoordinate(double value) => value.ToString("0.##", CultureInfo.InvariantCulture); + + private static string? SelectActiveOriginLog(LogTableState state) + { + var activeTab = state.EventTables.FirstOrDefault(tab => tab.Id == state.ActiveEventLogId); + + return activeTab is { IsCombined: false } ? activeTab.LogName : null; + } + + private void AggregateAndRender(bool syncScrollbar = true) + { + _binCursor = null; + + if (_baseData is not { } data) + { + _render = null; + StateHasChanged(); + + return; + } + + _render = HistogramAggregator.Aggregate(data, _windowStartTicks, _windowEndTicks, TargetBins(data)); + ComputeSegmentHeights(_render, data.Groups.Count); + + if (syncScrollbar) { _pendingViewStartFraction = StartFraction(); } + + ScheduleAnnouncement(); + StateHasChanged(); + } + + private async Task AnnounceAfterDelayAsync(int generation) + { + try { await Task.Delay(AnnounceDelayMs, _lifetimeCts.Token); } + catch (OperationCanceledException) { return; } + + try + { + await InvokeAsync(() => + { + if (generation != _announceGeneration || _disposed || _render is not { } render || _baseData is not { } data) { return; } + + _announcement = HistogramSummary.WindowAnnouncement(render, data.Groups, _timeZone); + StateHasChanged(); + }); + } + catch (ObjectDisposedException) { /* Component torn down mid-announce; nothing to update. */ } + } + + private void ApplyPublishedWindow() + { + if (_baseData is not { } data) { return; } + + // A disjoint-domain reload leaves both the pinned window and the absolute-tick undo history meaningless - including after a Fit cleared _isZoomed but left history - so drop the history and supersede queued nav whenever the retained window falls entirely outside the new domain, before the not-zoomed refit. A same-tab clear/refill reaches here (not OnActiveEventLogIdChanged), so the token bump must live here too. + bool windowDisjoint = _windowEndTicks < data.MinUtc.Ticks || _windowStartTicks > data.MaxUtc.Ticks; + + if (windowDisjoint) + { + _windowHistory.Clear(); + SupersedeQueuedNavigation(); + } + + if (!_isZoomed || windowDisjoint) + { + SetWindowByBins(data, 0, data.BinCount); + + return; + } + + SetWindowByBins(data, WindowStartBin(data), WindowBinCount(data)); + } + + private void ApplyZoom(double factor, double anchorFraction) + { + if (_baseData is not { } data) { return; } + + int totalBins = data.BinCount; + int minBins = Math.Min(MinWindowBaseBins, totalBins); + int currentBins = WindowBinCount(data); + int newBins = (int)Math.Round(currentBins * factor); + + if (factor < 1 && newBins >= currentBins) { newBins = currentBins - 1; } + + if (factor > 1 && newBins <= currentBins) { newBins = currentBins + 1; } + + newBins = Math.Clamp(newBins, minBins, totalBins); + + double anchorBin = WindowStartBin(data) + (anchorFraction * currentBins); + int newStartBin = (int)Math.Round(anchorBin - (anchorFraction * newBins)); + + SetWindowByBins(data, newStartBin, newBins, recordHistory: true); + + AggregateAndRender(); + } + + private IReadOnlyList AxisLabels() + { + var labels = new List(); + + if (_render is not { } render || _viewportWidthPx <= 0) { return labels; } + + long span = render.WindowEndTicks - render.WindowStartTicks; + int count = Math.Clamp(_viewportWidthPx / 130, 2, 6); + bool crossesDay = WindowCrossesDay(); + + for (int index = 0; index < count; index++) + { + double fraction = (double)index / (count - 1); + double x = fraction * _viewportWidthPx; + long ticks = Math.Clamp( + render.WindowStartTicks + (long)(fraction * span), + render.WindowStartTicks, + render.WindowEndTicks); + var display = ToDisplay(new DateTime(ticks, DateTimeKind.Utc)); + string text = crossesDay + ? $"{display:d} {display:HH:mm}" + : $"{display:HH:mm:ss}"; + string anchor = index == 0 ? "start" : index == count - 1 ? "end" : "middle"; + + labels.Add(new AxisLabel(x, text, anchor)); + } + + return labels; + } + + private int BarsAreaHeight() => Math.Max(0, _plotHeightPx - AxisReservePx); + + private string BarTooltip(HistogramRenderBin bin) + { + var start = ToDisplay(new DateTime(bin.StartTicks, DateTimeKind.Utc)); + var end = ToDisplay(new DateTime(Math.Max(bin.StartTicks, bin.EndTicks), DateTimeKind.Utc)); + bool crossesDay = WindowCrossesDay(); + string startText = crossesDay ? $"{start:d} {start:HH:mm:ss}" : $"{start:HH:mm:ss}"; + string endText = crossesDay ? $"{end:d} {end:HH:mm:ss}" : $"{end:HH:mm:ss}"; + + return $"{bin.Total} events{GroupBreakdown(bin)}, {startText} - {endText}"; + } + + private string BinCursorAnnouncement(HistogramRenderBin bin) + { + var start = ToDisplay(new DateTime(bin.StartTicks, DateTimeKind.Utc)); + var end = ToDisplay(new DateTime(Math.Max(bin.StartTicks, bin.EndTicks), DateTimeKind.Utc)); + string anomaly = bin.IsAnomaly ? ", spike" : string.Empty; + + return $"{start:g} to {end:g}: {bin.Total} events{GroupBreakdown(bin)}{anomaly}."; + } + + private void ClearBinCursor() + { + if (_binCursor is null) { return; } + + _binCursor = null; + + StateHasChanged(); + } + + private void ComputeSegmentHeights(HistogramRender render, int groupCount) + { + _segmentGroupCount = groupCount; + + int needed = render.Bins.Count * groupCount; + + if (_segmentHeights.Length < needed) { _segmentHeights = new int[needed]; } + if (_visibleGroupCounts.Length != groupCount) { _visibleGroupCounts = new int[groupCount]; } + + double barsHeight = BarsAreaHeight(); + + Span hidden = groupCount <= 16 ? stackalloc bool[16] : new bool[groupCount]; + hidden = hidden[..groupCount]; + + for (int group = 0; group < groupCount; group++) { hidden[group] = IsGroupHidden(group); } + + // Normalize bar heights to the tallest VISIBLE bin (hidden legend groups excluded) so toggling a group off rescales the remaining bars to fill the plot, instead of leaving the true tallest bar a 1-2px sliver under a hidden group's scale. + int maxVisibleBinTotal = 0; + + for (int bin = 0; bin < render.Bins.Count; bin++) + { + int[] counts = render.Bins[bin].GroupCounts; + int visible = 0; + + for (int group = 0; group < groupCount; group++) + { + if (!hidden[group]) { visible += counts[group]; } + } + + if (visible > maxVisibleBinTotal) { maxVisibleBinTotal = visible; } + } + + for (int bin = 0; bin < render.Bins.Count; bin++) + { + int[] counts = render.Bins[bin].GroupCounts; + + for (int group = 0; group < groupCount; group++) + { + _visibleGroupCounts[group] = hidden[group] ? 0 : counts[group]; + } + + HistogramScale.WriteStackedGroupHeights( + _visibleGroupCounts, + maxVisibleBinTotal, + barsHeight, + _segmentHeights.AsSpan(bin * groupCount, groupCount)); + } + } + + private void Fit() + { + if (_baseData is not { } data) { return; } + + SupersedeQueuedNavigation(); + SetWindowByBins(data, 0, data.BinCount, recordHistory: true); + + AggregateAndRender(); + } + + private double? FocusMarkerX() + { + if (_focusedTicks is not { } ticks || _viewportWidthPx <= 0 || _render is not { } render) { return null; } + + long span = render.WindowEndTicks - render.WindowStartTicks; + + if (span <= 0 || ticks < render.WindowStartTicks || ticks > render.WindowEndTicks) { return null; } + + return (double)(ticks - render.WindowStartTicks) / span * _viewportWidthPx; + } + + private string GroupBreakdown(HistogramRenderBin bin) + { + if (_baseData is not { } data) { return string.Empty; } + + var parts = new List(); + + for (int group = data.Groups.Count - 1; group >= 0; group--) + { + int count = bin.GroupCounts[group]; + + if (count > 0) { parts.Add($"{count} {data.Groups[group].Label}"); } + } + + 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 void HandleKeyDown(KeyboardEventArgs args) + { + if (args is { ShiftKey: true, Key: "ArrowLeft" or "ArrowRight" }) + { + MoveBinCursor(args.Key == "ArrowRight" ? 1 : -1); + + return; + } + + switch (args.Key) + { + case "ArrowLeft": PanByFraction(-KeyboardPanFraction); break; + case "ArrowRight": PanByFraction(KeyboardPanFraction); break; + case "ArrowUp" or "+" or "=": ZoomFromControl(ZoomInFactor); break; + case "ArrowDown" or "-" or "_": ZoomFromControl(ZoomOutFactor); break; + case "Home" or "0": Fit(); break; + case "Escape": ClearBinCursor(); break; + case "Enter": ScopeBinCursorOrWindow(); break; + } + } + + private bool HasFindHit(long startTicks, long endTicks) + { + if (_findTicks.Length == 0) { return false; } + + int index = Array.BinarySearch(_findTicks, startTicks); + + if (index < 0) { index = ~index; } + + return index < _findTicks.Length && _findTicks[index] <= endTicks; + } + + private bool IsGroupHidden(int group) => + _baseData is { } data && group < data.Groups.Count && _hiddenGroups.Contains(data.Groups[group].Key); + + private void MoveBinCursor(int delta) + { + if (_render is not { Bins.Count: > 0 } render) { return; } + + int next = _binCursor is { } cursor ? cursor + delta : delta > 0 ? 0 : render.Bins.Count - 1; + _binCursor = Math.Clamp(next, 0, render.Bins.Count - 1); + _binAnnouncement = BinCursorAnnouncement(render.Bins[_binCursor.Value]); + + StateHasChanged(); + } + + // A tab switch makes the absolute-tick zoom window meaningless, so drop the zoom and rescan at the new tab's full span. + private void OnActiveEventLogIdChanged(object? sender, EventLogId? logId) => _ = InvokeAsync(() => + { + _isZoomed = false; + _windowHistory.Clear(); + SupersedeQueuedNavigation(); + // Invalidate the old tab's data + render so nothing acts on stale data during the gap, then schedule the rescan directly: a one-member group's header and member tabs share one view, so OnActiveViewChanged is not guaranteed to fire and can't be relied on to republish (ScheduleRecompute de-dupes with it when it does). + _baseData = null; + _render = null; + RefreshFindTicks(); + ScheduleRecompute(); + }); + + private void OnActiveViewChanged(object? sender, IEventColumnView view) => _ = InvokeAsync(ScheduleRecompute); + + private void OnDimensionSelected(HistogramDimension dimension) + { + if (dimension == _dimension) { return; } + + _dimension = dimension; + _hiddenGroups.Clear(); + RecomputeSegmentHeights(); + + StartScan(); + } + + private void OnFindMarksChanged(object? sender, EventArgs args) => _ = InvokeAsync(() => + { + if (_disposed) { return; } + + RefreshFindTicks(); + StateHasChanged(); + }); + + private void OnFocusChanged(object? sender, SelectionEntry? focus) => _ = InvokeAsync(() => + { + ResolveFocusedTicks(); + StateHasChanged(); + }); + + private void OnTimeZoneChanged(object? sender, TimeZoneInfo timeZone) => _ = InvokeAsync(() => + { + if (_disposed) { return; } + + _timeZone = timeZone; + + // Both live regions embed _timeZone-formatted times, so re-render alone would leave a screen reader announcing the old zone until the next pan/zoom/scan. + ScheduleAnnouncement(); + + if (_binCursor is { } cursor && _render is { Bins.Count: > 0 } render && cursor < render.Bins.Count) + { + _binAnnouncement = BinCursorAnnouncement(render.Bins[cursor]); + } + + StateHasChanged(); + }); + + private void PanByFraction(double fraction) + { + if (_baseData is not { } data || !_isZoomed) { return; } + + SupersedeQueuedNavigation(); + + int binCount = WindowBinCount(data); + int delta = (int)Math.Round(fraction * binCount); + + if (delta == 0) { delta = fraction > 0 ? 1 : -1; } + + SetWindowByBins(data, WindowStartBin(data) + delta, binCount); + + AggregateAndRender(); + } + + private void PushWindowHistory() + { + (long, long, bool) current = (_windowStartTicks, _windowEndTicks, _isZoomed); + + if (_windowHistory.Count > 0 && _windowHistory[^1] == current) { return; } + + _windowHistory.Add(current); + + if (_windowHistory.Count > MaxWindowHistory) { _windowHistory.RemoveAt(0); } + } + + // Segment heights are scaled to the visible groups, so any change to the hidden set must rescale the current render's bars (the pending scan, if any, replaces them once it lands). + private void RecomputeSegmentHeights() + { + if (_render is { } render && _baseData is { } data) { ComputeSegmentHeights(render, data.Groups.Count); } + } + + private void RefreshFindTicks() => + _findTicks = FindMarkers.Owner == ActiveEventLogId.Value && FindMarkers.Ticks is { Count: > 0 } ticks + ? [.. ticks] + : []; + + private string RegionAria() => + _baseData is { } data ? HistogramSummary.RegionLabel(data, _timeZone) : "Timeline"; + + private void ResolveFocusedTicks() + { + _focusedTicks = Focus.Value?.CurrentHandle is { } handle + && ActiveView.Value.Rank(handle) >= 0 + && ActiveView.Value.TryGetTimeTicks(handle, out long ticks) + ? ticks + : null; + } + + private async Task RunScanAsync(IEventColumnView view, HistogramDimension dimension, int epoch, 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); + } + catch (OperationCanceledException) { return; } + catch (Exception e) + { + TraceLogger.Error($"{nameof(HistogramPane)}: histogram scan failed: {e}"); + + return; + } + + try + { + await InvokeAsync(() => + { + if (_disposed || epoch != _scanEpoch || !ReferenceEquals(view, ActiveView.Value)) { return; } + + _baseData = data; + ResolveFocusedTicks(); + ApplyPublishedWindow(); + AggregateAndRender(); + }); + } + catch (ObjectDisposedException) { /* Component torn down mid-publish; nothing to update. */ } + } + + private void ScheduleAnnouncement() => _ = AnnounceAfterDelayAsync(++_announceGeneration); + + private void ScheduleRecompute() + { + if (_recomputePending || _disposed) { return; } + + _recomputePending = true; + _ = ThrottleThenScanAsync(); + } + + private void ScopeBin(HistogramRenderBin bin) => + FilterLensCommands.ShowTimeRange( + new DateTime(bin.StartTicks, DateTimeKind.Utc), + new DateTime(bin.EndTicks, DateTimeKind.Utc), + _timeZone, + ActiveOriginLog.Value); + + private void ScopeBinCursorOrWindow() + { + if (_binCursor is { } cursor && _render is { } render && cursor < render.Bins.Count) + { + ScopeBin(render.Bins[cursor]); + + return; + } + + ScopeToRange(); + } + + private void ScopeToRange() + { + if (_render is not { } render) { return; } + + FilterLensCommands.ShowTimeRange( + new DateTime(render.WindowStartTicks, DateTimeKind.Utc), + new DateTime(render.WindowEndTicks, DateTimeKind.Utc), + _timeZone, + ActiveOriginLog.Value); + } + + private void SetWindow(long startTicks, long endTicks) + { + if (_baseData is not { } data) { return; } + + SupersedeQueuedNavigation(); + + long span = data.BucketSpanTicks; + long baseMin = data.MinUtc.Ticks; + int totalBins = data.BinCount; + int loBin = (int)Math.Clamp((Math.Min(startTicks, endTicks) - baseMin) / span, 0, totalBins - 1); + int hiBin = (int)Math.Clamp((Math.Max(startTicks, endTicks) - baseMin) / span, 0, totalBins - 1); + + SetWindowByBins(data, loBin, hiBin - loBin + 1, recordHistory: true); + + AggregateAndRender(); + } + + // Every zoom/pan/fit sets the window here as a whole base-bin range, so the render bounds equal the window and incremental zoom never re-quantizes back to the same bins (no stall). + private void SetWindowByBins(HistogramData data, int startBin, int binCount, bool recordHistory = false) + { + int totalBins = data.BinCount; + int minBins = Math.Min(MinWindowBaseBins, totalBins); + + // Keep the virtual scroll track under the browser's maximum layout width so scrollWidth == clientWidth / WindowFraction holds and the scrollbar can reach the final bins; a deeper zoom would overflow the cap. + if (_viewportWidthPx > 0) + { + minBins = Math.Max(minBins, HistogramTrackCap.MinBinsForWidth(_viewportWidthPx, totalBins)); + } + + binCount = Math.Clamp(binCount, minBins, totalBins); + startBin = Math.Clamp(startBin, 0, totalBins - binCount); + + long span = data.BucketSpanTicks; + long baseMin = data.MinUtc.Ticks; + + bool newZoomed = binCount < totalBins; + long newStartTicks = newZoomed ? baseMin + (startBin * span) : baseMin; + long newEndTicks = newZoomed ? Math.Min((baseMin + ((startBin + binCount) * span)) - 1, data.MaxUtc.Ticks) : data.MaxUtc.Ticks; + + // Record an undo step only when the window actually moves, so a no-op zoom (clamped at the floor or full span) can't leave a dead history entry that swallows the first Undo. + if (recordHistory && (newStartTicks != _windowStartTicks || newEndTicks != _windowEndTicks)) { PushWindowHistory(); } + + _isZoomed = newZoomed; + _windowStartTicks = newStartTicks; + _windowEndTicks = newEndTicks; + } + + private double StartFraction() + { + if (_baseData is not { } data || data.BinCount <= 0) { return 0; } + + return Math.Clamp((double)WindowStartBin(data) / data.BinCount, 0, 1); + } + + private void StartScan() + { + if (_disposed) { return; } + + _scanCts?.Cancel(); + _scanCts?.Dispose(); + _scanCts = null; + + if (_viewportWidthPx <= 0 || _plotHeightPx <= 0) { return; } + + IEventColumnView view = ActiveView.Value; + int epoch = ++_scanEpoch; + HistogramDimension dimension = _dimension; + var cts = new CancellationTokenSource(); + _scanCts = cts; + + _ = RunScanAsync(view, dimension, epoch, 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. + private void SupersedeQueuedNavigation() => _navToken++; + + private int TargetBins(HistogramData data) + { + return _viewportWidthPx <= 0 ? 1 : Math.Clamp((int)Math.Round(_viewportWidthPx / (double)MinBarPx), 1, data.BinCount); + } + + private async Task ThrottleThenScanAsync() + { + try { await Task.Delay(RecomputeThrottleMs, _lifetimeCts.Token); } + catch (OperationCanceledException) { return; } + + _recomputePending = false; + StartScan(); + } + + private DateTime ToDisplay(DateTime utc) => TimeZoneInfo.ConvertTimeFromUtc(utc, _timeZone); + + private void ToggleGroup(string key) + { + // Keyed by stable Key so a live-tail re-rank can't swap which category is hidden. Window totals and anomaly flags stay over all groups. + if (!_hiddenGroups.Remove(key)) { _hiddenGroups.Add(key); } + + RecomputeSegmentHeights(); + + StateHasChanged(); + } + + private string TrackWidthStyle() => $"width:{FormatCoordinate(100 / WindowFraction())}%"; + + private void UndoZoom() + { + if (_windowHistory.Count == 0 || _baseData is not { } data) { return; } + + SupersedeQueuedNavigation(); + + long beforeStart = _windowStartTicks; + long beforeEnd = _windowEndTicks; + + // Pop past any snapshot that, after the track-cap clamp, reproduces the current window (a resize-forced re-clamp can leave the top entry equal to the current view), so Undo always moves visibly. + while (_windowHistory.Count > 0) + { + (long start, long end, bool zoomed) = _windowHistory[^1]; + _windowHistory.RemoveAt(_windowHistory.Count - 1); + + if (!zoomed) + { + SetWindowByBins(data, 0, data.BinCount); + } + else + { + long span = data.BucketSpanTicks; + long baseMin = data.MinUtc.Ticks; + int totalBins = data.BinCount; + int startBin = (int)Math.Clamp((start - baseMin) / span, 0, totalBins - 1); + int endBin = (int)Math.Clamp((end - baseMin) / span, 0, totalBins - 1); + SetWindowByBins(data, startBin, endBin - startBin + 1); + } + + if (_windowStartTicks != beforeStart || _windowEndTicks != beforeEnd) { break; } + } + + AggregateAndRender(); + } + + private int WindowBinCount(HistogramData data) + { + int startBin = WindowStartBin(data); + int endBin = (int)Math.Clamp((_windowEndTicks - data.MinUtc.Ticks) / data.BucketSpanTicks, 0, data.BinCount - 1); + + return endBin - startBin + 1; + } + + // True when the window's displayed endpoints fall on different calendar days (including a short window straddling midnight), so times need a date to disambiguate. + private bool WindowCrossesDay() => + _render is { } render && + ToDisplay(new DateTime(render.WindowStartTicks, DateTimeKind.Utc)).Date + != ToDisplay(new DateTime(render.WindowEndTicks, DateTimeKind.Utc)).Date; + + private double WindowFraction() + { + if (_baseData is not { BinCount: > 0 } data) { return 1; } + + return Math.Clamp((double)WindowBinCount(data) / data.BinCount, MinWindowFraction, 1); + } + + private long WindowFractionToTicks(double fraction) + { + if (_render is not { } render) { return _windowStartTicks; } + + return render.WindowStartTicks + (long)(Math.Clamp(fraction, 0, 1) * (render.WindowEndTicks - render.WindowStartTicks)); + } + + private int WindowStartBin(HistogramData data) => + (int)Math.Clamp((_windowStartTicks - data.MinUtc.Ticks) / data.BucketSpanTicks, 0, data.BinCount - 1); + + // Explicit keyboard/toolbar zoom supersedes any queued wheel/scroll momentum before zooming; the wheel path (OnHistogramZoomed) calls ApplyZoom directly with no bump, so rapid coalesced wheel zooming isn't self-invalidated. + private void ZoomFromControl(double factor) + { + SupersedeQueuedNavigation(); + ApplyZoom(factor, 0.5); + } + + private readonly record struct AxisLabel(double X, string Text, string Anchor); +} diff --git a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.css b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.css new file mode 100644 index 000000000..4c862319d --- /dev/null +++ b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.css @@ -0,0 +1,308 @@ +.histogram-pane { + position: relative; + display: flex; + flex-direction: column; + flex: 0 0 auto; + height: 170px; + margin: 0 5px; + border-bottom: solid var(--background-darkgray); + overflow: hidden; + user-select: none; + -webkit-user-select: none; +} + +.histogram-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px 12px; + flex-wrap: wrap; + min-height: 20px; + padding: 1px 0; + color: var(--text-secondary); +} + +.histogram-toolbar-controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 10px; + min-width: 0; +} + +.histogram-groupby { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.histogram-groupby-label { color: var(--text-secondary); } + +.histogram-groupby ::deep .dropdown-input { margin: 0; } + +.histogram-groupby ::deep .histogram-dimension-select { + width: 8rem; + margin: 0; +} + +.histogram-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; +} + +.histogram-legend-item { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 0 2px; + border: 1px solid transparent; + border-radius: 2px; + background: transparent; + color: inherit; + line-height: 1.3; + cursor: pointer; +} + +.histogram-legend-item:hover { border-color: var(--background-darkgray); } + +.histogram-legend-item:focus-visible { + outline: 2px solid var(--toggle-accent); + outline-offset: 1px; +} + +.histogram-legend-hidden { + opacity: 0.5; + text-decoration: line-through; +} + +.histogram-swatch { + display: inline-block; + flex: 0 0 auto; + border-radius: 1px; +} + +.histogram-buttons { + display: flex; + gap: 2px; +} + +.histogram-button { + min-width: 20px; + padding: 2px 6px; + border: 1px solid var(--background-darkgray); + border-radius: 2px; + background: transparent; + color: inherit; + line-height: 1.3; + cursor: pointer; +} + +.histogram-button:disabled { + opacity: 0.4; + cursor: default; +} + +.histogram-button:hover { + background: color-mix(in srgb, var(--toggle-accent) 15%, transparent); +} + +.histogram-button:focus-visible { + outline: 2px solid var(--toggle-accent); + outline-offset: 1px; +} + +.histogram-button-accent { + border-color: var(--toggle-accent); + color: var(--toggle-accent); + font-weight: 600; +} + +.histogram-zoom-hint { + flex: 1 1 auto; + min-width: 0; + text-align: center; + color: var(--text-secondary); + font-style: italic; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.histogram-scroll { + position: relative; + flex: 1 1 auto; + min-height: 0; + overflow-x: auto; + overflow-y: hidden; +} + +.histogram-scroll:focus-visible { + outline: 2px solid var(--toggle-accent); + outline-offset: -2px; +} + +.histogram-scroll::-webkit-scrollbar { height: 6px; } + +.histogram-track { + position: absolute; + bottom: 0; + left: 0; + height: 1px; + pointer-events: none; +} + +.histogram-viewport { + position: sticky; + left: 0; + height: 100%; +} + +.histogram-plot { + display: block; +} + +.histogram-selection { + position: absolute; + top: 0; + z-index: 2; + height: 100%; + background: color-mix(in srgb, var(--toggle-accent) 20%, transparent); + border-inline: 1px solid var(--toggle-accent); + pointer-events: none; +} + +.histogram-tooltip { + position: fixed; + z-index: 10; + max-width: 260px; + padding: 3px 6px; + border: 1px solid var(--background-darkgray); + border-radius: 3px; + background: var(--background-dark); + color: var(--text-primary, inherit); + font-size: 0.7rem; + line-height: 1.3; + white-space: pre-line; + pointer-events: none; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} + +.histogram-axis-labels { + position: absolute; + left: 0; + bottom: 1px; + width: 100%; + height: 14px; + pointer-events: none; +} + +.histogram-axis-label { + position: absolute; + color: var(--text-secondary); + font-size: 0.65rem; + white-space: nowrap; +} + +.histogram-axis-label.anchor-start { transform: translateX(0); } + +.histogram-axis-label.anchor-middle { transform: translateX(-50%); } + +.histogram-axis-label.anchor-end { transform: translateX(-100%); } + +.histogram-hit { fill: transparent; cursor: pointer; } + +.histogram-bar-normal { fill: var(--toggle-accent); } + +.histogram-bar-warning { fill: var(--clr-yellow); } + +.histogram-bar-error { fill: var(--clr-red); } + +.histogram-cat-0 { fill: #56b4e9; } + +.histogram-cat-1 { fill: #e69f00; } + +.histogram-cat-2 { fill: #009e73; } + +.histogram-cat-3 { fill: #cc79a7; } + +.histogram-cat-other { fill: var(--text-secondary); } + +.histogram-bin-cursor { + fill: none; + stroke: var(--toggle-accent); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; + pointer-events: none; +} + +.histogram-find-marker { + fill: var(--clr-find); + stroke: var(--background-darkgray); + stroke-width: 0.5; + pointer-events: none; +} + +.histogram-anomaly { + fill: none; + stroke: var(--clr-statusbar); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; + pointer-events: none; +} + +.histogram-marker-selected { + stroke: var(--toggle-accent); + stroke-width: 1.5; + stroke-dasharray: 4 2; + vector-effect: non-scaling-stroke; + pointer-events: none; +} + +.histogram-axis-baseline, +.histogram-axis-tick { + stroke: var(--background-darkgray); + stroke-width: 1; +} + +.histogram-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-secondary); + font-style: italic; +} + +.histogram-status { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (forced-colors: active) { + .histogram-bar-normal { fill: CanvasText; } + + .histogram-bar-warning { fill: url(#histogram-hatch-warning); } + + .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-cat-0 { fill: url(#histogram-hatch-cat-0); } + + .histogram-cat-1 { fill: url(#histogram-hatch-cat-1); } + + .histogram-cat-2 { fill: url(#histogram-hatch-cat-2); } + + .histogram-cat-3 { fill: url(#histogram-hatch-cat-3); } + + .histogram-find-marker { fill: Highlight; stroke: CanvasText; } +} diff --git a/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.js b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.js new file mode 100644 index 000000000..12640eca4 --- /dev/null +++ b/src/EventLogExpert.UI/LogTable/Histogram/HistogramPane.razor.js @@ -0,0 +1,421 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +const histogramState = { + appliedScrollLeft: 0, + controller: null, + dotNetRef: null, + inFlight: false, + pending: null, + resizeObserver: null, + scrollRaf: 0, + session: 0, + wheelRaf: 0, + wheelZoomDir: 0, + wheelCursorFraction: 0.5, + pointerId: null, + dragStartX: 0, + dragActive: false, + dragShift: false, + dragCtrl: false, + clickTimer: 0, + clickPendingX: 0, + tooltipRaf: 0, + navToken: 0 +}; + +// Latest-only backpressure: keep one interop call in flight and dispatch only the newest input when it settles, so fast pan/zoom can't queue stale renders. +function requestInterop(payload) { + if (histogramState.inFlight) { + histogramState.pending = payload; + + return; + } + + runInterop(payload); +} + +function runInterop(payload) { + const ref = histogramState.dotNetRef; + + if (ref == null) { + return; + } + + // histogramState is module-global, so the session token makes a disposed pane's late promise a no-op instead of clobbering a new pane's state. + const session = histogramState.session; + histogramState.inFlight = true; + const call = payload.kind === 'zoom' + ? ref.invokeMethodAsync('OnHistogramZoomed', payload.zoomIn, payload.cursorFraction, payload.token) + : ref.invokeMethodAsync('OnHistogramPanned', payload.fraction, payload.token); + + call.catch(() => { }).finally(() => { + if (session !== histogramState.session) { + return; + } + + histogramState.inFlight = false; + const next = histogramState.pending; + histogramState.pending = null; + + if (next) { runInterop(next); } + }); +} + +export function initHistogram(dotNetRef) { + disposeHistogram(); + + const scroll = document.querySelector('.histogram-pane .histogram-scroll'); + + if (scroll == null) { + return; + } + + histogramState.dotNetRef = dotNetRef; + histogramState.controller = new AbortController(); + const signal = histogramState.controller.signal; + + scroll.addEventListener('scroll', () => onScroll(scroll), { signal, passive: true }); + scroll.addEventListener('wheel', (e) => onWheel(scroll, e), { signal, passive: false }); + scroll.addEventListener('keydown', onKeydownPreventDefault, { signal }); + scroll.addEventListener('pointerdown', (e) => onPointerDown(scroll, e), { signal }); + scroll.addEventListener('pointermove', (e) => onPointerMove(scroll, e), { signal }); + scroll.addEventListener('pointerup', (e) => onPointerUp(scroll, e), { signal }); + scroll.addEventListener('pointercancel', () => cancelDrag(scroll), { signal }); + scroll.addEventListener('pointerleave', hideTooltip, { signal }); + scroll.addEventListener('contextmenu', (e) => onContextMenu(scroll, e), { signal }); + scroll.addEventListener('keydown', (e) => { if (e.key === 'Escape') { cancelDrag(scroll); } }, { signal }); + + histogramState.resizeObserver = new ResizeObserver(() => report(scroll)); + histogramState.resizeObserver.observe(scroll); + report(scroll); +} + +const DragThresholdPx = 3; +const MinDragZoomPx = 12; +const DoubleClickMs = 250; +const DoubleClickTolerancePx = 24; + +function plotArea(scroll) { + return scroll.getBoundingClientRect(); +} + +function isOnPlot(e) { + return e.target instanceof Element && e.target.closest('.histogram-viewport') != null; +} + +function onPointerDown(scroll, e) { + if (e.button !== 0 || !isOnPlot(e)) { + return; + } + + hideTooltip(); + histogramState.pointerId = e.pointerId; + histogramState.dragStartX = e.clientX; + histogramState.dragShift = e.shiftKey; + histogramState.dragCtrl = e.ctrlKey; + histogramState.dragActive = false; + scroll.setPointerCapture?.(e.pointerId); +} + +function onContextMenu(scroll, e) { + const ref = histogramState.dotNetRef; + + if (ref == null || !isOnPlot(e)) { + return; + } + + e.preventDefault(); + cancelDrag(scroll); + cancelPendingClick(); + ref.invokeMethodAsync('OnHistogramUndo').catch(() => { }); +} + +function onPointerMove(scroll, e) { + if (histogramState.pointerId != null && e.pointerId === histogramState.pointerId) { + if (!histogramState.dragActive && Math.abs(e.clientX - histogramState.dragStartX) > DragThresholdPx) { + histogramState.dragActive = true; + + cancelPendingClick(); + } + + if (histogramState.dragActive) { + updateSelection(scroll, e.clientX); + e.preventDefault(); + } + + return; + } + + scheduleTooltip(scroll, e.clientX, e.clientY, e.target); +} + +function onPointerUp(scroll, e) { + if (histogramState.pointerId == null || e.pointerId !== histogramState.pointerId) { + return; + } + + const ref = histogramState.dotNetRef; + const wasDrag = histogramState.dragActive; + const shift = histogramState.dragShift; + const ctrl = histogramState.dragCtrl; + const rect = plotArea(scroll); + const startX = histogramState.dragStartX; + cancelDrag(scroll); + + if (ref == null || rect.width <= 0) { + return; + } + + // Clamp both endpoints to the plot before enforcing the minimum: a drag that travels >=MinDragZoomPx in raw pointer space + // but lies mostly outside the plot (near an edge) can select fewer than MinDragZoomPx on-plot pixels, and must fall through to a no-op click rather than zoom to a sliver. + const dragStartPx = clamp(startX, rect.left, rect.left + rect.width); + const dragEndPx = clamp(e.clientX, rect.left, rect.left + rect.width); + + if (wasDrag && Math.abs(dragEndPx - dragStartPx) >= MinDragZoomPx) { + const startFraction = (dragStartPx - rect.left) / rect.width; + const endFraction = (dragEndPx - rect.left) / rect.width; + ref.invokeMethodAsync('OnHistogramDragSelected', Math.min(startFraction, endFraction), Math.max(startFraction, endFraction), shift).catch(() => { }); + + return; + } + + const fraction = clamp01((e.clientX - rect.left) / rect.width); + + if (ctrl) { + cancelPendingClick(); + ref.invokeMethodAsync('OnHistogramScopeBin', fraction).catch(() => { }); + + return; + } + + arbitrateClick(ref, e.clientX); +} + +function arbitrateClick(ref, clientX) { + if (histogramState.clickTimer) { + clearTimeout(histogramState.clickTimer); + histogramState.clickTimer = 0; + + if (Math.abs(clientX - histogramState.clickPendingX) <= DoubleClickTolerancePx) { + ref.invokeMethodAsync('OnHistogramReset').catch(() => { }); + + return; + } + } + + histogramState.clickPendingX = clientX; + histogramState.clickTimer = setTimeout(() => { histogramState.clickTimer = 0; }, DoubleClickMs); +} + +function cancelPendingClick() { + if (histogramState.clickTimer) { + clearTimeout(histogramState.clickTimer); + histogramState.clickTimer = 0; + } +} + +function updateSelection(scroll, clientX) { + const selection = scroll.querySelector('.histogram-selection'); + + if (selection == null) { + return; + } + + const rect = plotArea(scroll); + const start = clamp(histogramState.dragStartX - rect.left, 0, rect.width); + const current = clamp(clientX - rect.left, 0, rect.width); + selection.style.left = `${Math.min(start, current)}px`; + selection.style.width = `${Math.abs(current - start)}px`; + selection.hidden = false; +} + +function cancelDrag(scroll) { + if (histogramState.pointerId != null) { + scroll.releasePointerCapture?.(histogramState.pointerId); + } + + histogramState.pointerId = null; + histogramState.dragActive = false; + const selection = scroll.querySelector('.histogram-selection'); + if (selection) { selection.hidden = true; } +} + +function scheduleTooltip(scroll, clientX, clientY, target) { + if (histogramState.tooltipRaf) { + return; + } + + histogramState.tooltipRaf = requestAnimationFrame(() => { + histogramState.tooltipRaf = 0; + const tooltip = document.querySelector('.histogram-pane .histogram-tooltip'); + + if (tooltip == null) { + return; + } + + const group = target instanceof Element ? target.closest('[data-tip]') : null; + + if (group == null) { + tooltip.hidden = true; + + return; + } + + tooltip.textContent = group.getAttribute('data-tip'); + tooltip.style.left = `${clientX + 12}px`; + tooltip.style.top = `${clientY + 12}px`; + tooltip.hidden = false; + }); +} + +function hideTooltip() { + const tooltip = document.querySelector('.histogram-pane .histogram-tooltip'); + if (tooltip) { tooltip.hidden = true; } +} + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function clamp01(value) { + return clamp(value, 0, 1); +} + +function report(scroll) { + const ref = histogramState.dotNetRef; + if (ref && scroll.isConnected) { + ref.invokeMethodAsync('OnHistogramResized', Math.round(scroll.clientWidth), Math.round(scroll.clientHeight)) + .catch(() => { }); + } +} + +function onScroll(scroll) { + if (histogramState.scrollRaf) { + return; + } + + // Capture the generation at schedule time (not at dispatch): if an undo bumps it before this rAF drains, the pan is stamped stale and the .NET side no-ops it. + const token = histogramState.navToken; + + histogramState.scrollRaf = requestAnimationFrame(() => { + histogramState.scrollRaf = 0; + const ref = histogramState.dotNetRef; + + if (!ref || !scroll.isConnected || scroll.scrollWidth <= 0) { + return; + } + + if (Math.abs(scroll.scrollLeft - histogramState.appliedScrollLeft) < 1) { + return; + } + + histogramState.appliedScrollLeft = scroll.scrollLeft; + requestInterop({ kind: 'pan', fraction: scroll.scrollLeft / scroll.scrollWidth, token }); + }); +} + +function onWheel(scroll, e) { + const ref = histogramState.dotNetRef; + + if (ref == null) { + return; + } + + if (e.shiftKey || e.deltaX !== 0) { + if (scroll.scrollWidth > scroll.clientWidth) { + scroll.scrollLeft += e.deltaY !== 0 ? e.deltaY : e.deltaX; + e.preventDefault(); + } + + return; + } + + const rect = scroll.getBoundingClientRect(); + histogramState.wheelCursorFraction = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0.5; + histogramState.wheelZoomDir = e.deltaY < 0 ? 1 : -1; + e.preventDefault(); + + if (histogramState.wheelRaf) { + return; + } + + // Capture the generation at schedule time so an undo before this rAF drains stamps the zoom stale (the .NET side then no-ops it). + const token = histogramState.navToken; + + histogramState.wheelRaf = requestAnimationFrame(() => { + histogramState.wheelRaf = 0; + + if (histogramState.wheelZoomDir !== 0) { + requestInterop({ kind: 'zoom', zoomIn: histogramState.wheelZoomDir > 0, cursorFraction: histogramState.wheelCursorFraction, token }); + histogramState.wheelZoomDir = 0; + } + }); +} + +function onKeydownPreventDefault(e) { + const navigationKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'PageUp', 'PageDown', '+', '=', '-', '_']; + + if (navigationKeys.includes(e.key)) { + e.preventDefault(); + } +} + +export function applyView(startFraction, navToken) { + // Adopt the .NET generation so pan/zoom scheduled after this point is stamped current; anything scheduled before an undo keeps its stale token and is rejected. + histogramState.navToken = navToken; + + const scroll = document.querySelector('.histogram-pane .histogram-scroll'); + + if (scroll == null) { + return; + } + + const target = Math.max(0, Math.round(startFraction * scroll.scrollWidth)); + scroll.scrollLeft = target; + // Read back the value the browser actually applied (it clamps to the max scroll offset) so the onScroll echo guard stays correct and can't misfire a spurious pan. + histogramState.appliedScrollLeft = scroll.scrollLeft; +} + +export function disposeHistogram() { + histogramState.session++; + + if (histogramState.resizeObserver) { + histogramState.resizeObserver.disconnect(); + histogramState.resizeObserver = null; + } + + if (histogramState.scrollRaf) { + cancelAnimationFrame(histogramState.scrollRaf); + histogramState.scrollRaf = 0; + } + + if (histogramState.wheelRaf) { + cancelAnimationFrame(histogramState.wheelRaf); + histogramState.wheelRaf = 0; + } + + if (histogramState.tooltipRaf) { + cancelAnimationFrame(histogramState.tooltipRaf); + histogramState.tooltipRaf = 0; + } + + if (histogramState.clickTimer) { + clearTimeout(histogramState.clickTimer); + histogramState.clickTimer = 0; + } + + if (histogramState.controller) { + histogramState.controller.abort(); + histogramState.controller = null; + } + + histogramState.dotNetRef = null; + histogramState.appliedScrollLeft = 0; + histogramState.wheelZoomDir = 0; + histogramState.inFlight = false; + histogramState.pending = null; + histogramState.pointerId = null; + histogramState.dragActive = false; + histogramState.navToken = 0; +} diff --git a/src/EventLogExpert.UI/LogTable/Histogram/HistogramTrackCap.cs b/src/EventLogExpert.UI/LogTable/Histogram/HistogramTrackCap.cs new file mode 100644 index 000000000..fcaab7c41 --- /dev/null +++ b/src/EventLogExpert.UI/LogTable/Histogram/HistogramTrackCap.cs @@ -0,0 +1,20 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.UI.LogTable.Histogram; + +internal static class HistogramTrackCap +{ + // Blink/WebView2 clamps a layout box's width near 33.55M px; staying comfortably under keeps the virtual scroll track representable so scrollWidth == clientWidth / WindowFraction holds and the scrollbar can still reach the final bins. + internal const int MaxTrackWidthPx = 30_000_000; + + // The fewest window bins whose CSS scroll track (clientWidth / (windowBins / totalBins)) still fits within MaxTrackWidthPx at the given viewport width; a deeper zoom would overflow the browser's layout cap and clamp scrollWidth. + internal static int MinBinsForWidth(int viewportWidthPx, int totalBins) + { + if (viewportWidthPx <= 0 || totalBins <= 0) { return 1; } + + int floorBins = (int)Math.Ceiling((double)viewportWidthPx * totalBins / MaxTrackWidthPx); + + return Math.Clamp(floorBins, 1, totalBins); + } +} diff --git a/src/EventLogExpert.UI/LogTable/LogTablePane.Find.cs b/src/EventLogExpert.UI/LogTable/LogTablePane.Find.cs index d8d0ebbad..6dabb1c09 100644 --- a/src/EventLogExpert.UI/LogTable/LogTablePane.Find.cs +++ b/src/EventLogExpert.UI/LogTable/LogTablePane.Find.cs @@ -45,6 +45,9 @@ public sealed partial class LogTablePane private int FindCurrentOrdinal => _findCurrentIndex >= 0 ? _findCurrentIndex + 1 : 0; + [Inject] + private IFindMarkerSource FindMarkerSource { get; init; } = null!; + private int FindMatchCount => _findMatches.Count; // Renders Blazor-escaped segments (never MarkupString) so event text can't inject markup; capped at MaxMarksPerCell so a pathological cell can't explode the DOM. @@ -96,6 +99,8 @@ private void ClearFindMatches() _findCurrentLocator = null; _findScanning = false; _findWrapAnnouncement = string.Empty; + + FindMarkerSource.Clear(); } private Task CloseFind() @@ -149,6 +154,8 @@ private void DisposeFind() _findDebounceCts?.Cancel(); _findDebounceCts?.Dispose(); _findDebounceCts = null; + + FindMarkerSource.Clear(); } private int FindAnchorIndex() @@ -269,7 +276,23 @@ private void PruneFindGroupOwnershipOnContextChange() } } - private void PublishFindMatches(List matches) + // Hand the current match timestamps (owner-tagged, ascending) to the timeline; collected free during the scan, the histogram re-bins them against its current window. + private void PublishFindMarks(List matchTicks) + { + if (_currentTable is not { } table) + { + FindMarkerSource.Clear(); + + return; + } + + long[] sorted = [.. matchTicks]; + Array.Sort(sorted); + + FindMarkerSource.Publish(table.Id, sorted); + } + + private void PublishFindMatches(List matches, List matchTicks) { EventLocator? priorLocator = _findCurrentLocator; @@ -281,6 +304,7 @@ private void PublishFindMatches(List matches) ResolveCurrentMatchAfterScan(priorLocator); _findScrollToCurrentOnRender = _findCurrentIndex >= 0; + PublishFindMarks(matchTicks); RequestFindRender(); } @@ -385,13 +409,15 @@ private async Task RunFindScanAsync( CancellationToken token) { List? matches = null; + List? matchTicks = null; try { - matches = await Task.Run( + (matches, matchTicks) = await Task.Run( () => { var found = new List(); + var foundTicks = new List(); int total = view.Count; for (int start = 0; start < total; start += FindChunkSize) @@ -406,11 +432,12 @@ private async Task RunFindScanAsync( if (EventFindMatcher.RowMatches(row.Lean, columns, timeZone, query, caseSensitive, wholeWord)) { found.Add(row.Loc); + foundTicks.Add(row.Lean.TimeCreated.Ticks); } } } - return found; + return (found, foundTicks); }, token); } @@ -434,7 +461,7 @@ await InvokeAsync(() => return; } - if (matches is null) + if (matches is null || matchTicks is null) { _findScanning = false; RequestFindRender(); @@ -442,7 +469,7 @@ await InvokeAsync(() => return; } - PublishFindMatches(matches); + PublishFindMatches(matches, matchTicks); }); } catch (ObjectDisposedException) { /* Component torn down mid-publish; nothing to update. */ } diff --git a/src/EventLogExpert.UI/Menu/MenuBar.razor.cs b/src/EventLogExpert.UI/Menu/MenuBar.razor.cs index f5b277d62..e4a813e4d 100644 --- a/src/EventLogExpert.UI/Menu/MenuBar.razor.cs +++ b/src/EventLogExpert.UI/Menu/MenuBar.razor.cs @@ -8,6 +8,7 @@ using EventLogExpert.Runtime.EventLog; using EventLogExpert.Runtime.Export; using EventLogExpert.Runtime.FilterPane; +using EventLogExpert.Runtime.Histogram; using EventLogExpert.Runtime.LogTable; using EventLogExpert.Runtime.Menu; using EventLogExpert.Runtime.Settings; @@ -52,6 +53,9 @@ public sealed partial class MenuBar [Inject] private IStateSelection HasActiveLogs { get; init; } = null!; + [Inject] + private IStateSelection HistogramVisible { get; init; } = null!; + [Inject] private IStateSelection IsGroupDescending { get; init; } = null!; @@ -114,6 +118,7 @@ protected override void OnInitialized() ContinuouslyUpdate.Select(state => state.ContinuouslyUpdate); FilterPaneIsEnabled.Select(state => state.IsEnabled); HasActiveLogs.Select(state => state.EventTables.Any(table => !table.IsCombined)); + HistogramVisible.Select(state => state.IsVisible); IsGroupDescending.Select(state => state.IsGroupDescending); IsGrouping.Select(state => state.GroupBy is not null); @@ -278,6 +283,7 @@ private IReadOnlyList BuildView() bool isContinuouslyUpdating = ContinuouslyUpdate.Value; bool isGrouping = IsGrouping.Value; bool isGroupDescending = IsGroupDescending.Value; + bool isHistogramVisible = HistogramVisible.Value; return [ @@ -308,6 +314,11 @@ private IReadOnlyList BuildView() () => Actions.SetAllGroupsCollapsed(true), isEnabled: isGrouping, disabledReason: isGrouping ? null : GroupDisabledReason), + MenuItem.Separator(), + MenuItem.Item( + "Timeline", + () => Actions.SetHistogramVisible(!isHistogramVisible), + isChecked: isHistogramVisible), ]; } diff --git a/src/EventLogExpert.UI/_Imports.razor b/src/EventLogExpert.UI/_Imports.razor index 2784efd92..b0c561bdb 100644 --- a/src/EventLogExpert.UI/_Imports.razor +++ b/src/EventLogExpert.UI/_Imports.razor @@ -39,6 +39,7 @@ @using EventLogExpert.UI.FilterPane @using EventLogExpert.UI.Inputs @using EventLogExpert.UI.LogTable +@using EventLogExpert.UI.LogTable.Histogram @using EventLogExpert.UI.Menu @using EventLogExpert.UI.Modal @using EventLogExpert.UI.StatusBar diff --git a/src/EventLogExpert/Adapters/Clipboard/MauiClipboardService.cs b/src/EventLogExpert/Adapters/Clipboard/MauiClipboardService.cs index 1b53ffa01..6b69de90d 100644 --- a/src/EventLogExpert/Adapters/Clipboard/MauiClipboardService.cs +++ b/src/EventLogExpert/Adapters/Clipboard/MauiClipboardService.cs @@ -102,9 +102,6 @@ private static string FormatXmlForCopy(string xml) } } - private static string GetLogShortName(string owningLog) => - owningLog[(owningLog.LastIndexOf('\\') + 1)..]; - private static ResolvedEvent? ResolveEntry(SelectionEntry? entry, LogTableState logTable) { if (entry?.CurrentHandle is not { } handle) { return null; } @@ -138,7 +135,7 @@ private void AppendFormattedEvent( builder.Append($"\"{@event.ActivityId}\" "); break; case ColumnName.Log: - builder.Append($"\"{GetLogShortName(@event.OwningLog)}\" "); + builder.Append($"\"{OwningLogDisplay.ShortName(@event.OwningLog)}\" "); break; case ColumnName.ComputerName: builder.Append($"\"{@event.ComputerName}\" "); diff --git a/src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs b/src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs index 3892173c6..eed6da901 100644 --- a/src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs +++ b/src/EventLogExpert/Adapters/Menu/MauiMenuActionService.cs @@ -13,6 +13,7 @@ using EventLogExpert.Runtime.Export; using EventLogExpert.Runtime.FilterLibrary; using EventLogExpert.Runtime.FilterPane; +using EventLogExpert.Runtime.Histogram; using EventLogExpert.Runtime.LogTable; using EventLogExpert.Runtime.Menu; using EventLogExpert.Runtime.Modal; @@ -43,6 +44,7 @@ public sealed class MauiMenuActionService( IEventLogCommands eventLogCommands, IFilterLibraryCommands filterLibraryCommands, IFilterPaneCommands filterPaneCommands, + IHistogramCommands histogramCommands, ILogTableCommands logTableCommands, IClipboardService clipboardService, IAlertDialogService dialogService, @@ -72,6 +74,7 @@ public sealed class MauiMenuActionService( private readonly IFilterLibraryCommands _filterLibraryCommands = filterLibraryCommands; private readonly IFilterPaneCommands _filterPaneCommands = filterPaneCommands; private readonly IFolderPickerService _folderPickerService = folderPickerService; + private readonly IHistogramCommands _histogramCommands = histogramCommands; private readonly SemaphoreSlim _logNamesLock = new(1, 1); private readonly ILogTableCommands _logTableCommands = logTableCommands; private readonly IState _logTableState = logTableState; @@ -433,6 +436,8 @@ public async Task SaveFiltersAsFilterSetAsync() public void SetContinuouslyUpdate(bool value) => _eventLogCommands.SetContinuouslyUpdate(value); + public void SetHistogramVisible(bool value) => _histogramCommands.SetVisible(value); + public Task ShowDebugLogsAsync() => TryOpenModalAsync(_modalCoordinator.OpenDebugLogsAsync, nameof(DebugLogModal)); diff --git a/src/EventLogExpert/Adapters/Settings/HistogramPreferencesAdapter.cs b/src/EventLogExpert/Adapters/Settings/HistogramPreferencesAdapter.cs new file mode 100644 index 000000000..e087ba1ea --- /dev/null +++ b/src/EventLogExpert/Adapters/Settings/HistogramPreferencesAdapter.cs @@ -0,0 +1,17 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Histogram; + +namespace EventLogExpert.Adapters.Settings; + +internal sealed class HistogramPreferencesAdapter : IHistogramPreferencesProvider +{ + private const string HistogramVisible = "histogram-visible"; + + public bool HistogramVisiblePreference + { + get => Preferences.Default.Get(HistogramVisible, true); + set => Preferences.Default.Set(HistogramVisible, value); + } +} diff --git a/src/EventLogExpert/DependencyInjection/MauiProgramExtensions.cs b/src/EventLogExpert/DependencyInjection/MauiProgramExtensions.cs index 6d0737d7c..a1ecaca03 100644 --- a/src/EventLogExpert/DependencyInjection/MauiProgramExtensions.cs +++ b/src/EventLogExpert/DependencyInjection/MauiProgramExtensions.cs @@ -19,6 +19,7 @@ using EventLogExpert.Runtime.Common.Threading; using EventLogExpert.Runtime.Database; using EventLogExpert.Runtime.DetailsPane; +using EventLogExpert.Runtime.Histogram; using EventLogExpert.Runtime.LogTable; using EventLogExpert.Runtime.Menu; using EventLogExpert.Runtime.Modal; @@ -112,6 +113,7 @@ public IServiceCollection AddMauiPreferenceAdapters() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); return services; diff --git a/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs b/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs index 11278dca3..340496d7f 100644 --- a/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs +++ b/tests/Shared/EventLogExpert.Eventing.TestUtils/Common/Events/LegacyEventColumnReader.cs @@ -36,6 +36,91 @@ public LegacyEventColumnReader(EventLogId logId, int generation, long contentVer public IReadOnlyList Pool => StringPool().Values; + public void BucketTimeTicksByEventId( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] targetIds, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(targetIds); + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + int slotCount = targetIds.Length + 1; + int otherSlot = targetIds.Length; + + for (int index = 0; index < _events.Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + long bucket = (_events[index].TimeCreated.Ticks - minTicks) / bucketSpanTicks; + int clamped = bucket < 0 ? 0 : bucket >= bucketCount ? bucketCount - 1 : (int)bucket; + int slot = otherSlot; + + for (int target = 0; target < targetIds.Length; target++) + { + if (_events[index].Id == targetIds[target]) { slot = target; break; } + } + + slotCounts[(clamped * slotCount) + slot]++; + } + } + + public void BucketTimeTicksByField( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + EventFieldId field, + string[] targetValues, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(targetValues); + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + int slotCount = targetValues.Length + 1; + int otherSlot = targetValues.Length; + + for (int index = 0; index < _events.Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + long bucket = (_events[index].TimeCreated.Ticks - minTicks) / bucketSpanTicks; + int clamped = bucket < 0 ? 0 : bucket >= bucketCount ? bucketCount - 1 : (int)bucket; + int slot = SlotForString(FieldValue(_events[index], field), targetValues, otherSlot); + slotCounts[(clamped * slotCount) + slot]++; + } + } + + public void BucketTimeTicksBySeverity( + ReadOnlySpan rankByPhysical, + long minTicks, + long bucketSpanTicks, + int bucketCount, + int[] slotCounts, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(slotCounts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + int slotCount = LevelSeverity.SlotCount; + + 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 = LevelSeverity.Slot(LevelSeverity.FromLevelName(_events[index].Level)); + slotCounts[(clamped * slotCount) + slot]++; + } + } + public void CopyGuidColumn(EventFieldId field, Guid[] values, bool[] hasValue) { ArgumentNullException.ThrowIfNull(values); @@ -116,6 +201,37 @@ public void CopyPoolIndexColumn(EventFieldId field, int[] poolIndices) } } + public void CountEventIds(ReadOnlySpan rankByPhysical, IDictionary counts, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(counts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + for (int index = 0; index < _events.Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + int id = _events[index].Id; + counts[id] = counts.TryGetValue(id, out int existing) ? existing + 1 : 1; + } + } + + public void CountFieldValues(ReadOnlySpan rankByPhysical, EventFieldId field, IDictionary counts, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(counts); + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + for (int index = 0; index < _events.Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + string? value = FieldValue(_events[index], field); + + if (string.IsNullOrEmpty(value)) { continue; } + + counts[value] = counts.TryGetValue(value, out int existing) ? existing + 1 : 1; + } + } + public EventDataFieldEnumerator EnumerateEventData(EventLocator locator) => new(GetEvent(locator).EventData); public UserDataFieldEnumerator EnumerateUserData(EventLocator locator) @@ -146,6 +262,8 @@ public EventFieldValue GetField(EventLocator locator, EventFieldId field) => public IReadOnlyList GetKeywords(EventLocator locator) => GetEvent(locator).Keywords; + public long GetTimeTicks(EventLocator locator) => GetEvent(locator).TimeCreated.Ticks; + public StructuredFieldResult GetUserData(EventLocator locator, string storageKey) => GetEvent(locator).TryGetUserDataValues(storageKey); @@ -156,6 +274,45 @@ public StructuredFieldResult GetUserData(EventLocator locator, string storageKey public bool TryGetEventData(EventLocator locator, string fieldName, out EventFieldValue value) => GetEvent(locator).EventData.TryGetValue(fieldName, out value); + public bool TryGetTimeTicksRange( + ReadOnlySpan rankByPhysical, + out long minTicks, + out long maxTicks, + CancellationToken cancellationToken) + { + ArgumentOutOfRangeException.ThrowIfNotEqual(rankByPhysical.Length, Count); + + long min = long.MaxValue; + long max = long.MinValue; + bool any = false; + + for (int index = 0; index < _events.Count; index++) + { + if (rankByPhysical[index] < 0) { continue; } + + long ticks = _events[index].TimeCreated.Ticks; + if (ticks < min) { min = ticks; } + if (ticks > max) { max = ticks; } + any = true; + } + + minTicks = any ? min : 0; + maxTicks = any ? max : 0; + + return any; + } + + private static string? FieldValue(ResolvedEvent @event, EventFieldId field) => field switch + { + EventFieldId.Source => @event.Source, + EventFieldId.TaskCategory => @event.TaskCategory, + EventFieldId.Opcode => @event.Opcode, + EventFieldId.LogName => @event.LogName, + EventFieldId.ComputerName => @event.ComputerName, + EventFieldId.OwningLog => @event.OwningLog, + _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Field is not a supported group-by dimension.") + }; + private static string? RawPoolString(ResolvedEvent resolvedEvent, EventFieldId field) => field switch { EventFieldId.Level => resolvedEvent.Level, @@ -171,6 +328,16 @@ public bool TryGetEventData(EventLocator locator, string fieldName, out EventFie _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Not a single pooled string column.") }; + private static int SlotForString(string? value, string[] targets, int otherSlot) + { + for (int slot = 0; slot < targets.Length; slot++) + { + if (string.Equals(value, targets[slot], StringComparison.Ordinal)) { return slot; } + } + + return otherSlot; + } + private LegacyStringPool BuildStringPool() { var indexByValue = new Dictionary(StringComparer.Ordinal); diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/TimeTicksHistogramTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/TimeTicksHistogramTests.cs new file mode 100644 index 000000000..602fd3824 --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/TimeTicksHistogramTests.cs @@ -0,0 +1,389 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; + +namespace EventLogExpert.Eventing.Tests.Common.Events; + +/// +/// Validates the survivor-scoped tick-histogram primitives ( +/// , +/// , +/// , the / +/// top-N count passes, and +/// ) over sealed, pending, and seal-boundary-straddling stores. +/// +public sealed class TimeTicksHistogramTests +{ + private const long ContentVersion = 1; + private const int Generation = 1; + + private static readonly EventLogId s_logId = EventLogId.Create(); + private static readonly int s_slotCount = LevelSeverity.SlotCount; + + [Fact] + public void BucketTimeTicksByEventId_AssignsSurvivorsToTargetIdSlotsElseOther() + { + var events = new[] { IdEvent(10), IdEvent(10), IdEvent(20), IdEvent(30) }; + var reader = new EventColumnStoreReader(s_logId, EventColumnStore.Build(events, Generation, ContentVersion)); + int[] targets = [10, 20]; + var slotCounts = new int[targets.Length + 1]; + + reader.BucketTimeTicksByEventId(AllSurvive(4), 0, 1, 1, targets, slotCounts, CancellationToken.None); + + Assert.Equal(2, slotCounts[0]); // id 10 + Assert.Equal(1, slotCounts[1]); // id 20 + Assert.Equal(1, slotCounts[2]); // Other (id 30) + } + + [Fact] + public void BucketTimeTicksByEventId_MatchesANegativeTargetId() + { + // A legitimately negative event id must still match its own negative target (the shared SlotForIndex is a pure match; + // absent pooled targets use int.MinValue, not -1, so this path is unaffected). + var events = new[] { IdEvent(-1), IdEvent(-1), IdEvent(5) }; + var reader = new EventColumnStoreReader(s_logId, EventColumnStore.Build(events, Generation, ContentVersion)); + int[] targets = [-1]; + var slotCounts = new int[targets.Length + 1]; + + reader.BucketTimeTicksByEventId(AllSurvive(3), 0, 1, 1, targets, slotCounts, CancellationToken.None); + + Assert.Equal(2, slotCounts[0]); // both id -1 rows matched target -1 + Assert.Equal(1, slotCounts[1]); // id 5 -> Other + } + + [Fact] + public void BucketTimeTicksByField_AssignsSurvivorsToTargetValueSlotsElseOther() + { + var events = new[] + { + EventWithSource(0, "A"), + EventWithSource(0, "A"), + EventWithSource(0, "B"), + EventWithSource(0, "C") + }; + var reader = new EventColumnStoreReader(s_logId, EventColumnStore.Build(events, Generation, ContentVersion)); + string[] targets = ["A", "B"]; + var slotCounts = new int[targets.Length + 1]; + + reader.BucketTimeTicksByField(AllSurvive(4), 0, 1, 1, EventFieldId.Source, targets, slotCounts, CancellationToken.None); + + Assert.Equal(2, slotCounts[0]); // A + Assert.Equal(1, slotCounts[1]); // B + Assert.Equal(1, slotCounts[2]); // Other (C is not a target) + } + + [Fact] + public void BucketTimeTicksByField_ClassifiesPendingRowsByValueStraddlingTheSealBoundary() + { + EventColumnStore store = EventColumnStore.Build(SourcesAtTick(0, "A", 4096), Generation, ContentVersion) + .Append([EventWithSource(0, "B"), EventWithSource(0, "Z")]); + var reader = new EventColumnStoreReader(s_logId, store); + string[] targets = ["A", "B"]; + var slotCounts = new int[targets.Length + 1]; + + reader.BucketTimeTicksByField(AllSurvive(4098), 0, 1, 1, EventFieldId.Source, targets, slotCounts, CancellationToken.None); + + Assert.Equal(4096, slotCounts[0]); // A (sealed) + Assert.Equal(1, slotCounts[1]); // B (pending) + Assert.Equal(1, slotCounts[2]); // Z -> Other (pending) + } + + [Fact] + public void BucketTimeTicksBySeverity_AccumulatesAcrossCallsRatherThanOverwriting() + { + IEventColumnReader reader = ReaderFor(0, 0); + var slotCounts = new int[s_slotCount]; + + reader.BucketTimeTicksBySeverity(AllSurvive(2), 0, 1, 1, slotCounts, CancellationToken.None); + reader.BucketTimeTicksBySeverity(AllSurvive(2), 0, 1, 1, slotCounts, CancellationToken.None); + + Assert.Equal(4, slotCounts[0]); + } + + [Fact] + public void BucketTimeTicksBySeverity_AssignsEachSurvivorToItsHalfOpenBucket() + { + IEventColumnReader reader = ReaderFor(0, 10, 20, 30); + var slotCounts = new int[4 * s_slotCount]; + + reader.BucketTimeTicksBySeverity(AllSurvive(4), minTicks: 0, bucketSpanTicks: 10, bucketCount: 4, slotCounts, CancellationToken.None); + + // No level is set, so every survivor lands in the unknown slot (0) of its own bucket. + Assert.Equal(1, slotCounts[0 * s_slotCount]); + Assert.Equal(1, slotCounts[1 * s_slotCount]); + Assert.Equal(1, slotCounts[2 * s_slotCount]); + Assert.Equal(1, slotCounts[3 * s_slotCount]); + } + + [Fact] + public void BucketTimeTicksBySeverity_ClampsOutOfRangeTicksToTheEndBuckets() + { + // Tick 5 falls below the domain (min 100); tick 1000 falls above the last bucket. + IEventColumnReader reader = ReaderFor(5, 1000); + var slotCounts = new int[3 * s_slotCount]; + + reader.BucketTimeTicksBySeverity(AllSurvive(2), minTicks: 100, bucketSpanTicks: 10, bucketCount: 3, slotCounts, CancellationToken.None); + + Assert.Equal(1, slotCounts[0 * s_slotCount]); + Assert.Equal(0, slotCounts[1 * s_slotCount]); + Assert.Equal(1, slotCounts[2 * s_slotCount]); + } + + [Fact] + public void BucketTimeTicksBySeverity_ClassifiesPendingRowsByLevelStraddlingTheSealBoundary() + { + EventColumnStore store = EventColumnStore.Build(EventsAtTick(0, 4096), Generation, ContentVersion) + .Append([EventWith(0, nameof(SeverityLevel.Error)), EventWith(0, nameof(SeverityLevel.Warning))]); + + Assert.Equal(4096, store.SealedCount); + Assert.Equal(4098, store.Count); + + var reader = new EventColumnStoreReader(s_logId, store); + var slotCounts = new int[s_slotCount]; + + reader.BucketTimeTicksBySeverity(AllSurvive(4098), 0, 1, 1, slotCounts, CancellationToken.None); + + Assert.Equal(4096, slotCounts[0]); + Assert.Equal(1, slotCounts[(int)SeverityLevel.Error]); + Assert.Equal(1, slotCounts[(int)SeverityLevel.Warning]); + } + + [Fact] + public void BucketTimeTicksBySeverity_CountsOnlySurvivors() + { + IEventColumnReader reader = ReaderFor(0, 0, 0, 0); + var slotCounts = new int[s_slotCount]; + int[] membership = [0, -1, 1, -1]; + + reader.BucketTimeTicksBySeverity(membership, 0, 1, 1, slotCounts, CancellationToken.None); + + Assert.Equal(2, slotCounts[0]); + } + + [Fact] + public void BucketTimeTicksBySeverity_GroupsSealedSurvivorsIntoTheirLevelSlot() + { + var events = new[] + { + EventWith(0, nameof(SeverityLevel.Critical)), + EventWith(0, nameof(SeverityLevel.Error)), + EventWith(0, nameof(SeverityLevel.Error)), + EventWith(0, nameof(SeverityLevel.Warning)), + EventWith(0, nameof(SeverityLevel.Information)), + EventWith(0, nameof(SeverityLevel.Verbose)), + EventWith(0, "Custom") + }; + + var reader = new EventColumnStoreReader(s_logId, EventColumnStore.Build(events, Generation, ContentVersion)); + var slotCounts = new int[s_slotCount]; + + reader.BucketTimeTicksBySeverity(AllSurvive(events.Length), 0, 1, 1, slotCounts, CancellationToken.None); + + Assert.Equal(1, slotCounts[(int)SeverityLevel.Critical]); + Assert.Equal(2, slotCounts[(int)SeverityLevel.Error]); + Assert.Equal(1, slotCounts[(int)SeverityLevel.Warning]); + Assert.Equal(1, slotCounts[(int)SeverityLevel.Information]); + Assert.Equal(1, slotCounts[(int)SeverityLevel.Verbose]); + Assert.Equal(1, slotCounts[0]); + } + + [Fact] + public void BucketTimeTicksBySeverity_PlacesABoundaryTickInTheUpperBucket() + { + IEventColumnReader reader = ReaderFor(9, 10); + var slotCounts = new int[2 * s_slotCount]; + + reader.BucketTimeTicksBySeverity(AllSurvive(2), 0, 10, 2, slotCounts, CancellationToken.None); + + Assert.Equal(1, slotCounts[0 * s_slotCount]); + Assert.Equal(1, slotCounts[1 * s_slotCount]); + } + + [Fact] + public void BucketTimeTicksBySeverity_ThrowsWhenMembershipLengthDoesNotMatchCount() + { + IEventColumnReader reader = ReaderFor(1, 2); + + Assert.Throws( + () => reader.BucketTimeTicksBySeverity(AllSurvive(1), 0, 1, 1, new int[s_slotCount], CancellationToken.None)); + } + + [Fact] + public void CountEventIds_TalliesBySurvivorId() + { + var events = new[] { IdEvent(10), IdEvent(10), IdEvent(20) }; + var reader = new EventColumnStoreReader(s_logId, EventColumnStore.Build(events, Generation, ContentVersion)); + var counts = new Dictionary(); + + reader.CountEventIds(AllSurvive(3), counts, CancellationToken.None); + + Assert.Equal(2, counts[10]); + Assert.Equal(1, counts[20]); + } + + [Fact] + public void CountFieldValues_CountsOnlySurvivorsAndAccumulates() + { + var events = new[] { EventWithSource(0, "A"), EventWithSource(0, "A"), EventWithSource(0, "A") }; + var reader = new EventColumnStoreReader(s_logId, EventColumnStore.Build(events, Generation, ContentVersion)); + int[] membership = [0, -1, 1]; + var counts = new Dictionary(StringComparer.Ordinal); + + reader.CountFieldValues(membership, EventFieldId.Source, counts, CancellationToken.None); + reader.CountFieldValues(membership, EventFieldId.Source, counts, CancellationToken.None); + + Assert.Equal(4, counts["A"]); // 2 survivors, tallied twice + } + + [Fact] + public void CountFieldValues_TalliesNonEmptyValuesAndSkipsEmpty() + { + var events = new[] + { + EventWithSource(0, "A"), + EventWithSource(0, "A"), + EventWithSource(0, "B"), + EventWithSource(0, string.Empty) + }; + var reader = new EventColumnStoreReader(s_logId, EventColumnStore.Build(events, Generation, ContentVersion)); + var counts = new Dictionary(StringComparer.Ordinal); + + reader.CountFieldValues(AllSurvive(4), EventFieldId.Source, counts, CancellationToken.None); + + Assert.Equal(2, counts["A"]); + Assert.Equal(1, counts["B"]); + Assert.False(counts.ContainsKey(string.Empty)); + } + + [Fact] + public void GetTimeTicks_ReturnsTheRowTimestampWithoutRehydrate() + { + IEventColumnReader reader = ReaderFor(500, 1500, 2500); + + Assert.Equal(1500, reader.GetTimeTicks(reader.LocatorAt(1))); + Assert.Equal(2500, reader.GetTimeTicks(reader.LocatorAt(2))); + } + + [Fact] + public void TryGetTimeTicksRange_IgnoresFilteredRows() + { + IEventColumnReader reader = ReaderFor(50, 10, 90, 30); + int[] membership = [0, -1, -1, 1]; + + bool any = reader.TryGetTimeTicksRange(membership, out long min, out long max, CancellationToken.None); + + Assert.True(any); + Assert.Equal(30, min); + Assert.Equal(50, max); + } + + [Fact] + public void TryGetTimeTicksRange_ReflectsAnOutOfOrderOlderPendingAppend() + { + EventColumnStore store = EventColumnStore.Build(EventsAt(100, 200), Generation, ContentVersion) + .Append(EventsAt(50)); + var reader = new EventColumnStoreReader(s_logId, store); + + reader.TryGetTimeTicksRange(AllSurvive(3), out long min, out long max, CancellationToken.None); + + Assert.Equal(50, min); + Assert.Equal(200, max); + } + + [Fact] + public void TryGetTimeTicksRange_ReturnsFalseWhenNoRowSurvives() + { + IEventColumnReader reader = ReaderFor(1, 2, 3); + int[] membership = [-1, -1, -1]; + + bool any = reader.TryGetTimeTicksRange(membership, out long min, out long max, CancellationToken.None); + + Assert.False(any); + Assert.Equal(0, min); + Assert.Equal(0, max); + } + + [Fact] + public void TryGetTimeTicksRange_ReturnsSurvivorMinAndMax() + { + IEventColumnReader reader = ReaderFor(50, 10, 90, 30); + + bool any = reader.TryGetTimeTicksRange(AllSurvive(4), out long min, out long max, CancellationToken.None); + + Assert.True(any); + Assert.Equal(10, min); + Assert.Equal(90, max); + } + + private static int[] AllSurvive(int count) => Enumerable.Range(0, count).ToArray(); + + private static ResolvedEvent[] EventsAt(params long[] ticks) + { + var events = new ResolvedEvent[ticks.Length]; + + for (int index = 0; index < ticks.Length; index++) + { + events[index] = new ResolvedEvent("TestLog", LogPathType.Channel) + { + Id = index, + TimeCreated = new DateTime(ticks[index], DateTimeKind.Utc) + }; + } + + return events; + } + + private static ResolvedEvent[] EventsAtTick(long ticks, int count) + { + var events = new ResolvedEvent[count]; + + for (int index = 0; index < count; index++) + { + events[index] = new ResolvedEvent("TestLog", LogPathType.Channel) + { + Id = index, + TimeCreated = new DateTime(ticks, DateTimeKind.Utc) + }; + } + + return events; + } + + private static ResolvedEvent EventWith(long ticks, string level) => + new("TestLog", LogPathType.Channel) + { + Id = 0, + TimeCreated = new DateTime(ticks, DateTimeKind.Utc), + Level = level + }; + + private static ResolvedEvent EventWithSource(long ticks, string source) => + new("TestLog", LogPathType.Channel) + { + Id = 0, + TimeCreated = new DateTime(ticks, DateTimeKind.Utc), + Source = source + }; + + private static ResolvedEvent IdEvent(int id) => + new("TestLog", LogPathType.Channel) + { + Id = id, + TimeCreated = new DateTime(0, DateTimeKind.Utc) + }; + + private static IEventColumnReader ReaderFor(params long[] ticks) => + new EventColumnStoreReader(s_logId, EventColumnStore.Build(EventsAt(ticks), Generation, ContentVersion)); + + private static ResolvedEvent[] SourcesAtTick(long ticks, string source, int count) + { + var events = new ResolvedEvent[count]; + + for (int index = 0; index < count; index++) { events[index] = EventWithSource(ticks, source); } + + return events; + } +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/FilterLenses/FilterLensCommandsTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/FilterLenses/FilterLensCommandsTests.cs index c1e3bcb41..96a7b9606 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/FilterLenses/FilterLensCommandsTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/FilterLenses/FilterLensCommandsTests.cs @@ -107,6 +107,22 @@ public void ShowEventsNearTime_MinValueAnchor_ClampsLowerBoundWithoutThrowing() action.Lens.Window.Before == DateTime.MinValue.AddSeconds(30))); } + [Fact] + public void ShowEventsNearTime_MinValueAnchorWithNegativeOffsetZone_RendersLabelWithoutThrowing() + { + var dispatcher = Substitute.For(); + var westOfUtc = TimeZoneInfo.CreateCustomTimeZone("test-5", TimeSpan.FromHours(-5), "test-5", "test-5"); + + // Guards the chip-label conversion for a degenerate default(DateTime) anchor against a non-UTC display zone: on + // net10 TimeZoneInfo.ConvertTimeFromUtc clamps an out-of-range result to DateTime.MinValue instead of throwing, so + // the label renders without crashing the context-menu handler. (Legacy .NET Framework threw for this exact case; a + // future retarget that reintroduced the throw would fail this test rather than crashing at runtime.) + new FilterLensCommands(dispatcher).ShowEventsNearTime( + DateTime.MinValue, TimeSpan.FromSeconds(30), westOfUtc); + + dispatcher.Received(1).Dispatch(Arg.Any()); + } + [Theory] [InlineData(0)] [InlineData(-30)] @@ -134,22 +150,6 @@ public void ShowEventsNearTime_SubSecondRadius_ThrowsAndDoesNotDispatch() dispatcher.DidNotReceive().Dispatch(Arg.Any()); } - [Fact] - public void ShowEventsNearTime_MinValueAnchorWithNegativeOffsetZone_RendersLabelWithoutThrowing() - { - var dispatcher = Substitute.For(); - var westOfUtc = TimeZoneInfo.CreateCustomTimeZone("test-5", TimeSpan.FromHours(-5), "test-5", "test-5"); - - // Guards the chip-label conversion for a degenerate default(DateTime) anchor against a non-UTC display zone: on - // net10 TimeZoneInfo.ConvertTimeFromUtc clamps an out-of-range result to DateTime.MinValue instead of throwing, so - // the label renders without crashing the context-menu handler. (Legacy .NET Framework threw for this exact case; a - // future retarget that reintroduced the throw would fail this test rather than crashing at runtime.) - new FilterLensCommands(dispatcher).ShowEventsNearTime( - DateTime.MinValue, TimeSpan.FromSeconds(30), westOfUtc); - - dispatcher.Received(1).Dispatch(Arg.Any()); - } - [Fact] public void ShowEventsNearTime_WithOriginLog_TagsLensWithThatLog() { @@ -266,4 +266,49 @@ public void ShowRelatedByRelatedActivityId_WithOriginLog_TagsLensWithThatLog() dispatcher.Received(1).Dispatch(Arg.Is(action => action.Lens.OriginLog == "LogA")); } + + [Fact] + public void ShowTimeRange_DispatchesPushWithInclusiveEnabledUtcWindow() + { + var dispatcher = Substitute.For(); + var start = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var end = new DateTime(2024, 1, 1, 13, 0, 0, DateTimeKind.Utc); + + new FilterLensCommands(dispatcher).ShowTimeRange(start, end, TimeZoneInfo.Utc); + + dispatcher.Received(1).Dispatch(Arg.Is(action => + action.Lens.Kind == LensKind.TimeWindow && + action.Lens.Window != null && + action.Lens.Window.IsEnabled && + action.Lens.Window.After == start && + action.Lens.Window.Before == end)); + } + + [Fact] + public void ShowTimeRange_NormalizesAReversedRange() + { + var dispatcher = Substitute.For(); + var earlier = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc); + var later = new DateTime(2024, 1, 1, 13, 0, 0, DateTimeKind.Utc); + + new FilterLensCommands(dispatcher).ShowTimeRange(later, earlier, TimeZoneInfo.Utc); + + dispatcher.Received(1).Dispatch(Arg.Is(action => + action.Lens.Window!.After == earlier && + action.Lens.Window.Before == later)); + } + + [Fact] + public void ShowTimeRange_WithOriginLog_TagsLensWithThatLog() + { + var dispatcher = Substitute.For(); + + new FilterLensCommands(dispatcher).ShowTimeRange( + new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc), + new DateTime(2024, 1, 1, 13, 0, 0, DateTimeKind.Utc), + TimeZoneInfo.Utc, + "LogA"); + + dispatcher.Received(1).Dispatch(Arg.Is(action => action.Lens.OriginLog == "LogA")); + } } diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/FilterLenses/FilterLensFactoryTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/FilterLenses/FilterLensFactoryTests.cs new file mode 100644 index 000000000..a7972b24c --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/FilterLenses/FilterLensFactoryTests.cs @@ -0,0 +1,56 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Common.Display; +using EventLogExpert.Runtime.FilterLenses; + +namespace EventLogExpert.Runtime.Tests.FilterLenses; + +public sealed class FilterLensFactoryTests +{ + [Fact] + public void ForTimeRange_CrossesDisplayedMidnight_LabelIncludesDates() + { + var after = new DateTime(2026, 7, 16, 23, 55, 0, DateTimeKind.Utc); + var before = new DateTime(2026, 7, 17, 0, 5, 0, DateTimeKind.Utc); + + var lens = FilterLensFactory.ForTimeRange(after, before, TimeZoneInfo.Utc); + + var afterLocal = after.ConvertTimeZone(TimeZoneInfo.Utc); + var beforeLocal = before.ConvertTimeZone(TimeZoneInfo.Utc); + Assert.Equal( + $"{afterLocal:d} {afterLocal:T} - {beforeLocal:d} {beforeLocal:T}", + lens.Label); + } + + [Fact] + public void ForTimeRange_DayBoundaryEvaluatedInDisplayZoneNotUtc() + { + // Both endpoints share a UTC calendar day (2026-07-16), but the +5h display zone pushes them across displayed midnight, so the label must still show dates. + var plusFive = TimeZoneInfo.CreateCustomTimeZone("t+5", TimeSpan.FromHours(5), "t+5", "t+5"); + var after = new DateTime(2026, 7, 16, 18, 0, 0, DateTimeKind.Utc); // +5 => 2026-07-16 23:00 + var before = new DateTime(2026, 7, 16, 20, 0, 0, DateTimeKind.Utc); // +5 => 2026-07-17 01:00 + + var lens = FilterLensFactory.ForTimeRange(after, before, plusFive); + + var afterLocal = after.ConvertTimeZone(plusFive); + var beforeLocal = before.ConvertTimeZone(plusFive); + Assert.NotEqual(afterLocal.Date, beforeLocal.Date); + Assert.Equal( + $"{afterLocal:d} {afterLocal:T} - {beforeLocal:d} {beforeLocal:T}", + lens.Label); + } + + [Fact] + public void ForTimeRange_WithinOneDisplayedDay_LabelShowsTimesOnly() + { + var after = new DateTime(2026, 7, 16, 10, 0, 0, DateTimeKind.Utc); + var before = new DateTime(2026, 7, 16, 14, 30, 0, DateTimeKind.Utc); + + var lens = FilterLensFactory.ForTimeRange(after, before, TimeZoneInfo.Utc); + + Assert.Equal( + $"{after.ConvertTimeZone(TimeZoneInfo.Utc):T} - {before.ConvertTimeZone(TimeZoneInfo.Utc):T}", + lens.Label); + } +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramAggregatorTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramAggregatorTests.cs new file mode 100644 index 000000000..9015a4acf --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramAggregatorTests.cs @@ -0,0 +1,237 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Runtime.Histogram; + +namespace EventLogExpert.Runtime.Tests.Histogram; + +public sealed class HistogramAggregatorTests +{ + private const int Errors = 2; + // Severity group indices in HistogramGroups.Severity (bottom to top): Other, Warnings, Errors. + private const int Other = 0; + private const int Warnings = 1; + + [Fact] + public void Aggregate_ClampsAStaleWindowIntoTheDomain() + { + var data = ErrorPerBin(binCount: 4, bucketSpanTicks: 10); + + HistogramRender render = HistogramAggregator.Aggregate(data, -100, 1_000_000, targetBins: 10); + + Assert.Equal(data.MinUtc.Ticks, render.WindowStartTicks); + Assert.Equal(data.MaxUtc.Ticks, render.WindowEndTicks); + Assert.Equal(4, render.WindowTotal); + } + + [Fact] + public void Aggregate_DownSamplesBaseBinsToTargetBins() + { + var data = ErrorPerBin(binCount: 4, bucketSpanTicks: 10); + + HistogramRender render = HistogramAggregator.Aggregate(data, 0, 39, targetBins: 2); + + Assert.Equal(2, render.Bins.Count); + Assert.Equal(2, render.Bins[0].GroupCounts[Errors]); + Assert.Equal(2, render.Bins[1].GroupCounts[Errors]); + Assert.Equal(4, render.WindowGroupTotals[Errors]); + } + + [Fact] + public void Aggregate_FewerThanTheMinimumSample_FlagNoAnomaly() + { + // Seven bins is below the 8-bin minimum, so even an obvious spike is not flagged (too small a sample to trust). + var data = BinsWithTotals(10, 1, 1, 1, 1, 1, 100, 1); + + HistogramRender render = HistogramAggregator.Aggregate(data, data.MinUtc.Ticks, data.MaxUtc.Ticks, targetBins: 1000); + + Assert.DoesNotContain(render.Bins, bin => bin.IsAnomaly); + } + + [Fact] + public void Aggregate_FlagsAStatisticalSpikeBinAsAnomaly() + { + // Nine quiet bins of 2 and one spike of 100: only the spike exceeds mean + 2 stddev of the visible bins. + var data = BinsWithTotals(10, 2, 2, 2, 2, 100, 2, 2, 2, 2, 2); + + HistogramRender render = HistogramAggregator.Aggregate(data, data.MinUtc.Ticks, data.MaxUtc.Ticks, targetBins: 1000); + + Assert.Single(render.Bins, bin => bin.IsAnomaly); + Assert.True(render.Bins[4].IsAnomaly); + } + + [Fact] + public void Aggregate_GroupByCategories_SumsEachCategorySlotAcrossBaseBins() + { + // Two categories (slots 0, 1) plus Other (slot 2), 2 base bins. ForCategories orders groups Other, cat0, cat1. + var groups = HistogramGroups.ForCategories(["a", "b"]); + var slots = new int[2 * 3]; + slots[(0 * 3) + 0] = 3; // bin0 category a + slots[(0 * 3) + 1] = 1; // bin0 category b + slots[(1 * 3) + 0] = 2; // bin1 category a + slots[(1 * 3) + 2] = 4; // bin1 Other + + var data = new HistogramData(slots, 3, 2, Utc(0), Utc(19), 10, 10, groups); + + HistogramRender render = HistogramAggregator.Aggregate(data, 0, 19, targetBins: 1); + + var bin = Assert.Single(render.Bins); + Assert.Equal(4, bin.GroupCounts[0]); // Other + Assert.Equal(5, bin.GroupCounts[1]); // category a + Assert.Equal(1, bin.GroupCounts[2]); // category b + Assert.Equal(10, bin.Total); + Assert.Equal([4, 5, 1], render.WindowGroupTotals); + } + + [Fact] + public void Aggregate_GroupsSlotsIntoErrorWarningAndNormalBands() + { + int slotCount = LevelSeverity.SlotCount; + var slots = new int[slotCount]; + slots[(int)SeverityLevel.Critical] = 2; + slots[(int)SeverityLevel.Error] = 3; + slots[(int)SeverityLevel.Warning] = 4; + slots[(int)SeverityLevel.Information] = 5; + slots[(int)SeverityLevel.Verbose] = 6; + slots[0] = 1; + + var data = Severity(slots, 1, Utc(0), Utc(9), 21, 10); + + HistogramRender render = HistogramAggregator.Aggregate(data, 0, 9, targetBins: 10); + + var bin = Assert.Single(render.Bins); + Assert.Equal(5, bin.GroupCounts[Errors]); + Assert.Equal(4, bin.GroupCounts[Warnings]); + Assert.Equal(12, bin.GroupCounts[Other]); + Assert.Equal(21, bin.Total); + Assert.Equal(21, render.WindowTotal); + Assert.Equal(5, render.WindowGroupTotals[Errors]); + Assert.Equal(4, render.WindowGroupTotals[Warnings]); + Assert.Equal(21, render.MaxBinTotal); + } + + [Fact] + public void Aggregate_PartitionsNonDivisibleBinCountsWithoutGapOrDoubleCount() + { + var data = ErrorPerBin(binCount: 5, bucketSpanTicks: 10); + + HistogramRender render = HistogramAggregator.Aggregate(data, 0, 49, targetBins: 3); + + // 5 base bins over 3 render bins partition as [0], [1,2], [3,4]; every base bin is counted exactly once. + Assert.Equal(3, render.Bins.Count); + Assert.Equal(1, render.Bins[0].GroupCounts[Errors]); + Assert.Equal(2, render.Bins[1].GroupCounts[Errors]); + Assert.Equal(2, render.Bins[2].GroupCounts[Errors]); + Assert.Equal(5, render.WindowGroupTotals[Errors]); + } + + [Fact] + public void Aggregate_RenderBinEndTicksAreInclusiveAndNonOverlapping() + { + // 4 base bins of 10 ticks over [0,39]; at high target-bins each render bar is one base bin. The inclusive end must be + // one tick before the next bar starts, so scope/click ranges and find-hit binning never leak the boundary event. + var data = ErrorPerBin(binCount: 4, bucketSpanTicks: 10); + + HistogramRender render = HistogramAggregator.Aggregate(data, 0, 39, targetBins: 1000); + + Assert.Equal(4, render.Bins.Count); + Assert.Equal(0, render.Bins[0].StartTicks); + Assert.Equal(9, render.Bins[0].EndTicks); + Assert.Equal(39, render.Bins[3].EndTicks); + + for (int index = 0; index + 1 < render.Bins.Count; index++) + { + Assert.True(render.Bins[index].EndTicks >= render.Bins[index].StartTicks); + Assert.Equal(render.Bins[index + 1].StartTicks - 1, render.Bins[index].EndTicks); + } + } + + [Fact] + public void Aggregate_ReportsBinSnappedWindowBounds() + { + // 20 base bins of 10 ticks each: an arbitrary window [111,188] snaps out to the containing bins [110,120)..[180,190), + // so the reported bounds are [110,189] - the same span the bars count, the axis labels, and Scope-to-range use. + var data = ErrorPerBin(binCount: 20, bucketSpanTicks: 10); + + HistogramRender render = HistogramAggregator.Aggregate(data, 111, 188, targetBins: 1000); + + Assert.Equal(110, render.WindowStartTicks); + Assert.Equal(189, render.WindowEndTicks); + } + + [Fact] + public void Aggregate_RestrictsToTheVisibleWindow() + { + var data = ErrorPerBin(binCount: 4, bucketSpanTicks: 10); + + HistogramRender render = HistogramAggregator.Aggregate(data, 10, 29, targetBins: 10); + + Assert.Equal(2, render.WindowGroupTotals[Errors]); + Assert.Equal(10, render.WindowStartTicks); + Assert.Equal(29, render.WindowEndTicks); + } + + [Fact] + public void Aggregate_ThrowsWhenTargetBinsIsNotPositive() + { + var data = ErrorPerBin(binCount: 2, bucketSpanTicks: 10); + + Assert.Throws(() => HistogramAggregator.Aggregate(data, 0, 19, targetBins: 0)); + } + + [Fact] + public void Aggregate_TracksTheTallestRenderedBin() + { + int slotCount = LevelSeverity.SlotCount; + var slots = new int[2 * slotCount]; + slots[(0 * slotCount) + (int)SeverityLevel.Error] = 1; + slots[(1 * slotCount) + (int)SeverityLevel.Error] = 7; + + var data = Severity(slots, 2, Utc(0), Utc(19), 8, 10); + + HistogramRender render = HistogramAggregator.Aggregate(data, 0, 19, targetBins: 10); + + Assert.Equal(7, render.MaxBinTotal); + } + + [Fact] + public void Aggregate_UniformBins_FlagNoAnomaly() + { + var data = BinsWithTotals(10, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5); + + HistogramRender render = HistogramAggregator.Aggregate(data, data.MinUtc.Ticks, data.MaxUtc.Ticks, targetBins: 1000); + + Assert.DoesNotContain(render.Bins, bin => bin.IsAnomaly); + } + + private static HistogramData BinsWithTotals(long bucketSpanTicks, params int[] totals) + { + int slotCount = LevelSeverity.SlotCount; + var slots = new int[totals.Length * slotCount]; + int total = 0; + + for (int bin = 0; bin < totals.Length; bin++) + { + slots[(bin * slotCount) + (int)SeverityLevel.Information] = totals[bin]; + total += totals[bin]; + } + + return Severity(slots, totals.Length, Utc(0), Utc((totals.Length * bucketSpanTicks) - 1), total, bucketSpanTicks); + } + + private static HistogramData ErrorPerBin(int binCount, long bucketSpanTicks) + { + int slotCount = LevelSeverity.SlotCount; + var slots = new int[binCount * slotCount]; + + for (int bin = 0; bin < binCount; bin++) { slots[(bin * slotCount) + (int)SeverityLevel.Error] = 1; } + + return Severity(slots, binCount, Utc(0), Utc((binCount * bucketSpanTicks) - 1), binCount, bucketSpanTicks); + } + + private static HistogramData Severity(int[] slots, int binCount, DateTime minUtc, DateTime maxUtc, int total, long bucketSpanTicks) => + new(slots, LevelSeverity.SlotCount, binCount, minUtc, maxUtc, total, bucketSpanTicks, HistogramGroups.Severity); + + private static DateTime Utc(long ticks) => new(ticks, DateTimeKind.Utc); +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramBuilderTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramBuilderTests.cs new file mode 100644 index 000000000..588942493 --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramBuilderTests.cs @@ -0,0 +1,327 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Runtime.Histogram; +using EventLogExpert.Runtime.LogTable; +using EventLogExpert.Runtime.Tests.TestUtils; + +namespace EventLogExpert.Runtime.Tests.Histogram; + +public sealed class HistogramBuilderTests +{ + private static readonly EventLogId s_logId = EventLogId.Create(); + + [Fact] + public void Build_AllEventsAtSameTime_ProducesASingleBucket() + { + var view = DisplayViewTestFactory.Build(s_logId, EventsAt(500, 500, 500)); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.Severity, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal(1, data!.BinCount); + Assert.Equal(LevelSeverity.SlotCount, data.SlotCount); + Assert.Equal(LevelSeverity.SlotCount, data.SlotCounts.Length); + Assert.Equal(3, data.SlotCounts[0]); + Assert.Equal(3, data.Total); + Assert.Equal(1, data.BucketSpanTicks); + Assert.Equal(data.MinUtc, data.MaxUtc); + Assert.Same(HistogramGroups.Severity, data.Groups); + } + + [Fact] + public void Build_CapsBucketCountAtMaxBuckets() + { + var view = DisplayViewTestFactory.Build(s_logId, EventsAt(0, 1_000_000)); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.Severity, maxBuckets: 8, CancellationToken.None); + + Assert.NotNull(data); + Assert.True(data!.BinCount <= 8); + Assert.Equal(data.BinCount * LevelSeverity.SlotCount, data.SlotCounts.Length); + } + + [Fact] + public void Build_CombinedView_GroupBySource_SumsByLogicalValueAcrossStores() + { + // "shared" appears in both child stores (each with its own string pool); the top-N resolution is by logical value, + // so the combined category count sums both stores rather than mis-classifying by a per-store pool index. + var first = DisplayViewTestFactory.Build(EventLogId.Create(), SourceEvents(("shared", 2), ("only-a", 1))); + var second = DisplayViewTestFactory.Build(EventLogId.Create(), SourceEvents(("shared", 3), ("only-b", 1))); + var combined = new CombinedColumnView([first, second], first.Context); + + 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(7, data.Total); + } + + [Fact] + public void Build_CombinedView_SumsCountsAndSpansAllChildViews() + { + var first = DisplayViewTestFactory.Build(EventLogId.Create(), EventsAt(0, 100)); + var second = DisplayViewTestFactory.Build(EventLogId.Create(), EventsAt(200, 300)); + var combined = new CombinedColumnView([first, second], first.Context); + + HistogramData? data = HistogramBuilder.Build(combined, HistogramDimension.Severity, maxBuckets: 10, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal(4, data!.Total); + Assert.Equal(new DateTime(0, DateTimeKind.Utc), data.MinUtc); + Assert.Equal(new DateTime(300, DateTimeKind.Utc), data.MaxUtc); + } + + [Fact] + public void Build_EmptyView_ReturnsNull() + { + var view = DisplayViewTestFactory.Build(s_logId, []); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.Severity, maxBuckets: 100, CancellationToken.None); + + Assert.Null(data); + } + + [Fact] + public void Build_FollowLatest_BucketsEverySurvivorOverTheSurvivorSpan() + { + var view = DisplayViewTestFactory.Build(s_logId, EventsAt(0, 100, 200, 300)); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.Severity, maxBuckets: 4, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal(4, data!.Total); + Assert.Equal(4, Sum(data.SlotCounts)); + Assert.Equal(new DateTime(0, DateTimeKind.Utc), data.MinUtc); + Assert.Equal(new DateTime(300, DateTimeKind.Utc), data.MaxUtc); + Assert.True(data.BucketSpanTicks >= 1); + } + + [Fact] + public void Build_GroupByEventId_LabelsCategoriesWithTheNumericIds() + { + var events = IdEvents((1000, 3), (2000, 2), (3000, 1)); + var view = DisplayViewTestFactory.Build(s_logId, events); + + 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(6, data.Total); + } + + [Fact] + public void Build_GroupByLog_EscalatesToFullPathWhenFileNameAndParentBothCollide() + { + var events = LogEvents( + (@"C:\logs\Security.evtx", 2), + (@"D:\logs\Security.evtx", 1)); + var view = DisplayViewTestFactory.Build(s_logId, events); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.Log, maxBuckets: 100, CancellationToken.None); + + 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); + } + + [Fact] + public void Build_GroupByLog_LabelsWithShortNameAndKeepsSameShortNameLogsSeparate() + { + var events = LogEvents( + (@"C:\logsA\Security.evtx", 3), + (@"C:\logsB\Security.evtx", 2), + ("System", 1)); + var view = DisplayViewTestFactory.Build(s_logId, events); + + 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(6, data.Total); + } + + [Fact] + public void Build_GroupBySource_FoldsValuesBeyondTheTopCapIntoOther() + { + var events = SourceEvents(("s1", 5), ("s2", 4), ("s3", 3), ("s4", 2), ("s5", 1), ("s6", 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); + } + + [Fact] + public void Build_GroupBySource_RanksTopCategoriesByCountDescending() + { + var events = SourceEvents(("apache", 3), ("nginx", 2), ("caddy", 1)); + var view = DisplayViewTestFactory.Build(s_logId, events); + + 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(6, data.Total); + } + + [Fact] + public void Build_RecordsPerSeverityCountsInTheBaseBuffer() + { + var events = new[] + { + EventAt(0, nameof(SeverityLevel.Critical)), + EventAt(0, nameof(SeverityLevel.Error)), + EventAt(0, nameof(SeverityLevel.Warning)), + EventAt(0, nameof(SeverityLevel.Information)) + }; + var view = DisplayViewTestFactory.Build(s_logId, events); + + HistogramData? data = HistogramBuilder.Build(view, HistogramDimension.Severity, maxBuckets: 100, CancellationToken.None); + + Assert.NotNull(data); + Assert.Equal(1, data!.BinCount); + Assert.Equal(1, data.SlotCounts[(int)SeverityLevel.Critical]); + Assert.Equal(1, data.SlotCounts[(int)SeverityLevel.Error]); + Assert.Equal(1, data.SlotCounts[(int)SeverityLevel.Warning]); + Assert.Equal(1, data.SlotCounts[(int)SeverityLevel.Information]); + Assert.Equal(4, data.Total); + } + + private static ResolvedEvent EventAt(long ticks, string level) => + new("TestLog", LogPathType.Channel) + { + Id = 0, + TimeCreated = new DateTime(ticks, DateTimeKind.Utc), + Level = level + }; + + private static ResolvedEvent[] EventsAt(params long[] ticks) + { + var events = new ResolvedEvent[ticks.Length]; + + for (int index = 0; index < ticks.Length; index++) + { + events[index] = new ResolvedEvent("TestLog", LogPathType.Channel) + { + Id = index, + TimeCreated = new DateTime(ticks[index], DateTimeKind.Utc) + }; + } + + return events; + } + + private static int GroupTotal(HistogramData data, int groupIndex) + { + int total = 0; + + for (int bin = 0; bin < data.BinCount; bin++) + { + int offset = bin * data.SlotCount; + + foreach (int slot in data.Groups[groupIndex].SlotIndices) { total += data.SlotCounts[offset + slot]; } + } + + return total; + } + + private static ResolvedEvent[] IdEvents(params (int Id, int Count)[] groups) + { + var events = new List(); + long ticks = 0; + + foreach ((int id, int count) in groups) + { + for (int index = 0; index < count; index++) + { + events.Add(new ResolvedEvent("TestLog", LogPathType.Channel) + { + Id = id, + TimeCreated = new DateTime(ticks++, DateTimeKind.Utc) + }); + } + } + + return [.. events]; + } + + private static ResolvedEvent[] LogEvents(params (string OwningLog, int Count)[] groups) + { + var events = new List(); + long ticks = 0; + + foreach ((string owningLog, int count) in groups) + { + for (int index = 0; index < count; index++) + { + events.Add(new ResolvedEvent(owningLog, LogPathType.Channel) + { + Id = 0, + TimeCreated = new DateTime(ticks++, DateTimeKind.Utc) + }); + } + } + + return [.. events]; + } + + private static ResolvedEvent[] SourceEvents(params (string Source, int Count)[] groups) + { + var events = new List(); + long ticks = 0; + + foreach ((string source, int count) in groups) + { + for (int index = 0; index < count; index++) + { + events.Add(new ResolvedEvent("TestLog", LogPathType.Channel) + { + Id = 0, + TimeCreated = new DateTime(ticks++, DateTimeKind.Utc), + Source = source + }); + } + } + + return [.. events]; + } + + private static int Sum(int[] counts) + { + int total = 0; + + foreach (int count in counts) { total += count; } + + return total; + } +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramGroupsTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramGroupsTests.cs new file mode 100644 index 000000000..352a3ce4e --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramGroupsTests.cs @@ -0,0 +1,43 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Histogram; + +namespace EventLogExpert.Runtime.Tests.Histogram; + +public sealed class HistogramGroupsTests +{ + [Fact] + public void ForCategories_CategoryKeyIsStableAcrossAReRank() + { + // The same logical value keeps the same key regardless of its rank position, so a hidden legend entry follows the + // category across a live-tail re-rank instead of aliasing whatever now sits at that slot. + var first = HistogramGroups.ForCategories(["a", "b"]); + var reranked = HistogramGroups.ForCategories(["b", "a"]); + + string keyWhenRankedFirst = first.First(group => group.Label == "a").Key; + string keyWhenRankedSecond = reranked.First(group => group.Label == "a").Key; + + Assert.Equal(keyWhenRankedFirst, keyWhenRankedSecond); + } + + [Fact] + public void ForCategories_SyntheticOtherKeyIsDistinctFromACategoryNamedOther() + { + // A dimension whose top value is literally "Other" produces two groups labeled "Other": the fold bucket and the real + // category. Their keys must differ so a legend toggle hides only one. + var groups = HistogramGroups.ForCategories(["Other", "x"]); + + Assert.Equal("Other", groups[0].Label); // synthetic fold + Assert.Equal("Other", groups[1].Label); // real category + Assert.NotEqual(groups[0].Key, groups[1].Key); + } + + [Fact] + public void Severity_GroupKeysAreDistinct() + { + var keys = HistogramGroups.Severity.Select(group => group.Key).ToArray(); + + Assert.Equal(keys.Length, keys.Distinct().Count()); + } +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramScaleTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramScaleTests.cs new file mode 100644 index 000000000..90f57ce40 --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramScaleTests.cs @@ -0,0 +1,122 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Histogram; + +namespace EventLogExpert.Runtime.Tests.Histogram; + +public sealed class HistogramScaleTests +{ + [Fact] + public void BarHeight_EmptyBucket_IsZero() + { + Assert.Equal(0, HistogramScale.BarHeight(0, 100, 50)); + } + + [Fact] + public void BarHeight_NonEmptyBucketBelowThePixelFloor_IsRaisedToOnePixel() + { + // A single event against a huge peak sqrt-scales below 1px; the floor keeps the bucket visible. + double height = HistogramScale.BarHeight(1, 1_000_000, 50); + + Assert.True(height >= 1); + } + + [Fact] + public void BarHeight_PeakBucket_FillsThePlot() + { + Assert.Equal(50, HistogramScale.BarHeight(100, 100, 50)); + } + + [Fact] + public void BarHeight_UsesSquareRootScaling() + { + // A quarter-count bucket reads at half height under sqrt (linear would read a quarter). + Assert.Equal(50, HistogramScale.BarHeight(25, 100, 100), precision: 6); + } + + [Fact] + public void BarHeight_ZeroMaxCountOrHeight_IsZero() + { + Assert.Equal(0, HistogramScale.BarHeight(5, 0, 50)); + Assert.Equal(0, HistogramScale.BarHeight(5, 100, 0)); + } + + [Fact] + public void StackedGroupHeights_DistributesLeftoverPixelsByLargestRemainder() + { + // Three equal counts over a 4px bar: the 1px floor gives each group 1px (3px), and the single leftover pixel is handed + // out by largest remainder, so the segments still sum to the bar height with every present group visible. + int[] heights = HistogramScale.StackedGroupHeights([1, 1, 1], 3, 4); + + Assert.Equal(4, heights[0] + heights[1] + heights[2]); + Assert.True(heights[0] >= 1); + Assert.True(heights[1] >= 1); + Assert.True(heights[2] >= 1); + } + + [Fact] + public void StackedGroupHeights_EmptyBin_IsZero() + { + Assert.Equal([0, 0, 0], HistogramScale.StackedGroupHeights([0, 0, 0], 100, 50)); + } + + [Fact] + public void StackedGroupHeights_FloorRepairPreservesTheSum() + { + // A rare group against a huge baseline: the proportional split rounds it to 0, the floor repair lifts it to 1 by + // stealing from the tallest group, and the total is unchanged. + int[] heights = HistogramScale.StackedGroupHeights([1000, 0, 1], 1001, 100); + int barTotal = (int)Math.Round(HistogramScale.BarHeight(1001, 1001, 100)); + + Assert.Equal(barTotal, heights[0] + heights[1] + heights[2]); + Assert.Equal(1, heights[2]); + Assert.Equal(0, heights[1]); + Assert.Equal(barTotal - 1, heights[0]); + } + + [Fact] + public void StackedGroupHeights_LeftoverPixelGoesToTheHighestRemainder() + { + // Counts (5,3,2) over a 7px bar: proportional shares are 3.5/2.1/1.4 -> floors 3/2/1 (6px), and the single leftover + // pixel goes to group 0 (the largest fractional remainder 0.5), yielding (4,2,1) - a non-tie case that pins selection. + int[] heights = HistogramScale.StackedGroupHeights([5, 3, 2], 10, 7); + + Assert.Equal(4, heights[0]); + Assert.Equal(2, heights[1]); + Assert.Equal(1, heights[2]); + } + + [Fact] + public void StackedGroupHeights_RareGroupAmongManyInAnother_StaysAtLeastOnePixel() + { + int[] heights = HistogramScale.StackedGroupHeights([1000, 0, 1], maxBinTotal: 1001, plotHeightPx: 100); + + Assert.True(heights[2] >= 1); + Assert.Equal(0, heights[1]); + Assert.True(heights[0] > heights[2]); + } + + [Fact] + public void StackedGroupHeights_SegmentsSumToTheScaledBarHeight() + { + int[] heights = HistogramScale.StackedGroupHeights([30, 15, 5], 50, 80); + + int expected = (int)Math.Round(HistogramScale.BarHeight(50, 50, 80)); + + Assert.Equal(expected, heights[0] + heights[1] + heights[2]); + Assert.True(heights[0] > 0); + Assert.True(heights[1] > 0); + Assert.True(heights[2] > 0); + } + + [Fact] + public void StackedGroupHeights_SingleGroup_TakesTheWholeBar() + { + int[] heights = HistogramScale.StackedGroupHeights([10, 0, 0], 10, 50); + + Assert.Equal(50, heights[0]); + Assert.Equal(0, heights[1]); + Assert.Equal(0, heights[2]); + } +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramSummaryTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramSummaryTests.cs new file mode 100644 index 000000000..ac026c0b4 --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Histogram/HistogramSummaryTests.cs @@ -0,0 +1,102 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Runtime.Histogram; + +namespace EventLogExpert.Runtime.Tests.Histogram; + +public sealed class HistogramSummaryTests +{ + [Fact] + public void RegionLabel_CitesRawTotalAndErrorAndWarningCounts() + { + int slotCount = LevelSeverity.SlotCount; + var slots = new int[3 * slotCount]; + slots[(0 * slotCount) + (int)SeverityLevel.Error] = 2; + slots[(1 * slotCount) + (int)SeverityLevel.Warning] = 9; + slots[(2 * slotCount) + (int)SeverityLevel.Information] = 4; + + var data = Severity( + slots, + 3, + new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2024, 1, 1, 3, 0, 0, DateTimeKind.Utc), + 15, + TimeSpan.FromHours(1).Ticks); + + string label = HistogramSummary.RegionLabel(data, TimeZoneInfo.Utc); + + Assert.Contains("15 events", label); + Assert.Contains("2 Errors", label); + Assert.Contains("9 Warnings", label); + } + + [Fact] + public void RegionLabel_CountsCriticalWithinTheErrorGroup() + { + int slotCount = LevelSeverity.SlotCount; + var slots = new int[slotCount]; + slots[(int)SeverityLevel.Critical] = 3; + slots[(int)SeverityLevel.Error] = 1; + + var data = Severity( + slots, + 1, + new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2024, 1, 1, 1, 0, 0, DateTimeKind.Utc), + 4, + TimeSpan.FromHours(1).Ticks); + + string label = HistogramSummary.RegionLabel(data, TimeZoneInfo.Utc); + + Assert.Contains("4 Errors", label); + } + + [Fact] + public void RegionLabel_NamesTopCategoriesForAGroupByDimension() + { + // Two categories over a single bin: 5 in category "chrome", 2 in "edge", plus 1 in Other (slot 2). + var groups = HistogramGroups.ForCategories(["chrome", "edge"]); + var slots = new[] { 5, 2, 1 }; + + var data = new HistogramData( + slots, + 3, + 1, + new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2024, 1, 1, 1, 0, 0, DateTimeKind.Utc), + 8, + TimeSpan.FromHours(1).Ticks, + groups); + + string label = HistogramSummary.RegionLabel(data, TimeZoneInfo.Utc); + + Assert.Contains("8 events", label); + Assert.Contains("5 chrome", label); + Assert.Contains("2 edge", label); + Assert.Contains("1 Other", label); + } + + [Fact] + public void WindowAnnouncement_ReportsTheVisibleRangeAndCounts() + { + var render = new HistogramRender( + [new HistogramRenderBin(0, 10, 15, [4, 9, 2])], + new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks, + new DateTime(2024, 1, 1, 3, 0, 0, DateTimeKind.Utc).Ticks, + 15, + 15, + [4, 9, 2]); + + string announcement = HistogramSummary.WindowAnnouncement(render, HistogramGroups.Severity, TimeZoneInfo.Utc); + + Assert.Contains("Showing", announcement); + Assert.Contains("15 events", announcement); + Assert.Contains("2 Errors", announcement); + Assert.Contains("9 Warnings", announcement); + } + + private static HistogramData Severity(int[] slots, int binCount, DateTime minUtc, DateTime maxUtc, int total, long bucketSpanTicks) => + new(slots, LevelSeverity.SlotCount, binCount, minUtc, maxUtc, total, bucketSpanTicks, HistogramGroups.Severity); +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TestSupport/LegacyEventColumnView.cs b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TestSupport/LegacyEventColumnView.cs index 6b8edc45d..9e0cc1124 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TestSupport/LegacyEventColumnView.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/LogTable/TestSupport/LegacyEventColumnView.cs @@ -21,6 +21,21 @@ internal sealed class LegacyEventColumnView( public IEventColumnReader Reader => _reader; + 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 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 BucketTimeTicksBySeverity(long minTicks, long bucketSpanTicks, int bucketCount, int[] slotCounts, CancellationToken cancellationToken) => + _reader.BucketTimeTicksBySeverity(AllSurvive(), minTicks, bucketSpanTicks, bucketCount, slotCounts, cancellationToken); + + public void CountEventIds(IDictionary counts, CancellationToken cancellationToken) => + _reader.CountEventIds(AllSurvive(), counts, cancellationToken); + + public void CountFieldValues(EventFieldId field, IDictionary counts, CancellationToken cancellationToken) => + _reader.CountFieldValues(AllSurvive(), field, counts, cancellationToken); + public IEnumerable EnumerateDetail() { for (int physical = 0; physical < _reader.Count; physical++) @@ -101,4 +116,33 @@ public bool TryGetDetail(EventLocator locator, [NotNullWhen(true)] out ResolvedE return false; } + + public bool TryGetTimeTicks(EventLocator locator, out long ticks) + { + if (locator.LogId == _reader.LogId + && locator.Generation == _reader.Generation + && locator.Index >= 0 + && locator.Index < _reader.Count) + { + ticks = _reader.GetEvent(locator).TimeCreated.Ticks; + + return true; + } + + ticks = 0; + + return false; + } + + public bool TryGetTimeTicksRange(out long minTicks, out long maxTicks, CancellationToken cancellationToken) => + _reader.TryGetTimeTicksRange(AllSurvive(), out minTicks, out maxTicks, cancellationToken); + + private int[] AllSurvive() + { + int[] ranks = new int[_reader.Count]; + + for (int index = 0; index < ranks.Length; index++) { ranks[index] = index; } + + return ranks; + } } diff --git a/tests/Unit/EventLogExpert.UI.Tests/Layout/MainContentTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Layout/MainContentTests.cs index 8a4a5f6f4..527637caf 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Layout/MainContentTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Layout/MainContentTests.cs @@ -4,10 +4,12 @@ using Bunit; using Bunit.TestDoubles; using EventLogExpert.Runtime.EventLog; +using EventLogExpert.Runtime.Histogram; using EventLogExpert.UI.Dashboard; using EventLogExpert.UI.FilterLenses; using EventLogExpert.UI.Layout; using EventLogExpert.UI.LogTable; +using EventLogExpert.UI.LogTable.Histogram; using Fluxor; using Microsoft.Extensions.DependencyInjection; using NSubstitute; @@ -21,14 +23,19 @@ public sealed class MainContentTests : BunitContext private readonly IStateSelection _hasActiveLogs = Substitute.For>(); + private readonly IStateSelection _histogramVisible = + Substitute.For>(); + public MainContentTests() { Services.AddSingleton(_hasActiveLogs); + Services.AddSingleton(_histogramVisible); Services.AddFluxor(options => options.ScanAssemblies(typeof(MainContent).Assembly)); JSInterop.Mode = JSRuntimeMode.Loose; ComponentFactories.AddStub(); ComponentFactories.AddStub(); + ComponentFactories.AddStub(); ComponentFactories.AddStub(); ComponentFactories.AddStub(); ComponentFactories.AddStub(); @@ -45,6 +52,28 @@ public void Render_WhenLogsActive_RendersPanesNotDashboard() Assert.Empty(cut.FindComponents>()); } + [Fact] + public void Render_WhenLogsActiveAndTimelineHidden_DoesNotRenderHistogramPane() + { + _hasActiveLogs.Value.Returns(true); + _histogramVisible.Value.Returns(false); + + var cut = Render(); + + Assert.Empty(cut.FindComponents>()); + } + + [Fact] + public void Render_WhenLogsActiveAndTimelineVisible_RendersHistogramPane() + { + _hasActiveLogs.Value.Returns(true); + _histogramVisible.Value.Returns(true); + + var cut = Render(); + + Assert.NotEmpty(cut.FindComponents>()); + } + [Fact] public void Render_WhenNoActiveLogs_RendersDashboardNotPanes() { diff --git a/tests/Unit/EventLogExpert.UI.Tests/LogTable/FindMarkerSourceTests.cs b/tests/Unit/EventLogExpert.UI.Tests/LogTable/FindMarkerSourceTests.cs new file mode 100644 index 000000000..ea2b394bf --- /dev/null +++ b/tests/Unit/EventLogExpert.UI.Tests/LogTable/FindMarkerSourceTests.cs @@ -0,0 +1,74 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.EventLogs; +using EventLogExpert.UI.LogTable.Find; + +namespace EventLogExpert.UI.Tests.LogTable; + +public sealed class FindMarkerSourceTests +{ + [Fact] + public void Clear_ResetsOwnerAndTicksAndRaisesMarksChanged() + { + var source = new FindMarkerSource(); + source.Publish(EventLogId.Create(), [5L]); + int raised = 0; + source.MarksChanged += (_, _) => raised++; + + source.Clear(); + + Assert.Null(source.Owner); + Assert.Empty(source.Ticks); + Assert.Equal(1, raised); + } + + [Fact] + public void Clear_WhenAlreadyEmpty_DoesNotRaise() + { + var source = new FindMarkerSource(); + int raised = 0; + source.MarksChanged += (_, _) => raised++; + + source.Clear(); + + Assert.Equal(0, raised); + } + + [Fact] + public void Publish_NullTicks_Throws() + { + var source = new FindMarkerSource(); + + Assert.Throws(() => source.Publish(EventLogId.Create(), null!)); + } + + [Fact] + public void Publish_ReplacesTheOwnerAndTicksOfAPriorPublish() + { + var source = new FindMarkerSource(); + var first = EventLogId.Create(); + var second = EventLogId.Create(); + + source.Publish(first, [1L, 2L]); + source.Publish(second, [9L]); + + Assert.Equal(second, source.Owner); + Assert.Equal([9L], source.Ticks); + } + + [Fact] + public void Publish_SetsOwnerAndTicksAndRaisesMarksChanged() + { + var source = new FindMarkerSource(); + var owner = EventLogId.Create(); + int raised = 0; + source.MarksChanged += (_, _) => raised++; + + source.Publish(owner, [10L, 20L, 30L]); + + Assert.Equal(owner, source.Owner); + Assert.Equal([10L, 20L, 30L], source.Ticks); + Assert.Equal(1, raised); + } +} diff --git a/tests/Unit/EventLogExpert.UI.Tests/LogTable/Histogram/HistogramTrackCapTests.cs b/tests/Unit/EventLogExpert.UI.Tests/LogTable/Histogram/HistogramTrackCapTests.cs new file mode 100644 index 000000000..1744016f5 --- /dev/null +++ b/tests/Unit/EventLogExpert.UI.Tests/LogTable/Histogram/HistogramTrackCapTests.cs @@ -0,0 +1,29 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.UI.LogTable.Histogram; + +namespace EventLogExpert.UI.Tests.LogTable.Histogram; + +public sealed class HistogramTrackCapTests +{ + [Fact] + public void MinBinsForWidth_HugeWidth_DoesNotOverflowAndClampsToTotalBins() + { + int result = HistogramTrackCap.MinBinsForWidth(int.MaxValue, 20000); + + Assert.Equal(20000, result); + } + + [Theory] + [InlineData(6000, 20000, 4)] // exactly at the 30M cap: 4 bins => track == 30M, so no floor beyond the raw minimum + [InlineData(6001, 20000, 5)] // one px over: ceil raises the floor to 5 so the track stays under the cap + [InlineData(1920, 20000, 2)] // ceil(1.28) + [InlineData(3840, 20000, 3)] // ceil(2.56) + [InlineData(0, 20000, 1)] // no measured viewport yet + [InlineData(-10, 20000, 1)] // bogus width guarded + [InlineData(1920, 0, 1)] // no bins + [InlineData(1920, 3, 1)] // tiny domain: raw floor below 1 clamps up to 1 + public void MinBinsForWidth_ReturnsCapFloor(int viewportWidthPx, int totalBins, int expected) => + Assert.Equal(expected, HistogramTrackCap.MinBinsForWidth(viewportWidthPx, totalBins)); +} diff --git a/tests/Unit/EventLogExpert.UI.Tests/LogTable/LogTablePaneFindTests.cs b/tests/Unit/EventLogExpert.UI.Tests/LogTable/LogTablePaneFindTests.cs index 848e9f280..aa01c0f2a 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/LogTable/LogTablePaneFindTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/LogTable/LogTablePaneFindTests.cs @@ -69,6 +69,21 @@ public LogTablePaneFindTests() Services.AddFluxor(options => options.ScanAssemblies(typeof(LogTablePane).Assembly)); } + [Fact] + public void ClosingFind_ClearsTheMarkerSource() + { + var markers = Services.GetRequiredService(); + var cut = RenderWithEvents(NewEvent(1, "alpha match"), NewEvent(2, "beta")); + + OpenFindAndSearch(cut, "match"); + cut.WaitForAssertion(() => Assert.NotEmpty(markers.Ticks)); + + cut.Find(".table-container").KeyDown(new KeyboardEventArgs { Code = "Escape", Key = "Escape" }); + + cut.WaitForAssertion(() => Assert.Empty(markers.Ticks)); + Assert.Null(markers.Owner); + } + [Fact] public void CurrentMatch_RendersInlineMark() { @@ -130,6 +145,21 @@ public void Query_MarksMatchingRowsAndReportsCount() Assert.Contains("/2", cut.Find(".find-count").TextContent); } + [Fact] + public void Query_PublishesMatchTimestampsToTheMarkerSource() + { + var markers = Services.GetRequiredService(); + var second = NewEvent(2, "beta match"); + var fourth = NewEvent(4, "delta match"); + var cut = RenderWithEvents(NewEvent(1, "alpha"), second, NewEvent(3, "gamma"), fourth); + + OpenFindAndSearch(cut, "match"); + + cut.WaitForAssertion(() => Assert.Equal(2, markers.Ticks.Count)); + Assert.Equal(_logId, markers.Owner); + Assert.Equal(new[] { second.TimeCreated.Ticks, fourth.TimeCreated.Ticks }, markers.Ticks); + } + [Fact] public void Stepping_ToNextMatch_DoesNotChangeSelection() { diff --git a/tests/Unit/EventLogExpert.UI.Tests/Menu/MenuBarGroupingTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Menu/MenuBarGroupingTests.cs index d5068c78a..c6403db9e 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/Menu/MenuBarGroupingTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/Menu/MenuBarGroupingTests.cs @@ -7,6 +7,7 @@ using EventLogExpert.Runtime.Common.Versioning; using EventLogExpert.Runtime.EventLog; using EventLogExpert.Runtime.FilterPane; +using EventLogExpert.Runtime.Histogram; using EventLogExpert.Runtime.LogTable; using EventLogExpert.Runtime.Menu; using EventLogExpert.Runtime.Settings; @@ -26,6 +27,7 @@ public sealed class MenuBarGroupingTests : BunitContext private readonly IAlertDialogService _alertDialogService = Substitute.For(); private readonly IStateSelection _eventLogSelection = Substitute.For>(); private readonly IStateSelection _filterPaneIsEnabled = Substitute.For>(); + private readonly IStateSelection _histogramVisible = Substitute.For>(); private readonly List> _logTableSelections = []; private readonly IMenuService _menuService = Substitute.For(); private readonly ISettingsService _settings = Substitute.For(); @@ -39,6 +41,7 @@ public MenuBarGroupingTests() Services.AddSingleton(_alertDialogService); Services.AddSingleton(_eventLogSelection); Services.AddSingleton(_filterPaneIsEnabled); + Services.AddSingleton(_histogramVisible); Services.AddTransient>(_ => CreateLogTableSelection()); Services.AddSingleton(_menuService); Services.AddSingleton(_settings); diff --git a/tests/Unit/EventLogExpert.UI.Tests/TestUtils/LogTablePaneDependenciesExtensions.cs b/tests/Unit/EventLogExpert.UI.Tests/TestUtils/LogTablePaneDependenciesExtensions.cs index 5471eb85f..050715bf0 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/TestUtils/LogTablePaneDependenciesExtensions.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/TestUtils/LogTablePaneDependenciesExtensions.cs @@ -27,6 +27,7 @@ public IServiceCollection AddLogTablePaneDependencies() services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For()); services.AddSingleton(Substitute.For());