From 982f7a1aeb126bbb8c73cf08173cfaeceab0416c Mon Sep 17 00:00:00 2001 From: jschick04 Date: Tue, 14 Jul 2026 15:12:39 +0000 Subject: [PATCH] Add a Details Pane reader view with decoded fields and severity --- .../Common/Events/EventFieldValue.cs | 28 ++ .../Common/Events/LevelSeverity.cs | 17 + .../DetailsPane/DetailsField.cs | 27 ++ .../DetailsPane/DetailsProperty.cs | 7 + .../DetailsPane/DetailsReaderFormatter.cs | 331 ++++++++++++++++ .../DetailsPane/DetailsReaderModel.cs | 31 ++ .../DetailsPane/EventFieldExplainer.cs | 164 ++++++++ .../DetailsPane/EventFieldExplanation.cs | 15 + .../FilterPane/FilterPaneState.cs | 19 +- .../DetailsPane/DetailsFieldRow.razor | 48 +++ .../DetailsPane/DetailsFieldRow.razor.css | 116 ++++++ .../DetailsPane/DetailsPane.razor | 265 ++++++++++--- .../DetailsPane/DetailsPane.razor.cs | 124 +++++- .../DetailsPane/DetailsPane.razor.css | 309 +++++++++++++-- .../LogTable/LogTablePane.razor.cs | 18 +- src/EventLogExpert.UI/_Imports.razor | 1 + .../EventDataTestFactory.cs | 19 + .../Common/Events/EventFieldValueTests.cs | 39 ++ .../Common/Events/LevelSeverityTests.cs | 27 ++ .../DetailsReaderFormatterTests.cs | 323 ++++++++++++++++ .../DetailsPane/EventFieldExplainerTests.cs | 135 +++++++ .../DetailsPane/DetailsPaneTests.cs | 357 ++++++++++++++++++ .../EventLogExpert.UI.Tests.csproj | 1 + .../LogTable/LogTablePaneLevelClassTests.cs | 24 ++ 24 files changed, 2338 insertions(+), 107 deletions(-) create mode 100644 src/EventLogExpert.Eventing/Common/Events/LevelSeverity.cs create mode 100644 src/EventLogExpert.Runtime/DetailsPane/DetailsField.cs create mode 100644 src/EventLogExpert.Runtime/DetailsPane/DetailsProperty.cs create mode 100644 src/EventLogExpert.Runtime/DetailsPane/DetailsReaderFormatter.cs create mode 100644 src/EventLogExpert.Runtime/DetailsPane/DetailsReaderModel.cs create mode 100644 src/EventLogExpert.Runtime/DetailsPane/EventFieldExplainer.cs create mode 100644 src/EventLogExpert.Runtime/DetailsPane/EventFieldExplanation.cs create mode 100644 src/EventLogExpert.UI/DetailsPane/DetailsFieldRow.razor create mode 100644 src/EventLogExpert.UI/DetailsPane/DetailsFieldRow.razor.css create mode 100644 tests/Unit/EventLogExpert.Eventing.Tests/Common/Events/LevelSeverityTests.cs create mode 100644 tests/Unit/EventLogExpert.Runtime.Tests/DetailsPane/DetailsReaderFormatterTests.cs create mode 100644 tests/Unit/EventLogExpert.Runtime.Tests/DetailsPane/EventFieldExplainerTests.cs create mode 100644 tests/Unit/EventLogExpert.UI.Tests/DetailsPane/DetailsPaneTests.cs create mode 100644 tests/Unit/EventLogExpert.UI.Tests/LogTable/LogTablePaneLevelClassTests.cs diff --git a/src/EventLogExpert.Eventing/Common/Events/EventFieldValue.cs b/src/EventLogExpert.Eventing/Common/Events/EventFieldValue.cs index e8322a07c..9e6b75b67 100644 --- a/src/EventLogExpert.Eventing/Common/Events/EventFieldValue.cs +++ b/src/EventLogExpert.Eventing/Common/Events/EventFieldValue.cs @@ -108,6 +108,20 @@ public bool TryGetGuid(out Guid value) return false; } + public bool TryGetBytes([NotNullWhen(true)] out byte[]? value) + { + if (_kind == EventFieldValueKind.Bytes && _reference is byte[] bytes) + { + value = bytes; + + return true; + } + + value = null; + + return false; + } + public bool TryGetStringArray([NotNullWhen(true)] out string[]? values) { if (_kind == EventFieldValueKind.StringArray && _reference is string[] array) @@ -122,6 +136,20 @@ public bool TryGetStringArray([NotNullWhen(true)] out string[]? values) return false; } + public bool TryGetArray([NotNullWhen(true)] out Array? value) + { + if (_kind == EventFieldValueKind.Array && _reference is Array array) + { + value = array; + + return true; + } + + value = null; + + return false; + } + public string AsString() => _kind switch { EventFieldValueKind.String => _reference as string ?? string.Empty, diff --git a/src/EventLogExpert.Eventing/Common/Events/LevelSeverity.cs b/src/EventLogExpert.Eventing/Common/Events/LevelSeverity.cs new file mode 100644 index 000000000..0f7e610f6 --- /dev/null +++ b/src/EventLogExpert.Eventing/Common/Events/LevelSeverity.cs @@ -0,0 +1,17 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Eventing.Common.Events; + +public static class LevelSeverity +{ + public static SeverityLevel? FromLevelName(string? level) => level switch + { + nameof(SeverityLevel.Critical) => SeverityLevel.Critical, + nameof(SeverityLevel.Error) => SeverityLevel.Error, + nameof(SeverityLevel.Warning) => SeverityLevel.Warning, + nameof(SeverityLevel.Information) => SeverityLevel.Information, + nameof(SeverityLevel.Verbose) => SeverityLevel.Verbose, + _ => null + }; +} diff --git a/src/EventLogExpert.Runtime/DetailsPane/DetailsField.cs b/src/EventLogExpert.Runtime/DetailsPane/DetailsField.cs new file mode 100644 index 000000000..503cb382c --- /dev/null +++ b/src/EventLogExpert.Runtime/DetailsPane/DetailsField.cs @@ -0,0 +1,27 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.DetailsPane; + +public sealed record DetailsField +{ + public required string Label { get; init; } + + public required IReadOnlyList PreviewLines { get; init; } + + public required IReadOnlyList FullLines { get; init; } + + public required string CopyValue { get; init; } + + public bool IsTruncated { get; init; } + + public bool IsMuted { get; init; } + + public bool IsMonospace { get; init; } + + public string? DecodedLabel { get; init; } + + public string? Description { get; init; } + + public bool PreferFullWidth => PreviewLines.Count > 1 || IsTruncated || Description is not null; +} diff --git a/src/EventLogExpert.Runtime/DetailsPane/DetailsProperty.cs b/src/EventLogExpert.Runtime/DetailsPane/DetailsProperty.cs new file mode 100644 index 000000000..965c9968e --- /dev/null +++ b/src/EventLogExpert.Runtime/DetailsPane/DetailsProperty.cs @@ -0,0 +1,7 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.DetailsPane; + +/// A single label / value row in the reader view's identity header or system-details section. +public readonly record struct DetailsProperty(string Label, string Value); diff --git a/src/EventLogExpert.Runtime/DetailsPane/DetailsReaderFormatter.cs b/src/EventLogExpert.Runtime/DetailsPane/DetailsReaderFormatter.cs new file mode 100644 index 000000000..08fe2154a --- /dev/null +++ b/src/EventLogExpert.Runtime/DetailsPane/DetailsReaderFormatter.cs @@ -0,0 +1,331 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.Eventing.Structured; +using EventLogExpert.Runtime.Common.Display; +using System.Globalization; +using System.Text; + +namespace EventLogExpert.Runtime.DetailsPane; + +/// +/// Projects a into the the details pane renders, +/// and builds the clipboard text for the whole event and for each section. All display strings are pre-rendered here +/// so the component retains no transient , and the copy builders reuse the projection so +/// displayed and copied text cannot drift. EventData fields render their names (or a positional [i] when +/// unnamed) with one line per array item, a hex preview for binary values, muted placeholders for empty values, and +/// any inline explanation from ; UserData fields are never run through the explainer +/// because their leaf paths lack the provider / event context the glossary keys on. +/// +public static class DetailsReaderFormatter +{ + private const int BytesPreviewByteCount = 64; + private const string EmptyArrayPlaceholder = "(no values)"; + private const string EmptyStringPlaceholder = "(empty)"; + private const int LongTextPreviewLength = 500; + private const string NullValuePlaceholder = "(none)"; + + public static string BuildEventCopyText(DetailsReaderModel model) + { + StringBuilder builder = new(); + + builder.AppendLine($"Event ID: {model.EventId}"); + + if (!string.IsNullOrEmpty(model.Level)) { builder.AppendLine($"Level: {model.Level}"); } + + foreach (DetailsProperty property in model.Header) + { + builder.AppendLine($"{property.Label}: {property.Value}"); + } + + foreach (DetailsProperty property in model.SystemProperties) + { + builder.AppendLine($"{property.Label}: {property.Value}"); + } + + if (model.HasMessage) + { + builder.AppendLine(); + builder.AppendLine("Message:"); + builder.AppendLine(model.Message); + } + + AppendFieldsSection(builder, "Event Data:", model.EventData); + AppendFieldsSection(builder, "User Data:", model.UserData); + + return builder.ToString().TrimEnd(); + } + + public static string BuildFieldsCopyText(IReadOnlyList fields) + { + StringBuilder builder = new(); + + foreach (DetailsField field in fields) + { + AppendFieldCopyText(builder, field); + } + + return builder.ToString().TrimEnd(); + } + + public static DetailsReaderModel BuildModel(ResolvedEvent @event, TimeZoneInfo timeZone) => + new() + { + EventId = @event.Id.ToString(CultureInfo.InvariantCulture), + Level = @event.Level, + Severity = LevelSeverity.FromLevelName(@event.Level), + Header = BuildHeader(@event, timeZone), + SystemProperties = BuildSystemProperties(@event), + EventData = BuildEventDataFields(@event, timeZone), + UserData = BuildUserDataFields(@event), + Message = @event.Description, + HasMessage = !string.IsNullOrEmpty(@event.Description), + UserDataIncomplete = @event.UserDataIncomplete + }; + + public static string BuildPropertiesCopyText(IReadOnlyList properties) + { + StringBuilder builder = new(); + + foreach (DetailsProperty property in properties) + { + builder.AppendLine($"{property.Label}: {property.Value}"); + } + + return builder.ToString().TrimEnd(); + } + + private static void AddIfPresent(List properties, string label, string value) + { + if (!string.IsNullOrEmpty(value)) { properties.Add(new DetailsProperty(label, value)); } + } + + private static void AppendFieldCopyText(StringBuilder builder, DetailsField field) + { + if (field.CopyValue.Contains('\n')) + { + builder.AppendLine($"{field.Label}:"); + + foreach (string line in field.CopyValue.Split('\n')) + { + builder.AppendLine($" {line}"); + } + + return; + } + + builder.Append(field.Label).Append(": ").Append(field.CopyValue); + + if (field.DecodedLabel is { } decoded) + { + builder.Append(" (").Append(decoded).Append(')'); + } + + builder.AppendLine(); + } + + private static void AppendFieldsSection(StringBuilder builder, string heading, IReadOnlyList fields) + { + if (fields.Count == 0) { return; } + + builder.AppendLine(); + builder.AppendLine(heading); + + foreach (DetailsField field in fields) + { + AppendFieldCopyText(builder, field); + } + } + + private static IReadOnlyList BuildEventDataFields(ResolvedEvent @event, TimeZoneInfo timeZone) + { + List fields = []; + int index = 0; + + foreach (EventDataView.Field field in @event.EventData) + { + string label = string.IsNullOrEmpty(field.Name) + ? $"[{index}]" + : field.Name; + + EventFieldExplanation explanation = + EventFieldExplainer.TryExplain(@event.Source, @event.Id, field.Name, field.Value, out EventFieldExplanation resolved) + ? resolved + : default; + + fields.Add(ToField(label, RenderValue(field.Value, timeZone), explanation)); + index++; + } + + return fields; + } + + private static IReadOnlyList BuildHeader(ResolvedEvent @event, TimeZoneInfo timeZone) + { + // Event ID + Level are projected as typed summary fields (with the severity bucket) and rendered separately, so + // they are intentionally not in this list; BuildEventCopyText re-emits them first to keep the clipboard order. + List properties = []; + + AddIfPresent(properties, "Source", @event.Source); + properties.Add(new DetailsProperty("Date and Time", @event.TimeCreated.ConvertTimeZone(timeZone).ToString(CultureInfo.CurrentCulture))); + AddIfPresent(properties, "Computer", @event.ComputerName); + AddIfPresent(properties, "Log Name", @event.LogName); + + return properties; + } + + private static IReadOnlyList BuildSystemProperties(ResolvedEvent @event) + { + List properties = []; + + AddIfPresent(properties, "Task Category", @event.TaskCategory); + AddIfPresent(properties, "Opcode", @event.Opcode); + AddIfPresent(properties, "Keywords", @event.KeywordsDisplayName); + + if (@event.RecordId is { } recordId) { properties.Add(new DetailsProperty("Record ID", recordId.ToString(CultureInfo.InvariantCulture))); } + + if (@event.ProcessId is { } processId) { properties.Add(new DetailsProperty("Process ID", processId.ToString(CultureInfo.InvariantCulture))); } + + if (@event.ThreadId is { } threadId) { properties.Add(new DetailsProperty("Thread ID", threadId.ToString(CultureInfo.InvariantCulture))); } + + if (@event.ActivityId is { } activityId) { properties.Add(new DetailsProperty("Activity ID", activityId.ToString())); } + + if (@event.RelatedActivityId is { } relatedActivityId) { properties.Add(new DetailsProperty("Related Activity ID", relatedActivityId.ToString())); } + + // Raw SID by design; account-name resolution is a separate task. + if (@event.UserId is { } userId) { properties.Add(new DetailsProperty("User", userId.Value)); } + + return properties; + } + + private static IReadOnlyList BuildUserDataFields(ResolvedEvent @event) + { + if (@event.UserData.IsDefaultOrEmpty) { return []; } + + List fields = []; + + foreach (UserDataField field in @event.UserData) + { + string[] values = field.Values.IsDefaultOrEmpty ? [] : [.. field.Values]; + + fields.Add(ToField(field.Path, RenderArray(values), default)); + } + + return fields; + } + + private static string GroupHex(byte[] bytes, int count) + { + StringBuilder builder = new(count * 3); + + for (int i = 0; i < count; i++) + { + if (i > 0) { builder.Append(' '); } + + builder.Append(bytes[i].ToString("X2", CultureInfo.InvariantCulture)); + } + + return builder.ToString(); + } + + private static RenderedValue Placeholder(string text) + { + string[] lines = [text]; + + return new RenderedValue(lines, lines, string.Empty, IsTruncated: false, IsMuted: true); + } + + private static RenderedValue RenderArray(string[] items) => + items.Length == 0 ? + Placeholder(EmptyArrayPlaceholder) : + new RenderedValue(items, items, string.Join('\n', items), IsTruncated: false, IsMuted: false); + + private static RenderedValue RenderBytes(byte[] bytes) + { + if (bytes.Length == 0) { return Placeholder(EmptyStringPlaceholder); } + + string copyValue = Convert.ToHexString(bytes); + string full = GroupHex(bytes, bytes.Length); + + if (bytes.Length > BytesPreviewByteCount) + { + return new RenderedValue([$"{GroupHex(bytes, BytesPreviewByteCount)} ..."], [full], copyValue, IsTruncated: true, IsMuted: false, IsMonospace: true); + } + + string[] lines = [full]; + + return new RenderedValue(lines, lines, copyValue, IsTruncated: false, IsMuted: false, IsMonospace: true); + } + + private static RenderedValue RenderScalarText(string text) + { + if (text.Length == 0) { return Placeholder(EmptyStringPlaceholder); } + + if (text.Length > LongTextPreviewLength) + { + return new RenderedValue([$"{text[..LongTextPreviewLength]}..."], [text], text, IsTruncated: true, IsMuted: false); + } + + string[] lines = [text]; + + return new RenderedValue(lines, lines, text, IsTruncated: false, IsMuted: false); + } + + private static RenderedValue RenderValue(in EventFieldValue value, TimeZoneInfo timeZone) + { + if (value.TryGetStringArray(out string[]? strings)) { return RenderArray(strings); } + + if (value.TryGetArray(out Array? array)) { return RenderArray(ToStringItems(array)); } + + if (value.TryGetBytes(out byte[]? bytes)) { return RenderBytes(bytes); } + + if (value.TryGetDateTime(out DateTime timestamp)) + { + return RenderScalarText(timestamp.ConvertTimeZone(timeZone).ToString(CultureInfo.CurrentCulture)); + } + + if (value.Kind == EventFieldValueKind.Null) { return Placeholder(NullValuePlaceholder); } + + RenderedValue scalar = RenderScalarText(value.AsString()); + + // GUIDs and SIDs scan better fixed-width; general strings and numbers keep the app's sans-serif. + return value.Kind is EventFieldValueKind.Guid or EventFieldValueKind.Sid + ? scalar with { IsMonospace = true } + : scalar; + } + + private static DetailsField ToField(string label, RenderedValue rendered, EventFieldExplanation explanation) => + new() + { + Label = label, + PreviewLines = rendered.PreviewLines, + FullLines = rendered.FullLines, + CopyValue = rendered.CopyValue, + IsTruncated = rendered.IsTruncated, + IsMuted = rendered.IsMuted, + IsMonospace = rendered.IsMonospace, + DecodedLabel = explanation.DecodedLabel, + Description = explanation.Description + }; + + private static string[] ToStringItems(Array array) + { + string[] items = new string[array.Length]; + + for (int i = 0; i < array.Length; i++) + { + items[i] = Convert.ToString(array.GetValue(i), CultureInfo.InvariantCulture) ?? string.Empty; + } + + return items; + } + + private readonly record struct RenderedValue( + IReadOnlyList PreviewLines, + IReadOnlyList FullLines, + string CopyValue, + bool IsTruncated, + bool IsMuted, + bool IsMonospace = false); +} diff --git a/src/EventLogExpert.Runtime/DetailsPane/DetailsReaderModel.cs b/src/EventLogExpert.Runtime/DetailsPane/DetailsReaderModel.cs new file mode 100644 index 000000000..5abb3144f --- /dev/null +++ b/src/EventLogExpert.Runtime/DetailsPane/DetailsReaderModel.cs @@ -0,0 +1,31 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; + +namespace EventLogExpert.Runtime.DetailsPane; + +public sealed record DetailsReaderModel +{ + public required string EventId { get; init; } + + public required string Level { get; init; } + + public SeverityLevel? Severity { get; init; } + + public required IReadOnlyList Header { get; init; } + + public required IReadOnlyList SystemProperties { get; init; } + + public required IReadOnlyList EventData { get; init; } + + public required IReadOnlyList UserData { get; init; } + + public required string Message { get; init; } + + public bool HasMessage { get; init; } + + public bool UserDataIncomplete { get; init; } + + public bool HasNamedEventData => EventData.Count > 0; +} diff --git a/src/EventLogExpert.Runtime/DetailsPane/EventFieldExplainer.cs b/src/EventLogExpert.Runtime/DetailsPane/EventFieldExplainer.cs new file mode 100644 index 000000000..06b45a39b --- /dev/null +++ b/src/EventLogExpert.Runtime/DetailsPane/EventFieldExplainer.cs @@ -0,0 +1,164 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; +using System.Collections.Frozen; +using System.Globalization; + +namespace EventLogExpert.Runtime.DetailsPane; + +/// +/// Resolves optional inline explanations for structured EventData fields: a small curated glossary (what a field +/// means) plus a bounded set of value decoders (what a specific value means, e.g. a logon type). Both are fail-closed +/// - an unrecognized field or value yields no text rather than a guess. Glossary lookups follow a most-specific-wins +/// order (provider + event id + field, then provider + field, then a small allowlist of provider-agnostic field +/// names); decoders and the glossary resolve independently, so a value decode is never suppressed by a glossary match +/// or vice versa. +/// +public static class EventFieldExplainer +{ + private const string SecurityAuditing = "Microsoft-Windows-Security-Auditing"; + + // Field names whose meaning is too provider-specific to explain from a bare name. They are only ever explained + // through a provider- or event-scoped rule, never the provider-agnostic allowlist. + private static readonly FrozenSet s_ambiguousFieldNames = + new[] { "Status", "Type", "Name", "Id", "Subject", "Data" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + private static readonly GlossaryEntry[] s_glossary = + [ + // Provider + event scoped (most specific). + new(SecurityAuditing, 4624, "TargetUserName", "The account that was logged on."), + new(SecurityAuditing, 4624, "SubjectUserName", "The account that requested the logon."), + new(SecurityAuditing, 4625, "TargetUserName", "The account that failed to log on."), + + // Provider scoped (any event from this provider). + new(SecurityAuditing, null, "AuthenticationPackageName", "The authentication package (e.g. NTLM, Kerberos) that handled the logon."), + new(SecurityAuditing, null, "LogonProcessName", "The trusted process that submitted the logon request."), + + // Provider-agnostic allowlist (field names whose meaning is stable across providers). + new(null, null, "LogonType", "How the logon was initiated; see the decoded value for the specific type."), + new(null, null, "IpAddress", "The source network address associated with the event."), + new(null, null, "IpPort", "The source network port associated with the event."), + new(null, null, "ProcessName", "The full path of the process associated with the event."), + new(null, null, "CommandLine", "The command line used to start the process."), + new(null, null, "WorkstationName", "The name of the workstation from which the action originated.") + ]; + + private static readonly FrozenDictionary> s_valueDecoders = + new Dictionary> + { + ["LogonType"] = DecodeLogonType + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Produces an for the given field, resolving a value decode and a glossary + /// description independently. Returns false (and an empty explanation) when neither applies. + /// + public static bool TryExplain( + string providerName, + int eventId, + string fieldName, + in EventFieldValue value, + out EventFieldExplanation explanation) + { + string? decodedLabel = TryDecodeValue(fieldName, value); + string? description = ResolveDescription(providerName, eventId, fieldName); + + explanation = new EventFieldExplanation(decodedLabel, description); + + return explanation.HasValue; + } + + private static string? DecodeLogonType(EventFieldValue value) + { + if (!TryReadWholeNumber(value, out ulong logonType)) { return null; } + + return logonType switch + { + 0 => "System", + 2 => "Interactive", + 3 => "Network", + 4 => "Batch", + 5 => "Service", + 7 => "Unlock", + 8 => "NetworkCleartext", + 9 => "NewCredentials", + 10 => "RemoteInteractive", + 11 => "CachedInteractive", + 12 => "CachedRemoteInteractive", + 13 => "CachedUnlock", + _ => null + }; + } + + private static string? ResolveDescription(string providerName, int eventId, string fieldName) + { + // Most specific: provider + event id + field. + foreach (GlossaryEntry entry in s_glossary) + { + if (entry.EventId == eventId + && entry.Provider is { } scopedProvider + && string.Equals(scopedProvider, providerName, StringComparison.OrdinalIgnoreCase) + && string.Equals(entry.Field, fieldName, StringComparison.OrdinalIgnoreCase)) + { + return entry.Description; + } + } + + // Provider + field (any event from that provider). + foreach (GlossaryEntry entry in s_glossary) + { + if (entry.EventId is null + && entry.Provider is { } scopedProvider + && string.Equals(scopedProvider, providerName, StringComparison.OrdinalIgnoreCase) + && string.Equals(entry.Field, fieldName, StringComparison.OrdinalIgnoreCase)) + { + return entry.Description; + } + } + + // Provider-agnostic allowlist, never applied to an ambiguous bare name. + if (s_ambiguousFieldNames.Contains(fieldName)) { return null; } + + foreach (GlossaryEntry entry in s_glossary) + { + if (entry is { Provider: null, EventId: null } + && string.Equals(entry.Field, fieldName, StringComparison.OrdinalIgnoreCase)) + { + return entry.Description; + } + } + + return null; + } + + private static string? TryDecodeValue(string fieldName, in EventFieldValue value) => + s_valueDecoders.TryGetValue(fieldName, out Func? decoder) + ? decoder(value) + : null; + + private static bool TryReadWholeNumber(in EventFieldValue value, out ulong number) + { + if (value.TryGetUInt64(out number)) { return true; } + + if (value.TryGetInt64(out long signed) && signed >= 0) + { + number = (ulong)signed; + + return true; + } + + // Strict parse only for a string-typed value: no sign, whitespace, or fractional part. + if (value.Kind == EventFieldValueKind.String + && ulong.TryParse(value.AsString(), NumberStyles.None, CultureInfo.InvariantCulture, out number)) + { + return true; + } + + number = 0; + + return false; + } + + private sealed record GlossaryEntry(string? Provider, int? EventId, string Field, string Description); +} diff --git a/src/EventLogExpert.Runtime/DetailsPane/EventFieldExplanation.cs b/src/EventLogExpert.Runtime/DetailsPane/EventFieldExplanation.cs new file mode 100644 index 000000000..7ac8dc764 --- /dev/null +++ b/src/EventLogExpert.Runtime/DetailsPane/EventFieldExplanation.cs @@ -0,0 +1,15 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.DetailsPane; + +/// +/// An inline explanation for a single EventData field. is a human reading of the +/// field's value (e.g. a logon type of 3 to "Network") shown beside the raw value and included in copy; +/// is glossary prose about what the field means, shown as a muted note and excluded from +/// copy. The two resolve independently, so a field may carry one, both, or neither. +/// +public readonly record struct EventFieldExplanation(string? DecodedLabel, string? Description) +{ + public bool HasValue => DecodedLabel is not null || Description is not null; +} diff --git a/src/EventLogExpert.Runtime/FilterPane/FilterPaneState.cs b/src/EventLogExpert.Runtime/FilterPane/FilterPaneState.cs index ce39cad5a..249f6faee 100644 --- a/src/EventLogExpert.Runtime/FilterPane/FilterPaneState.cs +++ b/src/EventLogExpert.Runtime/FilterPane/FilterPaneState.cs @@ -17,7 +17,20 @@ public sealed record FilterPaneState public bool IsEnabled { get; init; } = true; - public bool IsFilteringEnabled => - FilteredDateRange?.IsEnabled is true || - (IsEnabled ? Filters.Any(filter => filter.IsEnabled) : Filters.Any(filter => filter.IsExcluded)); + public bool IsFilteringEnabled + { + get + { + if (FilteredDateRange?.IsEnabled is true) { return true; } + + // foreach over the typed ImmutableList uses its struct enumerator; Filters.Any(predicate) would box the + // enumerator via IEnumerable, allocating on this frequently-evaluated render / state-selection path. + foreach (var filter in Filters) + { + if (IsEnabled ? filter.IsEnabled : filter.IsExcluded) { return true; } + } + + return false; + } + } } diff --git a/src/EventLogExpert.UI/DetailsPane/DetailsFieldRow.razor b/src/EventLogExpert.UI/DetailsPane/DetailsFieldRow.razor new file mode 100644 index 000000000..c1b06b00c --- /dev/null +++ b/src/EventLogExpert.UI/DetailsPane/DetailsFieldRow.razor @@ -0,0 +1,48 @@ +
+
+ @Field.Label + +
+
+ @if (Field.DecodedLabel is { } decoded) + { +
@Field.PreviewLines[0] (@decoded)
+ } + else + { + @foreach (string line in (IsExpanded ? Field.FullLines : Field.PreviewLines)) + { +
@line
+ } + + @if (Field.IsTruncated) + { + + } + } + + @if (Field.Description is { } description) + { +
@description
+ } +
+
+ +@code { + [Parameter] + [EditorRequired] + public DetailsField Field { get; set; } = null!; + + [Parameter] + public bool IsExpanded { get; set; } + + [Parameter] + public EventCallback OnCopy { get; set; } + + [Parameter] + public EventCallback OnToggleExpand { get; set; } +} diff --git a/src/EventLogExpert.UI/DetailsPane/DetailsFieldRow.razor.css b/src/EventLogExpert.UI/DetailsPane/DetailsFieldRow.razor.css new file mode 100644 index 000000000..0d2d7b1ec --- /dev/null +++ b/src/EventLogExpert.UI/DetailsPane/DetailsFieldRow.razor.css @@ -0,0 +1,116 @@ +.details-field { + padding: 0.3rem 0; + border-bottom: 1px solid var(--border-divider); + + /* is-wide fields (arrays, hex, long scalars, explained fields) span every column of the parent EventData grid so + their value block gets the full payload width instead of a single packed card. */ + &.is-wide { + grid-column: 1 / -1; + } + + &.is-muted .details-field-value { + color: var(--text-secondary); + } + + &:last-child { + border-bottom: 0; + } +} + +/* Stacked: the field name (+ hover copy button) sits on its own line above a full-width value block, so even long + Security/Sysmon field names get the whole card width and never truncate. */ +.details-field-name { + display: flex; + align-items: baseline; + gap: 4px; + min-width: 0; +} + +.details-field-name > span { + min-width: 0; + overflow-wrap: anywhere; + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + line-height: 1.3; +} + +.details-field-value { + min-width: 0; + margin-top: 0.1rem; + color: var(--text-primary); + overflow-wrap: anywhere; +} + +.details-value-line { + font-size: 13px; + line-height: 1.35; +} + +.details-value-line.is-mono { + font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; + font-size: 13px; +} + +.details-decoded { + display: inline-block; + margin-left: 0.35rem; + padding: 0 0.4rem; + border: 1px solid var(--border-divider); + border-radius: 999px; + color: var(--text-primary); + background: var(--surface-inset); + font-size: 12px; + font-weight: 600; + line-height: 1.4; +} + +.details-explain { + max-width: 90ch; + margin-top: 0.15rem; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.35; +} + +.details-show-more { + appearance: none; + margin-top: 2px; + padding: 0; + border: 0; + font: inherit; + font-size: 12px; + color: var(--text-secondary); + text-decoration: underline; + background: none; + cursor: pointer; + + &:hover, + &:focus-visible { + color: var(--text-primary); + } +} + +.details-copy { + appearance: none; + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + margin-left: auto; + padding: 0; + border: 0; + border-radius: 4px; + color: var(--text-secondary); + background: none; + opacity: 0.5; + cursor: pointer; +} + +.details-field:hover .details-copy, +.details-copy:focus-visible { + color: var(--text-primary); + opacity: 1; +} diff --git a/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor b/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor index dcbe238b5..06e0bb59f 100644 --- a/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor +++ b/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor @@ -1,64 +1,235 @@ @inherits FluxorComponent -