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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user