Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/DockerUpdateGuard/Components/Pages/MyImages.razor
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
</MudTd>
<MudTd DataLabel="Vulnerabilities">
<MudChip T="string" Size="Size.Small" Color="@GetVulnerabilityStatusColor(context.VulnerabilityStatus, context.VulnerabilitySeveritySummary)">@context.VulnerabilityStatus</MudChip>
<VulnerabilitySeverityChips Summary="context.VulnerabilitySeveritySummary" />
<VulnerabilitySeverityChips Summary="context.VulnerabilitySeveritySummary" Compact="true" />
<span class="table-secondary">@($"{context.ActiveVulnerabilityFindingCount} active · {context.VulnerabilityMessage}")</span>
@if (context.ActiveBaseImageVulnerabilityFindingCount > 0)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@
</MudTd>
<MudTd DataLabel="Vulnerabilities">
<MudChip T="string" Size="Size.Small" Color="@GetVulnerabilityStatusColor(context.VulnerabilityStatus, context.VulnerabilitySeveritySummary)">@context.VulnerabilityStatus</MudChip>
<VulnerabilitySeverityChips Summary="context.VulnerabilitySeveritySummary" />
<VulnerabilitySeverityChips Summary="context.VulnerabilitySeveritySummary" Compact="true" />
<span class="table-secondary">@($"{context.ActiveVulnerabilityFindingCount} active · {context.VulnerabilityMessage}")</span>
@if (context.ActiveBaseImageVulnerabilityFindingCount > 0)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@
@if (context.ActiveVulnerabilityFindingCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="@GetVulnerabilityStatusColor(context.VulnerabilityStatus, context.VulnerabilitySeveritySummary)">Runtime vulnerabilities</MudChip>
<VulnerabilitySeverityChips Summary="context.VulnerabilitySeveritySummary" />
<VulnerabilitySeverityChips Summary="context.VulnerabilitySeveritySummary" Compact="true" />
<span class="table-secondary">@($"{context.ActiveVulnerabilityFindingCount} active · {context.VulnerabilitySummary}")</span>
}
@if (context.ActiveBaseImageVulnerabilityFindingCount > 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
<MudTd DataLabel="Observed Images">@context.ObservedImageCount</MudTd>
<MudTd DataLabel="Vulnerabilities">
<MudChip T="string" Size="Size.Small" Color="@GetVulnerabilityStatusColor(context.VulnerabilityAssessment.Status, context.VulnerabilityAssessment.SeveritySummary)">@context.VulnerabilityAssessment.Status</MudChip>
<VulnerabilitySeverityChips Summary="context.VulnerabilityAssessment.SeveritySummary" />
<VulnerabilitySeverityChips Summary="context.VulnerabilityAssessment.SeveritySummary" Compact="true" />
<span class="table-secondary">@($"{context.VulnerabilityAssessment.ActiveFindingCount} active · {context.VulnerabilityAssessment.Source}")</span>
@if (string.IsNullOrWhiteSpace(context.VulnerabilityAssessment.Message) == false)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,26 @@
<span class="@RemediationCssClass">@RemediationSummary</span>
}
<MudChip T="string" Size="Size.Small" Color="@VulnerabilityDisplayFormatter.GetStatusColor(Assessment.Status, Assessment.SeveritySummary)">@Assessment.Status</MudChip>
<VulnerabilitySeverityChips Summary="Assessment.SeveritySummary" />
@if (Assessment.NewFindingCount > 0)
<VulnerabilitySeverityChips Summary="Assessment.SeveritySummary" Compact="true" />
@if (HasCollapsibleDetail)
{
<MudChip T="string" Size="Size.Small" Color="Color.Error">@($"+{Assessment.NewFindingCount} new")</MudChip>
}
@if (Assessment.ResolvedFindingCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">@($"−{Assessment.ResolvedFindingCount} resolved")</MudChip>
<MudButton Variant="Variant.Text"
Size="Size.Small"
Class="summary-card__detail-toggle"
EndIcon="@(_isDetailExpanded ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
OnClick="ToggleDetail"
aria-expanded="@DetailExpandedState">@DetailToggleLabel</MudButton>
<MudCollapse Expanded="_isDetailExpanded" Class="summary-card__detail">
<VulnerabilitySeverityChips Summary="Assessment.SeveritySummary" Compact="false" />
@if (Assessment.NewFindingCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Error">@($"+{Assessment.NewFindingCount} since last scan")</MudChip>
}
@if (Assessment.ResolvedFindingCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">@($"−{Assessment.ResolvedFindingCount} fixed since last scan")</MudChip>
}
</MudCollapse>
}
<span class="table-secondary">@($"{Assessment.Source} · {Assessment.ActiveFindingCount} active")</span>
@if (ShowCheckedAt && Assessment.CheckedAtUtc is DateTimeOffset checkedAtUtc)
Expand All @@ -26,4 +38,4 @@
<span class="table-secondary">@Assessment.Message</span>
}
</MudPaper>
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,27 @@ public partial class VulnerabilityAssessmentCard
/// </summary>
private const string NoFindingsStatus = "No findings";

