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