396 lines
12 KiB
Go
396 lines
12 KiB
Go
package reporter
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.gostacks.org/iwasforcedtobehere/stroke/pkg/metrics"
|
|
)
|
|
|
|
// Reporter interface for different output formats
|
|
type Reporter interface {
|
|
Generate(results *metrics.Results, outputDir string) error
|
|
}
|
|
|
|
// ConsoleReporter outputs results to the console
|
|
type ConsoleReporter struct{}
|
|
|
|
// NewConsoleReporter creates a new console reporter
|
|
func NewConsoleReporter() *ConsoleReporter {
|
|
return &ConsoleReporter{}
|
|
}
|
|
|
|
// Generate outputs results to console
|
|
func (cr *ConsoleReporter) Generate(results *metrics.Results, outputDir string) error {
|
|
fmt.Printf("\n📊 Stroke Test Results\n")
|
|
fmt.Print(strings.Repeat("=", 50) + "\n")
|
|
|
|
fmt.Printf("Duration: %v\n", results.TestDuration)
|
|
fmt.Printf("Total Requests: %d\n", results.TotalRequests)
|
|
fmt.Printf("Successful: %d\n", results.SuccessRequests)
|
|
fmt.Printf("Failed: %d\n", results.FailedRequests)
|
|
fmt.Printf("RPS: %.2f\n", results.RequestsPerSecond)
|
|
|
|
fmt.Printf("\nResponse Times:\n")
|
|
fmt.Printf(" Min: %v\n", results.MinResponseTime)
|
|
fmt.Printf(" Max: %v\n", results.MaxResponseTime)
|
|
fmt.Printf(" Avg: %v\n", results.AvgResponseTime)
|
|
fmt.Printf(" p50: %v\n", results.P50)
|
|
fmt.Printf(" p90: %v\n", results.P90)
|
|
fmt.Printf(" p95: %v\n", results.P95)
|
|
fmt.Printf(" p99: %v\n", results.P99)
|
|
|
|
if len(results.StatusCodes) > 0 {
|
|
fmt.Printf("\nStatus Codes:\n")
|
|
for code, count := range results.StatusCodes {
|
|
fmt.Printf(" %d: %d\n", code, count)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// JSONReporter outputs results as JSON
|
|
type JSONReporter struct{}
|
|
|
|
// NewJSONReporter creates a new JSON reporter
|
|
func NewJSONReporter() *JSONReporter {
|
|
return &JSONReporter{}
|
|
}
|
|
|
|
// Generate outputs results as JSON file
|
|
func (jr *JSONReporter) Generate(results *metrics.Results, outputDir string) error {
|
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create output directory: %w", err)
|
|
}
|
|
|
|
timestamp := time.Now().Format("20060102_150405")
|
|
filename := filepath.Join(outputDir, fmt.Sprintf("stroke_results_%s.json", timestamp))
|
|
|
|
file, err := os.Create(filename)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create JSON file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
encoder := json.NewEncoder(file)
|
|
encoder.SetIndent("", " ")
|
|
|
|
if err := encoder.Encode(results); err != nil {
|
|
return fmt.Errorf("failed to encode JSON: %w", err)
|
|
}
|
|
|
|
fmt.Printf("📄 JSON report saved to: %s\n", filename)
|
|
return nil
|
|
}
|
|
|
|
// HTMLReporter generates beautiful HTML reports
|
|
type HTMLReporter struct{}
|
|
|
|
// NewHTMLReporter creates a new HTML reporter
|
|
func NewHTMLReporter() *HTMLReporter {
|
|
return &HTMLReporter{}
|
|
}
|
|
|
|
// Generate creates an HTML report
|
|
func (hr *HTMLReporter) Generate(results *metrics.Results, outputDir string) error {
|
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create output directory: %w", err)
|
|
}
|
|
|
|
timestamp := time.Now().Format("20060102_150405")
|
|
filename := filepath.Join(outputDir, fmt.Sprintf("stroke_report_%s.html", timestamp))
|
|
|
|
file, err := os.Create(filename)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create HTML file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
tmpl := template.Must(template.New("report").Parse(htmlTemplate))
|
|
|
|
data := struct {
|
|
Results *metrics.Results
|
|
Timestamp string
|
|
Title string
|
|
}{
|
|
Results: results,
|
|
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
|
|
Title: "Stroke Stress Test Report",
|
|
}
|
|
|
|
if err := tmpl.Execute(file, data); err != nil {
|
|
return fmt.Errorf("failed to execute template: %w", err)
|
|
}
|
|
|
|
fmt.Printf("📊 HTML report saved to: %s\n", filename)
|
|
return nil
|
|
}
|
|
|
|
// CSVReporter outputs results as CSV
|
|
type CSVReporter struct{}
|
|
|
|
// NewCSVReporter creates a new CSV reporter
|
|
func NewCSVReporter() *CSVReporter {
|
|
return &CSVReporter{}
|
|
}
|
|
|
|
// Generate outputs results as CSV file
|
|
func (csvr *CSVReporter) Generate(results *metrics.Results, outputDir string) error {
|
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
|
return fmt.Errorf("failed to create output directory: %w", err)
|
|
}
|
|
|
|
timestamp := time.Now().Format("20060102_150405")
|
|
filename := filepath.Join(outputDir, fmt.Sprintf("stroke_results_%s.csv", timestamp))
|
|
|
|
file, err := os.Create(filename)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create CSV file: %w", err)
|
|
}
|
|
defer file.Close()
|
|
|
|
// Write CSV header
|
|
fmt.Fprintf(file, "Metric,Value\n")
|
|
fmt.Fprintf(file, "Total Requests,%d\n", results.TotalRequests)
|
|
fmt.Fprintf(file, "Success Requests,%d\n", results.SuccessRequests)
|
|
fmt.Fprintf(file, "Failed Requests,%d\n", results.FailedRequests)
|
|
fmt.Fprintf(file, "Test Duration,%v\n", results.TestDuration)
|
|
fmt.Fprintf(file, "Requests Per Second,%.2f\n", results.RequestsPerSecond)
|
|
fmt.Fprintf(file, "Min Response Time,%v\n", results.MinResponseTime)
|
|
fmt.Fprintf(file, "Max Response Time,%v\n", results.MaxResponseTime)
|
|
fmt.Fprintf(file, "Avg Response Time,%v\n", results.AvgResponseTime)
|
|
fmt.Fprintf(file, "P50 Response Time,%v\n", results.P50)
|
|
fmt.Fprintf(file, "P90 Response Time,%v\n", results.P90)
|
|
fmt.Fprintf(file, "P95 Response Time,%v\n", results.P95)
|
|
fmt.Fprintf(file, "P99 Response Time,%v\n", results.P99)
|
|
|
|
fmt.Printf("📈 CSV report saved to: %s\n", filename)
|
|
return nil
|
|
}
|
|
|
|
// MultiReporter combines multiple reporters
|
|
type MultiReporter struct {
|
|
reporters []Reporter
|
|
}
|
|
|
|
// NewMultiReporter creates a reporter that outputs to multiple formats
|
|
func NewMultiReporter(formats []string) *MultiReporter {
|
|
var reporters []Reporter
|
|
|
|
for _, format := range formats {
|
|
switch strings.ToLower(format) {
|
|
case "console":
|
|
reporters = append(reporters, NewConsoleReporter())
|
|
case "json":
|
|
reporters = append(reporters, NewJSONReporter())
|
|
case "html":
|
|
reporters = append(reporters, NewHTMLReporter())
|
|
case "csv":
|
|
reporters = append(reporters, NewCSVReporter())
|
|
}
|
|
}
|
|
|
|
return &MultiReporter{reporters: reporters}
|
|
}
|
|
|
|
// Generate runs all configured reporters
|
|
func (mr *MultiReporter) Generate(results *metrics.Results, outputDir string) error {
|
|
for _, reporter := range mr.reporters {
|
|
if err := reporter.Generate(results, outputDir); err != nil {
|
|
return fmt.Errorf("reporter failed: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// HTML template for the report
|
|
const htmlTemplate = `
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{{.Title}}</title>
|
|
<style>
|
|
body {
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
margin: 0;
|
|
padding: 20px;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
color: #333;
|
|
}
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
background: white;
|
|
border-radius: 10px;
|
|
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
|
overflow: hidden;
|
|
}
|
|
.header {
|
|
background: linear-gradient(135deg, #ff6b6b 0%, #feca57 100%);
|
|
color: white;
|
|
padding: 30px;
|
|
text-align: center;
|
|
}
|
|
.header h1 {
|
|
margin: 0;
|
|
font-size: 2.5em;
|
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
|
}
|
|
.content {
|
|
padding: 30px;
|
|
}
|
|
.metrics-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
.metric-card {
|
|
background: #f8f9fa;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
border-left: 4px solid #007bff;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
}
|
|
.metric-title {
|
|
font-size: 0.9em;
|
|
color: #6c757d;
|
|
margin-bottom: 5px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
}
|
|
.metric-value {
|
|
font-size: 1.8em;
|
|
font-weight: bold;
|
|
color: #212529;
|
|
}
|
|
.status-codes {
|
|
background: #f8f9fa;
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin-top: 20px;
|
|
}
|
|
.status-codes h3 {
|
|
margin-top: 0;
|
|
color: #495057;
|
|
}
|
|
.status-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid #dee2e6;
|
|
}
|
|
.footer {
|
|
background: #343a40;
|
|
color: white;
|
|
padding: 20px;
|
|
text-align: center;
|
|
}
|
|
.emoji {
|
|
font-size: 1.2em;
|
|
}
|
|
.success { border-left-color: #28a745; }
|
|
.warning { border-left-color: #ffc107; }
|
|
.danger { border-left-color: #dc3545; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<h1><span class="emoji">🚀</span> {{.Title}} <span class="emoji">🚀</span></h1>
|
|
<p>Generated on {{.Timestamp}}</p>
|
|
</div>
|
|
|
|
<div class="content">
|
|
<div class="metrics-grid">
|
|
<div class="metric-card success">
|
|
<div class="metric-title">Total Requests</div>
|
|
<div class="metric-value">{{.Results.TotalRequests}}</div>
|
|
</div>
|
|
|
|
<div class="metric-card success">
|
|
<div class="metric-title">Successful Requests</div>
|
|
<div class="metric-value">{{.Results.SuccessRequests}}</div>
|
|
</div>
|
|
|
|
<div class="metric-card {{if gt .Results.FailedRequests 0}}danger{{else}}success{{end}}">
|
|
<div class="metric-title">Failed Requests</div>
|
|
<div class="metric-value">{{.Results.FailedRequests}}</div>
|
|
</div>
|
|
|
|
<div class="metric-card">
|
|
<div class="metric-title">Test Duration</div>
|
|
<div class="metric-value">{{.Results.TestDuration}}</div>
|
|
</div>
|
|
|
|
<div class="metric-card">
|
|
<div class="metric-title">Requests Per Second</div>
|
|
<div class="metric-value">{{printf "%.2f" .Results.RequestsPerSecond}}</div>
|
|
</div>
|
|
|
|
<div class="metric-card">
|
|
<div class="metric-title">Average Response Time</div>
|
|
<div class="metric-value">{{.Results.AvgResponseTime}}</div>
|
|
</div>
|
|
|
|
<div class="metric-card">
|
|
<div class="metric-title">P50 Response Time</div>
|
|
<div class="metric-value">{{.Results.P50}}</div>
|
|
</div>
|
|
|
|
<div class="metric-card">
|
|
<div class="metric-title">P90 Response Time</div>
|
|
<div class="metric-value">{{.Results.P90}}</div>
|
|
</div>
|
|
|
|
<div class="metric-card">
|
|
<div class="metric-title">P95 Response Time</div>
|
|
<div class="metric-value">{{.Results.P95}}</div>
|
|
</div>
|
|
|
|
<div class="metric-card">
|
|
<div class="metric-title">P99 Response Time</div>
|
|
<div class="metric-value">{{.Results.P99}}</div>
|
|
</div>
|
|
|
|
<div class="metric-card">
|
|
<div class="metric-title">Min Response Time</div>
|
|
<div class="metric-value">{{.Results.MinResponseTime}}</div>
|
|
</div>
|
|
|
|
<div class="metric-card">
|
|
<div class="metric-title">Max Response Time</div>
|
|
<div class="metric-value">{{.Results.MaxResponseTime}}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{if .Results.StatusCodes}}
|
|
<div class="status-codes">
|
|
<h3><span class="emoji">📈</span> Status Code Distribution</h3>
|
|
{{range $code, $count := .Results.StatusCodes}}
|
|
<div class="status-item">
|
|
<span>HTTP {{$code}}</span>
|
|
<span><strong>{{$count}} requests</strong></span>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<p><span class="emoji">💪</span> Powered by Stroke - Because Your Server Needs Some Exercise <span class="emoji">💪</span></p>
|
|
<p>Made with ❤️ and a healthy dose of sarcasm by @iwasforcedtobehere</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`
|