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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ internal sealed class FilterLensCommands(IDispatcher dispatcher) : IFilterLensCo

public void RemoveLens(FilterLens lens) => _dispatcher.Dispatch(new RemoveFilterLensAction(lens));

public void ShowEventsNearTime(DateTime timeCreated, TimeSpan radius, TimeZoneInfo displayZone, string? originLog = null) =>
PushLens(FilterLensFactory.ForTimeWindow(timeCreated, radius, displayZone, originLog));

public void ShowParentActivity(Guid? relatedActivityId, string? originLog = null)
{
if (relatedActivityId is { } id)
Expand Down
53 changes: 53 additions & 0 deletions src/EventLogExpert.Runtime/FilterLenses/FilterLensFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using EventLogExpert.Filtering.Common.Filtering;
using EventLogExpert.Filtering.Evaluation;
using EventLogExpert.Filtering.Persistence;
using EventLogExpert.Runtime.Common.Display;

namespace EventLogExpert.Runtime.FilterLenses;

Expand All @@ -29,6 +30,46 @@ internal static class FilterLensFactory
$"Related Activity ID = {relatedActivityId}",
originLog);

/// <summary>
/// Builds a transient time-window lens centered on <paramref name="timeCreatedUtc" /> (the source event's UTC
/// timestamp): the effective view is narrowed to the inclusive range [timeCreatedUtc - radius, timeCreatedUtc +
/// radius], so the source event itself always survives. Bounds are clamped to the <see cref="DateTime" /> range
/// because boundary arithmetic throws on overflow - a degenerate near-min/near-max timestamp must not crash the menu
/// handler. The chip label renders the anchor's time of day in <paramref name="displayZone" /> (the grid's display
/// zone) as a compact marker; it is not the grid's full date+time rendering. <paramref name="radius" /> must be within
/// (0, 1 hour] and a whole number of seconds (validated); the suffix renders it to its largest whole unit (for example
/// 90s stays "90s").
/// </summary>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="radius" /> is not within (0, 1 hour], or is not a whole
/// number of seconds.
/// </exception>
public static FilterLens ForTimeWindow(
DateTime timeCreatedUtc,
TimeSpan radius,
TimeZoneInfo displayZone,
string? originLog = null)
{
ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(radius, TimeSpan.Zero);
ArgumentOutOfRangeException.ThrowIfGreaterThan(radius, TimeSpan.FromHours(1));

if (radius.Ticks % TimeSpan.TicksPerSecond != 0)
{
throw new ArgumentOutOfRangeException(nameof(radius), radius, "Radius must be a whole number of seconds.");
}

var after = timeCreatedUtc <= DateTime.MinValue + radius ? DateTime.MinValue : timeCreatedUtc - radius;
var before = timeCreatedUtc >= DateTime.MaxValue - radius ? DateTime.MaxValue : timeCreatedUtc + radius;

return new FilterLens
{
Label = $"Near {timeCreatedUtc.ConvertTimeZone(displayZone):T} \u00b1{FormatRadius(radius)}",
Kind = LensKind.TimeWindow,
Window = new DateFilter { After = after, Before = before, IsEnabled = true },
OriginLog = originLog
};
Comment thread
jschick04 marked this conversation as resolved.
}

/// <summary>
/// Encodes a nullable-Guid equality narrowing (keep only rows where the field equals <paramref name="value" />)
/// as an <em>exclude-of-complement</em>: because the base's include filters are OR-combined an appended include would
Expand Down Expand Up @@ -61,6 +102,18 @@ internal static class FilterLensFactory
};
}

/// <summary>
/// Formats the window radius as a compact chip suffix (for example "30s", "5m", "1h") using the largest whole
/// unit that represents it exactly. Callers are validated to whole-second radii, so every valid radius renders
/// losslessly.
/// </summary>
private static string FormatRadius(TimeSpan radius) => radius switch
{
{ Minutes: 0, Seconds: 0, Milliseconds: 0 } => $"{radius.TotalHours:0}h",
{ Seconds: 0, Milliseconds: 0 } => $"{radius.TotalMinutes:0}m",
_ => $"{radius.TotalSeconds:0}s"
};

