diff --git a/config/config.go b/config/config.go index bb46b6f..cd4b2ec 100644 --- a/config/config.go +++ b/config/config.go @@ -35,6 +35,8 @@ type Config struct { EnableTLS bool `mapstructure:"enable_tls"` TLSCertFile string `mapstructure:"tls_cert_file"` TLSKeyFile string `mapstructure:"tls_key_file"` + + RedirectPort string `mapstructure:"redirect_from"` } var ( diff --git a/database/bolt/bolt.go b/database/bolt/bolt.go index 71b0eb9..0daafae 100644 --- a/database/bolt/bolt.go +++ b/database/bolt/bolt.go @@ -55,7 +55,6 @@ func (p *Bolt) FetchByUUID(uuid string) (*schema.TelemetryData, error) { func (p *Bolt) FetchLast100() ([]schema.TelemetryData, error) { var records []schema.TelemetryData err := p.db.View(func(tx *bbolt.Tx) error { - var record schema.TelemetryData bucket := tx.Bucket([]byte(bucketName)) if bucket == nil { return errors.New("data bucket doesn't exist yet") @@ -65,6 +64,11 @@ func (p *Bolt) FetchLast100() ([]schema.TelemetryData, error) { _, b := cursor.Last() for len(records) < 100 { + // IMPORTANT: declare a fresh record on every iteration. + // json.Unmarshal does NOT reset fields that are absent from the JSON, + // so reusing one struct would let values (e.g. ClientID, GradeData) + // leak from an earlier row into a later one. + var record schema.TelemetryData if err := json.Unmarshal(b, &record); err != nil { return err } @@ -80,3 +84,53 @@ func (p *Bolt) FetchLast100() ([]schema.TelemetryData, error) { }) return records, err } + +// FetchAll returns one page of records (most recent first), skipping `offset` +// records. ULID keys are lexicographically ordered by time, so descending key +// order == newest first. +func (p *Bolt) FetchAll(offset, limit int) ([]schema.TelemetryData, error) { + if limit <= 0 { + limit = 50 + } + var records []schema.TelemetryData + err := p.db.View(func(tx *bbolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + if bucket == nil { + return errors.New("data bucket doesn't exist yet") + } + + skipped, taken := 0, 0 + cursor := bucket.Cursor() + for k, b := cursor.Last(); k != nil; k, b = cursor.Prev() { + if skipped < offset { + skipped++ + continue + } + if taken >= limit { + break + } + var record schema.TelemetryData // fresh each iteration (see FetchLast100) + if err := json.Unmarshal(b, &record); err != nil { + return err + } + records = append(records, record) + taken++ + } + return nil + }) + return records, err +} + +// Count returns the total number of stored records. +func (p *Bolt) Count() (int, error) { + var count int + err := p.db.View(func(tx *bbolt.Tx) error { + bucket := tx.Bucket([]byte(bucketName)) + if bucket == nil { + return nil // no bucket yet → 0 records + } + count = bucket.Stats().KeyN + return nil + }) + return count, err +} diff --git a/database/database.go b/database/database.go index cb347bd..8781ca5 100644 --- a/database/database.go +++ b/database/database.go @@ -22,6 +22,8 @@ type DataAccess interface { Insert(*schema.TelemetryData) error FetchByUUID(string) (*schema.TelemetryData, error) FetchLast100() ([]schema.TelemetryData, error) + FetchAll(offset, limit int) ([]schema.TelemetryData, error) + Count() (int, error) } func SetDBInfo(conf *config.Config) { diff --git a/database/memory/memory.go b/database/memory/memory.go index 550fdf8..c22d739 100644 --- a/database/memory/memory.go +++ b/database/memory/memory.go @@ -49,3 +49,33 @@ func (mem *Memory) FetchLast100() ([]schema.TelemetryData, error) { defer mem.lock.RUnlock() return mem.records, nil } + +func (mem *Memory) FetchAll(offset, limit int) ([]schema.TelemetryData, error) { + if limit <= 0 { + limit = 50 + } + mem.lock.RLock() + defer mem.lock.RUnlock() + + n := len(mem.records) + // records are stored oldest→first; we want newest→first + if offset >= n { + return nil, nil + } + end := n - offset + start := end - limit + if start < 0 { + start = 0 + } + out := make([]schema.TelemetryData, 0, end-start) + for i := end - 1; i >= start; i-- { + out = append(out, mem.records[i]) + } + return out, nil +} + +func (mem *Memory) Count() (int, error) { + mem.lock.RLock() + defer mem.lock.RUnlock() + return len(mem.records), nil +} diff --git a/database/mssql/mssql.go b/database/mssql/mssql.go index 526b016..66ae3a8 100644 --- a/database/mssql/mssql.go +++ b/database/mssql/mssql.go @@ -39,43 +39,91 @@ func Open(hostname, username, password, database, port string) *MSSQL { return &MSSQL{db: conn} } +func msScan(row interface{ Scan(...any) error }, record *schema.TelemetryData) error { + var id int64 + return row.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, + &record.UserAgent, &record.Language, &record.Download, &record.Upload, + &record.Ping, &record.Jitter, &record.Log, &record.UUID, + &record.GradeData, &record.ChartData, &record.LatencyUnderload, + &record.PingDuringTest, &record.ClientID) +} + func (p *MSSQL) Insert(data *schema.TelemetryData) error { - stmt := `INSERT INTO speedtest_users (ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid) - VALUES (@p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8, @p9, @p10, @p11);` - _, err := p.db.Exec(stmt, + _, err := p.db.Exec( + `INSERT INTO speedtest_users + (ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid, + grade_data, chart_data, latency_underload, ping_during_test, client_id) + VALUES (@p1,@p2,@p3,@p4,@p5,@p6,@p7,@p8,@p9,@p10,@p11,@p12,@p13,@p14,@p15,@p16)`, data.IPAddress, data.ISPInfo, data.Extra, data.UserAgent, data.Language, - data.Download, data.Upload, data.Ping, data.Jitter, data.Log, data.UUID) + data.Download, data.Upload, data.Ping, data.Jitter, data.Log, data.UUID, + data.GradeData, data.ChartData, data.LatencyUnderload, data.PingDuringTest, data.ClientID) return err } func (p *MSSQL) FetchByUUID(uuid string) (*schema.TelemetryData, error) { var record schema.TelemetryData - row := p.db.QueryRow(`SELECT * FROM speedtest_users WHERE uuid = @p1`, uuid) - if row != nil { - var id int64 - if err := row.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, &record.UserAgent, &record.Language, &record.Download, &record.Upload, &record.Ping, &record.Jitter, &record.Log, &record.UUID); err != nil { - return nil, fmt.Errorf("mssql fetch by uuid: %w", err) - } + row := p.db.QueryRow( + `SELECT id, timestamp, ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid, + COALESCE(grade_data,''), COALESCE(chart_data,''), COALESCE(latency_underload,''), + COALESCE(ping_during_test,''), COALESCE(client_id,'') + FROM speedtest_users WHERE uuid = @p1`, uuid) + if err := msScan(row, &record); err != nil { + return nil, fmt.Errorf("mssql fetch by uuid: %w", err) } return &record, nil } func (p *MSSQL) FetchLast100() ([]schema.TelemetryData, error) { - var records []schema.TelemetryData - rows, err := p.db.Query(`SELECT TOP 100 * FROM speedtest_users ORDER BY timestamp DESC;`) + rows, err := p.db.Query( + `SELECT TOP 100 id, timestamp, ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid, + COALESCE(grade_data,''), COALESCE(chart_data,''), COALESCE(latency_underload,''), + COALESCE(ping_during_test,''), COALESCE(client_id,'') + FROM speedtest_users ORDER BY timestamp DESC`) if err != nil { return nil, fmt.Errorf("mssql fetch last 100: %w", err) } - if rows != nil { - defer rows.Close() - for rows.Next() { - var record schema.TelemetryData - var id int64 - if err := rows.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, &record.UserAgent, &record.Language, &record.Download, &record.Upload, &record.Ping, &record.Jitter, &record.Log, &record.UUID); err != nil { - return nil, fmt.Errorf("mssql scan row: %w", err) - } - records = append(records, record) + defer rows.Close() + var records []schema.TelemetryData + for rows.Next() { + var record schema.TelemetryData + if err := msScan(rows, &record); err != nil { + return nil, fmt.Errorf("mssql scan row: %w", err) + } + records = append(records, record) + } + return records, nil +} + +func (p *MSSQL) FetchAll(offset, limit int) ([]schema.TelemetryData, error) { + if limit <= 0 { + limit = 50 + } + rows, err := p.db.Query( + `SELECT id, timestamp, ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid, + COALESCE(grade_data,''), COALESCE(chart_data,''), COALESCE(latency_underload,''), + COALESCE(ping_during_test,''), COALESCE(client_id,'') + FROM speedtest_users ORDER BY timestamp DESC OFFSET @p1 ROWS FETCH NEXT @p2 ROWS ONLY`, + offset, limit) + if err != nil { + return nil, fmt.Errorf("mssql fetch all: %w", err) + } + defer rows.Close() + var records []schema.TelemetryData + for rows.Next() { + var record schema.TelemetryData + if err := msScan(rows, &record); err != nil { + return nil, fmt.Errorf("mssql scan row: %w", err) } + records = append(records, record) } return records, nil } + +func (p *MSSQL) Count() (int, error) { + var count int + err := p.db.QueryRow(`SELECT COUNT(*) FROM speedtest_users;`).Scan(&count) + if err != nil { + return 0, fmt.Errorf("mssql count: %w", err) + } + return count, nil +} diff --git a/database/mysql/mysql.go b/database/mysql/mysql.go index 42756a6..7888169 100644 --- a/database/mysql/mysql.go +++ b/database/mysql/mysql.go @@ -27,40 +27,88 @@ func Open(hostname, username, password, database string) *MySQL { return &MySQL{db: conn} } +func myScan(row interface{ Scan(...any) error }, record *schema.TelemetryData) error { + var id string + return row.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, + &record.UserAgent, &record.Language, &record.Download, &record.Upload, + &record.Ping, &record.Jitter, &record.Log, &record.UUID, + &record.GradeData, &record.ChartData, &record.LatencyUnderload, + &record.PingDuringTest, &record.ClientID) +} + func (p *MySQL) Insert(data *schema.TelemetryData) error { - stmt := `INSERT INTO speedtest_users (ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);` - _, err := p.db.Exec(stmt, data.IPAddress, data.ISPInfo, data.Extra, data.UserAgent, data.Language, data.Download, data.Upload, data.Ping, data.Jitter, data.Log, data.UUID) + _, err := p.db.Exec( + `INSERT INTO speedtest_users + (ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid, + grade_data, chart_data, latency_underload, ping_during_test, client_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + data.IPAddress, data.ISPInfo, data.Extra, data.UserAgent, data.Language, + data.Download, data.Upload, data.Ping, data.Jitter, data.Log, data.UUID, + data.GradeData, data.ChartData, data.LatencyUnderload, data.PingDuringTest, data.ClientID) return err } func (p *MySQL) FetchByUUID(uuid string) (*schema.TelemetryData, error) { var record schema.TelemetryData - row := p.db.QueryRow(`SELECT * FROM speedtest_users WHERE uuid = ?`, uuid) - if row != nil { - var id string - if err := row.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, &record.UserAgent, &record.Language, &record.Download, &record.Upload, &record.Ping, &record.Jitter, &record.Log, &record.UUID); err != nil { - return nil, err - } + row := p.db.QueryRow( + `SELECT id, timestamp, ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid, + COALESCE(grade_data,''), COALESCE(chart_data,''), COALESCE(latency_underload,''), + COALESCE(ping_during_test,''), COALESCE(client_id,'') + FROM speedtest_users WHERE uuid = ?`, uuid) + if err := myScan(row, &record); err != nil { + return nil, err } return &record, nil } func (p *MySQL) FetchLast100() ([]schema.TelemetryData, error) { - var records []schema.TelemetryData - rows, err := p.db.Query(`SELECT * FROM speedtest_users ORDER BY "timestamp" DESC LIMIT 100;`) + rows, err := p.db.Query( + `SELECT id, timestamp, ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid, + COALESCE(grade_data,''), COALESCE(chart_data,''), COALESCE(latency_underload,''), + COALESCE(ping_during_test,''), COALESCE(client_id,'') + FROM speedtest_users ORDER BY timestamp DESC LIMIT 100`) if err != nil { return nil, err } - if rows != nil { - var id string + defer rows.Close() + var records []schema.TelemetryData + for rows.Next() { + var record schema.TelemetryData + if err := myScan(rows, &record); err != nil { + return nil, err + } + records = append(records, record) + } + return records, nil +} - for rows.Next() { - var record schema.TelemetryData - if err := rows.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, &record.UserAgent, &record.Language, &record.Download, &record.Upload, &record.Ping, &record.Jitter, &record.Log, &record.UUID); err != nil { - return nil, err - } - records = append(records, record) +func (p *MySQL) FetchAll(offset, limit int) ([]schema.TelemetryData, error) { + if limit <= 0 { + limit = 50 + } + rows, err := p.db.Query( + `SELECT id, timestamp, ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid, + COALESCE(grade_data,''), COALESCE(chart_data,''), COALESCE(latency_underload,''), + COALESCE(ping_during_test,''), COALESCE(client_id,'') + FROM speedtest_users ORDER BY timestamp DESC LIMIT ? OFFSET ?`, + limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + var records []schema.TelemetryData + for rows.Next() { + var record schema.TelemetryData + if err := myScan(rows, &record); err != nil { + return nil, err } + records = append(records, record) } return records, nil } + +func (p *MySQL) Count() (int, error) { + var count int + err := p.db.QueryRow(`SELECT COUNT(*) FROM speedtest_users;`).Scan(&count) + return count, err +} diff --git a/database/none/none.go b/database/none/none.go index 05c1d72..259cc23 100644 --- a/database/none/none.go +++ b/database/none/none.go @@ -21,3 +21,11 @@ func (n *None) FetchByUUID(_ string) (*schema.TelemetryData, error) { func (n *None) FetchLast100() ([]schema.TelemetryData, error) { return []schema.TelemetryData{}, nil } + +func (n *None) FetchAll(_, _ int) ([]schema.TelemetryData, error) { + return []schema.TelemetryData{}, nil +} + +func (n *None) Count() (int, error) { + return 0, nil +} diff --git a/database/postgresql/postgresql.go b/database/postgresql/postgresql.go index 3671da7..48799bf 100644 --- a/database/postgresql/postgresql.go +++ b/database/postgresql/postgresql.go @@ -27,40 +27,88 @@ func Open(hostname, username, password, database string) *PostgreSQL { return &PostgreSQL{db: conn} } +func pgScan(row interface{ Scan(...any) error }, record *schema.TelemetryData) error { + var id string + return row.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, + &record.UserAgent, &record.Language, &record.Download, &record.Upload, + &record.Ping, &record.Jitter, &record.Log, &record.UUID, + &record.GradeData, &record.ChartData, &record.LatencyUnderload, + &record.PingDuringTest, &record.ClientID) +} + func (p *PostgreSQL) Insert(data *schema.TelemetryData) error { - stmt := `INSERT INTO speedtest_users (ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id;` - _, err := p.db.Exec(stmt, data.IPAddress, data.ISPInfo, data.Extra, data.UserAgent, data.Language, data.Download, data.Upload, data.Ping, data.Jitter, data.Log, data.UUID) + _, err := p.db.Exec( + `INSERT INTO speedtest_users + (ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid, + grade_data, chart_data, latency_underload, ping_during_test, client_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) RETURNING id;`, + data.IPAddress, data.ISPInfo, data.Extra, data.UserAgent, data.Language, + data.Download, data.Upload, data.Ping, data.Jitter, data.Log, data.UUID, + data.GradeData, data.ChartData, data.LatencyUnderload, data.PingDuringTest, data.ClientID) return err } func (p *PostgreSQL) FetchByUUID(uuid string) (*schema.TelemetryData, error) { var record schema.TelemetryData - row := p.db.QueryRow(`SELECT * FROM speedtest_users WHERE uuid = $1`, uuid) - if row != nil { - var id string - if err := row.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, &record.UserAgent, &record.Language, &record.Download, &record.Upload, &record.Ping, &record.Jitter, &record.Log, &record.UUID); err != nil { - return nil, err - } + row := p.db.QueryRow( + `SELECT id, timestamp, ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid, + COALESCE(grade_data,''), COALESCE(chart_data,''), COALESCE(latency_underload,''), + COALESCE(ping_during_test,''), COALESCE(client_id,'') + FROM speedtest_users WHERE uuid = $1`, uuid) + if err := pgScan(row, &record); err != nil { + return nil, err } return &record, nil } func (p *PostgreSQL) FetchLast100() ([]schema.TelemetryData, error) { - var records []schema.TelemetryData - rows, err := p.db.Query(`SELECT * FROM speedtest_users ORDER BY "timestamp" DESC LIMIT 100;`) + rows, err := p.db.Query( + `SELECT id, timestamp, ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid, + COALESCE(grade_data,''), COALESCE(chart_data,''), COALESCE(latency_underload,''), + COALESCE(ping_during_test,''), COALESCE(client_id,'') + FROM speedtest_users ORDER BY timestamp DESC LIMIT 100`) if err != nil { return nil, err } - if rows != nil { - var id string + defer rows.Close() + var records []schema.TelemetryData + for rows.Next() { + var record schema.TelemetryData + if err := pgScan(rows, &record); err != nil { + return nil, err + } + records = append(records, record) + } + return records, nil +} - for rows.Next() { - var record schema.TelemetryData - if err := rows.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, &record.UserAgent, &record.Language, &record.Download, &record.Upload, &record.Ping, &record.Jitter, &record.Log, &record.UUID); err != nil { - return nil, err - } - records = append(records, record) +func (p *PostgreSQL) FetchAll(offset, limit int) ([]schema.TelemetryData, error) { + if limit <= 0 { + limit = 50 + } + rows, err := p.db.Query( + `SELECT id, timestamp, ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid, + COALESCE(grade_data,''), COALESCE(chart_data,''), COALESCE(latency_underload,''), + COALESCE(ping_during_test,''), COALESCE(client_id,'') + FROM speedtest_users ORDER BY timestamp DESC OFFSET $1 LIMIT $2`, + offset, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var records []schema.TelemetryData + for rows.Next() { + var record schema.TelemetryData + if err := pgScan(rows, &record); err != nil { + return nil, err } + records = append(records, record) } return records, nil } + +func (p *PostgreSQL) Count() (int, error) { + var count int + err := p.db.QueryRow(`SELECT COUNT(*) FROM speedtest_users;`).Scan(&count) + return count, err +} diff --git a/database/schema/schema.go b/database/schema/schema.go index b6ca208..94147ba 100644 --- a/database/schema/schema.go +++ b/database/schema/schema.go @@ -5,16 +5,21 @@ import ( ) type TelemetryData struct { - Timestamp time.Time - IPAddress string - ISPInfo string - Extra string - UserAgent string - Language string - Download string - Upload string - Ping string - Jitter string - Log string - UUID string + Timestamp time.Time + IPAddress string + ISPInfo string + Extra string + UserAgent string + Language string + Download string + Upload string + Ping string + Jitter string + Log string + UUID string + GradeData string // JSON: {grade: "A", criteria: {...}} + ChartData string // JSON: {dl: [{t, v}], ul: [{t, v}]} + LatencyUnderload string // ping during load (ms) + PingDuringTest string // JSON: {dl: [...], ul: [...]} + ClientID string // browser-generated stable device identifier } diff --git a/database/sqlite/sqlite.go b/database/sqlite/sqlite.go index 15e5136..189f932 100644 --- a/database/sqlite/sqlite.go +++ b/database/sqlite/sqlite.go @@ -25,7 +25,7 @@ func Open(databaseFile string) *SQLite { log.Warnf("Failed to set SQLite journal mode to WAL: %s", err) } - // Create table if not exists (matching the PHP SQLite auto-creation behavior) + // Create table if not exists stmt := `CREATE TABLE IF NOT EXISTS speedtest_users ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -39,12 +39,23 @@ func Open(databaseFile string) *SQLite { ping TEXT, jitter TEXT, log TEXT, - uuid TEXT + uuid TEXT, + grade_data TEXT, + chart_data TEXT, + latency_underload TEXT, + ping_during_test TEXT, + client_id TEXT );` if _, err := conn.Exec(stmt); err != nil { log.Fatalf("Failed to create speedtest_users table: %s", err) } + // Migrate existing databases that predate the new columns. + for _, col := range []string{"grade_data", "chart_data", "latency_underload", "ping_during_test", "client_id"} { + _, _ = conn.Exec(`ALTER TABLE speedtest_users ADD COLUMN ` + col + ` TEXT`) + // SQLite returns an error if the column already exists; ignoring it is correct. + } + return &SQLite{db: conn} } @@ -57,39 +68,90 @@ func (p *SQLite) Insert(data *schema.TelemetryData) error { return nil } - stmt := `INSERT INTO speedtest_users (ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);` - _, err = p.db.Exec(stmt, data.IPAddress, data.ISPInfo, data.Extra, data.UserAgent, data.Language, data.Download, data.Upload, data.Ping, data.Jitter, data.Log, data.UUID) + stmt := `INSERT INTO speedtest_users + (ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid, + grade_data, chart_data, latency_underload, ping_during_test, client_id) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);` + _, err = p.db.Exec(stmt, + data.IPAddress, data.ISPInfo, data.Extra, data.UserAgent, data.Language, + data.Download, data.Upload, data.Ping, data.Jitter, data.Log, data.UUID, + data.GradeData, data.ChartData, data.LatencyUnderload, data.PingDuringTest, data.ClientID) return err } +func sqScan(row interface{ Scan(...any) error }, record *schema.TelemetryData) error { + var id int + return row.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, + &record.UserAgent, &record.Language, &record.Download, &record.Upload, + &record.Ping, &record.Jitter, &record.Log, &record.UUID, + &record.GradeData, &record.ChartData, &record.LatencyUnderload, + &record.PingDuringTest, &record.ClientID) +} + func (p *SQLite) FetchByUUID(uuid string) (*schema.TelemetryData, error) { var record schema.TelemetryData - row := p.db.QueryRow(`SELECT * FROM speedtest_users WHERE uuid = ?`, uuid) - if row != nil { - var id int - if err := row.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, &record.UserAgent, &record.Language, &record.Download, &record.Upload, &record.Ping, &record.Jitter, &record.Log, &record.UUID); err != nil { - return nil, fmt.Errorf("sqlite fetch by uuid: %w", err) - } + row := p.db.QueryRow( + `SELECT id, timestamp, ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid, + COALESCE(grade_data,''), COALESCE(chart_data,''), COALESCE(latency_underload,''), + COALESCE(ping_during_test,''), COALESCE(client_id,'') + FROM speedtest_users WHERE uuid = ?`, uuid) + if err := sqScan(row, &record); err != nil { + return nil, fmt.Errorf("sqlite fetch by uuid: %w", err) } return &record, nil } func (p *SQLite) FetchLast100() ([]schema.TelemetryData, error) { - var records []schema.TelemetryData - rows, err := p.db.Query(`SELECT * FROM speedtest_users ORDER BY timestamp DESC LIMIT 100;`) + rows, err := p.db.Query( + `SELECT id, timestamp, ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid, + COALESCE(grade_data,''), COALESCE(chart_data,''), COALESCE(latency_underload,''), + COALESCE(ping_during_test,''), COALESCE(client_id,'') + FROM speedtest_users ORDER BY timestamp DESC LIMIT 100`) if err != nil { return nil, fmt.Errorf("sqlite fetch last 100: %w", err) } - if rows != nil { - defer rows.Close() - for rows.Next() { - var record schema.TelemetryData - var id int - if err := rows.Scan(&id, &record.Timestamp, &record.IPAddress, &record.ISPInfo, &record.Extra, &record.UserAgent, &record.Language, &record.Download, &record.Upload, &record.Ping, &record.Jitter, &record.Log, &record.UUID); err != nil { - return nil, fmt.Errorf("sqlite scan row: %w", err) - } - records = append(records, record) + defer rows.Close() + var records []schema.TelemetryData + for rows.Next() { + var record schema.TelemetryData + if err := sqScan(rows, &record); err != nil { + return nil, fmt.Errorf("sqlite scan row: %w", err) + } + records = append(records, record) + } + return records, nil +} + +func (p *SQLite) FetchAll(offset, limit int) ([]schema.TelemetryData, error) { + if limit <= 0 { + limit = 50 + } + rows, err := p.db.Query( + `SELECT id, timestamp, ip, ispinfo, extra, ua, lang, dl, ul, ping, jitter, log, uuid, + COALESCE(grade_data,''), COALESCE(chart_data,''), COALESCE(latency_underload,''), + COALESCE(ping_during_test,''), COALESCE(client_id,'') + FROM speedtest_users ORDER BY timestamp DESC LIMIT ? OFFSET ?`, + limit, offset) + if err != nil { + return nil, fmt.Errorf("sqlite fetch all: %w", err) + } + defer rows.Close() + var records []schema.TelemetryData + for rows.Next() { + var record schema.TelemetryData + if err := sqScan(rows, &record); err != nil { + return nil, fmt.Errorf("sqlite scan row: %w", err) } + records = append(records, record) } return records, nil } + +func (p *SQLite) Count() (int, error) { + var count int + err := p.db.QueryRow(`SELECT COUNT(*) FROM speedtest_users;`).Scan(&count) + if err != nil { + return 0, fmt.Errorf("sqlite count: %w", err) + } + return count, nil +} diff --git a/results/stats.go b/results/stats.go index cce6acb..17c9ece 100644 --- a/results/stats.go +++ b/results/stats.go @@ -1,13 +1,17 @@ package results import ( + "crypto/sha256" "html/template" "net/http" + "net/url" + "strconv" + "strings" + "sync" "github.com/go-chi/render" log "github.com/sirupsen/logrus" - "github.com/gorilla/securecookie" "github.com/gorilla/sessions" "github.com/librespeed/speedtest-go/config" "github.com/librespeed/speedtest-go/database" @@ -15,29 +19,176 @@ import ( ) type StatsData struct { - NoPassword bool - LoggedIn bool - Data []schema.TelemetryData + NoPassword bool + LoggedIn bool + Data []schema.TelemetryData + UniqueDevices int + UniqueIPs map[string]bool + DeviceGroups []DeviceGroup + + // pagination + TotalTests int + CurrentPage int + TotalPages int + PerPage int + Filters SearchFilters + FilterQuery string // active filter params as "&ip=..&dl=.." for pagination links +} + +// DeviceGroup holds all tests that share the same browser-generated ClientID, +// so repeated tests from one machine (even across different IPs) stay together. +type DeviceGroup struct { + ClientID string + Tests []schema.TelemetryData + TestCount int + IPs []string +} + +// SearchFilters holds the advanced-search form fields. Text fields (IP, UUID) +// match as substrings; numeric/date fields compare against an operator. +type SearchFilters struct { + UUID string + IP string + DL string + DLOp string + UL string + ULOp string + Ping string + PingOp string + Jitter string + JitterOp string + Date string + DateOp string +} + +// HasAny reports whether any filter field is set. +func (f SearchFilters) HasAny() bool { + return f.UUID != "" || f.IP != "" || f.DL != "" || f.UL != "" || + f.Ping != "" || f.Jitter != "" || f.Date != "" +} + +// QueryString builds a URL query suffix (starting with "&") carrying all +// active filters, so pagination links preserve the current search. +func (f SearchFilters) QueryString() string { + var b strings.Builder + appendParam := func(key, val, opKey, op string) { + if val != "" { + b.WriteString("&" + key + "=" + url.QueryEscape(val)) + if opKey != "" { + b.WriteString("&" + opKey + "=" + op) + } + } + } + appendParam("uuid", f.UUID, "", "") + appendParam("ip", f.IP, "", "") + appendParam("dl", f.DL, "dl_op", f.DLOp) + appendParam("ul", f.UL, "ul_op", f.ULOp) + appendParam("ping", f.Ping, "ping_op", f.PingOp) + appendParam("jitter", f.Jitter, "jitter_op", f.JitterOp) + appendParam("date", f.Date, "date_op", f.DateOp) + return b.String() +} + +// compareOp evaluates a string operator ("ge","gt","eq","lt","le") against two +// float64 values parsed from strings. Returns true if the record value passes. +func compareOp(op string, recordStr, queryStr string) bool { + rec, err1 := strconv.ParseFloat(strings.TrimSpace(recordStr), 64) + q, err2 := strconv.ParseFloat(strings.TrimSpace(queryStr), 64) + if err1 != nil || err2 != nil { + return false + } + switch op { + case "ge": + return rec >= q + case "gt": + return rec > q + case "lt": + return rec < q + case "le": + return rec <= q + default: // "eq" + return rec == q + } +} + +// compareDate evaluates a date operator against the record's timestamp. +// Dates are compared as YYYY-MM-DD strings (lexicographic order == chronological). +func compareDate(op string, recordTS, queryDate string) bool { + rec := recordTS[:10] // first 10 chars = YYYY-MM-DD + switch op { + case "ge": + return rec >= queryDate + case "gt": + return rec > queryDate + case "lt": + return rec < queryDate + case "le": + return rec <= queryDate + default: // "eq" + return rec == queryDate + } +} + +// matches reports whether a single record passes all active filters. +func (f SearchFilters) matches(d schema.TelemetryData) bool { + if f.UUID != "" && !strings.Contains(d.UUID, f.UUID) { + return false + } + if f.IP != "" && !strings.Contains(d.IPAddress, f.IP) { + return false + } + if f.DL != "" && !compareOp(f.DLOp, d.Download, f.DL) { + return false + } + if f.UL != "" && !compareOp(f.ULOp, d.Upload, f.UL) { + return false + } + if f.Ping != "" && !compareOp(f.PingOp, d.Ping, f.Ping) { + return false + } + if f.Jitter != "" && !compareOp(f.JitterOp, d.Jitter, f.Jitter) { + return false + } + if f.Date != "" { + if !compareDate(f.DateOp, d.Timestamp.Format("2006-01-02 15:04:05"), f.Date) { + return false + } + } + return true } var ( - key = []byte(securecookie.GenerateRandomKey(32)) - store = sessions.NewCookieStore(key) - conf = config.LoadedConfig() + store *sessions.CookieStore + storeOnce sync.Once ) -func init() { - store.Options = &sessions.Options{ - Path: conf.BaseURL+"/stats", - MaxAge: 3600 * 1, // 1 hour - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - } +func initStore(conf *config.Config) { + storeOnce.Do(func() { + // Derive a stable 32-byte key from the stats password so sessions + // survive process restarts (a random key would invalidate all sessions). + h := sha256.Sum256([]byte("speedtest-stats:" + conf.StatsPassword)) + store = sessions.NewCookieStore(h[:]) + store.Options = &sessions.Options{ + MaxAge: 3600, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + } + }) } func Stats(w http.ResponseWriter, r *http.Request) { + // IMPORTANT: call LoadedConfig() inside the function, NOT as a package var. + // A package-level var would snapshot the config at import time (BEFORE main() + // loads settings.toml), freezing the defaults. + conf := config.LoadedConfig() + initStore(conf) + store.Options.Path = conf.BaseURL + "/stats" + w.Header().Set("Content-Type", "text/html; charset=utf-8") - t, err := template.New("template").Parse(htmlTemplate) + t, err := template.New("template").Funcs(template.FuncMap{ + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, + }).Parse(statsTemplate) if err != nil { log.Errorf("Failed to parse template: %s", err) w.WriteHeader(http.StatusInternalServerError) @@ -51,7 +202,8 @@ func Stats(w http.ResponseWriter, r *http.Request) { var data StatsData - if conf.StatsPassword == "PASSWORD" { + // Check if password is properly configured (not default value) + if conf.StatsPassword == "" || conf.StatsPassword == "PASSWORD" { data.NoPassword = true } @@ -69,25 +221,152 @@ func Stats(w http.ResponseWriter, r *http.Request) { } else { data.LoggedIn = true - id := r.FormValue("id") - switch id { - case "L100": - stats, err := database.DB.FetchLast100() + const perPage = 50 + data.PerPage = perPage + + // page number (shared by browse and filtered views) + page := 1 + if p := r.FormValue("page"); p != "" { + if v, err := strconv.Atoi(p); err == nil && v > 0 { + page = v + } + } + + // collect advanced-search filters + filters := SearchFilters{ + UUID: strings.TrimSpace(r.FormValue("uuid")), + IP: strings.TrimSpace(r.FormValue("ip")), + DL: strings.TrimSpace(r.FormValue("dl")), + DLOp: r.FormValue("dl_op"), + UL: strings.TrimSpace(r.FormValue("ul")), + ULOp: r.FormValue("ul_op"), + Ping: strings.TrimSpace(r.FormValue("ping")), + PingOp: r.FormValue("ping_op"), + Jitter: strings.TrimSpace(r.FormValue("jitter")), + JitterOp: r.FormValue("jitter_op"), + Date: strings.TrimSpace(r.FormValue("date")), + DateOp: r.FormValue("date_op"), + } + data.Filters = filters + + // build a query suffix carrying the active filters so pagination + // links keep the search context across pages. + data.FilterQuery = filters.QueryString() + + if filters.HasAny() { + // advanced search: load all records, filter in memory, paginate + const maxScan = 100000 + all, err := database.DB.FetchAll(0, maxScan) if err != nil { log.Errorf("Error fetching data from database: %s", err) w.WriteHeader(http.StatusInternalServerError) return } - data.Data = stats - case "": - default: - stat, err := database.DB.FetchByUUID(id) + filtered := make([]schema.TelemetryData, 0, len(all)) + for _, d := range all { + if filters.matches(d) { + filtered = append(filtered, d) + } + } + + total := len(filtered) + totalPages := (total + perPage - 1) / perPage + if totalPages == 0 { + totalPages = 1 + } + if page > totalPages { + page = totalPages + } + offset := (page - 1) * perPage + end := offset + perPage + if end > total { + end = total + } + if offset > total { + offset = total + } + data.Data = filtered[offset:end] + data.TotalTests = total + data.CurrentPage = page + data.TotalPages = totalPages + } else { + // normal paginated browse of all tests (newest first) + total, err := database.DB.Count() + if err != nil { + log.Errorf("Error counting database records: %s", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + totalPages := (total + perPage - 1) / perPage + if totalPages == 0 { + totalPages = 1 + } + if page > totalPages { + page = totalPages + } + + offset := (page - 1) * perPage + stats, err := database.DB.FetchAll(offset, perPage) if err != nil { log.Errorf("Error fetching data from database: %s", err) w.WriteHeader(http.StatusInternalServerError) return } - data.Data = append(data.Data, *stat) + + data.Data = stats + data.TotalTests = total + data.CurrentPage = page + data.TotalPages = totalPages + } + + // group tests by device (ClientID) and count unique IPs + seen := map[string]bool{} + data.UniqueIPs = map[string]bool{} + groupOrder := []string{} + groups := map[string]*DeviceGroup{} + ungrouped := []schema.TelemetryData{} + + for _, d := range data.Data { + if d.IPAddress != "" { + data.UniqueIPs[d.IPAddress] = true + } + if d.ClientID == "" { + ungrouped = append(ungrouped, d) + continue + } + if _, ok := groups[d.ClientID]; !ok { + groups[d.ClientID] = &DeviceGroup{ClientID: d.ClientID} + groupOrder = append(groupOrder, d.ClientID) + seen[d.ClientID] = true + } + g := groups[d.ClientID] + g.Tests = append(g.Tests, d) + g.TestCount++ + if d.IPAddress != "" { + ipExists := false + for _, ip := range g.IPs { + if ip == d.IPAddress { + ipExists = true + break + } + } + if !ipExists { + g.IPs = append(g.IPs, d.IPAddress) + } + } + } + data.UniqueDevices = len(seen) + + // build ordered list of groups (most recent first), then ungrouped tests + for _, cid := range groupOrder { + data.DeviceGroups = append(data.DeviceGroups, *groups[cid]) + } + if len(ungrouped) > 0 { + data.DeviceGroups = append(data.DeviceGroups, DeviceGroup{ + ClientID: "", + Tests: ungrouped, + TestCount: len(ungrouped), + }) } } } else { @@ -111,87 +390,677 @@ func Stats(w http.ResponseWriter, r *http.Request) { } } -const htmlTemplate = ` - +const statsTemplate = ` + -LibreSpeed - Stats - + + + Speed Test Admin - Statistics + -

LibreSpeed - Stats

-{{ if .NoPassword }} - Please set statistics_password in settings.toml to enable access. -{{ else if .LoggedIn }} -
-
-

Search test results

- - - - -
- - {{ range $i, $v := .Data }} - - - - - - - - - - - -
Test ID{{ $v.UUID }}
Date and time{{ $v.Timestamp }}
IP and ISP Info{{ $v.IPAddress }}
{{ $v.ISPInfo }}
User agent and locale{{ $v.UserAgent }}
{{ $v.Language }}
Download speed{{ $v.Download }}
Upload speed{{ $v.Upload }}
Ping{{ $v.Ping }}
Jitter{{ $v.Jitter }}
Log{{ $v.Log }}
Extra info{{ $v.Extra }}
+
+ {{ if .NoPassword }} +
+

Statistics Disabled

+

Please set statistics_password in settings.toml to enable access.

+
+ {{ else if .LoggedIn }} +
+
+

πŸš€ Speed Test Admin

+

View and manage test results

+
+ {{ if .Filters.HasAny }} + πŸ”Ž filtered: {{ .TotalTests }} match{{ if gt .TotalTests 1 }}es{{ end }} + ← clear filters + {{ else }} + πŸ“Š {{ .TotalTests }} total test{{ if gt .TotalTests 1 }}s{{ end }} + {{ if gt .UniqueDevices 0 }}πŸ–₯ {{ .UniqueDevices }} unique device{{ if gt .UniqueDevices 1 }}s{{ end }}{{ end }} + {{ len .UniqueIPs }} unique IP{{ if gt (len .UniqueIPs) 1 }}s{{ end }} on this page + {{ end }} +
+
+
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+ + + +
+
+ + + +
+ +
+
+ + {{ range .DeviceGroups }} +
+
+ {{ if .ClientID }} + πŸ–₯ Device + {{ .ClientID }} + {{ else }} + ⚠ Unknown device + no client identifier + {{ end }} + {{ .TestCount }} test{{ if gt .TestCount 1 }}s{{ end }} Β· {{ len .IPs }} IP{{ if gt (len .IPs) 1 }}s{{ end }}{{ range .IPs }} Β· {{ . }}{{ end }} +
+ +
+ {{ range .Tests }} +
+
+
+
{{ .Timestamp }}
+
{{ .UUID }}
+
+
+ +
+
+
Download
+
{{ .Download }}
+
Mbps
+
+
+
Upload
+
{{ .Upload }}
+
Mbps
+
+
+
Ping
+
{{ .Ping }}
+
ms
+
+
+
Jitter
+
{{ .Jitter }}
+
ms
+
+
+ +
+

IP: {{ .IPAddress }}

+
+
+ {{ end }} +
+
+ {{ end }} + + + {{ else }} +
+

πŸ” Admin Login

+
+ + +
+
+ {{ end }} +
+ + {{ if .LoggedIn }} + + + {{ end }} + + {{ if .LoggedIn }} + {{ end }} -{{ else }} -
-

Login

- - -
-{{ end }} ` diff --git a/results/telemetry.go b/results/telemetry.go index 566db23..223027f 100644 --- a/results/telemetry.go +++ b/results/telemetry.go @@ -3,6 +3,7 @@ package results import ( _ "embed" "encoding/json" + "fmt" "image" "image/color" "image/draw" @@ -11,6 +12,7 @@ import ( "net" "net/http" "regexp" + "strconv" "strings" "time" @@ -50,22 +52,25 @@ var ( fontLight, fontBold *truetype.Font pingJitterLabelFace, upDownLabelFace, pingJitterValueFace, upDownValueFace, smallLabelFace, ispFace, watermarkFace font.Face + valueFace, labelFace, unitFace, footerFace font.Face - canvasWidth, canvasHeight = 500, 286 + canvasWidth, canvasHeight = 900, 240 dpi = 150.0 - topOffset = 10 - middleOffset = topOffset + 5 - bottomOffset = middleOffset - 10 - ispOffset = bottomOffset + 8 - colorLabel = image.NewUniform(color.RGBA{40, 40, 40, 255}) - colorDownload = image.NewUniform(color.RGBA{96, 96, 170, 255}) - colorUpload = image.NewUniform(color.RGBA{96, 96, 96, 255}) - colorPing = image.NewUniform(color.RGBA{170, 96, 96, 255}) - colorJitter = image.NewUniform(color.RGBA{170, 96, 96, 255}) - colorMeasure = image.NewUniform(color.RGBA{40, 40, 40, 255}) - colorISP = image.NewUniform(color.RGBA{40, 40, 40, 255}) - colorWatermark = image.NewUniform(color.RGBA{160, 160, 160, 255}) - colorSeparator = image.NewUniform(color.RGBA{192, 192, 192, 255}) + + // rgba(255,255,255,0.03) over #07090f β†’ card bg matching HTML .r-card + // rgba(255,255,255,0.07) over #07090f β†’ border matching HTML --border + colorBg = color.RGBA{7, 9, 15, 255} + colorCard = color.RGBA{14, 16, 22, 255} + colorBorder = color.RGBA{24, 26, 32, 255} + colorText = color.RGBA{238, 242, 255, 255} + colorMuted = color.RGBA{105, 110, 130, 255} + colorDim = color.RGBA{52, 56, 72, 255} + colorDownload = color.RGBA{34, 211, 238, 255} + colorUpload = color.RGBA{167, 139, 250, 255} + colorPing = color.RGBA{52, 211, 153, 255} + colorJitter = color.RGBA{251, 191, 36, 255} + colorLossWarn = color.RGBA{251, 191, 36, 255} // same as jitter: yellow for 0=2% ) type Result struct { @@ -142,6 +147,27 @@ func Initialize(c *config.Config) { DPI: dpi, Hinting: font.HintingFull, }) + + valueFace = truetype.NewFace(fontLight, &truetype.Options{ + Size: 30, + DPI: dpi, + Hinting: font.HintingFull, + }) + labelFace = truetype.NewFace(fontBold, &truetype.Options{ + Size: 7.5, + DPI: dpi, + Hinting: font.HintingFull, + }) + unitFace = truetype.NewFace(fontBold, &truetype.Options{ + Size: 8.5, + DPI: dpi, + Hinting: font.HintingFull, + }) + footerFace = truetype.NewFace(fontBold, &truetype.Options{ + Size: 7, + DPI: dpi, + Hinting: font.HintingFull, + }) } func Record(w http.ResponseWriter, r *http.Request) { @@ -163,7 +189,7 @@ func Record(w http.ResponseWriter, r *http.Request) { logs := r.FormValue("log") extra := r.FormValue("extra") - if config.LoadedConfig().RedactIP { + if conf.RedactIP { ipAddr = "0.0.0.0" ispInfo = ipv4Regex.ReplaceAllString(ispInfo, "0.0.0.0") logs = ipv4Regex.ReplaceAllString(logs, "0.0.0.0") @@ -188,6 +214,11 @@ func Record(w http.ResponseWriter, r *http.Request) { record.Ping = ping record.Jitter = jitter record.Log = logs + record.ClientID = r.FormValue("client_id") + record.GradeData = r.FormValue("grade_data") + record.ChartData = r.FormValue("chart_data") + record.LatencyUnderload = r.FormValue("latency_underload") + record.PingDuringTest = r.FormValue("ping_during_test") t := time.Now() entropy := ulid.Monotonic(rand.New(rand.NewSource(t.UnixNano())), 0) @@ -212,9 +243,53 @@ func Record(w http.ResponseWriter, r *http.Request) { } } +func fillRect(canvas *image.RGBA, x, y, w, h int, c color.Color) { + for row := y; row < y+h; row++ { + for col := x; col < x+w; col++ { + canvas.Set(col, row, c) + } + } +} + +// fillRoundedRect draws a filled rectangle with rounded corners (radius r). +// Matches CSS border-radius: uses a circle-distance test at each corner. +func fillRoundedRect(canvas *image.RGBA, x, y, w, h, r int, c color.Color) { + // inner rect whose corners are the circle centers + x1, y1 := x+r, y+r + x2, y2 := x+w-1-r, y+h-1-r + for row := y; row < y+h; row++ { + for col := x; col < x+w; col++ { + nearX := col + if nearX < x1 { + nearX = x1 + } else if nearX > x2 { + nearX = x2 + } + nearY := row + if nearY < y1 { + nearY = y1 + } else if nearY > y2 { + nearY = y2 + } + dx, dy := col-nearX, row-nearY + if dx*dx+dy*dy <= r*r { + canvas.Set(col, row, c) + } + } + } +} + +// drawText draws centered text and returns the drawn width in pixels. +func drawText(drawer *font.Drawer, text string, cx, y int, src image.Image, face font.Face) { + drawer.Face = face + drawer.Src = src + w := drawer.MeasureString(text).Round() + drawer.Dot = freetype.Pt(cx-w/2, y) + drawer.DrawString(text) +} + func DrawPNG(w http.ResponseWriter, r *http.Request) { conf := config.LoadedConfig() - if conf.DatabaseType == "none" { return } @@ -235,140 +310,96 @@ func DrawPNG(w http.ResponseWriter, r *http.Request) { return } - canvas := image.NewRGBA(image.Rectangle{ - Min: image.Point{}, - Max: image.Point{ - X: canvasWidth, - Y: canvasHeight, - }, - }) + canvas := image.NewRGBA(image.Rect(0, 0, canvasWidth, canvasHeight)) + draw.Draw(canvas, canvas.Bounds(), image.NewUniform(colorBg), image.Point{}, draw.Src) + drawer := &font.Drawer{Dst: canvas} + + // Layout β€” mirrors HTML .r-card grid + const ( + sidePad = 20 + barH = 28 + radius = 14 // matches CSS border-radius: 1rem + ) + + // Determine layout: 5 cards when loss param is present, 4 otherwise + lossParam := strings.TrimSpace(r.FormValue("loss")) + numCards := 4 + gapW := 24 + if lossParam != "" { + numCards = 5 + gapW = 10 // tighter gap to keep 900px canvas with 5 cards + } - draw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) + colW := (canvasWidth - sidePad*2 - gapW*(numCards-1)) / numCards + barY := canvasHeight - barH + cardTop := sidePad + cardH := barY - cardTop + + // Center content block (88px) in card + blockTop := cardTop + (cardH-88)/2 + valueY := blockTop + 45 + labelY := valueY + 21 + unitY := labelY + 19 + + // Determine packet-loss color from value + lossColor := colorPing // green: 0% + if lossParam != "" { + lf, _ := strconv.ParseFloat(lossParam, 64) + if lf > 0 && lf < 2 { lossColor = colorLossWarn } + if lf >= 2 { lossColor = colorLossBad } + } - drawer := &font.Drawer{ - Dst: canvas, - Face: pingJitterLabelFace, + type metric struct { + label, value, unit string + col color.RGBA + } + metrics := []metric{ + {"DOWNLOAD", formatSpeed(record.Download), "Mbps", colorDownload}, + {"UPLOAD", formatSpeed(record.Upload), "Mbps", colorUpload}, + {"PING", formatLatency(record.Ping), "ms", colorPing}, + {"JITTER", formatLatency(record.Jitter), "ms", colorJitter}, + } + if lossParam != "" { + metrics = append(metrics, metric{"LOSS", formatLoss(lossParam), "%", lossColor}) } - drawer.Src = colorLabel - - // labels - p := drawer.MeasureString(labelPing) - x := canvasWidth/4 - p.Round()/2 - drawer.Dot = freetype.Pt(x, canvasHeight/10+topOffset) - drawer.DrawString(labelPing) - - p = drawer.MeasureString(labelJitter) - x = canvasWidth*3/4 - p.Round()/2 - drawer.Dot = freetype.Pt(x, canvasHeight/10+topOffset) - drawer.DrawString(labelJitter) - - drawer.Face = upDownLabelFace - p = drawer.MeasureString(labelDownload) - x = canvasWidth/4 - p.Round()/2 - drawer.Dot = freetype.Pt(x, canvasHeight/2-middleOffset) - drawer.DrawString(labelDownload) - - p = drawer.MeasureString(labelUpload) - x = canvasWidth*3/4 - p.Round()/2 - drawer.Dot = freetype.Pt(x, canvasHeight/2-middleOffset) - drawer.DrawString(labelUpload) - - drawer.Face = smallLabelFace - drawer.Src = colorMeasure - p = drawer.MeasureString(labelMbps) - x = canvasWidth/4 - p.Round()/2 - drawer.Dot = freetype.Pt(x, canvasHeight*8/10-middleOffset) - drawer.DrawString(labelMbps) - - p = drawer.MeasureString(labelMbps) - x = canvasWidth*3/4 - p.Round()/2 - drawer.Dot = freetype.Pt(x, canvasHeight*8/10-middleOffset) - drawer.DrawString(labelMbps) - - msLength := drawer.MeasureString(labelMS) - - // ping value - drawer.Face = pingJitterValueFace - pingValue := strings.Split(record.Ping, ".")[0] - p = drawer.MeasureString(pingValue) - - x = canvasWidth/4 - (p.Round()+msLength.Round())/2 - drawer.Dot = freetype.Pt(x, canvasHeight*11/40) - drawer.Src = colorPing - drawer.DrawString(pingValue) - x = x + p.Round() - drawer.Dot = freetype.Pt(x, canvasHeight*11/40) - drawer.Src = colorMeasure - drawer.Face = smallLabelFace - drawer.DrawString(labelMS) - - // jitter value - drawer.Face = pingJitterValueFace - p = drawer.MeasureString(record.Jitter) - x = canvasWidth*3/4 - (p.Round()+msLength.Round())/2 - drawer.Dot = freetype.Pt(x, canvasHeight*11/40) - drawer.Src = colorJitter - drawer.DrawString(record.Jitter) - drawer.Face = smallLabelFace - x = x + p.Round() - drawer.Dot = freetype.Pt(x, canvasHeight*11/40) - drawer.Src = colorMeasure - drawer.DrawString(labelMS) - - // download value - drawer.Face = upDownValueFace - p = drawer.MeasureString(record.Download) - x = canvasWidth/4 - p.Round()/2 - drawer.Dot = freetype.Pt(x, canvasHeight*27/40-middleOffset) - drawer.Src = colorDownload - drawer.DrawString(record.Download) - - // upload value - p = drawer.MeasureString(record.Upload) - x = canvasWidth*3/4 - p.Round()/2 - drawer.Dot = freetype.Pt(x, canvasHeight*27/40-middleOffset) - drawer.Src = colorUpload - drawer.DrawString(record.Upload) - - // watermark - ctx := freetype.NewContext() - ctx.SetFont(fontLight) - ctx.SetFontSize(14) - ctx.SetDPI(dpi) - ctx.SetHinting(font.HintingFull) - - drawer.Face = watermarkFace - drawer.Src = colorWatermark - p = drawer.MeasureString(watermark) - x = canvasWidth - p.Round() - 5 - drawer.Dot = freetype.Pt(x, canvasHeight-bottomOffset) - drawer.DrawString(watermark) + for i, m := range metrics { + x := sidePad + i*(colW+gapW) - // timestamp - ts := record.Timestamp.Format("2006-01-02 15:04:05") - p = drawer.MeasureString(ts) - drawer.Dot = freetype.Pt(8, canvasHeight-bottomOffset) - drawer.DrawString(ts) + // border + card background β€” matches HTML .r-card + fillRoundedRect(canvas, x-1, cardTop-1, colW+2, cardH+2, radius+1, colorBorder) + fillRoundedRect(canvas, x, cardTop, colW, cardH, radius, colorCard) - // separator - for i := canvas.Bounds().Min.X; i < canvas.Bounds().Max.X; i++ { - canvas.Set(i, canvasHeight-ctx.PointToFixed(6).Round()-bottomOffset, colorSeparator) + cx := x + colW/2 + drawText(drawer, m.value, cx, valueY, image.NewUniform(m.col), valueFace) + drawText(drawer, m.label, cx, labelY, image.NewUniform(colorMuted), labelFace) + drawText(drawer, m.unit, cx, unitY, image.NewUniform(colorDim), unitFace) } - // ISP info - drawer.Face = ispFace - drawer.Src = colorISP - drawer.Dot = freetype.Pt(8, canvasHeight-ctx.PointToFixed(6).Round()-ispOffset) - var ispString string - if strings.Contains(result.ProcessedString, "-") { - str := strings.SplitN(result.ProcessedString, "-", 2) - if strings.Contains(str[1], "(") { - str = strings.SplitN(str[1], "(", 2) - } - ispString = str[0] + // thin footer bar + fillRect(canvas, 0, barY, canvasWidth, 1, colorBorder) + footerY := barY + 19 + + isp := extractISP(result.ProcessedString) + if isp != "" { + drawer.Face = footerFace + drawer.Src = image.NewUniform(colorMuted) + drawer.Dot = freetype.Pt(sidePad, footerY) + drawer.DrawString(isp) } - drawer.DrawString("ISP: " + ispString) + + ts := record.Timestamp.Format("2006-01-02 15:04 UTC") + drawer.Face = footerFace + drawer.Src = image.NewUniform(colorMuted) + tsW := drawer.MeasureString(ts) + drawer.Dot = freetype.Pt(canvasWidth/2-tsW.Round()/2, footerY) + drawer.DrawString(ts) + + drawer.Face = footerFace + drawer.Src = image.NewUniform(colorMuted) + wmW := drawer.MeasureString(watermark) + drawer.Dot = freetype.Pt(canvasWidth-wmW.Round()-sidePad, footerY) + drawer.DrawString(watermark) w.Header().Set("Content-Disposition", "inline; filename="+uuid+".png") w.Header().Set("Content-Type", "image/png") @@ -376,3 +407,47 @@ func DrawPNG(w http.ResponseWriter, r *http.Request) { log.Errorf("Failed to output image to HTTP client: %s", err) } } + +func formatSpeed(s string) string { + f, _ := strconv.ParseFloat(strings.TrimSpace(s), 64) + if f >= 1000 { + return fmt.Sprintf("%.0f", f) + } + if f >= 10 { + return fmt.Sprintf("%.1f", f) + } + return fmt.Sprintf("%.2f", f) +} + +func formatLoss(s string) string { + f, _ := strconv.ParseFloat(strings.TrimSpace(s), 64) + if f == 0 { + return "0.0" + } + if f < 10 { + return fmt.Sprintf("%.1f", f) + } + return fmt.Sprintf("%.0f", f) +} + +func formatLatency(s string) string { + f, _ := strconv.ParseFloat(strings.TrimSpace(s), 64) + if f < 10 { + return fmt.Sprintf("%.1f", f) + } + return fmt.Sprintf("%.0f", f) +} + +func extractISP(processed string) string { + if strings.Contains(processed, "-") { + parts := strings.SplitN(processed, "-", 2) + p := parts[1] + if i := strings.Index(p, "("); i >= 0 { + p = p[:i] + } + if isp := strings.TrimSpace(p); isp != "" { + return "ISP: " + isp + } + } + return "" +} diff --git a/results/view.go b/results/view.go new file mode 100644 index 0000000..4f50f27 --- /dev/null +++ b/results/view.go @@ -0,0 +1,491 @@ +package results + +import ( + "encoding/json" + "html/template" + "net/http" + "strings" + + "github.com/librespeed/speedtest-go/config" + "github.com/librespeed/speedtest-go/database" + log "github.com/sirupsen/logrus" +) + +// gradeInfo holds the parsed grade fields for template rendering. +type gradeInfo struct { + Grade string +} + +// ViewPage renders a beautiful HTML page with test results +func ViewPage(w http.ResponseWriter, r *http.Request) { + conf := config.LoadedConfig() + if conf.DatabaseType == "none" { + http.Error(w, "Database is disabled", http.StatusServiceUnavailable) + return + } + + rawID := r.FormValue("id") + if rawID == "" { + http.Error(w, "Missing test ID", http.StatusBadRequest) + return + } + + uuid := ResolveID(rawID) + record, err := database.DB.FetchByUUID(uuid) + if err != nil { + http.Error(w, "Test not found", http.StatusNotFound) + return + } + + // Parse ISP info + var ispInfo struct { + ProcessedString string `json:"processedString"` + RawISPInfo struct { + IP string `json:"ip"` + Hostname string `json:"hostname"` + City string `json:"city"` + Region string `json:"region"` + Country string `json:"country"` + Loc string `json:"loc"` + Org string `json:"org"` + Postal string `json:"postal"` + Timezone string `json:"timezone"` + } `json:"rawIspInfo"` + } + + if record.ISPInfo != "" { + if err := json.Unmarshal([]byte(record.ISPInfo), &ispInfo); err != nil { + log.Errorf("Error parsing ISP info: %s", err) + } + } + + // Parse the HTML template + t, err := template.New("results").Funcs(template.FuncMap{ + "lower": strings.ToLower, + }).Parse(viewTemplate) + if err != nil { + http.Error(w, "Template error", http.StatusInternalServerError) + return + } + + // Parse grade data + var grade gradeInfo + if record.GradeData != "" { + var gd struct { + Grade string `json:"grade"` + } + if err := json.Unmarshal([]byte(record.GradeData), &gd); err == nil { + grade.Grade = gd.Grade + } + } + if grade.Grade == "" { + grade.Grade = "β€”" + } + + // ChartData is injected as raw JS β€” use template.JS to skip HTML escaping. + chartJS := template.JS("{}") + if record.ChartData != "" { + chartJS = template.JS(record.ChartData) + } + + // Prepare data for template + data := struct { + BaseURL string + Download string + Upload string + Ping string + Jitter string + Timestamp string + IPAddress string + ISP struct { + ProcessedString string + City string + Region string + Country string + Organization string + } + Grade gradeInfo + Latency string + ChartJS template.JS + }{ + BaseURL: conf.BaseURL, + Download: record.Download, + Upload: record.Upload, + Ping: record.Ping, + Jitter: record.Jitter, + Timestamp: record.Timestamp.Format("2006-01-02 15:04:05"), + IPAddress: record.IPAddress, + ISP: struct { + ProcessedString string + City string + Region string + Country string + Organization string + }{ + ProcessedString: ispInfo.ProcessedString, + City: ispInfo.RawISPInfo.City, + Region: ispInfo.RawISPInfo.Region, + Country: ispInfo.RawISPInfo.Country, + Organization: ispInfo.RawISPInfo.Org, + }, + Grade: grade, + Latency: record.LatencyUnderload, + ChartJS: chartJS, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := t.Execute(w, data); err != nil { + http.Error(w, "Render error", http.StatusInternalServerError) + } +} + +const viewTemplate = ` + + + + + + Speed Test Results + + + +
+
+

Speed Test Results

+
{{ .Timestamp }}
+
+ +
+
+
Download
+
{{ .Download }}
+
Mbps
+
+ +
+
Upload
+
{{ .Upload }}
+
Mbps
+
+ +
+
Ping
+
{{ .Ping }}
+
ms
+
+ +
+
Jitter
+
{{ .Jitter }}
+
ms
+
+
+ +
+

Speed Over Time

+ +
+ +
+

Performance Grade

+
+
{{ .Grade.Grade }}
+ {{ if .Latency }} +
+

Latency under load: {{ .Latency }} ms

+
+ {{ end }} +
+
+ +
+

Connection Information

+
+
+ IP Address + {{ .IPAddress }} +
+ {{ if .ISP.ProcessedString }} +
+ Location + {{ .ISP.ProcessedString }} +
+ {{ end }} + {{ if .ISP.Organization }} +
+ Organization + {{ .ISP.Organization }} +
+ {{ end }} + {{ if .ISP.City }} +
+ City + {{ .ISP.City }} +
+ {{ end }} + {{ if .ISP.Region }} +
+ Region + {{ .ISP.Region }} +
+ {{ end }} + {{ if .ISP.Country }} +
+ Country + {{ .ISP.Country }} +
+ {{ end }} +
+
+
+ + + + +` diff --git a/settings.toml b/settings.toml index 4ef8a47..3f927bf 100644 --- a/settings.toml +++ b/settings.toml @@ -46,3 +46,9 @@ enable_http2=false # if you use HTTP/2 or TLS, you need to prepare certificates and private keys # tls_cert_file="cert.pem" # tls_key_file="privkey.pem" + +# redirect_from: if set, a plain-HTTP listener is started on this port and +# redirects all requests to the main HTTPS listener (301). +# Useful when running directly on port 443 without a reverse proxy. +# Example: redirect_from="80" +# redirect_from="" diff --git a/web/assets/design-switch.js b/web/assets/design-switch.js index 4469baa..2028880 100644 --- a/web/assets/design-switch.js +++ b/web/assets/design-switch.js @@ -30,8 +30,8 @@ return; } - // Default to classic design - redirectToOldDesign(); + // Default to modern design + redirectToNewDesign(); function redirectToNewDesign() { const currentParams = window.location.search; diff --git a/web/assets/index.html b/web/assets/index.html index c7b3620..7180d3f 100644 --- a/web/assets/index.html +++ b/web/assets/index.html @@ -1,16 +1,1567 @@ - - + - - - - - LibreSpeed - + + + + + + LibreSpeed + + + + -

Loading...

- +
+ LibreSpeed + +
+ +
+
+
+
+
+
+
+ +
+

+ + +
+ +
+ +
+ +
+
+
β€”
+
Download
+
Mbps
+
+
+
β€”
+
Upload
+
Mbps
+
+
+
β€”
+
Ping
+
ms
+
+
+
β€”
+
Jitter
+
ms
+
+
+
β€”
+
Packet Loss
+
%
+
+
+ +
+
+
+ – + Buffer Bloat +
+
+
+
Latency under load
+
+ Baseline ping + – + ms + +
+
+ Under download + – + ms + +
+
+ Under upload + – + ms + +
+
+
+
+ +
+
+ Test History + +
+
+ + + + + + + + + + + + + + +
TimeDownloadUploadPingJitterLossBloatShare
+
+
+
+ + + +

Share your results

+ Speed test results + +
+ + + diff --git a/web/assets/javascript/index.js b/web/assets/javascript/index.js index ba4eb0e..120fb1c 100644 --- a/web/assets/javascript/index.js +++ b/web/assets/javascript/index.js @@ -1,447 +1,427 @@ /** - * Design by fromScratch Studio - 2022, 2023 (fromscratch.io) - * Implementation in HTML/CSS/JS by Timendus - 2024 (https://github.com/Timendus) - * - * See https://github.com/librespeed/speedtest/issues/585 + * LibreSpeed β€” modern UI with canvas gauges */ -// States the UI can be in const INITIALIZING = 0; const READY = 1; const RUNNING = 2; const FINISHED = 3; -// Keep some global state here -const testState = { - state: INITIALIZING, +const appState = { + ui: INITIALIZING, speedtest: null, servers: [], selectedServerDirty: false, - testData: null, - testDataDirty: false, + data: null, + dataDirty: false, telemetryEnabled: false, }; -// Bootstrap the application when the DOM is ready -window.addEventListener("DOMContentLoaded", async () => { - createSpeedtest(); - hookUpButtons(); - startRenderingLoop(); - applySettingsJSON(); - applyServerListJSON(); -}); - -/** - * Create a new Speedtest and hook it into the global state - */ -function createSpeedtest() { - testState.speedtest = new Speedtest(); - testState.speedtest.onupdate = (data) => { - testState.testData = data; - testState.testDataDirty = true; - }; - testState.speedtest.onend = (aborted) => - (testState.state = aborted ? READY : FINISHED); +// ── Gauge geometry ────────────────────────────────────────────────────────── +// 240Β° arc, open at bottom (lower-left β†’ top β†’ lower-right, clockwise) +const G_START = (5 * Math.PI) / 6; // 150Β° β€” lower-left +const G_END = Math.PI / 6; // 30Β° β€” lower-right +const G_SWEEP = (4 * Math.PI) / 3; // 240Β° + +function valueToAngle(value, maxValue, isLog) { + let r = isLog + ? Math.log10(Math.max(0.001, value) + 1) / Math.log10(maxValue + 1) + : value / maxValue; + r = Math.max(0, Math.min(1, r)); + return G_START + r * G_SWEEP; } -/** - * Make all the buttons respond to the right clicks - */ -function hookUpButtons() { - document - .querySelector("#start-button") - .addEventListener("click", startButtonClickHandler); - document - .querySelector("#choose-privacy") - .addEventListener("click", () => - document.querySelector("#privacy").showModal() - ); - document - .querySelector("#share-results") - .addEventListener("click", () => - document.querySelector("#share").showModal() - ); - document - .querySelector("#copy-link") - .addEventListener("click", copyLinkButtonClickHandler); - document - .querySelectorAll(".close-dialog, #close-privacy") - .forEach((element) => { - element.addEventListener("click", () => - document.querySelectorAll("dialog").forEach((modal) => modal.close()) - ); - }); +// ── Tick builders ─────────────────────────────────────────────────────────── +function buildLogTicks() { + const ticks = []; + // major labeled + [1, 10, 100, 1000, 10000].forEach(v => { + ticks.push({ v, label: v >= 1000 ? (v / 1000) + 'G' : String(v), major: true }); + }); + // minor + [2,3,4,5,6,7,8,9, + 20,30,40,50,60,70,80,90, + 200,300,400,500,600,700,800,900, + 2000,3000,4000,5000,6000,7000,8000,9000].forEach(v => { + ticks.push({ v, major: false }); + }); + return ticks; } -/** - * Event listener for clicks on the main start button - */ -function startButtonClickHandler() { - switch (testState.state) { - case READY: - case FINISHED: - testState.speedtest.start(); - testState.state = RUNNING; - return; - case RUNNING: - testState.speedtest.abort(); - // testState.state is updated by `onend` handler of speedtest - return; - default: - return; - } +function buildLinearTicks(max, minorVals, majorVals) { + const map = new Map(); + minorVals.forEach(v => map.set(v, { v, major: false })); + majorVals.forEach(v => map.set(v, { v, label: String(v), major: true })); + return Array.from(map.values()); } -/** - * Event listener for clicks on the "Copy link" button in the modal - */ -async function copyLinkButtonClickHandler() { - const link = document.querySelector("img#results").src; - await navigator.clipboard.writeText(link); - const button = document.querySelector("#copy-link"); - button.classList.add("active"); - button.textContent = "Copied!"; - setTimeout(() => { - button.classList.remove("active"); - button.textContent = "Copy link"; - }, 3000); -} +// ── Gauge config ──────────────────────────────────────────────────────────── +const GAUGES = { + dl: { + color: '#22d3ee', glow: 'rgba(34,211,238,0.35)', + label: 'DOWNLOAD', unit: 'Mbps', + isLog: true, maxValue: 10000, + ticks: buildLogTicks(), + canvas: null, + }, + ul: { + color: '#a78bfa', glow: 'rgba(167,139,250,0.35)', + label: 'UPLOAD', unit: 'Mbps', + isLog: true, maxValue: 10000, + ticks: buildLogTicks(), + canvas: null, + }, + ping: { + color: '#34d399', glow: 'rgba(52,211,153,0.3)', + label: 'PING', unit: 'ms', + isLog: false, maxValue: 500, + ticks: buildLinearTicks(500, + [0, 50, 100, 150, 200, 250, 300, 400, 500], + [0, 100, 200, 300, 500]), + canvas: null, + }, + jitter: { + color: '#fbbf24', glow: 'rgba(251,191,36,0.3)', + label: 'JITTER', unit: 'ms', + isLog: false, maxValue: 150, + ticks: buildLinearTicks(150, + [0, 25, 50, 75, 100, 125, 150], + [0, 50, 100, 150]), + canvas: null, + }, +}; -/** - * Load settings from settings.json on the server and apply them - */ -async function applySettingsJSON() { - try { - const response = await fetch("settings.json"); - const settings = await response.json(); - if (!settings || typeof settings !== "object") { - return console.error("Settings are empty or malformed"); +// ── Core draw function ────────────────────────────────────────────────────── +function drawGauge(cfg, value, progress, active, dimmed) { + const canvas = cfg.canvas; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const dpr = window.devicePixelRatio || 1; + const W = canvas.clientWidth * dpr; + const H = canvas.clientHeight * dpr; + if (W === 0 || H === 0) return; + if (canvas.width !== W || canvas.height !== H) { + canvas.width = W; canvas.height = H; + } + ctx.clearRect(0, 0, W, H); + + // geometry + const cx = W / 2; + const R = Math.min(W * 0.36, H * 0.55); + const cy = R + H * 0.08; // arc center β€” top portion + const tw = R * 0.09; // track width + + const alpha = dimmed ? 0.35 : 1; + + // ── background track + ctx.save(); + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.arc(cx, cy, R, G_START, G_END, false); + ctx.strokeStyle = 'rgba(255,255,255,0.07)'; + ctx.lineWidth = tw; + ctx.lineCap = 'round'; + ctx.stroke(); + + // ── tick marks + cfg.ticks.forEach(({ v, label, major }) => { + const a = valueToAngle(v, cfg.maxValue, cfg.isLog); + const cos = Math.cos(a), sin = Math.sin(a); + const outerR = R + tw * 0.15; + const innerR = major ? R - tw * 0.9 : R - tw * 0.45; + + ctx.beginPath(); + ctx.moveTo(cx + outerR * cos, cy + outerR * sin); + ctx.lineTo(cx + innerR * cos, cy + innerR * sin); + ctx.strokeStyle = major ? 'rgba(255,255,255,0.35)' : 'rgba(255,255,255,0.12)'; + ctx.lineWidth = major ? 1.5 * dpr : 0.8 * dpr; + ctx.lineCap = 'butt'; + ctx.stroke(); + + if (label && major) { + const lr = R - tw * 1.75; + ctx.font = `${Math.round(9.5 * dpr)}px Inter,sans-serif`; + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, cx + lr * cos, cy + lr * sin); } - for (let setting in settings) { - testState.speedtest.setParameter(setting, settings[setting]); - if ( - setting == "telemetry_level" && - settings[setting] && - settings[setting] != "off" && - settings[setting] != "disabled" && - settings[setting] != "false" - ) { - testState.telemetryEnabled = true; - document.querySelector("#privacy-warning").classList.remove("hidden"); - } + }); + ctx.restore(); + + // ── value arc + glow + if (value > 0) { + const va = valueToAngle(value, cfg.maxValue, cfg.isLog); + + if (active) { + ctx.save(); + ctx.globalAlpha = 0.45; + ctx.beginPath(); + ctx.arc(cx, cy, R, G_START, va, false); + ctx.strokeStyle = cfg.color; + ctx.lineWidth = tw * 2.8; + ctx.lineCap = 'round'; + ctx.filter = `blur(${tw * 0.8}px)`; + ctx.stroke(); + ctx.restore(); } - } catch (error) { - console.error("Failed to fetch settings:", error); - } -} -/** - * Load server list from the configured source and populate the dropdown - */ -async function applyServerListJSON() { - try { - const serverSource = - typeof globalThis.SPEEDTEST_SERVERS !== "undefined" - ? globalThis.SPEEDTEST_SERVERS - : "server-list.json"; - const servers = Array.isArray(serverSource) - ? serverSource - : await fetch(serverSource).then((response) => response.json()); - if (!servers || !Array.isArray(servers) || servers.length === 0) { - return console.error("Server list is empty or malformed"); + ctx.save(); + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.arc(cx, cy, R, G_START, va, false); + ctx.strokeStyle = cfg.color; + ctx.lineWidth = tw; + ctx.lineCap = 'round'; + if (active) { + ctx.shadowColor = cfg.color; + ctx.shadowBlur = 10 * dpr; } + ctx.stroke(); + ctx.restore(); + + // tip dot + ctx.save(); + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.arc(cx + R * Math.cos(va), cy + R * Math.sin(va), tw * 0.65, 0, 2 * Math.PI); + ctx.fillStyle = '#fff'; + ctx.shadowColor = cfg.color; + ctx.shadowBlur = active ? 14 * dpr : 6 * dpr; + ctx.fill(); + ctx.restore(); + } - testState.servers = servers; + // ── progress ring (thin outer arc) + if (progress > 0 && progress < 1) { + const pa = G_START + progress * G_SWEEP; + ctx.save(); + ctx.globalAlpha = 0.5; + ctx.beginPath(); + ctx.arc(cx, cy, R + tw * 1.05, G_START, pa, false); + ctx.strokeStyle = cfg.color; + ctx.lineWidth = 2 * dpr; + ctx.lineCap = 'round'; + ctx.stroke(); + ctx.restore(); + } - // If there's only one server, just show it. No reachability checks needed. - if (servers.length === 1) { - populateDropdown(servers); - return; - } + // ── value text (inside bowl, below arc center) + const textAlpha = dimmed ? 0.25 : (value > 0 ? 1 : 0.2); + const displayVal = value <= 0 ? '–' : numberToText(value); + + // main number + const numSz = Math.round(R * 0.38); + ctx.save(); + ctx.globalAlpha = textAlpha; + ctx.font = `200 ${numSz}px Inter,sans-serif`; + ctx.fillStyle = value > 0 ? '#fff' : 'rgba(255,255,255,0.3)'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'alphabetic'; + const numY = cy + R * 0.28; + ctx.fillText(displayVal, cx, numY); + ctx.restore(); + + // unit + const unitSz = Math.round(R * 0.13); + ctx.save(); + ctx.globalAlpha = dimmed ? 0.2 : 0.75; + ctx.font = `500 ${unitSz}px Inter,sans-serif`; + ctx.fillStyle = cfg.color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillText(cfg.unit, cx, numY + unitSz * 0.3); + ctx.restore(); + + // label + const lblSz = Math.round(R * 0.105); + ctx.save(); + ctx.globalAlpha = dimmed ? 0.2 : 0.45; + ctx.font = `700 ${lblSz}px Inter,sans-serif`; + ctx.fillStyle = '#fff'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillText(cfg.label, cx, numY + unitSz * 0.3 + unitSz * 1.5); + ctx.restore(); +} - // For multiple servers: first run the built-in selection (which pings servers - // and annotates them with pingT). Only then populate the dropdown so that - // dead servers don't appear. - testState.speedtest.addTestPoints(servers); - testState.speedtest.selectServer((bestServer) => { - const aliveServers = testState.servers.filter((s) => { - // Keep servers that responded to ping (pingT !== -1). - if (s.pingT !== -1) return true; - // Also keep protocol-relative servers ("//...") as a defensive fallback. - // LibreSpeed normalizes them to the page protocol before pinging, so they - // are normally treated like any other server and get a real pingT value. - return typeof s.server === "string" && s.server.startsWith("//"); - }); - - // Prefer to show only reachable servers, but if none are reachable, - // fall back to the full list so users can still pick a server manually. - if (aliveServers.length > 0) { - testState.servers = aliveServers; - } - populateDropdown(testState.servers); +// ── Number formatter ──────────────────────────────────────────────────────── +function numberToText(v) { + v = Number(v); + if (!v || isNaN(v)) return '0.00'; + if (v < 10) return v.toFixed(2); + if (v < 100) return v.toFixed(1); + return v.toFixed(0); +} +// ── Bootstrap ─────────────────────────────────────────────────────────────── +window.addEventListener('DOMContentLoaded', () => { + GAUGES.dl.canvas = document.getElementById('dl-gauge'); + GAUGES.ul.canvas = document.getElementById('ul-gauge'); + GAUGES.ping.canvas = document.getElementById('ping-gauge'); + GAUGES.jitter.canvas = document.getElementById('jitter-gauge'); - if (bestServer) { - selectServer(bestServer); - } else { - alert( - "Can't reach any of the speedtest servers! But you're on this page. Something weird is going on with your network." - ); - } - }); - } catch (error) { - console.error("Failed to load server list:", error); - } + // draw initial empty state + Object.values(GAUGES).forEach(g => drawGauge(g, 0, 0, false, false)); + + createSpeedtest(); + hookUpButtons(); + startRenderLoop(); + applySettingsJSON(); + applyServerListJSON(); +}); + +function createSpeedtest() { + appState.speedtest = new Speedtest(); + appState.speedtest.onupdate = data => { + appState.data = data; + appState.dataDirty = true; + }; + appState.speedtest.onend = aborted => { + appState.ui = aborted ? READY : FINISHED; + }; } -/** - * Add all the servers to the server selection dropdown and make it actually - * work. - * @param {Array} servers - an array of server objects - */ -function populateDropdown(servers) { - const serverSelector = document.querySelector("div.server-selector"); - const serverList = serverSelector.querySelector("ul.servers"); - - // Reset previous state (populateDropdown can be called multiple times) - serverSelector.classList.remove("single-server"); - serverSelector.classList.remove("active"); - serverList.classList.remove("active"); - serverList.innerHTML = ""; - - // If we have only a single server, just show it - if (servers.length === 1) { - serverSelector.classList.add("single-server"); - selectServer(servers[0]); - return; - } - serverSelector.classList.add("active"); +function hookUpButtons() { + document.getElementById('start-button').addEventListener('click', () => { + if (appState.ui === READY || appState.ui === FINISHED) { + document.getElementById('results-panel').classList.add('hidden'); + document.getElementById('share-results').classList.add('hidden'); + appState.speedtest.start(); + appState.ui = RUNNING; + } else if (appState.ui === RUNNING) { + appState.speedtest.abort(); + } + }); - // Make the dropdown open and close (hook only once) - if (serverSelector.dataset.hooked !== "1") { - serverSelector.dataset.hooked = "1"; + document.getElementById('choose-privacy') + ?.addEventListener('click', () => document.getElementById('privacy').showModal()); - serverSelector.addEventListener("click", () => { - serverList.classList.toggle("active"); - }); - document.addEventListener("click", (e) => { - if (e.target.closest("div.server-selector") !== serverSelector) - serverList.classList.remove("active"); + document.getElementById('share-results') + ?.addEventListener('click', () => document.getElementById('share').showModal()); + + document.getElementById('copy-link') + ?.addEventListener('click', async () => { + const link = document.querySelector('img#results')?.src; + if (!link) return; + await navigator.clipboard.writeText(link); + const btn = document.getElementById('copy-link'); + btn.textContent = 'Copied!'; + setTimeout(() => btn.textContent = 'Copy link', 3000); }); - } - // Populate the list to choose from - servers.forEach((server) => { - const item = document.createElement("li"); - const link = document.createElement("a"); - link.href = "#"; - link.innerHTML = `${server.name}${ - server.sponsorName ? ` (${server.sponsorName})` : "" - }`; - link.addEventListener("click", () => selectServer(server)); - item.appendChild(link); - serverList.appendChild(item); - }); + document.querySelectorAll('.close-dialog, #close-privacy').forEach(el => + el.addEventListener('click', () => + document.querySelectorAll('dialog').forEach(d => d.close()) + ) + ); } -/** - * Set the given server as the selected server for the speedtest - * @param {Object} server - a server object - */ -function selectServer(server) { - testState.speedtest.setSelectedServer(server); - testState.selectedServerDirty = true; - testState.state = READY; +async function applySettingsJSON() { + try { + const res = await fetch('settings.json'); + const cfg = await res.json(); + for (const k in cfg) { + appState.speedtest.setParameter(k, cfg[k]); + if (k === 'telemetry_level' && cfg[k] && !['off','disabled','false'].includes(String(cfg[k]))) { + appState.telemetryEnabled = true; + document.getElementById('privacy-warning')?.classList.remove('hidden'); + } + } + } catch (_) {} } -/** - * Start the requestAnimationFrame UI rendering loop - */ -function startRenderingLoop() { - // Do these queries once to speed up the rendering itself - const serverSelector = document.querySelector("div.server-selector"); - const selectedServer = serverSelector.querySelector("#selected-server"); - const sponsor = serverSelector.querySelector("#sponsor"); - const startButton = document.querySelector("#start-button"); - const privacyWarning = document.querySelector("#privacy-warning"); - - const gauges = document.querySelectorAll("#download-gauge, #upload-gauge"); - const downloadProgress = document.querySelector("#download-gauge .progress"); - const uploadProgress = document.querySelector("#upload-gauge .progress"); - const downloadGauge = document.querySelector("#download-gauge .speed"); - const uploadGauge = document.querySelector("#upload-gauge .speed"); - const downloadText = document.querySelector("#download-gauge span"); - const uploadText = document.querySelector("#upload-gauge span"); - - const pingAndJitter = document.querySelectorAll(".ping, .jitter"); - const ping = document.querySelector("#ping"); - const jitter = document.querySelector("#jitter"); - const shareResults = document.querySelector("#share-results"); - const copyLink = document.querySelector("#copy-link"); - const resultsImage = document.querySelector("#results"); - - const buttonTexts = { - [INITIALIZING]: "Loading...", - [READY]: "Let's start", - [RUNNING]: "Abort", - [FINISHED]: "Restart", +async function applyServerListJSON() { + try { + const src = typeof globalThis.SPEEDTEST_SERVERS !== 'undefined' + ? globalThis.SPEEDTEST_SERVERS + : 'server-list.json'; + const servers = Array.isArray(src) + ? src + : await fetch(src).then(r => r.json()); + + if (!servers?.length) return console.error('Server list empty'); + const server = servers[0]; + appState.speedtest.setSelectedServer(server); + appState.selectedServerDirty = true; + appState.ui = READY; + } catch (e) { + console.error('Failed to load server list', e); + } +} + +// ── Render loop ───────────────────────────────────────────────────────────── +function startRenderLoop() { + const startBtn = document.getElementById('start-button'); + const selectedEl = document.getElementById('selected-server'); + const ipEl = document.getElementById('ip-display'); + const resultsPanel = document.getElementById('results-panel'); + const shareBtn = document.getElementById('share-results'); + const resultsImg = document.getElementById('results'); + + const btnLabel = { + [INITIALIZING]: 'Loading…', + [READY]: 'Start Test', + [RUNNING]: 'Abort', + [FINISHED]: 'Test Again', }; - // Show copy link button only if navigator.clipboard is available - copyLink.classList.toggle("hidden", !navigator.clipboard); - - function renderUI() { - // Make the main button reflect the current state - startButton.textContent = buttonTexts[testState.state]; - startButton.classList.toggle("disabled", testState.state === INITIALIZING); - startButton.classList.toggle("active", testState.state === RUNNING); - - // Disable the server selector while test is running - serverSelector.classList.toggle("disabled", testState.state === RUNNING); - - // Show selected server - if (testState.selectedServerDirty) { - const server = testState.speedtest.getSelectedServer(); - selectedServer.textContent = server.name; - if (server.sponsorName) { - if (server.sponsorURL) { - sponsor.innerHTML = `Sponsor: ${server.sponsorName}`; - } else { - sponsor.textContent = `Sponsor: ${server.sponsorName}`; - } - } else { - sponsor.innerHTML = " "; - } - testState.selectedServerDirty = false; + function render() { + startBtn.textContent = btnLabel[appState.ui]; + startBtn.classList.toggle('disabled', appState.ui === INITIALIZING); + startBtn.classList.toggle('active', appState.ui === RUNNING); + + if (appState.selectedServerDirty) { + try { + selectedEl.textContent = appState.speedtest.getSelectedServer().name; + } catch (_) {} + appState.selectedServerDirty = false; } - // Activate the gauges when test running or finished - gauges.forEach((e) => - e.classList.toggle( - "enabled", - testState.state === RUNNING || testState.state === FINISHED - ) - ); - - // Show ping and jitter if data is available - pingAndJitter.forEach((e) => - e.classList.toggle( - "hidden", - !( - testState.testData && - testState.testData.pingStatus && - testState.testData.jitterStatus - ) - ) - ); - - // Show share button after test if server supports it - shareResults.classList.toggle( - "hidden", - !( - testState.state === FINISHED && - testState.telemetryEnabled && - testState.testData.testId - ) - ); - - if (testState.testDataDirty) { - // Set gauge rotations - downloadProgress.style = `--progress-rotation: ${ - testState.testData.dlProgress * 180 - }deg`; - uploadProgress.style = `--progress-rotation: ${ - testState.testData.ulProgress * 180 - }deg`; - downloadGauge.style = `--speed-rotation: ${mbpsToRotation( - testState.testData.dlStatus, - testState.testData.testState === 1 - )}deg`; - uploadGauge.style = `--speed-rotation: ${mbpsToRotation( - testState.testData.ulStatus, - testState.testData.testState === 3 - )}deg`; - - // Set numeric values - downloadText.textContent = numberToText(testState.testData.dlStatus); - uploadText.textContent = numberToText(testState.testData.ulStatus); - ping.textContent = numberToText(testState.testData.pingStatus); - jitter.textContent = numberToText(testState.testData.jitterStatus); - - // Set user's IP and provider - if (testState.testData.clientIp) { - // Clear previous content - privacyWarning.innerHTML = ''; - - const connectedThrough = document.createElement('span'); - connectedThrough.textContent = 'You are connected through:'; - - const ipAddress = document.createTextNode(testState.testData.clientIp); - - privacyWarning.appendChild(connectedThrough); - privacyWarning.appendChild(document.createElement('br')); - privacyWarning.appendChild(ipAddress); - - privacyWarning.classList.remove("hidden"); + if (appState.dataDirty && appState.data) { + const d = appState.data; + const ts = d.testState; // 1=dl 2=ping 3=ul + const running = appState.ui === RUNNING; + const done = appState.ui === FINISHED; + const osc = (running && ts === 1) ? 1 + 0.015 * Math.sin(Date.now() / 120) : 1; + const oscU = (running && ts === 3) ? 1 + 0.015 * Math.sin(Date.now() / 120) : 1; + + const dlVal = (parseFloat(d.dlStatus) || 0) * osc; + const ulVal = (parseFloat(d.ulStatus) || 0) * oscU; + const pingVal = parseFloat(d.pingStatus) || 0; + const jitterVal = parseFloat(d.jitterStatus) || 0; + + drawGauge(GAUGES.dl, dlVal, parseFloat(d.dlProgress) || 0, ts === 1, running && ts !== 1 && !done); + drawGauge(GAUGES.ul, ulVal, parseFloat(d.ulProgress) || 0, ts === 3, running && ts !== 3 && !done); + drawGauge(GAUGES.ping, pingVal, parseFloat(d.pingProgress) || 0, ts === 2, running && ts !== 2 && !done); + drawGauge(GAUGES.jitter, jitterVal, 0, ts === 2, running && ts !== 2 && !done); + + // IP info + if (d.clientIp) { + ipEl.innerHTML = `Connected via ${d.clientIp}`; } - // Set image for sharing results - if (testState.testData.testId) { - resultsImage.src = - window.location.href.substring( - 0, - window.location.href.lastIndexOf("/") - ) + - "/results/?id=" + - testState.testData.testId; + // results panel + if (done) { + document.getElementById('result-dl').textContent = numberToText(d.dlStatus); + document.getElementById('result-ul').textContent = numberToText(d.ulStatus); + document.getElementById('result-ping').textContent = numberToText(d.pingStatus); + document.getElementById('result-jitter').textContent = numberToText(d.jitterStatus); + resultsPanel.classList.remove('hidden'); + + if (appState.telemetryEnabled && d.testId) { + shareBtn?.classList.remove('hidden'); + if (resultsImg) { + resultsImg.src = window.location.href.replace(/[^/]*$/, '') + 'results/?id=' + d.testId; + } + } } - testState.testDataDirty = false; + appState.dataDirty = false; } - requestAnimationFrame(renderUI); + requestAnimationFrame(render); } - renderUI(); -} - -/** - * Convert a speed in Mbits per second to a rotation for the gauge - * @param {string} speed Speed in Mbits - * @param {boolean} oscillate If the gauge should wiggle a bit - * @returns {number} Rotation for the gauge in degrees - */ -function mbpsToRotation(speed, oscillate) { - speed = Number(speed); - if (speed <= 0) return 0; - - const minSpeed = 0; - const maxSpeed = 10000; // 10 Gbps maxes out the gauge - const minRotation = 0; - const maxRotation = 180; - - // Can't do log10 of values less than one, +1 all to keep it fair - const logMinSpeed = Math.log10(minSpeed + 1); - const logMaxSpeed = Math.log10(maxSpeed + 1); - const logSpeed = Math.log10(speed + 1); - - const power = (logSpeed - logMinSpeed) / (logMaxSpeed - logMinSpeed); - const oscillation = oscillate ? 1 + 0.01 * Math.sin(Date.now() / 100) : 1; - const rotation = power * oscillation * maxRotation; - - // Make sure we stay within bounds at all times - return Math.max(Math.min(rotation, maxRotation), minRotation); -} - -/** - * Convert a number to a user friendly version - * @param {string} value Speed, ping or jitter - * @returns {string} A text version with proper decimals - */ -function numberToText(value) { - if (!value) return "00"; - value = Number(value); - if (value < 10) return value.toFixed(2); - if (value < 100) return value.toFixed(1); - return value.toFixed(0); + render(); } diff --git a/web/assets/speedtest_worker.js b/web/assets/speedtest_worker.js index 8626b7a..889e37d 100755 --- a/web/assets/speedtest_worker.js +++ b/web/assets/speedtest_worker.js @@ -17,6 +17,13 @@ let ulProgress = 0; //progress of upload test 0-1 let pingProgress = 0; //progress of ping+jitter test 0-1 let testId = null; //test ID (sent back by telemetry if used, null otherwise) +// Chart data for real-time graph +let dlChartData = []; // array of {t, v} for download +let ulChartData = []; // array of {t, v} for upload +let pingDuringTest = { dl: [], ul: [] }; // ping measurements during DL/UL tests +let latencyUnderload = ""; // ping during load (ms) +let basePing = 0; // baseline ping before load tests + let log = ""; //telemetry log function tlog(s) { if (settings.telemetry_level >= 2) { @@ -39,11 +46,11 @@ function twarn(s) { let settings = { mpot: false, //set to true when in MPOT mode test_order: "IP_D_U", //order in which tests will be performed as a string. D=Download, U=Upload, P=Ping+Jitter, I=IP, _=1 second delay - time_ul_max: 15, // max duration of upload test in seconds - time_dl_max: 15, // max duration of download test in seconds + time_ul_max: 20, // max duration of upload test in seconds + time_dl_max: 20, // max duration of download test in seconds time_auto: true, // if set to true, tests will take less time on faster connections - time_ulGraceTime: 3, //time to wait in seconds before actually measuring ul speed (wait for buffers to fill) - time_dlGraceTime: 1.5, //time to wait in seconds before actually measuring dl speed (wait for TCP window to increase) + time_ulGraceTime: 2, //time to wait in seconds before actually measuring ul speed (wait for buffers to fill) + time_dlGraceTime: 2, //time to wait in seconds before actually measuring dl speed (wait for TCP window to increase) count_ping: 10, // number of pings to perform in ping test url_dl: "backend/garbage.php", // path to a large file or garbage.php, used for download test. must be relative to this js file url_ul: "backend/empty.php", // path to an empty file, used for upload test. must be relative to this js file @@ -52,7 +59,7 @@ let settings = { getIp_ispInfo: true, //if set to true, the server will include ISP info with the IP address getIp_ispInfo_distance: "km", //km or mi=estimate distance from server in km/mi; set to false to disable distance estimation. getIp_ispInfo must be enabled in order for this to work xhr_dlMultistream: 6, // number of download streams to use (can be different if enable_quirks is active) - xhr_ulMultistream: 3, // number of upload streams to use (can be different if enable_quirks is active) + xhr_ulMultistream: 6, // number of upload streams to use (can be different if enable_quirks is active) xhr_multistreamDelay: 300, //how much concurrent requests should be delayed xhr_ignoreErrors: 1, // 0=fail on errors, 1=attempt to restart a stream if it fails, 2=ignore all errors xhr_dlUseBlob: false, // if set to true, it reduces ram usage but uses the hard drive (useful with large garbagePhp_chunkSize and/or high xhr_dlMultistream) @@ -65,7 +72,8 @@ let settings = { telemetry_level: 0, // 0=disabled, 1=basic (results only), 2=full (results and timing) 3=debug (results+log) url_telemetry: "results/telemetry.php", // path to the script that adds telemetry data to the database telemetry_extra: "", //extra data that can be passed to the telemetry through the settings - forceIE11Workaround: false //when set to true, it will force the IE11 upload test on all browsers. Debug only + forceIE11Workaround: false, //when set to true, it will force the IE11 upload test on all browsers. Debug only + client_id: "" // stable browser-generated device identifier (clientId:fingerprint) }; let xhr = null; // array of currently active xhr requests @@ -102,7 +110,11 @@ this.addEventListener("message", function(e) { dlProgress: dlProgress, ulProgress: ulProgress, pingProgress: pingProgress, - testId: testId + testId: testId, + dlChartData: dlChartData, + ulChartData: ulChartData, + pingDuringTest: pingDuringTest, + latencyUnderload: latencyUnderload }) ); } @@ -180,6 +192,15 @@ this.addEventListener("message", function(e) { if (testState == 5) return; if (test_pointer >= settings.test_order.length) { //test is finished + // Calculate latency underload + if (pingDuringTest.dl.length > 0 || pingDuringTest.ul.length > 0) { + const allPings = [...pingDuringTest.dl, ...pingDuringTest.ul].filter(p => p !== null && !isNaN(p)); + if (allPings.length > 0) { + const avgPingUnderload = allPings.reduce((a, b) => a + b, 0) / allPings.length; + const pingDiff = avgPingUnderload - basePing; + latencyUnderload = pingDiff > 0 ? pingDiff.toFixed(2) : "0"; + } + } if (settings.telemetry_level > 0) sendTelemetry(function(id) { testState = 4; @@ -409,6 +430,11 @@ function dlTest(done) { } //update status dlStatus = ((speed * 8 * settings.overheadCompensationFactor) / (settings.useMebibits ? 1048576 : 1000000)).toFixed(2); // speed is multiplied by 8 to go from bytes to bits, overhead compensation is applied, then everything is divided by 1048576 or 1000000 to go to megabits/mebibits + // Capture chart data point + const chartTime = (t - 1000 * settings.time_dlGraceTime) / 1000; + if (chartTime >= 0 && dlStatus !== "Fail") { + dlChartData.push({ t: chartTime, v: parseFloat(dlStatus) }); + } if ((t + bonusT) / 1000.0 > settings.time_dl_max || failed) { // test is over, stop streams and timer if (failed || isNaN(dlStatus)) dlStatus = "Fail"; @@ -416,7 +442,13 @@ function dlTest(done) { clearInterval(interval); dlProgress = 1; tlog("dlTest: " + dlStatus + ", took " + (new Date().getTime() - startT) + "ms"); - done(); + // Measure ping after download for latency underload + measurePing(function(pingTime) { + if (pingTime !== null) { + pingDuringTest.dl.push(pingTime); + } + done(); + }); } } }.bind(this), @@ -557,6 +589,11 @@ function ulTest(done) { } //update status ulStatus = ((speed * 8 * settings.overheadCompensationFactor) / (settings.useMebibits ? 1048576 : 1000000)).toFixed(2); // speed is multiplied by 8 to go from bytes to bits, overhead compensation is applied, then everything is divided by 1048576 or 1000000 to go to megabits/mebibits + // Capture chart data point + const chartTime = (t - 1000 * settings.time_ulGraceTime) / 1000; + if (chartTime >= 0 && ulStatus !== "Fail") { + ulChartData.push({ t: chartTime, v: parseFloat(ulStatus) }); + } if ((t + bonusT) / 1000.0 > settings.time_ul_max || failed) { // test is over, stop streams and timer if (failed || isNaN(ulStatus)) ulStatus = "Fail"; @@ -564,7 +601,13 @@ function ulTest(done) { clearInterval(interval); ulProgress = 1; tlog("ulTest: " + ulStatus + ", took " + (new Date().getTime() - startT) + "ms"); - done(); + // Measure ping after upload for latency underload + measurePing(function(pingTime) { + if (pingTime !== null) { + pingDuringTest.ul.push(pingTime); + } + done(); + }); } } }.bind(this), @@ -667,6 +710,7 @@ function pingTest(done) { else { // more pings to do? pingProgress = 1; + basePing = ping; tlog("ping: " + pingStatus + " jitter: " + jitterStatus + ", took " + (new Date().getTime() - startT) + "ms"); done(); } @@ -678,7 +722,37 @@ function pingTest(done) { }.bind(this); doPing(); // start first ping } +// Simple ping measurement for latency underload +function measurePing(callback) { + const startT = Date.now(); + const xhr = new XMLHttpRequest(); + xhr.onload = function() { + const pingTime = Date.now() - startT; + callback(pingTime); + }; + xhr.onerror = function() { + callback(null); + }; + xhr.open("GET", settings.url_ping + url_sep(settings.url_ping) + (settings.mpot ? "cors=true&" : "") + "r=" + Math.random(), true); + xhr.send(); +} // telemetry +function computeGrade(dl, ul, ping, jitter, latencyUnderload) { + const dlMbps = parseFloat(dl) || 0; + const ulMbps = parseFloat(ul) || 0; + const pingMs = parseFloat(ping) || 999; + const jitterMs = parseFloat(jitter) || 999; + const latMs = parseFloat(latencyUnderload) || 0; + let score = 100; + if (dlMbps < 5) score -= 30; else if (dlMbps < 25) score -= 15; else if (dlMbps < 100) score -= 5; + if (ulMbps < 2) score -= 20; else if (ulMbps < 10) score -= 10; else if (ulMbps < 50) score -= 5; + if (pingMs > 150) score -= 20; else if (pingMs > 80) score -= 10; else if (pingMs > 40) score -= 5; + if (jitterMs > 50) score -= 15; else if (jitterMs > 20) score -= 8; else if (jitterMs > 10) score -= 3; + if (latMs > 100) score -= 10; else if (latMs > 50) score -= 5; + let grade = score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 60 ? 'C' : score >= 45 ? 'D' : score >= 30 ? 'E' : 'F'; + return JSON.stringify({ grade, criteria: { dl: dlMbps, ul: ulMbps, ping: pingMs, jitter: jitterMs, latencyUnderload: latMs } }); +} + function sendTelemetry(done) { if (settings.telemetry_level < 1) return; xhr = new XMLHttpRequest(); @@ -715,9 +789,27 @@ function sendTelemetry(done) { fd.append("jitter", jitterStatus); fd.append("log", settings.telemetry_level > 1 ? log : ""); fd.append("extra", settings.telemetry_extra); + fd.append("client_id", settings.client_id); + const gradeData = computeGrade(dlStatus, ulStatus, pingStatus, jitterStatus, latencyUnderload); + fd.append("grade_data", gradeData); + fd.append("chart_data", JSON.stringify({ dl: dlChartData, ul: ulChartData })); + fd.append("latency_underload", latencyUnderload); + fd.append("ping_during_test", JSON.stringify(pingDuringTest)); xhr.send(fd); } catch (ex) { - const postData = "extra=" + encodeURIComponent(settings.telemetry_extra) + "&ispinfo=" + encodeURIComponent(JSON.stringify(telemetryIspInfo)) + "&dl=" + encodeURIComponent(dlStatus) + "&ul=" + encodeURIComponent(ulStatus) + "&ping=" + encodeURIComponent(pingStatus) + "&jitter=" + encodeURIComponent(jitterStatus) + "&log=" + encodeURIComponent(settings.telemetry_level > 1 ? log : ""); + const gradeData = computeGrade(dlStatus, ulStatus, pingStatus, jitterStatus, latencyUnderload); + const postData = "extra=" + encodeURIComponent(settings.telemetry_extra) + + "&ispinfo=" + encodeURIComponent(JSON.stringify(telemetryIspInfo)) + + "&dl=" + encodeURIComponent(dlStatus) + + "&ul=" + encodeURIComponent(ulStatus) + + "&ping=" + encodeURIComponent(pingStatus) + + "&jitter=" + encodeURIComponent(jitterStatus) + + "&log=" + encodeURIComponent(settings.telemetry_level > 1 ? log : "") + + "&client_id=" + encodeURIComponent(settings.client_id) + + "&grade_data=" + encodeURIComponent(gradeData) + + "&chart_data=" + encodeURIComponent(JSON.stringify({ dl: dlChartData, ul: ulChartData })) + + "&latency_underload=" + encodeURIComponent(latencyUnderload) + + "&ping_during_test=" + encodeURIComponent(JSON.stringify(pingDuringTest)); xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded"); xhr.send(postData); } diff --git a/web/web.go b/web/web.go index 9fcc21f..588d98d 100644 --- a/web/web.go +++ b/web/web.go @@ -3,6 +3,7 @@ package web import ( "embed" "encoding/json" + "errors" "io" "io/fs" "io/ioutil" @@ -11,6 +12,8 @@ import ( "os" "regexp" "strconv" + "strings" + "syscall" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -70,6 +73,7 @@ func ListenAndServe(conf *config.Config) error { r.Get(conf.BaseURL+"/backend/garbage", garbage) r.Get(conf.BaseURL+"/getIP", getIP) r.Get(conf.BaseURL+"/backend/getIP", getIP) + r.Get(conf.BaseURL+"/results/view", results.ViewPage) r.Get(conf.BaseURL+"/results", results.DrawPNG) r.Get(conf.BaseURL+"/results/", results.DrawPNG) r.Get(conf.BaseURL+"/backend/results", results.DrawPNG) @@ -96,10 +100,42 @@ func ListenAndServe(conf *config.Config) error { r.Get(conf.BaseURL+"/backend/results/json.php", results.JSONResult) go listenProxyProtocol(conf, r) + go listenRedirect(conf) return startListener(conf, r) } +func listenRedirect(conf *config.Config) { + if conf.RedirectPort == "" || conf.RedirectPort == "0" { + return + } + scheme := "http" + if conf.EnableTLS { + scheme = "https" + } + addr := net.JoinHostPort(conf.BindAddress, conf.RedirectPort) + log.Infof("Starting HTTPβ†’%s redirect listener on %s", strings.ToUpper(scheme), addr) + targetPort := conf.Port + standardPort := map[string]string{"http": "80", "https": "443"} + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := r.Host + // Strip any port from the incoming Host header + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + // Only append port when it's non-standard for the target scheme + if targetPort != "" && targetPort != standardPort[scheme] { + host = net.JoinHostPort(host, targetPort) + } + url := scheme + "://" + host + r.RequestURI + http.Redirect(w, r, url, http.StatusMovedPermanently) + }) + + if err := http.ListenAndServe(addr, handler); err != nil { + log.Errorf("HTTP redirect listener error: %s", err) + } +} + func listenProxyProtocol(conf *config.Config, r *chi.Mux) { if conf.ProxyProtocolPort != "0" { addr := net.JoinHostPort(conf.BindAddress, conf.ProxyProtocolPort) @@ -186,12 +222,35 @@ func garbage(w http.ResponseWriter, r *http.Request) { for i := 0; i < chunks; i++ { if _, err := w.Write(randomData); err != nil { - log.Errorf("Error writing back to client at chunk number %d: %s", i, err) + // Client disconnects are expected during a speed test: the browser + // aborts its download streams when the timed test ends. Don't spam + // the log for those β€” only surface genuinely unexpected errors. + if !isClientGone(err) { + log.Errorf("Error writing back to client at chunk number %d: %s", i, err) + } break } } } +// isClientGone reports whether err is a normal client-side disconnect (the peer +// closed the connection / aborted the HTTP2 stream), which happens routinely +// when a speed test finishes and is not a server error. +func isClientGone(err error) bool { + if err == nil { + return false + } + if errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNRESET) { + return true + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "stream closed") || + strings.Contains(msg, "broken pipe") || + strings.Contains(msg, "connection reset by peer") || + strings.Contains(msg, "client disconnected") || + strings.Contains(msg, "context canceled") +} + func getIP(w http.ResponseWriter, r *http.Request) { var ret results.Result @@ -208,7 +267,9 @@ func getIP(w http.ResponseWriter, r *http.Request) { ret.ProcessedString = clientIP + " - " + desc b, _ := json.Marshal(&ret) if _, err := w.Write(b); err != nil { - log.Errorf("Error writing to client: %s", err) + if !isClientGone(err) { + log.Errorf("Error writing to client: %s", err) + } } return }