From eea275391761d716a8314dff2555392046796fcf Mon Sep 17 00:00:00 2001 From: jschick04 Date: Thu, 23 Jul 2026 03:14:46 +0000 Subject: [PATCH] Add severity shape icons and merge the details pane header --- .../LogTable/ColumnDefaults.cs | 2 +- src/EventLogExpert.UI/Common/SeverityIcon.cs | 19 +++++ .../DetailsPane/DetailsPane.razor | 79 +++++++++++-------- .../DetailsPane/DetailsPane.razor.cs | 9 --- .../DetailsPane/DetailsPane.razor.css | 59 ++++++++------ .../LogTable/LogTablePane.razor | 4 +- .../LogTable/LogTablePane.razor.cs | 10 +-- .../LogTable/LogTablePane.razor.css | 16 +--- src/EventLogExpert.UI/wwwroot/app.css | 15 ++++ .../Common/SeverityIconTests.cs | 36 +++++++++ .../DetailsPane/DetailsPaneTests.cs | 63 ++++++++++++--- .../LogTable/LogTablePaneLevelClassTests.cs | 10 +-- 12 files changed, 212 insertions(+), 110 deletions(-) create mode 100644 src/EventLogExpert.UI/Common/SeverityIcon.cs create mode 100644 tests/Unit/EventLogExpert.UI.Tests/Common/SeverityIconTests.cs diff --git a/src/EventLogExpert.Runtime/LogTable/ColumnDefaults.cs b/src/EventLogExpert.Runtime/LogTable/ColumnDefaults.cs index 6fd4be65..1379f1c9 100644 --- a/src/EventLogExpert.Runtime/LogTable/ColumnDefaults.cs +++ b/src/EventLogExpert.Runtime/LogTable/ColumnDefaults.cs @@ -37,7 +37,7 @@ internal sealed class ColumnDefaults : ILogTableColumnDefaultsProvider private static readonly FrozenDictionary s_widths = new Dictionary { [ColumnName.RecordId] = 90, - [ColumnName.Level] = 100, + [ColumnName.Level] = 115, [ColumnName.DateAndTime] = 160, [ColumnName.ActivityId] = 270, [ColumnName.Log] = 100, diff --git a/src/EventLogExpert.UI/Common/SeverityIcon.cs b/src/EventLogExpert.UI/Common/SeverityIcon.cs new file mode 100644 index 00000000..21cdb1ce --- /dev/null +++ b/src/EventLogExpert.UI/Common/SeverityIcon.cs @@ -0,0 +1,19 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; + +namespace EventLogExpert.UI.Common; + +internal static class SeverityIcon +{ + public static string CssClass(SeverityLevel? severity) => severity switch + { + SeverityLevel.Critical => "bi bi-exclamation-octagon-fill critical", + SeverityLevel.Error => "bi bi-exclamation-circle error", + SeverityLevel.Warning => "bi bi-exclamation-triangle warning", + SeverityLevel.Information => "bi bi-info-circle", + SeverityLevel.Verbose => "bi bi-circle verbose", + _ => string.Empty + }; +} diff --git a/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor b/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor index 06e0bb59..0138d6a9 100644 --- a/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor +++ b/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor @@ -3,20 +3,8 @@ + - + } +

} diff --git a/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor.cs b/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor.cs index 52b9a19f..5b1d0e2e 100644 --- a/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor.cs +++ b/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor.cs @@ -143,15 +143,6 @@ protected override void OnInitialized() base.OnInitialized(); } - // Only Error/Warning get a coloured severity dot, mirroring the log table's level colouring exactly (LevelSeverity + - // GetLevelClass); Critical/Information/Verbose/unknown show no dot, matching the table's neutral treatment there. - private static string? SeverityDot(SeverityLevel? severity) => severity switch - { - SeverityLevel.Error => "error", - SeverityLevel.Warning => "warning", - _ => null - }; - private async Task CopyEventAsync() { if (_model is { } model) diff --git a/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor.css b/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor.css index 1571c631..3ba0c97d 100644 --- a/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor.css +++ b/src/EventLogExpert.UI/DetailsPane/DetailsPane.razor.css @@ -15,7 +15,8 @@ } } -.details-pane[data-toggle="false"] ::deep > :not(#details-header) { display: none; } +.details-pane[data-toggle="false"] ::deep .details-body, +.details-pane[data-toggle="false"] ::deep .details-resizer { display: none; } .details-resizer { position: absolute; @@ -30,22 +31,41 @@ cursor: n-resize; } -.details-pane ::deep #details-header { - appearance: none; - padding: 0; - border: 0; - font: inherit; - text-align: left; - color: inherit; - background: none; - user-select: none; - cursor: pointer; +.details-headerbar { + container: details-headerbar / inline-size; + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + padding: 0 0.5rem; + border-bottom: 1px solid var(--border-divider); +} + +/* Narrow pane: drop the "Copy event" text (its aria-label stays) so the toggle + tabs + copy icon keep to one + row instead of clipping under the pane's overflow:hidden; flex-wrap above is the last-resort safety. */ +@container details-headerbar (max-width: 22rem) { + .details-copy-event-label { display: none; } +} + +.details-headerbar-actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; +} + +.details-headerbar-label { + padding: 5px 0; + color: var(--text-primary); + font-weight: 600; } .details-body { display: flex; flex-direction: column; - height: 100%; + flex: 1 1 auto; + min-height: 0; overflow: hidden; } @@ -54,8 +74,6 @@ flex: 0 0 auto; align-items: center; gap: 2px; - padding: 0 0.5rem; - border-bottom: 1px solid var(--border-divider); } .details-tab { @@ -142,17 +160,7 @@ font-size: 13px; } -.details-severity-dot { - width: 8px; - height: 8px; - flex: 0 0 auto; - border-radius: 50%; - background: var(--text-secondary); -} - -.details-severity-dot[data-severity="error"] { background: var(--clr-red); } - -.details-severity-dot[data-severity="warning"] { background: var(--clr-yellow); } +.details-summary-level .level-icon { margin-inline-end: 0; } .details-grid { display: grid; @@ -281,7 +289,6 @@ align-items: center; gap: 0.35rem; height: 24px; - margin-left: auto; padding: 0 0.55rem; border: 0; border-radius: 4px; diff --git a/src/EventLogExpert.UI/LogTable/LogTablePane.razor b/src/EventLogExpert.UI/LogTable/LogTablePane.razor index 79397990..1058d737 100644 --- a/src/EventLogExpert.UI/LogTable/LogTablePane.razor +++ b/src/EventLogExpert.UI/LogTable/LogTablePane.razor @@ -47,9 +47,9 @@ @oncontextmenu:stopPropagation="true" role="gridcell"> - @if (capturedColumn == ColumnName.Level) + @if (capturedColumn == ColumnName.Level && GetLevelClass(row.Lean.Level) is { Length: > 0 } levelIcon) { - + } @FindableCell((EventTableColumnFormatter.GetCellText(row.Lean, capturedColumn, _timeZoneSettings), isCurrentFindMatch)) diff --git a/src/EventLogExpert.UI/LogTable/LogTablePane.razor.cs b/src/EventLogExpert.UI/LogTable/LogTablePane.razor.cs index 673d3cb7..6a3c287c 100644 --- a/src/EventLogExpert.UI/LogTable/LogTablePane.razor.cs +++ b/src/EventLogExpert.UI/LogTable/LogTablePane.razor.cs @@ -16,6 +16,7 @@ using EventLogExpert.Runtime.LogTable; using EventLogExpert.Runtime.Menu; using EventLogExpert.Runtime.Settings; +using EventLogExpert.UI.Common; using EventLogExpert.UI.Common.Interop; using EventLogExpert.UI.LogTable.Grouping; using Fluxor; @@ -143,14 +144,7 @@ internal static ItemsProviderResult ComputeEventViewport( return new ItemsProviderResult(window, totalCount); } - internal static string GetLevelClass(string level) => - LevelSeverity.FromLevelName(level) switch - { - SeverityLevel.Error => "bi bi-exclamation-circle error", - SeverityLevel.Warning => "bi bi-exclamation-triangle warning", - SeverityLevel.Information => "bi bi-info-circle", - _ => string.Empty, - }; + internal static string GetLevelClass(string level) => SeverityIcon.CssClass(LevelSeverity.FromLevelName(level)); protected override async ValueTask DisposeAsyncCore(bool disposing) { diff --git a/src/EventLogExpert.UI/LogTable/LogTablePane.razor.css b/src/EventLogExpert.UI/LogTable/LogTablePane.razor.css index fb8cd20b..32da4bec 100644 --- a/src/EventLogExpert.UI/LogTable/LogTablePane.razor.css +++ b/src/EventLogExpert.UI/LogTable/LogTablePane.razor.css @@ -45,10 +45,6 @@ tr { user-select: none; } -.error { color: var(--clr-red); } - -.warning { color: var(--clr-yellow); } - .table-row { cursor: pointer; } .table-row.selected { @@ -58,9 +54,7 @@ tr { mode) can still tell which row is selected. */ box-shadow: inset 3px 0 0 var(--clr-statusbar); - & span.error { color: var(--accent-on-fill); } - - & span.warning { color: var(--accent-on-fill); } + & .level-icon { color: var(--accent-on-fill); } } /* Single rule applies any highlight color via the [data-highlight] palette @@ -74,9 +68,7 @@ tr { color: var(--hl-fg); box-shadow: inset 3px 0 0 var(--hl-fg); - & span.error { color: var(--hl-fg); } - - & span.warning { color: var(--hl-fg); } + & .level-icon { color: var(--hl-fg); } } /* Selected + highlighted rows: stack both cues so neither state is lost. @@ -104,9 +96,7 @@ tr { box-shadow: inset 3px 0 0 MarkText; forced-color-adjust: none; - & span.error { color: MarkText; } - - & span.warning { color: MarkText; } + & .level-icon { color: MarkText; } } .table-row.selected[data-highlight] { diff --git a/src/EventLogExpert.UI/wwwroot/app.css b/src/EventLogExpert.UI/wwwroot/app.css index 3e0f2c63..11577702 100644 --- a/src/EventLogExpert.UI/wwwroot/app.css +++ b/src/EventLogExpert.UI/wwwroot/app.css @@ -217,6 +217,21 @@ a { color: var(--clr-lightblue); } .validation-message { color: var(--clr-red); } +.level-icon { + display: inline-block; + width: 1em; + margin-inline-end: .4rem; + text-align: center; +} + +.level-icon.critical { color: var(--clr-red); } + +.level-icon.error { color: var(--clr-red); } + +.level-icon.warning { color: var(--clr-yellow); } + +.level-icon.verbose { color: var(--text-secondary); } + .blazor-error-boundary { background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; padding: 1rem 1rem 1rem 3.7rem; diff --git a/tests/Unit/EventLogExpert.UI.Tests/Common/SeverityIconTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Common/SeverityIconTests.cs new file mode 100644 index 00000000..7e8cdb07 --- /dev/null +++ b/tests/Unit/EventLogExpert.UI.Tests/Common/SeverityIconTests.cs @@ -0,0 +1,36 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Events; +using EventLogExpert.UI.Common; + +namespace EventLogExpert.UI.Tests.Common; + +public sealed class SeverityIconTests +{ + [Fact] + public void CssClass_EveryKnownLevelUsesADistinctGlyph() + { + SeverityLevel[] levels = + [SeverityLevel.Critical, SeverityLevel.Error, SeverityLevel.Warning, SeverityLevel.Information, SeverityLevel.Verbose]; + + var classes = levels.Select(level => SeverityIcon.CssClass(level)).ToArray(); + + Assert.Equal(classes.Length, classes.Distinct().Count()); + } + + [Fact] + public void CssClass_ForNull_ReturnsEmpty() => Assert.Equal(string.Empty, SeverityIcon.CssClass(null)); + + // Pins the shared severity -> icon/colour class map. Every known level gets a DISTINCT Bootstrap-Icon shape (so + // levels are legible without colour / in forced-colors); Critical/Error/Warning/Verbose add a colour class, + // Information is icon-only, and null falls through to "" so no icon span is rendered. + [Theory] + [InlineData(SeverityLevel.Critical, "bi bi-exclamation-octagon-fill critical")] + [InlineData(SeverityLevel.Error, "bi bi-exclamation-circle error")] + [InlineData(SeverityLevel.Warning, "bi bi-exclamation-triangle warning")] + [InlineData(SeverityLevel.Information, "bi bi-info-circle")] + [InlineData(SeverityLevel.Verbose, "bi bi-circle verbose")] + public void CssClass_ReturnsDistinctShapePerLevel(SeverityLevel level, string expected) => + Assert.Equal(expected, SeverityIcon.CssClass(level)); +} diff --git a/tests/Unit/EventLogExpert.UI.Tests/DetailsPane/DetailsPaneTests.cs b/tests/Unit/EventLogExpert.UI.Tests/DetailsPane/DetailsPaneTests.cs index 3c819ef2..b730ccf5 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/DetailsPane/DetailsPaneTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/DetailsPane/DetailsPaneTests.cs @@ -56,6 +56,28 @@ public DetailsPaneTests() Services.AddFluxor(options => options.ScanAssemblies(typeof(DetailsPaneComponent).Assembly)); } + [Fact] + public void CollapseToggle_ControlsTheDetailsBody() + { + var cut = SelectAndRender(EventWithData(("LogonType", 3))); + + Assert.Equal("details-body", cut.Find("#details-header").GetAttribute("aria-controls")); + Assert.NotNull(cut.Find("#details-body")); + } + + [Fact] + public void CollapsedPane_HidesTabsAndCopyButKeepsToggleAndLabel() + { + var cut = SelectAndRender(EventWithData(("LogonType", 3))); + + cut.Find("#details-header").Click(); + + Assert.NotNull(cut.Find("#details-header")); + Assert.Empty(cut.FindAll(".details-tabs")); + Assert.Empty(cut.FindAll(".details-copy-event")); + Assert.Equal("Details", cut.Find(".details-headerbar-label").TextContent.Trim()); + } + [Fact] public void CollapsedPane_ReopensOnNextSelection_WhenPreferenceOn() { @@ -105,6 +127,14 @@ public void CopyEventButton_InvokesClipboardWithEventText() _clipboard.Received(1).CopyTextAsync(Arg.Is(text => text != null && text.Contains("LogonType: 3 (Network)"))); } + [Fact] + public void CopyEvent_IsNotAChildOfTheTablist() + { + var cut = SelectAndRender(EventWithData(("LogonType", 3))); + + Assert.Empty(cut.Find("[role=tablist]").QuerySelectorAll(".details-copy-event")); + } + [Fact] public void CorrelationButtons_DispatchLensCommandsForSelectedEventAndOwningLog() { @@ -167,6 +197,15 @@ public void CorrelationSection_ShowsOnlyRelatedButton_WhenOnlyActivityIdPresent( Assert.Contains("Show related events", button.TextContent); } + [Fact] + public void ExpandedPane_RenamesReaderTabToDetails_AndDropsStandaloneLabel() + { + var cut = SelectAndRender(EventWithData(("LogonType", 3))); + + Assert.Equal("Details", cut.FindAll(".details-tab")[0].TextContent.Trim()); + Assert.Empty(cut.FindAll(".details-headerbar-label")); + } + [Fact] public void LegacyEvent_ShowsNoNamedFieldsFallback() { @@ -231,26 +270,26 @@ public void SelectingEvent_OpensThePane() Assert.Equal("true", cut.Find(".details-pane").GetAttribute("data-toggle")); } - [Theory] - [InlineData("Critical")] - [InlineData("Information")] - [InlineData("Verbose")] - [InlineData("Audit Success")] - public void SeverityDot_AbsentForNonErrorWarningLevels(string level) + [Fact] + public void SeverityIcon_AbsentForUnknownLevel() { - var cut = SelectAndRender(BaseEvent() with { Level = level }); + var cut = SelectAndRender(BaseEvent() with { Level = "Audit Success" }); - Assert.Empty(cut.FindAll(".details-severity-dot")); + Assert.Empty(cut.FindAll(".details-summary-level .level-icon")); } [Theory] - [InlineData("Error", "error")] - [InlineData("Warning", "warning")] - public void SeverityDot_ColoredForErrorAndWarning(string level, string expected) + [InlineData("Critical", "bi-exclamation-octagon-fill")] + [InlineData("Error", "bi-exclamation-circle")] + [InlineData("Warning", "bi-exclamation-triangle")] + [InlineData("Information", "bi-info-circle")] + [InlineData("Verbose", "bi-circle")] + public void SeverityIcon_ShownWithDistinctShapeForKnownLevels(string level, string expectedGlyph) { var cut = SelectAndRender(BaseEvent() with { Level = level }); - Assert.Equal(expected, cut.Find(".details-severity-dot").GetAttribute("data-severity")); + var icon = cut.Find(".details-summary-level .level-icon"); + Assert.Contains(expectedGlyph, icon.GetAttribute("class")); } [Fact] diff --git a/tests/Unit/EventLogExpert.UI.Tests/LogTable/LogTablePaneLevelClassTests.cs b/tests/Unit/EventLogExpert.UI.Tests/LogTable/LogTablePaneLevelClassTests.cs index 01fc1613..08b8a6b5 100644 --- a/tests/Unit/EventLogExpert.UI.Tests/LogTable/LogTablePaneLevelClassTests.cs +++ b/tests/Unit/EventLogExpert.UI.Tests/LogTable/LogTablePaneLevelClassTests.cs @@ -7,15 +7,15 @@ namespace EventLogExpert.UI.Tests.LogTable; public sealed class LogTablePaneLevelClassTests { - // Pins the level -> icon/colour class mapping after it was refactored to consume the shared - // LevelSeverity.FromLevelName parse: Error/Warning keep their colour class, Information is icon-only, and - // Critical/Verbose/miscased/unknown fall through to "" exactly as the pre-refactor case-sensitive switch did. + // Pins the level -> icon/colour class mapping (delegates to the shared SeverityIcon map via + // LevelSeverity.FromLevelName): every known level gets a distinct shape; Error/Warning/Critical/Verbose add a + // colour class, Information is icon-only; miscased/unknown parse to null and fall through to "". [Theory] + [InlineData("Critical", "bi bi-exclamation-octagon-fill critical")] [InlineData("Error", "bi bi-exclamation-circle error")] [InlineData("Warning", "bi bi-exclamation-triangle warning")] [InlineData("Information", "bi bi-info-circle")] - [InlineData("Critical", "")] - [InlineData("Verbose", "")] + [InlineData("Verbose", "bi bi-circle verbose")] [InlineData("error", "")] [InlineData("Audit Success", "")] [InlineData("", "")]