From 1454e18de21da0299958f11a29dad5dd0e95c7d1 Mon Sep 17 00:00:00 2001 From: Md Nazmul Hasan Date: Sun, 12 Apr 2026 23:38:39 +1000 Subject: [PATCH 1/2] (feat) threshold settings page added --- internal/config/config.go | 24 +++- internal/runner/assertions.go | 94 +++++++++++++ internal/runner/result.go | 34 +++-- internal/runner/runner.go | 5 +- web.yml | 48 ++++++- web/handler_assertions.go | 42 ++++++ web/handler_history.go | 112 ++++++++++++---- web/handler_run.go | 2 + web/handler_threshold_settings.go | 14 ++ web/routes.go | 2 + web/server.go | 26 ++-- web/static/css/app.css | 208 +++++++++++++++++++++++++++++ web/static/js/app.js | 212 ++++++++++++++++++++++++++++++ web/templates.go | 2 +- web/templates/layout.html | 6 +- web/templates/settings.html | 42 ++++++ web/webconfig.go | 60 +++++++-- 17 files changed, 862 insertions(+), 71 deletions(-) create mode 100644 internal/runner/assertions.go create mode 100644 web/handler_assertions.go create mode 100644 web/handler_threshold_settings.go create mode 100644 web/templates/settings.html diff --git a/internal/config/config.go b/internal/config/config.go index a5c63ad..c473294 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,10 +9,26 @@ import ( ) type Config struct { - Name string `yaml:"name"` - BaseURL string `yaml:"base_url"` - Scenarios []Scenario `yaml:"scenarios"` - Load LoadConfig `yaml:"load"` + Name string `yaml:"name"` + BaseURL string `yaml:"base_url"` + Scenarios []Scenario `yaml:"scenarios"` + Load LoadConfig `yaml:"load"` + Assertions []Assertion `yaml:"assertions,omitempty"` +} + +type Assertion struct { + Metric string `yaml:"metric" json:"metric"` + Operator string `yaml:"operator" json:"operator"` + Value float64 `yaml:"value" json:"value"` + Enabled bool `yaml:"enabled" json:"enabled"` +} + +type AssertionResult struct { + Metric string `yaml:"metric" json:"metric"` + Operator string `yaml:"operator" json:"operator"` + Threshold float64 `yaml:"threshold" json:"threshold"` + Actual float64 `yaml:"actual" json:"actual"` + Passed bool `yaml:"passed" json:"passed"` } type Scenario struct { diff --git a/internal/runner/assertions.go b/internal/runner/assertions.go new file mode 100644 index 0000000..a413888 --- /dev/null +++ b/internal/runner/assertions.go @@ -0,0 +1,94 @@ +package runner + +import ( + "time" + + "github.com/farhapartex/loadforge/internal/config" + "github.com/farhapartex/loadforge/internal/loader" +) + +func evaluateAssertions(assertions []config.Assertion, snap *loader.MetricsSnapshot, percentiles *LatencyPercentiles) ([]config.AssertionResult, bool) { + if len(assertions) == 0 { + return nil, true + } + + metricValues := buildMetricValues(snap, percentiles) + + results := make([]config.AssertionResult, 0, len(assertions)) + allPassed := true + + for _, a := range assertions { + if !a.Enabled { + continue + } + + actual, ok := metricValues[a.Metric] + if !ok { + continue + } + + passed := applyOperator(actual, a.Operator, a.Value) + if !passed { + allPassed = false + } + + results = append(results, config.AssertionResult{ + Metric: a.Metric, + Operator: a.Operator, + Threshold: a.Value, + Actual: actual, + Passed: passed, + }) + } + + return results, allPassed +} + +func buildMetricValues(snap *loader.MetricsSnapshot, p *LatencyPercentiles) map[string]float64 { + values := map[string]float64{ + "rps": snap.RPS, + "total_requests": float64(snap.TotalRequests), + "total_errors": float64(snap.TotalFailures), + "error_rate": snap.ErrorRate(), + "success_rate": successRate(snap), + } + + if p != nil { + values["p50_latency"] = durationToMs(p.P50) + values["p90_latency"] = durationToMs(p.P90) + values["p95_latency"] = durationToMs(p.P95) + values["p99_latency"] = durationToMs(p.P99) + values["avg_latency"] = durationToMs(p.Avg) + values["max_latency"] = durationToMs(p.Max) + } + + return values +} + +func durationToMs(d time.Duration) float64 { + return float64(d.Milliseconds()) +} + +func successRate(snap *loader.MetricsSnapshot) float64 { + if snap.TotalRequests == 0 { + return 0 + } + return float64(snap.TotalSuccesses) / float64(snap.TotalRequests) * 100 +} + +func applyOperator(actual float64, operator string, threshold float64) bool { + switch operator { + case "less_than": + return actual < threshold + case "less_than_or_equal": + return actual <= threshold + case "greater_than": + return actual > threshold + case "greater_than_or_equal": + return actual >= threshold + case "equal": + return actual == threshold + default: + return false + } +} diff --git a/internal/runner/result.go b/internal/runner/result.go index d771762..56de44c 100644 --- a/internal/runner/result.go +++ b/internal/runner/result.go @@ -8,6 +8,7 @@ import ( "sync" "time" + "github.com/farhapartex/loadforge/internal/config" "github.com/farhapartex/loadforge/internal/loader" ) @@ -27,20 +28,24 @@ type LatencyPercentiles struct { P90 time.Duration P95 time.Duration P99 time.Duration + Avg time.Duration + Max time.Duration } type RunRecord struct { - ID string - SpecURL string - Profile string - Workers int - Duration string - StartedAt time.Time - EndedAt time.Time - Status RunStatus - Error string - Snapshot *loader.MetricsSnapshot - Percentiles *LatencyPercentiles + ID string + SpecURL string + Profile string + Workers int + Duration string + StartedAt time.Time + EndedAt time.Time + Status RunStatus + Error string + Snapshot *loader.MetricsSnapshot + Percentiles *LatencyPercentiles + AssertionResults []config.AssertionResult + AssertionsPassed bool } type ResultStore struct { @@ -135,11 +140,18 @@ func ComputePercentiles(snap *loader.MetricsSnapshot) *LatencyPercentiles { copy(sorted, snap.Latencies) sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + var total time.Duration + for _, d := range sorted { + total += d + } + p := &LatencyPercentiles{ P50: percentileAt(sorted, 50), P90: percentileAt(sorted, 90), P95: percentileAt(sorted, 95), P99: percentileAt(sorted, 99), + Avg: total / time.Duration(len(sorted)), + Max: sorted[len(sorted)-1], } snap.Latencies = nil diff --git a/internal/runner/runner.go b/internal/runner/runner.go index ff331a8..b163820 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -113,8 +113,11 @@ func (r *Runner) execute(ctx context.Context, cancel context.CancelFunc, cfg *co if result != nil && result.Metrics != nil { snap := result.Metrics.Snapshot() - record.Percentiles = ComputePercentiles(&snap) // computes p50-p99 and clears raw latencies + record.Percentiles = ComputePercentiles(&snap) record.Snapshot = &snap + + record.AssertionResults, record.AssertionsPassed = evaluateAssertions(cfg.Assertions, &snap, record.Percentiles) + log.Printf("Results requests=%d successes=%d failures=%d rps=%.2f", snap.TotalRequests, snap.TotalSuccesses, snap.TotalFailures, snap.RPS) } diff --git a/web.yml b/web.yml index 8c0f3f7..ce891a2 100644 --- a/web.yml +++ b/web.yml @@ -1,5 +1,43 @@ -addr: ":8080" -username: "admin" -password: "admin" -session_ttl: "24h" -log_file: "load_forge.logs" +addr: :8080 +username: admin +password: admin +session_ttl: 24h +log_file: load_forge.logs +history_file: load_forge_history.json +assertions: + - metric: p95_latency + operator: less_than + value: 500 + enabled: true + - metric: p99_latency + operator: less_than + value: 1000 + enabled: true + - metric: error_rate + operator: less_than + value: 1 + enabled: true + - metric: success_rate + operator: greater_than_or_equal + value: 99 + enabled: true + - metric: rps + operator: greater_than + value: 10 + enabled: true + - metric: max_latency + operator: less_than + value: 500 + enabled: true + - metric: error_rate + operator: less_than + value: 2 + enabled: true + - metric: total_requests + operator: less_than + value: 4000 + enabled: true + - metric: total_errors + operator: less_than + value: 100 + enabled: true diff --git a/web/handler_assertions.go b/web/handler_assertions.go new file mode 100644 index 0000000..fdb8cb4 --- /dev/null +++ b/web/handler_assertions.go @@ -0,0 +1,42 @@ +package web + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/farhapartex/loadforge/internal/config" +) + +func (s *Server) handleAssertions(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, s.cfg.DefaultAssertions) + case http.MethodPost: + s.saveAssertions(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *Server) saveAssertions(w http.ResponseWriter, r *http.Request) { + var assertions []config.Assertion + if err := json.NewDecoder(r.Body).Decode(&assertions); err != nil { + writeJSON(w, http.StatusBadRequest, apiError("invalid JSON body")) + return + } + + if assertions == nil { + assertions = []config.Assertion{} + } + + s.cfg.DefaultAssertions = assertions + + if err := s.cfg.save(s.configPath); err != nil { + log.Printf("ERR save assertions: %v", err) + writeJSON(w, http.StatusInternalServerError, apiError("failed to persist thresholds")) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "saved"}) +} diff --git a/web/handler_history.go b/web/handler_history.go index c6e53ce..374c4b9 100644 --- a/web/handler_history.go +++ b/web/handler_history.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "sort" + "strings" "time" "github.com/farhapartex/loadforge/internal/loader" @@ -27,28 +28,30 @@ type historyRow struct { } type runDetail struct { - ID string - SpecURL string - Profile string - Workers int - Duration string - StartedAt string - EndedAt string - Elapsed string - Status string - Error string - Requests int64 - Successes int64 - Failures int64 - ErrorRate string - RPS string - DataBytes string - P50 string - P90 string - P95 string - P99 string - StatusCodes []statusCodeRow - Errors []errorRow + ID string + SpecURL string + Profile string + Workers int + Duration string + StartedAt string + EndedAt string + Elapsed string + Status string + Error string + Requests int64 + Successes int64 + Failures int64 + ErrorRate string + RPS string + DataBytes string + P50 string + P90 string + P95 string + P99 string + StatusCodes []statusCodeRow + Errors []errorRow + AssertionResults []assertionDetailRow + AssertionsPassed bool } type statusCodeRow struct { @@ -61,6 +64,13 @@ type errorRow struct { Count int64 } +type assertionDetailRow struct { + Metric string + Expected string + Actual string + Passed bool +} + func (s *Server) handleHistory(w http.ResponseWriter, r *http.Request) { records := s.runner.Results().All() @@ -186,9 +196,67 @@ func toRunDetail(rec *runner.RunRecord) runDetail { d.P99 = p.P99.Round(time.Millisecond).String() } + d.AssertionsPassed = rec.AssertionsPassed + for _, ar := range rec.AssertionResults { + d.AssertionResults = append(d.AssertionResults, assertionDetailRow{ + Metric: formatMetricLabel(ar.Metric), + Expected: formatExpected(ar.Operator, ar.Threshold, ar.Metric), + Actual: formatActualValue(ar.Actual, ar.Metric), + Passed: ar.Passed, + }) + } + return d } +func formatMetricLabel(metric string) string { + labels := map[string]string{ + "p50_latency": "P50 Latency", + "p90_latency": "P90 Latency", + "p95_latency": "P95 Latency", + "p99_latency": "P99 Latency", + "avg_latency": "Avg Latency", + "max_latency": "Max Latency", + "error_rate": "Error Rate", + "success_rate": "Success Rate", + "rps": "RPS", + "total_requests": "Total Requests", + "total_errors": "Total Errors", + } + if label, ok := labels[metric]; ok { + return label + } + return strings.ReplaceAll(metric, "_", " ") +} + +func formatExpected(operator string, threshold float64, metric string) string { + ops := map[string]string{ + "less_than": "<", + "less_than_or_equal": "≤", + "greater_than": ">", + "greater_than_or_equal":"≥", + "equal": "=", + } + op := operator + if sym, ok := ops[operator]; ok { + op = sym + } + return fmt.Sprintf("%s %s", op, formatActualValue(threshold, metric)) +} + +func formatActualValue(value float64, metric string) string { + switch { + case strings.HasSuffix(metric, "_latency"): + return fmt.Sprintf("%.0fms", value) + case strings.HasSuffix(metric, "_rate"): + return fmt.Sprintf("%.2f%%", value) + case metric == "rps": + return fmt.Sprintf("%.2f req/s", value) + default: + return fmt.Sprintf("%.0f", value) + } +} + func formatRate(snap *loader.MetricsSnapshot) string { if snap.TotalRequests == 0 { return "0.00%" diff --git a/web/handler_run.go b/web/handler_run.go index 7f020a9..7bde22e 100644 --- a/web/handler_run.go +++ b/web/handler_run.go @@ -82,6 +82,8 @@ func (s *Server) handleRunStart(w http.ResponseWriter, r *http.Request) { return } + cfg.Assertions = s.cfg.DefaultAssertions + ref := input.URL if ref == "" { ref = input.Filename diff --git a/web/handler_threshold_settings.go b/web/handler_threshold_settings.go new file mode 100644 index 0000000..1cef97c --- /dev/null +++ b/web/handler_threshold_settings.go @@ -0,0 +1,14 @@ +package web + +import ( + "net/http" +) + +func (s *Server) handleThresholdSettings(w http.ResponseWriter, r *http.Request) { + s.templates.renderPage(w, "settings", PageData{ + Title: "SLA Thresholds", + ActiveNav: "threshold-settings", + Username: usernameFromContext(r.Context()), + Data: s.cfg.DefaultAssertions, + }) +} diff --git a/web/routes.go b/web/routes.go index 976b4a8..6c20a30 100644 --- a/web/routes.go +++ b/web/routes.go @@ -19,5 +19,7 @@ func (s *Server) registerRoutes() { s.mux.Handle("/history", s.requireAuth(http.HandlerFunc(s.handleHistory))) s.mux.Handle("/api/history", s.requireAuth(http.HandlerFunc(s.handleHistoryDetail))) + s.mux.Handle("/threshold-settings", s.requireAuth(http.HandlerFunc(s.handleThresholdSettings))) + s.mux.Handle("/api/assertions", s.requireAuth(http.HandlerFunc(s.handleAssertions))) s.mux.Handle("/", s.requireAuth(http.HandlerFunc(s.handleHome))) } diff --git a/web/server.go b/web/server.go index d6f78ba..1986c43 100644 --- a/web/server.go +++ b/web/server.go @@ -16,16 +16,17 @@ import ( ) type Server struct { - cfg *WebConfig - sessions *SessionStore - templates *templateCache - logs *LogBroadcaster - stats *RunStats - runner *runner.Runner - loaders *loaderRegistry - logFile *os.File - mux *http.ServeMux - http *http.Server + configPath string + cfg *WebConfig + sessions *SessionStore + templates *templateCache + logs *LogBroadcaster + stats *RunStats + runner *runner.Runner + loaders *loaderRegistry + logFile *os.File + mux *http.ServeMux + http *http.Server } func newServer(configPath string) (*Server, error) { @@ -48,8 +49,9 @@ func newServer(configPath string) (*Server, error) { mux := http.NewServeMux() srv := &Server{ - cfg: cfg, - sessions: newSessionStore(cfg.parsedSessionTTL()), + configPath: configPath, + cfg: cfg, + sessions: newSessionStore(cfg.parsedSessionTTL()), templates: tmplCache, logs: newLogBroadcaster(), stats: newRunStats(), diff --git a/web/static/css/app.css b/web/static/css/app.css index d6b968a..e2eb45a 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -1053,3 +1053,211 @@ a { color: var(--color-text-secondary); word-break: break-all; } + +/* ── Settings / Threshold page ───────────────────────────────── */ + +.settings-page { + max-width: 820px; +} + +.settings-card { + background: #fff; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; + box-shadow: var(--shadow-card); +} + +.settings-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 20px 24px 16px; + border-bottom: 1px solid var(--color-border); +} + +.settings-card-header-left { + display: flex; + flex-direction: column; + gap: 4px; +} + +.settings-card-title { + font-size: 15px; + font-weight: 600; + color: var(--color-text); +} + +.settings-card-subtitle { + font-size: 12px; + color: var(--color-text-muted); +} + +.settings-card-body { + overflow-x: auto; +} + +.settings-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 24px; + border-top: 1px solid var(--color-border); + background: #f8fafc; +} + +.settings-footer-right { + display: flex; + align-items: center; + gap: 12px; +} + +/* ── Threshold table ─────────────────────────────────────────── */ + +.threshold-table { + width: 100%; + border-collapse: collapse; +} + +.threshold-table thead th { + padding: 10px 14px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); + text-align: left; + border-bottom: 1px solid var(--color-border); + white-space: nowrap; + background: #f8fafc; +} + +.threshold-table .th-enabled { width: 64px; text-align: center; } +.threshold-table .th-operator { width: 130px; } +.threshold-table .th-value { width: 110px; } +.threshold-table .th-unit { width: 60px; } +.threshold-table .th-remove { width: 40px; } + +.threshold-row td { + padding: 10px 14px; + border-bottom: 1px solid var(--color-border); + vertical-align: middle; +} + +.threshold-row:last-child td { + border-bottom: none; +} + +.threshold-row.disabled-row { + opacity: 0.5; +} + +.threshold-empty-row td { + padding: 32px; + text-align: center; + color: var(--color-text-muted); + font-size: 13px; + border-bottom: none; +} + +/* Enabled checkbox */ +.th-enabled, +.td-enabled { + text-align: center; +} + +.threshold-checkbox { + width: 16px; + height: 16px; + cursor: pointer; + accent-color: var(--color-primary); +} + +/* Selects and inputs inside threshold rows */ +.threshold-select, +.threshold-input { + width: 100%; + padding: 6px 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 13px; + color: var(--color-text); + background: #fff; + appearance: none; + transition: border-color 0.15s; +} + +.threshold-select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 8px center; + padding-right: 26px; +} + +.threshold-select:focus, +.threshold-input:focus { + outline: none; + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12); +} + +.threshold-unit { + font-size: 12px; + color: var(--color-text-muted); + white-space: nowrap; +} + +/* Remove button */ +.threshold-remove { + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid transparent; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + font-size: 16px; + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; +} + +.threshold-remove:hover { + background: var(--color-danger-bg); + border-color: var(--color-danger-border); + color: var(--color-danger-text); +} + +/* Save status message */ +.save-status { + font-size: 13px; + font-weight: 500; +} + +.save-status.status-ok { color: #166534; } +.save-status.status-error { color: #991b1b; } + +/* Assertion result badges in history detail */ +.assertion-pass { color: #166534; font-weight: 600; } +.assertion-fail { color: #991b1b; font-weight: 600; } + +.assertion-summary-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; +} + +.assertion-summary-badge.all-passed { + background: #dcfce7; + color: #166534; +} + +.assertion-summary-badge.has-failures { + background: #fee2e2; + color: #991b1b; +} diff --git a/web/static/js/app.js b/web/static/js/app.js index 6ede99e..b4d8e9a 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -242,6 +242,198 @@ }; })(); +// ── SLA Thresholds settings page ───────────────────────────── +(function () { + 'use strict'; + + var tbody = document.getElementById('threshold-tbody'); + var btnAdd = document.getElementById('btn-add-threshold'); + var btnSave = document.getElementById('btn-save-thresholds'); + var saveStatus = document.getElementById('save-status'); + + if (!tbody) return; + + var METRICS = [ + { value: 'p50_latency', label: 'P50 Latency', unit: 'ms' }, + { value: 'p90_latency', label: 'P90 Latency', unit: 'ms' }, + { value: 'p95_latency', label: 'P95 Latency', unit: 'ms' }, + { value: 'p99_latency', label: 'P99 Latency', unit: 'ms' }, + { value: 'avg_latency', label: 'Avg Latency', unit: 'ms' }, + { value: 'max_latency', label: 'Max Latency', unit: 'ms' }, + { value: 'error_rate', label: 'Error Rate', unit: '%' }, + { value: 'success_rate', label: 'Success Rate', unit: '%' }, + { value: 'rps', label: 'RPS', unit: 'req/s' }, + { value: 'total_requests', label: 'Total Requests', unit: '' }, + { value: 'total_errors', label: 'Total Errors', unit: '' }, + ]; + + var OPERATORS = [ + { value: 'less_than', label: '<' }, + { value: 'less_than_or_equal', label: '≤' }, + { value: 'greater_than', label: '>' }, + { value: 'greater_than_or_equal',label: '≥' }, + { value: 'equal', label: '=' }, + ]; + + function unitForMetric(metric) { + var m = METRICS.find(function (x) { return x.value === metric; }); + return m ? m.unit : ''; + } + + function buildSelect(options, selectedValue, className) { + var sel = document.createElement('select'); + sel.className = className; + options.forEach(function (opt) { + var el = document.createElement('option'); + el.value = opt.value; + el.textContent = opt.label; + if (opt.value === selectedValue) el.selected = true; + sel.appendChild(el); + }); + return sel; + } + + function createThresholdRow(assertion) { + var tr = document.createElement('tr'); + tr.className = 'threshold-row' + (assertion.enabled ? '' : ' disabled-row'); + + var tdEnabled = document.createElement('td'); + tdEnabled.className = 'td-enabled'; + var checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'threshold-checkbox'; + checkbox.checked = assertion.enabled; + checkbox.addEventListener('change', function () { + tr.classList.toggle('disabled-row', !checkbox.checked); + }); + tdEnabled.appendChild(checkbox); + + var tdMetric = document.createElement('td'); + var metricSel = buildSelect(METRICS, assertion.metric, 'threshold-select threshold-metric'); + metricSel.addEventListener('change', function () { + unitCell.textContent = unitForMetric(metricSel.value); + }); + tdMetric.appendChild(metricSel); + + var tdOperator = document.createElement('td'); + tdOperator.appendChild(buildSelect(OPERATORS, assertion.operator, 'threshold-select threshold-operator')); + + var tdValue = document.createElement('td'); + var valueInput = document.createElement('input'); + valueInput.type = 'number'; + valueInput.className = 'threshold-input'; + valueInput.value = assertion.value; + valueInput.min = '0'; + valueInput.step = 'any'; + tdValue.appendChild(valueInput); + + var unitCell = document.createElement('td'); + unitCell.className = 'threshold-unit'; + unitCell.textContent = unitForMetric(assertion.metric); + + var tdRemove = document.createElement('td'); + var removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'threshold-remove'; + removeBtn.title = 'Remove'; + removeBtn.textContent = '×'; + removeBtn.addEventListener('click', function () { tr.remove(); }); + tdRemove.appendChild(removeBtn); + + tr.appendChild(tdEnabled); + tr.appendChild(tdMetric); + tr.appendChild(tdOperator); + tr.appendChild(tdValue); + tr.appendChild(unitCell); + tr.appendChild(tdRemove); + + return tr; + } + + function renderThresholds(assertions) { + tbody.innerHTML = ''; + if (!assertions || assertions.length === 0) { + var emptyRow = document.createElement('tr'); + emptyRow.className = 'threshold-empty-row'; + var emptyCell = document.createElement('td'); + emptyCell.colSpan = 6; + emptyCell.textContent = 'No thresholds configured. Click "+ Add Threshold" to add one.'; + emptyRow.appendChild(emptyCell); + tbody.appendChild(emptyRow); + return; + } + assertions.forEach(function (a) { + tbody.appendChild(createThresholdRow(a)); + }); + } + + function collectThresholds() { + var rows = tbody.querySelectorAll('.threshold-row'); + var result = []; + rows.forEach(function (tr) { + var enabled = tr.querySelector('.threshold-checkbox').checked; + var metric = tr.querySelector('.threshold-metric').value; + var operator = tr.querySelector('.threshold-operator').value; + var value = parseFloat(tr.querySelector('.threshold-input').value) || 0; + result.push({ metric: metric, operator: operator, value: value, enabled: enabled }); + }); + return result; + } + + function showSaveStatus(msg, isError) { + if (!saveStatus) return; + saveStatus.textContent = msg; + saveStatus.className = 'save-status ' + (isError ? 'status-error' : 'status-ok'); + setTimeout(function () { + saveStatus.textContent = ''; + saveStatus.className = 'save-status'; + }, 3000); + } + + fetch('/api/assertions') + .then(function (r) { return r.json(); }) + .then(function (data) { renderThresholds(data); }) + .catch(function () { renderThresholds([]); }); + + if (btnAdd) { + btnAdd.addEventListener('click', function () { + var emptyRow = tbody.querySelector('.threshold-empty-row'); + if (emptyRow) emptyRow.remove(); + tbody.appendChild(createThresholdRow({ + metric: 'p95_latency', operator: 'less_than', value: 500, enabled: true, + })); + }); + } + + if (btnSave) { + btnSave.addEventListener('click', function () { + btnSave.disabled = true; + btnSave.textContent = 'Saving…'; + + var payload = collectThresholds(); + + fetch('/api/assertions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + .then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); }) + .then(function (r) { + if (r.ok) { + showSaveStatus('Changes saved successfully.', false); + } else { + showSaveStatus(r.body.error || 'Save failed.', true); + } + }) + .catch(function () { showSaveStatus('Network error. Please try again.', true); }) + .finally(function () { + btnSave.disabled = false; + btnSave.textContent = 'Save Changes'; + }); + }); + } +}()); + // ── History detail modal ────────────────────────────────────── (function () { 'use strict'; @@ -356,6 +548,26 @@ html += ''; } + // SLA Thresholds + if (d.AssertionResults && d.AssertionResults.length > 0) { + var badgeClass = d.AssertionsPassed ? 'all-passed' : 'has-failures'; + var badgeText = d.AssertionsPassed ? 'All thresholds passed' : 'Some thresholds failed'; + html += '
'; + html += '

SLA Thresholds  ' + badgeText + '

'; + html += ''; + d.AssertionResults.forEach(function (ar) { + var resultClass = ar.Passed ? 'assertion-pass' : 'assertion-fail'; + var resultText = ar.Passed ? 'PASS' : 'FAIL'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + html += '
MetricExpectedActualResult
' + esc(ar.Metric) + '' + esc(ar.Expected) + '' + esc(ar.Actual) + '' + resultText + '
'; + } + detailBody.innerHTML = html; } diff --git a/web/templates.go b/web/templates.go index db3f328..f170e9c 100644 --- a/web/templates.go +++ b/web/templates.go @@ -26,7 +26,7 @@ func newTemplateCache() (*templateCache, error) { return nil, fmt.Errorf("sub templates fs: %w", err) } - pageNames := []string{"home", "history"} + pageNames := []string{"home", "history", "settings"} pages := make(map[string]*template.Template, len(pageNames)) for _, name := range pageNames { diff --git a/web/templates/layout.html b/web/templates/layout.html index 82ad125..3211d78 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -39,11 +39,11 @@ History - + - + - Settings + SLA Thresholds diff --git a/web/templates/settings.html b/web/templates/settings.html new file mode 100644 index 0000000..60aea4e --- /dev/null +++ b/web/templates/settings.html @@ -0,0 +1,42 @@ +{{define "content"}} +
+ +
+
+
+ SLA Thresholds + Evaluated automatically after every test run. Green = pass, red = fail. +
+
+ +
+ + + + + + + + + + + + + + + + +
EnabledMetricOperatorValueUnit
Loading thresholds...
+
+ + +
+ +
+{{end}} diff --git a/web/webconfig.go b/web/webconfig.go index 79070ae..299c739 100644 --- a/web/webconfig.go +++ b/web/webconfig.go @@ -7,15 +7,18 @@ import ( "time" "go.yaml.in/yaml/v3" + + "github.com/farhapartex/loadforge/internal/config" ) type WebConfig struct { - Addr string `yaml:"addr"` - Username string `yaml:"username"` - Password string `yaml:"password"` - SessionTTL string `yaml:"session_ttl"` - LogFile string `yaml:"log_file"` - HistoryFile string `yaml:"history_file"` + Addr string `yaml:"addr"` + Username string `yaml:"username"` + Password string `yaml:"password"` + SessionTTL string `yaml:"session_ttl"` + LogFile string `yaml:"log_file"` + HistoryFile string `yaml:"history_file"` + DefaultAssertions []config.Assertion `yaml:"assertions,omitempty"` } func (c *WebConfig) parsedSessionTTL() time.Duration { @@ -25,6 +28,23 @@ func (c *WebConfig) parsedSessionTTL() time.Duration { return 24 * time.Hour } +func (c *WebConfig) save(path string) error { + data, err := yaml.Marshal(c) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return fmt.Errorf("write temp config: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return fmt.Errorf("commit config: %w", err) + } + return nil +} + func loadWebConfig(path string) (*WebConfig, error) { cfg := defaultWebConfig() @@ -40,16 +60,32 @@ func loadWebConfig(path string) (*WebConfig, error) { return nil, fmt.Errorf("parse config %q: %w", path, err) } + if len(cfg.DefaultAssertions) == 0 { + cfg.DefaultAssertions = defaultAssertions() + _ = cfg.save(path) + } + return cfg, nil } func defaultWebConfig() *WebConfig { return &WebConfig{ - Addr: ":8080", - Username: "admin", - Password: "admin", - SessionTTL: "24h", - LogFile: "load_forge.logs", - HistoryFile: "load_forge_history.json", + Addr: ":8080", + Username: "admin", + Password: "admin", + SessionTTL: "24h", + LogFile: "load_forge.logs", + HistoryFile: "load_forge_history.json", + DefaultAssertions: defaultAssertions(), + } +} + +func defaultAssertions() []config.Assertion { + return []config.Assertion{ + {Metric: "p95_latency", Operator: "less_than", Value: 500, Enabled: true}, + {Metric: "p99_latency", Operator: "less_than", Value: 1000, Enabled: true}, + {Metric: "error_rate", Operator: "less_than", Value: 1.0, Enabled: true}, + {Metric: "success_rate", Operator: "greater_than_or_equal", Value: 99.0, Enabled: false}, + {Metric: "rps", Operator: "greater_than", Value: 10, Enabled: false}, } } From 9bdec20b478cecab6465d8247364477701bbf461 Mon Sep 17 00:00:00 2001 From: Md Nazmul Hasan Date: Tue, 14 Apr 2026 10:31:29 +1000 Subject: [PATCH 2/2] (feat) added ci pipeline for build and cli installation --- .github/workflows/ci.yml | 73 +++++++ .github/workflows/release.yml | 67 +++++++ cmd/web/main.go | 13 +- internal/cli/root.go | 45 ++++- internal/cli/version.go | 2 +- scripts/goreleaser.yml | 90 +++++++++ scripts/install.sh | 205 +++++++++++++++++++ version.yml | 1 + web/appdir.go | 19 ++ web/static/css/app.css | 358 ++++++++++++++++++++++++++++++---- web/static/js/app.js | 265 ++++++++++++++++++------- web/templates/history.html | 11 +- web/webconfig.go | 12 +- 13 files changed, 1044 insertions(+), 117 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 scripts/goreleaser.yml create mode 100755 scripts/install.sh create mode 100644 version.yml create mode 100644 web/appdir.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..51c6d8c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + push: + branches: [main] + paths-ignore: + - "**.md" + - "scripts/install.sh" + - "version.yml" + pull_request: + branches: [main] + paths-ignore: + - "**.md" + - "scripts/install.sh" + - "version.yml" + +jobs: + build: + name: Build & Verify + runs-on: ubuntu-latest + + strategy: + matrix: + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + exclude: + - goos: windows + goarch: arm64 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Download dependencies + run: go mod download + + - name: Verify dependencies + run: go mod verify + + - name: Build CLI + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + run: go build -o /dev/null ./cmd/loadforge + + - name: Build Web + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + run: go build -o /dev/null ./cmd/web + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run go vet + run: go vet ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9ee1ed9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,67 @@ +name: Release + +on: + push: + branches: [main] + paths-ignore: + - "**.md" + - "scripts/install.sh" + +permissions: + contents: write + +jobs: + release: + name: Tag & Publish + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Read version from version.yml + id: version + run: | + VERSION=$(grep 'version:' version.yml | sed "s/version: *['\"]*//" | tr -d "'\"" | tr -d '[:space:]') + echo "value=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Check if tag already exists + id: tag_check + run: | + TAG="${{ steps.version.outputs.tag }}" + if git ls-remote --tags origin "$TAG" | grep -q "$TAG"; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Tag $TAG already exists — skipping release." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "Tag $TAG not found — will create release." + fi + + - name: Create and push tag + if: steps.tag_check.outputs.exists == 'false' + run: | + TAG="${{ steps.version.outputs.tag }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag "$TAG" + git push origin "$TAG" + + - name: Set up Go + if: steps.tag_check.outputs.exists == 'false' + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Run GoReleaser + if: steps.tag_check.outputs.exists == 'false' + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean --config scripts/goreleaser.yml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/cmd/web/main.go b/cmd/web/main.go index 0151e54..0ff6001 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -3,15 +3,26 @@ package main import ( "flag" "log" + "os" + "path/filepath" "github.com/farhapartex/loadforge/web" ) func main() { - configPath := flag.String("config", "web.yml", "path to web server config file") + defaultConfig := defaultConfigPath() + configPath := flag.String("config", defaultConfig, "path to web server config file") flag.Parse() if err := web.Start(*configPath); err != nil { log.Fatal(err) } } + +func defaultConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "web.yml" + } + return filepath.Join(home, ".loadforge", "web.yml") +} diff --git a/internal/cli/root.go b/internal/cli/root.go index d288c9f..7358b71 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -3,23 +3,66 @@ package cli import ( "fmt" "os" + "path/filepath" "github.com/spf13/cobra" ) +const installPath = "/usr/local/bin/loadforge" + +var uninstall bool + var rootCmd = &cobra.Command{ Use: "loadforge", Short: "A powerful HTTP load testing tool", Long: "loadforge is a developer first HTTP load testing tool", + RunE: func(cmd *cobra.Command, args []string) error { + if uninstall { + return runUninstall() + } + return cmd.Help() + }, } func Execute() { if err := rootCmd.Execute(); err != nil { - fmt.Println(os.Stderr, err) + fmt.Fprintln(os.Stderr, err) os.Exit(1) } } func init() { rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose output") + rootCmd.Flags().BoolVar(&uninstall, "uninstall", false, "Uninstall loadforge from the system") +} + +func runUninstall() error { + if _, err := os.Stat(installPath); os.IsNotExist(err) { + return fmt.Errorf("loadforge is not installed at %s", installPath) + } + + if err := os.Remove(installPath); err != nil { + return fmt.Errorf("failed to remove %s: %w (try running with sudo)", installPath, err) + } + fmt.Printf("Removed %s\n", installPath) + + appDir, err := appDataDir() + if err == nil { + if err := os.RemoveAll(appDir); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not remove %s: %v\n", appDir, err) + } else { + fmt.Printf("Removed %s\n", appDir) + } + } + + fmt.Println("loadforge has been uninstalled.") + return nil +} + +func appDataDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".loadforge"), nil } diff --git a/internal/cli/version.go b/internal/cli/version.go index a4b5940..c0a7da5 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" ) -var version = "0.1.0" +var version = "dev" var versionCmd = &cobra.Command{ Use: "version", diff --git a/scripts/goreleaser.yml b/scripts/goreleaser.yml new file mode 100644 index 0000000..05300e7 --- /dev/null +++ b/scripts/goreleaser.yml @@ -0,0 +1,90 @@ +version: 2 + +project_name: loadforge + +before: + hooks: + - go mod tidy + +builds: + - id: loadforge + main: ./cmd/loadforge + binary: loadforge + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + ldflags: + - -s -w + - -X github.com/farhapartex/loadforge/internal/cli.version={{.Version}} + + - id: loadforge-web + main: ./cmd/web + binary: loadforge-web + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + ldflags: + - -s -w + +archives: + - id: loadforge + builds: + - loadforge + - loadforge-web + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format_overrides: + - goos: windows + formats: + - zip + files: + - README.md + - LICENSE + +checksum: + name_template: "checksums.txt" + algorithm: sha256 + +release: + github: + owner: farhapartex + name: loadforge + name_template: "v{{ .Version }}" + draft: false + prerelease: auto + header: | + ## LoadForge {{ .Tag }} + + Install with one command: + ```bash + curl -fsSL https://raw.githubusercontent.com/farhapartex/loadforge/main/scripts/install.sh | sudo bash + ``` + footer: | + **Full changelog**: https://github.com/farhapartex/loadforge/compare/{{ .PreviousTag }}...{{ .Tag }} + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - Merge pull request + - Merge branch diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..7183fb0 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO="farhapartex/loadforge" +BINARY="loadforge" +INSTALL_DIR="/usr/local/bin" +APP_DIR_NAME=".loadforge" + +info() { echo "[loadforge] $*"; } +error() { echo "[loadforge] ERROR: $*" >&2; exit 1; } + +require() { + command -v "$1" >/dev/null 2>&1 || error "'$1' is required but not installed." +} + + +real_user() { + if [ -n "${SUDO_USER:-}" ]; then + echo "$SUDO_USER" + else + echo "$(whoami)" + fi +} + +real_home() { + local user + user=$(real_user) + if command -v getent >/dev/null 2>&1; then + getent passwd "$user" | cut -d: -f6 + else + # macOS fallback + eval echo "~$user" + fi +} + +detect_os() { + case "$(uname -s)" in + Linux*) echo "linux" ;; + Darwin*) echo "darwin" ;; + *) error "Unsupported OS: $(uname -s). Only Linux and macOS are supported." ;; + esac +} + +detect_arch() { + case "$(uname -m)" in + x86_64|amd64) echo "amd64" ;; + arm64|aarch64) echo "arm64" ;; + *) error "Unsupported architecture: $(uname -m)." ;; + esac +} + +latest_version() { + local version + version=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name"' \ + | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/') + [ -n "$version" ] || error "Could not determine the latest release version." + echo "$version" +} + +write_default_config() { + local config_file="$1" + local history_file="$2" + local log_file="$3" + + cat > "$config_file" < "$history_file" + info "Created history file at ${history_file}" + fi + + # Ensure the real user owns the directory (not root) + if [ -n "${SUDO_USER:-}" ]; then + chown -R "${user}:$(id -gn "$user")" "$app_dir" + fi +} + +install() { + require curl + require tar + + local os arch version ver_num archive_name download_url tmpdir + + os=$(detect_os) + arch=$(detect_arch) + version=$(latest_version) + ver_num="${version#v}" + + archive_name="${BINARY}_${ver_num}_${os}_${arch}.tar.gz" + download_url="https://github.com/${REPO}/releases/download/${version}/${archive_name}" + + info "Installing ${BINARY} ${version} (${os}/${arch})" + info "Downloading ${download_url}" + + tmpdir=$(mktemp -d) + trap 'rm -rf "$tmpdir"' EXIT + + curl -fsSL "$download_url" -o "${tmpdir}/${archive_name}" \ + || error "Download failed. Check that ${version} exists at https://github.com/${REPO}/releases" + + tar -xzf "${tmpdir}/${archive_name}" -C "$tmpdir" + + install -m 755 "${tmpdir}/${BINARY}" "${INSTALL_DIR}/${BINARY}" \ + || error "Failed to install binary to ${INSTALL_DIR}. Try running with sudo." + + install -m 755 "${tmpdir}/${BINARY}-web" "${INSTALL_DIR}/${BINARY}-web" \ + || error "Failed to install loadforge-web to ${INSTALL_DIR}. Try running with sudo." + + info "Installed binary to ${INSTALL_DIR}/${BINARY}" + info "Installed binary to ${INSTALL_DIR}/${BINARY}-web" + + setup_app_dir + + echo "" + info "Installation complete!" + info " Binary : ${INSTALL_DIR}/${BINARY}" + info " Config : $(real_home)/${APP_DIR_NAME}/web.yml" + info " History : $(real_home)/${APP_DIR_NAME}/load_forge_history.json" + echo "" + info "Run '${BINARY} version' to verify." + info "Run '${BINARY} --help' to get started." +} + +uninstall() { + local target="${INSTALL_DIR}/${BINARY}" + local app_dir + app_dir="$(real_home)/${APP_DIR_NAME}" + + if [ ! -f "$target" ]; then + error "${BINARY} is not installed at ${target}." + fi + + rm -f "$target" + rm -f "${INSTALL_DIR}/${BINARY}-web" + info "Removed ${target}" + info "Removed ${INSTALL_DIR}/${BINARY}-web" + + if [ -d "$app_dir" ]; then + rm -rf "$app_dir" + info "Removed ${app_dir}" + fi + + info "${BINARY} has been fully uninstalled." +} + +case "${1:-install}" in + install) install ;; + uninstall) uninstall ;; + *) error "Unknown command '${1}'. Use: install | uninstall" ;; +esac diff --git a/version.yml b/version.yml new file mode 100644 index 0000000..ae3e08a --- /dev/null +++ b/version.yml @@ -0,0 +1 @@ +version: "0.1.0" diff --git a/web/appdir.go b/web/appdir.go new file mode 100644 index 0000000..90b12bb --- /dev/null +++ b/web/appdir.go @@ -0,0 +1,19 @@ +package web + +import ( + "fmt" + "os" + "path/filepath" +) + +func loadForgeDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + dir := filepath.Join(home, ".loadforge") + if err := os.MkdirAll(dir, 0755); err != nil { + return "", fmt.Errorf("cannot create %s: %w", dir, err) + } + return dir, nil +} diff --git a/web/static/css/app.css b/web/static/css/app.css index e2eb45a..12d1e15 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -938,86 +938,372 @@ a { /* ── Detail modal ─────────────────────────────────────────────── */ -.modal-wide { - width: min(860px, 94vw); - max-height: 88vh; +.modal-detail { + width: min(1100px, 96vw); + height: 90vh; display: flex; flex-direction: column; + overflow: hidden; } -.detail-body { - overflow-y: auto; +.detail-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid var(--color-border); + min-height: 0; + flex-shrink: 0; +} + +.detail-header-left { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.detail-header-title { + font-size: 13px; + font-weight: 500; + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 560px; + margin: 0; +} + +/* ── Hero bar ─────────────────────────────────────────────────── */ + +.detail-hero { + display: grid; + grid-template-columns: repeat(4, 1fr); + background: #f8fafc; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.detail-hero-stat { + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 12px; + gap: 4px; + border-right: 1px solid var(--color-border); +} + +.detail-hero-stat:last-child { + border-right: none; +} + +.detail-hero-value { + font-size: 22px; + font-weight: 700; + color: var(--color-text); + line-height: 1.2; + letter-spacing: -0.02em; +} + +.detail-hero-label { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); +} + +.detail-hero-stat.hero-success .detail-hero-value { color: #16a34a; } +.detail-hero-stat.hero-danger .detail-hero-value { color: #dc2626; } + +/* ── Tab navigation ──────────────────────────────────────────── */ + +.detail-tab-nav { + display: flex; + gap: 0; + border-bottom: 1px solid var(--color-border); + background: #fff; + flex-shrink: 0; + padding: 0 20px; +} + +.detail-tab { + position: relative; + padding: 11px 16px; + font-size: 13px; + font-weight: 500; + color: var(--color-text-muted); + background: none; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + cursor: pointer; + white-space: nowrap; + transition: color 0.15s; +} + +.detail-tab:hover { color: var(--color-text); } + +.detail-tab.active { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} + +/* ── Tab panels ──────────────────────────────────────────────── */ + +.detail-panels { flex: 1; - padding: 20px 24px 24px; + overflow-y: auto; + min-height: 0; } -.detail-loading { - text-align: center; - padding: 40px; +.detail-tab-panel { + padding: 24px; +} + +.detail-tab-panel.hidden { + display: none; +} + +/* ── Overview panel ──────────────────────────────────────────── */ + +.detail-info-list { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; +} + +.detail-info-row { + display: flex; + align-items: baseline; + padding: 10px 16px; + border-bottom: 1px solid var(--color-border); + font-size: 13px; +} + +.detail-info-row:last-child { border-bottom: none; } +.detail-info-row:nth-child(even) { background: #f8fafc; } + +.detail-info-label { + width: 160px; + flex-shrink: 0; + font-size: 12px; + font-weight: 500; color: var(--color-text-muted); - font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.detail-info-value { + color: var(--color-text); + font-weight: 500; + word-break: break-all; +} + +.detail-url-full { + color: var(--color-text-secondary); + font-size: 12px; + word-break: break-all; } -.detail-error { +.detail-error-banner { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 16px; + padding: 12px 16px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: var(--radius-sm); color: #991b1b; font-size: 13px; } -.detail-section { - margin-bottom: 24px; +.detail-error-banner svg { + flex-shrink: 0; + margin-top: 1px; +} + +/* ── Metrics panel ───────────────────────────────────────────── */ + +.detail-metric-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + margin-bottom: 28px; +} + +.detail-metric-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + padding: 18px 20px; + background: #fff; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + box-shadow: 0 1px 3px rgba(0,0,0,0.04); +} + +.detail-metric-card.card-success { border-left: 3px solid #22c55e; } +.detail-metric-card.card-danger { border-left: 3px solid #ef4444; } + +.detail-metric-value { + font-size: 26px; + font-weight: 700; + color: var(--color-text); + letter-spacing: -0.02em; + line-height: 1; } -.detail-section:last-child { - margin-bottom: 0; +.detail-metric-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); } -.detail-section-title { +.detail-latency-section { margin-top: 4px; } + +.detail-sub-title { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted); - margin-bottom: 10px; - padding-bottom: 6px; - border-bottom: 1px solid var(--color-border); + margin-bottom: 12px; } -.detail-grid { +.detail-latency-cards { display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); - gap: 10px 16px; + grid-template-columns: repeat(4, 1fr); + gap: 12px; } -.detail-cell { +.detail-latency-card { display: flex; flex-direction: column; - gap: 2px; + align-items: center; + gap: 8px; + padding: 18px 12px; + background: #f8fafc; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); } -.detail-label { +.detail-latency-pct { font-size: 11px; - color: var(--color-text-muted); - font-weight: 500; + font-weight: 700; text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.08em; + color: var(--color-text-muted); } -.detail-value { - font-size: 13px; +.detail-latency-val { + font-size: 20px; + font-weight: 700; color: var(--color-text); - font-weight: 500; - word-break: break-all; + letter-spacing: -0.01em; } -.detail-url { - color: var(--color-text-secondary); +/* ── Status codes panel ──────────────────────────────────────── */ + +.http-code-badge { + display: inline-block; + padding: 2px 8px; + border-radius: var(--radius-sm); font-size: 12px; + font-weight: 600; + font-family: monospace; } -.detail-err { +.http-code-badge.code-2xx { background: #dcfce7; color: #166534; } +.http-code-badge.code-4xx { background: #fef9c3; color: #854d0e; } +.http-code-badge.code-5xx { background: #fee2e2; color: #991b1b; } + +/* ── Errors panel ────────────────────────────────────────────── */ + +.error-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + padding: 2px 8px; + background: #fee2e2; color: #991b1b; + border-radius: 20px; font-size: 12px; - word-break: break-all; + font-weight: 600; +} + +.detail-no-errors { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 60px 24px; + color: #16a34a; + font-size: 14px; + font-weight: 500; +} + +.detail-no-errors svg { stroke: #16a34a; } + +/* ── SLA panel ───────────────────────────────────────────────── */ + +.sla-panel-header { + margin-bottom: 16px; +} + +.sla-row-pass td { background: #f0fdf4; } +.sla-row-fail td { background: #fff5f5; } + +.sla-result-badge { + display: inline-block; + padding: 2px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; +} + +.sla-result-badge.sla-pass { background: #dcfce7; color: #166534; } +.sla-result-badge.sla-fail { background: #fee2e2; color: #991b1b; } + +/* ── Shared empty panel state ────────────────────────────────── */ + +.detail-empty-panel { + display: flex; + align-items: center; + justify-content: center; + padding: 60px 24px; + color: var(--color-text-muted); + font-size: 14px; +} + +/* ── Loading / error fallback ────────────────────────────────── */ + +.detail-loading { + display: flex; + align-items: center; + justify-content: center; + height: 200px; + color: var(--color-text-muted); + font-size: 14px; +} + +.detail-body-shell { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; } .detail-table { diff --git a/web/static/js/app.js b/web/static/js/app.js index b4d8e9a..04b33ac 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -441,6 +441,8 @@ var detailOverlay = document.getElementById('modal-run-detail'); var detailBody = document.getElementById('detail-body'); var btnClose = document.getElementById('btn-detail-close'); + var headerBadge = document.getElementById('detail-header-badge'); + var headerTitle = document.getElementById('detail-modal-title'); if (!detailOverlay) return; @@ -450,7 +452,9 @@ function closeDetail() { detailOverlay.classList.remove('open'); - if (detailBody) detailBody.innerHTML = '
Loading...
'; + if (detailBody) detailBody.innerHTML = '
Loading...
'; + if (headerBadge) headerBadge.innerHTML = ''; + if (headerTitle) headerTitle.textContent = 'Run Details'; } if (btnClose) btnClose.addEventListener('click', closeDetail); @@ -470,9 +474,9 @@ openDetail(); fetch('/api/history?id=' + encodeURIComponent(id)) .then(function (res) { return res.json(); }) - .then(function (d) { renderDetail(d); }) - .catch(function () { - detailBody.innerHTML = '

Failed to load run details.

'; + .then(function (d) { renderDetail(d); }) + .catch(function () { + if (detailBody) detailBody.innerHTML = '

Failed to load run details.

'; }); }); }); @@ -484,94 +488,211 @@ .replace(/>/g, '>'); } - function renderDetail(d) { - var html = ''; - - // Overview - html += '
'; - html += '

Overview

'; - html += '
'; - html += detailCell('Status', '' + esc(d.Status) + ''); - html += detailCell('Spec URL', '' + esc(d.SpecURL) + ''); - html += detailCell('Profile', esc(d.Profile)); - html += detailCell('Workers', esc(d.Workers)); - html += detailCell('Config Duration', esc(d.Duration)); - html += detailCell('Started', esc(d.StartedAt)); - html += detailCell('Ended', esc(d.EndedAt) || '—'); - html += detailCell('Elapsed', esc(d.Elapsed)); - if (d.Error) html += detailCell('Error', '' + esc(d.Error) + ''); - html += '
'; - - // Metrics - html += '
'; - html += '

Metrics

'; - html += '
'; - html += detailCell('Total Requests', esc(d.Requests)); - html += detailCell('Successes', esc(d.Successes)); - html += detailCell('Failures', esc(d.Failures)); - html += detailCell('Error Rate', esc(d.ErrorRate)); - html += detailCell('Avg RPS', esc(d.RPS)); - html += detailCell('Data Received', esc(d.DataBytes)); - html += '
'; - - // Latency + // ── Render helpers ────────────────────────────────────────── + + function heroStat(value, label, mod) { + return '
' + + '' + esc(String(value)) + '' + + '' + label + '' + + '
'; + } + + function metricCard(value, label, mod) { + return '
' + + '' + esc(String(value)) + '' + + '' + label + '' + + '
'; + } + + function latencyCard(pct, value) { + return '
' + + '' + pct + '' + + '' + esc(value) + '' + + '
'; + } + + function infoRow(label, value) { + return '
' + + '' + label + '' + + '' + value + '' + + '
'; + } + + function emptyPanel(msg) { + return '
' + esc(msg) + '
'; + } + + // ── Tab panel builders ────────────────────────────────────── + + function buildOverviewPanel(d) { + var html = '
'; + html += '
'; + html += infoRow('Status', '' + esc(d.Status) + ''); + html += infoRow('Spec URL', '' + esc(d.SpecURL) + ''); + html += infoRow('Profile', esc(d.Profile)); + html += infoRow('Workers', esc(d.Workers)); + html += infoRow('Config Duration', esc(d.Duration)); + html += infoRow('Started', esc(d.StartedAt)); + html += infoRow('Ended', esc(d.EndedAt) || '—'); + html += infoRow('Elapsed', esc(d.Elapsed)); + html += '
'; + if (d.Error) { + html += '
' + + '' + + '' + esc(d.Error) + '
'; + } + html += '
'; + return html; + } + + function buildMetricsPanel(d) { + var html = '