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 ///