World map: - Fix zero-padded ISO numeric ids leaving 31 countries unrendered (Brazil, Australia, Belgium, Austria, Algeria and more) - Fix Russia and Fiji smearing across the full map width at the antimeridian by unwrapping ring longitudes and drawing them at both edges - Crop to 84N-60S, add evenodd fill rule, 174 countries rendered Improvements: - Logger::tail() reads backwards in chunks instead of loading the whole file - External links get rel=noopener noreferrer in footer and Markdown content - formatDisplayName() cleaned up and guarded against empty input - Export statistics as CSV (Excel BOM) or JSON - GeoIP database auto-updates when older than 35 days - Editor shortcuts Ctrl/Cmd+S to save and Ctrl/Cmd+N for a new page - Live search filter in the admin content browser - Content versioning with timestamped .bak copies in content/-backups/ Also removes eight stale TODO entries that were already implemented
379 lines
20 KiB
PHP
379 lines
20 KiB
PHP
<?php
|
|
$totals = $stats['totals'] ?? [];
|
|
$countries = $stats['countries'] ?? [];
|
|
$pages = $stats['pages'] ?? [];
|
|
$referrers = $stats['referrers'] ?? [];
|
|
$daily = $stats['daily_chart'] ?? [];
|
|
|
|
// Exclude the "UNKNOWN" bucket from the map scale
|
|
$mapCountries = $countries;
|
|
unset($mapCountries['UNKNOWN']);
|
|
$maxCountry = !empty($mapCountries) ? max($mapCountries) : 0;
|
|
$maxPage = !empty($pages) ? max($pages) : 0;
|
|
$maxDaily = 0;
|
|
foreach ($daily as $d) {
|
|
if (($d['views'] ?? 0) > $maxDaily) $maxDaily = $d['views'];
|
|
}
|
|
|
|
$periodLabels = [7 => 'Laatste 7 dagen', 30 => 'Laatste 30 dagen', 90 => 'Laatste 90 dagen', 0 => 'Alles'];
|
|
?>
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
|
<h2 class="mb-0"><i class="bi bi-bar-chart"></i> Statistieken</h2>
|
|
<div class="d-flex gap-2 flex-wrap">
|
|
<div class="btn-group">
|
|
<?php foreach ($periodLabels as $p => $label): ?>
|
|
<a href="/admin/statistics?period=<?= $p ?>" class="btn btn-sm <?= $period === $p ? 'btn-primary' : 'btn-outline-secondary' ?>"><?= htmlspecialchars($label) ?></a>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<div class="btn-group">
|
|
<a href="/admin/statistics?period=<?= $period ?>&export=csv" class="btn btn-sm btn-outline-success" title="Exporteer als CSV">
|
|
<i class="bi bi-filetype-csv"></i> CSV
|
|
</a>
|
|
<a href="/admin/statistics?period=<?= $period ?>&export=json" class="btn btn-sm btn-outline-success" title="Exporteer als JSON">
|
|
<i class="bi bi-filetype-json"></i> JSON
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- KPI cards -->
|
|
<div class="row g-3 mb-4">
|
|
<div class="col-md-3 col-6">
|
|
<div class="card stat-card shadow-sm h-100">
|
|
<div class="card-body d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<h6 class="text-muted mb-1">Paginaweergaven</h6>
|
|
<h3 class="mb-0"><?= number_format((int)($totals['views'] ?? 0), 0, ',', '.') ?></h3>
|
|
</div>
|
|
<i class="bi bi-eye stat-icon text-primary"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3 col-6">
|
|
<div class="card stat-card shadow-sm h-100">
|
|
<div class="card-body d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<h6 class="text-muted mb-1">Unieke bezoekers</h6>
|
|
<h3 class="mb-0"><?= number_format((int)($totals['uniques'] ?? 0), 0, ',', '.') ?></h3>
|
|
</div>
|
|
<i class="bi bi-people stat-icon text-success"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3 col-6">
|
|
<div class="card stat-card shadow-sm h-100">
|
|
<div class="card-body d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<h6 class="text-muted mb-1">Mens / Bot</h6>
|
|
<h3 class="mb-0">
|
|
<span class="text-success"><?= number_format((int)($totals['human'] ?? 0), 0, ',', '.') ?></span>
|
|
<small class="text-muted">/</small>
|
|
<span class="text-secondary"><?= number_format((int)($totals['bot'] ?? 0), 0, ',', '.') ?></span>
|
|
</h3>
|
|
</div>
|
|
<i class="bi bi-person-check stat-icon text-info"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3 col-6">
|
|
<div class="card stat-card shadow-sm h-100">
|
|
<div class="card-body d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<h6 class="text-muted mb-1">Geblokkeerd</h6>
|
|
<h3 class="mb-0 text-danger"><?= number_format((int)($totals['blocked'] ?? 0), 0, ',', '.') ?></h3>
|
|
</div>
|
|
<i class="bi bi-shield-x stat-icon text-danger"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- World map -->
|
|
<div class="card shadow-sm mb-4">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span><i class="bi bi-globe-europe-africa"></i> Bezoekers per land</span>
|
|
<?php if ($maxCountry > 0): ?>
|
|
<small class="text-muted d-flex align-items-center gap-1">
|
|
Minder
|
|
<span style="display:inline-block;width:18px;height:12px;background:#cfe2ff;border:1px solid #dee2e6;"></span>
|
|
<span style="display:inline-block;width:18px;height:12px;background:#6ea8fe;"></span>
|
|
<span style="display:inline-block;width:18px;height:12px;background:#0d6efd;"></span>
|
|
<span style="display:inline-block;width:18px;height:12px;background:#052c65;"></span>
|
|
Meer
|
|
</small>
|
|
<?php endif; ?>
|
|
</div>
|
|
<div class="card-body">
|
|
<?php if ($worldMapSvg === ''): ?>
|
|
<div class="alert alert-warning mb-0">
|
|
<i class="bi bi-exclamation-triangle"></i> Wereldkaart niet gevonden. Genereer deze met:
|
|
<code>php cli/generate-world-map.php</code>
|
|
</div>
|
|
<?php else: ?>
|
|
<style>
|
|
<?php foreach ($mapCountries as $cc => $count):
|
|
if (!preg_match('/^[A-Z]{2}$/', $cc) || $maxCountry <= 0) continue;
|
|
$ratio = $count / $maxCountry;
|
|
if ($ratio > 0.66) $fill = '#052c65';
|
|
elseif ($ratio > 0.33) $fill = '#0d6efd';
|
|
elseif ($ratio > 0.1) $fill = '#6ea8fe';
|
|
else $fill = '#cfe2ff';
|
|
?>
|
|
#<?= $cc ?> { fill: <?= $fill ?>; }
|
|
<?php endforeach; ?>
|
|
</style>
|
|
<div class="world-map-wrapper position-relative">
|
|
<?= $worldMapSvg ?>
|
|
<div id="mapTooltip" class="position-absolute bg-dark text-white px-2 py-1 rounded small" style="display:none;pointer-events:none;z-index:10;"></div>
|
|
</div>
|
|
<script>
|
|
(function () {
|
|
var counts = <?= json_encode($mapCountries) ?>;
|
|
var wrapper = document.querySelector('.world-map-wrapper');
|
|
var tooltip = document.getElementById('mapTooltip');
|
|
if (!wrapper || !tooltip) return;
|
|
wrapper.querySelectorAll('path.country').forEach(function (p) {
|
|
p.addEventListener('mousemove', function (e) {
|
|
var code = p.getAttribute('id');
|
|
var name = p.getAttribute('data-name') || code;
|
|
var n = counts[code] || 0;
|
|
tooltip.textContent = name + ': ' + n + ' weergave' + (n === 1 ? '' : 'n');
|
|
tooltip.style.display = 'block';
|
|
var r = wrapper.getBoundingClientRect();
|
|
tooltip.style.left = (e.clientX - r.left + 12) + 'px';
|
|
tooltip.style.top = (e.clientY - r.top + 12) + 'px';
|
|
});
|
|
p.addEventListener('mouseleave', function () {
|
|
tooltip.style.display = 'none';
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
<?php endif; ?>
|
|
</div>
|
|
<?php if ($geoMeta): ?>
|
|
<div class="card-footer text-muted small">
|
|
<?= htmlspecialchars($geoMeta['attribution'] ?? 'IP geolocation by DB-IP') ?> ·
|
|
Database bijgewerkt op <?= htmlspecialchars($geoMeta['updated'] ?? '-') ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
</div>
|
|
|
|
<div class="row g-4 mb-4">
|
|
<!-- Countries list -->
|
|
<div class="col-lg-6">
|
|
<div class="card shadow-sm h-100">
|
|
<div class="card-header"><i class="bi bi-flag"></i> Landen</div>
|
|
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
|
|
<?php if (empty($countries)): ?>
|
|
<p class="text-muted mb-0">Nog geen gegevens beschikbaar.</p>
|
|
<?php else: ?>
|
|
<?php $totalCountryViews = array_sum($countries); ?>
|
|
<?php foreach (array_slice($countries, 0, 25, true) as $cc => $count): ?>
|
|
<?php $pct = $totalCountryViews > 0 ? round(($count / $totalCountryViews) * 100, 1) : 0; ?>
|
|
<div class="mb-2">
|
|
<div class="d-flex justify-content-between small">
|
|
<span>
|
|
<?= GeoIP::getCountryFlagEmoji($cc === 'UNKNOWN' ? null : $cc) ?>
|
|
<?= htmlspecialchars(GeoIP::getCountryName($cc === 'UNKNOWN' ? null : $cc)) ?>
|
|
</span>
|
|
<span class="text-muted"><?= number_format($count, 0, ',', '.') ?> (<?= $pct ?>%)</span>
|
|
</div>
|
|
<div class="progress" style="height: 6px;">
|
|
<div class="progress-bar" style="width: <?= $pct ?>%"></div>
|
|
</div>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Top pages -->
|
|
<div class="col-lg-6">
|
|
<div class="card shadow-sm h-100">
|
|
<div class="card-header"><i class="bi bi-file-earmark-text"></i> Meest gelezen pagina's</div>
|
|
<div class="card-body" style="max-height: 400px; overflow-y: auto;">
|
|
<?php if (empty($pages)): ?>
|
|
<p class="text-muted mb-0">Nog geen gegevens beschikbaar.</p>
|
|
<?php else: ?>
|
|
<?php foreach (array_slice($pages, 0, 25, true) as $pageName => $count): ?>
|
|
<?php $pct = $maxPage > 0 ? round(($count / $maxPage) * 100, 1) : 0; ?>
|
|
<div class="mb-2">
|
|
<div class="d-flex justify-content-between small">
|
|
<span class="text-truncate" style="max-width: 70%;" title="<?= htmlspecialchars($pageName) ?>">
|
|
<?= htmlspecialchars($pageName) ?>
|
|
</span>
|
|
<span class="text-muted"><?= number_format($count, 0, ',', '.') ?></span>
|
|
</div>
|
|
<div class="progress" style="height: 6px;">
|
|
<div class="progress-bar bg-success" style="width: <?= $pct ?>%"></div>
|
|
</div>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-4 mb-4">
|
|
<!-- Daily chart -->
|
|
<div class="col-lg-8">
|
|
<div class="card shadow-sm h-100">
|
|
<div class="card-header"><i class="bi bi-graph-up"></i> Bezoekers per dag</div>
|
|
<div class="card-body">
|
|
<?php if (empty($daily) || $maxDaily === 0): ?>
|
|
<p class="text-muted mb-0">Nog geen gegevens beschikbaar.</p>
|
|
<?php else: ?>
|
|
<?php
|
|
$chartW = 800;
|
|
$chartH = 200;
|
|
$count = count($daily);
|
|
$barW = $count > 0 ? ($chartW / $count) : 10;
|
|
?>
|
|
<svg viewBox="0 0 <?= $chartW ?> <?= $chartH + 25 ?>" width="100%" height="auto">
|
|
<?php foreach (array_values($daily) as $i => $d): ?>
|
|
<?php
|
|
$v = (int)($d['views'] ?? 0);
|
|
$h = $maxDaily > 0 ? ($v / $maxDaily) * $chartH : 0;
|
|
$x = $i * $barW;
|
|
$y = $chartH - $h;
|
|
?>
|
|
<rect x="<?= round($x + 1, 2) ?>" y="<?= round($y, 2) ?>"
|
|
width="<?= round(max($barW - 2, 1), 2) ?>" height="<?= round($h, 2) ?>"
|
|
fill="#0d6efd" rx="1">
|
|
<title><?= htmlspecialchars($d['date']) ?>: <?= $v ?> weergaven, <?= (int)($d['uniques'] ?? 0) ?> unieke bezoekers</title>
|
|
</rect>
|
|
<?php endforeach; ?>
|
|
<line x1="0" y1="<?= $chartH ?>" x2="<?= $chartW ?>" y2="<?= $chartH ?>" stroke="#dee2e6" stroke-width="1"/>
|
|
<?php $firstDay = reset($daily); $lastDay = end($daily); ?>
|
|
<text x="0" y="<?= $chartH + 18 ?>" font-size="12" fill="#6c757d"><?= htmlspecialchars($firstDay['date'] ?? '') ?></text>
|
|
<text x="<?= $chartW ?>" y="<?= $chartH + 18 ?>" font-size="12" fill="#6c757d" text-anchor="end"><?= htmlspecialchars($lastDay['date'] ?? '') ?></text>
|
|
</svg>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Referrers -->
|
|
<div class="col-lg-4">
|
|
<div class="card shadow-sm h-100">
|
|
<div class="card-header"><i class="bi bi-link-45deg"></i> Verwijzende sites</div>
|
|
<div class="card-body" style="max-height: 300px; overflow-y: auto;">
|
|
<?php if (empty($referrers)): ?>
|
|
<p class="text-muted mb-0">Geen verwijzingen geregistreerd.</p>
|
|
<?php else: ?>
|
|
<ul class="list-unstyled mb-0">
|
|
<?php foreach (array_slice($referrers, 0, 15, true) as $host => $count): ?>
|
|
<li class="d-flex justify-content-between border-bottom py-1 small">
|
|
<span class="text-truncate" style="max-width: 70%;"><?= htmlspecialchars($host) ?></span>
|
|
<span class="text-muted"><?= number_format($count, 0, ',', '.') ?></span>
|
|
</li>
|
|
<?php endforeach; ?>
|
|
</ul>
|
|
<?php endif; ?>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- GeoIP & privacy settings -->
|
|
<div class="card shadow-sm mb-4">
|
|
<div class="card-header"><i class="bi bi-geo-alt"></i> GeoIP database & privacy</div>
|
|
<div class="card-body">
|
|
<div class="row mb-3">
|
|
<div class="col-md-8">
|
|
<?php if ($geoMeta): ?>
|
|
<p class="mb-1">
|
|
<span class="badge bg-success"><i class="bi bi-check-circle"></i> Database aanwezig</span>
|
|
<span class="text-muted small ms-2">
|
|
<?= number_format((int)($geoMeta['ipv4_records'] ?? 0), 0, ',', '.') ?> IPv4 ·
|
|
<?= number_format((int)($geoMeta['ipv6_records'] ?? 0), 0, ',', '.') ?> IPv6 ·
|
|
bijgewerkt <?= htmlspecialchars($geoMeta['updated'] ?? '-') ?>
|
|
</span>
|
|
</p>
|
|
<?php else: ?>
|
|
<p class="mb-1"><span class="badge bg-warning text-dark"><i class="bi bi-exclamation-triangle"></i> Nog geen lokale database</span></p>
|
|
<?php endif; ?>
|
|
</div>
|
|
<div class="col-md-4 text-md-end">
|
|
<form method="POST" action="/admin/statistics" class="d-inline">
|
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
|
<input type="hidden" name="action" value="update_geoip">
|
|
<button type="submit" class="btn btn-outline-primary btn-sm">
|
|
<i class="bi bi-cloud-download"></i> GeoIP database bijwerken
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<hr>
|
|
|
|
<form method="POST" action="/admin/statistics">
|
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
|
|
|
<div class="form-check form-switch mb-3">
|
|
<input class="form-check-input" type="checkbox" id="analytics_enabled" name="analytics_enabled" value="1" <?= !empty($ana['enabled']) ? 'checked' : '' ?>>
|
|
<label class="form-check-label fw-bold" for="analytics_enabled">Statistieken bijhouden</label>
|
|
</div>
|
|
|
|
<div class="form-check form-switch mb-3">
|
|
<input class="form-check-input" type="checkbox" id="anonymize_ip" name="anonymize_ip" value="1" <?= !empty($ana['anonymize_ip']) ? 'checked' : '' ?>>
|
|
<label class="form-check-label fw-bold" for="anonymize_ip">IP-adressen anonimiseren</label>
|
|
<div class="form-text">Maskeert het laatste deel van het IP (82.169.10.x). Let op: de IP-blocklist wordt hierdoor minder bruikbaar.</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-md-4 mb-3">
|
|
<label for="geoip_provider" class="form-label fw-bold">GeoIP bron</label>
|
|
<select name="geoip_provider" id="geoip_provider" class="form-select">
|
|
<option value="local" <?= ($ana['geoip_provider'] ?? 'local') === 'local' ? 'selected' : '' ?>>Lokaal (DB-IP Lite)</option>
|
|
<option value="mmdb" <?= ($ana['geoip_provider'] ?? '') === 'mmdb' ? 'selected' : '' ?>>MaxMind database (.mmdb)</option>
|
|
<option value="api" <?= ($ana['geoip_provider'] ?? '') === 'api' ? 'selected' : '' ?>>Externe API</option>
|
|
</select>
|
|
<div class="form-text">Valt automatisch terug op de lokale database.</div>
|
|
</div>
|
|
<div class="col-md-8 mb-3">
|
|
<label for="geoip_mmdb_path" class="form-label fw-bold">Pad naar .mmdb bestand</label>
|
|
<input type="text" class="form-control font-monospace" id="geoip_mmdb_path" name="geoip_mmdb_path"
|
|
value="<?= htmlspecialchars($ana['geoip_mmdb_path'] ?? '') ?>" placeholder="/var/lib/GeoIP/GeoLite2-Country.mmdb">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-md-8 mb-3">
|
|
<label for="geoip_api_url" class="form-label fw-bold">API URL</label>
|
|
<input type="text" class="form-control font-monospace" id="geoip_api_url" name="geoip_api_url"
|
|
value="<?= htmlspecialchars($ana['geoip_api_url'] ?? '') ?>" placeholder="http://ip-api.com/json/{ip}?fields=countryCode">
|
|
<div class="form-text"><code>{ip}</code> wordt vervangen door het IP-adres van de bezoeker.</div>
|
|
</div>
|
|
<div class="col-md-4 mb-3">
|
|
<label for="geoip_api_key" class="form-label fw-bold">API sleutel</label>
|
|
<input type="text" class="form-control" id="geoip_api_key" name="geoip_api_key"
|
|
value="<?= htmlspecialchars($ana['geoip_api_key'] ?? '') ?>">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-md-4 mb-3">
|
|
<label for="retention_days" class="form-label fw-bold">Bewaartermijn (dagen)</label>
|
|
<input type="number" class="form-control" id="retention_days" name="retention_days" min="30" max="3650"
|
|
value="<?= (int)($ana['retention_days'] ?? 400) ?>">
|
|
</div>
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-primary"><i class="bi bi-check-lg"></i> Instellingen opslaan</button>
|
|
</form>
|
|
</div>
|
|
<div class="card-footer d-flex justify-content-between align-items-center">
|
|
<small class="text-muted">IP geolocation by DB-IP (https://db-ip.com) · CC BY 4.0</small>
|
|
<form method="POST" action="/admin/statistics" onsubmit="return confirm('Weet je zeker dat je ALLE statistieken wilt wissen?')">
|
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
|
|
<input type="hidden" name="action" value="reset_stats">
|
|
<button type="submit" class="btn btn-outline-danger btn-sm"><i class="bi bi-trash"></i> Statistieken wissen</button>
|
|
</form>
|
|
</div>
|
|
</div>
|