/// <summary>
/// Label of the toggle while the detail area is collapsed
/// </summary>
private const string ShowDetailLabel = "Show details";

/// <summary>
/// Label of the toggle while the detail area is expanded
/// </summary>
private const string HideDetailLabel = "Hide details";

#endregion // Constants

#region Fields

/// <summary>
/// Whether the detail area with the full severity row and the scan deltas is expanded
/// </summary>
private bool _isDetailExpanded;

#endregion // Fields

#region Properties

/// <summary>
Expand Down Expand Up @@ -97,5 +116,52 @@ private bool ShowMessage
}
}

/// <summary>
/// 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
/// </summary>
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;
}
}

/// <summary>
/// Label of the toggle that expands and collapses the detail area
/// </summary>
private string DetailToggleLabel => _isDetailExpanded ? HideDetailLabel : ShowDetailLabel;

/// <summary>
/// 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
/// </summary>
private string DetailExpandedState => _isDetailExpanded ? "true" : "false";

#endregion // Properties

#region Methods

/// <summary>
/// Expand or collapse the detail area
/// </summary>
private void ToggleDetail()
{
_isDetailExpanded = _isDetailExpanded == false;
}

#endregion // Methods
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,23 @@
{
<MudChip T="string" Size="Size.Small" Color="Color.Warning">@($"{Summary.HighCount} High")</MudChip>
}
@if (Summary.MediumCount > 0)
@if (Compact == false)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info">@($"{Summary.MediumCount} Medium")</MudChip>
@if (Summary.MediumCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info">@($"{Summary.MediumCount} Medium")</MudChip>
}
@if (Summary.LowCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">@($"{Summary.LowCount} Low")</MudChip>
}
@if (Summary.OtherCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Default">@($"{Summary.OtherCount} Unknown")</MudChip>
}
}
@if (Summary.LowCount > 0)
else if (FoldedCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">@($"{Summary.LowCount} Low")</MudChip>
<MudChip T="string" Size="Size.Small" Color="Color.Default">@($"+{FoldedCount} more")</MudChip>
}
@if (Summary.OtherCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Default">@($"{Summary.OtherCount} Unknown")</MudChip>
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
namespace DockerUpdateGuard.Components.Shared;

/// <summary>
/// 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
/// </summary>
public partial class VulnerabilitySeverityChips
{
Expand All @@ -17,5 +18,27 @@ public partial class VulnerabilitySeverityChips
[Parameter]
public VulnerabilitySeveritySummaryViewData? Summary { get; set; }

/// <summary>
/// Whether everything below the high severity is folded into a single counter chip
/// </summary>
[Parameter]
public bool Compact { get; set; }

/// <summary>
/// Number of findings folded into the counter chip of the compact row
/// </summary>
private int FoldedCount
{
get
{
if (Summary is null)
{
return 0;
}

return Summary.MediumCount + Summary.LowCount + Summary.OtherCount;
}
}

#endregion // Properties
}
15 changes: 15 additions & 0 deletions src/DockerUpdateGuard/wwwroot/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
101 changes: 99 additions & 2 deletions src/Tests/DockerUpdateGuard.Tests/VulnerabilityAssessmentCardTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -203,6 +203,103 @@ public async Task VulnerabilityAssessmentCardEmptyAssessmentOmitsOptionalLines()
}
}

/// <summary>
/// Verify the card leads with the compact severity row and keeps the detail area collapsed
/// </summary>
/// <returns>Task</returns>
[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<VulnerabilityAssessmentCard>(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");
}
}

/// <summary>
/// Verify expanding the card reveals the full severity row and the scan deltas
/// </summary>
/// <returns>Task</returns>
[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<VulnerabilityAssessmentCard>(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");
}
}

/// <summary>
/// Verify the card omits the toggle when the compact row already shows everything
/// </summary>
/// <returns>Task</returns>
[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<VulnerabilityAssessmentCard>(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");
}
}

/// <summary>
/// Verify a missing assessment renders nothing
/// </summary>
Expand Down
Loading
Loading