mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-03 21:02:56 +00:00
8011d487f1
- Add status pages, incidents, badges, maintenance, bulk ops, and metrics - Add Docker packaging, env example, and frontend routes - Refresh GitHub workflows and project metadata
250 lines
6.9 KiB
Go
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)
|
|
}
|