diff --git a/app/Data/Traffic/TrafficBreakdownData.php b/app/Data/Traffic/TrafficBreakdownData.php index a98e5323a4..47114f1362 100644 --- a/app/Data/Traffic/TrafficBreakdownData.php +++ b/app/Data/Traffic/TrafficBreakdownData.php @@ -10,14 +10,23 @@ class TrafficBreakdownData extends Data public string $value, public int $requests, public int $bytesOut, + public ?float $lat = null, + public ?float $lon = null, ) {} public static function fromSentinel(array $row): self { + $lat = data_get($row, 'lat'); + $lon = data_get($row, 'lon'); + $hasCoordinates = is_numeric($lat) && is_numeric($lon) + && abs((float) $lat) <= 90 && abs((float) $lon) <= 180; + return new self( value: (string) data_get($row, 'value', ''), requests: (int) data_get($row, 'requests', 0), bytesOut: (int) data_get($row, 'bytes_out', 0), + lat: $hasCoordinates ? (float) $lat : null, + lon: $hasCoordinates ? (float) $lon : null, ); } } diff --git a/app/Enums/TrafficIpMode.php b/app/Enums/TrafficIpMode.php index 6f2b09d89a..4b9f5f48d1 100644 --- a/app/Enums/TrafficIpMode.php +++ b/app/Enums/TrafficIpMode.php @@ -44,7 +44,8 @@ enum TrafficIpMode: string } /** - * Drop the `ip` dimension when Sentinel does not record it. + * Drop the `ip` and `city` dimensions when Sentinel does not record them + * (Sentinel finds the city from the client IP, so `off` stores no cities). * * @param array $dimensions * @return array @@ -55,7 +56,7 @@ enum TrafficIpMode: string return $dimensions; } - return array_values(array_filter($dimensions, fn (string $dimension): bool => $dimension !== 'ip')); + return array_values(array_filter($dimensions, fn (string $dimension): bool => ! in_array($dimension, ['ip', 'city'], true))); } public function breakdownLabel(): string diff --git a/app/Livewire/Analytics.php b/app/Livewire/Analytics.php index e4400f58ec..007618501c 100644 --- a/app/Livewire/Analytics.php +++ b/app/Livewire/Analytics.php @@ -109,7 +109,7 @@ class Analytics extends Component private const MAX_LEADERBOARD_APPS = 200; /** @var array */ - protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent']; + protected array $breakdownDimensions = ['country', 'city', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent']; /** * Per-request cache of Sentinel key => display metadata, so resolving a name/domain/link diff --git a/app/Livewire/Concerns/BuildsTrafficChartPayload.php b/app/Livewire/Concerns/BuildsTrafficChartPayload.php index eb57c855fb..d1c16672be 100644 --- a/app/Livewire/Concerns/BuildsTrafficChartPayload.php +++ b/app/Livewire/Concerns/BuildsTrafficChartPayload.php @@ -4,7 +4,7 @@ namespace App\Livewire\Concerns; /** * Shared derivations for the traffic chart payload: per-bucket sparkline series, - * the device-donut labels/series, and the globe's per-country marker data. Consumed + * the device-donut labels/series, and the globe's marker data. Consumed * by both the global and per-application analytics components, which expose * `$series` (status buckets) and `$breakdowns` (dimension rows). */ @@ -77,22 +77,13 @@ trait BuildsTrafficChartPayload } /** - * Per-country marker data for the globe: [{code, requests}] over known ISO-A2 rows. + * Marker data for the globe: country markers and city markers, one set for each view. * - * @return array + * @return array{countries: array, cities: array} */ protected function geoMarkers(): array { - $out = []; - foreach (($this->breakdowns['country'] ?? []) as $row) { - $code = strtoupper((string) ($row['value'] ?? '')); - $requests = (int) ($row['requests'] ?? 0); - if (preg_match('/^[A-Z]{2}$/', $code) && $requests > 0) { - $out[] = ['code' => $code, 'requests' => $requests]; - } - } - - return $out; + return trafficGeoMarkers($this->breakdowns['country'] ?? [], trafficCityRows($this->breakdowns['city'] ?? [])); } /** diff --git a/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php b/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php index d936333a92..02f1718ede 100644 --- a/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php +++ b/app/Livewire/Project/Shared/ResourceTrafficAnalytics.php @@ -65,7 +65,7 @@ abstract class ResourceTrafficAnalytics extends Component public array $series = []; /** @var array */ - protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent']; + protected array $breakdownDimensions = ['country', 'city', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent']; /** * Name of the public model property and lazy-load parameter ('application' / 'service'). diff --git a/app/Services/SentinelTrafficClient.php b/app/Services/SentinelTrafficClient.php index 73c2b70556..1c5c28e2d8 100644 --- a/app/Services/SentinelTrafficClient.php +++ b/app/Services/SentinelTrafficClient.php @@ -48,7 +48,7 @@ class SentinelTrafficClient /** @var array */ private const ALLOWED_DIMENSIONS = [ - 'status', 'method', 'country', 'referer', 'browser', 'os', 'device', 'protocol', 'scheme', 'tls', 'cache', 'bot', 'agent', 'ip', 'useragent', + 'status', 'method', 'country', 'city', 'referer', 'browser', 'os', 'device', 'protocol', 'scheme', 'tls', 'cache', 'bot', 'agent', 'ip', 'useragent', ]; public function __construct(protected Server $server) {} diff --git a/app/Services/TrafficAnalyticsAggregator.php b/app/Services/TrafficAnalyticsAggregator.php index e58a78bc3b..66ef76432e 100644 --- a/app/Services/TrafficAnalyticsAggregator.php +++ b/app/Services/TrafficAnalyticsAggregator.php @@ -161,6 +161,12 @@ class TrafficAnalyticsAggregator $this->breakdowns[$dimension][$value] ??= ['value' => $value, 'requests' => 0, 'bytesOut' => 0]; $this->breakdowns[$dimension][$value]['requests'] += (int) ($data['requests'] ?? 0); $this->breakdowns[$dimension][$value]['bytesOut'] += (int) ($data['bytesOut'] ?? 0); + + // City rows carry coordinates; the first server that has them wins. + if (isset($data['lat'], $data['lon'])) { + $this->breakdowns[$dimension][$value]['lat'] ??= (float) $data['lat']; + $this->breakdowns[$dimension][$value]['lon'] ??= (float) $data['lon']; + } } } @@ -205,7 +211,7 @@ class TrafficAnalyticsAggregator } /** - * @return array> + * @return array> */ public function breakdowns(int $limit = 50): array { diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 0d36774e6e..537b7a4d59 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -5742,6 +5742,115 @@ function deviceLabel(?string $device): string }; } +/** + * Prepare aggregated `city` breakdown rows (`CC|subdivision|City`, from + * TrafficAnalyticsAggregator::breakdowns(), not raw Sentinel rows) for display, + * largest first. `name` and `detail` are the two parts of `label` ("Berlin" and + * "BE, Germany"). `CC||` (sent by older Sentinel builds) is an unknown city of + * that country. `__other__` and malformed values are merged into one "Other" + * row, which stays last. Returns [] when no row has a real city. + * + * @param iterable $rows + * @return array + */ +function trafficCityRows(iterable $rows): array +{ + $out = []; + $hasCity = false; + + foreach ($rows as $row) { + $requests = (int) data_get($row, 'requests', 0); + if ($requests <= 0) { + continue; + } + + $value = (string) data_get($row, 'value', ''); + $parts = explode('|', $value, 3); + $parts[0] = strtoupper($parts[0]); + $isCityKey = count($parts) === 3 && preg_match('/^[A-Z]{2}$/', $parts[0]) === 1; + + $key = '__other__'; + $country = ''; + $label = 'Other'; + $name = 'Other'; + $detail = ''; + $lat = null; + $lon = null; + + if ($isCityKey) { + [$country, $subdivision, $city] = array_map('trim', $parts); + $key = $value; + + if ($city === '') { + $label = 'Unknown, '.$country; + $name = 'Unknown'; + $detail = countryName($country); + } else { + $hasCity = true; + $region = preg_match('/^[A-Z]{2,3}$/', $subdivision) === 1 ? $subdivision.', ' : ''; + $label = $city.', '.$region.$country; + $name = $city; + $detail = $region.countryName($country); + + $rawLat = data_get($row, 'lat'); + $rawLon = data_get($row, 'lon'); + if (is_numeric($rawLat) && is_numeric($rawLon)) { + $lat = (float) $rawLat; + $lon = (float) $rawLon; + } + } + } + + $out[$key] ??= ['key' => $key, 'country' => $country, 'label' => $label, 'name' => $name, 'detail' => $detail, 'requests' => 0, 'bytesOut' => 0, 'lat' => $lat, 'lon' => $lon]; + $out[$key]['requests'] += $requests; + $out[$key]['bytesOut'] += (int) data_get($row, 'bytesOut', 0); + } + + if (! $hasCity) { + return []; + } + + $other = $out['__other__'] ?? null; + unset($out['__other__']); + $out = array_values($out); + usort($out, fn (array $a, array $b): int => $b['requests'] <=> $a['requests']); + + return $other === null ? $out : [...$out, $other]; +} + +/** + * Marker data for the analytics globe, one set for each view of the Locations + * card: a marker per country with a valid ISO-A2 code, and a marker per city + * that has coordinates. + * + * @param iterable $countryRows + * @param iterable $cityRows + * @return array{countries: array, cities: array} + */ +function trafficGeoMarkers(iterable $countryRows, iterable $cityRows): array +{ + $countries = []; + foreach ($countryRows as $row) { + $code = strtoupper((string) data_get($row, 'value', '')); + $requests = (int) data_get($row, 'requests', 0); + if ($requests > 0 && preg_match('/^[A-Z]{2}$/', $code) === 1) { + $countries[] = ['code' => $code, 'requests' => $requests]; + } + } + + $cities = []; + foreach ($cityRows as $row) { + $requests = (int) data_get($row, 'requests', 0); + $lat = data_get($row, 'lat'); + $lon = data_get($row, 'lon'); + if ($requests > 0 && is_numeric($lat) && is_numeric($lon)) { + $cities[] = ['lat' => (float) $lat, 'lon' => (float) $lon, 'requests' => $requests]; + } + } + + return ['countries' => $countries, 'cities' => $cities]; +} + /** * Resolve an ISO 3166-1 alpha-2 country code to its English country name. * diff --git a/resources/js/app.js b/resources/js/app.js index af249856ad..c9adb0f307 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -3,6 +3,7 @@ import { initializeRuntimeLogsComponent } from './runtime-logs.js'; import { initializeSettingsSidebarAccordionComponent } from './settings-sidebar-accordion.js'; import { initializeTerminalComponent } from './terminal.js'; import './traffic-globe.js'; +import './traffic-table.js'; import { registerLivewireRequestFailureHandler } from './livewire-request-failure.js'; import { registerLivewireUpdateDedupe } from './livewire-update-dedupe.js'; import { parseSubmitAction } from './modal-confirmation.js'; diff --git a/resources/js/traffic-globe.js b/resources/js/traffic-globe.js index 49f52ac9ac..ca8fd6a175 100644 --- a/resources/js/traffic-globe.js +++ b/resources/js/traffic-globe.js @@ -24,16 +24,26 @@ const THEMES = { }; /** - * Turn country-breakdown rows into cobe markers. Marker size scales with the - * square root of request volume so a single dominant country doesn't dwarf the rest. + * Turn marker rows into cobe markers. A row is placed at its own `lat`/`lon` + * (a city) or at the centroid of its ISO-A2 `code` (a country). Marker size + * scales with the square root of request volume so one dominant place doesn't + * dwarf the rest. City markers are smaller, because cities are close together. * - * @param {Array<{code: string, requests: number}>} data + * @param {Array<{code?: string, lat?: number, lon?: number, requests: number}>} data * @returns {Array<{location: [number, number], size: number}>} */ function buildMarkers(data) { const rows = (data || []) - .map((r) => ({ code: String(r.code || '').toUpperCase(), requests: Number(r.requests || 0) })) - .filter((r) => r.requests > 0 && CENTROIDS[r.code]); + .map((r) => { + const city = Number.isFinite(r.lat) && Number.isFinite(r.lon); + + return { + city, + location: city ? [r.lat, r.lon] : CENTROIDS[String(r.code || '').toUpperCase()], + requests: Number(r.requests || 0), + }; + }) + .filter((r) => r.requests > 0 && r.location); if (rows.length === 0) { return []; @@ -41,14 +51,29 @@ function buildMarkers(data) { const max = Math.max(...rows.map((r) => r.requests)); - return rows.map((r) => ({ - location: CENTROIDS[r.code], - size: Math.max(0.03, Math.min(0.11, Math.sqrt(r.requests / max) * 0.11)), - })); + return rows.map((r) => { + const [min, cap] = r.city ? [0.02, 0.07] : [0.03, 0.11]; + + return { + location: r.location, + size: Math.max(min, Math.min(cap, Math.sqrt(r.requests / max) * cap)), + }; + }); } const TWO_PI = Math.PI * 2; +const SPIN_SPEED = 0.15; // rad/s +// Ambient spin is drawn at 30fps. A little under two 60Hz frames, so timestamp +// jitter does not skip to the third frame. +const SPIN_FRAME_MS = 1000 / 30 - 4; +const SETTLE_EPSILON = 0.0005; + +// Signed shortest angular distance from `current` to `target`. +function angleDelta(current, target) { + return ((target - current + Math.PI) % TWO_PI + TWO_PI) % TWO_PI - Math.PI; +} + // cobe orientation for a lat/lng so the point faces the viewer (cobe's own // focus example formula). Returns [phi, theta]. function locationToAngles(lat, lng) { @@ -58,23 +83,21 @@ function locationToAngles(lat, lng) { // Shortest-path angular interpolation, so easing across the 0/2π seam never // spins the long way around. function lerpAngle(current, target, t) { - let delta = ((target - current + Math.PI) % TWO_PI + TWO_PI) % TWO_PI - Math.PI; - - return current + delta * t; + return current + angleDelta(current, target) * t; } /** * Mount an interactive, drag-to-rotate dotted globe onto a canvas. Returns a - * controller with `update(data, dark)`, `focus(code)`, `resume()` and + * controller with `update(data, dark)`, `focus(target)`, `resume()` and * `destroy()`. The globe auto-rotates, pauses while grabbed or while a country * is hover-focused, and eases smoothly toward whatever it's pointed at. * * cobe v2 has no internal render loop or `onRender` callback: createGlobe draws * a single frame and returns `{ update, destroy }`. We drive our own rAF loop, - * calling `globe.update({...})` each frame for rotation and to swap markers/theme. + * which draws only while something moves and stops when the canvas is off-screen. * * @param {HTMLCanvasElement} canvas - * @param {Array<{code: string, requests: number}>} data + * @param {Array<{code?: string, lat?: number, lon?: number, requests: number}>} data * @param {boolean} dark */ function mountTrafficGlobe(canvas, data, dark) { @@ -82,6 +105,9 @@ function mountTrafficGlobe(canvas, data, dark) { let width = 0; let destroyed = false; let rafId = 0; + let lastDraw = 0; + let dirty = true; + let visible = true; let markers = buildMarkers(data); // Ambient spin is decorative; honor reduced-motion by not auto-rotating @@ -103,6 +129,7 @@ function mountTrafficGlobe(canvas, data, dark) { dragStartPhi = targetPhi; autoRotate = false; canvas.style.cursor = 'grabbing'; + wake(); }; const onPointerUp = () => { if (dragging === null) { @@ -111,10 +138,12 @@ function mountTrafficGlobe(canvas, data, dark) { dragging = null; autoRotate = ! prefersReduced; canvas.style.cursor = 'grab'; + wake(); }; const onPointerMove = (e) => { if (dragging !== null) { targetPhi = dragStartPhi + (e.clientX - dragging) / 150; + wake(); } }; @@ -127,7 +156,7 @@ function mountTrafficGlobe(canvas, data, dark) { const theme = isDark ? THEMES.dark : THEMES.light; globe = createGlobe(canvas, { - devicePixelRatio: 2, + devicePixelRatio: Math.min(window.devicePixelRatio || 1, 2), width: width, height: width, phi: currentPhi, @@ -144,20 +173,45 @@ function mountTrafficGlobe(canvas, data, dark) { }); }; - // Self-driven animation loop (cobe v2 draws only when we call update()). - const tick = () => { - if (destroyed) { + const schedule = () => { + if (! rafId && ! destroyed && globe && visible) { + rafId = requestAnimationFrame(tick); + } + }; + + // Request a draw; a no-op while one is already queued. + function wake() { + dirty = true; + schedule(); + } + + // Draws only while the globe spins or eases toward a target, then goes idle. + const tick = (now) => { + rafId = 0; + if (destroyed || ! globe || width === 0) { return; } - if (globe && width > 0) { - if (autoRotate && dragging === null) { - targetPhi += 0.0025; - } - currentPhi = lerpAngle(currentPhi, targetPhi, 0.12); - currentTheta += (targetTheta - currentTheta) * 0.12; - globe.update({ phi: currentPhi, theta: currentTheta, width: width, height: width, markers }); + const spinning = autoRotate && dragging === null; + const elapsed = now - lastDraw; + if (spinning && ! dirty && elapsed < SPIN_FRAME_MS) { + schedule(); + + return; + } + if (spinning) { + targetPhi += (SPIN_SPEED * Math.min(elapsed, 100)) / 1000; + } + currentPhi = lerpAngle(currentPhi, targetPhi, 0.12); + currentTheta += (targetTheta - currentTheta) * 0.12; + globe.update({ phi: currentPhi, theta: currentTheta, width: width, height: width, markers }); + lastDraw = now; + dirty = false; + + const settled = Math.abs(angleDelta(currentPhi, targetPhi)) < SETTLE_EPSILON + && Math.abs(targetTheta - currentTheta) < SETTLE_EPSILON; + if (spinning || ! settled) { + schedule(); } - rafId = requestAnimationFrame(tick); }; // cobe needs a non-zero canvas width at creation; inside a freshly-rendered @@ -170,17 +224,26 @@ function mountTrafficGlobe(canvas, data, dark) { } width = next; create(dark); - rafId = requestAnimationFrame(tick); + wake(); }; const resizeObserver = new ResizeObserver(() => { if (globe) { width = canvas.offsetWidth || width; + wake(); } else { ensure(); } }); resizeObserver.observe(canvas); + + const intersectionObserver = new IntersectionObserver((entries) => { + visible = entries[entries.length - 1].isIntersecting; + if (visible) { + wake(); + } + }); + intersectionObserver.observe(canvas); requestAnimationFrame(ensure); return { @@ -203,19 +266,22 @@ function mountTrafficGlobe(canvas, data, dark) { }); } }, - focus(code) { - const c = CENTROIDS[String(code || '').toUpperCase()]; - if (!c) { + // `target` is an ISO-A2 code or a [lat, lon] pair. + focus(target) { + const c = Array.isArray(target) ? target : CENTROIDS[String(target || '').toUpperCase()]; + if (!c || !Number.isFinite(c[0]) || !Number.isFinite(c[1])) { return; } const [phi, theta] = locationToAngles(c[0], c[1]); targetPhi = phi; targetTheta = theta; autoRotate = false; + wake(); }, resume() { if (dragging === null) { autoRotate = ! prefersReduced; + wake(); } }, destroy() { @@ -225,6 +291,7 @@ function mountTrafficGlobe(canvas, data, dark) { rafId = 0; } resizeObserver.disconnect(); + intersectionObserver.disconnect(); canvas.removeEventListener('pointerdown', onPointerDown); window.removeEventListener('pointerup', onPointerUp); window.removeEventListener('pointermove', onPointerMove); diff --git a/resources/js/traffic-table.js b/resources/js/traffic-table.js new file mode 100644 index 0000000000..f8355b1708 --- /dev/null +++ b/resources/js/traffic-table.js @@ -0,0 +1,110 @@ +// Client-side sort and paging for the traffic analytics lists. Each row carries +// its sort values as JSON in `data-row`. Rows are moved in the DOM, so keyboard +// and screen-reader order match the visual order. A Livewire morph puts the rows +// back in server order (they are keyed) and can add or remove rows, so the rows +// are sorted again after it. +function compareValues(a, b) { + if (typeof a === 'number' && typeof b === 'number') { + return a - b; + } + + return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); +} + +function trafficTable({ per = 10, key = 'requests', dir = 'desc' } = {}) { + const ranks = new WeakMap(); + let observer = null; + let removeMorphHook = null; + + return { + page: 0, + per, + total: 0, + key, + dir, + tick: 0, + + init() { + this.resort(); + observer = new MutationObserver(() => this.resort()); + observer.observe(this.$root, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['data-row'], + }); + if (window.Livewire) { + removeMorphHook = window.Livewire.hook('morphed', ({ el }) => { + if (el.contains(this.$root)) { + this.resort(); + } + }); + } + }, + + destroy() { + if (observer) { + observer.disconnect(); + observer = null; + } + if (removeMorphHook) { + removeMorphHook(); + removeMorphHook = null; + } + }, + + // Text columns start ascending, number columns descending. + sortBy(next, firstDir = 'desc') { + if (this.key === next) { + this.dir = this.dir === 'desc' ? 'asc' : 'desc'; + } else { + this.key = next; + this.dir = firstDir; + } + this.page = 0; + this.resort(); + }, + + resort() { + const sign = this.dir === 'asc' ? 1 : -1; + const rows = [...this.$root.querySelectorAll('[data-row]')].map((el, index) => { + let values = {}; + try { + values = JSON.parse(el.dataset.row) || {}; + } catch { + values = {}; + } + + return { el, index, pinned: 'pinned' in el.dataset ? 1 : 0, value: values[this.key] ?? '' }; + }); + + // Pinned rows (the "Other" remainder) stay last in each sort. + rows.sort((a, b) => a.pinned - b.pinned || compareValues(a.value, b.value) * sign || a.index - b.index); + rows.forEach((row, rank) => ranks.set(row.el, rank)); + // All rows share one parent. Move them only when the order changed, so the + // mutation that the move causes finds them sorted and stops. + if (rows.some((row, rank) => row.index !== rank)) { + rows.forEach((row) => row.el.parentElement.appendChild(row.el)); + observer?.takeRecords(); + } + + this.total = rows.length; + this.page = Math.min(this.page, Math.max(0, Math.ceil(this.total / this.per) - 1)); + this.tick++; + }, + + shown(el) { + this.tick; + const rank = ranks.get(el) ?? 0; + + return rank >= this.page * this.per && rank < (this.page + 1) * this.per; + }, + + // 'asc', 'desc', or null when the list is sorted by another column. + sorted(column) { + return this.key === column ? this.dir : null; + }, + }; +} + +window.trafficTable = trafficTable; diff --git a/resources/views/livewire/analytics-placeholder.blade.php b/resources/views/livewire/analytics-placeholder.blade.php index 767f0f46ac..e33bf19353 100644 --- a/resources/views/livewire/analytics-placeholder.blade.php +++ b/resources/views/livewire/analytics-placeholder.blade.php @@ -52,8 +52,16 @@ - - + + + + +
+
+ +
+ +
@endif diff --git a/resources/views/livewire/analytics.blade.php b/resources/views/livewire/analytics.blade.php index c326ac29d5..f383f56506 100644 --- a/resources/views/livewire/analytics.blade.php +++ b/resources/views/livewire/analytics.blade.php @@ -254,14 +254,15 @@ $appListboxOptions = array_merge( icon-name="unordered-list" /> @else @php $maxAppRequests = max(1, (int) collect($topApps)->max('requests')); @endphp -
-
- Application / service - - Requests - +
+
+ @include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Application / service', 'text' => true, 'class' => 'min-w-0 flex-1']) + + @include('livewire.traffic._sort-header', ['column' => 'requests', 'label' => 'Requests', 'class' => 'w-16 shrink-0']) + @include('livewire.traffic._sort-header', ['column' => 'bytesOut', 'label' => 'Bandwidth', 'class' => 'hidden w-16 shrink-0 sm:inline-flex', 'title' => 'Total response data sent'])
+
@foreach ($topApps as $row) @php $appHref = $row['link'] ?? null; @@ -269,11 +270,9 @@ $appListboxOptions = array_merge( @endphp <{{ $appHref ? 'a' : 'div' }} wire:key="analytics-app-{{ $row['uuid'] }}" @if ($appHref) href="{{ $appHref }}" {{ wireNavigate() }} @endif - x-show="{{ $loop->index }} >= page * per && {{ $loop->index }} < (page + 1) * per" - @class([ - 'flex min-h-11 items-center gap-3 border-b border-neutral-200 px-4 py-2 last:border-b-0 dark:border-white/[0.07]', - 'transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.03]' => $appHref, - ])> + data-row="{{ json_encode(['name' => (string) $row['name'], 'requests' => (int) $row['requests'], 'bytesOut' => (int) $row['bandwidth']]) }}" + x-show="shown($el)" + class="flex min-h-11 items-center gap-3 border-t border-neutral-200 px-4 py-2 transition-colors hover:bg-neutral-50 dark:border-white/[0.07] dark:hover:bg-white/[0.03]"> {{ $row['name'] }} @if (! empty($row['isService'])) @@ -299,6 +298,7 @@ $appListboxOptions = array_merge( @endif @endforeach +
@include('livewire.traffic._pager')
@endif @@ -312,14 +312,12 @@ $appListboxOptions = array_merge( @include('livewire.traffic._paths-list', ['paths' => $topPaths]) - {{-- Countries --}} - - @include('livewire.traffic._geo', [ - 'countries' => data_get($breakdowns, 'country', []), - 'attribution' => $attribution, - ]) - + {{-- Locations (countries and cities) --}} + @include('livewire.traffic._geo', [ + 'countries' => data_get($breakdowns, 'country', []), + 'cities' => trafficCityRows(data_get($breakdowns, 'city', [])), + 'attribution' => $attribution, + ]) {{-- Requests by device type (donut) + HTTP versions / cache / status. --}}
diff --git a/resources/views/livewire/project/application/analytics-placeholder.blade.php b/resources/views/livewire/project/application/analytics-placeholder.blade.php index 1cb6cffc70..6c8df5d82c 100644 --- a/resources/views/livewire/project/application/analytics-placeholder.blade.php +++ b/resources/views/livewire/project/application/analytics-placeholder.blade.php @@ -25,8 +25,16 @@ - - + + + + +
+
+ +
+ +
diff --git a/resources/views/livewire/project/application/analytics.blade.php b/resources/views/livewire/project/application/analytics.blade.php index b2458f4cf8..53c09e5629 100644 --- a/resources/views/livewire/project/application/analytics.blade.php +++ b/resources/views/livewire/project/application/analytics.blade.php @@ -157,13 +157,11 @@ $approxBadge = fn (string $tooltip) => ' - @include('livewire.traffic._geo', [ - 'countries' => data_get($breakdowns, 'country', []), - 'attribution' => $attribution, - ]) - + @include('livewire.traffic._geo', [ + 'countries' => data_get($breakdowns, 'country', []), + 'cities' => trafficCityRows(data_get($breakdowns, 'city', [])), + 'attribution' => $attribution, + ]) {{-- Requests by device type (donut) + HTTP versions / cache / status. --}}
diff --git a/resources/views/livewire/server/traffic-analytics-settings.blade.php b/resources/views/livewire/server/traffic-analytics-settings.blade.php index 09ee724eeb..177bb1a59a 100644 --- a/resources/views/livewire/server/traffic-analytics-settings.blade.php +++ b/resources/views/livewire/server/traffic-analytics-settings.blade.php @@ -61,7 +61,7 @@ ['value' => 'anonymized', 'label' => 'Anonymized (/24 IPv4, /48 IPv6)'], ['value' => 'off', 'label' => 'Disabled'], ]" - helper="How Sentinel stores visitor IPs for the Top IPs list. Anonymized keeps only the network. Disabled does not store IPs. When you change this setting, Sentinel deletes the stored IPs that the new setting does not allow." /> + helper="How Sentinel stores visitor IPs for the Top IPs list. Anonymized keeps only the network. Disabled does not store IPs or visitor cities. When you change this setting, Sentinel deletes the stored IPs that the new setting does not allow." /> diff --git a/resources/views/livewire/traffic/_breakdown-section.blade.php b/resources/views/livewire/traffic/_breakdown-section.blade.php index e6b1e9be4f..df862b9ec1 100644 --- a/resources/views/livewire/traffic/_breakdown-section.blade.php +++ b/resources/views/livewire/traffic/_breakdown-section.blade.php @@ -1,5 +1,5 @@ {{-- - One breakdown dimension rendered as a paginated settings-section. Handles the + One breakdown dimension rendered as a sortable, paginated settings-section. Handles the per-dimension presentation: referrers get a DuckDuckGo favicon + an openable external link (rel="noopener noreferrer nofollow", new tab), devices are mapped to friendly capitalized labels, and Sentinel's `__other__` overflow bucket reads @@ -20,7 +20,14 @@ @else -
+
+
+ @include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Name', 'text' => true, 'class' => 'min-w-0 flex-1']) + + @include('livewire.traffic._sort-header', ['column' => 'requests', 'label' => 'Requests', 'class' => 'w-16 shrink-0']) + @include('livewire.traffic._sort-header', ['column' => 'bytesOut', 'label' => 'Bandwidth', 'class' => 'hidden w-16 shrink-0 sm:inline-flex', 'title' => 'Total response data sent']) +
+
@foreach ($rows as $row) @php $value = (string) ($row['value'] ?? ''); @@ -35,11 +42,13 @@ default => $value !== '' ? $value : 'Unknown', }; $requests = (int) ($row['requests'] ?? 0); + $bytesOut = (int) ($row['bytesOut'] ?? 0); $width = min(100, round(($requests / $maxRequests) * 100, 1)); @endphp
+ data-row="{{ json_encode(['name' => $display, 'requests' => $requests, 'bytesOut' => $bytesOut]) }}" @if ($isOther) data-pinned @endif + x-show="shown($el)" + class="flex min-h-11 items-center gap-3 border-t border-neutral-200 px-4 py-2 transition-colors hover:bg-neutral-50 dark:border-white/[0.07] dark:hover:bg-white/[0.03]"> @if ($dimension === 'referer' && $host) @@ -51,11 +60,12 @@ - {{ compactNumber($requests) }} - +
@endforeach +
@include('livewire.traffic._pager')
@endif diff --git a/resources/views/livewire/traffic/_geo.blade.php b/resources/views/livewire/traffic/_geo.blade.php index 06f24bfee6..754edd9aa6 100644 --- a/resources/views/livewire/traffic/_geo.blade.php +++ b/resources/views/livewire/traffic/_geo.blade.php @@ -1,13 +1,14 @@ {{-- - Shared geo visualization for traffic analytics: a lightweight interactive 3D - dotted globe (WebGL, via cobe — see resources/js/traffic-globe.js) with a - request-volume marker per country, beside a scrollable ranked country list. - Hovering a country row rotates the globe to face it. Driven by the `country` - breakdown; each row is ['value' => ISO-A2, 'requests', 'bytesOut']. The globe - live-updates from the host's `refreshChartData-{chartId}-status` payload - (`geo` key), so `$chartId` must be in scope. + "Locations" settings-section for traffic analytics: a lightweight interactive + 3D dotted globe (WebGL, via cobe — see resources/js/traffic-globe.js) beside a + scrollable ranked list. A Countries / Cities switch in the section header picks + the list and the globe markers; the choice is saved per-browser in localStorage. + Hovering a row rotates the globe to face it. The globe live-updates from the + host's `refreshChartData-{chartId}-status` payload (`geo` key), so `$chartId` + must be in scope. - @param iterable $countries country-breakdown rows + @param iterable $countries country-breakdown rows: ['value' => ISO-A2, 'requests', 'bytesOut'] + @param array $cities output of trafficCityRows(); empty when there is no city data @param ?string $attribution optional Sentinel attribution note --}} @php @@ -25,9 +26,6 @@ fn ($r) => preg_match('/^[A-Z]{2}$/', $r['value']) && countryName($r['value']) !== 'Unknown' ); - // Initial marker payload for the globe, keyed by ISO-A2 request volume. - $geoInit = $known->map(fn ($r) => ['code' => $r['value'], 'requests' => $r['requests']])->values()->all(); - $countryRows = $known->values(); if ($unknown->isNotEmpty()) { $countryRows->push([ @@ -39,99 +37,179 @@ $countryRows = $countryRows->sortByDesc('requests')->values(); $maxRequests = max(1, (int) $countryRows->max('requests')); + $cityRows = collect($cities ?? []); + $maxCityRequests = max(1, (int) $cityRows->max('requests')); + + // Initial marker payload for the globe: one set for each view. + $geoInit = trafficGeoMarkers($known->all(), $cityRows->all()); + $hasData = $countryRows->isNotEmpty(); $globeId = ($chartId ?? 'traffic').'-globe'; + $viewButton = 'h-7 rounded-md px-2.5 text-[12px] font-medium transition-colors'; + $viewButtonActive = 'bg-white text-black shadow-sm ring-1 ring-neutral-200 dark:bg-white/[0.09] dark:text-fg dark:ring-white/[0.08]'; + $viewButtonInactive = 'text-neutral-500 hover:text-black dark:text-fg-faint dark:hover:text-fg'; + $listClass = 'max-h-[320px] overflow-y-auto pr-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-neutral-300 dark:scrollbar-thumb-white/10'; + $rowClass = 'group flex min-h-10 items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-neutral-50 dark:hover:bg-white/[0.03]'; @endphp -
- @if (! $hasData) - - @else -
- {{-- Interactive dotted globe (40%). wire:ignore so live-poll morphs never tear - down the WebGL canvas. Mounted via Alpine, not Livewire's script directive: - it ran before the lazy-loaded canvas was in the DOM, and only once per - component, so a globe inserted by a later render (no data -> data after a - range change or live poll) never mounted either. Alpine's - init()/destroy() run each time this element enters or leaves the DOM. --}} -
-
- -
-
+ -
- @foreach ($countryRows as $row) - @php - $isUnknown = $row['value'] === ''; - $width = min(100, round(($row['requests'] / $maxRequests) * 100, 1)); - @endphp -
- @php $flagUrl = $isUnknown ? null : countryFlagUrl($row['value']); @endphp - @if ($flagUrl) - - @else - - @endif - - {{ $isUnknown ? 'Unknown' : countryName($row['value']) }} - - - {{ compactNumber($row['requests']) }} - -
- @endforeach -
-
+ return canvas ? canvas._trafficGlobe : null; + }, + }"> + + @if ($hasData) +
+ @foreach (['countries' => 'Countries', 'cities' => 'Cities'] as $viewKey => $viewLabel) + + @endforeach
- - @if (! empty($attribution)) -

