mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(analytics): city-level traffic analytics, sortable tables, lighter globe (#12158)
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Andras Bacsai
Claude Opus 5.5
parent
0a567cb8e7
commit
cf6350e665
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<int, string> $dimensions
|
||||
* @return array<int, string>
|
||||
@@ -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
|
||||
|
||||
@@ -109,7 +109,7 @@ class Analytics extends Component
|
||||
private const MAX_LEADERBOARD_APPS = 200;
|
||||
|
||||
/** @var array<int, string> */
|
||||
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
|
||||
|
||||
@@ -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<int, array{code: string, requests: int}>
|
||||
* @return array{countries: array<int, array{code: string, requests: int}>, cities: array<int, array{lat: float, lon: float, requests: int}>}
|
||||
*/
|
||||
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'] ?? []));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -65,7 +65,7 @@ abstract class ResourceTrafficAnalytics extends Component
|
||||
public array $series = [];
|
||||
|
||||
/** @var array<int, string> */
|
||||
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').
|
||||
|
||||
@@ -48,7 +48,7 @@ class SentinelTrafficClient
|
||||
|
||||
/** @var array<int, string> */
|
||||
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) {}
|
||||
|
||||
@@ -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<string, array<int, array{value: string, requests: int, bytesOut: int}>>
|
||||
* @return array<string, array<int, array{value: string, requests: int, bytesOut: int, lat?: float, lon?: float}>>
|
||||
*/
|
||||
public function breakdowns(int $limit = 50): array
|
||||
{
|
||||
|
||||
@@ -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<int, array{value?: string, requests?: int, bytesOut?: int, lat?: float, lon?: float}> $rows
|
||||
* @return array<int, array{key: string, country: string, label: string, name: string, detail: string, requests: int, bytesOut: int, lat: ?float, lon: ?float}>
|
||||
*/
|
||||
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<int, mixed> $countryRows
|
||||
* @param iterable<int, mixed> $cityRows
|
||||
* @return array{countries: array<int, array{code: string, requests: int}>, cities: array<int, array{lat: float, lon: float, requests: int}>}
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -52,8 +52,16 @@
|
||||
<x-skeleton.table :rows="6" flush />
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section id="analytics-country-section" title="Countries" flush>
|
||||
<x-skeleton class="h-64 w-full" />
|
||||
<x-application.settings-section id="analytics-country-section" title="Locations" flush>
|
||||
<x-slot:actions>
|
||||
<x-skeleton class="h-9 w-36 rounded-lg" />
|
||||
</x-slot:actions>
|
||||
<div class="grid grid-cols-1 items-start gap-4 p-4 lg:grid-cols-[2fr_3fr]">
|
||||
<div class="flex items-center justify-center">
|
||||
<x-skeleton class="aspect-square w-full max-w-[320px] rounded-full" />
|
||||
</div>
|
||||
<x-skeleton.table :rows="7" flush />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -254,14 +254,15 @@ $appListboxOptions = array_merge(
|
||||
icon-name="unordered-list" />
|
||||
@else
|
||||
@php $maxAppRequests = max(1, (int) collect($topApps)->max('requests')); @endphp
|
||||
<div x-data="{ page: 0, per: 10, total: {{ count($topApps) }} }">
|
||||
<div class="flex items-center gap-3 border-b border-neutral-200 px-4 py-2 text-[11px] font-medium text-neutral-500 dark:border-white/[0.07] dark:text-fg-dim">
|
||||
<span class="min-w-0 flex-1">Application / service</span>
|
||||
<span class="hidden w-16 shrink-0 text-right sm:inline" title="Request volume relative to the busiest row in this list">Volume</span>
|
||||
<span class="w-16 shrink-0 text-right">Requests</span>
|
||||
<span class="hidden w-16 shrink-0 text-right sm:inline" title="Total response data sent">Bandwidth</span>
|
||||
<div x-data="trafficTable()">
|
||||
<div class="flex items-center gap-3 px-4 py-2 text-[11px] font-medium text-neutral-500 dark:text-fg-dim">
|
||||
@include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Application / service', 'text' => true, 'class' => 'min-w-0 flex-1'])
|
||||
<span class="hidden w-16 shrink-0 sm:inline" aria-hidden="true"></span>
|
||||
@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'])
|
||||
<span class="size-3.5 shrink-0" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
@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]">
|
||||
<span class="flex min-w-0 flex-1 items-baseline gap-1.5">
|
||||
<span class="truncate text-[12px] text-black dark:text-fg">{{ $row['name'] }}</span>
|
||||
@if (! empty($row['isService']))
|
||||
@@ -299,6 +298,7 @@ $appListboxOptions = array_merge(
|
||||
@endif
|
||||
</{{ $appHref ? 'a' : 'div' }}>
|
||||
@endforeach
|
||||
</div>
|
||||
@include('livewire.traffic._pager')
|
||||
</div>
|
||||
@endif
|
||||
@@ -312,14 +312,12 @@ $appListboxOptions = array_merge(
|
||||
@include('livewire.traffic._paths-list', ['paths' => $topPaths])
|
||||
</x-application.settings-section>
|
||||
|
||||
{{-- Countries --}}
|
||||
<x-application.settings-section id="analytics-country-section" title="Countries"
|
||||
helper="Request volume by visitor country for the selected range." flush>
|
||||
@include('livewire.traffic._geo', [
|
||||
'countries' => data_get($breakdowns, 'country', []),
|
||||
'attribution' => $attribution,
|
||||
])
|
||||
</x-application.settings-section>
|
||||
{{-- 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. --}}
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
|
||||
@@ -25,8 +25,16 @@
|
||||
<x-skeleton.table :rows="6" flush />
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section id="analytics-country-section" title="Countries" flush>
|
||||
<x-skeleton class="h-64 w-full" />
|
||||
<x-application.settings-section id="analytics-country-section" title="Locations" flush>
|
||||
<x-slot:actions>
|
||||
<x-skeleton class="h-9 w-36 rounded-lg" />
|
||||
</x-slot:actions>
|
||||
<div class="grid grid-cols-1 items-start gap-4 p-4 lg:grid-cols-[2fr_3fr]">
|
||||
<div class="flex items-center justify-center">
|
||||
<x-skeleton class="aspect-square w-full max-w-[320px] rounded-full" />
|
||||
</div>
|
||||
<x-skeleton.table :rows="7" flush />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
|
||||
@@ -157,13 +157,11 @@ $approxBadge = fn (string $tooltip) => '<span title="'.e($tooltip).'" class="ml-
|
||||
@include('livewire.traffic._paths-list', ['paths' => $topPaths])
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section id="analytics-country-section" title="Countries"
|
||||
helper="Request volume by visitor country for the selected range." flush>
|
||||
@include('livewire.traffic._geo', [
|
||||
'countries' => data_get($breakdowns, 'country', []),
|
||||
'attribution' => $attribution,
|
||||
])
|
||||
</x-application.settings-section>
|
||||
@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. --}}
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
|
||||
@@ -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." />
|
||||
<x-forms.input canGate="update" :canResource="$server" type="number" min="1"
|
||||
id="geoipRefreshDays" label="GeoIP refresh interval" required
|
||||
helper="Days between GeoIP database update checks." />
|
||||
|
||||
@@ -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 @@
|
||||
<x-empty size="sm" title="No data"
|
||||
:description="'No '.strtolower($label).' data for the selected range.'" icon-name="network" />
|
||||
@else
|
||||
<div x-data="{ page: 0, per: 10, total: {{ count($rows) }} }">
|
||||
<div x-data="trafficTable()">
|
||||
<div class="flex items-center gap-3 px-4 py-2 text-[11px] font-medium text-neutral-500 dark:text-fg-dim">
|
||||
@include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Name', 'text' => true, 'class' => 'min-w-0 flex-1'])
|
||||
<span class="hidden w-16 shrink-0 sm:inline" aria-hidden="true"></span>
|
||||
@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'])
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
@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
|
||||
<div wire:key="analytics-{{ $dimension }}-{{ md5($value) }}"
|
||||
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]">
|
||||
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)
|
||||
<img src="{{ refererFaviconUrl($host) }}" alt="" loading="lazy" width="16" height="16"
|
||||
class="size-4 shrink-0 rounded-sm" onerror="this.style.visibility='hidden'">
|
||||
@@ -51,11 +60,12 @@
|
||||
<div class="hidden h-1 w-16 shrink-0 overflow-hidden rounded-full bg-neutral-100 sm:block dark:bg-white/[0.06]">
|
||||
<div class="h-full rounded-full bg-[var(--chart-status-3xx)]" style="width: {{ $width }}%;"></div>
|
||||
</div>
|
||||
<span class="w-12 shrink-0 text-right text-[12px] font-medium tabular-nums text-black dark:text-fg"
|
||||
<span class="w-16 shrink-0 text-right text-[12px] font-medium tabular-nums text-black dark:text-fg"
|
||||
title="{{ number_format($requests) }} requests">{{ compactNumber($requests) }}</span>
|
||||
<span class="hidden w-16 shrink-0 text-right text-[11px] tabular-nums text-neutral-400 sm:inline dark:text-fg-faint">{{ formatBytes((int) ($row['bytesOut'] ?? 0)) }}</span>
|
||||
<span class="hidden w-16 shrink-0 text-right text-[11px] tabular-nums text-neutral-400 sm:inline dark:text-fg-faint">{{ formatBytes($bytesOut) }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@include('livewire.traffic._pager')
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -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
|
||||
|
||||
<div class="flex flex-col">
|
||||
@if (! $hasData)
|
||||
<x-empty size="sm" title="No data" description="No country data for the selected range."
|
||||
icon-name="network" />
|
||||
@else
|
||||
<div class="grid grid-cols-1 items-stretch gap-4 border-b border-neutral-200 p-4 lg:grid-cols-[2fr_3fr] dark:border-white/[0.07]">
|
||||
{{-- 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. --}}
|
||||
<div wire:ignore class="flex items-center justify-center"
|
||||
x-data="{
|
||||
controller: null,
|
||||
refreshCleanup: null,
|
||||
isDark() { return document.documentElement.classList.contains('dark'); },
|
||||
init() {
|
||||
const canvas = this.$refs.canvas;
|
||||
if (typeof window.mountTrafficGlobe !== 'function') { return; }
|
||||
this.controller = window.mountTrafficGlobe(canvas, @js($geoInit), this.isDark());
|
||||
// Expose for row-hover focus() from the Alpine list scope.
|
||||
canvas._trafficGlobe = this.controller;
|
||||
this.refreshCleanup = Livewire.on('refreshChartData-{!! $chartId !!}-status', payload => {
|
||||
const data = Array.isArray(payload) ? payload[0] : payload;
|
||||
if (data && Array.isArray(data.geo)) { this.controller.update(data.geo, this.isDark()); }
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
if (this.refreshCleanup) { this.refreshCleanup(); this.refreshCleanup = null; }
|
||||
if (this.controller) { this.controller.destroy(); this.controller = null; }
|
||||
if (this.$refs.canvas) { this.$refs.canvas._trafficGlobe = null; }
|
||||
},
|
||||
}">
|
||||
<div class="relative aspect-square w-full max-w-[320px]">
|
||||
<canvas id="{!! $globeId !!}" x-ref="canvas"
|
||||
class="h-full w-full [touch-action:none]"
|
||||
style="width: 100%; height: 100%;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<x-application.settings-section id="analytics-country-section" title="Locations"
|
||||
helper="Request volume by visitor country and city for the selected range." flush
|
||||
x-data="{
|
||||
// Storage can be blocked (sandboxed iframe, privacy mode, full quota); the switch then works without saving.
|
||||
view: (() => { try { return localStorage.getItem('traffic-geo-view') === 'cities' ? 'cities' : 'countries'; } catch (e) { return 'countries'; } })(),
|
||||
setView(next) {
|
||||
this.view = next;
|
||||
try { localStorage.setItem('traffic-geo-view', next); } catch (e) {}
|
||||
},
|
||||
globe() {
|
||||
const canvas = document.getElementById('{{ $globeId }}');
|
||||
|
||||
{{-- Ranked, scrollable country list (60%). Hover a row to face it on the globe. --}}
|
||||
<div class="min-w-0" x-data="{
|
||||
focus(code) {
|
||||
const g = document.getElementById('{!! $globeId !!}');
|
||||
if (g && g._trafficGlobe) { g._trafficGlobe.focus(code); }
|
||||
},
|
||||
blur() {
|
||||
const g = document.getElementById('{!! $globeId !!}');
|
||||
if (g && g._trafficGlobe) { g._trafficGlobe.resume(); }
|
||||
},
|
||||
}">
|
||||
<div class="max-h-[320px] overflow-y-auto pr-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-neutral-300 dark:scrollbar-thumb-white/10">
|
||||
@foreach ($countryRows as $row)
|
||||
@php
|
||||
$isUnknown = $row['value'] === '';
|
||||
$width = min(100, round(($row['requests'] / $maxRequests) * 100, 1));
|
||||
@endphp
|
||||
<div wire:key="geo-country-{{ $globeId }}-{{ $loop->index }}"
|
||||
@if (! $isUnknown) @mouseenter="focus('{{ $row['value'] }}')" @mouseleave="blur()" @endif
|
||||
class="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]">
|
||||
@php $flagUrl = $isUnknown ? null : countryFlagUrl($row['value']); @endphp
|
||||
@if ($flagUrl)
|
||||
<img src="{{ $flagUrl }}" srcset="{{ countryFlagUrl($row['value'], '48x36') }} 2x" alt=""
|
||||
loading="lazy" width="20" height="15"
|
||||
class="h-[15px] w-5 shrink-0 rounded-[2px] object-cover ring-1 ring-black/5 dark:ring-white/10"
|
||||
onerror="this.style.visibility='hidden'">
|
||||
@else
|
||||
<span class="shrink-0 text-[14px] leading-none" aria-hidden="true">🌐</span>
|
||||
@endif
|
||||
<span class="min-w-0 flex-1 truncate text-[12px] text-black dark:text-fg">
|
||||
{{ $isUnknown ? 'Unknown' : countryName($row['value']) }}
|
||||
</span>
|
||||
<div class="hidden h-1 w-16 shrink-0 overflow-hidden rounded-full bg-neutral-100 sm:block dark:bg-white/[0.06]">
|
||||
<div class="h-full rounded-full bg-[var(--chart-status-3xx)]" style="width: {{ $width }}%;"></div>
|
||||
</div>
|
||||
<span class="w-12 shrink-0 text-right text-[12px] font-medium tabular-nums text-black dark:text-fg"
|
||||
title="{{ number_format($row['requests']) }} requests">{{ compactNumber($row['requests']) }}</span>
|
||||
<span class="hidden w-16 shrink-0 text-right text-[11px] tabular-nums text-neutral-400 sm:inline dark:text-fg-faint">{{ formatBytes($row['bytesOut']) }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
return canvas ? canvas._trafficGlobe : null;
|
||||
},
|
||||
}">
|
||||
<x-slot:actions>
|
||||
@if ($hasData)
|
||||
<div wire:key="geo-view-{{ $globeId }}"
|
||||
class="inline-flex items-center gap-0.5 rounded-lg bg-neutral-100 p-1 dark:bg-white/[0.04]"
|
||||
role="group" aria-label="Location detail">
|
||||
@foreach (['countries' => 'Countries', 'cities' => 'Cities'] as $viewKey => $viewLabel)
|
||||
<button type="button" @click="setView('{{ $viewKey }}')"
|
||||
:aria-pressed="view === '{{ $viewKey }}' ? 'true' : 'false'"
|
||||
class="{{ $viewButton }}"
|
||||
:class="view === '{{ $viewKey }}' ? '{{ $viewButtonActive }}' : '{{ $viewButtonInactive }}'">{{ $viewLabel }}</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@if (! empty($attribution))
|
||||
<p class="px-4 py-2 text-[11px] text-neutral-400 dark:text-fg-faint">
|
||||
{{ $attribution }}
|
||||
</p>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</x-slot:actions>
|
||||
|
||||
<div class="flex flex-col">
|
||||
@if (! $hasData)
|
||||
<x-empty size="sm" title="No data" description="No country data for the selected range."
|
||||
icon-name="network" />
|
||||
@else
|
||||
{{-- Keyed so the lazy-load morph inserts fresh nodes instead of reusing the
|
||||
placeholder skeleton, which has the same shape. --}}
|
||||
<div wire:key="geo-body-{{ $globeId }}"
|
||||
class="grid grid-cols-1 items-start gap-4 border-b border-neutral-200 p-4 lg:grid-cols-[2fr_3fr] dark:border-white/[0.07]">
|
||||
{{-- 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). --}}
|
||||
<div wire:ignore class="flex items-center justify-center"
|
||||
x-data="{
|
||||
controller: null,
|
||||
refreshCleanup: null,
|
||||
geo: @js($geoInit),
|
||||
isDark() { return document.documentElement.classList.contains('dark'); },
|
||||
// City markers in the Cities view; country markers when there are no cities.
|
||||
markers() {
|
||||
return this.view === 'cities' && this.geo.cities.length ? this.geo.cities : this.geo.countries;
|
||||
},
|
||||
init() {
|
||||
const canvas = this.$refs.canvas;
|
||||
if (typeof window.mountTrafficGlobe !== 'function') { return; }
|
||||
this.controller = window.mountTrafficGlobe(canvas, this.markers(), this.isDark());
|
||||
// Expose for row-hover focus() from the list rows.
|
||||
canvas._trafficGlobe = this.controller;
|
||||
this.$watch('view', () => this.controller && this.controller.update(this.markers(), this.isDark()));
|
||||
this.refreshCleanup = Livewire.on('refreshChartData-{!! $chartId !!}-status', payload => {
|
||||
const data = Array.isArray(payload) ? payload[0] : payload;
|
||||
if (data && data.geo && Array.isArray(data.geo.countries)) {
|
||||
this.geo = data.geo;
|
||||
this.controller.update(this.markers(), this.isDark());
|
||||
}
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
if (this.refreshCleanup) { this.refreshCleanup(); this.refreshCleanup = null; }
|
||||
if (this.controller) { this.controller.destroy(); this.controller = null; }
|
||||
if (this.$refs.canvas) { this.$refs.canvas._trafficGlobe = null; }
|
||||
},
|
||||
}">
|
||||
<div class="relative aspect-square w-full max-w-[320px]">
|
||||
<canvas id="{!! $globeId !!}" x-ref="canvas"
|
||||
class="h-full w-full [touch-action:none]"
|
||||
style="width: 100%; height: 100%;"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Ranked, scrollable lists (60%). Hover a row to face it on the globe. --}}
|
||||
<div class="min-w-0">
|
||||
<div x-show="view === 'countries'" class="{{ $listClass }}">
|
||||
@foreach ($countryRows as $row)
|
||||
@php
|
||||
$isUnknown = $row['value'] === '';
|
||||
$width = min(100, round(($row['requests'] / $maxRequests) * 100, 1));
|
||||
$flagUrl = $isUnknown ? null : countryFlagUrl($row['value']);
|
||||
@endphp
|
||||
<div wire:key="geo-country-{{ $globeId }}-{{ $isUnknown ? 'unknown' : $row['value'] }}"
|
||||
@if (! $isUnknown) @mouseenter="globe()?.focus('{{ $row['value'] }}')" @mouseleave="globe()?.resume()" @endif
|
||||
class="{{ $rowClass }}">
|
||||
@if ($flagUrl)
|
||||
<img src="{{ $flagUrl }}" srcset="{{ countryFlagUrl($row['value'], '48x36') }} 2x" alt=""
|
||||
loading="lazy" width="20" height="15"
|
||||
class="h-[15px] w-5 shrink-0 rounded-[2px] object-cover ring-1 ring-black/5 dark:ring-white/10"
|
||||
onerror="this.style.visibility='hidden'">
|
||||
@else
|
||||
<span class="w-5 shrink-0 text-center text-[14px] leading-none" aria-hidden="true">🌐</span>
|
||||
@endif
|
||||
<span class="min-w-0 flex-1 truncate text-[12px] text-black dark:text-fg">
|
||||
{{ $isUnknown ? 'Unknown' : countryName($row['value']) }}
|
||||
</span>
|
||||
<div class="hidden h-1 w-16 shrink-0 overflow-hidden rounded-full bg-neutral-100 sm:block dark:bg-white/[0.06]">
|
||||
<div class="h-full rounded-full bg-[var(--chart-status-3xx)]" style="width: {{ $width }}%;"></div>
|
||||
</div>
|
||||
<span class="w-12 shrink-0 text-right text-[12px] font-medium tabular-nums text-black dark:text-fg"
|
||||
title="{{ number_format($row['requests']) }} requests">{{ compactNumber($row['requests']) }}</span>
|
||||
<span class="hidden w-16 shrink-0 text-right text-[11px] tabular-nums text-neutral-400 sm:inline dark:text-fg-faint">{{ formatBytes($row['bytesOut']) }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div x-show="view === 'cities'" x-cloak class="{{ $listClass }}">
|
||||
@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
|
||||
<div wire:key="geo-city-{{ $globeId }}-{{ md5($row['key']) }}"
|
||||
@if ($hasCoordinates) @mouseenter="globe()?.focus([{{ (float) $row['lat'] }}, {{ (float) $row['lon'] }}])" @mouseleave="globe()?.resume()" @endif
|
||||
class="{{ $rowClass }}">
|
||||
@if ($flagUrl)
|
||||
<img src="{{ $flagUrl }}" srcset="{{ countryFlagUrl($row['country'], '48x36') }} 2x" alt=""
|
||||
loading="lazy" width="20" height="15"
|
||||
class="h-[15px] w-5 shrink-0 rounded-[2px] object-cover ring-1 ring-black/5 dark:ring-white/10"
|
||||
onerror="this.style.visibility='hidden'">
|
||||
@else
|
||||
<span class="w-5 shrink-0 text-center text-[14px] leading-none" aria-hidden="true">🌐</span>
|
||||
@endif
|
||||
<span class="flex min-w-0 flex-1 items-baseline gap-2" title="{{ $row['label'] }}">
|
||||
<span class="truncate text-[12px] text-black dark:text-fg">{{ $row['name'] }}</span>
|
||||
@if ($row['detail'] !== '')
|
||||
<span class="hidden shrink-0 text-[11px] text-neutral-400 md:inline dark:text-fg-faint">{{ $row['detail'] }}</span>
|
||||
@endif
|
||||
</span>
|
||||
<div class="hidden h-1 w-16 shrink-0 overflow-hidden rounded-full bg-neutral-100 sm:block dark:bg-white/[0.06]">
|
||||
<div class="h-full rounded-full bg-[var(--chart-status-3xx)]" style="width: {{ $width }}%;"></div>
|
||||
</div>
|
||||
<span class="w-12 shrink-0 text-right text-[12px] font-medium tabular-nums text-black dark:text-fg"
|
||||
title="{{ number_format($row['requests']) }} requests">{{ compactNumber($row['requests']) }}</span>
|
||||
<span class="hidden w-16 shrink-0 text-right text-[11px] tabular-nums text-neutral-400 sm:inline dark:text-fg-faint">{{ formatBytes($row['bytesOut']) }}</span>
|
||||
</div>
|
||||
@empty
|
||||
<x-empty size="sm" title="No city data" description="No city data for the selected range."
|
||||
icon-name="network" />
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (! empty($attribution))
|
||||
<p class="px-4 py-2 text-[11px] text-neutral-400 dark:text-fg-faint">
|
||||
{{ $attribution }}
|
||||
</p>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
@@ -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 @@
|
||||
<x-empty size="sm" title="No host data" description="No hostnames were recorded for the selected range."
|
||||
icon-name="unordered-list" />
|
||||
@else
|
||||
<div x-data="{ page: 0, per: 10, total: {{ count($hosts) }} }">
|
||||
<div class="flex items-center gap-3 border-b border-neutral-200 px-4 py-2 text-[11px] font-medium text-neutral-500 dark:border-white/[0.07] dark:text-fg-dim">
|
||||
<span class="min-w-0 flex-1">Host</span>
|
||||
<span class="hidden w-16 shrink-0 text-right sm:inline" title="Request volume relative to the busiest row in this list">Volume</span>
|
||||
<span class="w-16 shrink-0 text-right">Requests</span>
|
||||
<span class="hidden w-16 shrink-0 text-right sm:inline" title="Total response data sent">Bandwidth</span>
|
||||
<div x-data="trafficTable()">
|
||||
<div class="flex items-center gap-3 px-4 py-2 text-[11px] font-medium text-neutral-500 dark:text-fg-dim">
|
||||
@include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Host', 'text' => true, 'class' => 'min-w-0 flex-1'])
|
||||
<span class="hidden w-16 shrink-0 sm:inline" aria-hidden="true"></span>
|
||||
@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'])
|
||||
</div>
|
||||
@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
|
||||
<div wire:key="{{ $keyPrefix }}-{{ $loop->index }}"
|
||||
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]">
|
||||
@if ($known)
|
||||
<a href="https://{{ $host }}" target="_blank" rel="noopener noreferrer nofollow"
|
||||
class="min-w-0 flex-1 truncate font-mono text-[12px] text-black hover:underline dark:text-fg">{{ $host }}</a>
|
||||
@else
|
||||
<span class="min-w-0 flex-1 truncate text-[12px] text-neutral-500 dark:text-fg-dim">Unknown host</span>
|
||||
@endif
|
||||
<div class="hidden h-1 w-16 shrink-0 overflow-hidden rounded-full bg-neutral-100 sm:block dark:bg-white/[0.06]">
|
||||
<div class="h-full rounded-full bg-[var(--chart-status-3xx)]" style="width: {{ $width }}%;"></div>
|
||||
<div class="flex flex-col">
|
||||
@foreach ($hosts as $row)
|
||||
@php
|
||||
$host = (string) ($row['host'] ?? '');
|
||||
$known = $host !== '';
|
||||
$requests = (int) ($row['requests'] ?? 0);
|
||||
$bandwidth = (int) ($row['bandwidth'] ?? 0);
|
||||
$width = min(100, round(($requests / $maxRequests) * 100, 1));
|
||||
@endphp
|
||||
<div wire:key="{{ $keyPrefix }}-{{ md5($host) }}"
|
||||
data-row="{{ json_encode(['name' => $host, 'requests' => $requests, 'bytesOut' => $bandwidth]) }}" @if (! $known) 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 ($known)
|
||||
<a href="https://{{ $host }}" target="_blank" rel="noopener noreferrer nofollow"
|
||||
class="min-w-0 flex-1 truncate font-mono text-[12px] text-black hover:underline dark:text-fg">{{ $host }}</a>
|
||||
@else
|
||||
<span class="min-w-0 flex-1 truncate text-[12px] text-neutral-500 dark:text-fg-dim">Unknown host</span>
|
||||
@endif
|
||||
<div class="hidden h-1 w-16 shrink-0 overflow-hidden rounded-full bg-neutral-100 sm:block dark:bg-white/[0.06]"
|
||||
title="Request volume relative to the busiest row in this list">
|
||||
<div class="h-full rounded-full bg-[var(--chart-status-3xx)]" style="width: {{ $width }}%;"></div>
|
||||
</div>
|
||||
<span class="w-16 shrink-0 text-right text-[12px] font-medium tabular-nums text-black dark:text-fg"
|
||||
title="{{ number_format($requests) }} requests">{{ compactNumber($requests) }}</span>
|
||||
<span class="hidden w-16 shrink-0 text-right text-[11px] tabular-nums text-neutral-400 sm:inline dark:text-fg-faint">{{ formatBytes($bandwidth) }}</span>
|
||||
</div>
|
||||
<span class="w-16 shrink-0 text-right text-[12px] font-medium tabular-nums text-black dark:text-fg"
|
||||
title="{{ number_format($requests) }} requests">{{ compactNumber($requests) }}</span>
|
||||
<span class="hidden w-16 shrink-0 text-right text-[11px] tabular-nums text-neutral-400 sm:inline dark:text-fg-faint">{{ formatBytes((int) ($row['bandwidth'] ?? 0)) }}</span>
|
||||
</div>
|
||||
@endforeach
|
||||
@endforeach
|
||||
</div>
|
||||
@include('livewire.traffic._pager')
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -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.
|
||||
--}}
|
||||
|
||||
@@ -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())
|
||||
<x-empty size="sm" title="No path data" description="No requests were recorded for the selected range."
|
||||
icon-name="unordered-list" />
|
||||
@else
|
||||
<div x-data="{ page: 0, per: 10, total: {{ count($paths) }} }">
|
||||
<div class="flex items-center gap-3 border-b border-neutral-200 px-4 py-2 text-[11px] font-medium text-neutral-500 dark:border-white/[0.07] dark:text-fg-dim">
|
||||
<span class="min-w-0 flex-1">Path</span>
|
||||
<span class="hidden w-16 shrink-0 text-right sm:inline" title="Request volume relative to the busiest row in this list">Volume</span>
|
||||
<span class="w-16 shrink-0 text-right">Requests</span>
|
||||
<span class="hidden w-14 shrink-0 text-right sm:inline" title="HTTP 4xx client-error responses">4xx errors</span>
|
||||
<span class="w-14 shrink-0 text-right" title="HTTP 5xx server-error responses">5xx errors</span>
|
||||
<span class="hidden w-12 shrink-0 text-right lg:inline" title="Percentage of requests with a 4xx or 5xx response">Error %</span>
|
||||
<span class="hidden w-16 shrink-0 text-right sm:inline" title="Total response data sent">Bandwidth</span>
|
||||
<span class="hidden w-16 shrink-0 text-right md:inline" title="95% of requests completed within this response time">p95 latency</span>
|
||||
<div x-data="trafficTable()">
|
||||
<div class="flex items-center gap-3 px-4 py-2 text-[11px] font-medium text-neutral-500 dark:text-fg-dim">
|
||||
@include('livewire.traffic._sort-header', ['column' => 'name', 'label' => 'Path', 'text' => true, 'class' => 'min-w-0 flex-1'])
|
||||
<span class="hidden w-16 shrink-0 sm:inline" aria-hidden="true"></span>
|
||||
@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'])
|
||||
</div>
|
||||
@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
|
||||
<div wire:key="{{ $keyPrefix }}-{{ md5($domain."\n".$pathStr) }}"
|
||||
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]">
|
||||
@if ($isOther)
|
||||
<span class="min-w-0 flex-1 truncate text-[12px] text-neutral-500 dark:text-fg-dim"
|
||||
title="All paths outside the top paths that Sentinel tracks">Other paths</span>
|
||||
@else
|
||||
<span class="flex min-w-0 flex-1 items-baseline gap-1.5">
|
||||
@if ($href)
|
||||
<a href="{{ $href }}" target="_blank" rel="noopener noreferrer nofollow"
|
||||
class="truncate font-mono text-[12px] text-black hover:underline dark:text-fg">{{ $pathStr }}</a>
|
||||
@else
|
||||
<span class="truncate font-mono text-[12px] text-black dark:text-fg">{{ $pathStr }}</span>
|
||||
@endif
|
||||
@if ($domain)
|
||||
<span class="max-w-48 shrink-0 truncate text-[11px] text-neutral-400 dark:text-fg-faint" title="{{ $domain }}">{{ $domain }}</span>
|
||||
@endif
|
||||
</span>
|
||||
@endif
|
||||
<div class="hidden h-1 w-16 shrink-0 overflow-hidden rounded-full bg-neutral-100 sm:block dark:bg-white/[0.06]">
|
||||
<div class="h-full rounded-full bg-[var(--chart-status-3xx)]" style="width: {{ $width }}%;"></div>
|
||||
<div class="flex flex-col">
|
||||
@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);
|
||||
$bytesOut = (int) ($path['bytesOut'] ?? 0);
|
||||
$p95 = (float) ($path['p95'] ?? 0);
|
||||
$errorRate = $requests > 0 ? round((($s4xx + $s5xx) / $requests) * 100, 1) : 0;
|
||||
$width = min(100, round(($requests / $maxRequests) * 100, 1));
|
||||
$sort = ['name' => $pathStr.' '.$domain, 'requests' => $requests, 's4xx' => $s4xx, 's5xx' => $s5xx, 'errorRate' => $errorRate, 'bytesOut' => $bytesOut, 'p95' => $p95];
|
||||
@endphp
|
||||
<div wire:key="{{ $keyPrefix }}-{{ md5($domain."\n".$pathStr) }}"
|
||||
data-row="{{ json_encode($sort) }}" @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 ($isOther)
|
||||
<span class="min-w-0 flex-1 truncate text-[12px] text-neutral-500 dark:text-fg-dim"
|
||||
title="All paths outside the top paths that Sentinel tracks">Other paths</span>
|
||||
@else
|
||||
<span class="flex min-w-0 flex-1 items-baseline gap-1.5">
|
||||
@if ($href)
|
||||
<a href="{{ $href }}" target="_blank" rel="noopener noreferrer nofollow"
|
||||
class="truncate font-mono text-[12px] text-black hover:underline dark:text-fg">{{ $pathStr }}</a>
|
||||
@else
|
||||
<span class="truncate font-mono text-[12px] text-black dark:text-fg">{{ $pathStr }}</span>
|
||||
@endif
|
||||
@if ($domain)
|
||||
<span class="max-w-48 shrink-0 truncate text-[11px] text-neutral-400 dark:text-fg-faint" title="{{ $domain }}">{{ $domain }}</span>
|
||||
@endif
|
||||
</span>
|
||||
@endif
|
||||
<div class="hidden h-1 w-16 shrink-0 overflow-hidden rounded-full bg-neutral-100 sm:block dark:bg-white/[0.06]"
|
||||
title="Request volume relative to the busiest row in this list">
|
||||
<div class="h-full rounded-full bg-[var(--chart-status-3xx)]" style="width: {{ $width }}%;"></div>
|
||||
</div>
|
||||
<span class="w-16 shrink-0 text-right text-[12px] font-medium tabular-nums text-black dark:text-fg"
|
||||
title="{{ number_format($requests) }} requests">{{ compactNumber($requests) }}</span>
|
||||
<span @class(['hidden w-12 shrink-0 text-right text-[12px] tabular-nums sm:inline', $s4xx > 0 ? 'font-medium text-pink-600 dark:text-pink-400' : $muted])
|
||||
title="{{ number_format($s4xx) }} client-error responses">{{ compactNumber($s4xx) }}</span>
|
||||
<span @class(['w-12 shrink-0 text-right text-[12px] tabular-nums', $s5xx > 0 ? 'font-medium text-purple-600 dark:text-purple-400' : $muted])
|
||||
title="{{ number_format($s5xx) }} server-error responses">{{ compactNumber($s5xx) }}</span>
|
||||
<span @class(['hidden w-14 shrink-0 text-right text-[11px] tabular-nums lg:inline', $errorRate > 0 ? 'text-neutral-500 dark:text-fg-dim' : $muted])
|
||||
title="Combined 4xx and 5xx response rate">{{ $errorRate }}%</span>
|
||||
<span class="hidden w-16 shrink-0 text-right text-[11px] tabular-nums text-neutral-400 sm:inline dark:text-fg-faint">{{ formatBytes($bytesOut) }}</span>
|
||||
<span class="hidden w-16 shrink-0 text-right text-[11px] tabular-nums text-neutral-400 md:inline dark:text-fg-faint"
|
||||
title="p95 latency">{{ number_format($p95, 1) }} ms</span>
|
||||
</div>
|
||||
<span class="w-16 shrink-0 text-right text-[12px] font-medium tabular-nums text-black dark:text-fg"
|
||||
title="{{ number_format($requests) }} requests">{{ compactNumber($requests) }}</span>
|
||||
<span class="hidden w-14 shrink-0 text-right text-[11px] font-medium tabular-nums text-pink-600 sm:inline dark:text-pink-400"
|
||||
title="{{ number_format($s4xx) }} client-error responses">{{ compactNumber($s4xx) }} 4xx</span>
|
||||
<span class="w-14 shrink-0 text-right text-[11px] font-medium tabular-nums text-purple-600 dark:text-purple-400"
|
||||
title="{{ number_format($s5xx) }} server-error responses">{{ compactNumber($s5xx) }} 5xx</span>
|
||||
<span class="hidden w-12 shrink-0 text-right text-[11px] tabular-nums text-neutral-400 lg:inline dark:text-fg-faint"
|
||||
title="Combined 4xx and 5xx response rate">{{ $errorRate }}%</span>
|
||||
<span class="hidden w-16 shrink-0 text-right text-[11px] tabular-nums text-neutral-400 sm:inline dark:text-fg-faint">{{ formatBytes((int) ($path['bytesOut'] ?? 0)) }}</span>
|
||||
<span class="hidden w-16 shrink-0 text-right text-[11px] tabular-nums text-neutral-400 md:inline dark:text-fg-faint"
|
||||
title="p95 latency">{{ number_format((float) ($path['p95'] ?? 0), 1) }} ms</span>
|
||||
</div>
|
||||
@endforeach
|
||||
@endforeach
|
||||
</div>
|
||||
@include('livewire.traffic._pager')
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -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 <breakpoint>: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
|
||||
<button type="button" @click="sortBy('{{ $column }}', '{{ $firstDir }}')"
|
||||
title="{{ $title ?? 'Sort by '.strtolower($label) }}"
|
||||
:aria-label="'Sort by {{ strtolower($label) }}' + (sorted('{{ $column }}') ? ', sorted ' + (sorted('{{ $column }}') === 'asc' ? 'ascending' : 'descending') : '')"
|
||||
class="group {{ $display }} items-center gap-1 whitespace-nowrap rounded-sm transition-colors hover:text-black focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent dark:hover:text-fg {{ $text ? 'justify-start text-left' : 'flex-row-reverse justify-start text-right' }} {{ $class }}"
|
||||
:class="sorted('{{ $column }}') ? 'text-black dark:text-fg' : ''">
|
||||
<span>{{ $label }}</span>
|
||||
<svg class="size-3 shrink-0 transition-[opacity,transform]" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" aria-hidden="true"
|
||||
:class="[sorted('{{ $column }}') ? 'opacity-100' : 'opacity-0 group-hover:opacity-50', sorted('{{ $column }}') === 'asc' ? 'rotate-180' : '']">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -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 <b>x</b>", '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 <b>x</b>")
|
||||
->assertDontSeeHtml('<b>x</b></span>')
|
||||
->assertSeeHtml('focus([38.81, -90.7])');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Data\Traffic\TrafficBreakdownData;
|
||||
use App\Data\Traffic\TrafficOverviewData;
|
||||
use App\Services\TrafficAnalyticsAggregator;
|
||||
|
||||
@@ -24,3 +25,25 @@ it('returns a zeroed, non-approximate result for a single server', function () {
|
||||
expect($result['latencyApproximate'])->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]],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Data\Traffic\TrafficBreakdownData;
|
||||
use App\Data\Traffic\TrafficOverviewData;
|
||||
use App\Data\Traffic\TrafficPathData;
|
||||
|
||||
@@ -36,3 +37,26 @@ it('maps per-path error counters from sentinel', function () {
|
||||
expect($dto->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]],
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
describe('trafficCityRows', function () {
|
||||
it('builds labels, keeps coordinates, and sorts by requests', function () {
|
||||
$rows = trafficCityRows([
|
||||
['value' => '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 <b>", 'requests' => 1, 'bytesOut' => 1],
|
||||
]);
|
||||
|
||||
expect($rows[0]['name'])->toBe("O'Fallon <b>")
|
||||
->and($rows[0]['label'])->toBe("O'Fallon <b>, 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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use App\Services\SentinelTrafficClient;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class FakeBrowserCityTrafficClient extends SentinelTrafficClient
|
||||
{
|
||||
protected function raw(string $url): string
|
||||
{
|
||||
$responses = [
|
||||
'/traffic/apps' => [],
|
||||
'/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');
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
use App\Services\SentinelTrafficClient;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
class FakeBrowserSortTrafficClient extends SentinelTrafficClient
|
||||
{
|
||||
protected function raw(string $url): string
|
||||
{
|
||||
// Twelve paths: /p01 has the fewest requests and /p12 the most, so the
|
||||
// first page (10 rows) by requests hides /p01 and /p02.
|
||||
$paths = [];
|
||||
foreach (range(1, 12) as $i) {
|
||||
$paths[] = [
|
||||
'path' => 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');
|
||||
});
|
||||
Reference in New Issue
Block a user