private static bool TryFormatNotEqual(EventProperty property, string value, out string comparisonText)
{
var comparison = new FilterComparison
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ public interface IFilterLensCommands

void RemoveLens(FilterLens lens);

/// <summary>
/// Pushes a transient time-window lens centered on <paramref name="timeCreated" /> (the source event's UTC
/// timestamp), narrowing the view to events no more than <paramref name="radius" /> before or after it. The window is
/// inclusive, so the source event always stays in view. <paramref name="displayZone" /> renders the chip's anchor time
/// in the grid's display zone; <paramref name="originLog" /> is the source event's
/// <see cref="ResolvedEvent.OwningLog" /> so the lens auto-clears when that log is closed.
/// </summary>
void ShowEventsNearTime(DateTime timeCreated, TimeSpan radius, TimeZoneInfo displayZone, string? originLog = null);

/// <summary>
/// Pushes a lens narrowing the view to the parent activity's events - those whose ActivityId equals the source
/// event's <paramref name="relatedActivityId" />. A null id is a no-op. <paramref name="originLog" /> is the source
Expand Down
24 changes: 24 additions & 0 deletions src/EventLogExpert.UI/LogTable/LogTablePane.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,30 @@ private IReadOnlyList<MenuItem> ShowContextMenuItems(ResolvedEvent selectedEvent
() => FilterLensCommands.ShowParentActivity(selectedEvent.RelatedActivityId, selectedEvent.OwningLog),
isEnabled: selectedEvent.RelatedActivityId.HasValue,
disabledReason: selectedEvent.RelatedActivityId.HasValue ? null : "This event has no Related Activity ID."),
MenuItem.SubMenu(
"Show Events Near This Time",
[
MenuItem.Item(
"\u00b130 Seconds",
() => FilterLensCommands.ShowEventsNearTime(
selectedEvent.TimeCreated, TimeSpan.FromSeconds(30), Settings.TimeZoneInfo, selectedEvent.OwningLog)),
MenuItem.Item(
"\u00b11 Minute",
() => FilterLensCommands.ShowEventsNearTime(
selectedEvent.TimeCreated, TimeSpan.FromMinutes(1), Settings.TimeZoneInfo, selectedEvent.OwningLog)),
MenuItem.Item(
"\u00b15 Minutes",
() => FilterLensCommands.ShowEventsNearTime(
selectedEvent.TimeCreated, TimeSpan.FromMinutes(5), Settings.TimeZoneInfo, selectedEvent.OwningLog)),
MenuItem.Item(
"\u00b115 Minutes",
() => FilterLensCommands.ShowEventsNearTime(
selectedEvent.TimeCreated, TimeSpan.FromMinutes(15), Settings.TimeZoneInfo, selectedEvent.OwningLog)),
MenuItem.Item(
"\u00b11 Hour",
() => FilterLensCommands.ShowEventsNearTime(
selectedEvent.TimeCreated, TimeSpan.FromHours(1), Settings.TimeZoneInfo, selectedEvent.OwningLog)),
]),
MenuItem.Separator(),
MenuItem.SubMenu(
"More Fields",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,27 @@ public void Build_TimeWindowLens_NullOrDisabledBase_UsesWindowVerbatim()
Assert.Equal(At(16), disabledBase.DateFilter.Before);
}

[Fact]
public void Build_TimeWindowLensFromFactory_KeepsEventsWithinRadius_IncludingInclusiveBoundsAndSource()
{
// The factory-built centered window [T-1h, T+1h] keeps the source event at T and neighbors exactly on the
// inclusive bounds, and hides anything past them.
var anchor = At(12);
var source = FilterEventBuilder.CreateTestEvent(id: 1, timeCreated: anchor);
var onLowerBound = FilterEventBuilder.CreateTestEvent(id: 2, timeCreated: At(11));
var onUpperBound = FilterEventBuilder.CreateTestEvent(id: 3, timeCreated: At(13));
var belowWindow = FilterEventBuilder.CreateTestEvent(id: 4, timeCreated: At(10));
var aboveWindow = FilterEventBuilder.CreateTestEvent(id: 5, timeCreated: At(14));

var lens = FilterLensFactory.ForTimeWindow(anchor, TimeSpan.FromHours(1), TimeZoneInfo.Utc);
var composed = EffectiveFilterBuilder.Build(new Filter(null, []), [lens]);

var result = s_filterService.GetFilteredEvents(
[source, onLowerBound, onUpperBound, belowWindow, aboveWindow], composed);

Assert.Equal([1, 2, 3], result.Select(e => e.Id).OrderBy(id => id));
}

[Fact]
public void Build_WithActivityIdLensOnBaseInclude_NarrowsToIntersection()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,137 @@ public void RemoveLens_DispatchesRemove()
dispatcher.Received(1).Dispatch(Arg.Is<RemoveFilterLensAction>(action => action.Lens == lens));
}

[Theory]
[InlineData(30, "\u00b130s")]
[InlineData(60, "\u00b11m")]
[InlineData(300, "\u00b15m")]
[InlineData(900, "\u00b115m")]
[InlineData(3600, "\u00b11h")]
public void ShowEventsNearTime_ChipSuffixMatchesOfferedDurations(int seconds, string expectedSuffix)
{
var dispatcher = Substitute.For<IDispatcher>();

new FilterLensCommands(dispatcher).ShowEventsNearTime(
new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc), TimeSpan.FromSeconds(seconds), TimeZoneInfo.Utc);

dispatcher.Received(1).Dispatch(Arg.Is<PushFilterLensAction>(action =>
action.Lens.Label.EndsWith(expectedSuffix, StringComparison.Ordinal)));
}

[Fact]
public void ShowEventsNearTime_DispatchesPushWithCenteredEnabledWindow()
{
var dispatcher = Substitute.For<IDispatcher>();
var anchor = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);