- {{ $attribution }} -

@endif - @endif -
+ + +
+ @if (! $hasData) + + @else + {{-- Keyed so the lazy-load morph inserts fresh nodes instead of reusing the + placeholder skeleton, which has the same shape. --}} +
+ {{-- Interactive dotted globe (40%). wire:ignore so live-poll morphs never tear + down the WebGL canvas. Mounted via Alpine init()/destroy(), which run each + time this element enters or leaves the DOM (a Livewire script directive + runs one time only, before the lazy-loaded canvas exists). --}} +
+
+ +
+
+ + {{-- Ranked, scrollable lists (60%). Hover a row to face it on the globe. --}} +
+
+ @foreach ($countryRows as $row) + @php + $isUnknown = $row['value'] === ''; + $width = min(100, round(($row['requests'] / $maxRequests) * 100, 1)); + $flagUrl = $isUnknown ? null : countryFlagUrl($row['value']); + @endphp +
+ @if ($flagUrl) + + @else + + @endif + + {{ $isUnknown ? 'Unknown' : countryName($row['value']) }} + + + {{ compactNumber($row['requests']) }} + +
+ @endforeach +
+ +
+ @forelse ($cityRows as $row) + @php + $hasCoordinates = $row['lat'] !== null && $row['lon'] !== null; + $width = min(100, round(($row['requests'] / $maxCityRequests) * 100, 1)); + $flagUrl = countryFlagUrl($row['country']); + @endphp +
+ @if ($flagUrl) + + @else + + @endif + + {{ $row['name'] }} + @if ($row['detail'] !== '') + + @endif + + + {{ compactNumber($row['requests']) }} + +
+ @empty + + @endforelse +
+
+
+ + @if (! empty($attribution)) +

