feat: Add PathViewer and RiskDriftCard components with templates and styles
- Implemented PathViewerComponent for visualizing reachability call paths. - Added RiskDriftCardComponent to display reachability drift results. - Created corresponding HTML templates and SCSS styles for both components. - Introduced test fixtures for reachability analysis in JSON format. - Enhanced user interaction with collapsible and expandable features in PathViewer. - Included risk trend visualization and summary metrics in RiskDriftCard.
This commit is contained in:
320
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Drift.cs
Normal file
320
src/Cli/StellaOps.Cli/Commands/CommandHandlers.Drift.cs
Normal file
@@ -0,0 +1,320 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CommandHandlers.Drift.cs
|
||||
// Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
// Tasks: UI-019, UI-020, UI-021
|
||||
// Description: Command handlers for reachability drift CLI.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
internal static partial class CommandHandlers
|
||||
{
|
||||
private static readonly JsonSerializerOptions DriftJsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Handler for `drift compare` command.
|
||||
/// </summary>
|
||||
internal static async Task HandleDriftCompareAsync(
|
||||
IServiceProvider services,
|
||||
string baseId,
|
||||
string? headId,
|
||||
string? image,
|
||||
string? repo,
|
||||
string output,
|
||||
string minSeverity,
|
||||
bool onlyIncreases,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: Replace with actual service call when drift API is available
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Comparing drift: base={baseId}, head={headId ?? "(latest)"}[/]");
|
||||
}
|
||||
|
||||
// Placeholder: In real implementation, call drift service
|
||||
var driftResult = new DriftResultDto
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N")[..8],
|
||||
ComparedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
BaseGraphId = baseId,
|
||||
HeadGraphId = headId ?? "latest",
|
||||
Summary = new DriftSummaryDto
|
||||
{
|
||||
TotalSinks = 0,
|
||||
IncreasedReachability = 0,
|
||||
DecreasedReachability = 0,
|
||||
UnchangedReachability = 0,
|
||||
NewSinks = 0,
|
||||
RemovedSinks = 0,
|
||||
RiskTrend = "stable",
|
||||
NetRiskDelta = 0
|
||||
},
|
||||
DriftedSinks = Array.Empty<DriftedSinkDto>()
|
||||
};
|
||||
|
||||
switch (output)
|
||||
{
|
||||
case "json":
|
||||
await WriteJsonOutputAsync(console, driftResult, cancellationToken);
|
||||
break;
|
||||
case "sarif":
|
||||
await WriteSarifOutputAsync(console, driftResult, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
WriteTableOutput(console, driftResult, onlyIncreases, minSeverity);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handler for `drift show` command.
|
||||
/// </summary>
|
||||
internal static async Task HandleDriftShowAsync(
|
||||
IServiceProvider services,
|
||||
string id,
|
||||
string output,
|
||||
bool expandPaths,
|
||||
bool verbose,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var console = AnsiConsole.Console;
|
||||
|
||||
if (verbose)
|
||||
{
|
||||
console.MarkupLine($"[dim]Showing drift result: {id}[/]");
|
||||
}
|
||||
|
||||
// Placeholder: In real implementation, call drift service
|
||||
var driftResult = new DriftResultDto
|
||||
{
|
||||
Id = id,
|
||||
ComparedAt = DateTimeOffset.UtcNow.ToString("O"),
|
||||
BaseGraphId = "base",
|
||||
HeadGraphId = "head",
|
||||
Summary = new DriftSummaryDto
|
||||
{
|
||||
TotalSinks = 0,
|
||||
IncreasedReachability = 0,
|
||||
DecreasedReachability = 0,
|
||||
UnchangedReachability = 0,
|
||||
NewSinks = 0,
|
||||
RemovedSinks = 0,
|
||||
RiskTrend = "stable",
|
||||
NetRiskDelta = 0
|
||||
},
|
||||
DriftedSinks = Array.Empty<DriftedSinkDto>()
|
||||
};
|
||||
|
||||
switch (output)
|
||||
{
|
||||
case "json":
|
||||
await WriteJsonOutputAsync(console, driftResult, cancellationToken);
|
||||
break;
|
||||
case "sarif":
|
||||
await WriteSarifOutputAsync(console, driftResult, cancellationToken);
|
||||
break;
|
||||
default:
|
||||
WriteTableOutput(console, driftResult, false, "info");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Task: UI-020 - Table output using Spectre.Console
|
||||
private static void WriteTableOutput(
|
||||
IAnsiConsole console,
|
||||
DriftResultDto result,
|
||||
bool onlyIncreases,
|
||||
string minSeverity)
|
||||
{
|
||||
// Header panel
|
||||
var header = new Panel(new Markup($"[bold]Reachability Drift[/] [dim]({result.Id})[/]"))
|
||||
.Border(BoxBorder.Rounded)
|
||||
.Padding(1, 0);
|
||||
console.Write(header);
|
||||
|
||||
// Summary table
|
||||
var summaryTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.AddColumn("Metric")
|
||||
.AddColumn("Value");
|
||||
|
||||
summaryTable.AddRow("Trend", FormatTrend(result.Summary.RiskTrend));
|
||||
summaryTable.AddRow("Net Risk Delta", FormatDelta(result.Summary.NetRiskDelta));
|
||||
summaryTable.AddRow("Increased", result.Summary.IncreasedReachability.ToString());
|
||||
summaryTable.AddRow("Decreased", result.Summary.DecreasedReachability.ToString());
|
||||
summaryTable.AddRow("New Sinks", result.Summary.NewSinks.ToString());
|
||||
summaryTable.AddRow("Removed Sinks", result.Summary.RemovedSinks.ToString());
|
||||
|
||||
console.Write(summaryTable);
|
||||
|
||||
// Sinks table
|
||||
if (result.DriftedSinks.Length == 0)
|
||||
{
|
||||
console.MarkupLine("[green]No drifted sinks found.[/]");
|
||||
return;
|
||||
}
|
||||
|
||||
var sinksTable = new Table()
|
||||
.Border(TableBorder.Rounded)
|
||||
.AddColumn("Severity")
|
||||
.AddColumn("Sink")
|
||||
.AddColumn("CVE")
|
||||
.AddColumn("Bucket Change")
|
||||
.AddColumn("Delta");
|
||||
|
||||
var severityOrder = new Dictionary<string, int>
|
||||
{
|
||||
["critical"] = 0,
|
||||
["high"] = 1,
|
||||
["medium"] = 2,
|
||||
["low"] = 3,
|
||||
["info"] = 4
|
||||
};
|
||||
|
||||
var minSevOrder = severityOrder.GetValueOrDefault(minSeverity, 2);
|
||||
|
||||
foreach (var sink in result.DriftedSinks)
|
||||
{
|
||||
var sevOrder = severityOrder.GetValueOrDefault(sink.Severity ?? "info", 4);
|
||||
if (sevOrder > minSevOrder) continue;
|
||||
if (onlyIncreases && !sink.IsRiskIncrease) continue;
|
||||
|
||||
sinksTable.AddRow(
|
||||
FormatSeverity(sink.Severity),
|
||||
sink.SinkSymbol ?? "unknown",
|
||||
sink.CveId ?? "-",
|
||||
$"{sink.PreviousBucket ?? "N/A"} → {sink.CurrentBucket}",
|
||||
FormatDelta(sink.RiskDelta));
|
||||
}
|
||||
|
||||
console.Write(sinksTable);
|
||||
}
|
||||
|
||||
// Task: UI-021 - JSON output
|
||||
private static async Task WriteJsonOutputAsync(
|
||||
IAnsiConsole console,
|
||||
DriftResultDto result,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(result, DriftJsonOptions);
|
||||
console.WriteLine(json);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
// Task: UI-022, UI-023 - SARIF output (placeholder)
|
||||
private static async Task WriteSarifOutputAsync(
|
||||
IAnsiConsole console,
|
||||
DriftResultDto result,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: Implement full SARIF 2.1.0 generation in DriftSarifGenerator
|
||||
var sarif = new
|
||||
{
|
||||
version = "2.1.0",
|
||||
schema = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
||||
runs = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
tool = new
|
||||
{
|
||||
driver = new
|
||||
{
|
||||
name = "StellaOps Drift",
|
||||
version = "1.0.0",
|
||||
informationUri = "https://stellaops.io/docs/drift"
|
||||
}
|
||||
},
|
||||
results = result.DriftedSinks.Select(sink => new
|
||||
{
|
||||
ruleId = sink.CveId ?? $"drift-{sink.SinkSymbol}",
|
||||
level = MapSeverityToSarif(sink.Severity),
|
||||
message = new
|
||||
{
|
||||
text = $"Reachability changed: {sink.PreviousBucket ?? "N/A"} → {sink.CurrentBucket}"
|
||||
},
|
||||
locations = Array.Empty<object>()
|
||||
}).ToArray()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(sarif, DriftJsonOptions);
|
||||
console.WriteLine(json);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string FormatTrend(string trend) => trend switch
|
||||
{
|
||||
"increasing" => "[red]↑ Increasing[/]",
|
||||
"decreasing" => "[green]↓ Decreasing[/]",
|
||||
_ => "[dim]→ Stable[/]"
|
||||
};
|
||||
|
||||
private static string FormatDelta(int delta) => delta switch
|
||||
{
|
||||
> 0 => $"[red]+{delta}[/]",
|
||||
< 0 => $"[green]{delta}[/]",
|
||||
_ => "[dim]0[/]"
|
||||
};
|
||||
|
||||
private static string FormatSeverity(string? severity) => severity switch
|
||||
{
|
||||
"critical" => "[white on red] CRITICAL [/]",
|
||||
"high" => "[black on darkorange] HIGH [/]",
|
||||
"medium" => "[black on yellow] MEDIUM [/]",
|
||||
"low" => "[black on olive] LOW [/]",
|
||||
_ => "[dim] INFO [/]"
|
||||
};
|
||||
|
||||
private static string MapSeverityToSarif(string? severity) => severity switch
|
||||
{
|
||||
"critical" or "high" => "error",
|
||||
"medium" => "warning",
|
||||
_ => "note"
|
||||
};
|
||||
|
||||
// DTOs for drift output
|
||||
private sealed record DriftResultDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string ComparedAt { get; init; } = string.Empty;
|
||||
public string BaseGraphId { get; init; } = string.Empty;
|
||||
public string HeadGraphId { get; init; } = string.Empty;
|
||||
public DriftSummaryDto Summary { get; init; } = new();
|
||||
public DriftedSinkDto[] DriftedSinks { get; init; } = Array.Empty<DriftedSinkDto>();
|
||||
}
|
||||
|
||||
private sealed record DriftSummaryDto
|
||||
{
|
||||
public int TotalSinks { get; init; }
|
||||
public int IncreasedReachability { get; init; }
|
||||
public int DecreasedReachability { get; init; }
|
||||
public int UnchangedReachability { get; init; }
|
||||
public int NewSinks { get; init; }
|
||||
public int RemovedSinks { get; init; }
|
||||
public string RiskTrend { get; init; } = "stable";
|
||||
public int NetRiskDelta { get; init; }
|
||||
}
|
||||
|
||||
private sealed record DriftedSinkDto
|
||||
{
|
||||
public string? SinkSymbol { get; init; }
|
||||
public string? CveId { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public string? PreviousBucket { get; init; }
|
||||
public string CurrentBucket { get; init; } = string.Empty;
|
||||
public bool IsRiskIncrease { get; init; }
|
||||
public int RiskDelta { get; init; }
|
||||
}
|
||||
}
|
||||
160
src/Cli/StellaOps.Cli/Commands/DriftCommandGroup.cs
Normal file
160
src/Cli/StellaOps.Cli/Commands/DriftCommandGroup.cs
Normal file
@@ -0,0 +1,160 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// DriftCommandGroup.cs
|
||||
// Sprint: SPRINT_3600_0004_0001_ui_evidence_chain
|
||||
// Task: UI-019
|
||||
// Description: CLI command group for reachability drift detection.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.CommandLine;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Cli.Extensions;
|
||||
using Spectre.Console;
|
||||
|
||||
namespace StellaOps.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// CLI command group for reachability drift detection.
|
||||
/// </summary>
|
||||
internal static class DriftCommandGroup
|
||||
{
|
||||
internal static Command BuildDriftCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var drift = new Command("drift", "Reachability drift detection operations.");
|
||||
|
||||
drift.Add(BuildDriftCompareCommand(services, verboseOption, cancellationToken));
|
||||
drift.Add(BuildDriftShowCommand(services, verboseOption, cancellationToken));
|
||||
|
||||
return drift;
|
||||
}
|
||||
|
||||
private static Command BuildDriftCompareCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var baseOption = new Option<string>("--base", new[] { "-b" })
|
||||
{
|
||||
Description = "Base scan/graph ID or commit SHA for comparison.",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var headOption = new Option<string>("--head", new[] { "-h" })
|
||||
{
|
||||
Description = "Head scan/graph ID or commit SHA for comparison (defaults to latest)."
|
||||
};
|
||||
|
||||
var imageOption = new Option<string?>("--image", new[] { "-i" })
|
||||
{
|
||||
Description = "Container image reference (digest or tag)."
|
||||
};
|
||||
|
||||
var repoOption = new Option<string?>("--repo", new[] { "-r" })
|
||||
{
|
||||
Description = "Repository reference (owner/repo)."
|
||||
};
|
||||
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output format: table (default), json, sarif."
|
||||
}.SetDefaultValue("table").FromAmong("table", "json", "sarif");
|
||||
|
||||
var severityOption = new Option<string>("--min-severity")
|
||||
{
|
||||
Description = "Minimum severity to include: critical, high, medium, low, info."
|
||||
}.SetDefaultValue("medium").FromAmong("critical", "high", "medium", "low", "info");
|
||||
|
||||
var onlyIncreasesOption = new Option<bool>("--only-increases")
|
||||
{
|
||||
Description = "Only show sinks with increased reachability (risk increases)."
|
||||
};
|
||||
|
||||
var command = new Command("compare", "Compare reachability between two scans.")
|
||||
{
|
||||
baseOption,
|
||||
headOption,
|
||||
imageOption,
|
||||
repoOption,
|
||||
outputOption,
|
||||
severityOption,
|
||||
onlyIncreasesOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var baseId = parseResult.GetValue(baseOption)!;
|
||||
var headId = parseResult.GetValue(headOption);
|
||||
var image = parseResult.GetValue(imageOption);
|
||||
var repo = parseResult.GetValue(repoOption);
|
||||
var output = parseResult.GetValue(outputOption)!;
|
||||
var minSeverity = parseResult.GetValue(severityOption)!;
|
||||
var onlyIncreases = parseResult.GetValue(onlyIncreasesOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleDriftCompareAsync(
|
||||
services,
|
||||
baseId,
|
||||
headId,
|
||||
image,
|
||||
repo,
|
||||
output,
|
||||
minSeverity,
|
||||
onlyIncreases,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static Command BuildDriftShowCommand(
|
||||
IServiceProvider services,
|
||||
Option<bool> verboseOption,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var idOption = new Option<string>("--id")
|
||||
{
|
||||
Description = "Drift result ID to display.",
|
||||
Required = true
|
||||
};
|
||||
|
||||
var outputOption = new Option<string>("--output", new[] { "-o" })
|
||||
{
|
||||
Description = "Output format: table (default), json, sarif."
|
||||
}.SetDefaultValue("table").FromAmong("table", "json", "sarif");
|
||||
|
||||
var expandPathsOption = new Option<bool>("--expand-paths")
|
||||
{
|
||||
Description = "Show full call paths instead of compressed view."
|
||||
};
|
||||
|
||||
var command = new Command("show", "Show details of a drift result.")
|
||||
{
|
||||
idOption,
|
||||
outputOption,
|
||||
expandPathsOption,
|
||||
verboseOption
|
||||
};
|
||||
|
||||
command.SetAction(parseResult =>
|
||||
{
|
||||
var id = parseResult.GetValue(idOption)!;
|
||||
var output = parseResult.GetValue(outputOption)!;
|
||||
var expandPaths = parseResult.GetValue(expandPathsOption);
|
||||
var verbose = parseResult.GetValue(verboseOption);
|
||||
|
||||
return CommandHandlers.HandleDriftShowAsync(
|
||||
services,
|
||||
id,
|
||||
output,
|
||||
expandPaths,
|
||||
verbose,
|
||||
cancellationToken);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user