new FilterLensCommands(dispatcher).ShowEventsNearTime(anchor, TimeSpan.FromMinutes(5), TimeZoneInfo.Utc);

dispatcher.Received(1).Dispatch(Arg.Is<PushFilterLensAction>(action =>
action.Lens.Kind == LensKind.TimeWindow &&
action.Lens.Window != null &&
action.Lens.Window.IsEnabled &&
action.Lens.Window.After == anchor.AddMinutes(-5) &&
action.Lens.Window.Before == anchor.AddMinutes(5)));
}

[Fact]
public void ShowEventsNearTime_InRangeNonStandardRadius_RendersLosslessChipSuffix()
{
var dispatcher = Substitute.For<IDispatcher>();

// 90s is within (0, 1h] but not an offered duration; the suffix renders it losslessly as "90s", not a rounded "2m".
new FilterLensCommands(dispatcher).ShowEventsNearTime(
new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc), TimeSpan.FromSeconds(90), TimeZoneInfo.Utc);

dispatcher.Received(1).Dispatch(Arg.Is<PushFilterLensAction>(action =>
action.Lens.Label.EndsWith("\u00b190s", StringComparison.Ordinal)));
}

[Fact]
public void ShowEventsNearTime_LabelRendersAnchorInDisplayZone()
{
var dispatcher = Substitute.For<IDispatcher>();
var anchor = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc);
var displayZone = TimeZoneInfo.CreateCustomTimeZone("test+5", TimeSpan.FromHours(5), "test+5", "test+5");
var expectedAnchor = TimeZoneInfo.ConvertTimeFromUtc(anchor, displayZone);

new FilterLensCommands(dispatcher).ShowEventsNearTime(anchor, TimeSpan.FromMinutes(5), displayZone);

// Built with the same culture-sensitive ":T" the production label uses, so the assertion holds under any ambient
// culture; the anchor renders in the +5 display zone (17:00:00), not UTC (12:00:00).
dispatcher.Received(1).Dispatch(Arg.Is<PushFilterLensAction>(action =>
action.Lens.Label == $"Near {expectedAnchor:T} \u00b15m"));
}

[Fact]
public void ShowEventsNearTime_MinValueAnchor_ClampsLowerBoundWithoutThrowing()
{
var dispatcher = Substitute.For<IDispatcher>();

// A degenerate default(DateTime) anchor must not throw - DateTime boundary arithmetic throws on underflow; the
// lower bound clamps to DateTime.MinValue.
new FilterLensCommands(dispatcher).ShowEventsNearTime(
DateTime.MinValue, TimeSpan.FromSeconds(30), TimeZoneInfo.Utc);

dispatcher.Received(1).Dispatch(Arg.Is<PushFilterLensAction>(action =>
action.Lens.Window!.After == DateTime.MinValue &&
action.Lens.Window.Before == DateTime.MinValue.AddSeconds(30)));
}

[Theory]
[InlineData(0)]
[InlineData(-30)]
[InlineData(3601)]
public void ShowEventsNearTime_RadiusOutsideSupportedRange_ThrowsAndDoesNotDispatch(int seconds)
{
var dispatcher = Substitute.For<IDispatcher>();

Assert.Throws<ArgumentOutOfRangeException>(() => new FilterLensCommands(dispatcher).ShowEventsNearTime(
new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc), TimeSpan.FromSeconds(seconds), TimeZoneInfo.Utc));

dispatcher.DidNotReceive().Dispatch(Arg.Any<PushFilterLensAction>());
}

[Fact]
public void ShowEventsNearTime_SubSecondRadius_ThrowsAndDoesNotDispatch()
{
var dispatcher = Substitute.For<IDispatcher>();

// The window is whole-second granularity; a sub-second radius (which the compact suffix cannot render losslessly)
// is rejected rather than silently rounded.
Assert.Throws<ArgumentOutOfRangeException>(() => new FilterLensCommands(dispatcher).ShowEventsNearTime(
new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc), TimeSpan.FromMilliseconds(500), TimeZoneInfo.Utc));

dispatcher.DidNotReceive().Dispatch(Arg.Any<PushFilterLensAction>());
}

[Fact]
public void ShowEventsNearTime_MinValueAnchorWithNegativeOffsetZone_RendersLabelWithoutThrowing()
{
var dispatcher = Substitute.For<IDispatcher>();
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<PushFilterLensAction>());
}

[Fact]
public void ShowEventsNearTime_WithOriginLog_TagsLensWithThatLog()
{
var dispatcher = Substitute.For<IDispatcher>();

new FilterLensCommands(dispatcher).ShowEventsNearTime(
new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc), TimeSpan.FromMinutes(5), TimeZoneInfo.Utc, "LogA");

dispatcher.Received(1).Dispatch(Arg.Is<PushFilterLensAction>(action => action.Lens.OriginLog == "LogA"));
}

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