speedtest/results/analytics.php
2026-02-05 14:25:17 -05:00

1009 lines
42 KiB
PHP

<?php
session_start();
error_reporting(0);
require 'telemetry_settings.php';
require_once 'telemetry_db.php';
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0, s-maxage=0');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
/**
* Get analytics data from database
*/
function getAnalyticsData() {
$pdo = getPdo();
if (!($pdo instanceof PDO)) {
return false;
}
require TELEMETRY_SETTINGS_FILE;
$data = [
'total_tests' => 0,
'avg_download' => 0,
'avg_upload' => 0,
'avg_ping' => 0,
'avg_jitter' => 0,
'tests_by_day' => [],
'download_distribution' => [],
'upload_distribution' => [],
'ping_distribution' => [],
'browsers' => [],
'recent_speeds' => [],
'countries' => [],
'cities' => [],
'isps' => [],
'unique_countries' => 0
];
try {
// Total tests
$stmt = $pdo->query('SELECT COUNT(*) as total FROM speedtest_users');
$data['total_tests'] = (int)$stmt->fetch(PDO::FETCH_ASSOC)['total'];
// Average speeds
$stmt = $pdo->query('SELECT
AVG(CAST(dl AS DECIMAL(10,2))) as avg_dl,
AVG(CAST(ul AS DECIMAL(10,2))) as avg_ul,
AVG(CAST(ping AS DECIMAL(10,2))) as avg_ping,
AVG(CAST(jitter AS DECIMAL(10,2))) as avg_jitter,
MAX(CAST(dl AS DECIMAL(10,2))) as max_dl,
MAX(CAST(ul AS DECIMAL(10,2))) as max_ul,
MIN(CAST(ping AS DECIMAL(10,2))) as min_ping
FROM speedtest_users
WHERE dl IS NOT NULL AND dl != ""');
$row = $stmt->fetch(PDO::FETCH_ASSOC);
$data['avg_download'] = round((float)$row['avg_dl'], 2);
$data['avg_upload'] = round((float)$row['avg_ul'], 2);
$data['avg_ping'] = round((float)$row['avg_ping'], 2);
$data['avg_jitter'] = round((float)$row['avg_jitter'], 2);
$data['max_download'] = round((float)$row['max_dl'], 2);
$data['max_upload'] = round((float)$row['max_ul'], 2);
$data['min_ping'] = round((float)$row['min_ping'], 2);
// Tests by day (last 30 days)
if ('mssql' === $db_type) {
$stmt = $pdo->query("SELECT
CONVERT(VARCHAR(10), timestamp, 120) as day,
COUNT(*) as count
FROM speedtest_users
WHERE timestamp >= DATEADD(day, -30, GETDATE())
GROUP BY CONVERT(VARCHAR(10), timestamp, 120)
ORDER BY day ASC");
} elseif ('postgresql' === $db_type) {
$stmt = $pdo->query("SELECT
TO_CHAR(timestamp, 'YYYY-MM-DD') as day,
COUNT(*) as count
FROM speedtest_users
WHERE timestamp >= NOW() - INTERVAL '30 days'
GROUP BY TO_CHAR(timestamp, 'YYYY-MM-DD')
ORDER BY day ASC");
} else {
$stmt = $pdo->query("SELECT
DATE(timestamp) as day,
COUNT(*) as count
FROM speedtest_users
WHERE timestamp >= DATE('now', '-30 days')
GROUP BY DATE(timestamp)
ORDER BY day ASC");
}
$data['tests_by_day'] = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Download speed distribution (buckets)
$stmt = $pdo->query('SELECT dl FROM speedtest_users WHERE dl IS NOT NULL AND dl != ""');
$downloads = $stmt->fetchAll(PDO::FETCH_COLUMN);
$data['download_distribution'] = createSpeedBuckets($downloads);
// Upload speed distribution
$stmt = $pdo->query('SELECT ul FROM speedtest_users WHERE ul IS NOT NULL AND ul != ""');
$uploads = $stmt->fetchAll(PDO::FETCH_COLUMN);
$data['upload_distribution'] = createSpeedBuckets($uploads);
// Ping distribution
$stmt = $pdo->query('SELECT ping FROM speedtest_users WHERE ping IS NOT NULL AND ping != ""');
$pings = $stmt->fetchAll(PDO::FETCH_COLUMN);
$data['ping_distribution'] = createPingBuckets($pings);
// Browser distribution from user agent
$stmt = $pdo->query('SELECT ua FROM speedtest_users WHERE ua IS NOT NULL AND ua != ""');
$userAgents = $stmt->fetchAll(PDO::FETCH_COLUMN);
$data['browsers'] = parseBrowsers($userAgents);
// Recent speed trends (last 50 tests for line chart)
if ('mssql' === $db_type) {
$stmt = $pdo->query('SELECT TOP(50) timestamp, dl, ul, ping FROM speedtest_users ORDER BY timestamp DESC');
} else {
$stmt = $pdo->query('SELECT timestamp, dl, ul, ping FROM speedtest_users ORDER BY timestamp DESC LIMIT 50');
}
$data['recent_speeds'] = array_reverse($stmt->fetchAll(PDO::FETCH_ASSOC));
// Location data from ispinfo
$stmt = $pdo->query('SELECT ispinfo FROM speedtest_users WHERE ispinfo IS NOT NULL AND ispinfo != ""');
$ispInfos = $stmt->fetchAll(PDO::FETCH_COLUMN);
$locationData = parseLocationData($ispInfos);
$data['countries'] = $locationData['countries'];
$data['cities'] = $locationData['cities'];
$data['isps'] = $locationData['isps'];
$data['unique_countries'] = count($locationData['countries']);
} catch (Exception $e) {
return false;
}
return $data;
}
/**
* Create speed distribution buckets
*/
function createSpeedBuckets($speeds) {
$buckets = [
'0-10' => 0,
'10-25' => 0,
'25-50' => 0,
'50-100' => 0,
'100-250' => 0,
'250-500' => 0,
'500-1000' => 0,
'1000+' => 0
];
foreach ($speeds as $speed) {
$s = (float)$speed;
if ($s < 10) $buckets['0-10']++;
elseif ($s < 25) $buckets['10-25']++;
elseif ($s < 50) $buckets['25-50']++;
elseif ($s < 100) $buckets['50-100']++;
elseif ($s < 250) $buckets['100-250']++;
elseif ($s < 500) $buckets['250-500']++;
elseif ($s < 1000) $buckets['500-1000']++;
else $buckets['1000+']++;
}
return $buckets;
}
/**
* Create ping distribution buckets
*/
function createPingBuckets($pings) {
$buckets = [
'0-10' => 0,
'10-25' => 0,
'25-50' => 0,
'50-100' => 0,
'100-200' => 0,
'200+' => 0
];
foreach ($pings as $ping) {
$p = (float)$ping;
if ($p < 10) $buckets['0-10']++;
elseif ($p < 25) $buckets['10-25']++;
elseif ($p < 50) $buckets['25-50']++;
elseif ($p < 100) $buckets['50-100']++;
elseif ($p < 200) $buckets['100-200']++;
else $buckets['200+']++;
}
return $buckets;
}
/**
* Parse user agents to extract browser info
*/
function parseBrowsers($userAgents) {
$browsers = [
'Chrome' => 0,
'Firefox' => 0,
'Safari' => 0,
'Edge' => 0,
'Opera' => 0,
'Other' => 0
];
foreach ($userAgents as $ua) {
if (stripos($ua, 'Edg') !== false) {
$browsers['Edge']++;
} elseif (stripos($ua, 'OPR') !== false || stripos($ua, 'Opera') !== false) {
$browsers['Opera']++;
} elseif (stripos($ua, 'Chrome') !== false) {
$browsers['Chrome']++;
} elseif (stripos($ua, 'Firefox') !== false) {
$browsers['Firefox']++;
} elseif (stripos($ua, 'Safari') !== false) {
$browsers['Safari']++;
} else {
$browsers['Other']++;
}
}
return $browsers;
}
/**
* Parse location data from ispinfo JSON
*/
function parseLocationData($ispInfos) {
$countries = [];
$cities = [];
$isps = [];
foreach ($ispInfos as $ispinfo) {
$data = json_decode($ispinfo, true);
if (!is_array($data)) {
continue;
}
// Get rawIspInfo which contains the actual location data
$raw = isset($data['rawIspInfo']) ? $data['rawIspInfo'] : null;
if (!is_array($raw)) {
continue;
}
// Country - can be 'country' (code) or 'country_name' (from offline db)
$country = null;
if (!empty($raw['country_name'])) {
$country = $raw['country_name'];
} elseif (!empty($raw['country'])) {
$country = getCountryName($raw['country']);
}
if ($country) {
$countries[$country] = ($countries[$country] ?? 0) + 1;
}
// City
if (!empty($raw['city'])) {
$city = $raw['city'];
$cities[$city] = ($cities[$city] ?? 0) + 1;
}
// ISP - can be 'org', 'as_name', or in 'asn.name'
$isp = null;
if (!empty($raw['as_name'])) {
$isp = $raw['as_name'];
} elseif (!empty($raw['org'])) {
// Remove AS number prefix if present
$isp = preg_replace('/^AS\d+\s+/', '', $raw['org']);
} elseif (isset($raw['asn']) && is_array($raw['asn']) && !empty($raw['asn']['name'])) {
$isp = $raw['asn']['name'];
}
if ($isp) {
$isps[$isp] = ($isps[$isp] ?? 0) + 1;
}
}
// Sort by count descending and limit to top 10
arsort($countries);
arsort($cities);
arsort($isps);
return [
'countries' => array_slice($countries, 0, 10, true),
'cities' => array_slice($cities, 0, 10, true),
'isps' => array_slice($isps, 0, 10, true)
];
}
/**
* Convert country code to country name
*/
function getCountryName($code) {
$countries = [
'AF' => 'Afghanistan', 'AL' => 'Albania', 'DZ' => 'Algeria', 'AD' => 'Andorra',
'AO' => 'Angola', 'AR' => 'Argentina', 'AM' => 'Armenia', 'AU' => 'Australia',
'AT' => 'Austria', 'AZ' => 'Azerbaijan', 'BH' => 'Bahrain', 'BD' => 'Bangladesh',
'BY' => 'Belarus', 'BE' => 'Belgium', 'BZ' => 'Belize', 'BJ' => 'Benin',
'BT' => 'Bhutan', 'BO' => 'Bolivia', 'BA' => 'Bosnia', 'BW' => 'Botswana',
'BR' => 'Brazil', 'BN' => 'Brunei', 'BG' => 'Bulgaria', 'BF' => 'Burkina Faso',
'BI' => 'Burundi', 'KH' => 'Cambodia', 'CM' => 'Cameroon', 'CA' => 'Canada',
'CF' => 'Central African Republic', 'TD' => 'Chad', 'CL' => 'Chile', 'CN' => 'China',
'CO' => 'Colombia', 'CD' => 'Congo', 'CR' => 'Costa Rica', 'HR' => 'Croatia',
'CU' => 'Cuba', 'CY' => 'Cyprus', 'CZ' => 'Czech Republic', 'DK' => 'Denmark',
'DJ' => 'Djibouti', 'DO' => 'Dominican Republic', 'EC' => 'Ecuador', 'EG' => 'Egypt',
'SV' => 'El Salvador', 'EE' => 'Estonia', 'ET' => 'Ethiopia', 'FI' => 'Finland',
'FR' => 'France', 'GA' => 'Gabon', 'GM' => 'Gambia', 'GE' => 'Georgia',
'DE' => 'Germany', 'GH' => 'Ghana', 'GR' => 'Greece', 'GT' => 'Guatemala',
'GN' => 'Guinea', 'HT' => 'Haiti', 'HN' => 'Honduras', 'HK' => 'Hong Kong',
'HU' => 'Hungary', 'IS' => 'Iceland', 'IN' => 'India', 'ID' => 'Indonesia',
'IR' => 'Iran', 'IQ' => 'Iraq', 'IE' => 'Ireland', 'IL' => 'Israel',
'IT' => 'Italy', 'JM' => 'Jamaica', 'JP' => 'Japan', 'JO' => 'Jordan',
'KZ' => 'Kazakhstan', 'KE' => 'Kenya', 'KW' => 'Kuwait', 'KG' => 'Kyrgyzstan',
'LA' => 'Laos', 'LV' => 'Latvia', 'LB' => 'Lebanon', 'LY' => 'Libya',
'LT' => 'Lithuania', 'LU' => 'Luxembourg', 'MK' => 'Macedonia', 'MG' => 'Madagascar',
'MY' => 'Malaysia', 'MV' => 'Maldives', 'ML' => 'Mali', 'MT' => 'Malta',
'MX' => 'Mexico', 'MD' => 'Moldova', 'MC' => 'Monaco', 'MN' => 'Mongolia',
'ME' => 'Montenegro', 'MA' => 'Morocco', 'MZ' => 'Mozambique', 'MM' => 'Myanmar',
'NA' => 'Namibia', 'NP' => 'Nepal', 'NL' => 'Netherlands', 'NZ' => 'New Zealand',
'NI' => 'Nicaragua', 'NE' => 'Niger', 'NG' => 'Nigeria', 'NO' => 'Norway',
'OM' => 'Oman', 'PK' => 'Pakistan', 'PA' => 'Panama', 'PY' => 'Paraguay',
'PE' => 'Peru', 'PH' => 'Philippines', 'PL' => 'Poland', 'PT' => 'Portugal',
'QA' => 'Qatar', 'RO' => 'Romania', 'RU' => 'Russia', 'RW' => 'Rwanda',
'SA' => 'Saudi Arabia', 'SN' => 'Senegal', 'RS' => 'Serbia', 'SG' => 'Singapore',
'SK' => 'Slovakia', 'SI' => 'Slovenia', 'SO' => 'Somalia', 'ZA' => 'South Africa',
'KR' => 'South Korea', 'ES' => 'Spain', 'LK' => 'Sri Lanka', 'SD' => 'Sudan',
'SE' => 'Sweden', 'CH' => 'Switzerland', 'SY' => 'Syria', 'TW' => 'Taiwan',
'TJ' => 'Tajikistan', 'TZ' => 'Tanzania', 'TH' => 'Thailand', 'TN' => 'Tunisia',
'TR' => 'Turkey', 'TM' => 'Turkmenistan', 'UG' => 'Uganda', 'UA' => 'Ukraine',
'AE' => 'United Arab Emirates', 'GB' => 'United Kingdom', 'US' => 'United States',
'UY' => 'Uruguay', 'UZ' => 'Uzbekistan', 'VE' => 'Venezuela', 'VN' => 'Vietnam',
'YE' => 'Yemen', 'ZM' => 'Zambia', 'ZW' => 'Zimbabwe'
];
return $countries[$code] ?? $code;
}
?>
<!DOCTYPE html>
<html>
<head>
<title>LibreSpeed - Analytics</title>
<script src="chart.min.js"></script>
<style type="text/css">
:root {
--bg-page: hsl(198, 72%, 35%);
--bg-body: #FFFFFF;
--bg-chart: #f8f9fa;
--text-primary: #333;
--text-secondary: #555;
--text-muted: #666;
--border-color: #ccc;
--shadow-color: rgba(0,0,0,0.1);
--shadow-heavy: #00000080;
--link-color: hsl(198, 72%, 35%);
--input-bg: #FFFFFF;
--card-gradient-start: hsl(198, 72%, 45%);
--card-gradient-end: hsl(198, 72%, 35%);
}
[data-theme="dark"] {
--bg-page: hsl(220, 20%, 12%);
--bg-body: hsl(220, 20%, 18%);
--bg-chart: hsl(220, 20%, 22%);
--text-primary: #e4e4e7;
--text-secondary: #a1a1aa;
--text-muted: #71717a;
--border-color: #3f3f46;
--shadow-color: rgba(0,0,0,0.3);
--shadow-heavy: #00000099;
--link-color: hsl(198, 72%, 55%);
--input-bg: hsl(220, 20%, 25%);
--card-gradient-start: hsl(198, 60%, 35%);
--card-gradient-end: hsl(198, 60%, 25%);
}
html, body {
margin: 0;
padding: 0;
border: none;
width: 100%;
min-height: 100%;
}
html {
background-color: var(--bg-page);
font-family: "Segoe UI", "Roboto", sans-serif;
}
body {
background-color: var(--bg-body);
box-sizing: border-box;
width: 100%;
max-width: 90em;
margin: 4em auto;
box-shadow: 0 1em 6em var(--shadow-heavy);
padding: 1em 2em 4em 2em;
border-radius: 0.4em;
color: var(--text-primary);
}
h1, h2, h3 {
font-weight: 300;
margin-bottom: 0.5em;
color: var(--text-primary);
}
h1 {
text-align: center;
margin-bottom: 1em;
}
.header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1em;
}
.header-row h1 {
margin: 0;
}
.theme-toggle {
display: flex;
align-items: center;
gap: 0.5em;
cursor: pointer;
padding: 0.5em 1em;
background: var(--bg-chart);
border: 1px solid var(--border-color);
border-radius: 2em;
color: var(--text-secondary);
font-size: 0.9em;
transition: all 0.3s ease;
}
.theme-toggle:hover {
background: var(--border-color);
}
.theme-toggle svg {
width: 1.2em;
height: 1.2em;
fill: currentColor;
}
.theme-toggle .sun-icon { display: none; }
.theme-toggle .moon-icon { display: block; }
[data-theme="dark"] .theme-toggle .sun-icon { display: block; }
[data-theme="dark"] .theme-toggle .moon-icon { display: none; }
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5em;
margin: 2em 0;
}
.stat-card {
background: linear-gradient(135deg, var(--card-gradient-start), var(--card-gradient-end));
color: white;
padding: 1.5em;
border-radius: 0.5em;
text-align: center;
box-shadow: 0 4px 6px var(--shadow-color);
}
.stat-card h3 {
color: rgba(255,255,255,0.9);
font-size: 0.9em;
margin: 0 0 0.5em 0;
text-transform: uppercase;
letter-spacing: 1px;
}
.stat-card .value {
font-size: 2.5em;
font-weight: 600;
}
.stat-card .unit {
font-size: 0.8em;
opacity: 0.8;
}
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 2em;
margin: 2em 0;
}
.chart-container {
background: var(--bg-chart);
padding: 1.5em;
border-radius: 0.5em;
box-shadow: 0 2px 4px var(--shadow-color);
}
.chart-container h3 {
margin-top: 0;
color: var(--text-secondary);
}
.chart-wrapper {
position: relative;
height: 300px;
}
.full-width {
grid-column: 1 / -1;
}
.login-form, .logout-form {
margin: 1em 0;
}
.login-form input, .logout-form input {
padding: 0.5em 1em;
margin: 0.25em;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--input-bg);
color: var(--text-primary);
}
.login-form input[type="submit"], .logout-form input[type="submit"] {
background: hsl(198, 72%, 35%);
color: white;
border: none;
cursor: pointer;
}
.login-form input[type="submit"]:hover, .logout-form input[type="submit"]:hover {
background: hsl(198, 72%, 45%);
}
.nav-links {
text-align: center;
margin: 1em 0;
}
.nav-links a {
color: var(--link-color);
text-decoration: none;
margin: 0 1em;
}
.nav-links a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<script>
// Theme initialization - runs immediately to prevent flash
(function() {
const savedTheme = localStorage.getItem('analytics-theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = savedTheme || (prefersDark ? 'dark' : 'light');
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
function toggleTheme() {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const newTheme = isDark ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme === 'dark' ? 'dark' : '');
localStorage.setItem('analytics-theme', newTheme);
// Update Chart.js colors if charts exist
if (typeof Chart !== 'undefined') {
updateChartColors(newTheme);
}
}
function updateChartColors(theme) {
const textColor = theme === 'dark' ? '#a1a1aa' : '#666';
const gridColor = theme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
Chart.helpers.each(Chart.instances, function(chart) {
if (chart.options.scales) {
if (chart.options.scales.x) {
chart.options.scales.x.ticks = chart.options.scales.x.ticks || {};
chart.options.scales.x.ticks.color = textColor;
chart.options.scales.x.grid = chart.options.scales.x.grid || {};
chart.options.scales.x.grid.color = gridColor;
}
if (chart.options.scales.y) {
chart.options.scales.y.ticks = chart.options.scales.y.ticks || {};
chart.options.scales.y.ticks.color = textColor;
chart.options.scales.y.grid = chart.options.scales.y.grid || {};
chart.options.scales.y.grid.color = gridColor;
}
}
if (chart.options.plugins && chart.options.plugins.legend) {
chart.options.plugins.legend.labels = chart.options.plugins.legend.labels || {};
chart.options.plugins.legend.labels.color = textColor;
}
chart.update();
});
}
</script>
<div class="header-row">
<h1>LibreSpeed - Analytics</h1>
<button class="theme-toggle" onclick="toggleTheme()" aria-label="Toggle theme">
<svg class="sun-icon" viewBox="0 0 24 24"><path d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"/></svg>
<svg class="moon-icon" viewBox="0 0 24 24"><path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z"/></svg>
<span class="theme-label">Theme</span>
</button>
</div>
<?php
if (!isset($stats_password) || $stats_password === 'PASSWORD') {
?>
Please set $stats_password in telemetry_settings.php to enable access.
<?php
} elseif ($_SESSION['logged'] === true) {
if ($_GET['op'] === 'logout') {
$_SESSION['logged'] = false;
?><script type="text/javascript">window.location=location.protocol+"//"+location.host+location.pathname;</script><?php
} else {
$analytics = getAnalyticsData();
if ($analytics === false) {
echo '<p>Error loading analytics data. Please check database configuration.</p>';
} else {
?>
<div class="nav-links">
<a href="stats.php">View Test Records</a>
<a href="analytics.php">Analytics Dashboard</a>
</div>
<form action="analytics.php" method="GET" class="logout-form">
<input type="hidden" name="op" value="logout" />
<input type="submit" value="Logout" />
</form>
<div class="stats-grid">
<div class="stat-card">
<h3>Total Tests</h3>
<div class="value"><?= number_format($analytics['total_tests']) ?></div>
</div>
<div class="stat-card">
<h3>Avg Download</h3>
<div class="value"><?= $analytics['avg_download'] ?><span class="unit"> Mbps</span></div>
</div>
<div class="stat-card">
<h3>Avg Upload</h3>
<div class="value"><?= $analytics['avg_upload'] ?><span class="unit"> Mbps</span></div>
</div>
<div class="stat-card">
<h3>Avg Ping</h3>
<div class="value"><?= $analytics['avg_ping'] ?><span class="unit"> ms</span></div>
</div>
<div class="stat-card">
<h3>Max Download</h3>
<div class="value"><?= $analytics['max_download'] ?><span class="unit"> Mbps</span></div>
</div>
<div class="stat-card">
<h3>Max Upload</h3>
<div class="value"><?= $analytics['max_upload'] ?><span class="unit"> Mbps</span></div>
</div>
<div class="stat-card">
<h3>Best Ping</h3>
<div class="value"><?= $analytics['min_ping'] ?><span class="unit"> ms</span></div>
</div>
<div class="stat-card">
<h3>Avg Jitter</h3>
<div class="value"><?= $analytics['avg_jitter'] ?><span class="unit"> ms</span></div>
</div>
<div class="stat-card">
<h3>Countries</h3>
<div class="value"><?= $analytics['unique_countries'] ?></div>
</div>
</div>
<div class="charts-grid">
<div class="chart-container full-width">
<h3>Tests per Day (Last 30 Days)</h3>
<div class="chart-wrapper">
<canvas id="testsPerDayChart"></canvas>
</div>
</div>
<div class="chart-container full-width">
<h3>Recent Speed Trends</h3>
<div class="chart-wrapper">
<canvas id="speedTrendChart"></canvas>
</div>
</div>
<div class="chart-container">
<h3>Download Speed Distribution (Mbps)</h3>
<div class="chart-wrapper">
<canvas id="downloadDistChart"></canvas>
</div>
</div>
<div class="chart-container">
<h3>Upload Speed Distribution (Mbps)</h3>
<div class="chart-wrapper">
<canvas id="uploadDistChart"></canvas>
</div>
</div>
<div class="chart-container">
<h3>Ping Distribution (ms)</h3>
<div class="chart-wrapper">
<canvas id="pingDistChart"></canvas>
</div>
</div>
<div class="chart-container">
<h3>Browser Distribution</h3>
<div class="chart-wrapper">
<canvas id="browserChart"></canvas>
</div>
</div>
<div class="chart-container">
<h3>Top Countries</h3>
<div class="chart-wrapper">
<canvas id="countryChart"></canvas>
</div>
</div>
<div class="chart-container">
<h3>Top Cities</h3>
<div class="chart-wrapper">
<canvas id="cityChart"></canvas>
</div>
</div>
<div class="chart-container full-width">
<h3>Top ISPs</h3>
<div class="chart-wrapper">
<canvas id="ispChart"></canvas>
</div>
</div>
</div>
<script>
// Theme-aware color helper
function getThemeColors() {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
return {
text: isDark ? '#a1a1aa' : '#666',
grid: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'
};
}
const themeColors = getThemeColors();
// Color palette
const colors = {
primary: 'hsl(198, 72%, 35%)',
primaryLight: 'hsl(198, 72%, 55%)',
download: 'rgba(46, 204, 113, 0.8)',
upload: 'rgba(155, 89, 182, 0.8)',
ping: 'rgba(241, 196, 15, 0.8)',
chartColors: [
'rgba(52, 152, 219, 0.8)',
'rgba(46, 204, 113, 0.8)',
'rgba(155, 89, 182, 0.8)',
'rgba(241, 196, 15, 0.8)',
'rgba(231, 76, 60, 0.8)',
'rgba(149, 165, 166, 0.8)'
]
};
// Default chart options with theme colors
const defaultScaleOptions = {
ticks: { color: themeColors.text },
grid: { color: themeColors.grid }
};
const defaultLegendOptions = {
labels: { color: themeColors.text }
};
// Tests per day chart
const testsPerDayData = <?= json_encode($analytics['tests_by_day']) ?>;
new Chart(document.getElementById('testsPerDayChart'), {
type: 'bar',
data: {
labels: testsPerDayData.map(d => d.day),
datasets: [{
label: 'Tests',
data: testsPerDayData.map(d => d.count),
backgroundColor: colors.primary,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: defaultScaleOptions,
y: { beginAtZero: true, ...defaultScaleOptions }
}
}
});
// Speed trends chart
const recentSpeeds = <?= json_encode($analytics['recent_speeds']) ?>;
new Chart(document.getElementById('speedTrendChart'), {
type: 'line',
data: {
labels: recentSpeeds.map((d, i) => i + 1),
datasets: [{
label: 'Download (Mbps)',
data: recentSpeeds.map(d => parseFloat(d.dl) || 0),
borderColor: colors.download,
backgroundColor: 'transparent',
tension: 0.3
}, {
label: 'Upload (Mbps)',
data: recentSpeeds.map(d => parseFloat(d.ul) || 0),
borderColor: colors.upload,
backgroundColor: 'transparent',
tension: 0.3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top', labels: defaultLegendOptions.labels }
},
scales: {
x: defaultScaleOptions,
y: { beginAtZero: true, ...defaultScaleOptions }
}
}
});
// Download distribution chart
const downloadDist = <?= json_encode($analytics['download_distribution']) ?>;
new Chart(document.getElementById('downloadDistChart'), {
type: 'bar',
data: {
labels: Object.keys(downloadDist),
datasets: [{
label: 'Tests',
data: Object.values(downloadDist),
backgroundColor: colors.download,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: defaultScaleOptions,
y: { beginAtZero: true, ...defaultScaleOptions }
}
}
});
// Upload distribution chart
const uploadDist = <?= json_encode($analytics['upload_distribution']) ?>;
new Chart(document.getElementById('uploadDistChart'), {
type: 'bar',
data: {
labels: Object.keys(uploadDist),
datasets: [{
label: 'Tests',
data: Object.values(uploadDist),
backgroundColor: colors.upload,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: defaultScaleOptions,
y: { beginAtZero: true, ...defaultScaleOptions }
}
}
});
// Ping distribution chart
const pingDist = <?= json_encode($analytics['ping_distribution']) ?>;
new Chart(document.getElementById('pingDistChart'), {
type: 'bar',
data: {
labels: Object.keys(pingDist),
datasets: [{
label: 'Tests',
data: Object.values(pingDist),
backgroundColor: colors.ping,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: defaultScaleOptions,
y: { beginAtZero: true, ...defaultScaleOptions }
}
}
});
// Browser distribution chart
const browsers = <?= json_encode($analytics['browsers']) ?>;
new Chart(document.getElementById('browserChart'), {
type: 'doughnut',
data: {
labels: Object.keys(browsers),
datasets: [{
data: Object.values(browsers),
backgroundColor: colors.chartColors
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'right', labels: defaultLegendOptions.labels }
}
}
});
// Country distribution chart
const countries = <?= json_encode($analytics['countries']) ?>;
new Chart(document.getElementById('countryChart'), {
type: 'doughnut',
data: {
labels: Object.keys(countries),
datasets: [{
data: Object.values(countries),
backgroundColor: [
'rgba(52, 152, 219, 0.8)',
'rgba(46, 204, 113, 0.8)',
'rgba(155, 89, 182, 0.8)',
'rgba(241, 196, 15, 0.8)',
'rgba(231, 76, 60, 0.8)',
'rgba(26, 188, 156, 0.8)',
'rgba(230, 126, 34, 0.8)',
'rgba(149, 165, 166, 0.8)',
'rgba(52, 73, 94, 0.8)',
'rgba(127, 140, 141, 0.8)'
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'right', labels: defaultLegendOptions.labels }
}
}
});
// City distribution chart
const cities = <?= json_encode($analytics['cities']) ?>;
new Chart(document.getElementById('cityChart'), {
type: 'bar',
data: {
labels: Object.keys(cities),
datasets: [{
label: 'Tests',
data: Object.values(cities),
backgroundColor: 'rgba(26, 188, 156, 0.8)',
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: { display: false }
},
scales: {
x: { beginAtZero: true, ...defaultScaleOptions },
y: defaultScaleOptions
}
}
});
// ISP distribution chart
const isps = <?= json_encode($analytics['isps']) ?>;
new Chart(document.getElementById('ispChart'), {
type: 'bar',
data: {
labels: Object.keys(isps),
datasets: [{
label: 'Tests',
data: Object.values(isps),
backgroundColor: 'rgba(230, 126, 34, 0.8)',
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: { display: false }
},
scales: {
x: { beginAtZero: true, ...defaultScaleOptions },
y: defaultScaleOptions
}
}
});
</script>
<?php
}
}
} elseif ($_GET['op'] === 'login' && $_POST['password'] === $stats_password) {
$_SESSION['logged'] = true;
?><script type="text/javascript">window.location=location.protocol+"//"+location.host+location.pathname;</script><?php
} else {
?>
<form action="analytics.php?op=login" method="POST" class="login-form">
<h3>Login</h3>
<input type="password" name="password" placeholder="Password" value=""/>
<input type="submit" value="Login" />
</form>
<?php
}
?>
</body>
</html>