+ {{ $attribution }} +

+ @endif + @endif +
+ diff --git a/resources/views/livewire/traffic/_hosts-list.blade.php b/resources/views/livewire/traffic/_hosts-list.blade.php index 710e8fa637..9785bd38eb 100644 --- a/resources/views/livewire/traffic/_hosts-list.blade.php +++ b/resources/views/livewire/traffic/_hosts-list.blade.php @@ -1,7 +1,8 @@ {{-- - Paginated "top hosts" list — request volume grouped by served hostname (an app's - primary domain), with a proportional bar and right-aligned compact metrics. - Links each host to its live origin. Expects `$hosts` in scope. + Sortable, paginated "top hosts" list — request volume grouped by served hostname + (an app's primary domain), with a proportional bar and right-aligned compact + metrics. Select a column header to sort by it. Links each host to its live + origin. Expects `$hosts` in scope. @param iterable $hosts host rows: ['host', 'requests', 'bandwidth'] @param ?string $keyPrefix wire:key prefix (default "analytics-host") @@ -15,37 +16,42 @@ @else -
-
- Host - - Requests - +
+
+ @include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Host', 'text' => true, 'class' => 'min-w-0 flex-1']) + + @include('livewire.traffic._sort-header', ['column' => 'requests', 'label' => 'Requests', 'class' => 'w-16 shrink-0']) + @include('livewire.traffic._sort-header', ['column' => 'bytesOut', 'label' => 'Bandwidth', 'class' => 'hidden w-16 shrink-0 sm:inline-flex', 'title' => 'Total response data sent'])
- @foreach ($hosts as $row) - @php - $host = (string) ($row['host'] ?? ''); - $known = $host !== ''; - $requests = (int) ($row['requests'] ?? 0); - $width = min(100, round(($requests / $maxRequests) * 100, 1)); - @endphp -
- @if ($known) - {{ $host }} - @else - Unknown host - @endif - @include('livewire.traffic._pager')
@endif diff --git a/resources/views/livewire/traffic/_pager.blade.php b/resources/views/livewire/traffic/_pager.blade.php index 73aa17cc9b..d0dcf08dd0 100644 --- a/resources/views/livewire/traffic/_pager.blade.php +++ b/resources/views/livewire/traffic/_pager.blade.php @@ -1,7 +1,7 @@ {{-- Client-side "top 10" pager footer for traffic lists. Expects an enclosing Alpine scope that defines reactive `page` (0-based), `per` (page size) and - `total` (row count). Rows themselves are toggled with x-show on their index; + `total` (row count). Rows themselves are toggled with x-show by their scope; this partial only renders the summary + prev/next controls, and hides itself when everything fits on one page. --}} diff --git a/resources/views/livewire/traffic/_paths-list.blade.php b/resources/views/livewire/traffic/_paths-list.blade.php index 2882b0e69b..0287c84996 100644 --- a/resources/views/livewire/traffic/_paths-list.blade.php +++ b/resources/views/livewire/traffic/_paths-list.blade.php @@ -1,82 +1,90 @@ {{-- - Paginated "top paths" list. Each row shows the request path, a proportional - request-volume bar, and right-aligned compact metrics (requests / bytes / p95). - The path links to its live URL when the owning domain is known (new tab, - rel="noopener noreferrer nofollow"); the domain shows next to the path so the - same path of two apps stays apart. Sentinel's `__other__` overflow row reads - as "Other paths". Expects `$paths` in scope; optional - `$keyPrefix` to namespace wire:keys. + Sortable, paginated "top paths" list. Each row shows the request path, a + proportional request-volume bar, and right-aligned compact metrics. Select a + column header to sort by it. The path links to its live URL when the owning + domain is known (new tab, rel="noopener noreferrer nofollow"); the domain shows + next to the path so the same path of two apps stays apart. Sentinel's + `__other__` overflow row reads as "Other paths" and stays last. Expects + `$paths` in scope; optional `$keyPrefix` to namespace wire:keys. - @param iterable $paths path rows: ['path', 'domain'?, 'requests', 'bytesOut', 'p95'] + @param iterable $paths path rows: ['path', 'domain'?, 'requests', 'bytesOut', 's4xx', 's5xx', 'p95'] @param ?string $keyPrefix wire:key prefix (default "analytics-path") --}} @php $paths = $paths ?? []; $keyPrefix = $keyPrefix ?? 'analytics-path'; $maxRequests = max(1, (int) collect($paths)->max('requests')); + $muted = 'text-neutral-300 dark:text-fg-faint'; @endphp @if (collect($paths)->isEmpty()) @else -
-
- Path - - Requests - - 5xx errors - - - +
+
+ @include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Path', 'text' => true, 'class' => 'min-w-0 flex-1']) + + @include('livewire.traffic._sort-header', ['column' => 'requests', 'label' => 'Requests', 'class' => 'w-16 shrink-0']) + @include('livewire.traffic._sort-header', ['column' => 's4xx', 'label' => '4xx', 'class' => 'hidden w-12 shrink-0 sm:inline-flex', 'title' => 'HTTP 4xx client-error responses']) + @include('livewire.traffic._sort-header', ['column' => 's5xx', 'label' => '5xx', 'class' => 'w-12 shrink-0', 'title' => 'HTTP 5xx server-error responses']) + @include('livewire.traffic._sort-header', ['column' => 'errorRate', 'label' => 'Error %', 'class' => 'hidden w-14 shrink-0 lg:inline-flex', 'title' => 'Percentage of requests with a 4xx or 5xx response']) + @include('livewire.traffic._sort-header', ['column' => 'bytesOut', 'label' => 'Bandwidth', 'class' => 'hidden w-16 shrink-0 sm:inline-flex', 'title' => 'Total response data sent']) + @include('livewire.traffic._sort-header', ['column' => 'p95', 'label' => 'p95', 'class' => 'hidden w-16 shrink-0 md:inline-flex', 'title' => '95% of requests completed within this response time'])
- @foreach ($paths as $path) - @php - $domain = $path['domain'] ?? null; - $pathStr = (string) ($path['path'] ?? ''); - $isOther = $pathStr === '__other__'; - $href = $domain && ! $isOther ? 'https://'.$domain.$pathStr : null; - $requests = (int) ($path['requests'] ?? 0); - $s4xx = (int) ($path['s4xx'] ?? 0); - $s5xx = (int) ($path['s5xx'] ?? 0); - $errorRate = $requests > 0 ? round((($s4xx + $s5xx) / $requests) * 100, 1) : 0; - $width = min(100, round(($requests / $maxRequests) * 100, 1)); - @endphp -
- @if ($isOther) - Other paths - @else - - @if ($href) - {{ $pathStr }} - @else - {{ $pathStr }} - @endif - @if ($domain) - {{ $domain }} - @endif - - @endif - @include('livewire.traffic._pager')
@endif diff --git a/resources/views/livewire/traffic/_sort-header.blade.php b/resources/views/livewire/traffic/_sort-header.blade.php new file mode 100644 index 0000000000..e5b53499e0 --- /dev/null +++ b/resources/views/livewire/traffic/_sort-header.blade.php @@ -0,0 +1,30 @@ +{{-- + One sortable column header for a traffic list. Expects an enclosing Alpine + scope from `trafficTable()` (resources/js/traffic-table.js). + + @param string $column key in the rows' `data-row` JSON + @param string $label header text + @param ?string $class width and responsive classes of the column; a column that + hides on small screens passes `hidden :inline-flex` + @param ?string $title tooltip + @param ?bool $text true for a text column: left-aligned, first sort ascending +--}} +@php + $text = $text ?? false; + $firstDir = $text ? 'asc' : 'desc'; + $class = $class ?? ''; + // `hidden` and `inline-flex` on one element conflict, so add the display only when the caller sets none. + $display = str_contains($class, 'hidden') ? '' : 'inline-flex'; +@endphp + diff --git a/tests/Feature/TrafficAnalytics/GeoVisualizationTest.php b/tests/Feature/TrafficAnalytics/GeoVisualizationTest.php index 13e230d6a1..3c3fc9a06b 100644 --- a/tests/Feature/TrafficAnalytics/GeoVisualizationTest.php +++ b/tests/Feature/TrafficAnalytics/GeoVisualizationTest.php @@ -27,7 +27,7 @@ class FakeGeoTrafficClient extends SentinelTrafficClient } } -function fakeGeoResponses(array $countryRows): array +function fakeGeoResponses(array $countryRows, array $cityRows = []): array { return [ '/traffic/apps' => json_encode([]), @@ -41,6 +41,7 @@ function fakeGeoResponses(array $countryRows): array ]), '/traffic/paths' => json_encode([]), '/traffic/breakdown/country' => json_encode($countryRows), + '/traffic/breakdown/city' => json_encode($cityRows), '/traffic/attribution' => json_encode(['attribution' => 'GeoIP data by MaxMind']), ]; } @@ -111,7 +112,7 @@ it('shows a plain no-data state when no country data has been recorded', functio loadLazy(Livewire::test(Analytics::class)) ->assertOk() - ->assertSee('Countries') + ->assertSee('Locations') ->assertSee('No country data for the selected range'); }); @@ -135,3 +136,94 @@ it('mounts the globe from the element itself when country data arrives after the ->assertSeeHtml('id="global-analytics-globe" x-ref="canvas"') ->assertSeeHtml('window.mountTrafficGlobe(canvas'); }); + +it('renders the city list, the switch, and both marker sets when Sentinel returns city data', function () { + $server = bootGeoServer(); + + $fake = new FakeGeoTrafficClient($server); + $fake->responses = fakeGeoResponses( + [['value' => 'DE', 'requests' => 600, 'bytes_out' => 15000]], + [ + ['value' => 'DE|BE|Berlin', 'requests' => 400, 'bytes_out' => 9000, 'lat' => 52.52, 'lon' => 13.405], + ['value' => 'DE||', 'requests' => 150, 'bytes_out' => 4000], + ['value' => '__other__', 'requests' => 50, 'bytes_out' => 2000], + ], + ); + app()->bind(SentinelTrafficClient::class, fn () => $fake); + + loadLazy(Livewire::test(Analytics::class)) + ->assertOk() + ->assertSeeHtml('aria-label="Location detail"') + ->assertSee('Germany') + ->assertSee('Berlin') + ->assertSee('BE, Germany') + // Only the row with coordinates turns the globe on hover. + ->assertSeeHtml('focus([52.52, 13.405])'); +}); + +it('sends the globe one marker set for each view, without markers for unknown cities', function () { + $server = bootGeoServer(); + + $fake = new FakeGeoTrafficClient($server); + $fake->responses = fakeGeoResponses( + [['value' => 'DE', 'requests' => 600, 'bytes_out' => 15000]], + [ + ['value' => 'DE|BE|Berlin', 'requests' => 400, 'bytes_out' => 9000, 'lat' => 52.52, 'lon' => 13.405], + // An unknown city never gets a marker, also when Sentinel sends coordinates. + ['value' => 'DE||', 'requests' => 150, 'bytes_out' => 4000, 'lat' => 51.0, 'lon' => 9.0], + ], + ); + app()->bind(SentinelTrafficClient::class, fn () => $fake); + + loadLazy(Livewire::test(Analytics::class)) + ->call('loadData') + ->assertDispatched('refreshChartData-global-analytics-status', function (string $event, array $params): bool { + return data_get($params, '0.geo') === [ + 'countries' => [['code' => 'DE', 'requests' => 600]], + 'cities' => [['lat' => 52.52, 'lon' => 13.405, 'requests' => 400]], + ]; + }); +}); + +it('shows an empty city list and no city markers when Sentinel returns no city data', function (array $cityRows) { + $server = bootGeoServer(); + + $fake = new FakeGeoTrafficClient($server); + $fake->responses = fakeGeoResponses([ + ['value' => 'DE', 'requests' => 600, 'bytes_out' => 15000], + ], $cityRows); + app()->bind(SentinelTrafficClient::class, fn () => $fake); + + loadLazy(Livewire::test(Analytics::class)) + ->assertOk() + ->assertSee('Germany') + ->assertSee('No city data for the selected range.') + ->assertDontSeeHtml('focus([') + ->call('loadData') + ->assertDispatched('refreshChartData-global-analytics-status', function (string $event, array $params): bool { + return data_get($params, '0.geo') === [ + 'countries' => [['code' => 'DE', 'requests' => 600]], + 'cities' => [], + ]; + }); +})->with([ + 'old sentinel' => [[]], + 'country-only database' => [[['value' => 'DE||', 'requests' => 600, 'bytes_out' => 15000]]], +]); + +it('escapes a city name that contains quotes or HTML', function () { + $server = bootGeoServer(); + + $fake = new FakeGeoTrafficClient($server); + $fake->responses = fakeGeoResponses( + [['value' => 'US', 'requests' => 600, 'bytes_out' => 15000]], + [['value' => "US|MO|O'Fallon x", 'requests' => 400, 'bytes_out' => 9000, 'lat' => 38.81, 'lon' => -90.7]], + ); + app()->bind(SentinelTrafficClient::class, fn () => $fake); + + loadLazy(Livewire::test(Analytics::class)) + ->assertOk() + ->assertSee("O'Fallon x") + ->assertDontSeeHtml('x') + ->assertSeeHtml('focus([38.81, -90.7])'); +}); diff --git a/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php index 58add72c10..870befc0f5 100644 --- a/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php +++ b/tests/Feature/TrafficAnalytics/GlobalAnalyticsTest.php @@ -149,8 +149,6 @@ it('renders a team-wide analytics summary across enabled servers', function () { ->assertSee('Global Leaderboard App') ->assertSee('Top hosts') ->assertSee('Top paths') - ->assertSee('3 4xx') - ->assertSee('1 5xx') ->assertSee('Status codes') ->assertSee('Countries') ->assertSee('United States') @@ -490,7 +488,9 @@ it('labels the IP breakdown by the client IP mode of the queried servers', funct $component->assertDontSee('Top IPs') ->assertDontSee('Top networks') ->assertDontSee('203.0.113.7'); - expect($component->instance()->breakdowns)->not->toHaveKey('ip'); + // Sentinel finds the city from the client IP, so it stores no cities either. + expect($component->instance()->breakdowns)->not->toHaveKey('ip') + ->and($component->instance()->breakdowns)->not->toHaveKey('city'); return; } diff --git a/tests/Feature/TrafficAnalytics/SentinelTrafficClientTest.php b/tests/Feature/TrafficAnalytics/SentinelTrafficClientTest.php index b3946c00ba..81c1cb5c6c 100644 --- a/tests/Feature/TrafficAnalytics/SentinelTrafficClientTest.php +++ b/tests/Feature/TrafficAnalytics/SentinelTrafficClientTest.php @@ -95,10 +95,10 @@ it('accepts every known dimension and rejects unknown ones', function () { $client = new FakeTrafficClient($server); $client->response = json_encode([]); - foreach (['status', 'method', 'country', 'referer', 'browser', 'os', 'device', 'protocol', 'scheme', 'tls', 'cache', 'bot'] as $dimension) { + foreach (['status', 'method', 'country', 'city', 'referer', 'browser', 'os', 'device', 'protocol', 'scheme', 'tls', 'cache', 'bot'] as $dimension) { $client->breakdown(null, $dimension, 'a', 'b'); } - expect($client->captured)->toHaveCount(12); + expect($client->captured)->toHaveCount(13); expect(fn () => $client->breakdown(null, 'not-a-real-dimension', 'a', 'b')) ->toThrow(InvalidArgumentException::class); diff --git a/tests/Feature/TrafficAnalytics/TrafficAnalyticsAggregatorTest.php b/tests/Feature/TrafficAnalytics/TrafficAnalyticsAggregatorTest.php index ef9f7325f6..fbcaaf2307 100644 --- a/tests/Feature/TrafficAnalytics/TrafficAnalyticsAggregatorTest.php +++ b/tests/Feature/TrafficAnalytics/TrafficAnalyticsAggregatorTest.php @@ -1,5 +1,6 @@ toBeFalse(); expect($result['uniquesApproximate'])->toBeFalse(); }); + +it('sums one city from two servers and keeps the first coordinates', function () { + $aggregator = new TrafficAnalyticsAggregator(['city']); + $aggregator->addBreakdown('city', [new TrafficBreakdownData('DE|BE|Berlin', 5, 500)]); + $aggregator->addBreakdown('city', [new TrafficBreakdownData('DE|BE|Berlin', 4, 400, 52.52, 13.405)]); + $aggregator->addBreakdown('city', [new TrafficBreakdownData('DE|BE|Berlin', 1, 100, 1.0, 2.0)]); + + expect($aggregator->breakdowns()['city'])->toBe([ + ['value' => 'DE|BE|Berlin', 'requests' => 10, 'bytesOut' => 1000, 'lat' => 52.52, 'lon' => 13.405], + ]); +}); + +it('adds no coordinate keys to rows that have none', function () { + $aggregator = new TrafficAnalyticsAggregator(['country', 'city']); + $aggregator->addBreakdown('country', [new TrafficBreakdownData('DE', 5, 500)]); + $aggregator->addBreakdown('city', [new TrafficBreakdownData('DE||', 5, 500)]); + + expect($aggregator->breakdowns())->toBe([ + 'country' => [['value' => 'DE', 'requests' => 5, 'bytesOut' => 500]], + 'city' => [['value' => 'DE||', 'requests' => 5, 'bytesOut' => 500]], + ]); +}); diff --git a/tests/Feature/TrafficAnalytics/TrafficDataTest.php b/tests/Feature/TrafficAnalytics/TrafficDataTest.php index b75fd0b550..7169b5b42c 100644 --- a/tests/Feature/TrafficAnalytics/TrafficDataTest.php +++ b/tests/Feature/TrafficAnalytics/TrafficDataTest.php @@ -1,5 +1,6 @@ s4xx)->toBe(3) ->and($dto->s5xx)->toBe(2); }); + +it('maps city coordinates from sentinel', function () { + $dto = TrafficBreakdownData::fromSentinel([ + 'value' => 'DE|BE|Berlin', 'requests' => 9, 'bytes_out' => 900, 'lat' => 52.52, 'lon' => 13.405, + ]); + + expect($dto->value)->toBe('DE|BE|Berlin') + ->and($dto->lat)->toBe(52.52) + ->and($dto->lon)->toBe(13.405); +}); + +it('leaves coordinates null when sentinel sends none or sends only one', function (array $row) { + $dto = TrafficBreakdownData::fromSentinel(['value' => 'DE||', 'requests' => 2, 'bytes_out' => 20] + $row); + + expect($dto->lat)->toBeNull()->and($dto->lon)->toBeNull(); +})->with([ + 'none' => [[]], + 'only lat' => [['lat' => 52.52]], + 'not numeric' => [['lat' => 'x', 'lon' => 'y']], + 'not finite' => [['lat' => INF, 'lon' => 13.4]], + 'latitude out of range' => [['lat' => 91, 'lon' => 13.4]], + 'longitude out of range' => [['lat' => 52.5, 'lon' => -181]], +]); diff --git a/tests/Unit/TrafficCityHelpersTest.php b/tests/Unit/TrafficCityHelpersTest.php new file mode 100644 index 0000000000..9d1c7437dc --- /dev/null +++ b/tests/Unit/TrafficCityHelpersTest.php @@ -0,0 +1,137 @@ + 'SG||Singapore', 'requests' => 3, 'bytesOut' => 30, 'lat' => 1.29, 'lon' => 103.85], + ['value' => 'DE|BE|Berlin', 'requests' => 9, 'bytesOut' => 900, 'lat' => 52.52, 'lon' => 13.405], + ]); + + expect($rows)->toBe([ + ['key' => 'DE|BE|Berlin', 'country' => 'DE', 'label' => 'Berlin, BE, DE', 'name' => 'Berlin', 'detail' => 'BE, Germany', 'requests' => 9, 'bytesOut' => 900, 'lat' => 52.52, 'lon' => 13.405], + ['key' => 'SG||Singapore', 'country' => 'SG', 'label' => 'Singapore, SG', 'name' => 'Singapore', 'detail' => 'Singapore', 'requests' => 3, 'bytesOut' => 30, 'lat' => 1.29, 'lon' => 103.85], + ]); + }); + + it('omits a subdivision that is not a letter code', function (string $value, string $label, string $detail) { + $rows = trafficCityRows([ + ['value' => $value, 'requests' => 1, 'bytesOut' => 1], + ]); + + expect($rows[0]['label'])->toBe($label) + ->and($rows[0]['detail'])->toBe($detail); + })->with([ + 'a name' => ['DE|Land Berlin|Berlin', 'Berlin, DE', 'Germany'], + 'a number' => ['AT|3|Neunkirchen', 'Neunkirchen, AT', 'Austria'], + 'one letter' => ['SE|O|Lidköping', 'Lidköping, SE', 'Sweden'], + ]); + + it('labels an unknown city with its country and gives it no coordinates', function () { + $rows = trafficCityRows([ + ['value' => 'DE|BE|Berlin', 'requests' => 9, 'bytesOut' => 900, 'lat' => 52.52, 'lon' => 13.405], + ['value' => 'DE||', 'requests' => 2, 'bytesOut' => 20, 'lat' => 51.0, 'lon' => 9.0], + ]); + + expect($rows[1])->toBe([ + 'key' => 'DE||', 'country' => 'DE', 'label' => 'Unknown, DE', 'name' => 'Unknown', 'detail' => 'Germany', 'requests' => 2, 'bytesOut' => 20, 'lat' => null, 'lon' => null, + ]); + }); + + it('merges the remainder and malformed values into one Other row', function () { + $rows = trafficCityRows([ + ['value' => 'DE|BE|Berlin', 'requests' => 9, 'bytesOut' => 900], + ['value' => '__other__', 'requests' => 40, 'bytesOut' => 40], + ['value' => 'Berlin', 'requests' => 1, 'bytesOut' => 10], + ['value' => 'DE|Berlin', 'requests' => 1, 'bytesOut' => 10], + ['value' => '', 'requests' => 1, 'bytesOut' => 10], + ]); + + // "Other" has the most requests here and still stays last. + expect($rows)->toHaveCount(2) + ->and($rows[1])->toBe([ + 'key' => '__other__', 'country' => '', 'label' => 'Other', 'name' => 'Other', 'detail' => '', 'requests' => 43, 'bytesOut' => 70, 'lat' => null, 'lon' => null, + ]); + }); + + it('keeps a city name that contains quotes or HTML unchanged', function () { + $rows = trafficCityRows([ + ['value' => "US|MO|O'Fallon ", 'requests' => 1, 'bytesOut' => 1], + ]); + + expect($rows[0]['name'])->toBe("O'Fallon ") + ->and($rows[0]['label'])->toBe("O'Fallon , MO, US"); + }); + + it('accepts a lower-case country code', function () { + $rows = trafficCityRows([ + ['value' => 'de|BE|Berlin', 'requests' => 1, 'bytesOut' => 1], + ]); + + expect($rows[0]['country'])->toBe('DE') + ->and($rows[0]['label'])->toBe('Berlin, BE, DE'); + }); + + it('skips rows with no requests', function () { + $rows = trafficCityRows([ + ['value' => 'DE|BE|Berlin', 'requests' => 9, 'bytesOut' => 900], + ['value' => 'FR|IDF|Paris', 'requests' => 0, 'bytesOut' => 0], + ]); + + expect($rows)->toHaveCount(1); + }); + + it('returns nothing when no row has a real city', function (array $input) { + expect(trafficCityRows($input))->toBe([]); + })->with([ + 'old sentinel' => [[]], + 'country-only database' => [[ + ['value' => 'DE||', 'requests' => 5, 'bytesOut' => 50], + ['value' => 'US||', 'requests' => 3, 'bytesOut' => 30], + ]], + 'only the remainder' => [[ + ['value' => '__other__', 'requests' => 5, 'bytesOut' => 50], + ]], + ]); +}); + +describe('trafficGeoMarkers', function () { + it('returns country markers and city markers as two sets', function () { + $markers = trafficGeoMarkers( + [ + ['value' => 'DE', 'requests' => 12], + ['value' => 'us', 'requests' => 7], + ['value' => '', 'requests' => 3], + ['value' => 'FR', 'requests' => 0], + ], + [ + ['value' => 'DE|BE|Berlin', 'requests' => 9, 'lat' => 52.52, 'lon' => 13.405], + ['value' => 'DE||', 'requests' => 3], + ['value' => 'FR|IDF|Paris', 'requests' => 0, 'lat' => 48.85, 'lon' => 2.35], + ], + ); + + expect($markers)->toBe([ + 'countries' => [ + ['code' => 'DE', 'requests' => 12], + ['code' => 'US', 'requests' => 7], + ], + 'cities' => [ + ['lat' => 52.52, 'lon' => 13.405, 'requests' => 9], + ], + ]); + }); + + it('has no city markers when Sentinel sends no city data', function () { + expect(trafficGeoMarkers([['value' => 'DE', 'requests' => 12]], [])['cities'])->toBe([]); + }); + + it('accepts the rows that trafficCityRows returns', function () { + $rows = trafficCityRows([ + ['value' => 'DE|BE|Berlin', 'requests' => 9, 'bytesOut' => 900, 'lat' => 52.52, 'lon' => 13.405], + ]); + + expect(trafficGeoMarkers([], $rows)['cities'])->toBe([ + ['lat' => 52.52, 'lon' => 13.405, 'requests' => 9], + ]); + }); +}); diff --git a/tests/v4/Browser/TrafficGeoCitiesTest.php b/tests/v4/Browser/TrafficGeoCitiesTest.php new file mode 100644 index 0000000000..882c3e131f --- /dev/null +++ b/tests/v4/Browser/TrafficGeoCitiesTest.php @@ -0,0 +1,67 @@ + [], + '/traffic/overview' => [ + 'requests' => 1000, 'bytes_in' => 5000, 'bytes_out' => 25000, + 'status' => ['s2xx' => 900, 's3xx' => 50, 's4xx' => 40, 's5xx' => 10], + 'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1], + 'unique_visitors' => 320, + ], + '/traffic/breakdown/country' => [ + ['value' => 'DE', 'requests' => 600, 'bytes_out' => 15000], + ['value' => 'FR', 'requests' => 400, 'bytes_out' => 10000], + ], + '/traffic/breakdown/city' => [ + ['value' => 'DE|BE|Berlin', 'requests' => 450, 'bytes_out' => 11000, 'lat' => 52.52, 'lon' => 13.405], + ['value' => 'FR|IDF|Paris', 'requests' => 400, 'bytes_out' => 10000, 'lat' => 48.85, 'lon' => 2.35], + ['value' => 'DE||', 'requests' => 150, 'bytes_out' => 4000], + ], + ]; + + foreach ($responses as $needle => $response) { + if (str_contains($url, $needle)) { + return json_encode($response); + } + } + + return '{}'; + } +} + +beforeEach(function () { + $this->stack = seedBrowserResourceStack(); + + $server = $this->stack['server']; + $server->settings->is_traffic_analytics_enabled = true; + $server->settings->save(); + + app()->bind(SentinelTrafficClient::class, fn () => new FakeBrowserCityTrafficClient($server)); +}); + +it('switches the Locations card between countries and cities and remembers the selection', function () { + loginAndSkipBoarding(); + + $page = visit('/analytics'); + + $page->assertSee('Locations') + ->assertSee('Germany') + ->assertDontSee('BE, Germany') + ->click('button:visible:has-text("Cities")') + ->assertSee('BE, Germany') + ->assertSee('IDF, France') + ->assertDontSee('United States') + ->refresh() + ->assertSee('BE, Germany') + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'analytics-locations-cities'); +}); diff --git a/tests/v4/Browser/TrafficTableSortTest.php b/tests/v4/Browser/TrafficTableSortTest.php new file mode 100644 index 0000000000..3236e45f9b --- /dev/null +++ b/tests/v4/Browser/TrafficTableSortTest.php @@ -0,0 +1,104 @@ + sprintf('/p%02d', $i), 'app' => 'app-1', 'requests' => $i * 10, 'bytes_out' => 1000, + 's4xx' => 0, 's5xx' => 0, 'p50' => 10, 'p95' => 30, + ]; + } + + $responses = [ + '/traffic/apps' => [], + '/traffic/overview' => [ + 'requests' => 780, 'bytes_in' => 5000, 'bytes_out' => 12000, + 'status' => ['s2xx' => 780, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0], + 'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1], + 'unique_visitors' => 320, + ], + '/traffic/paths' => $paths, + ]; + + foreach ($responses as $needle => $response) { + if (str_contains($url, $needle)) { + return json_encode($response); + } + } + + return '{}'; + } +} + +beforeEach(function () { + $this->stack = seedBrowserResourceStack(); + + $server = $this->stack['server']; + $server->settings->is_traffic_analytics_enabled = true; + $server->settings->save(); + + app()->bind(SentinelTrafficClient::class, fn () => new FakeBrowserSortTrafficClient($server)); +}); + +/** + * The first visible path rows in DOM order, which is the keyboard and + * screen-reader order. Returns null when the screen order is different. + */ +function visiblePathNames(mixed $page): ?array +{ + return $page->script(<<<'JS' + (() => { + const rows = [...document.querySelectorAll('#analytics-paths-section [data-row]')] + .filter((row) => getComputedStyle(row).display !== 'none'); + const ys = rows.map((row) => row.getBoundingClientRect().y); + if (ys.some((y, i) => i > 0 && y <= ys[i - 1])) { + return null; + } + + return rows.slice(0, 3).map((row) => JSON.parse(row.dataset.row).name.trim()); + })() + JS); +} + +it('sorts the top paths table when a column header is selected', function () { + loginAndSkipBoarding(); + + $page = visit('/analytics'); + + // Default: requests, largest first. The two smallest are on page 2. + $page->assertSee('/p12') + ->assertDontSee('/p01'); + expect(visiblePathNames($page))->toBe(['/p12', '/p11', '/p10']); + + // Path A to Z: the two last names are on page 2. + $page->click('#analytics-paths-section button[title="Sort by path"]') + ->assertSee('/p01') + ->assertDontSee('/p12'); + expect(visiblePathNames($page))->toBe(['/p01', '/p02', '/p03']); + + // A data refresh keeps the sort. + $page->click('button:visible:has-text("7 days")') + ->assertQueryStringHas('range', '7d') + ->assertSee('/p01'); + expect(visiblePathNames($page))->toBe(['/p01', '/p02', '/p03']); + + // A second click reverses the direction, and the pager follows the sort. + $page->click('#analytics-paths-section button[title="Sort by path"]') + ->assertSee('/p12') + ->assertDontSee('/p01') + ->click('#analytics-paths-section button:has-text("Next")') + ->assertSee('/p01') + ->assertDontSee('/p12') + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'analytics-paths-sorted'); +});