diff --git a/src/NFury/Commands/Server/ServerCommand.cs b/src/NFury/Commands/Server/ServerCommand.cs index d0707aa..fced230 100644 --- a/src/NFury/Commands/Server/ServerCommand.cs +++ b/src/NFury/Commands/Server/ServerCommand.cs @@ -252,6 +252,12 @@ public override async Task ExecuteAsync(CommandContext context, ServerSetti return Results.Ok(executions); }); + app.MapPost("/api/executions/compare", async (ExecutionComparisonRequest request, ExecutionService service) => + { + var result = await service.CompareExecutionsAsync(request.BaselineExecutionId, request.CompareExecutionId); + return result != null ? Results.Ok(result) : Results.NotFound(new ErrorResponse("One or both executions not found")); + }); + AnsiConsole.MarkupLine($"[bold green]NFury Web Server started![/]"); AnsiConsole.MarkupLine($"[blue]Open your browser at:[/] [link]http://{settings.Host}:{settings.Port}[/]"); AnsiConsole.MarkupLine($"[dim]Database:[/] {dbPath}"); diff --git a/src/NFury/Commands/Server/ServerSettings.cs b/src/NFury/Commands/Server/ServerSettings.cs index 11cee49..ef98e33 100644 --- a/src/NFury/Commands/Server/ServerSettings.cs +++ b/src/NFury/Commands/Server/ServerSettings.cs @@ -6,8 +6,8 @@ namespace NFury.Commands.Server; public class ServerSettings : CommandSettings { [CommandOption("-p|--port")] - [DefaultValue(5000)] - [Description("Define the port for the web server. Default is 5000.")] + [DefaultValue(5002)] + [Description("Define the port for the web server. Default is 5002.")] public int Port { get; set; } [CommandOption("--host")] diff --git a/src/NFury/Web/JsonContext.cs b/src/NFury/Web/JsonContext.cs index c75c545..b8d2a21 100644 --- a/src/NFury/Web/JsonContext.cs +++ b/src/NFury/Web/JsonContext.cs @@ -36,6 +36,10 @@ public record ExecutionListResponse(List Executions, int Total); [JsonSerializable(typeof(EndpointExportData))] [JsonSerializable(typeof(ExecutionExportData))] [JsonSerializable(typeof(ProjectImportResult))] +[JsonSerializable(typeof(ExecutionComparisonRequest))] +[JsonSerializable(typeof(ExecutionComparisonResult))] +[JsonSerializable(typeof(ExecutionSummary))] +[JsonSerializable(typeof(PerformanceDelta))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] diff --git a/src/NFury/Web/Models.cs b/src/NFury/Web/Models.cs index 633ed9c..f2aa6d0 100644 --- a/src/NFury/Web/Models.cs +++ b/src/NFury/Web/Models.cs @@ -440,3 +440,207 @@ public record SignalRTestErrorMessage /// public string Error { get; init; } = string.Empty; } + +/// +/// Request to compare two executions +/// +public record ExecutionComparisonRequest +{ + /// + /// The ID of the baseline execution + /// + public int BaselineExecutionId { get; init; } + + /// + /// The ID of the execution to compare against the baseline + /// + public int CompareExecutionId { get; init; } +} + +/// +/// Result of comparing two test executions +/// +public record ExecutionComparisonResult +{ + /// + /// The baseline execution details + /// + public ExecutionSummary Baseline { get; init; } = new(); + + /// + /// The comparison execution details + /// + public ExecutionSummary Compare { get; init; } = new(); + + /// + /// Performance differences between the two executions + /// + public PerformanceDelta Delta { get; init; } = new(); +} + +/// +/// Summary of an execution for comparison purposes +/// +public record ExecutionSummary +{ + /// + /// The execution ID + /// + public int Id { get; init; } + + /// + /// The test identifier + /// + public string TestId { get; init; } = string.Empty; + + /// + /// The endpoint name if linked to an endpoint + /// + public string? EndpointName { get; init; } + + /// + /// The target URL tested + /// + public string Url { get; init; } = string.Empty; + + /// + /// When the test started + /// + public DateTime StartedAt { get; init; } + + /// + /// Total requests made + /// + public long TotalRequests { get; init; } + + /// + /// Failed requests count + /// + public long FailedRequests { get; init; } + + /// + /// Requests per second + /// + public double RequestsPerSecond { get; init; } + + /// + /// Average response time in ms + /// + public double AverageResponseTime { get; init; } + + /// + /// Minimum response time in ms + /// + public double MinResponseTime { get; init; } + + /// + /// Maximum response time in ms + /// + public double MaxResponseTime { get; init; } + + /// + /// 50th percentile response time + /// + public double Percentile50 { get; init; } + + /// + /// 90th percentile response time + /// + public double Percentile90 { get; init; } + + /// + /// 95th percentile response time + /// + public double Percentile95 { get; init; } + + /// + /// 99th percentile response time + /// + public double Percentile99 { get; init; } +} + +/// +/// Performance difference between two executions +/// +public record PerformanceDelta +{ + /// + /// Requests per second difference (positive = improvement) + /// + public double RpsDelta { get; init; } + + /// + /// Requests per second percentage change + /// + public double RpsPercentChange { get; init; } + + /// + /// Average response time difference (negative = improvement) + /// + public double AvgResponseTimeDelta { get; init; } + + /// + /// Average response time percentage change + /// + public double AvgResponseTimePercentChange { get; init; } + + /// + /// Minimum response time difference + /// + public double MinResponseTimeDelta { get; init; } + + /// + /// Maximum response time difference + /// + public double MaxResponseTimeDelta { get; init; } + + /// + /// P50 response time difference + /// + public double P50Delta { get; init; } + + /// + /// P50 percentage change + /// + public double P50PercentChange { get; init; } + + /// + /// P90 response time difference + /// + public double P90Delta { get; init; } + + /// + /// P90 percentage change + /// + public double P90PercentChange { get; init; } + + /// + /// P95 response time difference + /// + public double P95Delta { get; init; } + + /// + /// P95 percentage change + /// + public double P95PercentChange { get; init; } + + /// + /// P99 response time difference + /// + public double P99Delta { get; init; } + + /// + /// P99 percentage change + /// + public double P99PercentChange { get; init; } + + /// + /// Failure rate difference + /// + public double FailureRateDelta { get; init; } + + /// + /// Overall assessment of performance change + /// + public string Assessment { get; init; } = string.Empty; +} diff --git a/src/NFury/Web/Services/ExecutionService.cs b/src/NFury/Web/Services/ExecutionService.cs index ff8c3c0..33cddfa 100644 --- a/src/NFury/Web/Services/ExecutionService.cs +++ b/src/NFury/Web/Services/ExecutionService.cs @@ -697,6 +697,119 @@ public static TestExecution MapExecution(SqliteDataReader reader) ErrorMessage = reader.IsDBNull(25) ? null : reader.GetString(25) }; } + + /// + /// Compares two test executions and calculates performance delta + /// + /// The baseline execution ID + /// The execution ID to compare against baseline + /// The comparison result or null if either execution is not found + public async Task CompareExecutionsAsync(int baselineId, int compareId) + { + var baseline = await GetExecutionByIdAsync(baselineId); + var compare = await GetExecutionByIdAsync(compareId); + + if (baseline == null || compare == null) + return null; + + var baselineSummary = new ExecutionSummary + { + Id = baseline.Id, + TestId = baseline.TestId, + EndpointName = baseline.Endpoint?.Name, + Url = baseline.Url, + StartedAt = baseline.StartedAt, + TotalRequests = baseline.TotalRequests, + FailedRequests = baseline.FailedRequests, + RequestsPerSecond = baseline.RequestsPerSecond, + AverageResponseTime = baseline.AverageResponseTime, + MinResponseTime = baseline.MinResponseTime, + MaxResponseTime = baseline.MaxResponseTime, + Percentile50 = baseline.Percentile50, + Percentile90 = baseline.Percentile90, + Percentile95 = baseline.Percentile95, + Percentile99 = baseline.Percentile99 + }; + + var compareSummary = new ExecutionSummary + { + Id = compare.Id, + TestId = compare.TestId, + EndpointName = compare.Endpoint?.Name, + Url = compare.Url, + StartedAt = compare.StartedAt, + TotalRequests = compare.TotalRequests, + FailedRequests = compare.FailedRequests, + RequestsPerSecond = compare.RequestsPerSecond, + AverageResponseTime = compare.AverageResponseTime, + MinResponseTime = compare.MinResponseTime, + MaxResponseTime = compare.MaxResponseTime, + Percentile50 = compare.Percentile50, + Percentile90 = compare.Percentile90, + Percentile95 = compare.Percentile95, + Percentile99 = compare.Percentile99 + }; + + var rpsDelta = compare.RequestsPerSecond - baseline.RequestsPerSecond; + var avgResponseDelta = compare.AverageResponseTime - baseline.AverageResponseTime; + var p50Delta = compare.Percentile50 - baseline.Percentile50; + var p90Delta = compare.Percentile90 - baseline.Percentile90; + var p95Delta = compare.Percentile95 - baseline.Percentile95; + var p99Delta = compare.Percentile99 - baseline.Percentile99; + + var baselineFailureRate = baseline.TotalRequests > 0 ? (double)baseline.FailedRequests / baseline.TotalRequests : 0; + var compareFailureRate = compare.TotalRequests > 0 ? (double)compare.FailedRequests / compare.TotalRequests : 0; + + var assessment = DetermineAssessment(rpsDelta, avgResponseDelta, baseline.RequestsPerSecond, baseline.AverageResponseTime); + + var delta = new PerformanceDelta + { + RpsDelta = rpsDelta, + RpsPercentChange = baseline.RequestsPerSecond > 0 ? (rpsDelta / baseline.RequestsPerSecond) * 100 : 0, + AvgResponseTimeDelta = avgResponseDelta, + AvgResponseTimePercentChange = baseline.AverageResponseTime > 0 ? (avgResponseDelta / baseline.AverageResponseTime) * 100 : 0, + MinResponseTimeDelta = compare.MinResponseTime - baseline.MinResponseTime, + MaxResponseTimeDelta = compare.MaxResponseTime - baseline.MaxResponseTime, + P50Delta = p50Delta, + P50PercentChange = baseline.Percentile50 > 0 ? (p50Delta / baseline.Percentile50) * 100 : 0, + P90Delta = p90Delta, + P90PercentChange = baseline.Percentile90 > 0 ? (p90Delta / baseline.Percentile90) * 100 : 0, + P95Delta = p95Delta, + P95PercentChange = baseline.Percentile95 > 0 ? (p95Delta / baseline.Percentile95) * 100 : 0, + P99Delta = p99Delta, + P99PercentChange = baseline.Percentile99 > 0 ? (p99Delta / baseline.Percentile99) * 100 : 0, + FailureRateDelta = (compareFailureRate - baselineFailureRate) * 100, + Assessment = assessment + }; + + return new ExecutionComparisonResult + { + Baseline = baselineSummary, + Compare = compareSummary, + Delta = delta + }; + } + + private static string DetermineAssessment(double rpsDelta, double avgResponseDelta, double baselineRps, double baselineAvgResponse) + { + var rpsPercentChange = baselineRps > 0 ? (rpsDelta / baselineRps) * 100 : 0; + var responsePercentChange = baselineAvgResponse > 0 ? (avgResponseDelta / baselineAvgResponse) * 100 : 0; + + var rpsImproved = rpsPercentChange > 5; + var rpsRegressed = rpsPercentChange < -5; + var responseImproved = responsePercentChange < -5; + var responseRegressed = responsePercentChange > 5; + + if (rpsImproved && responseImproved) + return "Significant Improvement"; + if (rpsImproved || responseImproved) + return "Improvement"; + if (rpsRegressed && responseRegressed) + return "Significant Regression"; + if (rpsRegressed || responseRegressed) + return "Regression"; + return "No Significant Change"; + } } /// diff --git a/src/NFury/wwwroot/css/styles.css b/src/NFury/wwwroot/css/styles.css index 9519c6d..f85f6c1 100644 --- a/src/NFury/wwwroot/css/styles.css +++ b/src/NFury/wwwroot/css/styles.css @@ -2511,3 +2511,284 @@ body.sidebar-open .test-info-bar { .btn-alert-close:hover { background: var(--primary-dark); } + +.modal-content.xlarge { + max-width: 900px; + width: 95%; +} + +.history-actions-mini { + padding: 8px 12px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: flex-end; +} + +.btn-compare-mini { + padding: 4px 12px; + background: var(--primary); + color: white; + border: none; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.15s; +} + +.btn-compare-mini:hover { + background: var(--primary-dark); +} + +.comparison-selection { + text-align: center; + padding: 24px; +} + +.comparison-instructions { + color: var(--text-secondary); + margin-bottom: 24px; +} + +.comparison-selectors { + display: flex; + align-items: center; + justify-content: center; + gap: 24px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.comparison-selector { + flex: 1; + min-width: 200px; + max-width: 300px; +} + +.comparison-selector label { + display: block; + font-weight: 600; + margin-bottom: 8px; + color: var(--text-primary); +} + +.comparison-selector select { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--input-bg); + color: var(--text-primary); + font-size: 0.875rem; +} + +.comparison-vs { + font-weight: 700; + font-size: 1.25rem; + color: var(--text-muted); + padding: 24px 0 0; +} + +.comparison-results { + padding: 16px; +} + +.comparison-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); +} + +.comparison-assessment { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-radius: var(--radius-sm); + font-weight: 600; + font-size: 1rem; +} + +.comparison-assessment.excellent { + background: rgba(16, 185, 129, 0.15); + color: var(--success); +} + +.comparison-assessment.good { + background: rgba(16, 185, 129, 0.1); + color: var(--success); +} + +.comparison-assessment.warning { + background: rgba(245, 158, 11, 0.15); + color: var(--warning); +} + +.comparison-assessment.critical { + background: rgba(239, 68, 68, 0.15); + color: var(--error); +} + +.comparison-assessment.neutral { + background: var(--bg-tertiary); + color: var(--text-secondary); +} + +.comparison-grid { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 16px; + margin-bottom: 24px; +} + +.comparison-column { + padding: 16px; + background: var(--bg-tertiary); + border-radius: var(--radius-sm); +} + +.comparison-column h4 { + text-align: center; + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); + font-size: 0.875rem; + text-transform: uppercase; + color: var(--text-secondary); +} + +.comparison-column.baseline h4 { + color: var(--primary); +} + +.comparison-column.compare h4 { + color: var(--success); +} + +.comparison-column.delta { + min-width: 160px; +} + +.comparison-meta { + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +.comparison-meta .meta-item { + font-size: 0.8125rem; + color: var(--text-secondary); + margin-bottom: 4px; +} + +.comparison-meta .meta-item strong { + color: var(--text-primary); +} + +.comparison-metrics .metric-row, +.comparison-delta-metrics .metric-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; + font-size: 0.8125rem; + border-bottom: 1px solid var(--border); +} + +.comparison-metrics .metric-row:last-child, +.comparison-delta-metrics .metric-row:last-child { + border-bottom: none; +} + +.comparison-metrics .metric-row .label { + color: var(--text-secondary); +} + +.comparison-metrics .metric-row .value { + font-weight: 600; + color: var(--text-primary); +} + +.comparison-delta-metrics .metric-row { + justify-content: center; + text-align: center; +} + +.comparison-delta-metrics .improved { + color: var(--success); + font-weight: 600; + display: flex; + align-items: center; + gap: 4px; +} + +.comparison-delta-metrics .regressed { + color: var(--error); + font-weight: 600; + display: flex; + align-items: center; + gap: 4px; +} + +.comparison-delta-metrics .neutral { + color: var(--text-muted); +} + +.comparison-percentiles { + background: var(--bg-tertiary); + border-radius: var(--radius-sm); + padding: 16px; +} + +.comparison-percentiles h5 { + margin-bottom: 16px; + font-size: 0.875rem; + color: var(--text-secondary); +} + +.comparison-percentiles canvas { + height: 200px !important; +} + +.btn-secondary { + padding: 8px 16px; + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + transition: all 0.15s; +} + +.btn-secondary:hover { + background: var(--border); +} + +@media (max-width: 768px) { + .comparison-grid { + grid-template-columns: 1fr; + } + + .comparison-column.delta { + order: -1; + } + + .comparison-selectors { + flex-direction: column; + } + + .comparison-vs { + padding: 0; + } +} diff --git a/src/NFury/wwwroot/index.html b/src/NFury/wwwroot/index.html index d7bf650..c92f370 100644 --- a/src/NFury/wwwroot/index.html +++ b/src/NFury/wwwroot/index.html @@ -723,6 +723,69 @@

Import Preview

+ +
diff --git a/src/NFury/wwwroot/js/app.js b/src/NFury/wwwroot/js/app.js index 1cf7bf1..2d6d693 100644 --- a/src/NFury/wwwroot/js/app.js +++ b/src/NFury/wwwroot/js/app.js @@ -533,7 +533,14 @@ class NFuryApp { if (executions.length === 0) { historyEl.innerHTML = '
No test history
'; } else { - historyEl.innerHTML = executions.map(exec => { + let html = ` +
+ +
+ `; + html += executions.map(exec => { const date = new Date(exec.startedAt); const statusClass = exec.status.toLowerCase(); return ` @@ -546,6 +553,7 @@ class NFuryApp { `; }).join(''); + historyEl.innerHTML = html; } } else { historyEl.style.display = 'none'; @@ -774,6 +782,9 @@ class NFuryApp { async showExecutionDetails(executionId) { try { const response = await fetch(`/api/executions/${executionId}/metrics`); + if (!response.ok) { + throw new Error(`Failed to load execution: ${response.status}`); + } const execution = await response.json(); let statusCodes = {}; @@ -831,25 +842,27 @@ class NFuryApp { if (execution.metrics && execution.metrics.length > 0) { const rtChart = this.charts.responseTime; - rtChart.data.labels = execution.metrics.map((_, i) => i); - rtChart.data.datasets[0].data = execution.metrics.map(m => m.responseTime); - rtChart.data.datasets[1].data = execution.metrics.map(m => m.averageResponseTime); - rtChart.update(); + if (rtChart) { + rtChart.data.labels = execution.metrics.map((_, i) => i); + rtChart.data.datasets[0].data = execution.metrics.map(m => m.responseTime); + rtChart.data.datasets[1].data = execution.metrics.map(m => m.averageResponseTime); + rtChart.update(); + } - const rpsChart = this.charts.rps; - rpsChart.data.labels = execution.metrics.map((_, i) => i); - rpsChart.data.datasets[0].data = execution.metrics.map(m => m.currentRps); - rpsChart.update(); + // Note: rps chart doesn't exist, skip it } - if (Object.keys(statusCodes).length > 0) { - const scChart = this.charts.statusCodes; + // Update status code chart (always, even if empty to clear previous data) + const scChart = this.charts.statusCode; + if (scChart) { const labels = Object.keys(statusCodes); const data = labels.map(code => statusCodes[code].count || 0); scChart.data.labels = labels; scChart.data.datasets[0].data = data; scChart.update(); + } else { + console.warn('statusCode chart not initialized'); } this.displayFinalResults(result); @@ -865,19 +878,270 @@ class NFuryApp { return div.innerHTML; } - initCharts() { - const rtCtx = document.getElementById('responseTimeChart').getContext('2d'); + async showComparisonModal(endpointId) { + this.comparisonEndpointId = endpointId; + this.comparisonChart = null; + + const modal = document.getElementById('comparisonModal'); + const overlay = document.getElementById('overlay'); + const selectionDiv = document.getElementById('comparisonSelection'); + const resultsDiv = document.getElementById('comparisonResults'); + + selectionDiv.classList.remove('hidden'); + resultsDiv.classList.add('hidden'); + + const baselineSelect = document.getElementById('baselineExecution'); + const compareSelect = document.getElementById('compareExecution'); + + baselineSelect.innerHTML = ''; + compareSelect.innerHTML = ''; + + modal.classList.add('open'); + overlay.classList.add('visible'); + + try { + const response = await fetch(`/api/endpoints/${endpointId}/executions?page=1&pageSize=50`); + const data = await response.json(); + const executions = (data.executions || []).filter(e => e.status === 'Completed'); + + if (executions.length < 2) { + baselineSelect.innerHTML = ''; + compareSelect.innerHTML = ''; + return; + } + + this.comparisonExecutions = executions; + + const optionsHtml = executions.map(exec => { + const date = new Date(exec.startedAt); + return ``; + }).join(''); + + baselineSelect.innerHTML = '' + optionsHtml; + compareSelect.innerHTML = '' + optionsHtml; + + } catch (err) { + console.error('Failed to load executions for comparison:', err); + baselineSelect.innerHTML = ''; + compareSelect.innerHTML = ''; + } + } + + updateCompareButton() { + const baseline = document.getElementById('baselineExecution').value; + const compare = document.getElementById('compareExecution').value; + const btn = document.getElementById('btnCompare'); + + btn.disabled = !baseline || !compare || baseline === compare; + } + + async executeComparison() { + const baselineId = parseInt(document.getElementById('baselineExecution').value); + const compareId = parseInt(document.getElementById('compareExecution').value); + + if (!baselineId || !compareId) return; + + try { + const response = await fetch('/api/executions/compare', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + baselineExecutionId: baselineId, + compareExecutionId: compareId + }) + }); + + if (!response.ok) { + throw new Error('Failed to compare executions'); + } + + const result = await response.json(); + this.displayComparisonResults(result); + + } catch (err) { + console.error('Comparison failed:', err); + this.showAlert('error', 'Comparison Failed', err.message); + } + } + + displayComparisonResults(result) { + const selectionDiv = document.getElementById('comparisonSelection'); + const resultsDiv = document.getElementById('comparisonResults'); + + selectionDiv.classList.add('hidden'); + resultsDiv.classList.remove('hidden'); + + const assessmentDiv = document.getElementById('comparisonAssessment'); + const assessmentClass = this.getAssessmentClass(result.delta.assessment); + assessmentDiv.className = `comparison-assessment ${assessmentClass}`; + assessmentDiv.textContent = result.delta.assessment; + + document.getElementById('baselineMeta').innerHTML = ` +
${result.baseline.endpointName || 'Ad-hoc Test'}
+
${new Date(result.baseline.startedAt).toLocaleString()}
+ `; + + document.getElementById('compareMeta').innerHTML = ` +
${result.compare.endpointName || 'Ad-hoc Test'}
+
${new Date(result.compare.startedAt).toLocaleString()}
+ `; + + document.getElementById('deltaMeta').innerHTML = ` +
 
+
 
+ `; + + document.getElementById('baselineMetrics').innerHTML = this.renderMetricsColumn(result.baseline); + document.getElementById('compareMetrics').innerHTML = this.renderMetricsColumn(result.compare); + document.getElementById('deltaMetrics').innerHTML = this.renderDeltaColumn(result.delta); + + this.renderComparisonChart(result); + } + + renderMetricsColumn(summary) { + return ` +
Requests/sec${summary.requestsPerSecond.toFixed(2)}
+
Avg Response${summary.averageResponseTime.toFixed(2)} ms
+
Min Response${summary.minResponseTime.toFixed(2)} ms
+
Max Response${summary.maxResponseTime.toFixed(2)} ms
+
Total Requests${summary.totalRequests.toLocaleString()}
+
Failed${summary.failedRequests.toLocaleString()}
+
P50${summary.percentile50.toFixed(2)} ms
+
P90${summary.percentile90.toFixed(2)} ms
+
P95${summary.percentile95.toFixed(2)} ms
+
P99${summary.percentile99.toFixed(2)} ms
+ `; + } + + renderDeltaColumn(delta) { + return ` +
${this.formatDelta(delta.rpsDelta, delta.rpsPercentChange, true)}
+
${this.formatDelta(delta.avgResponseTimeDelta, delta.avgResponseTimePercentChange, false)}
+
${this.formatDelta(delta.minResponseTimeDelta, 0, false)}
+
${this.formatDelta(delta.maxResponseTimeDelta, 0, false)}
+
-
+
${this.formatDelta(delta.failureRateDelta, 0, false)}
+
${this.formatDelta(delta.p50Delta, delta.p50PercentChange, false)}
+
${this.formatDelta(delta.p90Delta, delta.p90PercentChange, false)}
+
${this.formatDelta(delta.p95Delta, delta.p95PercentChange, false)}
+
${this.formatDelta(delta.p99Delta, delta.p99PercentChange, false)}
+ `; + } + + formatDelta(value, percent, higherIsBetter) { + if (Math.abs(value) < 0.01 && Math.abs(percent) < 0.01) { + return '-'; + } + + const isPositive = value > 0; + const isImprovement = higherIsBetter ? isPositive : !isPositive; + const className = isImprovement ? 'improved' : 'regressed'; + const sign = isPositive ? '+' : ''; + + let displayValue = `${sign}${value.toFixed(2)}`; + if (Math.abs(percent) >= 0.01) { + displayValue += ` (${sign}${percent.toFixed(1)}%)`; + } + + return `${displayValue}`; + } + + getAssessmentClass(assessment) { + if (assessment.includes('Significant Improvement')) return 'excellent'; + if (assessment.includes('Improvement')) return 'good'; + if (assessment.includes('Significant Regression')) return 'critical'; + if (assessment.includes('Regression')) return 'warning'; + return 'neutral'; + } + + getAssessmentIcon(assessment) { + if (assessment.includes('Improvement')) return 'chart-line'; + if (assessment.includes('Regression')) return 'chart-line'; + return 'equals'; + } + + renderComparisonChart(result) { + const ctx = document.getElementById('comparisonPercentileChart').getContext('2d'); - const gradientPurple = rtCtx.createLinearGradient(0, 0, 0, 280); - gradientPurple.addColorStop(0, 'rgba(124, 58, 237, 0.3)'); - gradientPurple.addColorStop(1, 'rgba(124, 58, 237, 0.0)'); + if (this.comparisonChart) { + this.comparisonChart.destroy(); + } + + this.comparisonChart = new Chart(ctx, { + type: 'bar', + data: { + labels: ['P50', 'P90', 'P95', 'P99'], + datasets: [ + { + label: 'Baseline', + data: [ + result.baseline.percentile50, + result.baseline.percentile90, + result.baseline.percentile95, + result.baseline.percentile99 + ], + backgroundColor: 'rgba(124, 58, 237, 0.7)', + borderRadius: 4 + }, + { + label: 'Compare', + data: [ + result.compare.percentile50, + result.compare.percentile90, + result.compare.percentile95, + result.compare.percentile99 + ], + backgroundColor: 'rgba(16, 185, 129, 0.7)', + borderRadius: 4 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', + labels: { + boxWidth: 12, + padding: 16 + } + } + }, + scales: { + y: { + beginAtZero: true, + ticks: { + callback: (value) => value + ' ms' + } + } + } + } + }); + } + + showComparisonSelection() { + const selectionDiv = document.getElementById('comparisonSelection'); + const resultsDiv = document.getElementById('comparisonResults'); - const gradientCyan = rtCtx.createLinearGradient(0, 0, 0, 280); - gradientCyan.addColorStop(0, 'rgba(6, 182, 212, 0.3)'); - gradientCyan.addColorStop(1, 'rgba(6, 182, 212, 0.0)'); + selectionDiv.classList.remove('hidden'); + resultsDiv.classList.add('hidden'); + } + + initCharts() { + try { + const rtCtx = document.getElementById('responseTimeChart').getContext('2d'); + + const gradientPurple = rtCtx.createLinearGradient(0, 0, 0, 280); + gradientPurple.addColorStop(0, 'rgba(124, 58, 237, 0.3)'); + gradientPurple.addColorStop(1, 'rgba(124, 58, 237, 0.0)'); + + const gradientCyan = rtCtx.createLinearGradient(0, 0, 0, 280); + gradientCyan.addColorStop(0, 'rgba(6, 182, 212, 0.3)'); + gradientCyan.addColorStop(1, 'rgba(6, 182, 212, 0.0)'); - this.charts.responseTime = new Chart(rtCtx, { - type: 'line', + this.charts.responseTime = new Chart(rtCtx, { + type: 'line', data: { labels: [], datasets: [{ @@ -1033,6 +1297,9 @@ class NFuryApp { } } }); + } catch (err) { + console.error('Error initializing charts:', err); + } } bindEvents() { @@ -2154,6 +2421,18 @@ function closeAlertModal() { window.app.closeAlertModal(); } +function closeComparisonModal() { + const modal = document.getElementById('comparisonModal'); + const overlay = document.getElementById('overlay'); + modal.classList.remove('open'); + overlay.classList.remove('visible'); + + if (window.app && window.app.comparisonChart) { + window.app.comparisonChart.destroy(); + window.app.comparisonChart = null; + } +} + document.addEventListener('DOMContentLoaded', () => { initializeTheme(); window.app = new NFuryApp();