Files
Beszel/internal/hub/pagespeed/checker.go
T
Tomas Dvorak 8011d487f1 Add public monitoring features and CI updates
- Add status pages, incidents, badges, maintenance, bulk ops, and metrics
- Add Docker packaging, env example, and frontend routes
- Refresh GitHub workflows and project metadata
2026-04-27 11:10:18 +02:00

250 lines
6.9 KiB
Go

package pagespeed
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// PageSpeedResponse represents the PageSpeed API response
type PageSpeedResponse struct {
LighthouseResult struct {
Categories struct {
Performance struct {
Score float64 `json:"score"`
} `json:"performance"`
Accessibility struct {
Score float64 `json:"score"`
} `json:"accessibility"`
BestPractices struct {
Score float64 `json:"best-practices"`
} `json:"best-practices"`
SEO struct {
Score float64 `json:"score"`
} `json:"seo"`
PWA struct {
Score float64 `json:"score"`
} `json:"pwa"`
} `json:"categories"`
Audits struct {
Fcp struct {
DisplayValue string `json:"displayValue"`
NumericValue float64 `json:"numericValue"`
} `json:"first-contentful-paint"`
Lcp struct {
DisplayValue string `json:"displayValue"`
NumericValue float64 `json:"numericValue"`
} `json:"largest-contentful-paint"`
Ttfb struct {
DisplayValue string `json:"displayValue"`
NumericValue float64 `json:"numericValue"`
} `json:"server-response-time"`
Cls struct {
DisplayValue string `json:"displayValue"`
NumericValue float64 `json:"numericValue"`
} `json:"cumulative-layout-shift"`
Tbt struct {
DisplayValue string `json:"displayValue"`
NumericValue float64 `json:"numericValue"`
} `json:"total-blocking-time"`
SpeedIndex struct {
DisplayValue string `json:"displayValue"`
NumericValue float64 `json:"numericValue"`
} `json:"speed-index"`
Interactive struct {
DisplayValue string `json:"displayValue"`
NumericValue float64 `json:"numericValue"`
} `json:"interactive"`
} `json:"audits"`
} `json:"lighthouseResult"`
AnalysisUTCTimestamp string `json:"analysisUTCTimestamp"`
Id string `json:"id"`
}
// Metrics represents the extracted performance metrics
type Metrics struct {
Performance float64 `json:"performance"`
Accessibility float64 `json:"accessibility"`
BestPractices float64 `json:"bestPractices"`
SEO float64 `json:"seo"`
PWA float64 `json:"pwa"`
FCP float64 `json:"fcp"` // First Contentful Paint (ms)
LCP float64 `json:"lcp"` // Largest Contentful Paint (ms)
TTFB float64 `json:"ttfb"` // Time to First Byte (ms)
CLS float64 `json:"cls"` // Cumulative Layout Shift
TBT float64 `json:"tbt"` // Total Blocking Time (ms)
SpeedIndex float64 `json:"speedIndex"` // Speed Index (ms)
TTI float64 `json:"tti"` // Time to Interactive (ms)
CheckedAt time.Time `json:"checkedAt"`
URL string `json:"url"`
Strategy string `json:"strategy"` // mobile or desktop
}
// Checker handles PageSpeed checks
type Checker struct {
apiKey string
client *http.Client
}
// NewChecker creates a new PageSpeed checker
func NewChecker(apiKey string) *Checker {
return &Checker{
apiKey: apiKey,
client: &http.Client{
Timeout: 60 * time.Second,
},
}
}
// CheckURL runs a PageSpeed check on a URL
func (c *Checker) CheckURL(url string, strategy string) (*Metrics, error) {
if strategy == "" {
strategy = "mobile"
}
// Build PageSpeed API URL
apiURL := fmt.Sprintf(
"https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=%s&strategy=%s&category=PERFORMANCE&category=ACCESSIBILITY&category=BEST_PRACTICES&category=SEO&category=PWA",
url,
strategy,
)
if c.apiKey != "" {
apiURL += "&key=" + c.apiKey
}
resp, err := c.client.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("pagespeed API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("pagespeed API returned status %d: %s", resp.StatusCode, string(body))
}
var result PageSpeedResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode pagespeed response: %w", err)
}
metrics := &Metrics{
URL: url,
Strategy: strategy,
CheckedAt: time.Now(),
Performance: result.LighthouseResult.Categories.Performance.Score * 100,
Accessibility: result.LighthouseResult.Categories.Accessibility.Score * 100,
BestPractices: result.LighthouseResult.Categories.BestPractices.Score * 100,
SEO: result.LighthouseResult.Categories.SEO.Score * 100,
PWA: result.LighthouseResult.Categories.PWA.Score * 100,
FCP: result.LighthouseResult.Audits.Fcp.NumericValue,
LCP: result.LighthouseResult.Audits.Lcp.NumericValue,
TTFB: result.LighthouseResult.Audits.Ttfb.NumericValue,
CLS: result.LighthouseResult.Audits.Cls.NumericValue,
TBT: result.LighthouseResult.Audits.Tbt.NumericValue,
SpeedIndex: result.LighthouseResult.Audits.SpeedIndex.NumericValue,
TTI: result.LighthouseResult.Audits.Interactive.NumericValue,
}
return metrics, nil
}
// Grade returns a letter grade based on score
func Grade(score float64) string {
switch {
case score >= 90:
return "A"
case score >= 80:
return "B"
case score >= 70:
return "C"
case score >= 60:
return "D"
default:
return "F"
}
}
// GradeColor returns a color for the grade
func GradeColor(score float64) string {
switch {
case score >= 90:
return "brightgreen"
case score >= 80:
return "green"
case score >= 70:
return "yellow"
case score >= 60:
return "orange"
default:
return "red"
}
}
// GetCoreWebVitalsStatus returns the status for Core Web Vitals
func GetCoreWebVitalsStatus(metrics *Metrics) map[string]string {
return map[string]string{
"lcp": getLCPStatus(metrics.LCP),
"fid": getFIDStatus(metrics.TBT), // Using TBT as proxy for FID
"cls": getCLSStatus(metrics.CLS),
"fcp": getFCPStatus(metrics.FCP),
"ttfb": getTTFBStatus(metrics.TTFB),
}
}
func getLCPStatus(value float64) string {
if value <= 2500 {
return "good"
} else if value <= 4000 {
return "needs-improvement"
}
return "poor"
}
func getFIDStatus(value float64) string {
if value <= 100 {
return "good"
} else if value <= 300 {
return "needs-improvement"
}
return "poor"
}
func getCLSStatus(value float64) string {
if value <= 0.1 {
return "good"
} else if value <= 0.25 {
return "needs-improvement"
}
return "poor"
}
func getFCPStatus(value float64) string {
if value <= 1800 {
return "good"
} else if value <= 3000 {
return "needs-improvement"
}
return "poor"
}
func getTTFBStatus(value float64) string {
if value <= 800 {
return "good"
} else if value <= 1800 {
return "needs-improvement"
}
return "poor"
}
// FormatDuration formats milliseconds to readable string
func FormatDuration(ms float64) string {
if ms < 1000 {
return fmt.Sprintf("%.0fms", ms)
}
return fmt.Sprintf("%.1fs", ms/1000)
}