From 2f9d32e1108c3a633d7ce4f2a551569941487d8f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 18:13:58 +0000 Subject: [PATCH] Collapse severity and delta chips behind an expandable detail area The severity chip row could reach seven chips on a single card, which wraps into a block on a phone while the decision is usually made on the critical and high counts alone. VulnerabilitySeverityChips gains an opt-in Compact parameter that keeps those two chips and folds medium, low and unknown into one neutral counter chip, leaving the eight existing call sites untouched by default. The vulnerability assessment card now leads with that compact row and moves the full breakdown plus the reworded scan deltas into a collapse, and the four list pages opt into the compact row while the dashboard and the base-image chain rows keep the full breakdown. Refs #209 --- .../Components/Pages/MyImages.razor | 2 +- .../Components/Pages/ObservedImages.razor | 2 +- .../Components/Pages/RuntimeContainers.razor | 2 +- .../Components/Pages/SharedBaseImages.razor | 2 +- .../Shared/VulnerabilityAssessmentCard.razor | 28 +++-- .../VulnerabilityAssessmentCard.razor.cs | 66 ++++++++++++ .../Shared/VulnerabilitySeverityChips.razor | 25 +++-- .../VulnerabilitySeverityChips.razor.cs | 25 ++++- src/DockerUpdateGuard/wwwroot/app.css | 15 +++ .../VulnerabilityAssessmentCardTests.cs | 101 +++++++++++++++++- .../VulnerabilitySeverityChipsTests.cs | 88 +++++++++++++++ 11 files changed, 332 insertions(+), 24 deletions(-) diff --git a/src/DockerUpdateGuard/Components/Pages/MyImages.razor b/src/DockerUpdateGuard/Components/Pages/MyImages.razor index 99a7786..c027605 100644 --- a/src/DockerUpdateGuard/Components/Pages/MyImages.razor +++ b/src/DockerUpdateGuard/Components/Pages/MyImages.razor @@ -68,7 +68,7 @@ @context.VulnerabilityStatus - + @($"{context.ActiveVulnerabilityFindingCount} active · {context.VulnerabilityMessage}") @if (context.ActiveBaseImageVulnerabilityFindingCount > 0) { diff --git a/src/DockerUpdateGuard/Components/Pages/ObservedImages.razor b/src/DockerUpdateGuard/Components/Pages/ObservedImages.razor index bd933ac..700a4d0 100644 --- a/src/DockerUpdateGuard/Components/Pages/ObservedImages.razor +++ b/src/DockerUpdateGuard/Components/Pages/ObservedImages.razor @@ -119,7 +119,7 @@ @context.VulnerabilityStatus - + @($"{context.ActiveVulnerabilityFindingCount} active · {context.VulnerabilityMessage}") @if (context.ActiveBaseImageVulnerabilityFindingCount > 0) { diff --git a/src/DockerUpdateGuard/Components/Pages/RuntimeContainers.razor b/src/DockerUpdateGuard/Components/Pages/RuntimeContainers.razor index 2a3c7db..e6f0f38 100644 --- a/src/DockerUpdateGuard/Components/Pages/RuntimeContainers.razor +++ b/src/DockerUpdateGuard/Components/Pages/RuntimeContainers.razor @@ -126,7 +126,7 @@ @if (context.ActiveVulnerabilityFindingCount > 0) { Runtime vulnerabilities - + @($"{context.ActiveVulnerabilityFindingCount} active · {context.VulnerabilitySummary}") } @if (context.ActiveBaseImageVulnerabilityFindingCount > 0) diff --git a/src/DockerUpdateGuard/Components/Pages/SharedBaseImages.razor b/src/DockerUpdateGuard/Components/Pages/SharedBaseImages.razor index 77b9379..8cac55e 100644 --- a/src/DockerUpdateGuard/Components/Pages/SharedBaseImages.razor +++ b/src/DockerUpdateGuard/Components/Pages/SharedBaseImages.razor @@ -72,7 +72,7 @@ @context.ObservedImageCount @context.VulnerabilityAssessment.Status - + @($"{context.VulnerabilityAssessment.ActiveFindingCount} active · {context.VulnerabilityAssessment.Source}") @if (string.IsNullOrWhiteSpace(context.VulnerabilityAssessment.Message) == false) { diff --git a/src/DockerUpdateGuard/Components/Shared/VulnerabilityAssessmentCard.razor b/src/DockerUpdateGuard/Components/Shared/VulnerabilityAssessmentCard.razor index 1edffae..a7a3403 100644 --- a/src/DockerUpdateGuard/Components/Shared/VulnerabilityAssessmentCard.razor +++ b/src/DockerUpdateGuard/Components/Shared/VulnerabilityAssessmentCard.razor @@ -7,14 +7,26 @@ @RemediationSummary } @Assessment.Status - - @if (Assessment.NewFindingCount > 0) + + @if (HasCollapsibleDetail) { - @($"+{Assessment.NewFindingCount} new") - } - @if (Assessment.ResolvedFindingCount > 0) - { - @($"−{Assessment.ResolvedFindingCount} resolved") + @DetailToggleLabel + + + @if (Assessment.NewFindingCount > 0) + { + @($"+{Assessment.NewFindingCount} since last scan") + } + @if (Assessment.ResolvedFindingCount > 0) + { + @($"−{Assessment.ResolvedFindingCount} fixed since last scan") + } + } @($"{Assessment.Source} · {Assessment.ActiveFindingCount} active") @if (ShowCheckedAt && Assessment.CheckedAtUtc is DateTimeOffset checkedAtUtc) @@ -26,4 +38,4 @@ @Assessment.Message } -} \ No newline at end of file +} diff --git a/src/DockerUpdateGuard/Components/Shared/VulnerabilityAssessmentCard.razor.cs b/src/DockerUpdateGuard/Components/Shared/VulnerabilityAssessmentCard.razor.cs index bf4685d..e241d3f 100644 --- a/src/DockerUpdateGuard/Components/Shared/VulnerabilityAssessmentCard.razor.cs +++ b/src/DockerUpdateGuard/Components/Shared/VulnerabilityAssessmentCard.razor.cs @@ -21,8 +21,27 @@ public partial class VulnerabilityAssessmentCard /// private const string NoFindingsStatus = "No findings"; + /// + /// Label of the toggle while the detail area is collapsed + /// + private const string ShowDetailLabel = "Show details"; + + /// + /// Label of the toggle while the detail area is expanded + /// + private const string HideDetailLabel = "Hide details"; + #endregion // Constants + #region Fields + + /// + /// Whether the detail area with the full severity row and the scan deltas is expanded + /// + private bool _isDetailExpanded; + + #endregion // Fields + #region Properties /// @@ -97,5 +116,52 @@ private bool ShowMessage } } + /// + /// Whether the card offers a detail area, which is the case as soon as the compact severity row + /// folds at least one finding away or the last scan reported a new or resolved finding + /// + private bool HasCollapsibleDetail + { + get + { + if (Assessment is null) + { + return false; + } + + if (Assessment.NewFindingCount > 0 || Assessment.ResolvedFindingCount > 0) + { + return true; + } + + var severitySummary = Assessment.SeveritySummary; + + return severitySummary.MediumCount + severitySummary.LowCount + severitySummary.OtherCount > 0; + } + } + + /// + /// Label of the toggle that expands and collapses the detail area + /// + private string DetailToggleLabel => _isDetailExpanded ? HideDetailLabel : ShowDetailLabel; + + /// + /// Expanded state of the detail area as the literal the aria-expanded attribute expects, + /// because a bool would be dropped from the markup while it is false + /// + private string DetailExpandedState => _isDetailExpanded ? "true" : "false"; + #endregion // Properties + + #region Methods + + /// + /// Expand or collapse the detail area + /// + private void ToggleDetail() + { + _isDetailExpanded = _isDetailExpanded == false; + } + + #endregion // Methods } \ No newline at end of file diff --git a/src/DockerUpdateGuard/Components/Shared/VulnerabilitySeverityChips.razor b/src/DockerUpdateGuard/Components/Shared/VulnerabilitySeverityChips.razor index 90dbac5..3e0d317 100644 --- a/src/DockerUpdateGuard/Components/Shared/VulnerabilitySeverityChips.razor +++ b/src/DockerUpdateGuard/Components/Shared/VulnerabilitySeverityChips.razor @@ -8,16 +8,23 @@ { @($"{Summary.HighCount} High") } - @if (Summary.MediumCount > 0) + @if (Compact == false) { - @($"{Summary.MediumCount} Medium") + @if (Summary.MediumCount > 0) + { + @($"{Summary.MediumCount} Medium") + } + @if (Summary.LowCount > 0) + { + @($"{Summary.LowCount} Low") + } + @if (Summary.OtherCount > 0) + { + @($"{Summary.OtherCount} Unknown") + } } - @if (Summary.LowCount > 0) + else if (FoldedCount > 0) { - @($"{Summary.LowCount} Low") + @($"+{FoldedCount} more") } - @if (Summary.OtherCount > 0) - { - @($"{Summary.OtherCount} Unknown") - } -} \ No newline at end of file +} diff --git a/src/DockerUpdateGuard/Components/Shared/VulnerabilitySeverityChips.razor.cs b/src/DockerUpdateGuard/Components/Shared/VulnerabilitySeverityChips.razor.cs index aa65a3c..8e85467 100644 --- a/src/DockerUpdateGuard/Components/Shared/VulnerabilitySeverityChips.razor.cs +++ b/src/DockerUpdateGuard/Components/Shared/VulnerabilitySeverityChips.razor.cs @@ -5,7 +5,8 @@ namespace DockerUpdateGuard.Components.Shared; /// -/// Compact severity chip row for a vulnerability severity summary +/// Severity chip row for a vulnerability severity summary, either as the full breakdown +/// or as a compact row that folds everything below the high severity into one counter chip /// public partial class VulnerabilitySeverityChips { @@ -17,5 +18,27 @@ public partial class VulnerabilitySeverityChips [Parameter] public VulnerabilitySeveritySummaryViewData? Summary { get; set; } + /// + /// Whether everything below the high severity is folded into a single counter chip + /// + [Parameter] + public bool Compact { get; set; } + + /// + /// Number of findings folded into the counter chip of the compact row + /// + private int FoldedCount + { + get + { + if (Summary is null) + { + return 0; + } + + return Summary.MediumCount + Summary.LowCount + Summary.OtherCount; + } + } + #endregion // Properties } \ No newline at end of file diff --git a/src/DockerUpdateGuard/wwwroot/app.css b/src/DockerUpdateGuard/wwwroot/app.css index 4cf0ca8..fc83095 100644 --- a/src/DockerUpdateGuard/wwwroot/app.css +++ b/src/DockerUpdateGuard/wwwroot/app.css @@ -558,6 +558,21 @@ a { color: #f59e0b; } +.summary-card__detail-toggle.mud-button-root { + display: flex; + margin-top: 0.3rem; + padding: 0.1rem 0.35rem; + min-width: 0; + color: var(--dug-text-faint); + font-size: 0.82rem; + font-weight: 600; + text-transform: none; +} + +.summary-card__detail { + margin-top: 0.1rem; +} + .dug-chart { position: relative; min-height: 200px; diff --git a/src/Tests/DockerUpdateGuard.Tests/VulnerabilityAssessmentCardTests.cs b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityAssessmentCardTests.cs index 102d9a8..304f9f6 100644 --- a/src/Tests/DockerUpdateGuard.Tests/VulnerabilityAssessmentCardTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/VulnerabilityAssessmentCardTests.cs @@ -60,8 +60,8 @@ public async Task VulnerabilityAssessmentCardPopulatedAssessmentRendersStatusChi Assert.Contains("Findings detected", markup, "The card must render the assessment status"); Assert.Contains("1 Critical", markup, "The card must render the severity chips"); Assert.Contains("2 High", markup, "The card must render the severity chips"); - Assert.Contains("+2 new", markup, "The card must render the new finding count"); - Assert.Contains("−3 resolved", markup, "The card must render the resolved finding count"); + Assert.Contains("+2 since last scan", markup, "The card must render the new finding count"); + Assert.Contains("−3 fixed since last scan", markup, "The card must render the resolved finding count"); Assert.Contains("Trivy · 6 active", markup, "The card must render the source and the active finding count"); Assert.Contains("4 of 6 findings can be fixed by updating this image", markup, "The card must lead with the fixable finding statement"); Assert.DoesNotContain("Scan completed with findings", markup, "The card must suppress the provider message for a completed scan with findings"); @@ -203,6 +203,103 @@ public async Task VulnerabilityAssessmentCardEmptyAssessmentOmitsOptionalLines() } } + /// + /// Verify the card leads with the compact severity row and keeps the detail area collapsed + /// + /// Task + [TestMethod] + public async Task VulnerabilityAssessmentCardFoldedSeveritiesRendersCompactRowWithCollapsedDetail() + { + var testContext = BlazorTestContextFactory.Create(); + + await using (testContext) + { + var assessment = CreateAssessment(); + + assessment.SeveritySummary.MediumCount = 12; + assessment.SeveritySummary.LowCount = 5; + assessment.SeveritySummary.OtherCount = 1; + + var component = testContext.Render(parameters => parameters.Add(card => card.Assessment, assessment)); + var markup = component.Markup; + var detailToggle = component.Find("button.summary-card__detail-toggle"); + + Assert.Contains("+18 more", markup, "The compact row must fold the lower severities into one counter chip"); + Assert.Contains("Show details", markup, "The collapsed card must offer to show the details"); + Assert.AreEqual("false", + detailToggle.GetAttribute("aria-expanded"), + "The collapsed card must report the detail area as not expanded"); + Assert.Contains("invisible", + component.Find("div.summary-card__detail").ClassName ?? string.Empty, + "The detail area must stay hidden while the card is collapsed"); + } + } + + /// + /// Verify expanding the card reveals the full severity row and the scan deltas + /// + /// Task + [TestMethod] + public async Task VulnerabilityAssessmentCardExpandedDetailRevealsFullSeverityRowAndScanDeltas() + { + var testContext = BlazorTestContextFactory.Create(); + + await using (testContext) + { + var assessment = CreateAssessment(); + + assessment.SeveritySummary.MediumCount = 12; + assessment.SeveritySummary.LowCount = 5; + assessment.SeveritySummary.OtherCount = 1; + + var component = testContext.Render(parameters => parameters.Add(card => card.Assessment, assessment)); + + await component.Find("button.summary-card__detail-toggle") + .ClickAsync() + .ConfigureAwait(false); + + var markup = component.Markup; + + Assert.Contains("Hide details", markup, "The expanded card must offer to hide the details again"); + Assert.AreEqual("true", + component.Find("button.summary-card__detail-toggle").GetAttribute("aria-expanded"), + "The expanded card must report the detail area as expanded"); + Assert.DoesNotContain("invisible", + component.Find("div.summary-card__detail").ClassName ?? string.Empty, + "The detail area must be visible while the card is expanded"); + Assert.Contains("12 Medium", markup, "The detail area must render the full severity breakdown"); + Assert.Contains("5 Low", markup, "The detail area must render the full severity breakdown"); + Assert.Contains("1 Unknown", markup, "The detail area must render the full severity breakdown"); + Assert.Contains("+2 since last scan", markup, "The detail area must name the comparison of the new finding count"); + Assert.Contains("−3 fixed since last scan", markup, "The detail area must name the comparison of the resolved finding count"); + } + } + + /// + /// Verify the card omits the toggle when the compact row already shows everything + /// + /// Task + [TestMethod] + public async Task VulnerabilityAssessmentCardWithoutFoldedSeveritiesOrDeltasOmitsDetailToggle() + { + var testContext = BlazorTestContextFactory.Create(); + + await using (testContext) + { + var assessment = CreateAssessment(); + + assessment.NewFindingCount = 0; + assessment.ResolvedFindingCount = 0; + + var component = testContext.Render(parameters => parameters.Add(card => card.Assessment, assessment)); + var markup = component.Markup; + + Assert.Contains("1 Critical", markup, "The compact row must still render the critical count"); + Assert.DoesNotContain("Show details", markup, "The card must omit the toggle when the compact row already shows everything"); + Assert.IsEmpty(component.FindAll("div.summary-card__detail"), "The card must omit the detail area when nothing is folded away"); + } + } + /// /// Verify a missing assessment renders nothing /// diff --git a/src/Tests/DockerUpdateGuard.Tests/VulnerabilitySeverityChipsTests.cs b/src/Tests/DockerUpdateGuard.Tests/VulnerabilitySeverityChipsTests.cs index 829efe2..81b0d06 100644 --- a/src/Tests/DockerUpdateGuard.Tests/VulnerabilitySeverityChipsTests.cs +++ b/src/Tests/DockerUpdateGuard.Tests/VulnerabilitySeverityChipsTests.cs @@ -87,6 +87,94 @@ public async Task VulnerabilitySeverityChipsEmptySummaryRendersNothing() } } + /// + /// Verify the compact row keeps the critical and high chips and folds everything below into one counter chip + /// + /// Task + [TestMethod] + public async Task VulnerabilitySeverityChipsCompactModeFoldsLowerSeveritiesIntoCounterChip() + { + var testContext = BlazorTestContextFactory.Create(); + + await using (testContext) + { + var summary = new VulnerabilitySeveritySummaryViewData + { + CriticalCount = 3, + HighCount = 7, + MediumCount = 12, + LowCount = 5, + OtherCount = 1, + }; + + var component = testContext.Render(parameters => parameters.Add(chips => chips.Summary, summary) + .Add(chips => chips.Compact, true)); + var markup = component.Markup; + + Assert.Contains("3 Critical", markup, "The compact row must keep the critical count"); + Assert.Contains("7 High", markup, "The compact row must keep the high count"); + Assert.Contains("+18 more", markup, "The compact row must fold the remaining severities into one counter chip"); + Assert.DoesNotContain("Medium", markup, "The compact row must not render the medium chip"); + Assert.DoesNotContain("Low", markup, "The compact row must not render the low chip"); + Assert.DoesNotContain("Unknown", markup, "The compact row must not render the unknown chip"); + } + } + + /// + /// Verify the compact row omits the counter chip when nothing is folded away + /// + /// Task + [TestMethod] + public async Task VulnerabilitySeverityChipsCompactModeWithoutLowerSeveritiesOmitsCounterChip() + { + var testContext = BlazorTestContextFactory.Create(); + + await using (testContext) + { + var summary = new VulnerabilitySeveritySummaryViewData + { + CriticalCount = 3, + HighCount = 7, + }; + + var component = testContext.Render(parameters => parameters.Add(chips => chips.Summary, summary) + .Add(chips => chips.Compact, true)); + var markup = component.Markup; + + Assert.Contains("3 Critical", markup, "The compact row must keep the critical count"); + Assert.Contains("7 High", markup, "The compact row must keep the high count"); + Assert.DoesNotContain("more", markup, "The compact row must omit the counter chip when no severity is folded away"); + } + } + + /// + /// Verify the compact row renders only the counter chip when no critical or high findings exist + /// + /// Task + [TestMethod] + public async Task VulnerabilitySeverityChipsCompactModeWithoutCriticalOrHighRendersCounterChipOnly() + { + var testContext = BlazorTestContextFactory.Create(); + + await using (testContext) + { + var summary = new VulnerabilitySeveritySummaryViewData + { + MediumCount = 365, + LowCount = 682, + OtherCount = 73, + }; + + var component = testContext.Render(parameters => parameters.Add(chips => chips.Summary, summary) + .Add(chips => chips.Compact, true)); + var markup = component.Markup; + + Assert.Contains("+1120 more", markup, "The compact row must fold every remaining severity into the counter chip"); + Assert.DoesNotContain("Critical", markup, "The compact row must not render a critical chip without critical findings"); + Assert.DoesNotContain("High", markup, "The compact row must not render a high chip without high findings"); + } + } + /// /// Verify a null severity summary renders no chips ///